diff --git a/backend/alembic/versions/004_add_tree_is_public.py b/backend/alembic/versions/004_add_tree_is_public.py new file mode 100644 index 00000000..9b1e8d7a --- /dev/null +++ b/backend/alembic/versions/004_add_tree_is_public.py @@ -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') diff --git a/backend/app/api/endpoints/trees.py b/backend/app/api/endpoints/trees.py index 97947281..2177abea 100644 --- a/backend/app/api/endpoints/trees.py +++ b/backend/app/api/endpoints/trees.py @@ -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) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index f830f5a9..a254b10e 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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() diff --git a/backend/app/models/tree.py b/backend/app/models/tree.py index 48077e35..e5ef4b35 100644 --- a/backend/app/models/tree.py +++ b/backend/app/models/tree.py @@ -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( diff --git a/backend/app/schemas/tree.py b/backend/app/schemas/tree.py index 2d1e98d9..88849aed 100644 --- a/backend/app/schemas/tree.py +++ b/backend/app/schemas/tree.py @@ -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 diff --git a/backend/scripts/seed_trees.py b/backend/scripts/seed_trees.py index e3419c0e..ea9b71fe 100644 --- a/backend/scripts/seed_trees.py +++ b/backend/scripts/seed_trees.py @@ -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(