From 194d5d96ddbaa354535661dfd94eb140bd570c50 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Mon, 27 Apr 2026 20:38:16 -0400 Subject: [PATCH] feat(sub12a): JSONRPC endpoints for the Simple Recipe Editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../fusion_plating/controllers/__init__.py | 1 + .../controllers/simple_recipe_controller.py | 269 ++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 fusion_plating/fusion_plating/controllers/simple_recipe_controller.py diff --git a/fusion_plating/fusion_plating/controllers/__init__.py b/fusion_plating/fusion_plating/controllers/__init__.py index 05a46f72..6c7e7a59 100644 --- a/fusion_plating/fusion_plating/controllers/__init__.py +++ b/fusion_plating/fusion_plating/controllers/__init__.py @@ -3,3 +3,4 @@ # License OPL-1 (Odoo Proprietary License v1.0) from . import recipe_controller +from . import simple_recipe_controller diff --git a/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py b/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py new file mode 100644 index 00000000..2597cddb --- /dev/null +++ b/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py @@ -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, + })