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>
152 lines
5.0 KiB
Python
152 lines
5.0 KiB
Python
"""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
|