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>
This commit is contained in:
5
fusion-plating/fusion_plating/controllers/__init__.py
Normal file
5
fusion-plating/fusion_plating/controllers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from . import recipe_controller
|
||||
188
fusion-plating/fusion_plating/controllers/recipe_controller.py
Normal file
188
fusion-plating/fusion_plating/controllers/recipe_controller.py
Normal file
@@ -0,0 +1,188 @@
|
||||
# -*- 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)}
|
||||
Reference in New Issue
Block a user