""" 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)