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:
@@ -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):
|
||||
|
||||
@@ -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': {
|
||||
|
||||
16
fusion_plating/fusion_plating/data/fp_migration_cron.xml
Normal file
16
fusion_plating/fusion_plating/data/fp_migration_cron.xml
Normal 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>
|
||||
@@ -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
|
||||
|
||||
248
fusion_plating/fusion_plating/models/fp_migration.py
Normal file
248
fusion_plating/fusion_plating/models/fp_migration.py
Normal 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 = ''
|
||||
86
fusion_plating/fusion_plating/models/fp_role_constants.py
Normal file
86
fusion_plating/fusion_plating/models/fp_role_constants.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
103
fusion_plating/fusion_plating/tests/test_migration_workflow.py
Normal file
103
fusion_plating/fusion_plating/tests/test_migration_workflow.py
Normal 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()
|
||||
92
fusion_plating/fusion_plating/views/fp_migration_views.xml
Normal file
92
fusion_plating/fusion_plating/views/fp_migration_views.xml
Normal 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 & 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>
|
||||
Reference in New Issue
Block a user