diff --git a/fusion_plating/CLAUDE.md b/fusion_plating/CLAUDE.md index e107bd54..660973b5 100644 --- a/fusion_plating/CLAUDE.md +++ b/fusion_plating/CLAUDE.md @@ -218,6 +218,31 @@ Use only: `name`, `model_id`, `state`, `code` (or `function`/`model`), `interval ``` 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//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 `` 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 '' 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 `` 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 `` 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 `` to its `` 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`) diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index cadc5dd8..e6ba92f7 100644 --- a/fusion_plating/fusion_plating/__manifest__.py +++ b/fusion_plating/fusion_plating/__manifest__.py @@ -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': """ diff --git a/fusion_plating/fusion_plating/models/fp_landing.py b/fusion_plating/fusion_plating/models/fp_landing.py index ee7477ed..465e5d70 100644 --- a/fusion_plating/fusion_plating/models/fp_landing.py +++ b/fusion_plating/fusion_plating/models/fp_landing.py @@ -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)] diff --git a/fusion_plating/fusion_plating/models/fp_migration.py b/fusion_plating/fusion_plating/models/fp_migration.py index df237205..9552a7be 100644 --- a/fusion_plating/fusion_plating/models/fp_migration.py +++ b/fusion_plating/fusion_plating/models/fp_migration.py @@ -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: %s' )) % line.proposed_role, diff --git a/fusion_plating/fusion_plating/models/res_users.py b/fusion_plating/fusion_plating/models/res_users.py index 6afea2d6..d5e146ce 100644 --- a/fusion_plating/fusion_plating/models/res_users.py +++ b/fusion_plating/fusion_plating/models/res_users.py @@ -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: %(old)s -> %(new)s by %(actor)s' )) % { diff --git a/fusion_plating/fusion_plating/security/ir.model.access.csv b/fusion_plating/fusion_plating/security/ir.model.access.csv index 4fa5ef83..70ec2856 100644 --- a/fusion_plating/fusion_plating/security/ir.model.access.csv +++ b/fusion_plating/fusion_plating/security/ir.model.access.csv @@ -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 diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py index 996e0e6e..56b16e16 100644 --- a/fusion_plating/fusion_plating_shopfloor/__manifest__.py +++ b/fusion_plating/fusion_plating_shopfloor/__manifest__.py @@ -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.', diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py index 0906ae99..ac3eadaf 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py @@ -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 diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/_tablet_lock_tokens.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/_tablet_lock_tokens.scss index d5b2bbfa..6099c349 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/scss/_tablet_lock_tokens.scss +++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/_tablet_lock_tokens.scss @@ -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; diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_pin_pad.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_pin_pad.scss index fcdaa00f..8c3acc78 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_pin_pad.scss +++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_pin_pad.scss @@ -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 {