Files
Odoo-Modules/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py
gsinghpal 7b90f210b9 feat(fusion_plating): kind.area_kind drives Shop Floor column routing
Add required area_kind Selection to fp.step.kind so each kind
self-declares which plant-view column its steps belong in. Replaces
the hardcoded _STEP_KIND_TO_AREA dict (removed in fp_job_step.py
in the follow-up commit).

- New `blast` kind for the Blasting column (sequence=35)
- 26 existing kind records seeded with area_kind in XML
- Pre-migrate 19.0.21.2.0 seeds existing rows BEFORE NOT NULL hits
  the schema; also activates derack/demask/gating that were
  deactivated in 19.0.20.6.0 but are needed for the full taxonomy
- Step Kind form + list views surface area_kind (badge + chip)
- Step Kind search adds Group By Shop Floor Column
- Simple Editor kind picker shows "Masking — Masking column"
  suffix so authors see the routing at pick time
- Add Hot Water Porosity Test (A-15) + Final Inspection / Packaging
  templates (used by 7+3 recipe nodes that previously had no
  library entry)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:02:02 -04:00

1068 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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}