From 006ef3d7b1cf53e3c3e5f77505d8cc6ab25f9f60 Mon Sep 17 00:00:00 2001 From: HotSwapp <47397945+HotSwapp@users.noreply.github.com> Date: Fri, 15 Aug 2025 15:06:45 -0500 Subject: [PATCH] templates: support include_total for search and categories endpoints; update docs; add tests --- app/api/templates.py | 30 ++++++++++++++++++++++++------ docs/README.md | 2 ++ tests/test_templates_api.py | 17 +++++++++++++++++ 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/app/api/templates.py b/app/api/templates.py index 6e8d76b..f982ff1 100644 --- a/app/api/templates.py +++ b/app/api/templates.py @@ -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) diff --git a/docs/README.md b/docs/README.md index 06eb0f1..c7f6a63 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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" diff --git a/tests/test_templates_api.py b/tests/test_templates_api.py index 95575da..25aab54 100644 --- a/tests/test_templates_api.py +++ b/tests/test_templates_api.py @@ -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