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"
)