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>
This commit is contained in:
gsinghpal
2026-05-24 10:02:32 -04:00
parent 7bcbcb4008
commit 42036c23ab
10 changed files with 117 additions and 60 deletions

View File

@@ -39,8 +39,14 @@ from odoo import api, fields, models
# Kanban, Quality Dashboard) too, so we add the same Boolean column
# to ir.actions.client. The resolver returns either kind of action;
# the role dispatch helper uses env.ref(...) which is type-agnostic.
class IrActionsActWindow(models.Model):
_inherit = 'ir.actions.act_window'
class IrActionsActions(models.Model):
"""Base ir.actions.actions extension so x_fc_pickable_landing is
available on BOTH ir.actions.act_window (Sale Orders, Quotations,
Process Recipes) AND ir.actions.client (Manager Desk, Plant Kanban,
Workstation, Quality Dashboard). The picker on res.users / res.company
is Many2one('ir.actions.actions') so it accepts either kind.
"""
_inherit = 'ir.actions.actions'
x_fc_pickable_landing = fields.Boolean(
string='Pickable as Plating Landing',
@@ -51,6 +57,25 @@ class IrActionsActWindow(models.Model):
'the picker manageable.',
)
def _render_resolved(self):
"""Dispatcher — render this action as a dict for the landing resolver.
Routes to the correct subclass based on `type` so both act_window
and client actions resolve correctly."""
self.ensure_one()
if self.type == 'ir.actions.client':
return self.env['ir.actions.client'].browse(self.id)._render_resolved()
if self.type == 'ir.actions.act_window':
return self.env['ir.actions.act_window'].browse(self.id)._render_resolved()
# URL / server / report — generic dict
action = self.sudo().read()[0]
action.pop('id', None)
action['xml_id'] = self.get_external_id().get(self.id) or None
return action
class IrActionsActWindow(models.Model):
_inherit = 'ir.actions.act_window'
# ------------------------------------------------------------------
# Resolver — role-based dispatch (Phase E)
# ------------------------------------------------------------------
@@ -169,13 +194,8 @@ class IrActionsClient(models.Model):
"""
_inherit = 'ir.actions.client'
x_fc_pickable_landing = fields.Boolean(
string='Pickable as Plating Landing',
default=False,
help='When True, this client action appears in the Plating '
'landing-page dropdown on res.users and res.company. Used '
'for Manager Desk, Plant Kanban, Quality Dashboard.',
)
# x_fc_pickable_landing moved to ir.actions.actions base — see IrActionsActions
# above. This subclass keeps _render_resolved for the dispatcher to call.
def _render_resolved(self):
"""Render this client action as a dict for the landing resolver."""
@@ -193,7 +213,7 @@ class ResCompany(models.Model):
_inherit = 'res.company'
x_fc_default_landing_action_id = fields.Many2one(
'ir.actions.act_window',
'ir.actions.actions',
string='Default Plating Landing Page',
domain=[('x_fc_pickable_landing', '=', True)],
help='Page that opens when a user clicks the Plating app, '
@@ -206,45 +226,18 @@ class ResUsers(models.Model):
_inherit = 'res.users'
x_fc_plating_landing_action_id = fields.Many2one(
'ir.actions.act_window',
'ir.actions.actions',
string='My Plating Landing Page',
# Phase E — Tighten picklist to actions the user can actually
# see. The computed M2M `accessible_landing_action_ids` runs an
# access-rights check per pickable action; the picklist only
# offers entries the user could navigate to.
domain="[('x_fc_pickable_landing', '=', True),"
" ('id', 'in', accessible_landing_action_ids)]",
# Picker shows ALL pickable landing actions. Per-user accessibility
# filtering was attempted via a Many2many compute but failed for
# non-admin users because the field assignment requires read on
# ir.actions.actions. Easier path: show all 6 pickable actions to
# everyone, let the resolver fall through gracefully if the user
# picks an action they can't reach (role-based default takes over).
# Read access on ir.actions.actions for plating roles is granted
# via a fusion_plating ACL row (security/ir.model.access.csv).
domain=[('x_fc_pickable_landing', '=', True)],
help='Personal override for the page that opens when you click '
'the Plating app. When blank, follows the company default.',
'the Plating app. When blank, follows the company default '
'and then the role-based default per Section 3 of the spec.',
)
accessible_landing_action_ids = fields.Many2many(
'ir.actions.act_window',
compute='_compute_accessible_landing_action_ids',
store=False,
help='Per-user list of pickable landing actions the user has '
'read access to. Drives the dropdown domain on '
'x_fc_plating_landing_action_id.',
)
@api.depends('group_ids')
def _compute_accessible_landing_action_ids(self):
Window = self.env['ir.actions.act_window']
pickable = Window.sudo().search([('x_fc_pickable_landing', '=', True)])
for user in self:
allowed_ids = []
for action in pickable:
# Actions without a target model (server actions /
# bookmark-style) are always considered accessible —
# the menu they sit under does its own ACL gating.
if not action.res_model:
allowed_ids.append(action.id)
continue
try:
self.env[action.res_model].with_user(user) \
.check_access_rights('read', raise_exception=True)
allowed_ids.append(action.id)
except Exception:
# Access denied for this user — skip
pass
user.accessible_landing_action_ids = [(6, 0, allowed_ids)]

View File

@@ -162,7 +162,7 @@ class FpMigrationPreview(models.Model):
user.sudo().write({'group_ids': [(4, target.id)]})
# Audit chatter on the user
user.message_post(
user.partner_id.message_post(
body=Markup(_(
'Plating role assigned by migration: <b>%s</b>'
)) % line.proposed_role,

View File

@@ -33,6 +33,26 @@ _FP_ROLE_PRECEDENCE = (
class ResUsers(models.Model):
_inherit = 'res.users'
# Allow non-admin users to write their OWN plating-related fields
# from the standard User Preferences dialog. SELF_WRITEABLE_FIELDS is
# a @property in Odoo 19 (not a class attribute) — must override via
# @property + super(). See CLAUDE.md rule 13k.
@property
def SELF_WRITEABLE_FIELDS(self):
return super().SELF_WRITEABLE_FIELDS + [
'x_fc_plating_landing_action_id', # personal landing-page override
'x_fc_signature_image', # "Plating Signature" used on reports
]
@property
def SELF_READABLE_FIELDS(self):
return super().SELF_READABLE_FIELDS + [
'x_fc_plating_landing_action_id',
'x_fc_signature_image',
'x_fc_plating_role',
'x_fc_tablet_pin_set_date',
]
x_fc_plating_role = fields.Selection(
[
('no', 'No'),
@@ -114,7 +134,7 @@ class ResUsers(models.Model):
})
# Post audit (Markup so role names render bold, not literal HTML)
user.message_post(
user.partner_id.message_post(
body=Markup(_(
'Plating role changed: <b>%(old)s</b> -> <b>%(new)s</b> by %(actor)s'
)) % {