Files
Odoo-Modules/fusion_plating/fusion_plating/controllers/recipe_controller.py
gsinghpal 03f41422de fix(plating): tree editor — title wrapping + import hierarchy
Two bugs reported on the tree editor after the move/import feature
shipped:

1. Card titles truncated to "Contrac…" because .o_fp_re_title had
   white-space: nowrap + text-overflow: ellipsis. Swapped to
   white-space: normal + overflow-wrap: anywhere so long names
   wrap onto multiple lines inside the card. Widened card max-
   width 380→460px and bumped min-width 240→260px so wrapped
   titles have room.

2. Import-children was flattening the tree — all operations AND
   their step children landed at the top level instead of staying
   nested under their operations.

   Root cause: src_node.copy({'parent_id': new_parent.id, ...})
   on a _parent_store model behaved unpredictably — in some runs
   the override in copy_vals didn't stick and child recursion
   ended up with a wrong parent_id. Rewrote _copy_subtree to use
   copy_data() + Node.create() so parent_id is set explicitly and
   child_ids / parent_path are stripped (we recurse ourselves).

   Smoke verified on entech: General Processing (1 root + 5 ops
   + 7 steps = 13 nodes) imports with hierarchy bit-identical to
   source.

fusion_plating → 19.0.7.1.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 08:08:55 -04:00

327 lines
14 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)}
# ------------------------------------------------------------------
# List every recipe-root for the "Import from recipe" picker
# ------------------------------------------------------------------
@http.route('/fp/recipe/list', type='jsonrpc', auth='user')
def list_recipes(self, exclude_id=None):
"""Return all recipe-roots available for the import picker.
exclude_id: optional — skip this recipe (usually the currently-
open one, so the user can't import from themselves).
"""
Node = request.env['fusion.plating.process.node']
domain = [('node_type', '=', 'recipe')]
if exclude_id:
domain.append(('id', '!=', int(exclude_id)))
recipes = Node.search(domain, order='name')
return {
'ok': True,
'recipes': [
{'id': r.id, 'name': r.name or f'Recipe #{r.id}'}
for r in recipes
],
}
# ------------------------------------------------------------------
# Move sibling up / down — explicit button-driven reorder
# ------------------------------------------------------------------
@http.route('/fp/recipe/node/move_sibling', type='jsonrpc', auth='user')
def move_sibling(self, node_id, direction):
"""Swap this node's sequence with its immediate sibling.
direction: 'up' or 'down'. Safer than drag-and-drop for the
common "nudge one slot" case that the DnD flow can't do
reliably on long lists.
"""
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 not node.parent_id:
return {'ok': False, 'error': 'Cannot move a recipe root.'}
siblings = node.parent_id.child_ids.sorted('sequence')
idx = list(siblings.ids).index(node.id)
if direction == 'up':
if idx == 0:
return {'ok': True, 'no_move': True}
other = siblings[idx - 1]
elif direction == 'down':
if idx >= len(siblings) - 1:
return {'ok': True, 'no_move': True}
other = siblings[idx + 1]
else:
return {'ok': False, 'error': 'Invalid direction.'}
# Swap the two sequence values
a_seq, b_seq = node.sequence, other.sequence
if a_seq == b_seq:
# Sequences collided — renumber everyone cleanly, then swap
for i, s in enumerate(siblings, 1):
s.sequence = i * 10
a_seq, b_seq = node.sequence, other.sequence
node.sequence, other.sequence = b_seq, a_seq
return {'ok': True}
# ------------------------------------------------------------------
# Import children from another recipe into a target parent
# ------------------------------------------------------------------
@http.route('/fp/recipe/node/import_children', type='jsonrpc', auth='user')
def import_children(self, source_recipe_id, target_parent_id,
dedupe_by_name=True):
"""Copy every top-level child of `source_recipe_id` under
`target_parent_id`, preserving the sub-tree structure.
dedupe_by_name: when True, skip any immediate child whose name
already exists under target_parent_id. Useful for importing a
preset without clobbering custom tweaks already made.
Returns: {ok, imported_count, skipped_count}
"""
Node = request.env['fusion.plating.process.node']
source = Node.browse(int(source_recipe_id))
target = Node.browse(int(target_parent_id))
if not source.exists() or not target.exists():
return {'ok': False, 'error': 'Source or target not found.'}
if f'/{source.id}/' in (target.parent_path or ''):
return {'ok': False, 'error': 'Target is inside source — would loop.'}
existing_names = set()
if dedupe_by_name:
existing_names = {
(c.name or '').strip().lower()
for c in target.child_ids
}
imported = 0
skipped = 0
max_seq = max((c.sequence for c in target.child_ids), default=0)
def _copy_subtree(src_node, new_parent, base_seq):
"""Deep-copy src_node under new_parent, recursing children.
Uses copy_data() + manual create() rather than copy() so we
have full control over what's carried:
* strip child_ids (we recurse ourselves)
* strip parent_path (Odoo recomputes from parent_id)
* force parent_id + sequence to our target values
The previous copy()-based version sometimes produced a
flattened tree because copy() on a _parent_store model can
leave parent_id pointed at the original source when the
override in copy_vals collides with the field's copy= flag.
copy_data() returns a plain dict — safer.
"""
[vals] = src_node.copy_data()
vals.pop('child_ids', None)
vals.pop('parent_path', None)
vals['parent_id'] = new_parent.id
vals['sequence'] = base_seq
new_node = Node.create(vals)
for i, child in enumerate(src_node.child_ids.sorted('sequence'), 1):
_copy_subtree(child, new_node, i * 10)
return new_node
for i, child in enumerate(source.child_ids.sorted('sequence'), 1):
key = (child.name or '').strip().lower()
if dedupe_by_name and key and key in existing_names:
skipped += 1
continue
max_seq += 10
_copy_subtree(child, target, max_seq)
imported += 1
if key:
existing_names.add(key)
return {
'ok': True,
'imported_count': imported,
'skipped_count': skipped,
}
# ------------------------------------------------------------------
# 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)}