feat(plating-team): Owner-only Team kanban + Designated Official fields

Phase F of permissions overhaul.

Adds res.users.x_fc_plating_role Selection field (8 options matching
the role hierarchy). Compute reads highest plating group from
groups_id (precedence: owner > QM > manager > sales_manager >
shop_manager > sales_rep > technician). Inverse uses sudo().write()
to clear all plating-role groups (additive-by-default m2m (3, id))
then adds the chosen one, and posts a Markup-wrapped chatter audit
naming the actor.

New Owner-only menu: Plating > Configuration > Team. Standard
res.users kanban grouped by x_fc_plating_role with records_draggable
for drag-and-drop role changes. Domain hides shared/portal users
and archived users.

res.company gains two Designated Official fields:
- x_fc_cgp_designated_official_id (CGP DO per Defence Production Act §22)
- x_fc_nadcap_authority_user_id (Nadcap signer)

Both tracking=True for audit. View-level domain restricts picker to
Owner or Quality Manager users via [(ref('...'), ref('...'))] xmlid
domains. New 'Plating Designated Officials' page on res.company form,
Owner-only visibility.

Tests in test_team_page.py cover compute/inverse/chatter/menu.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-24 02:03:44 -04:00
parent 830b29ce49
commit 89a937fb32
8 changed files with 336 additions and 1 deletions

View File

@@ -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',

View File

@@ -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

View File

@@ -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.',
)

View File

@@ -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: <b>%(old)s</b> -> <b>%(new)s</b> by %(actor)s'
)) % {
'old': old_role or 'unset',
'new': new_role or 'unset',
'actor': self.env.user.name,
},
message_type='notification',
)

View File

@@ -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

View File

@@ -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')

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Owner-only Team page: kanban of internal users grouped by plating role.
Drag-and-drop a card between columns changes the user's role
(inverse handler on res.users.x_fc_plating_role). -->
<record id="view_fp_team_kanban" model="ir.ui.view">
<field name="name">res.users.fp.team.kanban</field>
<field name="model">res.users</field>
<field name="arch" type="xml">
<kanban default_group_by="x_fc_plating_role"
class="o_kanban_small_column"
group_create="false"
group_delete="false"
records_draggable="true">
<field name="id"/>
<field name="x_fc_plating_role"/>
<field name="login"/>
<field name="email"/>
<field name="image_128"/>
<field name="login_date"/>
<field name="name"/>
<templates>
<t t-name="kanban-box">
<div t-attf-class="oe_kanban_card oe_kanban_global_click">
<div class="o_kanban_image">
<img t-att-src="kanban_image('res.users', 'image_128', record.id.raw_value)"
alt="Photo"/>
</div>
<div class="oe_kanban_details">
<strong><field name="name"/></strong>
<div t-if="record.email.raw_value">
<field name="email"/>
</div>
<div t-if="record.login_date.raw_value" class="text-muted">
Last seen: <field name="login_date" widget="date"/>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="action_fp_team" model="ir.actions.act_window">
<field name="name">Team</field>
<field name="res_model">res.users</field>
<field name="view_mode">kanban,list,form</field>
<field name="domain">[('share', '=', False), ('active', '=', True)]</field>
<field name="context">{'search_default_groupby_plating_role': 1}</field>
</record>
<menuitem id="menu_fp_team"
name="Team"
parent="fusion_plating.menu_fp_config"
action="action_fp_team"
sequence="5"
groups="fusion_plating.group_fp_owner"/>
</data>
</odoo>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="view_company_form_fp_dos" model="ir.ui.view">
<field name="name">res.company.form.fp.designated.officials</field>
<field name="model">res.company</field>
<field name="inherit_id" ref="base.view_company_form"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="Plating Designated Officials"
groups="fusion_plating.group_fp_owner">
<group>
<field name="x_fc_cgp_designated_official_id"
domain="[('groups_id', 'in', [ref('fusion_plating.group_fp_quality_manager'), ref('fusion_plating.group_fp_owner')])]"/>
<field name="x_fc_nadcap_authority_user_id"
domain="[('groups_id', 'in', [ref('fusion_plating.group_fp_quality_manager'), ref('fusion_plating.group_fp_owner')])]"/>
</group>
</page>
</xpath>
</field>
</record>
</data>
</odoo>