templates: support include_total for search and categories endpoints; update docs; add tests
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user