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:
@@ -3,3 +3,4 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
from . import recipe_controller
|
from . import recipe_controller
|
||||||
|
from . import simple_recipe_controller
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user