Files
Odoo-Modules/fusion_plating/fusion_plating/models/fp_migration.py
gsinghpal 42036c23ab fix(plating-perms): Phase I post-deploy fixes (live entech test catches)
8 distinct bugs caught + fixed while testing the live admin DB on entech
after the migration was approved. Each surfaced a real Odoo 19 gotcha
now codified in CLAUDE.md (rules 13b-13l).

Picker architecture:
- res.users.x_fc_plating_landing_action_id and res.company.x_fc_default_landing_action_id
  now Many2one('ir.actions.actions') instead of ('ir.actions.act_window'),
  so the picker accepts BOTH window actions (Sale Orders / Quotations /
  Process Recipes) AND client actions (Manager Desk / Plant Kanban /
  Quality Dashboard). Picker went from 3 entries to 6.
- x_fc_pickable_landing field moved from the two subclasses to the
  ir.actions.actions base. Single source of truth.
- _render_resolved on the base dispatches to the correct subclass by
  action type.

Non-admin Preferences access:
- Added ACL grant: group_fp_technician (and all higher roles via
  implication) get read on ir.actions.actions. Without this, opening
  Preferences raised AccessError on the picker domain evaluation.
- Removed the accessible_landing_action_ids Many2many compute (failed
  for non-admins because field assignment requires write access on
  the comodel relation, even with sudo'd search). Picker now shows all
  6 entries to all users; resolver falls through gracefully if the
  user picks an action they can't reach.
- res.users SELF_WRITEABLE_FIELDS / SELF_READABLE_FIELDS extended via
  @property + super() (NOT class attribute — Odoo 19 changed the
  pattern). Non-admin users can now save the Preferences dialog with
  plating fields without hitting the standard write ACL.

Migration workflow:
- res.groups.users -> .user_ids (Odoo 19 rename; deprecated alias
  removed). Was crashing _fp_notify_owners and _cron_purge_expired.
- user.message_post -> user.partner_id.message_post (res.users uses
  _inherits delegation which doesn't expose mail.thread methods).
  Was crashing the Owner approval click.

Tablet lock screen:
- /fp/tablet/tiles points at group_fp_technician instead of the old
  group_fusion_plating_operator. Post-migration nobody holds the old
  group directly (only via implication), so res.groups.user_ids on
  the old xmlid returned empty — 'No operators configured' shown
  even with PIN set.
- PIN pad dots dark mode: empty dot now dark gray (#424245), filled
  dot now pure white. Previous version had both at light shades so
  user couldn't see PIN entry progress.
- Lock-screen logo frame dark mode: near-opaque white plate
  (rgba 0.95) so company logos designed for light backgrounds
  render correctly. Previous 0.08 alpha let the dark page bleed
  through.

Pre-deploy collision fix (already committed before deploy but
documented here for completeness):
- pre-migrate.py to rename old configurator's 'Shop Manager' group
  display name before new fp_security_v2.xml loads the new
  group_fp_shop_manager_v2 with the same display name (avoids
  res_groups_name_uniq violation).

Module versions bumped:
  fusion_plating: 19.0.21.1.0 -> 19.0.21.1.2
  fusion_plating_shopfloor: 19.0.32.0.4 -> 19.0.32.0.6

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:02:32 -04:00

266 lines
10 KiB
Python

# -*- 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.user_ids.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 group_ids for rollback
line.applied_groups_snapshot = json.dumps(user.group_ids.ids)
# Remove old plating-role groups
if old_group_ids:
user.sudo().write({
'group_ids': [(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({'group_ids': [(4, target.id)]})
# Audit chatter on the user
user.partner_id.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({'group_ids': [(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:
# I6 safety check — never unlink a group that still has active
# internal users on it. If anyone still references the group
# we'd cascade-strip them silently from their permissions.
safe_to_unlink = []
skipped = []
for old_group in self.env['res.groups'].browse(old_group_ids).exists():
active_users = old_group.user_ids.filtered(lambda u: u.active and not u.share)
if active_users:
skipped.append((old_group.name, active_users.mapped('login')))
else:
safe_to_unlink.append(old_group.id)
if skipped:
_logger.warning(
'Fusion Plating migration purge: skipped %d old groups with active users: %s',
len(skipped), skipped)
if safe_to_unlink:
self.env['res.groups'].browse(safe_to_unlink).unlink()
_logger.info('Fusion Plating migration: purged %d expired old plating groups',
len(safe_to_unlink))
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 group_ids for rollback')
@api.depends('user_id', 'user_id.group_ids')
def _compute_current_groups(self):
for line in self:
if line.user_id:
line.current_groups = ', '.join(line.user_id.group_ids.mapped('name'))
else:
line.current_groups = ''