Files
Odoo-Modules/fusion_plating/fusion_plating/__init__.py
gsinghpal 7c31269691 fix(simple-editor): stop seed resurrection + add promote/demote + drag substeps
Three bugs reported on 2026-05-20:

1. RESURRECTION. User deletes a substep in the Simple Editor (e.g.
   Soak Clean (S-3) under Cleaner), then on the next -u fusion_plating
   the substep comes back. Root cause: the recipe XML lived in the
   manifest's `data` list with `noupdate="1"`. Odoo's noupdate=1 only
   blocks UPDATE of existing records — when a record's ir.model.data
   row is missing, the loader treats it as "not yet created" and
   re-creates from XML. Every upgrade resurrected every user-deleted
   seed node.

   Fix: pull the recipe XML files out of `data` and load them once
   via post_init_hook → _seed_starter_recipes_once. Sentinel checks
   ir.model.data for each recipe's root xmlid; if present, skip
   loading entirely. Result: deletions are permanent across all
   future upgrades. Existing entech recipes untouched.

   Files affected: fp_recipe_enp_alum_basic, fp_recipe_enp_steel_basic,
   fp_recipe_enp_sp, fp_recipe_general_processing, fp_recipe_anodize,
   fp_recipe_chem_conversion.

2. PROMOTE / DEMOTE. Simple Editor had no way to turn a substep into
   a top-level operation, or to tuck an operation under another as a
   substep. Authors had to delete + re-create. New endpoints:

   * /fp/simple_recipe/step/promote → flips node_type 'step' →
     'operation', re-parents to the recipe (or sub-process) root,
     places right after the old parent operation.
   * /fp/simple_recipe/step/demote → flips 'operation' → 'step',
     re-parents under the preceding operation (or a caller-supplied
     target_op_id). Blocks demoting an operation that has its own
     children, with a helpful message.

   UI: each row in the editor now carries an up-arrow (promote, only
   shown on substeps) and a down-arrow (demote, only shown on
   operations). Confirmation dialog explains what's about to happen.

3. DRAG SUBSTEPS. Last commit (2142a66b) disabled drag on substep
   rows. Operators couldn't reorder substeps within an operation.
   Re-enabled drag on substeps. The step_reorder endpoint now groups
   incoming node_ids by parent_id and renumbers within each parent
   (10, 20, 30…). Cross-parent drag still no-ops on parent change —
   Promote/Demote buttons are the way to move between parents.

Drive-by:
- Added `from odoo import _` to the controller (missing import the
  new endpoints surfaced).
- Edit-panel field wiring audited: all fields visible in the screen
  (Step name, Default instructions, Step Type, Triggers Workflow,
  Parallel Start, QA Sign-off, Collect measurements, Instruction
  Images, custom prompts) persist correctly through step_write or
  dedicated endpoints. No broken wires.

Tests: 15 total in TestSimpleRecipeFlatten (was 10). 5 new cover
promote happy-path, promote reject (non-substep), demote happy-path,
demote block on has_children, and reorder parent-scoping.

Module: fusion_plating 19.0.20.4.0 → 19.0.20.5.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:53:09 -04:00

422 lines
15 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
import logging
from . import controllers
from . import models
_logger = logging.getLogger(__name__)
def post_init_hook(env):
"""Run on first install / module upgrade. Idempotent.
Does several things, each guarded by an "is this already done?"
check so re-running the hook doesn't clobber state:
1. Auto-detect a sensible default timezone (original behavior).
2. Sub 12a — backfill `kind='step_input'` on existing
fusion.plating.process.node.input rows that pre-date the
`kind` field.
3. Sub 12a — seed fp.step.template with starter library entries
derived from ENP-ALUM-BASIC if the library is currently empty.
4. Sub 12b — seed 4 starter rack tags if the registry is empty.
"""
_seed_default_timezone(env)
_backfill_node_input_kind(env)
_seed_step_library_if_empty(env)
_backfill_contract_review_template(env)
_seed_rack_tags_if_empty(env)
_migrate_legacy_uom_columns(env)
_seed_starter_recipes_once(env)
def _seed_starter_recipes_once(env):
"""Load starter recipe XML files on FIRST install only.
Before 19.0.20.5.0 the recipe XML files (ENP-STEEL-BASIC, ENP-SP,
ENP-ALUM-BASIC, etc.) lived in the manifest's ``data`` list. With
``noupdate="1"`` we expected user edits / deletions to survive
module upgrades — but Odoo only treats noupdate=1 as "don't update
existing records". If a record's ir.model.data row is deleted via
unlink, Odoo on the next ``-u`` sees the xmlid as missing and
RE-CREATES the record from XML. Bug reported 2026-05-20: every
time the user deleted a substep from a starter recipe, the next
upgrade brought it back.
Fix: pull those files out of the manifest's data list, load them
here via convert_file ONCE per xmlid. Each file gets a sentinel
check (does the root recipe's xmlid exist in ir.model.data?); if
yes, skip. The hook is itself idempotent so it's safe to run on
every upgrade as well — but the sentinel ensures recipe content
is only seeded the very first time.
"""
from odoo.tools import convert
Module = env['ir.module.module']
mod = Module.search([('name', '=', 'fusion_plating')], limit=1)
if not mod:
return
# (xmlid_to_check, data_file_path) pairs.
# If the xmlid already exists in ir.model.data, the file is skipped.
sentinels = [
('fusion_plating.recipe_enp_alum_basic',
'data/fp_recipe_enp_alum_basic.xml'),
('fusion_plating.recipe_enp_steel_basic',
'data/fp_recipe_enp_steel_basic.xml'),
('fusion_plating.recipe_enp_sp',
'data/fp_recipe_enp_sp.xml'),
('fusion_plating.recipe_general_processing',
'data/fp_recipe_general_processing.xml'),
('fusion_plating.recipe_anodize',
'data/fp_recipe_anodize.xml'),
('fusion_plating.recipe_chem_conversion',
'data/fp_recipe_chem_conversion.xml'),
]
IMD = env['ir.model.data']
for xmlid, filepath in sentinels:
module_name, name = xmlid.split('.', 1)
if IMD.search_count([('module', '=', module_name), ('name', '=', name)]):
# Recipe already in DB (either from a previous install, or
# already loaded by an earlier hook run). Don't touch — user
# may have made edits.
continue
# File not yet loaded for this DB. Run it once.
try:
with open_module_data_file(filepath) as fh:
convert.convert_file(
env, module_name, filepath, idref={}, mode='init',
noupdate=True,
)
_logger.info('Seeded starter recipe %s', xmlid)
except FileNotFoundError:
_logger.warning('Starter recipe file %s not found, skipping',
filepath)
except Exception as exc:
_logger.warning('Could not seed %s: %s', xmlid, exc)
def open_module_data_file(relpath):
"""Open a file relative to the fusion_plating module root."""
import os
here = os.path.dirname(__file__)
return open(os.path.join(here, relpath), 'rb')
def _resolve_kind_id(env, code):
"""Look up an fp.step.kind id by code. Returns False if not found.
Cheap helper used during seeding so legacy code paths that referenced
string codes can keep their semantics."""
if not code:
return False
rec = env['fp.step.kind'].search(
[('code', '=', code)], limit=1,
)
return rec.id or False
def _backfill_contract_review_template(env):
"""Idempotent — ensure the Contract Review library template exists.
`_seed_step_library_if_empty` only fires on a fresh DB; existing DBs
upgraded from pre-Policy-B versions still have a populated library
minus the Contract Review entry. This function fills that hole.
Re-running it is a no-op once the template exists.
"""
Tpl = env['fp.step.template']
if Tpl.search([('default_kind', '=', 'contract_review')], limit=1):
return # already there
tpl = Tpl.create({
'name': 'Contract Review',
'kind_id': _resolve_kind_id(env, 'contract_review'),
})
tpl.action_seed_default_inputs()
_logger.info(
"Fusion Plating: backfilled Contract Review library template "
"(id=%s, %s default inputs).",
tpl.id, len(tpl.input_template_ids),
)
def _seed_default_timezone(env):
from .models.fp_tz import detect_default_tz
detected = detect_default_tz(env)
for company in env['res.company'].sudo().search([]):
if not company.x_fc_default_tz:
company.x_fc_default_tz = detected
_logger.info(
'Fusion Plating: set default timezone for company %s -> %s',
company.name, detected,
)
def _backfill_node_input_kind(env):
"""Sub 12a — set kind='step_input' on rows that have NULL kind."""
cr = env.cr
cr.execute(
"UPDATE fusion_plating_process_node_input "
"SET kind = 'step_input' WHERE kind IS NULL"
)
if cr.rowcount:
_logger.info(
"Fusion Plating: backfilled kind='step_input' on %s "
"fusion.plating.process.node.input rows", cr.rowcount,
)
# Mapping of recipe-step name → default_kind. Drives sane-default
# input seeding on the starter library entries.
_STARTER_KIND_BY_NAME = {
# Policy B (2026-04-28) — recipe-side Contract Review step.
# When an author drops this template into a recipe, fp.job.step.button_*
# hooks in fusion_plating_jobs detect the kind=='contract_review' and
# auto-open / gate the QA-005 audit form (fp.contract.review).
'contract review': 'contract_review',
'qa-005': 'contract_review',
'soak clean': 'cleaning',
'electroclean': 'cleaning',
'solvent clean': 'cleaning',
'rinse': 'rinse',
'primary rinse': 'rinse',
'secondary rinse': 'rinse',
'hot rinse': 'rinse',
'final rinse': 'rinse',
'etch': 'etch',
'desmut': 'etch',
'zincate': 'etch',
'strip zincate': 'etch',
'acid dip': 'etch',
'hcl activation': 'etch',
'water break test': 'wbf_test',
'water break free test': 'wbf_test',
'issue panels': 'mask',
'masking': 'mask',
'mask': 'mask',
'racking': 'racking',
'rack': 'racking',
'e-nickel plate': 'plate',
'e-nickel plating': 'plate',
'electroless nickel plate': 'plate',
'electroless nickel plating': 'plate',
'enp': 'plate',
'plate': 'plate',
'plating': 'plate',
'drying': 'dry',
'dry': 'dry',
'bake': 'bake',
'oven baking': 'bake',
'oven bake': 'bake',
'baking': 'bake',
'hydrogen embrittlement bake': 'bake',
'he bake': 'bake',
'de-rack': 'derack',
'de-racking': 'derack',
'deracking': 'derack',
'derack': 'derack',
'demask': 'demask',
'de-mask': 'demask',
'de-masking': 'demask',
'demasking': 'demask',
'inspection': 'inspect',
'incoming inspection': 'inspect',
'post-plate inspection': 'inspect',
'post plate inspection': 'inspect',
'visual inspection': 'inspect',
'porosity test': 'inspect',
'adhesion test': 'inspect',
'final inspection': 'final_inspect',
'final inspection / packaging': 'final_inspect',
'shipping': 'ship',
'pack': 'ship',
'packaging': 'ship',
# Gating steps (Steelhead-style "Ready for X" intermediate states).
'ready for incoming inspection': 'gating',
'ready for plating': 'gating',
'ready for racking': 'gating',
'ready for de-masking': 'gating',
'ready for demasking': 'gating',
'ready for masking': 'gating',
'ready for bake': 'gating',
'ready for deracking': 'gating',
'ready for de-racking': 'gating',
'ready for post plate inspection': 'gating',
'ready for post-plate inspection': 'gating',
'ready for final inspection': 'gating',
'ready for shipping': 'gating',
}
def fp_resolve_step_kind(name):
"""Resolve a step name to a default_kind, tolerant of whitespace and
case. Used by both the seeder and the migration backfill so we don't
have two slightly-different lookup paths.
Returns the kind str or None when no match.
"""
if not name:
return None
key = name.strip().lower()
if key in _STARTER_KIND_BY_NAME:
return _STARTER_KIND_BY_NAME[key]
# Gating "Ready for / Ready For" prefix — anything starting with that
# is a gating node regardless of the destination step name.
if key.startswith('ready for ') or key.startswith('ready '):
return 'gating'
return None
def _seed_step_library_if_empty(env):
"""Sub 12a — seed fp.step.template starter library.
Source priority:
1. ENP-ALUM-BASIC recipe's child nodes (best — reuses the
author-curated step set).
2. Hard-coded minimal list (fallback for fresh DBs).
"""
Tpl = env['fp.step.template']
if Tpl.search_count([]):
_logger.info(
'Fusion Plating: step library already populated, skip seed',
)
return
Node = env['fusion.plating.process.node']
src = Node.search([
('node_type', '=', 'recipe'),
'|', ('code', '=', 'ENP-ALUM-BASIC'),
('name', 'ilike', 'ENP-ALUM-BASIC'),
], limit=1)
if not src:
_seed_minimal_library(env)
return
seen = set()
for child in src.child_ids:
if child.node_type == 'step':
_create_template_from_node(env, child, seen)
else:
for grandchild in child.child_ids:
_create_template_from_node(env, grandchild, seen)
_logger.info(
"Fusion Plating: seeded step library with %s entries from %s",
len(seen), src.name,
)
def _create_template_from_node(env, node, seen):
if not node.name or node.name.lower() in seen:
return
seen.add(node.name.lower())
kind = fp_resolve_step_kind(node.name)
vals = {
'name': node.name,
'description': node.description or False,
'icon': node.icon or 'fa-cog',
'process_type_id': node.process_type_id.id,
'requires_signoff': node.requires_signoff,
'requires_predecessor_done': node.requires_predecessor_done,
'kind_id': _resolve_kind_id(env, kind),
}
# Snapshot tank_ids if the node has them (added by Sub 12a;
# existing nodes may not).
if 'tank_ids' in node._fields and node.tank_ids:
vals['tank_ids'] = [(6, 0, node.tank_ids.ids)]
# Snapshot any time/temp targets the node may already carry.
for f in ('time_min_target', 'time_max_target', 'time_unit',
'temp_min_target', 'temp_max_target', 'temp_unit'):
if f in node._fields:
vals[f] = node[f] or vals.get(f)
tpl = env['fp.step.template'].create(vals)
if kind:
tpl.action_seed_default_inputs()
def _seed_minimal_library(env):
"""Hard-coded minimal seed when ENP-ALUM-BASIC isn't on the target DB."""
Tpl = env['fp.step.template']
minimal = [
('Contract Review', 'contract_review'),
('Soak Clean', 'cleaning'),
('Electroclean', 'cleaning'),
('Rinse', 'rinse'),
('Etch', 'etch'),
('Desmut', 'etch'),
('Zincate', 'etch'),
('Acid Dip', 'etch'),
('Water Break Test', 'wbf_test'),
('Racking', 'racking'),
('De-Racking', 'derack'),
('E-Nickel Plate', 'plate'),
('Drying', 'dry'),
('Inspection', 'inspect'),
('Final Inspection', 'final_inspect'),
('Shipping', 'ship'),
]
for name, kind in minimal:
tpl = Tpl.create({
'name': name,
'kind_id': _resolve_kind_id(env, kind),
})
tpl.action_seed_default_inputs()
_logger.info(
'Fusion Plating: seeded minimal step library (%s entries)',
len(minimal),
)
def _migrate_legacy_uom_columns(env):
"""Translate every free-text UoM column in the plating suite into the
new curated Selection keys.
Runs unconditionally on every fusion_plating upgrade so the day a
downstream module's migration converts a Char to Selection, the data
follows. Each call is a no-op when:
* the column already holds selection keys (identity mapping)
* the table doesn't exist (module not installed on this DB)
"""
from .models._fp_uom_selection import fp_migrate_uom_column
targets = [
# core
('fusion_plating_bath_parameter', 'uom', 'bath parameter'),
('fusion_plating_process_node_input', 'uom', 'process node input'),
('fusion_plating_process_node_input', 'target_unit', 'process node target'),
('fp_step_template_input', 'target_unit', 'step template input target'),
# compliance
('fusion_plating_discharge_limit', 'uom', 'discharge limit'),
('fusion_plating_discharge_sample_line', 'uom', 'discharge sample line'),
('fusion_plating_waste_manifest', 'uom', 'waste manifest'),
('fusion_plating_waste_stream', 'generation_uom', 'waste stream'),
('fusion_plating_spill_register', 'uom', 'spill register'),
# safety
('fusion_plating_chemical', 'container_uom', 'chemical container'),
('fusion_plating_exposure_monitoring', 'uom', 'exposure monitoring'),
]
for table, column, label in targets:
fp_migrate_uom_column(env, table, column, label)
def _seed_rack_tags_if_empty(env):
"""Sub 12b — seed 4 starter rack tags."""
Tag = env['fp.rack.tag']
if Tag.search_count([]):
return
starters = [
('Rush', 1),
('Hold for QC', 3),
('Damaged', 9),
('Customer Sample', 5),
]
for name, color in starters:
Tag.create({'name': name, 'color': color})
_logger.info(
'Fusion Plating: seeded %s starter rack tags', len(starters),
)