feat(plating-migration): dry-run + Owner-approval workflow

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>
This commit is contained in:
gsinghpal
2026-05-24 02:21:43 -04:00
parent de3ec7d97a
commit 5cc1117f75
10 changed files with 597 additions and 1 deletions

View File

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

View File

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