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