This commit is contained in:
HotSwapp
2025-08-18 20:20:04 -05:00
parent 89b2bc0aa2
commit bac8cc4bd5
114 changed files with 30258 additions and 1341 deletions

View File

@@ -20,12 +20,23 @@ from sqlalchemy import func, or_, exists
import hashlib
from app.database.base import get_db
from app.auth.security import get_current_user
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()
@@ -97,6 +108,12 @@ class PaginatedCategoriesResponse(BaseModel):
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(...),
@@ -107,38 +124,15 @@ async def upload_template(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if file.content_type not in {"application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/pdf"}:
raise HTTPException(status_code=400, detail="Only .docx or .pdf templates are supported")
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail="No file uploaded")
sha256 = hashlib.sha256(content).hexdigest()
storage = get_default_storage()
storage_path = storage.save_bytes(content=content, filename_hint=file.filename or "template.bin", subdir="templates")
template = DocumentTemplate(name=name, description=description, category=category, active=True, created_by=getattr(current_user, "username", None))
db.add(template)
db.flush() # get id
version = DocumentTemplateVersion(
template_id=template.id,
service = TemplateUploadService(db)
template = await service.upload_template(
name=name,
category=category,
description=description,
semantic_version=semantic_version,
storage_path=storage_path,
mime_type=file.content_type,
size=len(content),
checksum=sha256,
changelog=None,
file=file,
created_by=getattr(current_user, "username", None),
is_approved=True,
)
db.add(version)
db.flush()
template.current_version_id = version.id
db.commit()
db.refresh(template)
return TemplateResponse(
id=template.id,
name=template.name,
@@ -177,88 +171,34 @@ async def search_templates(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
query = db.query(DocumentTemplate)
if active_only:
query = query.filter(DocumentTemplate.active == True)
if q:
like = f"%{q}%"
query = query.filter(
or_(
DocumentTemplate.name.ilike(like),
DocumentTemplate.description.ilike(like),
)
)
# Category filtering (supports repeatable param and CSV within each value)
# Normalize category values including CSV-in-parameter support
categories: Optional[List[str]] = None
if category:
raw_values = category or []
categories: List[str] = []
cat_values: List[str] = []
for value in raw_values:
parts = [part.strip() for part in (value or "").split(",")]
for part in parts:
if part:
categories.append(part)
unique_categories = sorted(set(categories))
if unique_categories:
query = query.filter(DocumentTemplate.category.in_(unique_categories))
if keywords:
normalized = [kw.strip().lower() for kw in keywords if kw and kw.strip()]
unique_keywords = sorted(set(normalized))
if unique_keywords:
mode = (keywords_mode or "any").lower()
if mode not in ("any", "all"):
mode = "any"
query = query.join(TemplateKeyword, TemplateKeyword.template_id == DocumentTemplate.id)
if mode == "any":
query = query.filter(TemplateKeyword.keyword.in_(unique_keywords)).distinct()
else:
query = query.filter(TemplateKeyword.keyword.in_(unique_keywords))
query = query.group_by(DocumentTemplate.id)
query = query.having(func.count(func.distinct(TemplateKeyword.keyword)) == len(unique_keywords))
# Has keywords filter (independent of specific keyword matches)
if has_keywords is not None:
kw_exists = exists().where(TemplateKeyword.template_id == DocumentTemplate.id)
if has_keywords:
query = query.filter(kw_exists)
else:
query = query.filter(~kw_exists)
# Sorting
sort_key = (sort_by or "name").lower()
direction = (sort_dir or "asc").lower()
if sort_key not in ("name", "category", "updated"):
sort_key = "name"
if direction not in ("asc", "desc"):
direction = "asc"
cat_values.append(part)
categories = sorted(set(cat_values))
if sort_key == "name":
order_col = DocumentTemplate.name
elif sort_key == "category":
order_col = DocumentTemplate.category
else: # updated
order_col = func.coalesce(DocumentTemplate.updated_at, DocumentTemplate.created_at)
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,
)
if direction == "asc":
query = query.order_by(order_col.asc())
else:
query = query.order_by(order_col.desc())
# Pagination with optional total
templates, total = paginate_with_total(query, skip, limit, include_total)
items: List[SearchResponseItem] = []
for tpl in templates:
latest_version = None
if tpl.current_version_id:
ver = db.query(DocumentTemplateVersion).filter(DocumentTemplateVersion.id == tpl.current_version_id).first()
if ver:
latest_version = ver.semantic_version
items.append(
SearchResponseItem(
id=tpl.id,
name=tpl.name,
category=tpl.category,
active=tpl.active,
latest_version=latest_version,
)
)
items: List[SearchResponseItem] = [SearchResponseItem(**it) for it in results]
if include_total:
return {"items": items, "total": int(total or 0)}
return items
@@ -271,25 +211,65 @@ async def list_template_categories(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
query = db.query(DocumentTemplate.category, func.count(DocumentTemplate.id).label("count"))
if active_only:
query = query.filter(DocumentTemplate.active == True)
rows = query.group_by(DocumentTemplate.category).order_by(DocumentTemplate.category.asc()).all()
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 = db.query(DocumentTemplate).filter(DocumentTemplate.id == template_id).first()
if not tpl:
raise HTTPException(status_code=404, detail="Template not found")
tpl = get_template_or_404(db, template_id)
return TemplateResponse(
id=tpl.id,
name=tpl.name,
@@ -306,12 +286,7 @@ async def list_versions(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
versions = (
db.query(DocumentTemplateVersion)
.filter(DocumentTemplateVersion.template_id == template_id)
.order_by(DocumentTemplateVersion.created_at.desc())
.all()
)
versions = svc_list_template_versions(db, template_id)
return [
VersionResponse(
id=v.id,
@@ -337,31 +312,18 @@ async def add_version(
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")
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail="No file uploaded")
sha256 = hashlib.sha256(content).hexdigest()
storage = get_default_storage()
storage_path = storage.save_bytes(content=content, filename_hint=file.filename or "template.bin", subdir="templates")
version = DocumentTemplateVersion(
version = svc_add_template_version(
db,
template_id=template_id,
semantic_version=semantic_version,
storage_path=storage_path,
mime_type=file.content_type,
size=len(content),
checksum=sha256,
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),
is_approved=bool(approve),
)
db.add(version)
db.flush()
if approve:
tpl.current_version_id = version.id
db.commit()
return VersionResponse(
id=version.id,
template_id=version.template_id,
@@ -381,31 +343,32 @@ async def preview_template(
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")
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")
resolved, unresolved, output_bytes, output_mime = svc_resolve_template_preview(
db,
template_id=template_id,
version_id=payload.version_id,
context=payload.context or {},
)
storage = get_default_storage()
content = storage.open_bytes(ver.storage_path)
tokens = extract_tokens_from_bytes(content)
context = build_context(payload.context or {})
resolved, unresolved = resolve_tokens(db, tokens, context)
# 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)
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
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=resolved,
resolved=sanitized_resolved,
unresolved=unresolved,
output_mime_type=output_mime,
output_size=len(output_bytes),
@@ -419,40 +382,16 @@ async def download_template(
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()
content, mime_type, original_name = svc_get_download_payload(
db,
template_id=template_id,
version_id=version_id,
)
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)
return StreamingResponse(iter([content]), media_type=mime_type, headers=headers)
@router.get("/{template_id}/keywords", response_model=KeywordsResponse)
@@ -461,16 +400,9 @@ async def list_keywords(
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")
kws = (
db.query(TemplateKeyword)
.filter(TemplateKeyword.template_id == template_id)
.order_by(TemplateKeyword.keyword.asc())
.all()
)
return KeywordsResponse(keywords=[k.keyword for k in kws])
search_service = TemplateSearchService(db)
keywords = search_service.list_keywords(template_id)
return KeywordsResponse(keywords=keywords)
@router.post("/{template_id}/keywords", response_model=KeywordsResponse)
@@ -480,31 +412,9 @@ async def add_keywords(
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")
to_add = []
for kw in (payload.keywords or []):
normalized = (kw or "").strip().lower()
if not normalized:
continue
exists = (
db.query(TemplateKeyword)
.filter(TemplateKeyword.template_id == template_id, TemplateKeyword.keyword == normalized)
.first()
)
if not exists:
to_add.append(TemplateKeyword(template_id=template_id, keyword=normalized))
if to_add:
db.add_all(to_add)
db.commit()
kws = (
db.query(TemplateKeyword)
.filter(TemplateKeyword.template_id == template_id)
.order_by(TemplateKeyword.keyword.asc())
.all()
)
return KeywordsResponse(keywords=[k.keyword for k in kws])
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)
@@ -514,21 +424,7 @@ async def remove_keyword(
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")
normalized = (keyword or "").strip().lower()
if normalized:
db.query(TemplateKeyword).filter(
TemplateKeyword.template_id == template_id,
TemplateKeyword.keyword == normalized,
).delete(synchronize_session=False)
db.commit()
kws = (
db.query(TemplateKeyword)
.filter(TemplateKeyword.template_id == template_id)
.order_by(TemplateKeyword.keyword.asc())
.all()
)
return KeywordsResponse(keywords=[k.keyword for k in kws])
search_service = TemplateSearchService(db)
keywords = await search_service.remove_keyword(template_id, keyword)
return KeywordsResponse(keywords=keywords)