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:
32
backend/alembic/versions/004_add_tree_is_public.py
Normal file
32
backend/alembic/versions/004_add_tree_is_public.py
Normal 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')
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user