From 29c3d5eed60457d974445b744c42d6a89bd684b1 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 24 Feb 2026 03:25:26 -0500 Subject: [PATCH] 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 --- backend/app/api/endpoints/trees.py | 21 +++++++++-- backend/app/schemas/tree.py | 2 ++ backend/tests/test_trees.py | 56 ++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/backend/app/api/endpoints/trees.py b/backend/app/api/endpoints/trees.py index 743106f8..cce1b785 100644 --- a/backend/app/api/endpoints/trees.py +++ b/backend/app/api/endpoints/trees.py @@ -32,7 +32,7 @@ from app.core.tree_validation import can_publish_tree 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.""" category_info = None if tree.category_rel: @@ -42,6 +42,8 @@ def build_tree_response(tree: Tree) -> TreeListResponse: slug=tree.category_rel.slug ) + author_name = (author_map or {}).get(tree.author_id) + return TreeListResponse( id=tree.id, name=tree.name, @@ -52,10 +54,12 @@ def build_tree_response(tree: Tree) -> TreeListResponse: category_info=category_info, tags=tree.tag_names, author_id=tree.author_id, + author_name=author_name, account_id=tree.account_id, is_active=tree.is_active, is_public=tree.is_public, is_default=tree.is_default, + visibility=tree.visibility, status=tree.status, version=tree.version, usage_count=tree.usage_count, @@ -125,6 +129,7 @@ async def list_trees( is_active: Optional[bool] = Query(None, description="Filter by active status"), author_id: Optional[UUID] = Query(None, description="Filter by author ID"), 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"), skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=100) @@ -158,6 +163,8 @@ async def list_trees( query = query.where(Tree.author_id == author_id) if is_public is not None: 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) if tags: @@ -206,7 +213,17 @@ async def list_trees( result = await db.execute(query) 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]) diff --git a/backend/app/schemas/tree.py b/backend/app/schemas/tree.py index c19c5f70..dc9cf116 100644 --- a/backend/app/schemas/tree.py +++ b/backend/app/schemas/tree.py @@ -176,10 +176,12 @@ class TreeListResponse(BaseModel): category_info: Optional[CategoryInfo] = None tags: list[str] = [] # List of tag names author_id: Optional[UUID] = None + author_name: Optional[str] = None # Display name or email of author account_id: Optional[UUID] = None is_active: bool is_public: bool is_default: bool + visibility: str = 'team' status: str # draft or published version: int usage_count: int diff --git a/backend/tests/test_trees.py b/backend/tests/test_trees.py index 79347246..300a50f6 100644 --- a/backend/tests/test_trees.py +++ b/backend/tests/test_trees.py @@ -391,3 +391,59 @@ class TestTrees: assert response.status_code == 200 trees = response.json() 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]