Files
Odoo-Modules/fusion-plating/fusion_plating/controllers/recipe_controller.py
gsinghpal a7d224899a fusion_plating: add process recipe system with OWL tree editor (v19.0.2.0.0)
New model fusion.plating.process.node with _parent_store hierarchy for
defining reusable plating recipes. Node types: recipe, sub_process,
operation, step. Includes companion model for operator input definitions.

Full OWL tree editor (client action) with:
- Hierarchical tree with connector lines and type-coloured borders
- Click-to-edit side panel with save
- Add/delete child nodes inline
- Drag & drop reorder and reparent
- Theme-aware SCSS (light + dark mode)
- Demo data: Electroless Nickel Plating — Steel Line (30+ nodes)

Backend: 7 JSON-RPC endpoints for tree CRUD, reorder, move, duplicate.
Security: 3-tier ACL (operator read / supervisor write / manager full).
Menu: Process Recipes under Plating > Operations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:29:58 -04:00

189 lines
7.7 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
import logging
from odoo import http
from odoo.http import request
_logger = logging.getLogger(__name__)
class FpRecipeController(http.Controller):
"""JSON-RPC endpoints for the process recipe tree editor."""
# ------------------------------------------------------------------
# Read — full tree
# ------------------------------------------------------------------
@http.route('/fp/recipe/tree', type='jsonrpc', auth='user')
def get_tree(self, recipe_id):
"""Return the full nested tree for a recipe."""
Node = request.env['fusion.plating.process.node']
recipe = Node.browse(int(recipe_id))
if not recipe.exists():
return {'ok': False, 'error': f'Recipe {recipe_id} not found.'}
return {
'ok': True,
'recipe': {
'id': recipe.id,
'name': recipe.name,
'code': recipe.code or '',
'version': recipe.version,
'process_type': recipe.process_type_id.name if recipe.process_type_id else '',
},
'tree': recipe.get_tree_data(),
}
# ------------------------------------------------------------------
# Create node
# ------------------------------------------------------------------
@http.route('/fp/recipe/node/create', type='jsonrpc', auth='user')
def create_node(self, parent_id, name, node_type='operation', vals=None):
"""Create a new child node under parent_id."""
Node = request.env['fusion.plating.process.node']
parent = Node.browse(int(parent_id))
if not parent.exists():
return {'ok': False, 'error': 'Parent node not found.'}
# Determine next sequence
max_seq = max((c.sequence for c in parent.child_ids), default=0)
data = {
'name': name,
'node_type': node_type,
'parent_id': parent.id,
'sequence': max_seq + 10,
}
if vals:
data.update(vals)
try:
new_node = Node.create(data)
_logger.info('Recipe: created node %s (%s) under %s by uid %s',
new_node.id, name, parent.id, request.env.uid)
return {'ok': True, 'node_id': new_node.id}
except Exception as exc:
_logger.exception('Recipe create_node failed')
return {'ok': False, 'error': str(exc)}
# ------------------------------------------------------------------
# Update node
# ------------------------------------------------------------------
@http.route('/fp/recipe/node/write', type='jsonrpc', auth='user')
def write_node(self, node_id, vals):
"""Update fields on an existing node."""
Node = request.env['fusion.plating.process.node']
node = Node.browse(int(node_id))
if not node.exists():
return {'ok': False, 'error': 'Node not found.'}
# Filter to allowed fields only
allowed = {
'name', 'code', 'node_type', 'icon', 'color',
'process_type_id', 'work_center_id',
'description', 'notes',
'estimated_duration',
'auto_complete', 'customer_visible', 'is_manual',
'requires_signoff', 'sequence', 'version',
}
safe_vals = {k: v for k, v in vals.items() if k in allowed}
if not safe_vals:
return {'ok': False, 'error': 'No valid fields to update.'}
try:
node.write(safe_vals)
return {'ok': True}
except Exception as exc:
_logger.exception('Recipe write_node failed')
return {'ok': False, 'error': str(exc)}
# ------------------------------------------------------------------
# Delete node
# ------------------------------------------------------------------
@http.route('/fp/recipe/node/unlink', type='jsonrpc', auth='user')
def unlink_node(self, node_id):
"""Delete a node and all its children (cascade)."""
Node = request.env['fusion.plating.process.node']
node = Node.browse(int(node_id))
if not node.exists():
return {'ok': False, 'error': 'Node not found.'}
if node.node_type == 'recipe':
return {'ok': False, 'error': 'Cannot delete a recipe root from the tree editor. Use the list view.'}
try:
name = node.name
node.unlink()
_logger.info('Recipe: deleted node %s (%s) by uid %s',
node_id, name, request.env.uid)
return {'ok': True}
except Exception as exc:
_logger.exception('Recipe unlink_node failed')
return {'ok': False, 'error': str(exc)}
# ------------------------------------------------------------------
# Reorder siblings
# ------------------------------------------------------------------
@http.route('/fp/recipe/node/reorder', type='jsonrpc', auth='user')
def reorder_nodes(self, node_ids):
"""Bulk-update sequence for an ordered list of sibling node IDs."""
Node = request.env['fusion.plating.process.node']
try:
for idx, nid in enumerate(node_ids):
Node.browse(int(nid)).write({'sequence': (idx + 1) * 10})
return {'ok': True}
except Exception as exc:
_logger.exception('Recipe reorder failed')
return {'ok': False, 'error': str(exc)}
# ------------------------------------------------------------------
# Move node to new parent
# ------------------------------------------------------------------
@http.route('/fp/recipe/node/move', type='jsonrpc', auth='user')
def move_node(self, node_id, new_parent_id):
"""Move a node to a new parent (drag between sub-trees)."""
Node = request.env['fusion.plating.process.node']
node = Node.browse(int(node_id))
parent = Node.browse(int(new_parent_id))
if not node.exists() or not parent.exists():
return {'ok': False, 'error': 'Node or parent not found.'}
# Prevent moving a recipe root
if node.node_type == 'recipe':
return {'ok': False, 'error': 'Cannot move a recipe root.'}
# Prevent making a node its own descendant
if f'/{node.id}/' in (parent.parent_path or ''):
return {'ok': False, 'error': 'Cannot move a node under its own descendant.'}
try:
max_seq = max((c.sequence for c in parent.child_ids), default=0)
node.write({
'parent_id': parent.id,
'sequence': max_seq + 10,
})
return {'ok': True}
except Exception as exc:
_logger.exception('Recipe move_node failed')
return {'ok': False, 'error': str(exc)}
# ------------------------------------------------------------------
# Duplicate recipe
# ------------------------------------------------------------------
@http.route('/fp/recipe/duplicate', type='jsonrpc', auth='user')
def duplicate_recipe(self, recipe_id):
"""Deep-copy an entire recipe tree."""
Node = request.env['fusion.plating.process.node']
recipe = Node.browse(int(recipe_id))
if not recipe.exists():
return {'ok': False, 'error': 'Recipe not found.'}
if recipe.node_type != 'recipe':
return {'ok': False, 'error': 'Can only duplicate recipe roots.'}
try:
new_recipe = recipe.copy()
return {'ok': True, 'recipe_id': new_recipe.id}
except Exception as exc:
_logger.exception('Recipe duplicate failed')
return {'ok': False, 'error': str(exc)}