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