feat: add procedural flows with intake forms, navigation, and seed templates
Adds a new "procedural" tree type for linear step-by-step project workflows (domain controller setup, M365 onboarding, VPN config, etc). Includes intake form builder, two-panel step navigation, variable resolution, procedural exports, 3 seed templates, and UI rename from "Trees" to "Flows". Also archives 19 implemented plan docs and creates deferred features backlog. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -136,13 +136,27 @@ async def start_session(
|
||||
detail="You don't have access to this tree"
|
||||
)
|
||||
|
||||
# For procedural trees with intake forms, validate required fields
|
||||
session_variables = session_data.session_variables or {}
|
||||
if tree.tree_type == 'procedural' and tree.intake_form:
|
||||
missing_fields = []
|
||||
for field in tree.intake_form:
|
||||
if field.get("required") and not session_variables.get(field["variable_name"]):
|
||||
missing_fields.append(field["label"])
|
||||
if missing_fields:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Missing required intake form fields: {', '.join(missing_fields)}"
|
||||
)
|
||||
|
||||
# Create session with tree snapshot (includes tree metadata for filtering/export)
|
||||
tree_snapshot = {
|
||||
**tree.tree_structure,
|
||||
"name": tree.name,
|
||||
"description": tree.description,
|
||||
"category": tree.category,
|
||||
"version": tree.version
|
||||
"version": tree.version,
|
||||
"tree_type": tree.tree_type,
|
||||
}
|
||||
|
||||
new_session = Session(
|
||||
@@ -152,7 +166,8 @@ async def start_session(
|
||||
path_taken=[],
|
||||
decisions=[],
|
||||
ticket_number=session_data.ticket_number,
|
||||
client_name=session_data.client_name
|
||||
client_name=session_data.client_name,
|
||||
session_variables=session_variables,
|
||||
)
|
||||
|
||||
# Increment tree usage count
|
||||
@@ -421,7 +436,9 @@ async def save_session_as_tree(
|
||||
can_publish, validation_errors = can_publish_tree(
|
||||
tree_structure,
|
||||
tree_name,
|
||||
request_data.description
|
||||
request_data.description,
|
||||
tree_type=original_tree.tree_type,
|
||||
intake_form=original_tree.intake_form,
|
||||
)
|
||||
if not can_publish:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -44,6 +44,7 @@ def build_tree_response(tree: Tree) -> TreeListResponse:
|
||||
id=tree.id,
|
||||
name=tree.name,
|
||||
description=tree.description,
|
||||
tree_type=tree.tree_type,
|
||||
category=tree.category,
|
||||
category_id=tree.category_id,
|
||||
category_info=category_info,
|
||||
@@ -89,12 +90,14 @@ def build_full_tree_response(tree: Tree, parent_tree: Tree = None) -> TreeRespon
|
||||
id=tree.id,
|
||||
name=tree.name,
|
||||
description=tree.description,
|
||||
tree_type=tree.tree_type,
|
||||
category=tree.category,
|
||||
category_id=tree.category_id,
|
||||
category_info=category_info,
|
||||
tags=tree.tag_names,
|
||||
fork_info=fork_info,
|
||||
tree_structure=tree.tree_structure,
|
||||
intake_form=tree.intake_form,
|
||||
author_id=tree.author_id,
|
||||
account_id=tree.account_id,
|
||||
is_active=tree.is_active,
|
||||
@@ -112,6 +115,7 @@ def build_full_tree_response(tree: Tree, parent_tree: Tree = None) -> TreeRespon
|
||||
async def list_trees(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
tree_type: Optional[str] = Query(None, description="Filter by tree type: troubleshooting or procedural"),
|
||||
category: Optional[str] = Query(None, description="Filter by legacy category string"),
|
||||
category_id: Optional[UUID] = Query(None, description="Filter by category ID"),
|
||||
tags: Optional[str] = Query(None, description="Comma-separated tag slugs to filter by"),
|
||||
@@ -137,6 +141,8 @@ async def list_trees(
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if tree_type:
|
||||
query = query.where(Tree.tree_type == tree_type)
|
||||
if category:
|
||||
query = query.where(Tree.category == category)
|
||||
if category_id:
|
||||
@@ -297,10 +303,16 @@ async def create_tree(
|
||||
"""
|
||||
# Validate tree if status is 'published'
|
||||
if tree_data.status == 'published':
|
||||
# Convert intake_form to dicts for validation
|
||||
intake_form_dicts = None
|
||||
if tree_data.intake_form:
|
||||
intake_form_dicts = [f.model_dump() for f in tree_data.intake_form]
|
||||
can_publish, validation_errors = can_publish_tree(
|
||||
tree_data.tree_structure,
|
||||
tree_data.name,
|
||||
tree_data.description
|
||||
tree_data.description,
|
||||
tree_type=tree_data.tree_type,
|
||||
intake_form=intake_form_dicts,
|
||||
)
|
||||
if not can_publish:
|
||||
raise HTTPException(
|
||||
@@ -332,12 +344,19 @@ async def create_tree(
|
||||
detail="You don't have access to this category"
|
||||
)
|
||||
|
||||
# Convert intake_form Pydantic models to dicts for JSONB storage
|
||||
intake_form_data = None
|
||||
if tree_data.intake_form:
|
||||
intake_form_data = [f.model_dump(exclude_none=True) for f in tree_data.intake_form]
|
||||
|
||||
new_tree = Tree(
|
||||
name=tree_data.name,
|
||||
description=tree_data.description,
|
||||
category=tree_data.category,
|
||||
category_id=tree_data.category_id,
|
||||
tree_type=tree_data.tree_type,
|
||||
tree_structure=tree_data.tree_structure,
|
||||
intake_form=intake_form_data,
|
||||
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
|
||||
@@ -463,11 +482,15 @@ async def update_tree(
|
||||
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)
|
||||
final_tree_type = update_data.get("tree_type", tree.tree_type)
|
||||
final_intake_form = update_data.get("intake_form", tree.intake_form)
|
||||
|
||||
can_publish, validation_errors = can_publish_tree(
|
||||
final_tree_structure,
|
||||
final_name,
|
||||
final_description
|
||||
final_description,
|
||||
tree_type=final_tree_type,
|
||||
intake_form=final_intake_form,
|
||||
)
|
||||
if not can_publish:
|
||||
raise HTTPException(
|
||||
@@ -673,7 +696,9 @@ async def fork_tree(
|
||||
description=parent.description,
|
||||
category=parent.category,
|
||||
category_id=parent.category_id,
|
||||
tree_type=parent.tree_type,
|
||||
tree_structure=parent.tree_structure,
|
||||
intake_form=parent.intake_form,
|
||||
author_id=current_user.id,
|
||||
account_id=current_user.account_id,
|
||||
is_public=False,
|
||||
@@ -996,7 +1021,9 @@ async def check_tree_can_publish(
|
||||
can_publish, validation_errors = can_publish_tree(
|
||||
tree.tree_structure,
|
||||
tree.name,
|
||||
tree.description
|
||||
tree.description,
|
||||
tree_type=tree.tree_type,
|
||||
intake_form=tree.intake_form,
|
||||
)
|
||||
|
||||
return TreeValidationResponse(
|
||||
|
||||
Reference in New Issue
Block a user