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:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating',
|
||||
'version': '19.0.21.1.0',
|
||||
'version': '19.0.21.1.2',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
)) % {
|
||||
|
||||
@@ -96,3 +96,4 @@ access_fp_job_step_move_input_value_supervisor,fp.job.step.move.input.value.supe
|
||||
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
|
||||
access_ir_actions_actions_plating,ir.actions.actions.plating.read,base.model_ir_actions_actions,fusion_plating.group_fp_technician,1,0,0,0
|
||||
|
||||
|
Reference in New Issue
Block a user