feat: add visibility filter param and author_name to tree list endpoint
GET /trees now accepts ?visibility=private|team|link|public to scope results. TreeListResponse includes author_name (full_name or email) and visibility. Author names fetched in single query to avoid N+1. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -32,7 +32,7 @@ from app.core.tree_validation import can_publish_tree
|
|||||||
router = APIRouter(prefix="/trees", tags=["trees"])
|
router = APIRouter(prefix="/trees", tags=["trees"])
|
||||||
|
|
||||||
|
|
||||||
def build_tree_response(tree: Tree) -> TreeListResponse:
|
def build_tree_response(tree: Tree, author_map: dict | None = None) -> TreeListResponse:
|
||||||
"""Build TreeListResponse with category_info and tags."""
|
"""Build TreeListResponse with category_info and tags."""
|
||||||
category_info = None
|
category_info = None
|
||||||
if tree.category_rel:
|
if tree.category_rel:
|
||||||
@@ -42,6 +42,8 @@ def build_tree_response(tree: Tree) -> TreeListResponse:
|
|||||||
slug=tree.category_rel.slug
|
slug=tree.category_rel.slug
|
||||||
)
|
)
|
||||||
|
|
||||||
|
author_name = (author_map or {}).get(tree.author_id)
|
||||||
|
|
||||||
return TreeListResponse(
|
return TreeListResponse(
|
||||||
id=tree.id,
|
id=tree.id,
|
||||||
name=tree.name,
|
name=tree.name,
|
||||||
@@ -52,10 +54,12 @@ def build_tree_response(tree: Tree) -> TreeListResponse:
|
|||||||
category_info=category_info,
|
category_info=category_info,
|
||||||
tags=tree.tag_names,
|
tags=tree.tag_names,
|
||||||
author_id=tree.author_id,
|
author_id=tree.author_id,
|
||||||
|
author_name=author_name,
|
||||||
account_id=tree.account_id,
|
account_id=tree.account_id,
|
||||||
is_active=tree.is_active,
|
is_active=tree.is_active,
|
||||||
is_public=tree.is_public,
|
is_public=tree.is_public,
|
||||||
is_default=tree.is_default,
|
is_default=tree.is_default,
|
||||||
|
visibility=tree.visibility,
|
||||||
status=tree.status,
|
status=tree.status,
|
||||||
version=tree.version,
|
version=tree.version,
|
||||||
usage_count=tree.usage_count,
|
usage_count=tree.usage_count,
|
||||||
@@ -125,6 +129,7 @@ async def list_trees(
|
|||||||
is_active: Optional[bool] = Query(None, description="Filter by active status"),
|
is_active: Optional[bool] = Query(None, description="Filter by active status"),
|
||||||
author_id: Optional[UUID] = Query(None, description="Filter by author ID"),
|
author_id: Optional[UUID] = Query(None, description="Filter by author ID"),
|
||||||
is_public: Optional[bool] = Query(None, description="Filter by public status"),
|
is_public: Optional[bool] = Query(None, description="Filter by public status"),
|
||||||
|
visibility: Optional[str] = Query(None, description="Filter by visibility: private, team, link, public"),
|
||||||
sort_by: Optional[str] = Query("usage_count", description="Sort order: usage_count, updated_at, created_at, name, name_desc, version"),
|
sort_by: Optional[str] = Query("usage_count", description="Sort order: usage_count, updated_at, created_at, name, name_desc, version"),
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(100, ge=1, le=100)
|
limit: int = Query(100, ge=1, le=100)
|
||||||
@@ -158,6 +163,8 @@ async def list_trees(
|
|||||||
query = query.where(Tree.author_id == author_id)
|
query = query.where(Tree.author_id == author_id)
|
||||||
if is_public is not None:
|
if is_public is not None:
|
||||||
query = query.where(Tree.is_public == is_public)
|
query = query.where(Tree.is_public == is_public)
|
||||||
|
if visibility:
|
||||||
|
query = query.where(Tree.visibility == visibility)
|
||||||
|
|
||||||
# Filter by tags (all specified tags must be present)
|
# Filter by tags (all specified tags must be present)
|
||||||
if tags:
|
if tags:
|
||||||
@@ -206,7 +213,17 @@ async def list_trees(
|
|||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
trees = result.scalars().unique().all()
|
trees = result.scalars().unique().all()
|
||||||
|
|
||||||
return [build_tree_response(tree) for tree in trees]
|
# Fetch author names in one query (avoids N+1)
|
||||||
|
author_ids = {t.author_id for t in trees if t.author_id}
|
||||||
|
author_map: dict = {}
|
||||||
|
if author_ids:
|
||||||
|
authors_result = await db.execute(
|
||||||
|
select(User.id, User.name, User.email).where(User.id.in_(author_ids))
|
||||||
|
)
|
||||||
|
for row in authors_result:
|
||||||
|
author_map[row.id] = row.name or row.email
|
||||||
|
|
||||||
|
return [build_tree_response(tree, author_map) for tree in trees]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/categories", response_model=list[str])
|
@router.get("/categories", response_model=list[str])
|
||||||
|
|||||||
@@ -176,10 +176,12 @@ class TreeListResponse(BaseModel):
|
|||||||
category_info: Optional[CategoryInfo] = None
|
category_info: Optional[CategoryInfo] = None
|
||||||
tags: list[str] = [] # List of tag names
|
tags: list[str] = [] # List of tag names
|
||||||
author_id: Optional[UUID] = None
|
author_id: Optional[UUID] = None
|
||||||
|
author_name: Optional[str] = None # Display name or email of author
|
||||||
account_id: Optional[UUID] = None
|
account_id: Optional[UUID] = None
|
||||||
is_active: bool
|
is_active: bool
|
||||||
is_public: bool
|
is_public: bool
|
||||||
is_default: bool
|
is_default: bool
|
||||||
|
visibility: str = 'team'
|
||||||
status: str # draft or published
|
status: str # draft or published
|
||||||
version: int
|
version: int
|
||||||
usage_count: int
|
usage_count: int
|
||||||
|
|||||||
@@ -391,3 +391,59 @@ class TestTrees:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
trees = response.json()
|
trees = response.json()
|
||||||
assert isinstance(trees, list)
|
assert isinstance(trees, list)
|
||||||
|
|
||||||
|
|
||||||
|
class TestVisibilityFilter:
|
||||||
|
"""Test that visibility filtering and author_name work correctly."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_private_tree_only_visible_to_author(
|
||||||
|
self, client: AsyncClient, auth_headers: dict, test_user: dict
|
||||||
|
):
|
||||||
|
"""A private tree created by test_user should appear in their own list."""
|
||||||
|
tree_data = {
|
||||||
|
"name": "Private Flow Test",
|
||||||
|
"tree_structure": {"id": "root", "type": "decision", "question": "Q?", "options": [], "children": []},
|
||||||
|
}
|
||||||
|
create_resp = await client.post("/api/v1/trees", json=tree_data, headers=auth_headers)
|
||||||
|
assert create_resp.status_code == 201
|
||||||
|
tree_id = create_resp.json()["id"]
|
||||||
|
|
||||||
|
# Set visibility to private
|
||||||
|
vis_resp = await client.patch(
|
||||||
|
f"/api/v1/trees/{tree_id}/visibility",
|
||||||
|
json={"visibility": "private"},
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
assert vis_resp.status_code == 200
|
||||||
|
|
||||||
|
# Verify it still appears for the author
|
||||||
|
list_resp = await client.get("/api/v1/trees", headers=auth_headers)
|
||||||
|
assert list_resp.status_code == 200
|
||||||
|
ids = [t["id"] for t in list_resp.json()]
|
||||||
|
assert tree_id in ids
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_visibility_query_param_filters_correctly(
|
||||||
|
self, client: AsyncClient, auth_headers: dict
|
||||||
|
):
|
||||||
|
"""?visibility=public should only return trees with visibility='public'."""
|
||||||
|
resp = await client.get("/api/v1/trees?visibility=public", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
trees = resp.json()
|
||||||
|
for tree in trees:
|
||||||
|
assert tree["visibility"] == "public", f"Tree {tree['id']} has visibility={tree['visibility']}"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_author_name_present_in_list_response(
|
||||||
|
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||||
|
):
|
||||||
|
"""TreeListResponse should include author_name field."""
|
||||||
|
resp = await client.get("/api/v1/trees", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
trees = resp.json()
|
||||||
|
assert len(trees) >= 1
|
||||||
|
# author_name key should be present (value may be None for system/default trees)
|
||||||
|
assert "author_name" in trees[0]
|
||||||
|
# visibility key should be present
|
||||||
|
assert "visibility" in trees[0]
|
||||||
|
|||||||
Reference in New Issue
Block a user