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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user