431 lines
14 KiB
Python
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)
|
|
|