Files
delphi-database/app/api/templates.py
HotSwapp bac8cc4bd5 changes
2025-08-18 20:20:04 -05:00

431 lines
14 KiB
Python

"""
Document Template API (MVP)
Endpoints:
- POST /api/templates/upload
- GET /api/templates/search
- GET /api/templates/{id}
- POST /api/templates/{id}/versions
- GET /api/templates/{id}/versions
- POST /api/templates/{id}/preview
"""
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
from app.database.base import get_db
from app.auth.security import get_current_user, get_admin_user
from app.models.user import User
from app.models.templates import DocumentTemplate, DocumentTemplateVersion, TemplateKeyword
from app.services.storage import get_default_storage
from app.services.template_merge import extract_tokens_from_bytes, build_context, resolve_tokens, render_docx
from app.services.template_service import (
get_template_or_404,
list_template_versions as svc_list_template_versions,
add_template_version as svc_add_template_version,
resolve_template_preview as svc_resolve_template_preview,
get_download_payload as svc_get_download_payload,
)
from app.services.query_utils import paginate_with_total
from app.services.template_upload import TemplateUploadService
from app.services.template_search import TemplateSearchService
from app.config import settings
from app.services.cache import _get_client
router = APIRouter()
from pydantic import BaseModel, Field
class TemplateResponse(BaseModel):
id: int
name: str
description: Optional[str] = None
category: Optional[str] = None
active: bool
current_version_id: Optional[int] = None
class VersionResponse(BaseModel):
id: int
template_id: int
semantic_version: str
mime_type: str
size: int
checksum: str
changelog: Optional[str] = None
is_approved: bool
class SearchResponseItem(BaseModel):
id: int
name: str
category: Optional[str] = None
active: bool
latest_version: Optional[str] = None
class KeywordsRequest(BaseModel):
keywords: List[str]
class KeywordsResponse(BaseModel):
keywords: List[str]
class PreviewRequest(BaseModel):
context: Dict[str, Any] = Field(default_factory=dict)
version_id: Optional[int] = None
class PreviewResponse(BaseModel):
resolved: Dict[str, Any]
unresolved: List[str]
output_mime_type: str
output_size: int
class CategoryCount(BaseModel):
category: Optional[str] = None
count: int
class PaginatedSearchResponse(BaseModel):
items: List[SearchResponseItem]
total: int
class PaginatedCategoriesResponse(BaseModel):
items: List[CategoryCount]
total: int
class TemplateCacheStatusResponse(BaseModel):
cache_enabled: bool
redis_available: bool
mem_cache: Dict[str, int]
@router.post("/upload", response_model=TemplateResponse)
async def upload_template(
name: str = Form(...),
category: Optional[str] = Form("GENERAL"),
description: Optional[str] = Form(None),
semantic_version: str = Form("1.0.0"),
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
service = TemplateUploadService(db)
template = await service.upload_template(
name=name,
category=category,
description=description,
semantic_version=semantic_version,
file=file,
created_by=getattr(current_user, "username", None),
)
return TemplateResponse(
id=template.id,
name=template.name,
description=template.description,
category=template.category,
active=template.active,
current_version_id=template.current_version_id,
)
@router.get("/search", response_model=Union[List[SearchResponseItem], PaginatedSearchResponse])
async def search_templates(
q: Optional[str] = None,
category: Optional[List[str]] = Query(
None,
description=(
"Filter by category. Repeat the parameter (e.g., ?category=A&category=B) "
"or pass a comma-separated list (e.g., ?category=A,B)."
),
),
keywords: Optional[List[str]] = Query(None),
keywords_mode: str = Query("any", description="Keyword match mode: any|all (default any)"),
has_keywords: Optional[bool] = Query(
None,
description=(
"When true, only templates that have one or more keywords are returned; "
"when false, only templates with no keywords are returned."
),
),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
sort_by: Optional[str] = Query("name", description="Sort by: name | category | updated"),
sort_dir: Optional[str] = Query("asc", description="Sort direction: asc or desc"),
active_only: bool = Query(True, description="When true (default), only active templates are returned"),
include_total: bool = Query(False, description="When true, returns {items, total} instead of a plain list"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Normalize category values including CSV-in-parameter support
categories: Optional[List[str]] = None
if category:
raw_values = category or []
cat_values: List[str] = []
for value in raw_values:
parts = [part.strip() for part in (value or "").split(",")]
for part in parts:
if part:
cat_values.append(part)
categories = sorted(set(cat_values))
search_service = TemplateSearchService(db)
results, total = await search_service.search_templates(
q=q,
categories=categories,
keywords=keywords,
keywords_mode=keywords_mode,
has_keywords=has_keywords,
skip=skip,
limit=limit,
sort_by=sort_by or "name",
sort_dir=sort_dir or "asc",
active_only=active_only,
include_total=include_total,
)
items: List[SearchResponseItem] = [SearchResponseItem(**it) for it in results]
if include_total:
return {"items": items, "total": int(total or 0)}
return items
@router.get("/categories", response_model=Union[List[CategoryCount], PaginatedCategoriesResponse])
async def list_template_categories(
active_only: bool = Query(True, description="When true (default), only active templates are counted"),
include_total: bool = Query(False, description="When true, returns {items, total} instead of a plain list"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
search_service = TemplateSearchService(db)
rows = await search_service.list_categories(active_only=active_only)
items = [CategoryCount(category=row[0], count=row[1]) for row in rows]
if include_total:
return {"items": items, "total": len(items)}
return items
@router.get("/_cache_status", response_model=TemplateCacheStatusResponse)
async def cache_status(
current_user: User = Depends(get_admin_user),
):
# In-memory cache breakdown
with TemplateSearchService._mem_lock:
keys = list(TemplateSearchService._mem_cache.keys())
mem_templates = sum(1 for k in keys if k.startswith("search:templates:"))
mem_categories = sum(1 for k in keys if k.startswith("search:templates_categories:"))
# Redis availability check (best-effort)
redis_available = False
try:
client = await _get_client()
if client is not None:
try:
pong = await client.ping()
redis_available = bool(pong)
except Exception:
redis_available = False
except Exception:
redis_available = False
return TemplateCacheStatusResponse(
cache_enabled=bool(getattr(settings, "cache_enabled", False)),
redis_available=redis_available,
mem_cache={
"templates": int(mem_templates),
"categories": int(mem_categories),
},
)
@router.post("/_cache_invalidate")
async def cache_invalidate(
current_user: User = Depends(get_admin_user),
):
try:
await TemplateSearchService.invalidate_all()
return {"cleared": True}
except Exception as e:
return {"cleared": False, "error": str(e)}
@router.get("/{template_id}", response_model=TemplateResponse)
async def get_template(
template_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tpl = get_template_or_404(db, template_id)
return TemplateResponse(
id=tpl.id,
name=tpl.name,
description=tpl.description,
category=tpl.category,
active=tpl.active,
current_version_id=tpl.current_version_id,
)
@router.get("/{template_id}/versions", response_model=List[VersionResponse])
async def list_versions(
template_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
versions = svc_list_template_versions(db, template_id)
return [
VersionResponse(
id=v.id,
template_id=v.template_id,
semantic_version=v.semantic_version,
mime_type=v.mime_type,
size=v.size,
checksum=v.checksum,
changelog=v.changelog,
is_approved=v.is_approved,
)
for v in versions
]
@router.post("/{template_id}/versions", response_model=VersionResponse)
async def add_version(
template_id: int,
semantic_version: str = Form("1.0.0"),
changelog: Optional[str] = Form(None),
approve: bool = Form(True),
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
content = await file.read()
version = svc_add_template_version(
db,
template_id=template_id,
semantic_version=semantic_version,
changelog=changelog,
approve=approve,
content=content,
filename_hint=file.filename or "template.bin",
content_type=file.content_type,
created_by=getattr(current_user, "username", None),
)
return VersionResponse(
id=version.id,
template_id=version.template_id,
semantic_version=version.semantic_version,
mime_type=version.mime_type,
size=version.size,
checksum=version.checksum,
changelog=version.changelog,
is_approved=version.is_approved,
)
@router.post("/{template_id}/preview", response_model=PreviewResponse)
async def preview_template(
template_id: int,
payload: PreviewRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
resolved, unresolved, output_bytes, output_mime = svc_resolve_template_preview(
db,
template_id=template_id,
version_id=payload.version_id,
context=payload.context or {},
)
# Sanitize resolved values to ensure JSON-serializable output
def _json_sanitize(value: Any) -> Any:
from datetime import date, datetime
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, (date, datetime)):
return value.isoformat()
if isinstance(value, (list, tuple)):
return [_json_sanitize(v) for v in value]
if isinstance(value, dict):
return {k: _json_sanitize(v) for k, v in value.items()}
# Fallback: stringify unsupported types (e.g., functions)
return str(value)
sanitized_resolved = {k: _json_sanitize(v) for k, v in resolved.items()}
# We don't store preview output; just return metadata and resolution state
return PreviewResponse(
resolved=sanitized_resolved,
unresolved=unresolved,
output_mime_type=output_mime,
output_size=len(output_bytes),
)
@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),
):
content, mime_type, original_name = svc_get_download_payload(
db,
template_id=template_id,
version_id=version_id,
)
headers = {
"Content-Disposition": f"attachment; filename=\"{original_name}\"",
}
return StreamingResponse(iter([content]), media_type=mime_type, headers=headers)
@router.get("/{template_id}/keywords", response_model=KeywordsResponse)
async def list_keywords(
template_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
search_service = TemplateSearchService(db)
keywords = search_service.list_keywords(template_id)
return KeywordsResponse(keywords=keywords)
@router.post("/{template_id}/keywords", response_model=KeywordsResponse)
async def add_keywords(
template_id: int,
payload: KeywordsRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
search_service = TemplateSearchService(db)
keywords = await search_service.add_keywords(template_id, payload.keywords)
return KeywordsResponse(keywords=keywords)
@router.delete("/{template_id}/keywords/{keyword}", response_model=KeywordsResponse)
async def remove_keyword(
template_id: int,
keyword: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
search_service = TemplateSearchService(db)
keywords = await search_service.remove_keyword(template_id, keyword)
return KeywordsResponse(keywords=keywords)