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

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

View File

@@ -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': {

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="ir_cron_purge_expired_migrations" model="ir.cron">
<field name="name">Fusion Plating: Purge Expired Role Migrations</field>
<field name="model_id" ref="model_fp_migration_preview"/>
<field name="state">code</field>
<field name="code">model._cron_purge_expired_migrations()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active" eval="True"/>
</record>
</data>
</odoo>

View File

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

View File

@@ -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: <b>%s</b>'
)) % 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 = ''

View File

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

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
94 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
95 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
96 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
97 access_fp_migration_preview_owner fp.migration.preview.owner model_fp_migration_preview fusion_plating.group_fp_owner 1 1 1 1
98 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

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

View File

@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="view_fp_migration_preview_form" model="ir.ui.view">
<field name="name">fp.migration.preview.form</field>
<field name="model">fp.migration.preview</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_approve_and_run" type="object"
string="Approve &amp; Run"
class="oe_highlight"
invisible="state != 'pending'"
confirm="This will apply role changes to all listed users. Continue?"/>
<button name="action_cancel" type="object"
string="Cancel"
invisible="state != 'pending'"/>
<button name="action_rollback" type="object"
string="Rollback"
invisible="state != 'approved'"
confirm="This will restore all users to their pre-migration groups. Continue?"/>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name"/></h1>
</div>
<group>
<group>
<field name="user_count"/>
<field name="warning_count"/>
</group>
<group>
<field name="approved_by_id"/>
<field name="approved_at"/>
<field name="rollback_deadline"/>
</group>
</group>
<notebook>
<page string="Users">
<field name="line_ids">
<list editable="bottom" decoration-warning="warning">
<field name="user_id"/>
<field name="current_groups"/>
<field name="proposed_role"/>
<field name="capability_delta"/>
<field name="warning" widget="boolean_toggle"/>
<field name="notes"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_fp_migration_preview_list" model="ir.ui.view">
<field name="name">fp.migration.preview.list</field>
<field name="model">fp.migration.preview</field>
<field name="arch" type="xml">
<list decoration-warning="state == 'pending'"
decoration-success="state == 'approved'"
decoration-muted="state in ('cancelled', 'rolled_back')">
<field name="name"/>
<field name="state" widget="badge"/>
<field name="user_count"/>
<field name="warning_count"/>
<field name="create_date"/>
<field name="approved_by_id"/>
<field name="approved_at"/>
</list>
</field>
</record>
<record id="action_fp_migration_preview" model="ir.actions.act_window">
<field name="name">Role Migrations</field>
<field name="res_model">fp.migration.preview</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_fp_migration_preview"
name="Role Migrations"
parent="fusion_plating.menu_fp_config"
action="action_fp_migration_preview"
sequence="9"
groups="fusion_plating.group_fp_owner"/>
</data>
</odoo>