feat(sub12a): JSONRPC endpoints for the Simple Recipe Editor

11 routes under /fp/simple_recipe/...:
  load
  library/{list,create,write,delete}
  step/{insert,write,remove,reorder}
  template/{list,import}

Library/template imports snapshot-copy fields (Q4 = A locked) — no
live references. The _SNAPSHOT_FIELDS + _INPUT_SNAPSHOT_FIELDS module
constants are the single source of truth for what gets copied;
adding a new authoring field on fp.step.template means appending it
once to _SNAPSHOT_FIELDS and the controller stays correct.

library_delete is soft when nodes still reference the template via
source_template_id (operator can't accidentally orphan recipe steps).

Uses recipe.check_access('read') (Odoo 19 unified API) instead of the
older check_access_rights/check_access_rule pair.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-27 20:38:16 -04:00
parent 33ddec926c
commit 194d5d96dd
2 changed files with 270 additions and 0 deletions

View File

@@ -3,3 +3,4 @@
# License OPL-1 (Odoo Proprietary License v1.0)
from . import recipe_controller
from . import simple_recipe_controller

View File

@@ -0,0 +1,269 @@
# -*- 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',
'requires_rack_assignment', 'requires_transition_form',
'default_kind',
]
# 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',
]
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')
steps = recipe.child_ids.sorted('sequence')
return {
'recipe': self._recipe_payload(recipe),
'steps': [self._step_payload(s) for s in steps],
}
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
),
}
def _step_payload(self, step):
return {
'id': step.id,
'name': step.name,
'sequence': step.sequence,
'icon': step.icon,
'default_kind': step.default_kind,
'requires_signoff': step.requires_signoff,
'requires_rack_assignment': step.requires_rack_assignment,
'requires_transition_form': step.requires_transition_form,
'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,
}
# --------------------------------------------------------------- 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,
'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}
# ------------------------------------------------------------------ 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,
'node_type': 'step',
'sequence': target_seq,
}
tpl = False
if template_id:
tpl = request.env['fp.step.template'].browse(template_id)
for f in _SNAPSHOT_FIELDS:
new_vals[f] = tpl[f]
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 or position >= len(siblings):
return (siblings[-1].sequence + 10) if siblings else 10
if position <= 0:
return max(1, siblings[0].sequence - 10)
before = siblings[position - 1].sequence
after = siblings[position].sequence
return (before + after) // 2 if (after - before) > 1 else before + 1
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):
node = request.env['fusion.plating.process.node'].browse(node_id)
node.write(vals)
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):
Node = request.env['fusion.plating.process.node']
for i, nid in enumerate(node_ids, start=1):
Node.browse(nid).write({'sequence': i * 10})
return {'ok': True}
# -------------------------------------------------------------- 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,
'node_type': 'step',
'sequence': src_node.sequence,
'source_template_id': src_node.source_template_id.id or False,
}
for f in _SNAPSHOT_FIELDS:
new_vals[f] = src_node[f]
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,
})