New opt_in_out selection field (disabled/opt-in/opt-out) matching Steelhead's Configure OPT IN/OUT feature. Shown in both the form view and the tree editor side panel. Time tracking: form view now shows Created, Created By, Last Updated, Updated By fields. Tree editor side panel shows relative timestamps down to the second (e.g. "46w 3d 4h 17m 21s ago by Brett Kinzett"). 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', 'opt_in_out', '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)}
|