feat: add tree forking, custom step tracking, and session sharing

Implement three foundational schema features from the design doc:

- Tree forking with lineage tracking (migration 022): parent_tree_id,
  root_tree_id, fork_depth columns with self-referential FKs and
  composite analytics index
- Custom step enhancement: CustomStepSchema with source tracking
  (ad-hoc, step-library, forked-tree) for backward-compatible JSONB
- Session sharing (migration 023): session_shares and session_share_views
  tables with account-scoped visibility, cryptographic tokens, view
  tracking, and allow_public_shares account policy

Includes 21 new integration tests (9 forking, 12 sharing), SaaS
consultant-recommended denormalizations, rate limiting on public share
access, and test fixture fix for invite code requirement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-02-07 19:10:47 -05:00
parent c8e7aaad1a
commit ffb14cd014
16 changed files with 1345 additions and 8 deletions

View File

@@ -12,7 +12,7 @@ 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
from app.schemas.tree import TreeCreate, TreeUpdate, TreeResponse, TreeListResponse, CategoryInfo, ForkCreate, ForkInfo
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
@@ -73,8 +73,8 @@ def build_tree_response(tree: Tree) -> TreeListResponse:
)
def build_full_tree_response(tree: Tree) -> TreeResponse:
"""Build TreeResponse with all details including category_info and tags."""
def build_full_tree_response(tree: Tree, parent_tree: Tree = None) -> TreeResponse:
"""Build TreeResponse with all details including category_info, tags, and fork_info."""
category_info = None
if tree.category_rel:
category_info = CategoryInfo(
@@ -83,6 +83,20 @@ def build_full_tree_response(tree: Tree) -> TreeResponse:
slug=tree.category_rel.slug
)
fork_info = None
if tree.parent_tree_id or tree.fork_depth > 0:
has_updates = False
if parent_tree and tree.parent_updated_at:
has_updates = parent_tree.updated_at > tree.parent_updated_at
fork_info = ForkInfo(
parent_tree_id=tree.parent_tree_id,
root_tree_id=tree.root_tree_id,
fork_reason=tree.fork_reason,
fork_depth=tree.fork_depth,
parent_updated_at=tree.parent_updated_at,
has_parent_updates=has_updates
)
return TreeResponse(
id=tree.id,
name=tree.name,
@@ -91,6 +105,7 @@ def build_full_tree_response(tree: Tree) -> TreeResponse:
category_id=tree.category_id,
category_info=category_info,
tags=tree.tag_names,
fork_info=fork_info,
tree_structure=tree.tree_structure,
author_id=tree.author_id,
account_id=tree.account_id,
@@ -561,3 +576,166 @@ async def delete_tree(
{"tree_name": tree.name})
await db.commit()
return None
# --- Fork Endpoints ---
@router.post("/{tree_id}/fork", response_model=TreeResponse, status_code=status.HTTP_201_CREATED)
async def fork_tree(
tree_id: UUID,
fork_data: ForkCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_engineer_or_admin)]
):
"""Fork a tree to create a personal copy.
Engineers can fork any tree they can access (public, account, or default).
Fork inherits tree_structure but gets new ownership.
"""
# Load parent tree
result = await db.execute(
select(Tree)
.options(selectinload(Tree.category_rel), selectinload(Tree.tags))
.where(Tree.id == tree_id)
)
parent = result.scalar_one_or_none()
if not parent:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tree not found"
)
if not can_access_tree(current_user, parent):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this tree"
)
# 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."
)
# Build fork
fork_name = fork_data.name or f"Fork of {parent.name}"
fork = Tree(
name=fork_name,
description=parent.description,
category=parent.category,
category_id=parent.category_id,
tree_structure=parent.tree_structure,
author_id=current_user.id,
account_id=current_user.account_id,
is_public=False,
is_default=False,
version=1,
# Fork tracking
parent_tree_id=parent.id,
fork_reason=fork_data.fork_reason,
parent_updated_at=parent.updated_at,
# Lineage tracking
root_tree_id=parent.root_tree_id if parent.root_tree_id else parent.id,
fork_depth=parent.fork_depth + 1,
)
db.add(fork)
await db.flush()
await log_audit(db, current_user.id, "tree.fork", "tree", fork.id,
{"parent_tree_id": str(parent.id), "parent_name": parent.name,
"fork_reason": fork_data.fork_reason})
await db.commit()
# Reload with relationships
result = await db.execute(
select(Tree)
.options(selectinload(Tree.category_rel), selectinload(Tree.tags))
.where(Tree.id == fork.id)
)
fork = result.scalar_one()
return build_full_tree_response(fork, parent_tree=parent)
@router.get("/{tree_id}/forks", response_model=list[TreeListResponse])
async def list_forks(
tree_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100)
):
"""List all direct forks of a tree."""
# Verify parent exists and user can access it
parent_result = await db.execute(select(Tree).where(Tree.id == tree_id))
parent = parent_result.scalar_one_or_none()
if not parent:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tree not found"
)
if not can_access_tree(current_user, parent):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this tree"
)
# Query direct forks, filtered by access
query = select(Tree).options(
selectinload(Tree.category_rel),
selectinload(Tree.tags)
).where(
Tree.parent_tree_id == tree_id,
Tree.is_active == True,
build_tree_access_filter(current_user)
).order_by(Tree.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
forks = result.scalars().unique().all()
return [build_tree_response(tree) for tree in forks]
@router.get("/{tree_id}/lineage", response_model=list[TreeListResponse])
async def get_tree_lineage(
tree_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Get the fork lineage chain from current tree back to root.
Returns ordered list: [current tree, parent, grandparent, ..., root]
Limited to 10 levels to prevent infinite loops.
"""
lineage = []
current_id = tree_id
visited = set()
max_depth = 10
for _ in range(max_depth):
if current_id is None or current_id in visited:
break
visited.add(current_id)
result = await db.execute(
select(Tree)
.options(selectinload(Tree.category_rel), selectinload(Tree.tags))
.where(Tree.id == current_id)
)
tree = result.scalar_one_or_none()
if not tree:
break
lineage.append(build_tree_response(tree))
current_id = tree.parent_tree_id
return lineage