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"
|
||||
)
|
||||
|
||||
70
backend/app/api/endpoints/shared.py
Normal file
70
backend/app/api/endpoints/shared.py
Normal 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
|
||||
)
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
206
backend/app/core/session_to_tree.py
Normal file
206
backend/app/core/session_to_tree.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""Helper module to convert sessions into tree structures."""
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
|
||||
def convert_session_to_tree(
|
||||
session_path: list[str],
|
||||
tree_snapshot: dict[str, Any],
|
||||
custom_steps: list[dict[str, Any]],
|
||||
decisions: list[dict[str, Any]]
|
||||
) -> dict[str, Any]:
|
||||
"""Convert a session's path and custom steps into a linear tree structure.
|
||||
|
||||
Creates a linear decision tree that represents the path taken through the
|
||||
original tree, including any custom steps inserted during the session.
|
||||
|
||||
Args:
|
||||
session_path: List of node IDs representing the path taken
|
||||
tree_snapshot: Original tree structure (for node details)
|
||||
custom_steps: Custom steps inserted during session
|
||||
decisions: Decision records with answers and notes
|
||||
|
||||
Returns:
|
||||
Tree structure dict representing the linear path
|
||||
"""
|
||||
if not session_path:
|
||||
# Return minimal valid tree if no path taken
|
||||
return {
|
||||
"id": str(uuid.uuid4()),
|
||||
"type": "solution",
|
||||
"solution": "Session had no recorded path",
|
||||
"children": []
|
||||
}
|
||||
|
||||
# Build a map of custom steps by their ID
|
||||
custom_steps_map = {}
|
||||
for step in custom_steps:
|
||||
if "id" in step:
|
||||
custom_steps_map[step["id"]] = step
|
||||
|
||||
# Build a map of decisions by node_id for quick lookup
|
||||
decisions_map = {}
|
||||
for decision in decisions:
|
||||
if "node_id" in decision:
|
||||
decisions_map[decision["node_id"]] = decision
|
||||
|
||||
# Build the linear tree structure
|
||||
root_node = None
|
||||
current_node = None
|
||||
|
||||
for i, node_id in enumerate(session_path):
|
||||
# Check if this is a custom step
|
||||
if node_id in custom_steps_map:
|
||||
step = custom_steps_map[node_id]
|
||||
new_node = _create_node_from_custom_step(step, node_id)
|
||||
else:
|
||||
# Find node in original tree
|
||||
original_node = _find_node_in_tree(tree_snapshot, node_id)
|
||||
if original_node:
|
||||
new_node = _create_node_from_original(original_node, decisions_map.get(node_id))
|
||||
else:
|
||||
# Node not found, create a placeholder
|
||||
new_node = {
|
||||
"id": node_id,
|
||||
"type": "action",
|
||||
"action": f"Step from original tree (node {node_id})",
|
||||
"children": []
|
||||
}
|
||||
|
||||
# Add notes from decision if available
|
||||
decision = decisions_map.get(node_id)
|
||||
if decision and decision.get("notes"):
|
||||
new_node["notes"] = decision["notes"]
|
||||
|
||||
# Build the chain
|
||||
if root_node is None:
|
||||
root_node = new_node
|
||||
current_node = root_node
|
||||
else:
|
||||
current_node["children"] = [new_node]
|
||||
current_node = new_node
|
||||
|
||||
return root_node
|
||||
|
||||
|
||||
def _find_node_in_tree(tree: dict[str, Any], node_id: str) -> dict[str, Any] | None:
|
||||
"""Recursively find a node in the tree structure by ID.
|
||||
|
||||
Args:
|
||||
tree: Tree structure dict
|
||||
node_id: Node ID to find
|
||||
|
||||
Returns:
|
||||
Node dict if found, None otherwise
|
||||
"""
|
||||
if tree.get("id") == node_id:
|
||||
return tree
|
||||
|
||||
for child in tree.get("children", []):
|
||||
result = _find_node_in_tree(child, node_id)
|
||||
if result:
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _create_node_from_original(
|
||||
original_node: dict[str, Any],
|
||||
decision: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new node based on an original tree node.
|
||||
|
||||
Args:
|
||||
original_node: Original node from tree
|
||||
decision: Decision record for this node (optional)
|
||||
|
||||
Returns:
|
||||
New node dict for the linear tree
|
||||
"""
|
||||
node_type = original_node.get("type", "action")
|
||||
new_node = {
|
||||
"id": str(uuid.uuid4()), # Generate new ID for the saved tree
|
||||
"type": node_type,
|
||||
"children": []
|
||||
}
|
||||
|
||||
# Copy relevant content based on node type
|
||||
if node_type == "decision":
|
||||
new_node["question"] = original_node.get("question", "")
|
||||
if decision and decision.get("answer"):
|
||||
new_node["question"] += f"\n\nAnswer: {decision['answer']}"
|
||||
elif node_type == "action":
|
||||
new_node["action"] = original_node.get("action", "")
|
||||
if decision and decision.get("action_performed"):
|
||||
new_node["action"] = decision["action_performed"]
|
||||
elif node_type == "solution":
|
||||
new_node["solution"] = original_node.get("solution", "")
|
||||
|
||||
return new_node
|
||||
|
||||
|
||||
def _create_node_from_custom_step(
|
||||
custom_step: dict[str, Any],
|
||||
step_id: str
|
||||
) -> dict[str, Any]:
|
||||
"""Create a node from a custom step.
|
||||
|
||||
Args:
|
||||
custom_step: Custom step dict
|
||||
step_id: ID of the custom step
|
||||
|
||||
Returns:
|
||||
Node dict for the linear tree
|
||||
"""
|
||||
step_type = custom_step.get("type", "action")
|
||||
content = custom_step.get("content", "")
|
||||
|
||||
new_node = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"type": step_type,
|
||||
"children": []
|
||||
}
|
||||
|
||||
# Map content to appropriate field based on type
|
||||
if step_type == "decision":
|
||||
new_node["question"] = content
|
||||
elif step_type == "action":
|
||||
new_node["action"] = content
|
||||
elif step_type == "solution":
|
||||
new_node["solution"] = content
|
||||
|
||||
# Add notes if present
|
||||
if custom_step.get("notes"):
|
||||
if step_type == "decision":
|
||||
new_node["question"] += f"\n\nNotes: {custom_step['notes']}"
|
||||
elif step_type == "action":
|
||||
new_node["action"] += f"\n\nNotes: {custom_step['notes']}"
|
||||
elif step_type == "solution":
|
||||
new_node["solution"] += f"\n\nNotes: {custom_step['notes']}"
|
||||
|
||||
return new_node
|
||||
|
||||
|
||||
def generate_tree_name_from_session(
|
||||
original_tree_name: str,
|
||||
ticket_number: str | None = None,
|
||||
client_name: str | None = None
|
||||
) -> str:
|
||||
"""Generate a descriptive name for the saved tree.
|
||||
|
||||
Args:
|
||||
original_tree_name: Name of the original tree
|
||||
ticket_number: Optional ticket number
|
||||
client_name: Optional client name
|
||||
|
||||
Returns:
|
||||
Generated tree name
|
||||
"""
|
||||
parts = [original_tree_name, "Session"]
|
||||
|
||||
if ticket_number:
|
||||
parts.append(f"(Ticket {ticket_number})")
|
||||
if client_name:
|
||||
parts.append(f"- {client_name}")
|
||||
|
||||
return " ".join(parts)
|
||||
151
backend/app/core/tree_validation.py
Normal file
151
backend/app/core/tree_validation.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Tree validation helper module for draft/published workflow."""
|
||||
from typing import Any
|
||||
|
||||
|
||||
class TreeValidationError(Exception):
|
||||
"""Custom exception for tree validation errors."""
|
||||
def __init__(self, field: str, message: str):
|
||||
self.field = field
|
||||
self.message = message
|
||||
super().__init__(f"{field}: {message}")
|
||||
|
||||
|
||||
def validate_tree_structure(tree_structure: dict[str, Any]) -> tuple[bool, list[dict[str, str]]]:
|
||||
"""Validate tree structure for publishing.
|
||||
|
||||
A valid tree for publishing must have:
|
||||
- A root node with id, type, and appropriate content fields
|
||||
- All decision nodes must have a question field
|
||||
- All decision nodes with children must have at least 2 children
|
||||
- All action nodes must have an action field
|
||||
- All solution nodes must have a solution field
|
||||
- No orphaned nodes (all nodes reachable from root)
|
||||
|
||||
Args:
|
||||
tree_structure: The tree structure dict to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, list of errors)
|
||||
Each error is a dict with 'field' and 'message' keys
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Check root node exists
|
||||
if not tree_structure:
|
||||
errors.append({"field": "tree_structure", "message": "Tree structure cannot be empty"})
|
||||
return False, errors
|
||||
|
||||
if "id" not in tree_structure:
|
||||
errors.append({"field": "tree_structure.id", "message": "Root node must have an id"})
|
||||
|
||||
if "type" not in tree_structure:
|
||||
errors.append({"field": "tree_structure.type", "message": "Root node must have a type"})
|
||||
return False, errors
|
||||
|
||||
# Validate root node based on type
|
||||
_validate_node(tree_structure, "root", errors)
|
||||
|
||||
# Validate all child nodes recursively
|
||||
if "children" in tree_structure:
|
||||
_validate_children(tree_structure["children"], "root.children", errors)
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
|
||||
def _validate_node(node: dict[str, Any], path: str, errors: list[dict[str, str]]) -> None:
|
||||
"""Validate a single node in the tree structure.
|
||||
|
||||
Args:
|
||||
node: The node dict to validate
|
||||
path: Current path in the tree (for error messages)
|
||||
errors: List to append errors to
|
||||
"""
|
||||
node_type = node.get("type")
|
||||
|
||||
if node_type == "decision":
|
||||
if "question" not in node or not node["question"]:
|
||||
errors.append({
|
||||
"field": f"{path}.question",
|
||||
"message": "Decision nodes must have a non-empty question"
|
||||
})
|
||||
|
||||
# If node has children, must have at least 2 (for decision branches)
|
||||
children = node.get("children", [])
|
||||
if children and len(children) < 2:
|
||||
errors.append({
|
||||
"field": f"{path}.children",
|
||||
"message": "Decision nodes with children must have at least 2 branches"
|
||||
})
|
||||
|
||||
elif node_type == "action":
|
||||
if "action" not in node or not node["action"]:
|
||||
errors.append({
|
||||
"field": f"{path}.action",
|
||||
"message": "Action nodes must have a non-empty action"
|
||||
})
|
||||
|
||||
elif node_type == "solution":
|
||||
if "solution" not in node or not node["solution"]:
|
||||
errors.append({
|
||||
"field": f"{path}.solution",
|
||||
"message": "Solution nodes must have a non-empty solution"
|
||||
})
|
||||
|
||||
else:
|
||||
errors.append({
|
||||
"field": f"{path}.type",
|
||||
"message": f"Unknown node type: {node_type}"
|
||||
})
|
||||
|
||||
|
||||
def _validate_children(children: list[dict[str, Any]], path: str, errors: list[dict[str, str]]) -> None:
|
||||
"""Recursively validate child nodes.
|
||||
|
||||
Args:
|
||||
children: List of child nodes
|
||||
path: Current path in the tree (for error messages)
|
||||
errors: List to append errors to
|
||||
"""
|
||||
for i, child in enumerate(children):
|
||||
child_path = f"{path}[{i}]"
|
||||
|
||||
if "id" not in child:
|
||||
errors.append({"field": f"{child_path}.id", "message": "Child node must have an id"})
|
||||
|
||||
if "type" not in child:
|
||||
errors.append({"field": f"{child_path}.type", "message": "Child node must have a type"})
|
||||
continue
|
||||
|
||||
_validate_node(child, child_path, errors)
|
||||
|
||||
# Recursively validate grandchildren
|
||||
if "children" in child:
|
||||
_validate_children(child["children"], f"{child_path}.children", errors)
|
||||
|
||||
|
||||
def can_publish_tree(tree_structure: dict[str, Any], name: str, description: str | None = None) -> tuple[bool, list[dict[str, str]]]:
|
||||
"""Check if a tree can be published.
|
||||
|
||||
Validates:
|
||||
- Tree has a name (non-empty)
|
||||
- Tree structure is valid
|
||||
|
||||
Args:
|
||||
tree_structure: The tree structure to validate
|
||||
name: The tree name
|
||||
description: Optional tree description
|
||||
|
||||
Returns:
|
||||
Tuple of (can_publish, list of errors)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Validate name
|
||||
if not name or not name.strip():
|
||||
errors.append({"field": "name", "message": "Tree must have a name to be published"})
|
||||
|
||||
# Validate tree structure
|
||||
structure_valid, structure_errors = validate_tree_structure(tree_structure)
|
||||
errors.extend(structure_errors)
|
||||
|
||||
return len(errors) == 0, errors
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Any, TYPE_CHECKING
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, Index
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, Index, CheckConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from app.core.database import Base
|
||||
@@ -14,10 +14,21 @@ if TYPE_CHECKING:
|
||||
from app.models.category import TreeCategory
|
||||
from app.models.tag import TreeTag
|
||||
from app.models.folder import UserFolder
|
||||
from app.models.tree_share import TreeShare
|
||||
|
||||
|
||||
class Tree(Base):
|
||||
__tablename__ = "trees"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"visibility IN ('private', 'team', 'link', 'public')",
|
||||
name='ck_trees_visibility'
|
||||
),
|
||||
CheckConstraint(
|
||||
"status IN ('draft', 'published')",
|
||||
name='ck_trees_status'
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
@@ -57,6 +68,20 @@ class Tree(Base):
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
is_public: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
visibility: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default='team',
|
||||
index=True,
|
||||
comment="Visibility level: private (author only), team (account members), link (share token), public (all users)"
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default='published',
|
||||
index=True,
|
||||
comment="Status: draft (work in progress) or published (validated and available)"
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
@@ -136,6 +161,11 @@ class Tree(Base):
|
||||
foreign_keys=[root_tree_id]
|
||||
)
|
||||
sessions: Mapped[list["Session"]] = relationship("Session", back_populates="tree")
|
||||
shares: Mapped[list["TreeShare"]] = relationship(
|
||||
"TreeShare",
|
||||
back_populates="tree",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
# New organization relationships
|
||||
category_rel: Mapped[Optional["TreeCategory"]] = relationship("TreeCategory", back_populates="trees")
|
||||
|
||||
60
backend/app/models/tree_share.py
Normal file
60
backend/app/models/tree_share.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tree import Tree
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class TreeShare(Base):
|
||||
__tablename__ = "tree_shares"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4
|
||||
)
|
||||
tree_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("trees.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
share_token: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="URL-safe random token (48 bytes -> 64 base64 chars)"
|
||||
)
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
allow_forking: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=True,
|
||||
comment="Whether recipients can fork this tree"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
expires_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="Optional expiration for time-limited shares"
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tree: Mapped["Tree"] = relationship("Tree", back_populates="shares")
|
||||
creator: Mapped["User"] = relationship("User", foreign_keys=[created_by])
|
||||
@@ -77,3 +77,17 @@ class SessionExport(BaseModel):
|
||||
|
||||
class ScratchpadUpdate(BaseModel):
|
||||
scratchpad: str
|
||||
|
||||
|
||||
class SaveAsTreeRequest(BaseModel):
|
||||
"""Request to save a session as a tree."""
|
||||
tree_name: Optional[str] = Field(None, max_length=255, description="Custom name for the saved tree (auto-generated if not provided)")
|
||||
description: Optional[str] = Field(None, description="Description for the saved tree")
|
||||
status: Literal["draft", "published"] = Field("draft", description="Status of the saved tree")
|
||||
|
||||
|
||||
class SaveAsTreeResponse(BaseModel):
|
||||
"""Response after saving a session as a tree."""
|
||||
tree_id: UUID
|
||||
tree_name: str
|
||||
message: str
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
from typing import Optional, Any, Literal
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -25,6 +25,7 @@ class TreeCreate(TreeBase):
|
||||
tree_structure: dict[str, Any] = Field(..., description="The decision tree structure in JSON format")
|
||||
is_public: bool = Field(False, description="Make tree visible to all users")
|
||||
is_default: bool = Field(False, description="Mark as a default/system tree (admin only)")
|
||||
status: Literal['draft', 'published'] = Field('published', description="Status: draft or published")
|
||||
category_id: Optional[UUID] = Field(None, description="Category ID from tree_categories table")
|
||||
tags: Optional[list[str]] = Field(None, max_length=10, description="List of tag names to assign")
|
||||
|
||||
@@ -37,6 +38,7 @@ class TreeUpdate(BaseModel):
|
||||
tree_structure: Optional[dict[str, Any]] = None
|
||||
is_public: Optional[bool] = None
|
||||
is_active: Optional[bool] = None
|
||||
status: Optional[Literal['draft', 'published']] = None
|
||||
tags: Optional[list[str]] = Field(None, max_length=10, description="List of tag names to assign (replaces existing)")
|
||||
|
||||
|
||||
@@ -70,6 +72,7 @@ class TreeResponse(TreeBase):
|
||||
is_active: bool
|
||||
is_public: bool
|
||||
is_default: bool
|
||||
status: str # draft or published
|
||||
version: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -92,6 +95,7 @@ class TreeListResponse(BaseModel):
|
||||
is_active: bool
|
||||
is_public: bool
|
||||
is_default: bool
|
||||
status: str # draft or published
|
||||
version: int
|
||||
usage_count: int
|
||||
created_at: datetime
|
||||
@@ -99,3 +103,60 @@ class TreeListResponse(BaseModel):
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# --- Tree Sharing Schemas ---
|
||||
|
||||
class TreeShareCreate(BaseModel):
|
||||
"""Request to create a share token for a tree."""
|
||||
allow_forking: bool = Field(True, description="Whether recipients can fork this tree")
|
||||
expires_at: Optional[datetime] = Field(None, description="Optional expiration time for the share")
|
||||
|
||||
|
||||
class TreeShareResponse(BaseModel):
|
||||
"""Response containing share token and URL."""
|
||||
id: UUID
|
||||
tree_id: UUID
|
||||
share_token: str
|
||||
share_url: str
|
||||
allow_forking: bool
|
||||
created_by: UUID
|
||||
created_at: datetime
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TreeVisibilityUpdate(BaseModel):
|
||||
"""Request to update tree visibility."""
|
||||
visibility: Literal['private', 'team', 'link', 'public'] = Field(..., description="Visibility level")
|
||||
|
||||
|
||||
class SharedTreeResponse(TreeBase):
|
||||
"""Public response for shared trees (minimal info)."""
|
||||
id: UUID
|
||||
tree_structure: dict[str, Any]
|
||||
category: Optional[str] = None
|
||||
tags: list[str] = []
|
||||
version: int
|
||||
allow_forking: bool # From share token
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# --- Tree Validation Schemas ---
|
||||
|
||||
class ValidationError(BaseModel):
|
||||
"""Individual validation error."""
|
||||
field: str
|
||||
message: str
|
||||
|
||||
|
||||
class TreeValidationResponse(BaseModel):
|
||||
"""Response for tree validation endpoint."""
|
||||
can_publish: bool
|
||||
errors: list[ValidationError] = []
|
||||
|
||||
Reference in New Issue
Block a user