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)
|
||||
|
||||
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