feat: implement tree sharing, draft trees, and session-to-tree conversion (Issues #16, #25, #17)

Backend features:
- Tree sharing via secure tokens with expiration (Issue #16)
- Draft tree status with conditional validation (Issue #25)
- Save session as custom tree with fork tracking (Issue #17)
- Tree validation system for publish requirements
- Session-to-tree conversion preserving custom steps

Database migrations:
- 024: Tree sharing (tree_shares table, visibility field)
- 025: Tree status field (draft/published)
- 25b: Merge migration for indexes

New endpoints:
- POST /api/v1/trees/{id}/share - Generate share token
- GET /api/v1/shared/{token} - Public tree access
- POST /api/v1/trees/{id}/can-publish - Validate tree
- POST /api/v1/sessions/{id}/save-as-tree - Convert session

Test coverage:
- 20 tests for draft trees functionality
- 14 tests for session-to-tree conversion
- 15 tests for tree sharing

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-02-07 23:06:13 -05:00
parent 9f92547309
commit c7b2c59ef6
16 changed files with 2141 additions and 7 deletions

View File

@@ -11,7 +11,7 @@ from app.core.database import get_db
from app.models.tree import Tree
from app.models.session import Session
from app.models.user import User
from app.schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionExport, ScratchpadUpdate
from app.schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionExport, ScratchpadUpdate, SaveAsTreeRequest, SaveAsTreeResponse
from app.api.deps import get_current_active_user
from app.core.permissions import can_access_tree
@@ -449,3 +449,130 @@ def _generate_html_export(session: Session, options: SessionExport) -> str:
html_parts.extend(['</body>', '</html>'])
return "\n".join(html_parts)
# --- Save Session as Tree ---
@router.post("/{session_id}/save-as-tree", response_model=SaveAsTreeResponse, status_code=status.HTTP_201_CREATED)
async def save_session_as_tree(
session_id: UUID,
request_data: SaveAsTreeRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Save a session as a new tree.
Converts the session's path_taken and custom_steps into a linear tree structure.
The new tree is linked to the original tree via parent_tree_id (fork relationship).
Args:
session_id: ID of the session to save
request_data: Tree name, description, and status
db: Database session
current_user: Current authenticated user
Returns:
SaveAsTreeResponse with new tree ID and name
"""
from app.core.session_to_tree import convert_session_to_tree, generate_tree_name_from_session
from app.core.tree_validation import can_publish_tree
from app.core.subscriptions import check_tree_limit
# Load the session
result = await db.execute(
select(Session).where(
Session.id == session_id,
Session.user_id == current_user.id
)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
# Load the original tree to get metadata
tree_result = await db.execute(
select(Tree).where(Tree.id == session.tree_id)
)
original_tree = tree_result.scalar_one_or_none()
if not original_tree:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Original tree not found"
)
# Convert session to tree structure
tree_structure = convert_session_to_tree(
session.path_taken,
session.tree_snapshot,
session.custom_steps,
session.decisions
)
# Generate tree name
if request_data.tree_name:
tree_name = request_data.tree_name
else:
tree_name = generate_tree_name_from_session(
original_tree.name,
session.ticket_number,
session.client_name
)
# Validate if status is published
if request_data.status == 'published':
can_publish, validation_errors = can_publish_tree(
tree_structure,
tree_name,
request_data.description
)
if not can_publish:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"message": "Cannot save as published tree with validation errors",
"errors": validation_errors
}
)
# Check subscription tree limit
if current_user.account_id:
can_create, limit, count = await check_tree_limit(current_user.account_id, db)
if not can_create:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=f"Tree limit reached ({count}/{limit}). Upgrade your plan to create more trees."
)
# Create the new tree as a fork of the original
new_tree = Tree(
name=tree_name,
description=request_data.description or f"Saved from troubleshooting session on {session.started_at.strftime('%Y-%m-%d')}",
tree_structure=tree_structure,
author_id=current_user.id,
account_id=current_user.account_id,
status=request_data.status,
is_public=False,
is_default=False,
# Fork tracking - link to original tree
parent_tree_id=original_tree.id,
root_tree_id=original_tree.root_tree_id if original_tree.root_tree_id else original_tree.id,
fork_depth=original_tree.fork_depth + 1,
fork_reason=f"Saved from session: {session.ticket_number or 'No ticket'}" if session.ticket_number else "Saved from troubleshooting session",
parent_updated_at=original_tree.updated_at
)
db.add(new_tree)
await db.commit()
await db.refresh(new_tree)
return SaveAsTreeResponse(
tree_id=new_tree.id,
tree_name=new_tree.name,
message=f"Session saved as {'published' if request_data.status == 'published' else 'draft'} tree"
)

View File

@@ -0,0 +1,70 @@
"""Public endpoints for accessing shared content (no authentication required)."""
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.models.tree import Tree
from app.models.tree_share import TreeShare
from app.schemas.tree import SharedTreeResponse
router = APIRouter(prefix="/shared", tags=["shared"])
@router.get("/{share_token}", response_model=SharedTreeResponse)
async def get_shared_tree(
share_token: str,
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Get a tree by its share token (PUBLIC endpoint - no auth required).
Returns 404 if:
- Share token doesn't exist
- Share token has expired
- Tree is not active
"""
# Look up share token
result = await db.execute(
select(TreeShare)
.options(selectinload(TreeShare.tree).selectinload(Tree.tags))
.where(TreeShare.share_token == share_token)
)
tree_share = result.scalar_one_or_none()
if not tree_share:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Share link not found or has been revoked"
)
# Check expiration
if tree_share.expires_at and tree_share.expires_at < datetime.now(timezone.utc):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Share link has expired"
)
# Check tree is active
tree = tree_share.tree
if not tree or not tree.is_active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tree not found"
)
# Build response (minimal info for public access)
return SharedTreeResponse(
id=tree.id,
name=tree.name,
description=tree.description,
category=tree.category,
tree_structure=tree.tree_structure,
tags=tree.tag_names,
version=tree.version,
allow_forking=tree_share.allow_forking,
created_at=tree.created_at,
updated_at=tree.updated_at
)

View File

@@ -1,22 +1,30 @@
from datetime import datetime, timezone
from typing import Annotated, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status, Query
import secrets
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_, true as sa_true, update
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.models.tree import Tree
from app.models.tree_share import TreeShare
from app.models.user import User
from app.models.category import TreeCategory
from app.models.tag import TreeTag, tree_tag_assignments
from app.models.folder import UserFolder, user_folder_trees
from app.schemas.tree import TreeCreate, TreeUpdate, TreeResponse, TreeListResponse, CategoryInfo, ForkCreate, ForkInfo
from app.schemas.tree import (
TreeCreate, TreeUpdate, TreeResponse, TreeListResponse, CategoryInfo,
ForkCreate, ForkInfo, TreeShareCreate, TreeShareResponse,
TreeVisibilityUpdate, SharedTreeResponse, TreeValidationResponse, ValidationError
)
from app.api.deps import get_current_active_user, require_engineer_or_admin, require_admin
from app.core.permissions import can_edit_tree, can_access_tree
from app.core.subscriptions import check_tree_limit
from app.core.audit import log_audit
from app.core.config import settings
from app.core.tree_validation import can_publish_tree
router = APIRouter(prefix="/trees", tags=["trees"])
@@ -66,6 +74,7 @@ def build_tree_response(tree: Tree) -> TreeListResponse:
is_active=tree.is_active,
is_public=tree.is_public,
is_default=tree.is_default,
status=tree.status,
version=tree.version,
usage_count=tree.usage_count,
created_at=tree.created_at,
@@ -112,6 +121,7 @@ def build_full_tree_response(tree: Tree, parent_tree: Tree = None) -> TreeRespon
is_active=tree.is_active,
is_public=tree.is_public,
is_default=tree.is_default,
status=tree.status,
version=tree.version,
usage_count=tree.usage_count,
created_at=tree.created_at,
@@ -304,7 +314,24 @@ async def create_tree(
Supports:
- category_id: Assign to a category from tree_categories
- tags: List of tag names to assign (creates new tags if needed)
- status: draft or published (published requires validation)
"""
# Validate tree if status is 'published'
if tree_data.status == 'published':
can_publish, validation_errors = can_publish_tree(
tree_data.tree_structure,
tree_data.name,
tree_data.description
)
if not can_publish:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"message": "Cannot publish tree with validation errors",
"errors": validation_errors
}
)
# Only admins can create default/system trees
is_default = tree_data.is_default and current_user.is_super_admin
@@ -335,7 +362,8 @@ async def create_tree(
author_id=None if is_default else current_user.id, # Default trees have no author
account_id=None if is_default else current_user.account_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,
status=tree_data.status
)
# Check subscription tree limit
if not is_default and current_user.account_id:
@@ -422,6 +450,7 @@ async def update_tree(
Supports:
- category_id: Change category assignment
- tags: Replace all tags on the tree
- status: Update status (requires validation when publishing)
"""
result = await db.execute(
select(Tree)
@@ -449,6 +478,27 @@ async def update_tree(
update_data = tree_data.model_dump(exclude_unset=True)
tags_data = update_data.pop("tags", None)
# Validate if transitioning to published status
if "status" in update_data and update_data["status"] == 'published':
# Get the final tree structure and name after update
final_tree_structure = update_data.get("tree_structure", tree.tree_structure)
final_name = update_data.get("name", tree.name)
final_description = update_data.get("description", tree.description)
can_publish, validation_errors = can_publish_tree(
final_tree_structure,
final_name,
final_description
)
if not can_publish:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"message": "Cannot publish tree with validation errors",
"errors": validation_errors
}
)
# Verify new category if provided
if "category_id" in update_data and update_data["category_id"]:
cat_result = await db.execute(
@@ -754,3 +804,223 @@ async def get_tree_lineage(
current_id = tree.parent_tree_id
return lineage
# --- Tree Sharing Endpoints ---
@router.post("/{tree_id}/share", response_model=TreeShareResponse, status_code=status.HTTP_201_CREATED)
async def create_tree_share(
tree_id: UUID,
share_data: TreeShareCreate,
request: Request,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Generate a share token for a tree.
Requirements:
- Tree author can always create shares
- Account members can share trees with visibility 'team', 'link', or 'public'
- Super admins can share any tree
"""
# Load tree
result = await db.execute(
select(Tree).where(Tree.id == tree_id, Tree.is_active == True)
)
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tree not found"
)
# Check permissions
if not can_access_tree(current_user, tree):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this tree"
)
# Generate unique share token
share_token = secrets.token_urlsafe(48) # 48 bytes -> 64 base64 chars
# Create share
tree_share = TreeShare(
tree_id=tree.id,
share_token=share_token,
created_by=current_user.id,
allow_forking=share_data.allow_forking,
expires_at=share_data.expires_at
)
db.add(tree_share)
await log_audit(db, current_user.id, "tree.share.create", "tree_share", tree_share.id,
{"tree_id": str(tree.id), "tree_name": tree.name, "allow_forking": share_data.allow_forking})
await db.commit()
await db.refresh(tree_share)
# Build share URL
base_url = str(request.base_url).rstrip('/')
share_url = f"{base_url}/shared/{share_token}"
return TreeShareResponse(
id=tree_share.id,
tree_id=tree_share.tree_id,
share_token=tree_share.share_token,
share_url=share_url,
allow_forking=tree_share.allow_forking,
created_by=tree_share.created_by,
created_at=tree_share.created_at,
expires_at=tree_share.expires_at
)
@router.get("/{tree_id}/shares", response_model=list[TreeShareResponse])
async def list_tree_shares(
tree_id: UUID,
request: Request,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""List all active shares for a tree."""
# Verify tree exists and user can access it
result = await db.execute(
select(Tree).where(Tree.id == tree_id)
)
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tree not found"
)
if not can_access_tree(current_user, tree):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this tree"
)
# Query shares
shares_result = await db.execute(
select(TreeShare)
.where(TreeShare.tree_id == tree_id)
.order_by(TreeShare.created_at.desc())
)
shares = shares_result.scalars().all()
# Build responses with share URLs
base_url = str(request.base_url).rstrip('/')
return [
TreeShareResponse(
id=share.id,
tree_id=share.tree_id,
share_token=share.share_token,
share_url=f"{base_url}/shared/{share.share_token}",
allow_forking=share.allow_forking,
created_by=share.created_by,
created_at=share.created_at,
expires_at=share.expires_at
)
for share in shares
]
@router.patch("/{tree_id}/visibility", response_model=TreeResponse)
async def update_tree_visibility(
tree_id: UUID,
visibility_data: TreeVisibilityUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_engineer_or_admin)]
):
"""Update tree visibility level.
Visibility levels:
- private: Only tree author can access
- team: Account members can access
- link: Anyone with a valid share token can access
- public: All authenticated users can access
"""
result = await db.execute(
select(Tree).where(Tree.id == tree_id)
)
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tree not found"
)
if not can_edit_tree(current_user, tree):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only edit your own trees"
)
# Update visibility
old_visibility = tree.visibility
tree.visibility = visibility_data.visibility
await log_audit(db, current_user.id, "tree.visibility.update", "tree", tree.id,
{"tree_name": tree.name, "old_visibility": old_visibility,
"new_visibility": visibility_data.visibility})
await db.commit()
# Reload with relationships
result = await db.execute(
select(Tree)
.options(
selectinload(Tree.category_rel),
selectinload(Tree.tags)
)
.where(Tree.id == tree_id)
)
tree = result.scalar_one()
return build_full_tree_response(tree)
# --- Tree Validation Endpoint ---
@router.post("/{tree_id}/can-publish", response_model=TreeValidationResponse)
async def check_tree_can_publish(
tree_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Check if a tree can be published (validation endpoint).
Returns validation status and any errors that would prevent publishing.
Useful for providing real-time feedback in the UI without attempting to publish.
"""
result = await db.execute(
select(Tree).where(Tree.id == tree_id)
)
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tree not found"
)
if not can_access_tree(current_user, tree):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this tree"
)
# Validate the tree
can_publish, validation_errors = can_publish_tree(
tree.tree_structure,
tree.name,
tree.description
)
return TreeValidationResponse(
can_publish=can_publish,
errors=[ValidationError(**error) for error in validation_errors]
)

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter
from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares
from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared
api_router = APIRouter()
@@ -16,3 +16,4 @@ api_router.include_router(admin.router)
api_router.include_router(accounts.router)
api_router.include_router(webhooks.router)
api_router.include_router(shares.router)
api_router.include_router(shared.router) # Public endpoints (no auth)