diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index deeab6a5..28682dcc 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.0.3', + 'version': '19.0.21.0.4', 'category': 'Manufacturing/Plating', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'description': """ @@ -115,6 +115,11 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. 'views/fp_operator_certification_views.xml', 'views/res_config_settings_views.xml', 'views/fp_landing_views.xml', + # Phase F — Owner-only Team page + Designated Officials on res.company. + # Both reference menu_fp_config (Configuration root) and Phase 1 + # role groups, all loaded earlier (fp_menu.xml + fp_security_v2.xml). + 'views/fp_team_views.xml', + 'views/res_company_views.xml', 'views/fp_work_centre_views.xml', 'views/fp_job_views.xml', 'views/fp_job_step_views.xml', diff --git a/fusion_plating/fusion_plating/models/__init__.py b/fusion_plating/fusion_plating/models/__init__.py index 2f6476c0..946aec1e 100644 --- a/fusion_plating/fusion_plating/models/__init__.py +++ b/fusion_plating/fusion_plating/models/__init__.py @@ -25,6 +25,7 @@ from . import fp_job_step_timelog from . import fp_operator_certification from . import fp_tz from . import res_company +from . import res_users from . import res_config_settings # Phase 1 (Sub 11) — relocated from fusion_plating_bridge_mrp via diff --git a/fusion_plating/fusion_plating/models/res_company.py b/fusion_plating/fusion_plating/models/res_company.py index 66e9fd14..3d004837 100644 --- a/fusion_plating/fusion_plating/models/res_company.py +++ b/fusion_plating/fusion_plating/models/res_company.py @@ -185,3 +185,28 @@ class ResCompany(models.Model): 'When BOTH are blank the report falls back to a hardcoded ' 'AS9100/ISO 9001 statement.', ) + + # ===================================================================== + # Phase F — Plating Designated Officials + # ===================================================================== + # These are SPECIFIC NAMED PEOPLE registered with regulatory bodies. + # Stored as Many2one to res.users so the link survives renames. + # View-level domain restricts the picker to Owner or Quality Manager + # group members (a Python-side domain would resolve groups by id at + # recordset load and is fragile across DB migrations). + x_fc_cgp_designated_official_id = fields.Many2one( + 'res.users', + string='CGP Designated Official', + tracking=True, + help='Specific person registered with PSPC as Designated Official ' + 'under Defence Production Act §22. Must be Owner or Quality ' + 'Manager.', + ) + + x_fc_nadcap_authority_user_id = fields.Many2one( + 'res.users', + string='Nadcap Authority', + tracking=True, + help='Specific person who signs Nadcap-specific certificates and ' + 'audits. Must be Owner or Quality Manager.', + ) diff --git a/fusion_plating/fusion_plating/models/res_users.py b/fusion_plating/fusion_plating/models/res_users.py new file mode 100644 index 00000000..fdfe4d0c --- /dev/null +++ b/fusion_plating/fusion_plating/models/res_users.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# Part of the Fusion Plating product family. +"""Fusion Plating role helpers on res.users. + +The x_fc_plating_role Selection field is a clean UX wrapper around the +seven plating-role groups. Owner-only Team page reads/writes this field +via drag-and-drop on a kanban grouped by role. +""" +from markupsafe import Markup + +from odoo import _, api, fields, models + + +_FP_PLATING_ROLE_TO_GROUP_XMLID = { + 'technician': 'fusion_plating.group_fp_technician', + 'sales_rep': 'fusion_plating.group_fp_sales_rep', + 'shop_manager': 'fusion_plating.group_fp_shop_manager_v2', + 'sales_manager': 'fusion_plating.group_fp_sales_manager', + 'manager': 'fusion_plating.group_fp_manager', + 'quality_manager': 'fusion_plating.group_fp_quality_manager', + 'owner': 'fusion_plating.group_fp_owner', +} + +# Highest precedence first — first match wins +_FP_ROLE_PRECEDENCE = ( + 'owner', 'quality_manager', 'manager', 'sales_manager', + 'shop_manager', 'sales_rep', 'technician', +) + + +class ResUsers(models.Model): + _inherit = 'res.users' + + x_fc_plating_role = fields.Selection( + [ + ('no', 'No'), + ('technician', 'Technician'), + ('sales_rep', 'Sales Representative'), + ('shop_manager', 'Shop Manager'), + ('sales_manager', 'Sales Manager'), + ('manager', 'Manager'), + ('quality_manager', 'Quality Manager'), + ('owner', 'Owner'), + ], + compute='_compute_plating_role', + inverse='_inverse_plating_role', + store=True, + string='Fusion Plating Role', + help='Highest plating role currently held by this user. Changing this ' + 'field reassigns the user to the corresponding res.groups (clears ' + 'old plating groups, adds new). Posts an audit chatter message.', + ) + + @api.depends('groups_id') + def _compute_plating_role(self): + # Resolve xmlids once + role_to_group = {} + for role, xmlid in _FP_PLATING_ROLE_TO_GROUP_XMLID.items(): + grp = self.env.ref(xmlid, raise_if_not_found=False) + if grp: + role_to_group[role] = grp + for user in self: + user.x_fc_plating_role = 'no' + for candidate in _FP_ROLE_PRECEDENCE: + grp = role_to_group.get(candidate) + if grp and grp in user.groups_id: + user.x_fc_plating_role = candidate + break + + def _inverse_plating_role(self): + # Resolve all plating-role group ids + all_role_ids = [] + role_to_group = {} + for role, xmlid in _FP_PLATING_ROLE_TO_GROUP_XMLID.items(): + grp = self.env.ref(xmlid, raise_if_not_found=False) + if grp: + role_to_group[role] = grp + all_role_ids.append(grp.id) + + for user in self: + old_role = user._origin.x_fc_plating_role if user._origin else None + new_role = user.x_fc_plating_role + + # Remove every plating-role group (additive-by-default Odoo + # m2m write of (3, id) removes single rows) + user.sudo().write({ + 'groups_id': [(3, gid) for gid in all_role_ids] + }) + + # Add the chosen role (no-op for 'no') + if new_role and new_role != 'no': + target = role_to_group.get(new_role) + if target: + user.sudo().write({ + 'groups_id': [(4, target.id)] + }) + + # Post audit (Markup() so role names render as bold, not literal HTML) + user.message_post( + body=Markup(_( + 'Plating role changed: %(old)s -> %(new)s by %(actor)s' + )) % { + 'old': old_role or 'unset', + 'new': new_role or 'unset', + 'actor': self.env.user.name, + }, + message_type='notification', + ) diff --git a/fusion_plating/fusion_plating/tests/__init__.py b/fusion_plating/fusion_plating/tests/__init__.py index 5f115bcc..3ef5dd19 100644 --- a/fusion_plating/fusion_plating/tests/__init__.py +++ b/fusion_plating/fusion_plating/tests/__init__.py @@ -8,3 +8,4 @@ from . import test_acl_migration from . import test_quality_split from . import test_menu_visibility from . import test_landing_resolver +from . import test_team_page diff --git a/fusion_plating/fusion_plating/tests/test_team_page.py b/fusion_plating/fusion_plating/tests/test_team_page.py new file mode 100644 index 00000000..0c95c34c --- /dev/null +++ b/fusion_plating/fusion_plating/tests/test_team_page.py @@ -0,0 +1,104 @@ +from odoo.tests.common import TransactionCase, tagged + + +@tagged('-at_install', 'post_install', 'fp_perms') +class TestTeamPage(TransactionCase): + """Phase F — Owner-only Team management page. + Covers x_fc_plating_role compute/inverse + audit chatter + menu visibility.""" + + def setUp(self): + super().setUp() + Users = self.env['res.users'].with_context(no_reset_password=True) + self.owner = Users.create({ + 'login': 'team_owner', 'name': 'Team Owner', + 'email': 'team_owner@example.com', + 'groups_id': [(6, 0, [self.env.ref('fusion_plating.group_fp_owner').id])], + }) + self.target = Users.create({ + 'login': 'team_target', 'name': 'Team Target', + 'email': 'team_target@example.com', + 'groups_id': [(6, 0, [self.env.ref('fusion_plating.group_fp_technician').id])], + }) + + def test_compute_returns_technician(self): + self.assertEqual(self.target.x_fc_plating_role, 'technician') + + def test_compute_picks_highest_role(self): + # Add Manager group on top of Technician + self.target.write({'groups_id': [(4, self.env.ref('fusion_plating.group_fp_manager').id)]}) + self.target.invalidate_recordset(['x_fc_plating_role']) + self.assertEqual(self.target.x_fc_plating_role, 'manager') + + def test_inverse_sets_only_chosen_role(self): + self.target.with_user(self.owner).x_fc_plating_role = 'shop_manager' + # Shop Manager group should be present, Technician should be ABSENT + sm = self.env.ref('fusion_plating.group_fp_shop_manager_v2') + tech = self.env.ref('fusion_plating.group_fp_technician') + self.assertIn(sm, self.target.groups_id) + # Technician is implied via shop_manager_v2.implied_ids → so it IS in user's + # transitive group set. But the inverse should NOT have ADDED it directly. + # Verify by checking groups_id (which Odoo stores as the union of explicit + # + implied groups) — Technician will be present via implication. That's + # correct. What we want to verify is no OTHER plating role is set explicitly. + # Easier assertion: after setting to shop_manager, compute should return + # shop_manager (highest plating role held). + self.target.invalidate_recordset(['x_fc_plating_role']) + self.assertEqual(self.target.x_fc_plating_role, 'shop_manager') + + def test_inverse_to_no_clears_all_plating_roles(self): + # Start as Manager + self.target.with_user(self.owner).x_fc_plating_role = 'manager' + self.target.invalidate_recordset(['x_fc_plating_role']) + self.assertEqual(self.target.x_fc_plating_role, 'manager') + # Set to 'no' + self.target.with_user(self.owner).x_fc_plating_role = 'no' + self.target.invalidate_recordset(['x_fc_plating_role']) + # Verify no plating group remains + plating_groups = [ + self.env.ref(f'fusion_plating.group_fp_{x}', raise_if_not_found=False) + for x in ('technician', 'sales_rep', 'shop_manager_v2', + 'sales_manager', 'manager', 'quality_manager', 'owner') + ] + for g in plating_groups: + if g: + self.assertNotIn(g, self.target.groups_id, + f'{g.name} should be removed when role=no') + self.assertEqual(self.target.x_fc_plating_role, 'no') + + def test_inverse_posts_chatter_audit(self): + before = self.target.message_ids + self.target.with_user(self.owner).x_fc_plating_role = 'manager' + after = self.target.message_ids - before + self.assertTrue(after, 'Role change must post a chatter message') + # Verify the message body mentions the role change + bodies = ' '.join(after.mapped('body')) + self.assertIn('manager', bodies.lower()) + + def test_team_menu_visible_to_owner(self): + menu = self.env.ref('fusion_plating.menu_fp_team', raise_if_not_found=False) + if not menu: + self.skipTest('menu_fp_team not found') + visible = self.env['ir.ui.menu'].with_user(self.owner).search_count([('id', '=', menu.id)]) + self.assertTrue(visible) + + def test_team_menu_hidden_from_manager(self): + menu = self.env.ref('fusion_plating.menu_fp_team', raise_if_not_found=False) + if not menu: + self.skipTest('menu_fp_team not found') + mgr = self.env['res.users'].with_context(no_reset_password=True).create({ + 'login': 'team_mgr', 'name': 'Team Mgr', + 'email': 'team_mgr@example.com', + 'groups_id': [(6, 0, [self.env.ref('fusion_plating.group_fp_manager').id])], + }) + visible = self.env['ir.ui.menu'].with_user(mgr).search_count([('id', '=', menu.id)]) + self.assertFalse(visible, 'Manager must not see Team menu (Owner-only)') + + def test_cgp_do_field_on_company(self): + co = self.env.company + self.assertTrue(hasattr(co, 'x_fc_cgp_designated_official_id'), + 'res.company must have x_fc_cgp_designated_official_id field') + + def test_nadcap_authority_field_on_company(self): + co = self.env.company + self.assertTrue(hasattr(co, 'x_fc_nadcap_authority_user_id'), + 'res.company must have x_fc_nadcap_authority_user_id field') diff --git a/fusion_plating/fusion_plating/views/fp_team_views.xml b/fusion_plating/fusion_plating/views/fp_team_views.xml new file mode 100644 index 00000000..a5dd7b21 --- /dev/null +++ b/fusion_plating/fusion_plating/views/fp_team_views.xml @@ -0,0 +1,64 @@ + + + + + + + + res.users.fp.team.kanban + res.users + + + + + + + + + + + +
+
+ Photo +
+
+ +
+ +
+
+ Last seen: +
+
+
+
+
+
+
+
+ + + Team + res.users + kanban,list,form + [('share', '=', False), ('active', '=', True)] + {'search_default_groupby_plating_role': 1} + + + + +
+
diff --git a/fusion_plating/fusion_plating/views/res_company_views.xml b/fusion_plating/fusion_plating/views/res_company_views.xml new file mode 100644 index 00000000..f1454c59 --- /dev/null +++ b/fusion_plating/fusion_plating/views/res_company_views.xml @@ -0,0 +1,25 @@ + + + + + + res.company.form.fp.designated.officials + res.company + + + + + + + + + + + + + + +