Phase H of permissions overhaul (LAST subagent phase). New models: - fp.migration.preview (state: pending/approved/cancelled/rolled_back) - fp.migration.preview.line (one per active internal user) On -u, post_init_hook creates a preview in 'pending' state, walks all active non-share users through the 12-rule mapping predicate chain (first match wins, highest precedence first), and schedules a mail.activity on every Owner. Mapping table (per spec Section 5): uid 1/2 / Administrator -> owner CGP DO (existing) -> owner + res.company DO field set CGP Officer -> quality_manager Manager / Shop Mgr (old) -> manager Accounting -> manager Estimator-without-Manager -> sales_rep (flagged: loses confirm) Supervisor / Receiving -> shop_manager Operator -> technician catchall -> 'no' Owner clicks 'Approve & Run' on the preview form -> sudo write removes old plating groups, adds new role's group, posts Markup chatter audit. Optionally sets res.company.x_fc_cgp_designated_official_id for the DO. 30-day rollback window via JSON snapshot of groups_id per line. Daily cron (Fusion Plating: Purge Expired Role Migrations) clears snapshots + unlinks old [DEPRECATED] groups after 30 days. ACL: fp.migration.preview + .line both Owner-only (CRUD). Menu: Plating > Configuration > Role Migrations (Owner-only). Tests cover: only-Owner-can-approve, approve advances state, cancel blocks after approval, rollback restores groups_id, Estimator warning flagged, uid 2 maps to owner, rollback blocked after 30 days. Per CLAUDE.md: ir.cron uses only Odoo-19-valid fields (no numbercall, no doall). Post-init hook is idempotent — won't double-create previews or re-fire if all users already migrated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
458 lines
16 KiB
Python
458 lines
16 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.
|
|
5. Phase H — create a pending fp.migration.preview if any user
|
|
still holds an old plating-role group + notify Owners.
|
|
"""
|
|
_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)
|
|
_fp_post_init_role_migration(env)
|
|
|
|
|
|
def _fp_post_init_role_migration(env):
|
|
"""Idempotent: creates a fp.migration.preview if none is pending or applied.
|
|
|
|
Called automatically on `-u fusion_plating`. The preview enters 'pending'
|
|
state and schedules a mail.activity on every Owner. Owner must explicitly
|
|
click 'Approve & Run' to actually apply the migration.
|
|
"""
|
|
Preview = env['fp.migration.preview']
|
|
if Preview.search_count([('state', '=', 'pending')]):
|
|
return
|
|
if Preview.search_count([('state', '=', 'approved')]):
|
|
# Already migrated previously; only re-fire if any unmigrated user remains
|
|
# An unmigrated user is one who still holds an OLD plating group directly
|
|
# AND does NOT hold any NEW role group. The compute on res.users.x_fc_plating_role
|
|
# returns 'no' for users without any new group regardless of their old groups.
|
|
# Heuristic: if any active user still holds an old group, re-fire.
|
|
from .models.fp_role_constants import _FP_OLD_GROUP_XMLIDS
|
|
any_unmigrated = False
|
|
for xmlid in _FP_OLD_GROUP_XMLIDS:
|
|
old_grp = env.ref(xmlid, raise_if_not_found=False)
|
|
if not old_grp:
|
|
continue
|
|
if old_grp.users.filtered(lambda u: u.active and not u.share):
|
|
# Found at least one user still on an old group → re-fire
|
|
any_unmigrated = True
|
|
break
|
|
if not any_unmigrated:
|
|
return # All users migrated; nothing to do
|
|
preview = Preview.create({})
|
|
preview._fp_build_lines()
|
|
preview._fp_notify_owners()
|
|
|
|
|
|
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),
|
|
)
|