Add public/private visibility for trees

- Add is_public field to Tree model (private by default)
- Update access control: users see default trees, public trees, or their own
- Update all tree endpoints (list, search, get, categories) with new visibility logic
- Default/system trees are automatically marked as public
- Add migration 004 to add is_public column and update existing defaults
- Fix pydantic settings to ignore extra env vars (DATABASE_URL_SYNC)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-02-01 16:53:19 -05:00
parent db0b05eba7
commit 2d99c52025
6 changed files with 63 additions and 8 deletions

View File

@@ -0,0 +1,32 @@
"""Add is_public field to trees
Revision ID: 004
Revises: 003
Create Date: 2026-02-01
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '004'
down_revision: Union[str, None] = '003'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add is_public column with default False (private by default)
op.add_column('trees', sa.Column('is_public', sa.Boolean(), nullable=False, server_default='false'))
op.create_index('ix_trees_is_public', 'trees', ['is_public'])
# Make existing default trees public
op.execute("UPDATE trees SET is_public = true WHERE is_default = true")
def downgrade() -> None:
op.drop_index('ix_trees_is_public', table_name='trees')
op.drop_column('trees', 'is_public')

View File

@@ -31,11 +31,15 @@ async def list_trees(
if is_active is not None:
query = query.where(Tree.is_active == is_active)
# Only show active trees or trees owned by user (for now)
# Later, add team-based filtering
# Only show trees user has access to:
# - Default/system trees (visible to all)
# - Public trees
# - User's own trees (public or private)
query = query.where(
Tree.is_active == True,
or_(
Tree.is_active == True,
Tree.is_default == True,
Tree.is_public == True,
Tree.author_id == current_user.id
)
)
@@ -53,10 +57,15 @@ async def list_categories(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)]
):
"""List all unique categories."""
"""List all unique categories from trees the user can access."""
query = select(Tree.category).where(
Tree.category.isnot(None),
Tree.is_active == True
Tree.is_active == True,
or_(
Tree.is_default == True,
Tree.is_public == True,
Tree.author_id == current_user.id
)
).distinct()
result = await db.execute(query)
categories = [row[0] for row in result.all() if row[0]]
@@ -77,6 +86,11 @@ async def search_trees(
query = select(Tree).where(
Tree.is_active == True,
or_(
Tree.is_default == True,
Tree.is_public == True,
Tree.author_id == current_user.id
),
search_vector.op('@@')(search_query)
).order_by(
func.ts_rank(search_vector, search_query).desc()
@@ -103,8 +117,9 @@ async def get_tree(
detail="Tree not found"
)
# Check access: tree must be active OR user is the author
if not tree.is_active and tree.author_id != current_user.id:
# Check access: tree must be active AND (default OR public OR author)
can_access = tree.is_default or tree.is_public or tree.author_id == current_user.id
if not tree.is_active or not can_access:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this tree"
@@ -130,6 +145,7 @@ async def create_tree(
tree_structure=tree_data.tree_structure,
author_id=None if is_default else current_user.id, # Default trees have no author
team_id=None if is_default else current_user.team_id,
is_public=True if is_default else tree_data.is_public, # Default trees are always public
is_default=is_default
)
db.add(new_tree)

View File

@@ -52,6 +52,7 @@ class Settings(BaseSettings):
class Config:
env_file = ".env"
case_sensitive = True
extra = "ignore" # Ignore extra env vars like DATABASE_URL_SYNC
settings = Settings()

View File

@@ -31,6 +31,7 @@ class Tree(Base):
index=True
)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_public: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
version: Mapped[int] = mapped_column(Integer, default=1)
created_at: Mapped[datetime] = mapped_column(

View File

@@ -12,6 +12,7 @@ class TreeBase(BaseModel):
class TreeCreate(TreeBase):
tree_structure: dict[str, Any] = Field(..., description="The decision tree structure in JSON format")
is_public: bool = Field(False, description="Make tree visible to all users")
is_default: bool = Field(False, description="Mark as a default/system tree (admin only)")
@@ -20,6 +21,7 @@ class TreeUpdate(BaseModel):
description: Optional[str] = None
category: Optional[str] = Field(None, max_length=100)
tree_structure: Optional[dict[str, Any]] = None
is_public: Optional[bool] = None
is_active: Optional[bool] = None
@@ -29,6 +31,7 @@ class TreeResponse(TreeBase):
author_id: Optional[UUID] = None
team_id: Optional[UUID] = None
is_active: bool
is_public: bool
is_default: bool
version: int
created_at: datetime
@@ -45,6 +48,7 @@ class TreeListResponse(BaseModel):
description: Optional[str] = None
category: Optional[str] = None
is_active: bool
is_public: bool
is_default: bool
version: int
usage_count: int

View File

@@ -3336,8 +3336,9 @@ async def create_tree(client: httpx.AsyncClient, token: str, tree_data: dict) ->
print(f" [SKIP] Tree '{tree_data['name']}' already exists (ID: {tree['id']})")
return None
# Mark as default/system tree
# Mark as default/system tree (public and visible to all)
tree_data["is_default"] = True
tree_data["is_public"] = True
# Create the tree
response = await client.post(