diff --git a/fusion_plating/fusion_plating/__init__.py b/fusion_plating/fusion_plating/__init__.py index aaaee1d7..8d76baf1 100644 --- a/fusion_plating/fusion_plating/__init__.py +++ b/fusion_plating/fusion_plating/__init__.py @@ -23,6 +23,8 @@ def post_init_hook(env): 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) @@ -31,6 +33,40 @@ def post_init_hook(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): diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index 4a4f150f..4941fd3f 100644 --- a/fusion_plating/fusion_plating/__manifest__.py +++ b/fusion_plating/fusion_plating/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating', - 'version': '19.0.21.0.5', + 'version': '19.0.21.0.6', 'category': 'Manufacturing/Plating', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'description': """ @@ -140,6 +140,12 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. # 'data/fp_recipe_anodize.xml', # 'data/fp_recipe_chem_conversion.xml', 'data/fp_step_template_data.xml', + # Phase H — Owner-approval migration workflow. + # Views file declares the action + menu; cron declares the + # daily 30-day expiry purge. Both reference model_fp_migration_preview + # which Odoo's model autoload makes available before data load. + 'views/fp_migration_views.xml', + 'data/fp_migration_cron.xml', ], 'post_init_hook': 'post_init_hook', 'assets': { diff --git a/fusion_plating/fusion_plating/data/fp_migration_cron.xml b/fusion_plating/fusion_plating/data/fp_migration_cron.xml new file mode 100644 index 00000000..9d81d461 --- /dev/null +++ b/fusion_plating/fusion_plating/data/fp_migration_cron.xml @@ -0,0 +1,16 @@ + + + + + + Fusion Plating: Purge Expired Role Migrations + + code + model._cron_purge_expired_migrations() + 1 + days + + + + + diff --git a/fusion_plating/fusion_plating/models/__init__.py b/fusion_plating/fusion_plating/models/__init__.py index 946aec1e..466db4ac 100644 --- a/fusion_plating/fusion_plating/models/__init__.py +++ b/fusion_plating/fusion_plating/models/__init__.py @@ -49,3 +49,9 @@ from . import fp_job_step_move # Phase 1 — Plating landing-page resolver from . import fp_landing + +# Phase H — dry-run + Owner-approval role migration workflow. +# fp_role_constants MUST be imported before fp_migration (the latter +# imports the predicate chain + xmlid maps from the former). +from . import fp_role_constants +from . import fp_migration diff --git a/fusion_plating/fusion_plating/models/fp_migration.py b/fusion_plating/fusion_plating/models/fp_migration.py new file mode 100644 index 00000000..a6b2f680 --- /dev/null +++ b/fusion_plating/fusion_plating/models/fp_migration.py @@ -0,0 +1,248 @@ +# -*- coding: utf-8 -*- +"""Phase H — dry-run + Owner-approval migration workflow.""" +import json +import logging +from datetime import timedelta + +from markupsafe import Markup + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +from .fp_role_constants import ( + _FP_OLD_GROUP_XMLIDS, + _NEW_ROLE_XMLID, + fp_resolve_target_role, +) + + +_logger = logging.getLogger(__name__) + + +_ROLE_SELECTION = [ + ('no', 'No'), + ('technician', 'Technician'), + ('sales_rep', 'Sales Representative'), + ('shop_manager', 'Shop Manager'), + ('sales_manager', 'Sales Manager'), + ('manager', 'Manager'), + ('quality_manager', 'Quality Manager'), + ('owner', 'Owner'), +] + + +class FpMigrationPreview(models.Model): + _name = 'fp.migration.preview' + _description = 'Fusion Plating Role Migration Preview' + _inherit = ['mail.thread'] + _order = 'create_date desc' + + name = fields.Char( + default=lambda s: _('Migration %s') % fields.Datetime.now(), + tracking=True, + ) + state = fields.Selection( + [ + ('pending', 'Pending Review'), + ('approved', 'Approved & Applied'), + ('cancelled', 'Cancelled'), + ('rolled_back', 'Rolled Back'), + ], + default='pending', + required=True, + tracking=True, + ) + line_ids = fields.One2many('fp.migration.preview.line', 'preview_id') + user_count = fields.Integer(compute='_compute_counts', store=True) + warning_count = fields.Integer(compute='_compute_counts', store=True) + approved_by_id = fields.Many2one('res.users', readonly=True) + approved_at = fields.Datetime(readonly=True) + rollback_deadline = fields.Datetime(compute='_compute_rollback_deadline') + + @api.depends('line_ids', 'line_ids.warning') + def _compute_counts(self): + for rec in self: + rec.user_count = len(rec.line_ids) + rec.warning_count = sum(1 for ln in rec.line_ids if ln.warning) + + @api.depends('approved_at') + def _compute_rollback_deadline(self): + for rec in self: + rec.rollback_deadline = ( + rec.approved_at + timedelta(days=30) if rec.approved_at else False + ) + + def _fp_build_lines(self): + """Walk all active internal users; one line per user with the + proposed role + capability_delta.""" + self.ensure_one() + Line = self.env['fp.migration.preview.line'] + users = self.env['res.users'].search([ + ('share', '=', False), + ('active', '=', True), + ]) + vals_list = [] + for user in users: + role, delta = fp_resolve_target_role(user) + vals_list.append({ + 'preview_id': self.id, + 'user_id': user.id, + 'proposed_role': role, + 'capability_delta': delta or '', + 'warning': bool(delta), + }) + if vals_list: + Line.create(vals_list) + + def _fp_notify_owners(self): + """Schedule a 'Review Fusion Plating role migration' activity on + every Owner user. Idempotent — won't double-schedule.""" + self.ensure_one() + owner_grp = self.env.ref('fusion_plating.group_fp_owner', raise_if_not_found=False) + if not owner_grp: + return + owners = owner_grp.users.filtered(lambda u: u.active and not u.share) + if not owners: + _logger.warning('Fusion Plating migration preview %s: no Owner users to notify', self.id) + return + activity_type = self.env.ref('mail.mail_activity_data_todo') + for owner in owners: + existing = self.env['mail.activity'].search([ + ('res_model_id', '=', self.env.ref('fusion_plating.model_fp_migration_preview').id), + ('res_id', '=', self.id), + ('user_id', '=', owner.id), + ], limit=1) + if existing: + continue + self.env['mail.activity'].create({ + 'res_model_id': self.env.ref('fusion_plating.model_fp_migration_preview').id, + 'res_id': self.id, + 'activity_type_id': activity_type.id, + 'summary': _('Review Fusion Plating role migration'), + 'note': _('%(n)d users affected, %(w)d with capability changes.') % { + 'n': self.user_count, + 'w': self.warning_count, + }, + 'user_id': owner.id, + 'date_deadline': fields.Date.today(), + }) + + def action_approve_and_run(self): + self.ensure_one() + if not self.env.user.has_group('fusion_plating.group_fp_owner'): + raise UserError(_('Only Owners can approve role migrations.')) + if self.state != 'pending': + raise UserError(_( + 'Migration is no longer pending - current state: %s' + ) % self.state) + + # Resolve old group ids once + old_group_ids = [] + for xmlid in _FP_OLD_GROUP_XMLIDS: + g = self.env.ref(xmlid, raise_if_not_found=False) + if g: + old_group_ids.append(g.id) + + for line in self.line_ids: + user = line.user_id + # Snapshot current groups_id for rollback + line.applied_groups_snapshot = json.dumps(user.groups_id.ids) + + # Remove old plating-role groups + if old_group_ids: + user.sudo().write({ + 'groups_id': [(3, gid) for gid in old_group_ids] + }) + + # Add the new role group (no-op for 'no') + target_xmlid = _NEW_ROLE_XMLID.get(line.proposed_role) + if target_xmlid: + target = self.env.ref(target_xmlid, raise_if_not_found=False) + if target: + user.sudo().write({'groups_id': [(4, target.id)]}) + + # Audit chatter on the user + user.message_post( + body=Markup(_( + 'Plating role assigned by migration: %s' + )) % line.proposed_role, + message_type='notification', + ) + + # Special: CGP DO becomes a res.company field, not a role + if line.capability_delta and 'CGP DO' in line.capability_delta: + user.company_id.x_fc_cgp_designated_official_id = user.id + + self.write({ + 'state': 'approved', + 'approved_by_id': self.env.user.id, + 'approved_at': fields.Datetime.now(), + }) + + def action_cancel(self): + self.ensure_one() + if self.state != 'pending': + raise UserError(_('Only pending migrations can be cancelled.')) + self.state = 'cancelled' + + def action_rollback(self): + self.ensure_one() + if self.state != 'approved': + raise UserError(_('Only approved migrations can be rolled back.')) + if self.rollback_deadline and fields.Datetime.now() > self.rollback_deadline: + raise UserError(_( + 'Rollback window has expired (30 days after approval). ' + 'Restore from pg_dump backup instead.' + )) + for line in self.line_ids: + if line.applied_groups_snapshot: + old_ids = json.loads(line.applied_groups_snapshot) + line.user_id.sudo().write({'groups_id': [(6, 0, old_ids)]}) + self.state = 'rolled_back' + + @api.model + def _cron_purge_expired_migrations(self): + """After 30 days, clear snapshots + unlink old plating groups. + Runs daily via fp_migration_cron.xml.""" + deadline = fields.Datetime.now() - timedelta(days=30) + expired = self.search([ + ('state', '=', 'approved'), + ('approved_at', '<', deadline), + ]) + if not expired: + return + # Clear snapshots (no more rollback possible) + for preview in expired: + preview.line_ids.write({'applied_groups_snapshot': False}) + # Unlink old plating groups (now confirmed unused — every user is + # on the new groups; backward-compat implied_ids chains can drop) + old_group_ids = [] + for xmlid in _FP_OLD_GROUP_XMLIDS: + g = self.env.ref(xmlid, raise_if_not_found=False) + if g: + old_group_ids.append(g.id) + if old_group_ids: + self.env['res.groups'].browse(old_group_ids).unlink() + _logger.info('Fusion Plating migration: purged %d expired old plating groups', len(old_group_ids)) + + +class FpMigrationPreviewLine(models.Model): + _name = 'fp.migration.preview.line' + _description = 'Migration Preview Line' + + preview_id = fields.Many2one('fp.migration.preview', required=True, ondelete='cascade') + user_id = fields.Many2one('res.users', required=True, ondelete='cascade') + current_groups = fields.Char(compute='_compute_current_groups') + proposed_role = fields.Selection(_ROLE_SELECTION) + capability_delta = fields.Char() + warning = fields.Boolean() + notes = fields.Text(help='Owner may annotate before approving') + applied_groups_snapshot = fields.Text(help='JSON of pre-migration groups_id for rollback') + + @api.depends('user_id', 'user_id.groups_id') + def _compute_current_groups(self): + for line in self: + if line.user_id: + line.current_groups = ', '.join(line.user_id.groups_id.mapped('name')) + else: + line.current_groups = '' diff --git a/fusion_plating/fusion_plating/models/fp_role_constants.py b/fusion_plating/fusion_plating/models/fp_role_constants.py new file mode 100644 index 00000000..71c3ad9f --- /dev/null +++ b/fusion_plating/fusion_plating/models/fp_role_constants.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +"""Single source of truth for migration mapping rules + old-group xmlids. + +The mapping predicates are evaluated against res.users records. First match +wins (highest-precedence first). See spec Section 5 + plan Phase H. +""" + +# Every plating role group xmlid that exists BEFORE the migration (deprecated +# but still defined for backward-compat during 30-day rollback window). +_FP_OLD_GROUP_XMLIDS = ( + 'fusion_plating.group_fusion_plating_operator', + 'fusion_plating.group_fusion_plating_supervisor', + 'fusion_plating.group_fusion_plating_manager', + 'fusion_plating.group_fusion_plating_admin', + 'fusion_plating_configurator.group_fp_estimator', + 'fusion_plating_configurator.group_fp_shop_manager', + 'fusion_plating_invoicing.group_fp_accounting', + 'fusion_plating_receiving.group_fp_receiving', + 'fusion_plating_cgp.group_fusion_plating_cgp_officer', + 'fusion_plating_cgp.group_fusion_plating_cgp_designated_official', + 'fusion_plating_jobs.group_fusion_plating_legacy_menus', +) + +# New role -> the group xmlid to add when migration assigns this role. +# 'no' maps to None (no plating group added; old ones still get removed). +_NEW_ROLE_XMLID = { + 'no': None, + 'technician': 'fusion_plating.group_fp_technician', + 'sales_rep': 'fusion_plating.group_fp_sales_rep', + 'shop_manager': 'fusion_plating.group_fp_shop_manager_v2', + 'sales_manager': 'fusion_plating.group_fp_sales_manager', + 'manager': 'fusion_plating.group_fp_manager', + 'quality_manager': 'fusion_plating.group_fp_quality_manager', + 'owner': 'fusion_plating.group_fp_owner', +} + +# Mapping rules: (label, predicate, new_role, capability_delta_or_None) +# Highest precedence first; first match wins. +# Predicate is a callable taking a res.users record; returns bool. +_FP_ROLE_MAPPING_RULES = [ + ('uid_1_or_2', + lambda u: u.id in (1, 2), + 'owner', None), + ('admin', + lambda u: u.has_group('fusion_plating.group_fusion_plating_admin'), + 'owner', None), + ('cgp_designated_official', + lambda u: u.has_group('fusion_plating_cgp.group_fusion_plating_cgp_designated_official'), + 'owner', 'Was CGP DO; field set on res.company'), + ('cgp_officer', + lambda u: u.has_group('fusion_plating_cgp.group_fusion_plating_cgp_officer'), + 'quality_manager', None), + ('manager', + lambda u: u.has_group('fusion_plating.group_fusion_plating_manager'), + 'manager', None), + ('shop_manager_old', + lambda u: u.has_group('fusion_plating_configurator.group_fp_shop_manager'), + 'manager', None), + ('accounting', + lambda u: u.has_group('fusion_plating_invoicing.group_fp_accounting'), + 'manager', None), + ('estimator_alone', + lambda u: (u.has_group('fusion_plating_configurator.group_fp_estimator') + and not u.has_group('fusion_plating.group_fusion_plating_manager')), + 'sales_rep', 'Loses order-confirm authority'), + ('supervisor', + lambda u: u.has_group('fusion_plating.group_fusion_plating_supervisor'), + 'shop_manager', None), + ('receiving', + lambda u: u.has_group('fusion_plating_receiving.group_fp_receiving'), + 'shop_manager', None), + ('operator', + lambda u: u.has_group('fusion_plating.group_fusion_plating_operator'), + 'technician', None), + ('catchall', + lambda u: True, + 'no', None), +] + + +def fp_resolve_target_role(user): + """Returns (role_key, capability_delta_or_None). First predicate match wins.""" + for _label, predicate, role, delta in _FP_ROLE_MAPPING_RULES: + if predicate(user): + return role, delta + return 'no', None diff --git a/fusion_plating/fusion_plating/security/ir.model.access.csv b/fusion_plating/fusion_plating/security/ir.model.access.csv index 49ff3f26..4fa5ef83 100644 --- a/fusion_plating/fusion_plating/security/ir.model.access.csv +++ b/fusion_plating/fusion_plating/security/ir.model.access.csv @@ -94,3 +94,5 @@ access_fp_job_step_move_manager,fp.job.step.move.manager,model_fp_job_step_move, access_fp_job_step_move_input_value_operator,fp.job.step.move.input.value.operator,model_fp_job_step_move_input_value,fusion_plating.group_fp_technician,1,1,1,0 access_fp_job_step_move_input_value_supervisor,fp.job.step.move.input.value.supervisor,model_fp_job_step_move_input_value,fusion_plating.group_fp_shop_manager_v2,1,1,1,0 access_fp_job_step_move_input_value_manager,fp.job.step.move.input.value.manager,model_fp_job_step_move_input_value,fusion_plating.group_fp_manager,1,1,1,1 +access_fp_migration_preview_owner,fp.migration.preview.owner,model_fp_migration_preview,fusion_plating.group_fp_owner,1,1,1,1 +access_fp_migration_preview_line_owner,fp.migration.preview.line.owner,model_fp_migration_preview_line,fusion_plating.group_fp_owner,1,1,1,1 diff --git a/fusion_plating/fusion_plating/tests/__init__.py b/fusion_plating/fusion_plating/tests/__init__.py index 987e873f..50a9fc0f 100644 --- a/fusion_plating/fusion_plating/tests/__init__.py +++ b/fusion_plating/fusion_plating/tests/__init__.py @@ -10,3 +10,4 @@ from . import test_menu_visibility from . import test_landing_resolver from . import test_team_page from . import test_sales_manager_gate +from . import test_migration_workflow diff --git a/fusion_plating/fusion_plating/tests/test_migration_workflow.py b/fusion_plating/fusion_plating/tests/test_migration_workflow.py new file mode 100644 index 00000000..0e6b31be --- /dev/null +++ b/fusion_plating/fusion_plating/tests/test_migration_workflow.py @@ -0,0 +1,103 @@ +import json +from odoo.tests.common import TransactionCase, tagged +from odoo.exceptions import UserError + + +@tagged('-at_install', 'post_install', 'fp_perms') +class TestMigrationWorkflow(TransactionCase): + + def setUp(self): + super().setUp() + Users = self.env['res.users'].with_context(no_reset_password=True) + self.owner = Users.create({ + 'login': 'mig_owner', 'name': 'Mig Owner', + 'email': 'mig_owner@example.com', + 'groups_id': [(6, 0, [self.env.ref('fusion_plating.group_fp_owner').id])], + }) + + def test_only_owner_can_approve(self): + non_owner = self.env['res.users'].with_context(no_reset_password=True).create({ + 'login': 'mig_nonowner', 'name': 'Non Owner', + 'email': 'mig_nonowner@example.com', + 'groups_id': [(6, 0, [self.env.ref('fusion_plating.group_fp_manager').id])], + }) + preview = self.env['fp.migration.preview'].create({}) + preview._fp_build_lines() + with self.assertRaises(UserError): + preview.with_user(non_owner).action_approve_and_run() + + def test_approve_advances_state(self): + preview = self.env['fp.migration.preview'].create({}) + preview._fp_build_lines() + preview.with_user(self.owner).action_approve_and_run() + self.assertEqual(preview.state, 'approved') + self.assertTrue(preview.approved_at) + self.assertEqual(preview.approved_by_id, self.owner) + + def test_cancel_advances_state(self): + preview = self.env['fp.migration.preview'].create({}) + preview.action_cancel() + self.assertEqual(preview.state, 'cancelled') + + def test_cancel_blocked_after_approval(self): + preview = self.env['fp.migration.preview'].create({}) + preview._fp_build_lines() + preview.with_user(self.owner).action_approve_and_run() + with self.assertRaises(UserError): + preview.action_cancel() + + def test_rollback_restores_groups(self): + # Create a test user with an old Manager group + old_mgr = self.env.ref('fusion_plating.group_fusion_plating_manager') + u = self.env['res.users'].with_context(no_reset_password=True).create({ + 'login': 'mig_rb', 'name': 'RB', + 'email': 'mig_rb@example.com', + 'groups_id': [(6, 0, [old_mgr.id])], + }) + before_ids = sorted(u.groups_id.ids) + preview = self.env['fp.migration.preview'].create({}) + preview._fp_build_lines() + preview.with_user(self.owner).action_approve_and_run() + # Verify the migration changed things + u.invalidate_recordset() + # Now rollback + preview.with_user(self.owner).action_rollback() + u.invalidate_recordset() + self.assertEqual(sorted(u.groups_id.ids), before_ids, + 'Rollback must restore original groups_id') + self.assertEqual(preview.state, 'rolled_back') + + def test_estimator_warning_flagged(self): + est = self.env.ref('fusion_plating_configurator.group_fp_estimator', raise_if_not_found=False) + if not est: + self.skipTest('Estimator group not defined') + u = self.env['res.users'].with_context(no_reset_password=True).create({ + 'login': 'mig_est', 'name': 'Est', + 'email': 'mig_est@example.com', + 'groups_id': [(6, 0, [est.id])], + }) + preview = self.env['fp.migration.preview'].create({}) + preview._fp_build_lines() + line = preview.line_ids.filtered(lambda l: l.user_id == u) + self.assertTrue(line.warning, + 'Estimator-only user should be flagged for capability loss') + self.assertEqual(line.proposed_role, 'sales_rep') + + def test_admin_user_maps_to_owner(self): + # uid 2 always gets owner via the first mapping rule + preview = self.env['fp.migration.preview'].create({}) + preview._fp_build_lines() + admin_line = preview.line_ids.filtered(lambda l: l.user_id.id == 2) + if admin_line: + self.assertEqual(admin_line.proposed_role, 'owner') + + def test_rollback_blocked_after_30_days(self): + from datetime import timedelta + preview = self.env['fp.migration.preview'].create({}) + preview._fp_build_lines() + preview.with_user(self.owner).action_approve_and_run() + # Backdate approved_at by 31 days + preview.approved_at = preview.approved_at - timedelta(days=31) + preview.invalidate_recordset(['rollback_deadline']) + with self.assertRaises(UserError): + preview.with_user(self.owner).action_rollback() diff --git a/fusion_plating/fusion_plating/views/fp_migration_views.xml b/fusion_plating/fusion_plating/views/fp_migration_views.xml new file mode 100644 index 00000000..bc359a89 --- /dev/null +++ b/fusion_plating/fusion_plating/views/fp_migration_views.xml @@ -0,0 +1,92 @@ + + + + + + fp.migration.preview.form + fp.migration.preview + +
+
+
+ +
+

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + fp.migration.preview.list + fp.migration.preview + + + + + + + + + + + + + + + Role Migrations + fp.migration.preview + list,form + + + + +
+