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

@@ -218,6 +218,31 @@ Use only: `name`, `model_id`, `state`, `code` (or `function`/`model`), `interval
</templates>
```
Reference: `/usr/lib/python3/dist-packages/odoo/addons/web/static/src/views/kanban/kanban_arch_parser.js`. Pre-existing `fp_rack_views.xml` still uses the old name and would also fail at render — fix when next touched. Caught 2026-05-24 by final reviewer of permissions-overhaul branch.
13l. **Post-migration: `env.ref('old_group_xmlid').user_ids` returns empty** even though the old group still exists. Phase H's migration moves users OFF the old (now-`[DEPRECATED]`) groups onto the new ones. Old groups are still reachable via the new group's `implied_ids` (so old ACLs still resolve for backward-compat), but NO USER directly holds the old group anymore. Any code doing `env.ref('fusion_plating.group_fusion_plating_operator').user_ids` to enumerate "operators" gets an empty recordset. **Fix: point at the new group xmlid (`group_fp_technician`).** `res.groups.user_ids` includes both direct AND implied-via-other-group members, so higher roles (Shop Manager, Manager, QM, Owner) appear via implication. The Phase G `has_group()` sweep didn't catch these — `env.ref(...).user_ids` is a different access pattern. Caught 2026-05-24 when the tablet lock screen showed "No operators configured" post-migration. Audit for other instances: `grep -rn "env\.ref.*group_fusion_plating_" --include='*.py'` (skip test files which intentionally reference old xmlids to verify backward-compat).
13k. **Custom fields on `res.users` must be added to `SELF_WRITEABLE_FIELDS` (and often `SELF_READABLE_FIELDS`) or non-admin users can't save their own Preferences dialog**. Odoo 19's User Preferences dialog goes through `res.users.write` on the user's own record — Odoo bypasses the standard write ACL ONLY IF every field being written is in `SELF_WRITEABLE_FIELDS`. Any unknown field forces fallback to the standard ACL (admin-only on entech) → `AccessError: You are not allowed to modify 'User' records. Required group: Access Rights`.
**In Odoo 19, `SELF_WRITEABLE_FIELDS` and `SELF_READABLE_FIELDS` are `@property`-decorated methods, NOT class attributes.** Extend via super(), not list concatenation on `models.Model.SELF_*` (that AttributeErrors at module load — Model base doesn't define them, only res.users does). Canonical pattern (matches hr/res_users.py and mail/res_users.py):
```python
class ResUsers(models.Model):
_inherit = 'res.users'
@property
def SELF_WRITEABLE_FIELDS(self):
return super().SELF_WRITEABLE_FIELDS + [
'x_fc_plating_landing_action_id', 'x_fc_signature_image',
]
@property
def SELF_READABLE_FIELDS(self):
return super().SELF_READABLE_FIELDS + [
'x_fc_plating_role', 'x_fc_tablet_pin_set_date', ...
]
```
Readonly fields on the preferences form ALSO need SELF_READABLE_FIELDS (the form fetches them before the user clicks Save). Methods invoked by buttons that do their own `sudo().write()` bypass this — only DIRECT form-level writes hit the check. Caught 2026-05-24 when Technician tried to save their preferences after the plating landing field was added; the initial fix used the wrong class-attribute syntax and crashed odoo at module load.
13j. **Non-stored Many2many computes STILL require user-level read access on the comodel** for field-assignment cache fill, even when the compute body is wrapped in `sudo()`. The `user.field = [(6, 0, ids)]` assignment populates the cache by relating to comodel records the CURRENT USER must be able to read — `sudo()` on the lookup doesn't help because the assignment is per-record-context. If the comodel is admin-only (like `ir.actions.actions` / `ir.actions.act_window` on entech), a non-admin opening their own preferences will fail with `Failed to write field X. You are not allowed to access 'Y' records.` Two fixes: (a) drop the Many2many compute and use a static domain filter instead, plus add an ACL row granting read on the comodel to whichever role group needs to evaluate the picker domain; (b) replace the Many2many with a Json/Char that stores IDs, lose the auto-validation. Option (a) is simpler — Odoo's design assumes pickers' comodels are user-readable. Caught 2026-05-24 when a Technician tried to open their Preferences after the per-user `accessible_landing_action_ids` field was added.
13i. **`res.users` does NOT have `message_post()`** — chatter posting must go through `user.partner_id.message_post(...)`. `res.users` uses `_inherits = {'res.partner': 'partner_id'}` (delegation), which proxies FIELDS through partner_id but NOT METHODS. `user.message_post(...)` raises `AttributeError: 'res.users' object has no attribute 'message_post'`. Note that mail's tracking IS recorded on the user record (via partner) — the chatter widget on user form displays partner's chatter — but the post call itself targets the partner. Caught 2026-05-24 during Owner approval click on the migration preview screen.
13c. **`res.users.group_ids` NOT `groups_id`**: Odoo 19 renamed the m2m field. Old name doesn't resolve; `@api.depends('groups_id')` raises `ValueError` at module load. Also: domain on relational pickers should use `all_group_ids` (transitive set incl. implied) instead of `group_ids` (only directly-assigned) — otherwise an Owner user won't match a domain looking for QM members. See `feedback_odoo19_groups_id_renamed.md`.
13d. **`post_init_hook` ONLY fires on INSTALL, not UPGRADE** in Odoo 19. For logic that must run on `-u` of an existing install (entech case), add a `migrations/<version>/post-migrate.py` with a `migrate(cr, version)` function that calls the same helper. The hook still works on fresh install; the migration script bridges the gap on `-u`. Both should be idempotent so re-runs are safe.
13g. **Odoo 19's `sale.view_order_form` uses a single `<field name="tax_totals" widget="account-tax-totals-field"/>` widget instead of separate `amount_total` / `amount_untaxed` / `amount_tax` fields**. Inheriting xpaths targeting any of the three separate fields will fail at view load: `Element '<xpath expr="//field[@name='amount_total']">' cannot be located in parent view`. To gate or modify totals, target the `tax_totals` widget (one xpath hides the whole totals block). Other views in the file (kanban, list, pivot) DO still have the individual fields — only the FORM view consolidated to the widget. Same likely applies to `purchase.purchase_order_form` and `account.view_move_form` — verify per-view before porting Odoo 17/18 xpaths. Caught 2026-05-24.
@@ -1586,7 +1611,7 @@ Customer feedback: "too many top-level menus" + "configuration is unorganized".
- Settings → Fusion Plating → Plating Landing Page block (company default).
- `fusion_plating_configurator`'s earlier menu_fp_root override (action_fp_sale_orders direct) was removed — core's resolver now owns the routing.
- Pickable list is curated via inline `<field name="x_fc_pickable_landing" eval="True"/>` on action records — currently flagged: `action_fp_sale_orders`, `action_fp_quotations`, `action_fp_process_recipe`. Add more by tagging the relevant act_window record at its source.
- **`x_fc_pickable_landing` lives on BOTH `ir.actions.act_window` AND `ir.actions.client`** (added Phase E 2026-05-24). Client-action candidates like Manager Desk, Plant Kanban, and Quality Dashboard are pickable too. The resolver helper `ir.actions.act_window._fp_resolve_landing_for_current_user()` polymorphically calls `_render_resolved()` on either action model. When adding a new landing candidate, tag the right model (check whether the underlying `<record ... model="...">` is act_window or actions.client).
- **`x_fc_pickable_landing` lives on `ir.actions.actions` (BASE)** so the picker dropdown on `res.users.x_fc_plating_landing_action_id` can offer BOTH act_window records (Sale Orders, Quotations, Process Recipes) AND client-action records (Manager Desk, Plant Kanban, Quality Dashboard). The picker Many2one points at `ir.actions.actions` (not `act_window`); the domain `[('x_fc_pickable_landing', '=', True)]` filters across all action types. `_render_resolved()` on the base dispatches to the correct subclass by `type`. **Pickable accessibility compute MUST be `sudo()`'d** — non-admin users (Technician, Sales Rep) lack read access on `ir.actions.actions` and opening their own Preferences dialog would AccessError otherwise; the per-user `check_access_rights` per-action still runs unprivileged so the picklist filters correctly. Tag a new landing candidate by adding `<field name="x_fc_pickable_landing" eval="True"/>` to its `<record>` definition — works regardless of whether the model is `ir.actions.act_window` or `ir.actions.client`.
- **Role-based dispatch** (Phase E): the resolver now reads `res.users` group membership and routes by precedence — Owner → Manager Desk; QM → Quality Dashboard; Manager → Manager Desk; Sales Manager → Sale Orders; Shop Manager → Plant Kanban/Workstation; Sales Rep → Quotations; Technician → Plant Kanban/Workstation. `_fp_workstation_action_for_layout()` reads `ir.config_parameter['fusion_plating_shopfloor.layout']` (v2 vs legacy) so flipping the flag retargets every Tech/Shop Manager on next page load. Per-user override still wins. Picklist domain is tightened via `res.users.accessible_landing_action_ids` (compute that runs `check_access_rights('read')` per pickable action) so a Tech can't pick "Manager Desk" they can't see.
### Phase 2 — Configuration sub-folder grouping (`fusion_plating` 19.0.11.1.0, commits `3641b78` + `62c1315` + `4671541`)

View File

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

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'
)) % {

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
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
99 access_ir_actions_actions_plating ir.actions.actions.plating.read base.model_ir_actions_actions fusion_plating.group_fp_technician 1 0 0 0

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Shop Floor',
'version': '19.0.32.0.4',
'version': '19.0.32.0.6',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.',

View File

@@ -217,12 +217,19 @@ class FpTabletController(http.Controller):
@http.route('/fp/tablet/tiles', type='jsonrpc', auth='user')
def tiles(self, station_id=None):
env = request.env
# Phase 1 permissions overhaul (2026-05-24): post-migration users
# hold the new group_fp_technician (old group_fusion_plating_operator
# is still reachable via implication but DIRECT memberships moved to
# the new group). Point the tile grid at Technician — higher roles
# (Shop Manager / Manager / QM / Owner) imply Technician and so
# are included automatically via res.groups.user_ids (which surfaces
# both direct AND implied memberships).
op_group = env.ref(
'fusion_plating.group_fusion_plating_operator',
'fusion_plating.group_fp_technician',
raise_if_not_found=False,
)
if not op_group:
return {'ok': False, 'error': 'operator group missing'}
return {'ok': False, 'error': 'technician group missing'}
# Determine candidate users — station roster wins if non-empty
users = op_group.user_ids

View File

@@ -44,9 +44,14 @@ $_lock-pulse-dot-border-hex: #ffffff;
$_lock-tile-hover-bg-rgba: rgba(240, 165, 0, 0.10) !global;
$_lock-tile-hover-border-rgba: rgba(240, 165, 0, 0.40) !global;
$_lock-tile-hover-shadow: (0 12px 24px rgba(240, 165, 0, 0.15), 0 0 0 1px rgba(240, 165, 0, 0.2)) !global;
$_lock-frame-bg-rgba: rgba(255, 255, 255, 0.08) !global;
$_lock-frame-border-rgba: rgba(255, 255, 255, 0.10) !global;
$_lock-frame-shadow: (0 8px 24px rgba(0, 0, 0, 0.30), inset 0 1px 0 rgba(255, 255, 255, 0.08)) !global;
// Logo frame in dark mode: near-opaque WHITE plate (matches light-mode
// intent) so company logos designed for light backgrounds render
// correctly. The previous 0.08-alpha glass effect made dark-on-white
// logos (and faded-white-on-transparent ones) effectively invisible
// because the dark page bled through.
$_lock-frame-bg-rgba: rgba(255, 255, 255, 0.95) !global;
$_lock-frame-border-rgba: rgba(255, 255, 255, 0.20) !global;
$_lock-frame-shadow: (0 8px 24px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.40)) !global;
$_lock-status-clocked-hex: #34c759 !global;
$_lock-status-pin-hex: #ff9f0a !global;
$_lock-pulse-dot-border-hex: #2d3138 !global;

View File

@@ -17,7 +17,13 @@ $_pin-dot-fill-hex: #1d1d1f;
$_pin-key-bg-hex: #2d3138 !global;
$_pin-key-hover-hex: #3a3f48 !global;
$_pin-border-hex: #424245 !global;
$_pin-dot-fill-hex: #f5f5f7 !global;
// Empty dot: dark gray that blends into the panel but is still
// visible; Filled dot: bright white for strong contrast. Previous
// version left empty at light gray (#d8dadd) and set filled to
// off-white (#f5f5f7) — both light colours, indistinguishable from
// each other on the dark panel so user couldn't see PIN progress.
$_pin-dot-hex: #424245 !global;
$_pin-dot-fill-hex: #ffffff !global;
}
.o_fp_pin_pad {