feat(simple-editor): node_type bug fix + inline library authoring + back nav

Bucket 1 — Generation bug fix
- post-migrate.py for 19.0.18.8.0 promotes flat 'step' children of
  recipes to 'operation' so fp.job._generate_steps() picks them up.
  Filter is narrow: only direct children of node_type='recipe' get
  flipped, tree-editor sub-steps (parent.node_type='operation') are
  untouched. Idempotent. Posts an audit chatter note on each affected
  recipe.
- Simple Editor controller hardcodes node_type='operation' on insert
  + snapshot-import path so future recipes start correct.

Bucket 2 — Inline library authoring
- 6 new JSONRPC routes (/fp/simple_recipe/library/load + save +
  seed_defaults + input/{add,write,remove}, /fp/simple_recipe/tank/list).
- + New Step button in the right pane opens an inline form with name /
  kind / icon / instructions / stations / flags / prompts table.
- Pencil icon on each library row reopens the same form prefilled.
- Step Kind picker leads with 'Generic — no automatic behaviour'.
- 'Seed defaults from kind' calls action_seed_default_inputs server-side
  for kinds that have curated default prompts.

Bucket 3 — Back nav
- '← Recipes' button in the header (or '← Part' when opened from
  Process Composer) mirrors recipe_tree_editor.js, with
  clearBreadcrumbs:true to avoid stack pollution.

Verified on entech: LGPS1104's 19 'step' children now show as
'operation', migration chatter note posted on the recipe, asset cache
busted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-30 16:16:14 -04:00
parent b8d064b180
commit 4213c44e51
6 changed files with 954 additions and 23 deletions

View File

@@ -167,6 +167,156 @@ class SimpleRecipeController(http.Controller):
tpl.unlink()
return {'ok': True, 'soft_deleted': False}
# ---- Inline authoring (Simple Editor right-pane "+ New Step" / pencil) ----
#
# Endpoints below let a shop foreman create or edit a library template
# (with prompts) without leaving the Simple Editor. Manager-grade
# features (transition form, advanced time/temp targets, common-audit
# seeding) still live on the dedicated form view.
@http.route('/fp/simple_recipe/library/load', type='jsonrpc', auth='user')
def library_load(self, template_id):
"""Return the full payload for one library template."""
tpl = request.env['fp.step.template'].browse(int(template_id))
if not tpl.exists():
return {'ok': False, 'error': 'not_found'}
tpl.check_access('read')
return {'ok': True, 'template': self._library_payload(tpl)}
def _library_payload(self, tpl):
return {
'id': tpl.id,
'name': tpl.name or '',
'code': tpl.code or '',
'icon': tpl.icon or 'fa-cog',
'default_kind': tpl.default_kind or '',
'description': tpl.description or '',
'requires_signoff': tpl.requires_signoff,
'requires_predecessor_done': tpl.requires_predecessor_done,
'requires_rack_assignment': tpl.requires_rack_assignment,
'requires_transition_form': tpl.requires_transition_form,
'tank_ids': [
{'id': t.id, 'name': t.name, 'code': t.code or ''}
for t in tpl.tank_ids
],
'inputs': [
{
'id': i.id,
'name': i.name or '',
'input_type': i.input_type or 'text',
'target_min': i.target_min or 0.0,
'target_max': i.target_max or 0.0,
'target_unit': i.target_unit or '',
'required': bool(i.required),
'sequence': i.sequence or 0,
'hint': i.hint or '',
'selection_options': i.selection_options or '',
}
for i in tpl.input_template_ids.sorted('sequence')
],
}
@http.route('/fp/simple_recipe/library/save', type='jsonrpc', auth='user')
def library_save(self, template_id, vals):
"""Upsert: create when template_id is falsy, write otherwise.
Returns the full template payload so the OWL component can
refresh in one round-trip.
"""
Tpl = request.env['fp.step.template']
# Whitelist — never trust client-provided write_uid / id / etc.
allowed = {
'name', 'code', 'icon', 'default_kind', 'description',
'requires_signoff', 'requires_predecessor_done',
'requires_rack_assignment', 'requires_transition_form',
'tank_ids',
}
clean = {k: v for k, v in (vals or {}).items() if k in allowed}
# tank_ids comes in as a plain list of ids from the OWL form;
# translate into the Odoo (6, 0, ids) command form.
if 'tank_ids' in clean:
clean['tank_ids'] = [(6, 0, [int(x) for x in clean['tank_ids']])]
if template_id:
tpl = Tpl.browse(int(template_id))
tpl.check_access('write')
tpl.write(clean)
else:
tpl = Tpl.create(clean)
return {'ok': True, 'template': self._library_payload(tpl)}
@http.route('/fp/simple_recipe/library/seed_defaults', type='jsonrpc', auth='user')
def library_seed_defaults(self, template_id):
"""Run action_seed_default_inputs on this template. Idempotent —
only adds prompts whose name doesn't already exist.
"""
tpl = request.env['fp.step.template'].browse(int(template_id))
if not tpl.exists():
return {'ok': False, 'error': 'not_found'}
tpl.check_access('write')
if not tpl.default_kind:
return {'ok': False, 'error': 'no_kind',
'message': 'Pick a Step Kind first to seed defaults.'}
tpl.action_seed_default_inputs()
return {'ok': True, 'template': self._library_payload(tpl)}
@http.route('/fp/simple_recipe/library/input/add', type='jsonrpc', auth='user')
def library_input_add(self, template_id, payload):
tpl = request.env['fp.step.template'].browse(int(template_id))
tpl.check_access('write')
Input = request.env['fp.step.template.input']
existing_max = max(tpl.input_template_ids.mapped('sequence') or [0])
rec = Input.create({
'template_id': tpl.id,
'name': (payload or {}).get('name') or 'New Prompt',
'input_type': (payload or {}).get('input_type') or 'text',
'sequence': existing_max + 10,
'required': bool((payload or {}).get('required')),
})
return {'ok': True, 'input_id': rec.id,
'template': self._library_payload(tpl)}
@http.route('/fp/simple_recipe/library/input/write', type='jsonrpc', auth='user')
def library_input_write(self, input_id, payload):
Input = request.env['fp.step.template.input']
rec = Input.browse(int(input_id))
if not rec.exists():
return {'ok': False, 'error': 'not_found'}
rec.template_id.check_access('write')
allowed = {
'name', 'input_type', 'target_min', 'target_max', 'target_unit',
'required', 'sequence', 'selection_options', 'hint',
}
clean = {k: v for k, v in (payload or {}).items() if k in allowed}
if clean:
rec.write(clean)
return {'ok': True, 'template': self._library_payload(rec.template_id)}
@http.route('/fp/simple_recipe/library/input/remove', type='jsonrpc', auth='user')
def library_input_remove(self, input_id):
Input = request.env['fp.step.template.input']
rec = Input.browse(int(input_id))
if not rec.exists():
return {'ok': False, 'error': 'not_found'}
tpl = rec.template_id
tpl.check_access('write')
rec.unlink()
return {'ok': True, 'template': self._library_payload(tpl)}
@http.route('/fp/simple_recipe/tank/list', type='jsonrpc', auth='user')
def tank_list(self, query=''):
"""Tank picker for the inline library form. Returns active tanks
scoped to the current company.
"""
Tank = request.env['fusion.plating.tank']
domain = [('active', '=', True)]
if query:
domain += ['|', ('name', 'ilike', query), ('code', 'ilike', query)]
return {
'tanks': [
{'id': t.id, 'name': t.name, 'code': t.code or ''}
for t in Tank.search(domain, limit=50)
],
}
# ------------------------------------------------------------------ step
@http.route('/fp/simple_recipe/step/insert', type='jsonrpc', auth='user')
def step_insert(self, recipe_id, template_id=False, position=99, vals=None):
@@ -175,7 +325,10 @@ class SimpleRecipeController(http.Controller):
new_vals = {
'parent_id': recipe.id,
'node_type': 'step',
# Must be 'operation' — fp.job._generate_steps() only creates
# fp.job.step rows for operation nodes. Flat 'step' children
# of a recipe were silently skipped pre-19.0.18.8.0.
'node_type': 'operation',
'sequence': target_seq,
}
tpl = False
@@ -291,7 +444,8 @@ class SimpleRecipeController(http.Controller):
Node = request.env['fusion.plating.process.node']
new_vals = {
'parent_id': target_recipe.id,
'node_type': 'step',
# See _SNAPSHOT_FIELDS comment — operation, not step.
'node_type': 'operation',
'sequence': src_node.sequence,
'source_template_id': src_node.source_template_id.id or False,
}