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 __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 fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import func, or_, exists
|
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.models.templates import DocumentTemplate, DocumentTemplateVersion, TemplateKeyword
|
||||||
from app.services.storage import get_default_storage
|
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_merge import extract_tokens_from_bytes, build_context, resolve_tokens, render_docx
|
||||||
|
from app.services.query_utils import paginate_with_total
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -84,6 +85,16 @@ class CategoryCount(BaseModel):
|
|||||||
count: int
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedSearchResponse(BaseModel):
|
||||||
|
items: List[SearchResponseItem]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedCategoriesResponse(BaseModel):
|
||||||
|
items: List[CategoryCount]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload", response_model=TemplateResponse)
|
@router.post("/upload", response_model=TemplateResponse)
|
||||||
async def upload_template(
|
async def upload_template(
|
||||||
name: str = Form(...),
|
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(
|
async def search_templates(
|
||||||
q: Optional[str] = None,
|
q: Optional[str] = None,
|
||||||
category: Optional[List[str]] = Query(
|
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_by: Optional[str] = Query("name", description="Sort by: name | category | updated"),
|
||||||
sort_dir: Optional[str] = Query("asc", description="Sort direction: asc or desc"),
|
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"),
|
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),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
@@ -227,8 +239,8 @@ async def search_templates(
|
|||||||
else:
|
else:
|
||||||
query = query.order_by(order_col.desc())
|
query = query.order_by(order_col.desc())
|
||||||
|
|
||||||
# Pagination
|
# Pagination with optional total
|
||||||
templates = query.offset(skip).limit(limit).all()
|
templates, total = paginate_with_total(query, skip, limit, include_total)
|
||||||
items: List[SearchResponseItem] = []
|
items: List[SearchResponseItem] = []
|
||||||
for tpl in templates:
|
for tpl in templates:
|
||||||
latest_version = None
|
latest_version = None
|
||||||
@@ -245,12 +257,15 @@ async def search_templates(
|
|||||||
latest_version=latest_version,
|
latest_version=latest_version,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if include_total:
|
||||||
|
return {"items": items, "total": int(total or 0)}
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
@router.get("/categories", response_model=List[CategoryCount])
|
@router.get("/categories", response_model=Union[List[CategoryCount], PaginatedCategoriesResponse])
|
||||||
async def list_template_categories(
|
async def list_template_categories(
|
||||||
active_only: bool = Query(True, description="When true (default), only active templates are counted"),
|
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),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
@@ -258,7 +273,10 @@ async def list_template_categories(
|
|||||||
if active_only:
|
if active_only:
|
||||||
query = query.filter(DocumentTemplate.active == True)
|
query = query.filter(DocumentTemplate.active == True)
|
||||||
rows = query.group_by(DocumentTemplate.category).order_by(DocumentTemplate.category.asc()).all()
|
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)
|
@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_by` (str, optional): `name` (default) | `category` | `updated`
|
||||||
- `sort_dir` (str, optional): `asc` (default) | `desc`
|
- `sort_dir` (str, optional): `asc` (default) | `desc`
|
||||||
- `active_only` (bool, optional): when `true` (default), only active templates are returned
|
- `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:
|
- Examples:
|
||||||
```bash
|
```bash
|
||||||
# Any of the keywords (default)
|
# Any of the keywords (default)
|
||||||
@@ -320,6 +321,7 @@ Allowed sort fields (high level):
|
|||||||
- `GET /api/templates/categories` - List distinct template categories with counts
|
- `GET /api/templates/categories` - List distinct template categories with counts
|
||||||
- Query params:
|
- Query params:
|
||||||
- `active_only` (bool, optional): when `true` (default), only counts active templates
|
- `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:
|
- Example:
|
||||||
```bash
|
```bash
|
||||||
curl "http://localhost:6920/api/templates/categories?active_only=false"
|
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
|
# We can't assert exact order of timestamps easily; just ensure we got results
|
||||||
assert isinstance(resp.json(), list) and len(resp.json()) >= 5
|
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):
|
def test_templates_search_active_filtering(client: TestClient):
|
||||||
# Create two templates, mark one inactive directly
|
# Create two templates, mark one inactive directly
|
||||||
@@ -373,6 +383,13 @@ def test_templates_categories_listing(client: TestClient):
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
rows_all = resp.json()
|
rows_all = resp.json()
|
||||||
by_cat_all = {r["category"]: r["count"] for r in rows_all}
|
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("K1", 0) >= 2
|
||||||
assert by_cat_all.get("K2", 0) >= 1
|
assert by_cat_all.get("K2", 0) >= 1
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user