templates: support include_total for search and categories endpoints; update docs; add tests

This commit is contained in:
HotSwapp
2025-08-15 15:06:45 -05:00
parent e3a279dba7
commit 006ef3d7b1
3 changed files with 43 additions and 6 deletions

View File

@@ -11,7 +11,7 @@ Endpoints:
"""
from __future__ import annotations
from typing import List, Optional, Dict, Any
from typing import List, Optional, Dict, Any, Union
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, or_, exists
@@ -23,6 +23,7 @@ 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.query_utils import paginate_with_total
router = APIRouter()
@@ -84,6 +85,16 @@ class CategoryCount(BaseModel):
count: int
class PaginatedSearchResponse(BaseModel):
items: List[SearchResponseItem]
total: int
class PaginatedCategoriesResponse(BaseModel):
items: List[CategoryCount]
total: int
@router.post("/upload", response_model=TemplateResponse)
async def upload_template(
name: str = Form(...),
@@ -136,7 +147,7 @@ async def upload_template(
)
@router.get("/search", response_model=List[SearchResponseItem])
@router.get("/search", response_model=Union[List[SearchResponseItem], PaginatedSearchResponse])
async def search_templates(
q: Optional[str] = None,
category: Optional[List[str]] = Query(
@@ -160,6 +171,7 @@ async def search_templates(
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),
):
@@ -227,8 +239,8 @@ async def search_templates(
else:
query = query.order_by(order_col.desc())
# Pagination
templates = query.offset(skip).limit(limit).all()
# Pagination with optional total
templates, total = paginate_with_total(query, skip, limit, include_total)
items: List[SearchResponseItem] = []
for tpl in templates:
latest_version = None
@@ -245,12 +257,15 @@ async def search_templates(
latest_version=latest_version,
)
)
if include_total:
return {"items": items, "total": int(total or 0)}
return items
@router.get("/categories", response_model=List[CategoryCount])
@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),
):
@@ -258,7 +273,10 @@ async def list_template_categories(
if active_only:
query = query.filter(DocumentTemplate.active == True)
rows = query.group_by(DocumentTemplate.category).order_by(DocumentTemplate.category.asc()).all()
return [CategoryCount(category=row[0], count=row[1]) for row in rows]
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("/{template_id}", response_model=TemplateResponse)

View File

@@ -302,6 +302,7 @@ Allowed sort fields (high level):
- `sort_by` (str, optional): `name` (default) | `category` | `updated`
- `sort_dir` (str, optional): `asc` (default) | `desc`
- `active_only` (bool, optional): when `true` (default), only active templates are returned
- `include_total` (bool, optional): when `true`, returns `{ items, total }` instead of a plain list
- Examples:
```bash
# Any of the keywords (default)
@@ -320,6 +321,7 @@ Allowed sort fields (high level):
- `GET /api/templates/categories` - List distinct template categories with counts
- Query params:
- `active_only` (bool, optional): when `true` (default), only counts active templates
- `include_total` (bool, optional): when `true`, returns `{ items, total }` instead of a plain list
- Example:
```bash
curl "http://localhost:6920/api/templates/categories?active_only=false"

View File

@@ -247,6 +247,16 @@ def test_templates_search_pagination_and_sorting(client: TestClient):
# We can't assert exact order of timestamps easily; just ensure we got results
assert isinstance(resp.json(), list) and len(resp.json()) >= 5
# include_total=true returns object with items and total
resp = client.get(
"/api/templates/search",
params={"sort_by": "name", "sort_dir": "asc", "include_total": True},
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert isinstance(data, dict) and "items" in data and "total" in data
assert isinstance(data["items"], list) and isinstance(data["total"], int)
def test_templates_search_active_filtering(client: TestClient):
# Create two templates, mark one inactive directly
@@ -373,6 +383,13 @@ def test_templates_categories_listing(client: TestClient):
assert resp.status_code == 200
rows_all = resp.json()
by_cat_all = {r["category"]: r["count"] for r in rows_all}
# include_total=true shape
resp = client.get("/api/templates/categories", params={"include_total": True, "active_only": False})
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, dict) and "items" in data and "total" in data
assert isinstance(data["items"], list) and isinstance(data["total"], int)
assert by_cat_all.get("K1", 0) >= 2
assert by_cat_all.get("K2", 0) >= 1