# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. """JSONRPC endpoints for the Simple Recipe Editor. All endpoints expect the user to be authenticated. Permissions are enforced by the underlying ACL on fp.step.template + process.node: operators get read; supervisors+ get write. """ from odoo import _, http from odoo.http import request # Field list copied from a library template into a new recipe step on # drag-drop. Snapshot semantics (Q4 from the design doc — editing a # library template later does NOT change recipes already built). _SNAPSHOT_FIELDS = [ 'name', 'code', 'description', 'icon', 'material_callout', 'time_min_target', 'time_max_target', 'time_unit', 'temp_min_target', 'temp_max_target', 'temp_unit', 'voltage_target', 'viscosity_target', 'requires_signoff', 'requires_predecessor_done', 'parallel_start', 'triggers_workflow_state_id', # Sub 14 — workflow milestone trigger 'requires_rack_assignment', 'requires_transition_form', 'kind_id', # Sub 14b — replaces default_kind (now a related Char) ] # Fields on fp.step.template.input that copy 1:1 into # fusion.plating.process.node.input on snapshot. _INPUT_SNAPSHOT_FIELDS = [ 'name', 'input_type', 'target_min', 'target_max', 'target_unit', 'required', 'hint', 'selection_options', 'sequence', ] def _copy_snapshot_fields(source, fields): """Copy ``fields`` from ``source`` record into a write-ready dict. Many2one values must be unwrapped to their integer id — passing a recordset to ``create`` triggers psycopg2 ``can't adapt type X`` because the SQL adapter doesn't know how to serialize a recordset. Scalar fields pass through untouched. """ out = {} for f in fields: field = source._fields[f] val = source[f] if field.type == 'many2one': out[f] = val.id if val else False else: out[f] = val return out class SimpleRecipeController(http.Controller): # ------------------------------------------------------------------ load @http.route('/fp/simple_recipe/load', type='jsonrpc', auth='user') def load(self, recipe_id): recipe = request.env['fusion.plating.process.node'].browse(recipe_id) recipe.check_access('read') # Tree-Editor-authored recipes carry FOUR node levels: # recipe → sub_process → operation → step # The Tree Editor shows all of them. The Simple Editor used to # only show direct children of the recipe — so for # ENP-STEEL-BASIC (1 sub_process + 16 operations + 26 step # nodes), authors saw 10 rows out of 43. Work-order generation # walked the full tree and emitted operations as fp.job.step # rows with step-nodes folded in as instruction text. # # We now walk the full tree depth-first and surface EVERY # operation and step node, in traversal order, each tagged # with: # - `nested_under`: chained sub-process path ("Steel Line", # "Steel Line › Cleaner", etc.) # - `node_type`: 'operation' or 'step' # - `is_substep`: True for `step` nodes (renders indented) # # The Simple Editor's drag/insert/reorder semantics still # treat operations as headline rows; substeps are read-only # by default in the UI but their fields can be edited via the # existing step_write endpoint (which doesn't care about # node_type). flat_nodes = self._flatten_recipe_nodes(recipe) return { 'recipe': self._recipe_payload(recipe), 'steps': [ dict(self._step_payload(node), nested_under=path, node_type=node.node_type, is_substep=(node.node_type == 'step')) for node, path in flat_nodes ], } def _flatten_recipe_operations(self, recipe): """Legacy helper — returns ONLY operations. Kept for back-compat with callers and tests that asked for the operations-only view. Most paths should now use ``_flatten_recipe_nodes`` which also surfaces step children. """ return [ (n, p) for n, p in self._flatten_recipe_nodes(recipe) if n.node_type == 'operation' ] def _flatten_recipe_nodes(self, recipe): """Walk the recipe DFS, return [(node, path_label)]. Surfaces both `operation` and `step` nodes. The traversal order matches what the Tree Editor displays: recipe → recurse → operation (emit) → its step children (emit) recipe → recurse → sub_process → recurse → operation → steps Step children are emitted IMMEDIATELY after their parent operation so the editor can render them as a contiguous block. """ out = [] def _walk(node, path): if node.node_type == 'operation': out.append((node, path)) # Emit step children right after the operation so the # editor sees: [Op, step, step, NextOp, step, ...]. # The path label for a substep names its parent # operation, chained from the sub-process if present. sub_path = ( f"{path} › {node.name}" if path else node.name ) for child in node.child_ids.sorted('sequence'): if child.node_type == 'step': out.append((child, sub_path)) return if node.node_type in ('recipe', 'sub_process'): sub_path = ( path if node.node_type == 'recipe' else (f"{path} › {node.name}" if path else node.name) ) for child in node.child_ids.sorted('sequence'): _walk(child, sub_path) # `step` nodes that are direct children of a recipe (rare, # legacy seed data) are silently dropped — _generate_steps # has always skipped them. _walk(recipe, '') return out def _recipe_payload(self, recipe): return { 'id': recipe.id, 'name': recipe.name, 'code': recipe.code, 'is_template': recipe.is_template, 'preferred_editor': recipe.preferred_editor, 'process_type_id': ( [recipe.process_type_id.id, recipe.process_type_id.name] if recipe.process_type_id else False ), # 2026-05-20 — drives the visibility of admin-only affordances # in the Simple Editor (e.g. "+ New kind…" inline create). 'user_is_manager': request.env.user.has_group( 'fusion_plating.group_fusion_plating_manager' ), } def _step_payload(self, step): # Sub 12d — measurement prompts. Filter to step_input only (transition # prompts live on the move dialog). Sort by sequence so the editor # renders them in author order. step_inputs = step.input_ids.filtered( lambda i: (i.kind or 'step_input') == 'step_input' ).sorted('sequence') total = len(step_inputs) on = sum(1 for i in step_inputs if getattr(i, 'collect', True)) if total == 0: badge_text = 'No measurements' badge_class = 'bg-secondary' elif not step.collect_measurements: badge_text = 'Off' badge_class = 'bg-secondary' elif on == total: badge_text = '%d/%d collected' % (on, total) badge_class = 'bg-success' else: badge_text = '%d/%d collected' % (on, total) badge_class = 'bg-warning' return { 'id': step.id, 'name': step.name, 'sequence': step.sequence, 'icon': step.icon, 'default_kind': step.default_kind, 'kind_id': step.kind_id.id if step.kind_id else False, 'kind_name': step.kind_id.name if step.kind_id else '', 'requires_signoff': step.requires_signoff, 'requires_rack_assignment': step.requires_rack_assignment, 'requires_transition_form': step.requires_transition_form, 'description': step.description or '', 'notes': step.notes or '', 'tank_ids': [ {'id': t.id, 'name': t.name, 'code': t.code} for t in step.tank_ids ], 'work_center_id': step.work_center_id.id if step.work_center_id else False, 'source_template_id': step.source_template_id.id or False, 'collect_measurements': bool(step.collect_measurements), # Sub 13 — per-step opt-out of the sequential gate 'parallel_start': bool(step.parallel_start), # Sub 14 — workflow milestone trigger override 'triggers_workflow_state_id': ( step.triggers_workflow_state_id.id if 'triggers_workflow_state_id' in step._fields and step.triggers_workflow_state_id else False ), 'measurements_badge_text': badge_text, 'measurements_badge_class': badge_class, # Reference images attached to the step. Operators see # these in the Record Inputs dialog and the step quick-look # modal — recipe authors upload via the inline edit panel. 'instruction_images': [ { 'id': att.id, 'name': att.name or '', 'mimetype': att.mimetype or '', 'url': '/web/image/%s' % att.id, } for att in step.instruction_attachment_ids ], 'inputs': [ { 'id': i.id, 'name': i.name or '', 'input_type': i.input_type or 'text', 'collect': bool(getattr(i, 'collect', True)), 'required': bool(i.required), 'target_min': i.target_min or 0.0, 'target_max': i.target_max or 0.0, 'target_unit': i.target_unit or '', 'sequence': i.sequence or 0, 'from_library': bool(getattr(i, 'template_input_id', False)), 'hint': i.hint or '', } for i in step_inputs ], } # --------------------------------------------------------------- library @http.route('/fp/simple_recipe/library/list', type='jsonrpc', auth='user') def library_list(self, query='', limit=200): Tpl = request.env['fp.step.template'] domain = [('active', '=', True)] if query: domain += ['|', '|', ('name', 'ilike', query), ('code', 'ilike', query), ('description', 'ilike', query)] records = Tpl.search(domain, limit=limit) return { 'templates': [ { 'id': t.id, 'name': t.name, 'code': t.code, 'icon': t.icon, 'default_kind': t.default_kind, 'kind_id': t.kind_id.id if t.kind_id else False, 'kind_name': t.kind_id.name if t.kind_id else '', 'station_count': len(t.tank_ids), } for t in records ], } @http.route('/fp/simple_recipe/library/create', type='jsonrpc', auth='user') def library_create(self, vals): tpl = request.env['fp.step.template'].create(vals) return {'id': tpl.id, 'name': tpl.name} @http.route('/fp/simple_recipe/library/write', type='jsonrpc', auth='user') def library_write(self, template_id, vals): tpl = request.env['fp.step.template'].browse(template_id) tpl.write(vals) return {'ok': True} @http.route('/fp/simple_recipe/library/delete', type='jsonrpc', auth='user') def library_delete(self, template_id): tpl = request.env['fp.step.template'].browse(template_id) Node = request.env['fusion.plating.process.node'] used_count = Node.search_count([('source_template_id', '=', template_id)]) if used_count: tpl.write({'active': False}) return {'ok': True, 'soft_deleted': True, 'used_in': used_count} 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 '', 'kind_id': tpl.kind_id.id if tpl.kind_id else False, 'kind_name': tpl.kind_id.name if tpl.kind_id else '', 'description': tpl.description or '', 'requires_signoff': tpl.requires_signoff, 'requires_predecessor_done': tpl.requires_predecessor_done, 'parallel_start': tpl.parallel_start, # Sub 14 — workflow trigger (id + name for display) 'triggers_workflow_state_id': ( tpl.triggers_workflow_state_id.id if tpl.triggers_workflow_state_id else False ), 'triggers_workflow_state_name': ( tpl.triggers_workflow_state_id.name if tpl.triggers_workflow_state_id else '' ), '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. # Sub 14b: `default_kind` is now a related read-only Char. The # client may still send it as a string code for back-compat — we # translate it to kind_id below. allowed = { 'name', 'code', 'icon', 'kind_id', 'description', 'requires_signoff', 'requires_predecessor_done', 'parallel_start', 'triggers_workflow_state_id', # Sub 14 'requires_rack_assignment', 'requires_transition_form', 'tank_ids', } clean = {k: v for k, v in (vals or {}).items() if k in allowed} # Back-compat: accept default_kind (string code) and resolve to kind_id. if 'kind_id' not in clean and (vals or {}).get('default_kind'): clean['kind_id'] = self._resolve_kind_id_from_code( vals['default_kind'], ) # 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)} def _resolve_kind_id_from_code(self, code): """Look up fp.step.kind id by code. Empty string → False.""" if not code: return False rec = request.env['fp.step.kind'].search( [('code', '=', code)], limit=1, ) return rec.id or False @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) ], } @http.route('/fp/simple_recipe/kinds/list', type='jsonrpc', auth='user') def kinds_list(self): """Sub 14b — Step Kind dropdown options for the inline library form. User-extensible via /fp/simple_recipe/kinds/create. 2026-05-24 — payload now includes `area_kind` + a humanized `area_kind_label` so the Simple Editor picker can render "Masking — Masking column" and authors see which Shop Floor column they're routing the step to. """ Kind = request.env['fp.step.kind'] area_labels = dict(Kind._fields['area_kind'].selection) return { 'kinds': [ { 'id': k.id, 'code': k.code or '', 'name': k.name or '', 'icon': k.icon or '', 'sequence': k.sequence, 'area_kind': k.area_kind or '', 'area_kind_label': area_labels.get(k.area_kind, ''), } for k in Kind.search( [('active', '=', True)], order='sequence, name', ) ], } @http.route('/fp/simple_recipe/kinds/create', type='jsonrpc', auth='user') def kinds_create(self, name, code=''): """Inline create for "+ New kind…" in the library form. Auto-derives a code from the name if blank. 2026-05-20 lockdown: manager group only. Kinds drive gates, milestones, and operator routing — a user-created kind with no corresponding behaviour is a silent foot-gun. The dropdown is the curated catalog; adding a new kind requires manager approval and follow-up code work to wire the new code into the downstream behaviour map. """ Kind = request.env['fp.step.kind'] if not name or not name.strip(): return {'ok': False, 'error': 'name_required'} if not request.env.user.has_group( 'fusion_plating.group_fusion_plating_manager' ): return { 'ok': False, 'error': 'forbidden', 'message': ( 'Only Plating Managers can add new Step Kinds. The ' 'catalog is curated because each kind drives gates, ' 'milestones, and operator routing. Pick "Other" if ' 'no existing kind fits — or ask a manager to add the ' 'new kind once the downstream behaviour is wired up.' ), } if not code: code = name.strip().lower().replace(' ', '_').replace('/', '_') existing = Kind.search([('code', '=', code)], limit=1) if existing: return { 'ok': True, 'id': existing.id, 'name': existing.name, 'code': existing.code, 'duplicate': True, } rec = Kind.create({ 'name': name.strip(), 'code': code, }) return { 'ok': True, 'id': rec.id, 'name': rec.name, 'code': rec.code, 'duplicate': False, } @http.route('/fp/simple_recipe/workflow_states/list', type='jsonrpc', auth='user') def workflow_states_list(self): """Sub 14 — workflow-state picker for the inline library form. Returns active states ordered by sequence so the dropdown renders left-to-right matching the status bar. Soft-fail when fp.job.workflow.state isn't installed (rare, only when fusion_plating_jobs is missing) — empty list lets the dropdown render disabled instead of throwing. """ WS = request.env.get('fp.job.workflow.state') if WS is None: return {'workflow_states': []} return { 'workflow_states': [ { 'id': ws.id, 'name': ws.name or '', 'code': ws.code or '', 'sequence': ws.sequence, } for ws in WS.search( [('active', '=', True)], order='sequence, id', ) ], } # ------------------------------------------------------------------ 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): recipe = request.env['fusion.plating.process.node'].browse(recipe_id) target_seq = self._sequence_for_position(recipe, position) new_vals = { 'parent_id': recipe.id, # 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 if template_id: tpl = request.env['fp.step.template'].browse(template_id) new_vals.update(_copy_snapshot_fields(tpl, _SNAPSHOT_FIELDS)) if tpl.process_type_id: new_vals['process_type_id'] = tpl.process_type_id.id if tpl.tank_ids: new_vals['tank_ids'] = [(6, 0, tpl.tank_ids.ids)] new_vals['source_template_id'] = tpl.id if vals: new_vals.update(vals) new_node = request.env['fusion.plating.process.node'].create(new_vals) if tpl: self._copy_inputs_from_template(tpl, new_node) return {'id': new_node.id, 'sequence': new_node.sequence} def _sequence_for_position(self, recipe, position): siblings = recipe.child_ids.sorted('sequence') if not siblings: return 10 if position >= len(siblings): return siblings[-1].sequence + 10 if position <= 0: return max(1, siblings[0].sequence - 10) before = siblings[position - 1].sequence after = siblings[position].sequence if after - before > 1: return (before + after) // 2 # Sequences are tightly packed (gap == 1 → midpoint == after, # which collides). Renumber siblings to 10/20/30… first, then # the new step lands cleanly between renumbered neighbours. for idx, sib in enumerate(siblings): new_seq = (idx + 1) * 10 if sib.sequence != new_seq: sib.sequence = new_seq return position * 10 + 5 def _copy_inputs_from_template(self, tpl, new_node): NodeInput = request.env['fusion.plating.process.node.input'] for ti in tpl.input_template_ids: payload = {f: ti[f] for f in _INPUT_SNAPSHOT_FIELDS} payload['node_id'] = new_node.id payload['kind'] = 'step_input' NodeInput.create(payload) for tt in tpl.transition_input_ids: NodeInput.create({ 'node_id': new_node.id, 'name': tt.name, 'input_type': tt.input_type, 'required': tt.required, 'hint': tt.hint, 'selection_options': tt.selection_options, 'sequence': tt.sequence, 'compliance_tag': tt.compliance_tag, 'kind': 'transition_input', }) @http.route('/fp/simple_recipe/step/write', type='jsonrpc', auth='user') def step_write(self, node_id, vals): """Update fields on an existing recipe step (operation node). Whitelisted to the fields the inline edit panel actually surfaces — never trust client-provided node_type / parent_id / etc. """ node = request.env['fusion.plating.process.node'].browse(int(node_id)) if not node.exists(): return {'ok': False, 'error': 'not_found'} node.check_access('write') allowed = { 'name', 'description', 'icon', 'kind_id', # Sub 14b — replaces default_kind 'requires_signoff', 'requires_predecessor_done', 'parallel_start', # Sub 13 'triggers_workflow_state_id', # Sub 14 'requires_rack_assignment', 'requires_transition_form', 'estimated_duration', 'collect_measurements', } clean = {k: v for k, v in (vals or {}).items() if k in allowed} # Back-compat: accept default_kind (string code) and resolve. if 'kind_id' not in clean and (vals or {}).get('default_kind'): clean['kind_id'] = self._resolve_kind_id_from_code( vals['default_kind'], ) if clean: node.write(clean) return {'ok': True} @http.route('/fp/simple_recipe/step/remove', type='jsonrpc', auth='user') def step_remove(self, node_id): node = request.env['fusion.plating.process.node'].browse(node_id) node.unlink() return {'ok': True} @http.route('/fp/simple_recipe/step/reorder', type='jsonrpc', auth='user') def step_reorder(self, node_ids): """Renumber sequence within each parent group. Naive version (pre-19.0.20.5.0): renumber the entire flat list 1..N regardless of parent. Broke when the flat list mixed operations and substeps — siblings got out-of-order numbers because the list interleaved them. New version: group node ids by their parent_id, then renumber within each parent. Substeps stay sequenced under their operation; operations stay sequenced under the recipe / sub- process. Drop-across-parent shows up as a same-position no-op — the UI's Promote/Demote buttons are the way to change parents. """ Node = request.env['fusion.plating.process.node'] nodes = Node.browse([int(n) for n in node_ids]) # Group by parent_id (preserve client-provided order within each). from collections import OrderedDict by_parent = OrderedDict() for n in nodes: by_parent.setdefault(n.parent_id.id, []).append(n) for parent_id, siblings in by_parent.items(): for i, n in enumerate(siblings, start=1): target = i * 10 if n.sequence != target: n.sequence = target return {'ok': True} @http.route('/fp/simple_recipe/step/promote', type='jsonrpc', auth='user') def step_promote(self, node_id): """Promote a substep (`step` node) to an operation under the recipe root. Use case: author added a sub-step under an operation in the Tree Editor, but actually wants it as a standalone operation that the operator clocks separately. This call: 1. Flips node_type 'step' → 'operation' 2. Re-parents to the recipe root (or sub-process root if the parent operation lives inside a sub_process) 3. Places the new operation immediately after its old parent (so it shows up in a sensible position in the editor list) """ Node = request.env['fusion.plating.process.node'] node = Node.browse(int(node_id)) if not node.exists(): return {'ok': False, 'error': 'not_found'} node.check_access('write') if node.node_type != 'step': return {'ok': False, 'error': 'not_a_substep', 'message': 'Only substeps can be promoted.'} parent_op = node.parent_id if not parent_op or parent_op.node_type != 'operation': return {'ok': False, 'error': 'no_parent_op', 'message': 'Substep has no operation parent to promote out of.'} new_parent = parent_op.parent_id if not new_parent or new_parent.node_type not in ('recipe', 'sub_process'): return {'ok': False, 'error': 'no_grandparent', 'message': 'Cannot find a recipe / sub-process to promote into.'} # Place the new operation right after parent_op. new_seq = parent_op.sequence + 1 # Bump later siblings to make room (so we don't collide). for sibling in new_parent.child_ids.filtered( lambda s: s.sequence > parent_op.sequence and s.id != node.id ): sibling.sequence = sibling.sequence + 10 node.write({ 'node_type': 'operation', 'parent_id': new_parent.id, 'sequence': new_seq, }) return {'ok': True, 'new_parent_id': new_parent.id, 'new_sequence': new_seq} @http.route('/fp/simple_recipe/step/demote', type='jsonrpc', auth='user') def step_demote(self, node_id, target_op_id=False): """Demote an operation to a substep under another operation. If ``target_op_id`` is provided, the node becomes a substep of that operation. Otherwise it falls under the operation immediately preceding it in the editor list (most common case — author drops a header into the preceding section). """ Node = request.env['fusion.plating.process.node'] node = Node.browse(int(node_id)) if not node.exists(): return {'ok': False, 'error': 'not_found'} node.check_access('write') if node.node_type != 'operation': return {'ok': False, 'error': 'not_an_operation', 'message': 'Only operations can be demoted to substeps.'} # Substeps of operations don't recurse further — bail if this # operation has its own step children (would lose them on demote). if node.child_ids: return {'ok': False, 'error': 'has_children', 'message': ( 'Operation "%s" has %d child step(s). Remove ' 'or promote them first before demoting this ' 'operation.' ) % (node.name, len(node.child_ids))} # Resolve target operation. if target_op_id: target = Node.browse(int(target_op_id)) if not target.exists() or target.node_type != 'operation': return {'ok': False, 'error': 'invalid_target', 'message': 'Target must be an operation.'} else: # Find the preceding operation in the same parent. parent = node.parent_id if not parent: return {'ok': False, 'error': 'no_parent'} siblings = parent.child_ids.sorted('sequence') before = [s for s in siblings if s.sequence < node.sequence and s.node_type == 'operation'] if not before: return {'ok': False, 'error': 'no_preceding_op', 'message': ( 'There is no preceding operation to demote ' 'into. Add one above this step first, or ' 'pick an operation manually.' )} target = before[-1] # Place the substep at the end of the target operation's children. last_seq = max(target.child_ids.mapped('sequence') or [0]) node.write({ 'node_type': 'step', 'parent_id': target.id, 'sequence': last_seq + 10, }) return {'ok': True, 'new_parent_id': target.id} # -------------------------------------------------------------- template @http.route('/fp/simple_recipe/template/list', type='jsonrpc', auth='user') def template_list(self): Node = request.env['fusion.plating.process.node'] recipes = Node.search([ ('node_type', '=', 'recipe'), ('is_template', '=', True), ('active', '=', True), ], order='name') return { 'templates': [ {'id': r.id, 'name': r.name, 'code': r.code, 'step_count': len(r.child_ids)} for r in recipes ], } @http.route('/fp/simple_recipe/template/import', type='jsonrpc', auth='user') def template_import(self, source_recipe_id, target_recipe_id): Node = request.env['fusion.plating.process.node'] source = Node.browse(source_recipe_id) target = Node.browse(target_recipe_id) imported = 0 for child in source.child_ids.sorted('sequence'): self._snapshot_step_into(child, target) imported += 1 return {'ok': True, 'imported_count': imported} def _snapshot_step_into(self, src_node, target_recipe): Node = request.env['fusion.plating.process.node'] new_vals = { 'parent_id': target_recipe.id, # 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, } new_vals.update(_copy_snapshot_fields(src_node, _SNAPSHOT_FIELDS)) if src_node.process_type_id: new_vals['process_type_id'] = src_node.process_type_id.id if src_node.tank_ids: new_vals['tank_ids'] = [(6, 0, src_node.tank_ids.ids)] new_node = Node.create(new_vals) NodeInput = request.env['fusion.plating.process.node.input'] for src_in in src_node.input_ids: NodeInput.create({ 'node_id': new_node.id, 'name': src_in.name, 'input_type': src_in.input_type, 'required': src_in.required, 'hint': src_in.hint, 'selection_options': src_in.selection_options, 'sequence': src_in.sequence, 'kind': src_in.kind or 'step_input', 'target_min': src_in.target_min, 'target_max': src_in.target_max, 'target_unit': src_in.target_unit, 'compliance_tag': src_in.compliance_tag, }) # ============================================================ # Sub 12d — per-recipe configurability endpoints # ============================================================ @http.route('/fp/simple_recipe/step/toggle_collect', type='jsonrpc', auth='user') def step_toggle_collect(self, node_id, collect): """Master switch — toggle collect_measurements on a recipe step.""" node = request.env['fusion.plating.process.node'].browse(int(node_id)) node.check_access('write') node.collect_measurements = bool(collect) return {'ok': True, 'collect_measurements': node.collect_measurements} @http.route('/fp/simple_recipe/step/edit_input', type='jsonrpc', auth='user') def step_edit_input(self, input_id, payload): """Edit a single recipe-step input. payload is a dict with any of: collect, name, input_type, target_min, target_max, target_unit, required, sequence, selection_options, hint.""" Input = request.env['fusion.plating.process.node.input'] rec = Input.browse(int(input_id)) if not rec.exists(): return {'ok': False, 'error': 'not_found'} rec.node_id.check_access('write') allowed = { 'collect', 'name', 'input_type', 'target_min', 'target_max', 'target_unit', 'required', 'sequence', 'selection_options', 'hint', } vals = {k: v for k, v in (payload or {}).items() if k in allowed} if vals: rec.write(vals) return {'ok': True} @http.route('/fp/simple_recipe/step/add_input', type='jsonrpc', auth='user') def step_add_input(self, node_id, payload): """Add a custom prompt to a recipe step (no template_input_id link).""" node = request.env['fusion.plating.process.node'].browse(int(node_id)) node.check_access('write') Input = request.env['fusion.plating.process.node.input'] existing_max = max(node.input_ids.mapped('sequence') or [0]) rec = Input.create({ 'node_id': node.id, 'name': (payload or {}).get('name') or 'Custom Prompt', 'input_type': (payload or {}).get('input_type') or 'text', 'kind': 'step_input', 'collect': True, 'sequence': existing_max + 10, 'required': bool((payload or {}).get('required')), }) return {'ok': True, 'input_id': rec.id} @http.route('/fp/simple_recipe/step/remove_input', type='jsonrpc', auth='user') def step_remove_input(self, input_id): """Delete a custom prompt. Library-sourced rows are protected — recipe authors should toggle collect=False instead of deleting.""" Input = request.env['fusion.plating.process.node.input'] rec = Input.browse(int(input_id)) if not rec.exists(): return {'ok': False, 'error': 'not_found'} rec.node_id.check_access('write') if getattr(rec, 'template_input_id', False) and rec.template_input_id: return { 'ok': False, 'error': 'library_sourced', 'message': 'Toggle Collect off instead of deleting library prompts.', } rec.unlink() return {'ok': True} # ============================================================ # Step instruction images — recipe authors attach reference photos # / screenshots / diagrams to a step from the Simple Editor's inline # edit panel. Operators see them on the Record Inputs dialog and # the step quick-look modal at runtime. # ============================================================ @http.route('/fp/simple_recipe/step/image/add', type='jsonrpc', auth='user') def step_image_add(self, node_id, filename, datas, mimetype=None): """Upload a new instruction image to a recipe step. Args: node_id: recipe node (fusion.plating.process.node) id filename: display name (with extension) for the attachment datas: base64-encoded image payload (no data: URL prefix) mimetype: optional override; falls back to image/png Returns the new attachment metadata so the JS can append it to the step's gallery without a full reload. """ node = request.env['fusion.plating.process.node'].browse(int(node_id)) node.check_access('write') att = request.env['ir.attachment'].create({ 'name': filename or 'image.png', 'datas': datas, 'res_model': 'fusion.plating.process.node', 'res_id': node.id, 'mimetype': mimetype or 'image/png', }) node.instruction_attachment_ids = [(4, att.id)] return { 'ok': True, 'image': { 'id': att.id, 'name': att.name, 'mimetype': att.mimetype or '', 'url': '/web/image/%s' % att.id, }, } @http.route('/fp/simple_recipe/step/image/remove', type='jsonrpc', auth='user') def step_image_remove(self, node_id, attachment_id): """Unlink an instruction image from a recipe step. Soft-removes from the M2M; the underlying ir.attachment is deleted only if it isn't referenced by any other recipe node. """ node = request.env['fusion.plating.process.node'].browse(int(node_id)) node.check_access('write') Att = request.env['ir.attachment'] att = Att.browse(int(attachment_id)) if not att.exists(): return {'ok': False, 'error': 'not_found'} node.instruction_attachment_ids = [(3, att.id)] # Drop the attachment file too if no other node still links to it. Node = request.env['fusion.plating.process.node'] still_used = Node.search_count([ ('instruction_attachment_ids', '=', att.id), ]) if not still_used: att.sudo().unlink() return {'ok': True} @http.route('/fp/simple_recipe/step/reset_to_library', type='jsonrpc', auth='user') def step_reset_to_library(self, node_id): """Re-sync the recipe step's input_ids + description from the linked library template. Preserves rows where template_input_id=False (recipe-author-added custom prompts).""" Node = request.env['fusion.plating.process.node'] Input = request.env['fusion.plating.process.node.input'] node = Node.browse(int(node_id)) if not node.exists() or not node.source_template_id: return {'ok': False, 'error': 'no_library_template'} node.check_access('write') tpl = node.source_template_id # Drop existing rows that came from the library (template_input_id set); # preserve recipe-only customs. node.input_ids.filtered( lambda i: getattr(i, 'template_input_id', False) and i.template_input_id ).unlink() # Re-snapshot from library for src in tpl.input_template_ids: Input.create({ 'node_id': node.id, 'template_input_id': src.id, 'name': src.name, 'input_type': src.input_type, 'target_min': src.target_min, 'target_max': src.target_max, 'target_unit': src.target_unit, 'required': src.required, 'hint': src.hint, 'sequence': src.sequence, 'selection_options': src.selection_options, 'kind': 'step_input', 'collect': True, }) node.description = tpl.description or False node.collect_measurements = True node.message_post( body='Reset to library defaults from template "%s"' % tpl.name, message_type='notification', ) return {'ok': True}