From 830b29ce49b2d8250d1594ec965b0920ba9df56d Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 24 May 2026 01:56:37 -0400 Subject: [PATCH] feat(plating-landing): role-based dispatch resolver + picklist expansion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase E of permissions overhaul. The landing resolver now dispatches based on the user's highest role (per spec Section 3): Owner -> Manager Desk Quality Mgr -> Quality Dashboard Manager -> Manager Desk Sales Manager -> Sale Orders Shop Manager -> Plant Kanban (v2 layout) or Workstation (legacy) Sales Rep -> Quotations Technician -> Plant Kanban / Workstation User override (x_fc_plating_landing_action_id) still wins; company default and hardcoded Sale Orders are fallbacks. Layout-flag-aware via ir.config_parameter['fusion_plating_shopfloor.layout'] (v2 vs legacy). x_fc_pickable_landing field added to BOTH ir.actions.act_window AND ir.actions.client (Manager Desk / Plant Kanban / Quality Dashboard are client actions). Resolver helper polymorphically calls _render_resolved() on either model. Tagged 3 of 4 new actions pickable: Manager Desk, Plant Kanban, Quality Dashboard. (action_fp_shopfloor_landing doesn't exist as an XML record — it's a JS component name only; legacy layout falls through to company default gracefully via raise_if_not_found=False.) Tightened picklist domain to filter by user accessibility (Technician no longer sees Manager Desk in the dropdown). New compute field res.users.accessible_landing_action_ids runs check_access_rights on each pickable action. Tests in fusion_plating/tests/test_landing_resolver.py. CLAUDE.md updated with two durable rules: - x_fc_pickable_landing lives on BOTH act_window and actions.client - Role-based dispatch precedence and helper API Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_plating/CLAUDE.md | 2 + .../fusion_plating/models/fp_landing.py | 219 ++++++++++++++++-- .../fusion_plating/tests/__init__.py | 1 + .../tests/test_landing_resolver.py | 150 ++++++++++++ .../fusion_plating_quality/__manifest__.py | 2 +- .../views/fp_quality_dashboard_views.xml | 1 + .../fusion_plating_shopfloor/__manifest__.py | 2 +- .../views/fp_menu.xml | 1 + .../views/fp_plant_overview_views.xml | 1 + 9 files changed, 362 insertions(+), 17 deletions(-) create mode 100644 fusion_plating/fusion_plating/tests/test_landing_resolver.py diff --git a/fusion_plating/CLAUDE.md b/fusion_plating/CLAUDE.md index b0cdee4a..d06df2b7 100644 --- a/fusion_plating/CLAUDE.md +++ b/fusion_plating/CLAUDE.md @@ -1550,6 +1550,8 @@ 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). +- **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/models/fp_landing.py b/fusion_plating/fusion_plating/models/fp_landing.py index ebadf848..7aabff00 100644 --- a/fusion_plating/fusion_plating/models/fp_landing.py +++ b/fusion_plating/fusion_plating/models/fp_landing.py @@ -2,27 +2,43 @@ # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. -"""Phase 1 — Plating landing-page resolver fields. +"""Phase 1 + Phase E — Plating landing-page resolver. -Three pieces: -1. `ir.actions.act_window.x_fc_pickable_landing` — Boolean tag. Mark a - curated set of plating actions (Sale Orders, Plant Overview, - Quotations, Quality Dashboard, Manager Dashboard, Tablet Station, - Labor History) so the landing-page dropdown only offers sensible - options, not all 200 act_window records in the DB. +Layers: -2. `res.company.x_fc_default_landing_action_id` — admin sets the - fallback for users who don't pick a preference. +1. ``ir.actions.act_window.x_fc_pickable_landing`` AND + ``ir.actions.client.x_fc_pickable_landing`` — Boolean tag on BOTH + action types. Mark a curated set of plating actions (Sale Orders, + Quotations, Manager Desk, Plant Kanban, Quality Dashboard, etc.) so + the landing-page dropdown only offers sensible options, not all 200+ + action records in the DB. -3. `res.users.x_fc_plating_landing_action_id` — each user's own - override. +2. ``res.company.x_fc_default_landing_action_id`` — admin sets the + fallback for users who don't pick a preference. References + ``ir.actions.act_window`` (only act_window actions can be selected + as the company default since they're navigable from the menu tree). -The resolver server action (data/fp_landing_data.xml) reads these. +3. ``res.users.x_fc_plating_landing_action_id`` — each user's own + override. References ``ir.actions.act_window`` and is filtered by + the user's actually-accessible actions (Technician can't pick + "Manager Desk" if they can't see it). + +4. ``ir.actions.act_window._fp_resolve_landing_for_current_user()`` — + role-based dispatch resolver. Section 3 of the permissions design + spec. Returns an action dict suitable for the + ``action_fp_resolve_plating_landing`` server action. """ -from odoo import fields, models +from odoo import api, fields, models +# ---------------------------------------------------------------------- +# Pickable-landing tag on BOTH action types +# ---------------------------------------------------------------------- +# The picklist needs to cover client actions (Manager Desk, Plant +# 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' @@ -31,11 +47,148 @@ class IrActionsActWindow(models.Model): default=False, help='When True, this action appears in the Plating landing-' 'page dropdown on res.users and res.company. Tag a small ' - 'curated list (Sale Orders, Plant Overview, etc.) to keep ' + 'curated list (Sale Orders, Manager Desk, etc.) to keep ' 'the picker manageable.', ) + # ------------------------------------------------------------------ + # Resolver — role-based dispatch (Phase E) + # ------------------------------------------------------------------ + @api.model + def _fp_resolve_landing_for_current_user(self): + """Resolve which action to open when the current user clicks the + Plating app. + Priority order: + 1. Per-user override (``res.users.x_fc_plating_landing_action_id``) + 2. Role-based default (``_fp_role_default_landing``) + 3. Company default (``res.company.x_fc_default_landing_action_id``) + 4. Hardcoded last-ditch (Sale Orders) + """ + user = self.env.user + company = self.env.company + + # 1. Per-user override + if 'x_fc_plating_landing_action_id' in user._fields \ + and user.x_fc_plating_landing_action_id: + return user.x_fc_plating_landing_action_id._render_resolved() + + # 2. Role-based default + role_action = self._fp_role_default_landing(user, company) + if role_action: + return role_action._render_resolved() + + # 3. Company default + if 'x_fc_default_landing_action_id' in company._fields \ + and company.x_fc_default_landing_action_id: + return company.x_fc_default_landing_action_id._render_resolved() + + # 4. Hardcoded last-ditch — Sale Orders + fallback = self.env.ref( + 'fusion_plating_configurator.action_fp_sale_orders', + raise_if_not_found=False, + ) + if fallback: + return fallback._render_resolved() + return False + + @api.model + def _fp_role_default_landing(self, user, company): + """Return the per-role default action (recordset, act_window OR + ir.actions.client) for ``user``, or False. + + Precedence is highest role first so a multi-role user + (Manager promoted to QM) gets the upper role's landing. + """ + workstation = self._fp_workstation_action_for_layout(company) + + def safe(xmlid): + return self.env.ref(xmlid, raise_if_not_found=False) + + if user.has_group('fusion_plating.group_fp_owner'): + return safe('fusion_plating_shopfloor.action_fp_manager_dashboard') + if user.has_group('fusion_plating.group_fp_quality_manager'): + return safe('fusion_plating_quality.action_fp_quality_dashboard') + if user.has_group('fusion_plating.group_fp_manager'): + return safe('fusion_plating_shopfloor.action_fp_manager_dashboard') + if user.has_group('fusion_plating.group_fp_sales_manager'): + return safe('fusion_plating_configurator.action_fp_sale_orders') + if user.has_group('fusion_plating.group_fp_shop_manager_v2'): + return workstation + if user.has_group('fusion_plating.group_fp_sales_rep'): + return safe('fusion_plating_configurator.action_fp_quotations') + if user.has_group('fusion_plating.group_fp_technician'): + return workstation + return False + + @api.model + def _fp_workstation_action_for_layout(self, company): + """Single source of truth: which Shop Floor surface is active on + this DB? + + ``ir.config_parameter['fusion_plating_shopfloor.layout']`` is the + feature flag. Flipping it instantly retargets every Technician / + Shop Manager landing on next page load. + """ + param = self.env['ir.config_parameter'].sudo().get_param( + 'fusion_plating_shopfloor.layout', 'v2') + if param == 'v2': + return self.env.ref( + 'fusion_plating_shopfloor.action_fp_plant_kanban', + raise_if_not_found=False, + ) + return self.env.ref( + 'fusion_plating_shopfloor.action_fp_shopfloor_landing', + raise_if_not_found=False, + ) + + def _render_resolved(self): + """Render this act_window record as an action dict that the + landing server action can return. + + Mirrors ``self.sudo().read()[0]`` shape, plus injects ``xml_id`` + so the resolver / tests / breadcrumbs know which curated action + this is. Strips ``id`` because the act_window dispatcher chokes + on it for fresh-load actions. + """ + self.ensure_one() + action = self.sudo().read()[0] + action.pop('id', None) + action['xml_id'] = self.get_external_id().get(self.id) or None + return action + + +class IrActionsClient(models.Model): + """Client actions also need to be tagged as pickable landings — + Manager Desk, Plant Kanban, Quality Dashboard are all client + actions, not act_window records. + + ``_render_resolved`` is defined on this class too so the resolver + can polymorphically call ``action._render_resolved()`` regardless + of which kind of action came back from env.ref(). + """ + _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.', + ) + + def _render_resolved(self): + """Render this client action as a dict for the landing resolver.""" + self.ensure_one() + action = self.sudo().read()[0] + action.pop('id', None) + action['xml_id'] = self.get_external_id().get(self.id) or None + return action + + +# ---------------------------------------------------------------------- +# Company + User landing-action preference fields +# ---------------------------------------------------------------------- class ResCompany(models.Model): _inherit = 'res.company' @@ -55,7 +208,43 @@ class ResUsers(models.Model): x_fc_plating_landing_action_id = fields.Many2one( 'ir.actions.act_window', string='My Plating Landing Page', - domain=[('x_fc_pickable_landing', '=', True)], + # 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)]", help='Personal override for the page that opens when you click ' 'the Plating app. When blank, follows the company default.', ) + + 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('groups_id') + 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/tests/__init__.py b/fusion_plating/fusion_plating/tests/__init__.py index be2bf5fd..5f115bcc 100644 --- a/fusion_plating/fusion_plating/tests/__init__.py +++ b/fusion_plating/fusion_plating/tests/__init__.py @@ -7,3 +7,4 @@ from . import test_role_groups from . import test_acl_migration from . import test_quality_split from . import test_menu_visibility +from . import test_landing_resolver diff --git a/fusion_plating/fusion_plating/tests/test_landing_resolver.py b/fusion_plating/fusion_plating/tests/test_landing_resolver.py new file mode 100644 index 00000000..d9427ab4 --- /dev/null +++ b/fusion_plating/fusion_plating/tests/test_landing_resolver.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# Part of the Fusion Plating product family. +"""Phase E (Plating permissions overhaul) — role-based landing dispatch. + +Section 3 of the design spec covers per-role landing pages: + + Owner -> Manager Desk + Quality Mgr -> Quality Dashboard + Manager -> Manager Desk + Sales Manager -> Sale Orders + Shop Manager -> Plant Kanban (v2) or Workstation (legacy) + Sales Rep -> Quotations + Technician -> Plant Kanban (v2) or Workstation (legacy) + +Per-user override (`x_fc_plating_landing_action_id`) always wins. + +NB: The resolver returns an action dict produced by +`_fp_resolve_landing_for_current_user()`. We compare against the +expected action's xmlid so the test stays robust if module names or +view ordering change downstream. +""" + +from odoo.tests.common import TransactionCase, tagged + + +@tagged('-at_install', 'post_install', 'fp_perms') +class TestLandingResolver(TransactionCase): + """Section 3 of spec: per-role landing dispatch.""" + + def setUp(self): + super().setUp() + Users = self.env['res.users'].with_context(no_reset_password=True) + + def mk(name, xmlid): + return Users.create({ + 'login': f'land_{name}', + 'name': f'Land {name}', + 'email': f'land_{name}@example.com', + 'groups_id': [(6, 0, [self.env.ref(xmlid).id])], + }) + + self.u_tech = mk('tech', 'fusion_plating.group_fp_technician') + self.u_sr = mk('sr', 'fusion_plating.group_fp_sales_rep') + self.u_smg = mk('smg', 'fusion_plating.group_fp_sales_manager') + self.u_mgr = mk('mgr', 'fusion_plating.group_fp_manager') + self.u_qm = mk('qm', 'fusion_plating.group_fp_quality_manager') + self.u_owner = mk('owner', 'fusion_plating.group_fp_owner') + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + def _resolve_xmlid(self, user): + """Run the resolver as `user` and return the xml_id of the resulting + action, or None if no action was returned. + + The resolver lives on `ir.actions.act_window` (helper method, not a + column). It can return an action dict for either an act_window or a + client action — both carry an `xml_id` key once we go through + `_render_resolved`. + """ + Window = self.env['ir.actions.act_window'] + if not hasattr(Window, '_fp_resolve_landing_for_current_user'): + self.skipTest('Resolver helper not implemented yet') + result = Window.with_user(user)._fp_resolve_landing_for_current_user() + if not result: + return None + return result.get('xml_id') or result.get('xmlid') + + def _xmlid_of(self, xmlid): + """Resolve an xmlid and return it back if the action exists. + + Returns None when the underlying action isn't installed in this + DB (e.g. running tests without a sibling module). Callers use this + to skip a test when the candidate action is missing. + """ + action = self.env.ref(xmlid, raise_if_not_found=False) + return xmlid if action else None + + # ------------------------------------------------------------------ + # Per-role tests + # ------------------------------------------------------------------ + def test_owner_lands_on_manager_desk(self): + expected = self._xmlid_of('fusion_plating_shopfloor.action_fp_manager_dashboard') + if not expected: + self.skipTest('Manager Dashboard action not found') + self.assertEqual(self._resolve_xmlid(self.u_owner), expected) + + def test_qm_lands_on_quality_dashboard(self): + expected = self._xmlid_of('fusion_plating_quality.action_fp_quality_dashboard') + if not expected: + self.skipTest('Quality Dashboard action not found') + self.assertEqual(self._resolve_xmlid(self.u_qm), expected) + + def test_manager_lands_on_manager_desk(self): + expected = self._xmlid_of('fusion_plating_shopfloor.action_fp_manager_dashboard') + if not expected: + self.skipTest('Manager Dashboard action not found') + self.assertEqual(self._resolve_xmlid(self.u_mgr), expected) + + def test_sales_manager_lands_on_sale_orders(self): + expected = self._xmlid_of('fusion_plating_configurator.action_fp_sale_orders') + if not expected: + self.skipTest('Sale Orders action not found') + self.assertEqual(self._resolve_xmlid(self.u_smg), expected) + + def test_sales_rep_lands_on_quotations(self): + expected = self._xmlid_of('fusion_plating_configurator.action_fp_quotations') + if not expected: + self.skipTest('Quotations action not found') + self.assertEqual(self._resolve_xmlid(self.u_sr), expected) + + def test_technician_lands_on_plant_kanban_v2(self): + self.env['ir.config_parameter'].sudo().set_param( + 'fusion_plating_shopfloor.layout', 'v2') + expected = self._xmlid_of('fusion_plating_shopfloor.action_fp_plant_kanban') + if not expected: + self.skipTest('Plant Kanban action not found') + self.assertEqual(self._resolve_xmlid(self.u_tech), expected) + + def test_technician_lands_on_legacy_workstation(self): + self.env['ir.config_parameter'].sudo().set_param( + 'fusion_plating_shopfloor.layout', 'legacy') + expected = self._xmlid_of('fusion_plating_shopfloor.action_fp_shopfloor_landing') + if not expected: + # The legacy action is currently not defined by that xmlid + # in this codebase — both old XMLIDs (action_fp_shopfloor_tablet + # and action_fp_plant_overview) point at the v2 fp_plant_kanban + # tag after the 2026-05-23 plant-view redesign. The resolver + # falls through to the company default / hardcoded fallback + # when no action is found. Skip the assertion here rather + # than fail. + self.skipTest('Legacy Workstation action not found in this DB') + self.assertEqual(self._resolve_xmlid(self.u_tech), expected) + # Reset to v2 to avoid bleeding into other tests + self.env['ir.config_parameter'].sudo().set_param( + 'fusion_plating_shopfloor.layout', 'v2') + + # ------------------------------------------------------------------ + # User-override and fallback + # ------------------------------------------------------------------ + def test_user_override_wins(self): + override = self.env.ref('fusion_plating_configurator.action_fp_quotations', + raise_if_not_found=False) + if not override: + self.skipTest('Quotations action not found') + self.u_tech.x_fc_plating_landing_action_id = override.id + expected = override.get_external_id().get(override.id) + self.assertEqual(self._resolve_xmlid(self.u_tech), expected) diff --git a/fusion_plating/fusion_plating_quality/__manifest__.py b/fusion_plating/fusion_plating_quality/__manifest__.py index ccaba8bd..adee94a9 100644 --- a/fusion_plating/fusion_plating_quality/__manifest__.py +++ b/fusion_plating/fusion_plating_quality/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Quality (QMS)', - 'version': '19.0.6.6.4', + 'version': '19.0.6.6.5', 'category': 'Manufacturing/Plating', 'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, ' 'internal audits, customer specs, document control. CE + EE compatible.', diff --git a/fusion_plating/fusion_plating_quality/views/fp_quality_dashboard_views.xml b/fusion_plating/fusion_plating_quality/views/fp_quality_dashboard_views.xml index a977915f..c26b5290 100644 --- a/fusion_plating/fusion_plating_quality/views/fp_quality_dashboard_views.xml +++ b/fusion_plating/fusion_plating_quality/views/fp_quality_dashboard_views.xml @@ -12,6 +12,7 @@ Quality Dashboard fp_quality_dashboard current + Manager Desk fp_manager_dashboard + Shop Floor fp_plant_kanban main +