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