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:
|
if is_active is not None:
|
||||||
query = query.where(Tree.is_active == is_active)
|
query = query.where(Tree.is_active == is_active)
|
||||||
|
|
||||||
# Only show active trees or trees owned by user (for now)
|
# Only show trees user has access to:
|
||||||
# Later, add team-based filtering
|
# - Default/system trees (visible to all)
|
||||||
|
# - Public trees
|
||||||
|
# - User's own trees (public or private)
|
||||||
query = query.where(
|
query = query.where(
|
||||||
|
Tree.is_active == True,
|
||||||
or_(
|
or_(
|
||||||
Tree.is_active == True,
|
Tree.is_default == True,
|
||||||
|
Tree.is_public == True,
|
||||||
Tree.author_id == current_user.id
|
Tree.author_id == current_user.id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -53,10 +57,15 @@ async def list_categories(
|
|||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
current_user: Annotated[User, Depends(get_current_user)]
|
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(
|
query = select(Tree.category).where(
|
||||||
Tree.category.isnot(None),
|
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()
|
).distinct()
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
categories = [row[0] for row in result.all() if row[0]]
|
categories = [row[0] for row in result.all() if row[0]]
|
||||||
@@ -77,6 +86,11 @@ async def search_trees(
|
|||||||
|
|
||||||
query = select(Tree).where(
|
query = select(Tree).where(
|
||||||
Tree.is_active == True,
|
Tree.is_active == True,
|
||||||
|
or_(
|
||||||
|
Tree.is_default == True,
|
||||||
|
Tree.is_public == True,
|
||||||
|
Tree.author_id == current_user.id
|
||||||
|
),
|
||||||
search_vector.op('@@')(search_query)
|
search_vector.op('@@')(search_query)
|
||||||
).order_by(
|
).order_by(
|
||||||
func.ts_rank(search_vector, search_query).desc()
|
func.ts_rank(search_vector, search_query).desc()
|
||||||
@@ -103,8 +117,9 @@ async def get_tree(
|
|||||||
detail="Tree not found"
|
detail="Tree not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check access: tree must be active OR user is the author
|
# Check access: tree must be active AND (default OR public OR author)
|
||||||
if not tree.is_active and tree.author_id != current_user.id:
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="You don't have access to this tree"
|
detail="You don't have access to this tree"
|
||||||
@@ -130,6 +145,7 @@ async def create_tree(
|
|||||||
tree_structure=tree_data.tree_structure,
|
tree_structure=tree_data.tree_structure,
|
||||||
author_id=None if is_default else current_user.id, # Default trees have no author
|
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,
|
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
|
is_default=is_default
|
||||||
)
|
)
|
||||||
db.add(new_tree)
|
db.add(new_tree)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class Settings(BaseSettings):
|
|||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
case_sensitive = True
|
case_sensitive = True
|
||||||
|
extra = "ignore" # Ignore extra env vars like DATABASE_URL_SYNC
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class Tree(Base):
|
|||||||
index=True
|
index=True
|
||||||
)
|
)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=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)
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||||
version: Mapped[int] = mapped_column(Integer, default=1)
|
version: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class TreeBase(BaseModel):
|
|||||||
|
|
||||||
class TreeCreate(TreeBase):
|
class TreeCreate(TreeBase):
|
||||||
tree_structure: dict[str, Any] = Field(..., description="The decision tree structure in JSON format")
|
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)")
|
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
|
description: Optional[str] = None
|
||||||
category: Optional[str] = Field(None, max_length=100)
|
category: Optional[str] = Field(None, max_length=100)
|
||||||
tree_structure: Optional[dict[str, Any]] = None
|
tree_structure: Optional[dict[str, Any]] = None
|
||||||
|
is_public: Optional[bool] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -29,6 +31,7 @@ class TreeResponse(TreeBase):
|
|||||||
author_id: Optional[UUID] = None
|
author_id: Optional[UUID] = None
|
||||||
team_id: Optional[UUID] = None
|
team_id: Optional[UUID] = None
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
is_public: bool
|
||||||
is_default: bool
|
is_default: bool
|
||||||
version: int
|
version: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
@@ -45,6 +48,7 @@ class TreeListResponse(BaseModel):
|
|||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
category: Optional[str] = None
|
category: Optional[str] = None
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
is_public: bool
|
||||||
is_default: bool
|
is_default: bool
|
||||||
version: int
|
version: int
|
||||||
usage_count: 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']})")
|
print(f" [SKIP] Tree '{tree_data['name']}' already exists (ID: {tree['id']})")
|
||||||
return None
|
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_default"] = True
|
||||||
|
tree_data["is_public"] = True
|
||||||
|
|
||||||
# Create the tree
|
# Create the tree
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
|
|||||||
Reference in New Issue
Block a user