feat(plating): tree editor — move up/down buttons + import from recipe
Two gaps closed on the recipe tree editor based on user feedback: 1. Explicit Move Up / Move Down buttons on every non-recipe node row, driven by a new POST /fp/recipe/node/move_sibling endpoint. DnD already existed but couldn't reliably move a node above a sibling when the drop zone overlapped the node being dragged. The button-based flow sidesteps that entirely and makes "nudge one slot" a single click. 2. Inline "Import from recipe" toolbar that appears when the Import button is clicked in the header. User picks a source recipe from a dropdown (POST /fp/recipe/list, excludes the current one), toggles "Skip duplicate names", and clicks Import. POST /fp/recipe/node/import_children deep-copies every top-level child of the source under the current recipe, preserving the sub-tree structure. Dedupe is on by default so re-running the import on an already-populated recipe is a no-op; users who want to merge identical-named branches can untick the checkbox. Controller endpoints: - /fp/recipe/list (list recipe roots for the picker) - /fp/recipe/node/move_sibling (swap with neighbour by direction) - /fp/recipe/node/import_children (deep-copy subtree with dedupe) Smoke verified on entech: 5-child recipe imported cleanly, dedupe blocks re-import, sequence swap works. fusion_plating → 19.0.7.0.0 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -136,6 +136,133 @@ class FpRecipeController(http.Controller):
|
||||
_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."""
|
||||
copy_vals = {
|
||||
'parent_id': new_parent.id,
|
||||
'sequence': base_seq,
|
||||
}
|
||||
# copy() picks up every other field; we only override parent + seq.
|
||||
new_node = src_node.copy(copy_vals)
|
||||
# Recurse — child_ids of the copy are blank (Odoo doesn't
|
||||
# deep-copy one2many children by default for this model)
|
||||
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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user