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>
189 lines
7.7 KiB
Python
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)}
|