Spec describes consolidation of 12 res.groups into 8 roles (No / Technician / Sales Rep / Shop Manager / Sales Manager / Manager / Quality Manager / Owner), role-based landing-page defaults, Owner-only Team management page, and dry-run + Owner-approval migration workflow. Plan breaks the work into 9 phases (A through I), ~40 TDD tasks, with explicit file lists and entech deploy commands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2675 lines
110 KiB
Markdown
2675 lines
110 KiB
Markdown
# Fusion Plating Permissions Overhaul Phase 1 Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Consolidate the 12 current Fusion Plating `res.groups` into 8 clean roles (No / Technician / Sales Representative / Shop Manager / Sales Manager / Manager / Quality Manager / Owner), add role-based landing-page defaults, build an Owner-only Team management page, and ship a dry-run + Owner-approval migration workflow.
|
|
|
|
**Architecture:** New groups are defined alongside the old ones in `fusion_plating/security/fp_security_v2.xml` with `implied_ids` chains that include the old groups (so existing ACLs keep working as backward-compat). ACL CSVs are then re-pointed to the new groups in a mechanical sweep. Quality permissions split between Manager (reactive: NCR/Hold/Cert/RMA) and QM (strategic: CAPA/Audit/AVL/FAIR/Nadcap/CGP) via per-model ACL changes plus two new `ir.rule` records on `fp.certificate`. Landing resolver gets a role-based dispatch step that respects the existing `ir.config_parameter['fusion_plating_shopfloor.layout']` flag. Team page is built with standard Odoo views (kanban grouped by a new `res.users.x_fc_plating_role` Selection field with compute/inverse) — zero custom OWL. Migration workflow ships as a `fp.migration.preview` model that runs in `pending` state on `-u` and only applies when an Owner clicks "Approve & Run" inside the preview UI.
|
|
|
|
**Tech Stack:** Odoo 19, Python 3.11, PostgreSQL 15, XML data files, QWeb templates, standard Odoo kanban/form/list views (no OWL custom components).
|
|
|
|
**Source spec:** [`docs/superpowers/specs/2026-05-23-permissions-overhaul-design.md`](../specs/2026-05-23-permissions-overhaul-design.md)
|
|
|
|
**Target deployment:** entech LXC 111 (`pve-worker5`), database `admin`, addons at `/mnt/extra-addons/custom/`.
|
|
|
|
---
|
|
|
|
## File Structure (master list)
|
|
|
|
### New files
|
|
|
|
| Path | Responsibility |
|
|
|---|---|
|
|
| `fusion_plating/security/fp_security_v2.xml` | The 8 new groups + implied_ids chains |
|
|
| `fusion_plating/models/fp_migration.py` | `fp.migration.preview` + `fp.migration.preview.line` models |
|
|
| `fusion_plating/models/fp_role_constants.py` | `PLATING_ROLE_DESCRIPTIONS` dict + `_NEW_ROLE_XMLID` mapping + `_FP_OLD_GROUP_XMLIDS` list |
|
|
| `fusion_plating/views/fp_team_views.xml` | Team kanban, role-reference template, audit-log list, menu, action |
|
|
| `fusion_plating/views/fp_migration_views.xml` | Preview list, form, action, "Approve & Run" + "Rollback" buttons |
|
|
| `fusion_plating/data/fp_migration_cron.xml` | 30-day purge cron |
|
|
| `fusion_plating/migrations/19.0.21.0.0/post-migrate.py` | Optional sanity backfill (idempotent re-seed of group memberships for uid 1/2) |
|
|
| `fusion_plating/tests/test_role_groups.py` | Group structure / implied_ids / auto-assign tests |
|
|
| `fusion_plating/tests/test_role_compute_inverse.py` | `x_fc_plating_role` compute + inverse tests |
|
|
| `fusion_plating/tests/test_landing_resolver.py` | Per-role resolver dispatch tests |
|
|
| `fusion_plating/tests/test_team_page.py` | Drag-and-drop role change tests |
|
|
| `fusion_plating/tests/test_migration_workflow.py` | Dry-run, approve, rollback tests |
|
|
| `fusion_plating/tests/test_sales_manager_gate.py` | SO confirm gate tests |
|
|
| `fusion_plating/tests/test_quality_split.py` | Manager-vs-QM quality permission tests |
|
|
| `fusion_plating/tests/test_menu_visibility.py` | Per-role menu render tests |
|
|
|
|
### Modified files (security CSVs — sweep)
|
|
|
|
| Path | Change |
|
|
|---|---|
|
|
| `fusion_plating/security/ir.model.access.csv` | Re-point all rows from old → new groups per Section 2.A of spec |
|
|
| `fusion_plating_configurator/security/ir.model.access.csv` | Same sweep |
|
|
| `fusion_plating_invoicing/security/ir.model.access.csv` | Same sweep |
|
|
| `fusion_plating_receiving/security/ir.model.access.csv` | Same sweep |
|
|
| `fusion_plating_quality/security/ir.model.access.csv` | Sweep + QM/Manager split for CAPA/Audit/AVL/CustomerSpec/DocControl |
|
|
| `fusion_plating_certificates/security/ir.model.access.csv` | Sweep |
|
|
| `fusion_plating_cgp/security/ir.model.access.csv` | Re-gate all CGP ACLs to QM |
|
|
| `fusion_plating_aerospace/security/ir.model.access.csv` | Sweep |
|
|
| `fusion_plating_nuclear/security/ir.model.access.csv` | Sweep |
|
|
| `fusion_plating_safety/security/ir.model.access.csv` | Sweep |
|
|
| `fusion_plating_jobs/security/ir.model.access.csv` | Sweep |
|
|
| `fusion_plating_shopfloor/security/ir.model.access.csv` | Sweep |
|
|
|
|
### Modified files (ir.rule)
|
|
|
|
| Path | Change |
|
|
|---|---|
|
|
| `fusion_plating_cgp/security/fp_cgp_security.xml` | `fp.cgp.psa` + `fp.cgp.security.incident` rules re-gated to QM |
|
|
| `fusion_plating_certificates/security/fp_cert_security.xml` (may need creation) | NEW ir.rule for `cert_type in ('fair','nadcap')` write restriction to QM |
|
|
|
|
### Modified files (menu visibility — Layer 1 + 2)
|
|
|
|
| Path | Change |
|
|
|---|---|
|
|
| `fusion_plating/views/fp_menu.xml` | Plating root + Operations + Configuration top-level gates |
|
|
| `fusion_plating_configurator/views/fp_configurator_menu.xml` | Sales & Quoting top-level + submenus |
|
|
| `fusion_plating_shopfloor/views/fp_shopfloor_menu.xml` | Shop Floor top-level |
|
|
| `fusion_plating_receiving/views/fp_receiving_menu.xml` | Receiving & Shipping top-level + children |
|
|
| `fusion_plating_quality/views/fp_menu.xml` | Quality top-level + per-child split (Audits/AVL/Specs to QM) |
|
|
| `fusion_plating_compliance/views/fp_menu.xml` + verticals | Compliance hub + child verticals all to QM |
|
|
| `fusion_plating_kpi/views/fp_kpi_menu.xml` | KPIs top-level to Manager |
|
|
| `fusion_plating_invoicing/views/fp_invoicing_menu.xml` | Children re-parent (Accounting folds into Manager) |
|
|
| `fusion_plating_jobs/views/jobs_in_shopfloor_menu.xml` | Sweep any operator/supervisor refs |
|
|
|
|
### Modified files (field/button visibility — Layer 3)
|
|
|
|
| Path | Change |
|
|
|---|---|
|
|
| `fusion_plating_configurator/views/sale_order_views.xml` | Hide Confirm button from non-Sales-Manager; hide pricing columns from non-Sales-Rep |
|
|
| `fusion_plating_invoicing/views/res_partner_views.xml` | Account-hold-override field to `group_fp_manager` (fixes `_administrator` typo) |
|
|
| `fusion_plating_quality/views/fp_capa_views.xml` | Close button + edit fields to QM |
|
|
| `fusion_plating_quality/views/fp_audit_views.xml` | All buttons to QM |
|
|
| `fusion_plating_quality/views/fp_avl_views.xml` | Approve / Disqualify buttons to QM |
|
|
| `fusion_plating_quality/views/fp_customer_spec_views.xml` | Edit fields to QM |
|
|
| `fusion_plating_certificates/views/fp_certificate_views.xml` | Sign button on FAIR/Nadcap to QM |
|
|
| `fusion_plating_cgp/views/*_views.xml` | All CGP form buttons to QM |
|
|
| Various smart-button host views | Match underlying action visibility |
|
|
|
|
### Modified files (Python — bypass flags + bug fixes)
|
|
|
|
| Path | Change |
|
|
|---|---|
|
|
| `fusion_plating_invoicing/models/res_partner.py:33-35` | Fix `_administrator` typo → `group_fp_manager` |
|
|
| `fusion_plating_configurator/wizard/fp_direct_order_wizard.py:467` | Fix same typo |
|
|
| `fusion_plating_jobs/models/fp_job_step.py` | Update bypass-flag group checks from old Manager → `group_fp_manager` |
|
|
| `fusion_plating_jobs/models/fp_job.py` | Same |
|
|
| `fusion_plating_quality/controllers/qc_controller.py` | Same |
|
|
| `fusion_plating_shopfloor/controllers/_tablet_audit.py` | Same |
|
|
|
|
### Modified files (Python — model fields)
|
|
|
|
| Path | Change |
|
|
|---|---|
|
|
| `fusion_plating/models/res_users.py` | Add `x_fc_plating_role` Selection field + compute + inverse |
|
|
| `fusion_plating/models/res_company.py` | Add `x_fc_cgp_designated_official_id` + `x_fc_nadcap_authority_user_id` |
|
|
| `fusion_plating/views/res_company_views.xml` | Surface the two DO fields on company form |
|
|
| `fusion_plating/models/sale_order.py` (may need creation) | Override `action_confirm` to gate on `group_fp_sales_manager` |
|
|
| `fusion_plating/models/fp_landing.py` | Replace landing resolver with role-based dispatch (Section 3 of spec) |
|
|
| `fusion_plating_shopfloor/views/manager_dashboard_action.xml` | Add `x_fc_pickable_landing=True` |
|
|
| `fusion_plating_shopfloor/views/plant_kanban_action.xml` | Add `x_fc_pickable_landing=True` |
|
|
| `fusion_plating_shopfloor/views/shopfloor_landing_action.xml` | Add `x_fc_pickable_landing=True` |
|
|
| `fusion_plating_quality/views/fp_quality_dashboard_action.xml` | Add `x_fc_pickable_landing=True` |
|
|
|
|
### Modified files (manifests — version bump)
|
|
|
|
| Path | Bump to |
|
|
|---|---|
|
|
| `fusion_plating/__manifest__.py` | 19.0.21.0.0 |
|
|
| `fusion_plating_configurator/__manifest__.py` | 19.0.22.0.0 |
|
|
| `fusion_plating_invoicing/__manifest__.py` | next minor |
|
|
| `fusion_plating_receiving/__manifest__.py` | next minor |
|
|
| `fusion_plating_quality/__manifest__.py` | 19.0.5.0.0 |
|
|
| `fusion_plating_certificates/__manifest__.py` | 19.0.6.0.0 |
|
|
| `fusion_plating_cgp/__manifest__.py` | next minor |
|
|
| `fusion_plating_aerospace/__manifest__.py` | next minor |
|
|
| `fusion_plating_nuclear/__manifest__.py` | next minor |
|
|
| `fusion_plating_safety/__manifest__.py` | next minor |
|
|
| `fusion_plating_shopfloor/__manifest__.py` | 19.0.25.0.0 |
|
|
| `fusion_plating_jobs/__manifest__.py` | 19.0.11.0.0 |
|
|
|
|
### Modified files (docs)
|
|
|
|
| Path | Change |
|
|
|---|---|
|
|
| `K:\Github\Odoo-Modules\fusion_plating\CLAUDE.md` | Update role hierarchy section + add Phase 1 permissions-overhaul section |
|
|
|
|
---
|
|
|
|
## Phase A — New Group Definitions (foundation)
|
|
|
|
Defines the 8 new groups, sets up implied_ids chains (new groups imply old ones for backward-compat), auto-assigns Owner to uid 1+2. Existing ACLs keep working because new groups carry old groups as implications. No ACL CSVs touched yet.
|
|
|
|
### Task A1: Bump `fusion_plating` manifest version
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating/__manifest__.py`
|
|
|
|
- [ ] **Step 1: Edit manifest version**
|
|
|
|
Open `fusion_plating/__manifest__.py` and change the `version` line (currently `19.0.20.x.x` per CLAUDE.md "Sub 12c") to `19.0.21.0.0`. Also add `'security/fp_security_v2.xml'` to the `data` list, placed AFTER the existing `'security/fp_security.xml'` entry so the new groups can reference the existing privilege record.
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating/__manifest__.py
|
|
git commit -m "chore(plating): bump version to 19.0.21.0.0 for permissions overhaul"
|
|
```
|
|
|
|
### Task A2: Define the 8 new groups in security XML
|
|
|
|
**Files:**
|
|
- Create: `fusion_plating/security/fp_security_v2.xml`
|
|
|
|
- [ ] **Step 1: Write the file**
|
|
|
|
```xml
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<odoo>
|
|
<data noupdate="0">
|
|
<!-- Phase 1 Permissions Overhaul: 8 consolidated roles -->
|
|
<!-- See docs/superpowers/specs/2026-05-23-permissions-overhaul-design.md -->
|
|
|
|
<record id="group_fp_technician" model="res.groups">
|
|
<field name="name">Technician</field>
|
|
<field name="sequence">10</field>
|
|
<field name="privilege_id" ref="fusion_plating.res_groups_privilege_fusion_plating"/>
|
|
<field name="implied_ids" eval="[
|
|
(4, ref('base.group_user')),
|
|
(4, ref('fusion_plating.group_fusion_plating_operator')),
|
|
]"/>
|
|
</record>
|
|
|
|
<record id="group_fp_sales_rep" model="res.groups">
|
|
<field name="name">Sales Representative</field>
|
|
<field name="sequence">20</field>
|
|
<field name="privilege_id" ref="fusion_plating.res_groups_privilege_fusion_plating"/>
|
|
<field name="implied_ids" eval="[
|
|
(4, ref('base.group_user')),
|
|
(4, ref('fusion_plating_configurator.group_fp_estimator')),
|
|
]"/>
|
|
</record>
|
|
|
|
<record id="group_fp_shop_manager_v2" model="res.groups">
|
|
<field name="name">Shop Manager</field>
|
|
<field name="sequence">30</field>
|
|
<field name="privilege_id" ref="fusion_plating.res_groups_privilege_fusion_plating"/>
|
|
<field name="implied_ids" eval="[
|
|
(4, ref('group_fp_technician')),
|
|
(4, ref('fusion_plating.group_fusion_plating_supervisor')),
|
|
(4, ref('fusion_plating_receiving.group_fp_receiving')),
|
|
]"/>
|
|
</record>
|
|
|
|
<record id="group_fp_sales_manager" model="res.groups">
|
|
<field name="name">Sales Manager</field>
|
|
<field name="sequence">40</field>
|
|
<field name="privilege_id" ref="fusion_plating.res_groups_privilege_fusion_plating"/>
|
|
<field name="implied_ids" eval="[
|
|
(4, ref('group_fp_sales_rep')),
|
|
]"/>
|
|
</record>
|
|
|
|
<record id="group_fp_manager" model="res.groups">
|
|
<field name="name">Manager</field>
|
|
<field name="sequence">50</field>
|
|
<field name="privilege_id" ref="fusion_plating.res_groups_privilege_fusion_plating"/>
|
|
<field name="implied_ids" eval="[
|
|
(4, ref('group_fp_shop_manager_v2')),
|
|
(4, ref('group_fp_sales_manager')),
|
|
(4, ref('fusion_plating.group_fusion_plating_manager')),
|
|
(4, ref('fusion_plating_invoicing.group_fp_accounting')),
|
|
]"/>
|
|
</record>
|
|
|
|
<record id="group_fp_quality_manager" model="res.groups">
|
|
<field name="name">Quality Manager</field>
|
|
<field name="sequence">60</field>
|
|
<field name="privilege_id" ref="fusion_plating.res_groups_privilege_fusion_plating"/>
|
|
<field name="implied_ids" eval="[
|
|
(4, ref('group_fp_manager')),
|
|
(4, ref('fusion_plating_cgp.group_fusion_plating_cgp_officer')),
|
|
]"/>
|
|
</record>
|
|
|
|
<record id="group_fp_owner" model="res.groups">
|
|
<field name="name">Owner</field>
|
|
<field name="sequence">70</field>
|
|
<field name="privilege_id" ref="fusion_plating.res_groups_privilege_fusion_plating"/>
|
|
<field name="implied_ids" eval="[
|
|
(4, ref('group_fp_quality_manager')),
|
|
(4, ref('fusion_plating.group_fusion_plating_admin')),
|
|
(4, ref('fusion_plating_cgp.group_fusion_plating_cgp_designated_official')),
|
|
(4, ref('base.group_system')),
|
|
]"/>
|
|
<field name="user_ids" eval="[
|
|
(4, ref('base.user_root')),
|
|
(4, ref('base.user_admin')),
|
|
]"/>
|
|
</record>
|
|
</data>
|
|
</odoo>
|
|
```
|
|
|
|
- [ ] **Step 2: Verify the file parses**
|
|
|
|
```bash
|
|
python -c "import lxml.etree as et; et.parse('fusion_plating/security/fp_security_v2.xml'); print('XML OK')"
|
|
```
|
|
Expected: `XML OK`
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating/security/fp_security_v2.xml
|
|
git commit -m "feat(plating-sec): add 8 consolidated role groups (technician → owner)"
|
|
```
|
|
|
|
### Task A3: Write group-structure tests
|
|
|
|
**Files:**
|
|
- Create: `fusion_plating/tests/test_role_groups.py`
|
|
|
|
- [ ] **Step 1: Write the test file**
|
|
|
|
```python
|
|
from odoo.tests.common import TransactionCase, tagged
|
|
|
|
|
|
@tagged('-at_install', 'post_install', 'fp_perms')
|
|
class TestRoleGroupsStructure(TransactionCase):
|
|
"""Verify the 8 new roles exist with correct implied_ids chains."""
|
|
|
|
def test_all_eight_groups_exist(self):
|
|
names = {
|
|
'group_fp_technician', 'group_fp_sales_rep',
|
|
'group_fp_shop_manager_v2', 'group_fp_sales_manager',
|
|
'group_fp_manager', 'group_fp_quality_manager', 'group_fp_owner',
|
|
}
|
|
for xmlid in names:
|
|
grp = self.env.ref(f'fusion_plating.{xmlid}', raise_if_not_found=False)
|
|
self.assertTrue(grp, f'Group {xmlid} not found')
|
|
|
|
def test_owner_implies_quality_manager(self):
|
|
owner = self.env.ref('fusion_plating.group_fp_owner')
|
|
qm = self.env.ref('fusion_plating.group_fp_quality_manager')
|
|
self.assertIn(qm, owner.implied_ids)
|
|
|
|
def test_owner_implies_system(self):
|
|
owner = self.env.ref('fusion_plating.group_fp_owner')
|
|
system = self.env.ref('base.group_system')
|
|
self.assertIn(system, owner.trans_implied_ids,
|
|
'Owner must transitively imply base.group_system')
|
|
|
|
def test_manager_implies_both_branches(self):
|
|
mgr = self.env.ref('fusion_plating.group_fp_manager')
|
|
sm = self.env.ref('fusion_plating.group_fp_shop_manager_v2')
|
|
sales_mgr = self.env.ref('fusion_plating.group_fp_sales_manager')
|
|
self.assertIn(sm, mgr.implied_ids, 'Manager must imply Shop Manager (diamond)')
|
|
self.assertIn(sales_mgr, mgr.implied_ids, 'Manager must imply Sales Manager (diamond)')
|
|
|
|
def test_technician_does_not_imply_sales_rep(self):
|
|
tech = self.env.ref('fusion_plating.group_fp_technician')
|
|
sales_rep = self.env.ref('fusion_plating.group_fp_sales_rep')
|
|
self.assertNotIn(sales_rep, tech.trans_implied_ids,
|
|
'Technician must NOT see Sales Rep menus')
|
|
|
|
def test_sales_rep_does_not_imply_technician(self):
|
|
sales_rep = self.env.ref('fusion_plating.group_fp_sales_rep')
|
|
tech = self.env.ref('fusion_plating.group_fp_technician')
|
|
self.assertNotIn(tech, sales_rep.trans_implied_ids,
|
|
'Sales Rep must NOT see Workstation')
|
|
|
|
def test_owner_auto_assigned_to_uid_1_and_2(self):
|
|
owner = self.env.ref('fusion_plating.group_fp_owner')
|
|
user_ids = owner.user_ids.ids
|
|
self.assertIn(1, user_ids, 'Owner must include uid 1 (__system__)')
|
|
self.assertIn(2, user_ids, 'Owner must include uid 2 (admin)')
|
|
|
|
def test_sequence_numbers_are_unique(self):
|
|
seqs = [
|
|
self.env.ref(f'fusion_plating.{x}').sequence
|
|
for x in ('group_fp_technician', 'group_fp_sales_rep',
|
|
'group_fp_shop_manager_v2', 'group_fp_sales_manager',
|
|
'group_fp_manager', 'group_fp_quality_manager', 'group_fp_owner')
|
|
]
|
|
self.assertEqual(len(seqs), len(set(seqs)),
|
|
f'All sequence numbers must be unique, got {seqs}')
|
|
|
|
def test_new_groups_imply_old_for_backward_compat(self):
|
|
"""During the 30-day rollback window, new groups must trigger old ACLs."""
|
|
tech = self.env.ref('fusion_plating.group_fp_technician')
|
|
old_op = self.env.ref('fusion_plating.group_fusion_plating_operator')
|
|
self.assertIn(old_op, tech.trans_implied_ids)
|
|
|
|
mgr = self.env.ref('fusion_plating.group_fp_manager')
|
|
old_mgr = self.env.ref('fusion_plating.group_fusion_plating_manager')
|
|
self.assertIn(old_mgr, mgr.trans_implied_ids)
|
|
```
|
|
|
|
- [ ] **Step 2: Run the test — verify it FAILS** (groups not deployed yet)
|
|
|
|
```bash
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin --test-tags fp_perms --test-enable --stop-after-init -u fusion_plating\"'" 2>&1 | grep -E '(FAIL|OK|ERROR)'
|
|
```
|
|
Expected: tests run AFTER the module updates with the new XML; they will PASS on this iteration because Task A2 already deployed the groups. (Document: if the prior module update was on a different DB, run `-u fusion_plating` first to materialise the new groups.)
|
|
|
|
- [ ] **Step 3: If tests fail, fix Task A2 XML and re-run**
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating/tests/test_role_groups.py
|
|
git commit -m "test(plating-sec): verify 8-role hierarchy + implied_ids chains"
|
|
```
|
|
|
|
### Task A4: Add deprecation note to old group display names
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating/security/fp_security.xml`
|
|
- Modify: `fusion_plating_configurator/security/fp_configurator_security.xml`
|
|
- Modify: `fusion_plating_invoicing/security/fp_invoicing_security.xml`
|
|
- Modify: `fusion_plating_receiving/security/fp_receiving_security.xml`
|
|
- Modify: `fusion_plating_cgp/security/fp_cgp_security.xml`
|
|
- Modify: `fusion_plating_jobs/security/legacy_groups.xml`
|
|
|
|
- [ ] **Step 1: Prefix each old group's name with `[DEPRECATED] `**
|
|
|
|
For each file, find each `<field name="name">…</field>` line on the OLD group records and prefix with `[DEPRECATED] `. Example for `fusion_plating/security/fp_security.xml` on the Operator group:
|
|
|
|
```xml
|
|
<field name="name">[DEPRECATED] Operator</field>
|
|
```
|
|
|
|
Do NOT remove the records — they're needed for backward-compat during the 30-day rollback window. Do NOT change the XML IDs.
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating/security/fp_security.xml \
|
|
fusion_plating_configurator/security/fp_configurator_security.xml \
|
|
fusion_plating_invoicing/security/fp_invoicing_security.xml \
|
|
fusion_plating_receiving/security/fp_receiving_security.xml \
|
|
fusion_plating_cgp/security/fp_cgp_security.xml \
|
|
fusion_plating_jobs/security/legacy_groups.xml
|
|
git commit -m "chore(plating-sec): mark old groups as [DEPRECATED] in display name"
|
|
```
|
|
|
|
### Task A5: Deploy Phase A to a test DB and smoke-verify
|
|
|
|
- [ ] **Step 1: Deploy to entech test DB**
|
|
|
|
```bash
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && \
|
|
su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin \
|
|
-u fusion_plating --stop-after-init\" && systemctl start odoo'"
|
|
```
|
|
|
|
- [ ] **Step 2: SQL check that the 8 groups exist**
|
|
|
|
```bash
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql admin -c \\\"
|
|
SELECT g.id, g.name, g.sequence
|
|
FROM res_groups g JOIN ir_model_data d ON d.res_id = g.id AND d.model = 'res.groups'
|
|
WHERE d.module = 'fusion_plating' AND d.name LIKE 'group_fp_%'
|
|
ORDER BY g.sequence;
|
|
\\\"\"'"
|
|
```
|
|
Expected: 7 rows in sequence order 10/20/30/40/50/60/70 (the 8th role "No" has no group record).
|
|
|
|
- [ ] **Step 3: Verify Owner auto-assigned to uid 1+2**
|
|
|
|
```bash
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql admin -c \\\"
|
|
SELECT r.uid, u.login FROM res_groups_users_rel r
|
|
JOIN res_users u ON u.id = r.uid
|
|
WHERE r.gid = (SELECT res_id FROM ir_model_data
|
|
WHERE module='fusion_plating' AND name='group_fp_owner');
|
|
\\\"\"'"
|
|
```
|
|
Expected: 2 rows (uid 1 = __system__, uid 2 = admin).
|
|
|
|
---
|
|
|
|
## Phase B — ACL Mechanical Migration Sweep
|
|
|
|
Re-points every ACL CSV row from old group xmlids → new group xmlids per the standard mapping. Backward-compat preserved because new groups still imply old ones from Phase A. Tests verify both sets work simultaneously.
|
|
|
|
### Task B1: Write ACL migration test scaffolding
|
|
|
|
**Files:**
|
|
- Create: `fusion_plating/tests/test_acl_migration.py`
|
|
|
|
- [ ] **Step 1: Write the test**
|
|
|
|
```python
|
|
from odoo.tests.common import TransactionCase, tagged
|
|
|
|
|
|
@tagged('-at_install', 'post_install', 'fp_perms')
|
|
class TestAclMigration(TransactionCase):
|
|
"""Sample-based ACL coverage: pick 1 model per role and verify access."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
Users = self.env['res.users'].with_context(no_reset_password=True)
|
|
# Build one test user per role, freshly assigned
|
|
def make(login, group_xmlid):
|
|
user = Users.create({
|
|
'login': f'fp_test_{login}',
|
|
'name': f'FP Test {login.title()}',
|
|
'email': f'fp_test_{login}@example.com',
|
|
'groups_id': [(6, 0, [self.env.ref(group_xmlid).id])],
|
|
})
|
|
return user
|
|
|
|
self.u_tech = make('tech', 'fusion_plating.group_fp_technician')
|
|
self.u_sm = make('sm', 'fusion_plating.group_fp_shop_manager_v2')
|
|
self.u_mgr = make('mgr', 'fusion_plating.group_fp_manager')
|
|
self.u_qm = make('qm', 'fusion_plating.group_fp_quality_manager')
|
|
self.u_sr = make('sr', 'fusion_plating.group_fp_sales_rep')
|
|
self.u_smg = make('smg', 'fusion_plating.group_fp_sales_manager')
|
|
|
|
def test_technician_can_read_jobs(self):
|
|
Jobs = self.env['fp.job'].with_user(self.u_tech)
|
|
Jobs.check_access_rights('read') # raises if no access
|
|
|
|
def test_technician_cannot_read_part_catalog(self):
|
|
from odoo.exceptions import AccessError
|
|
Parts = self.env['fp.part.catalog'].with_user(self.u_tech)
|
|
with self.assertRaises(AccessError):
|
|
Parts.check_access_rights('read')
|
|
|
|
def test_sales_rep_can_read_part_catalog(self):
|
|
Parts = self.env['fp.part.catalog'].with_user(self.u_sr)
|
|
Parts.check_access_rights('read')
|
|
|
|
def test_shop_manager_can_read_receiving(self):
|
|
Rec = self.env['fp.receiving'].with_user(self.u_sm)
|
|
Rec.check_access_rights('read')
|
|
|
|
def test_manager_can_create_ncr(self):
|
|
Ncr = self.env['fusion.plating.ncr'].with_user(self.u_mgr)
|
|
Ncr.check_access_rights('create')
|
|
|
|
def test_manager_can_only_read_capa(self):
|
|
from odoo.exceptions import AccessError
|
|
Capa = self.env['fusion.plating.capa'].with_user(self.u_mgr)
|
|
Capa.check_access_rights('read')
|
|
with self.assertRaises(AccessError):
|
|
Capa.check_access_rights('write')
|
|
|
|
def test_qm_can_write_capa(self):
|
|
Capa = self.env['fusion.plating.capa'].with_user(self.u_qm)
|
|
Capa.check_access_rights('write')
|
|
```
|
|
|
|
- [ ] **Step 2: Run to see initial failures** (most assertions fail until Phase B + C complete)
|
|
|
|
```bash
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin --test-tags fp_perms --test-enable --stop-after-init -u fusion_plating\"'" 2>&1 | tail -30
|
|
```
|
|
Expected: several FAILs (acceptable; we'll fix in B2-B5 + C).
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating/tests/test_acl_migration.py
|
|
git commit -m "test(plating-sec): scaffolding for per-role ACL coverage"
|
|
```
|
|
|
|
### Task B2: Sweep ACL CSVs — core modules
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating/security/ir.model.access.csv`
|
|
- Modify: `fusion_plating_jobs/security/ir.model.access.csv`
|
|
- Modify: `fusion_plating_shopfloor/security/ir.model.access.csv`
|
|
- Modify: `fusion_plating_certificates/security/ir.model.access.csv`
|
|
|
|
- [ ] **Step 1: Apply the mechanical replacement table per file**
|
|
|
|
In each CSV, do a literal text replace for each pattern (NOT regex — exact string match), case-sensitive:
|
|
|
|
| Find | Replace with |
|
|
|---|---|
|
|
| `fusion_plating.group_fusion_plating_operator` | `fusion_plating.group_fp_technician` |
|
|
| `fusion_plating.group_fusion_plating_supervisor` | `fusion_plating.group_fp_shop_manager_v2` |
|
|
| `fusion_plating.group_fusion_plating_manager` | `fusion_plating.group_fp_manager` |
|
|
| `fusion_plating.group_fusion_plating_admin` | `fusion_plating.group_fp_owner` |
|
|
| `group_fusion_plating_operator` (bare) | `group_fp_technician` |
|
|
| `group_fusion_plating_supervisor` (bare) | `group_fp_shop_manager_v2` |
|
|
| `group_fusion_plating_manager` (bare) | `group_fp_manager` |
|
|
| `group_fusion_plating_admin` (bare) | `group_fp_owner` |
|
|
|
|
Use the Read tool to inspect each file, then Edit with `replace_all: true` per substitution.
|
|
|
|
- [ ] **Step 2: Smoke-check no orphaned references**
|
|
|
|
```bash
|
|
grep -E 'group_fusion_plating_(operator|supervisor|manager|admin)' \
|
|
fusion_plating/security/ir.model.access.csv \
|
|
fusion_plating_jobs/security/ir.model.access.csv \
|
|
fusion_plating_shopfloor/security/ir.model.access.csv \
|
|
fusion_plating_certificates/security/ir.model.access.csv
|
|
```
|
|
Expected: empty output (zero matches).
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating/security/ir.model.access.csv \
|
|
fusion_plating_jobs/security/ir.model.access.csv \
|
|
fusion_plating_shopfloor/security/ir.model.access.csv \
|
|
fusion_plating_certificates/security/ir.model.access.csv
|
|
git commit -m "refactor(plating-sec): sweep ACLs in core/jobs/shopfloor/certs to new groups"
|
|
```
|
|
|
|
### Task B3: Sweep ACL CSVs — sales, accounting, receiving
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating_configurator/security/ir.model.access.csv`
|
|
- Modify: `fusion_plating_invoicing/security/ir.model.access.csv`
|
|
- Modify: `fusion_plating_receiving/security/ir.model.access.csv`
|
|
|
|
- [ ] **Step 1: Apply replacement table (same as B2, plus these)**
|
|
|
|
Additional replacements for these modules:
|
|
|
|
| Find | Replace with |
|
|
|---|---|
|
|
| `fusion_plating_configurator.group_fp_estimator` | `fusion_plating.group_fp_sales_rep` |
|
|
| `group_fp_estimator` (bare) | `group_fp_sales_rep` (when in configurator CSV) — note: cross-module reference becomes `fusion_plating.group_fp_sales_rep` |
|
|
| `fusion_plating_invoicing.group_fp_accounting` | `fusion_plating.group_fp_manager` |
|
|
| `group_fp_accounting` (bare) | `fusion_plating.group_fp_manager` |
|
|
| `fusion_plating_receiving.group_fp_receiving` | `fusion_plating.group_fp_shop_manager_v2` |
|
|
| `group_fp_receiving` (bare) | `fusion_plating.group_fp_shop_manager_v2` |
|
|
| `fusion_plating_configurator.group_fp_shop_manager` | `fusion_plating.group_fp_manager` (old Shop Manager = Manager-equivalent) |
|
|
|
|
- [ ] **Step 2: Smoke-check no orphaned references**
|
|
|
|
```bash
|
|
grep -E '(group_fp_estimator|group_fp_accounting|group_fp_receiving|group_fp_shop_manager)\b' \
|
|
fusion_plating_configurator/security/ir.model.access.csv \
|
|
fusion_plating_invoicing/security/ir.model.access.csv \
|
|
fusion_plating_receiving/security/ir.model.access.csv
|
|
```
|
|
Expected: empty (the `_v2` suffix prevents matching the new Shop Manager xmlid).
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating_configurator/security/ir.model.access.csv \
|
|
fusion_plating_invoicing/security/ir.model.access.csv \
|
|
fusion_plating_receiving/security/ir.model.access.csv
|
|
git commit -m "refactor(plating-sec): sweep ACLs in configurator/invoicing/receiving"
|
|
```
|
|
|
|
### Task B4: Sweep ACL CSVs — verticals (Aerospace / Nuclear / Safety)
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating_aerospace/security/ir.model.access.csv`
|
|
- Modify: `fusion_plating_nuclear/security/ir.model.access.csv`
|
|
- Modify: `fusion_plating_safety/security/ir.model.access.csv`
|
|
|
|
- [ ] **Step 1: Apply same replacement table as B2**
|
|
|
|
All three verticals only use core Operator/Supervisor/Manager today (per audit). No vertical-specific group references.
|
|
|
|
- [ ] **Step 2: Smoke-check**
|
|
|
|
```bash
|
|
grep -E 'group_fusion_plating_(operator|supervisor|manager|admin)' \
|
|
fusion_plating_aerospace/security/ir.model.access.csv \
|
|
fusion_plating_nuclear/security/ir.model.access.csv \
|
|
fusion_plating_safety/security/ir.model.access.csv
|
|
```
|
|
Expected: empty.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating_aerospace/security/ir.model.access.csv \
|
|
fusion_plating_nuclear/security/ir.model.access.csv \
|
|
fusion_plating_safety/security/ir.model.access.csv
|
|
git commit -m "refactor(plating-sec): sweep vertical ACLs (aerospace/nuclear/safety)"
|
|
```
|
|
|
|
### Task B5: Sweep ACL CSVs — CGP (folds to QM)
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating_cgp/security/ir.model.access.csv`
|
|
|
|
- [ ] **Step 1: Apply replacement**
|
|
|
|
Additional CGP-specific replacements:
|
|
|
|
| Find | Replace with |
|
|
|---|---|
|
|
| `fusion_plating_cgp.group_fusion_plating_cgp_officer` | `fusion_plating.group_fp_quality_manager` |
|
|
| `fusion_plating_cgp.group_fusion_plating_cgp_designated_official` | `fusion_plating.group_fp_owner` |
|
|
| (the standard core replacements from B2) | (same) |
|
|
|
|
- [ ] **Step 2: Smoke-check**
|
|
|
|
```bash
|
|
grep -E '(group_fusion_plating_cgp_|group_fusion_plating_(operator|supervisor|manager|admin))' \
|
|
fusion_plating_cgp/security/ir.model.access.csv
|
|
```
|
|
Expected: empty.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating_cgp/security/ir.model.access.csv
|
|
git commit -m "refactor(plating-sec): fold CGP ACLs into Quality Manager"
|
|
```
|
|
|
|
### Task B6: Sweep ACL CSV — quality (sweep only; QM/Manager split is Phase C)
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating_quality/security/ir.model.access.csv`
|
|
|
|
- [ ] **Step 1: Apply core replacement table from B2 only**
|
|
|
|
DO NOT split Manager/QM yet — that's Phase C. For now, just mechanically replace old group names with new ones (every old Manager ref → new Manager).
|
|
|
|
- [ ] **Step 2: Smoke-check**
|
|
|
|
```bash
|
|
grep -E 'group_fusion_plating_(operator|supervisor|manager|admin)' \
|
|
fusion_plating_quality/security/ir.model.access.csv
|
|
```
|
|
Expected: empty.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating_quality/security/ir.model.access.csv
|
|
git commit -m "refactor(plating-sec): sweep quality ACLs to new group names (pre-split)"
|
|
```
|
|
|
|
### Task B7: Deploy Phase B and re-run ACL tests
|
|
|
|
- [ ] **Step 1: Deploy**
|
|
|
|
```bash
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && \
|
|
su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin \
|
|
-u fusion_plating,fusion_plating_configurator,fusion_plating_invoicing,fusion_plating_receiving,fusion_plating_quality,fusion_plating_certificates,fusion_plating_cgp,fusion_plating_aerospace,fusion_plating_nuclear,fusion_plating_safety,fusion_plating_jobs,fusion_plating_shopfloor \
|
|
--stop-after-init\" && systemctl start odoo'"
|
|
```
|
|
|
|
- [ ] **Step 2: Run the ACL test suite**
|
|
|
|
```bash
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin --test-tags fp_perms --test-enable --stop-after-init -u fusion_plating\"'" 2>&1 | tail -50
|
|
```
|
|
Expected: most tests PASS. `test_manager_can_only_read_capa` and `test_qm_can_write_capa` may still FAIL — that's Phase C work.
|
|
|
|
- [ ] **Step 3: If non-CAPA tests fail, troubleshoot before continuing**
|
|
|
|
Check for typos in the CSV sweep, then re-deploy.
|
|
|
|
---
|
|
|
|
## Phase C — Quality Split (Manager vs Quality Manager)
|
|
|
|
Splits quality permissions per spec Section 2.C. Manager keeps reactive Quality (NCR, Hold, Check, Cert, RMA). QM gets exclusive control of CAPA, Audit, AVL, Customer Spec, Doc Control, FAIR/Nadcap signing. Adds two new `ir.rule` records.
|
|
|
|
### Task C1: Write quality-split test cases
|
|
|
|
**Files:**
|
|
- Create: `fusion_plating/tests/test_quality_split.py`
|
|
|
|
- [ ] **Step 1: Write the test (~80 lines, ~10 test methods)**
|
|
|
|
```python
|
|
from odoo.tests.common import TransactionCase, tagged
|
|
from odoo.exceptions import AccessError
|
|
|
|
|
|
@tagged('-at_install', 'post_install', 'fp_perms')
|
|
class TestQualitySplit(TransactionCase):
|
|
"""Section 2.C of spec: Manager handles reactive Quality;
|
|
QM exclusively owns CAPA close, Audit, AVL, Customer Spec, FAIR/Nadcap signing."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
Users = self.env['res.users'].with_context(no_reset_password=True)
|
|
self.u_mgr = Users.create({
|
|
'login': 'qsplit_mgr', 'name': 'QSplit Mgr',
|
|
'email': 'qsplit_mgr@example.com',
|
|
'groups_id': [(6, 0, [self.env.ref('fusion_plating.group_fp_manager').id])],
|
|
})
|
|
self.u_qm = Users.create({
|
|
'login': 'qsplit_qm', 'name': 'QSplit QM',
|
|
'email': 'qsplit_qm@example.com',
|
|
'groups_id': [(6, 0, [self.env.ref('fusion_plating.group_fp_quality_manager').id])],
|
|
})
|
|
|
|
# CAPA: Manager read-only, QM full
|
|
def test_manager_can_read_capa(self):
|
|
self.env['fusion.plating.capa'].with_user(self.u_mgr).check_access_rights('read')
|
|
def test_manager_cannot_write_capa(self):
|
|
with self.assertRaises(AccessError):
|
|
self.env['fusion.plating.capa'].with_user(self.u_mgr).check_access_rights('write')
|
|
def test_manager_cannot_create_capa(self):
|
|
with self.assertRaises(AccessError):
|
|
self.env['fusion.plating.capa'].with_user(self.u_mgr).check_access_rights('create')
|
|
def test_qm_can_write_capa(self):
|
|
self.env['fusion.plating.capa'].with_user(self.u_qm).check_access_rights('write')
|
|
|
|
# Audit: Manager read-only, QM full
|
|
def test_manager_can_read_audit(self):
|
|
self.env['fusion.plating.audit'].with_user(self.u_mgr).check_access_rights('read')
|
|
def test_manager_cannot_write_audit(self):
|
|
with self.assertRaises(AccessError):
|
|
self.env['fusion.plating.audit'].with_user(self.u_mgr).check_access_rights('write')
|
|
def test_qm_can_write_audit(self):
|
|
self.env['fusion.plating.audit'].with_user(self.u_qm).check_access_rights('write')
|
|
|
|
# NCR: Manager full
|
|
def test_manager_can_create_ncr(self):
|
|
self.env['fusion.plating.ncr'].with_user(self.u_mgr).check_access_rights('create')
|
|
def test_manager_can_write_ncr(self):
|
|
self.env['fusion.plating.ncr'].with_user(self.u_mgr).check_access_rights('write')
|
|
|
|
# Hold: Manager full
|
|
def test_manager_can_create_hold(self):
|
|
self.env['fusion.plating.quality.hold'].with_user(self.u_mgr).check_access_rights('create')
|
|
|
|
# AVL: Manager read-only, QM full
|
|
def test_manager_can_read_avl(self):
|
|
self.env['fp.approved.vendor.list'].with_user(self.u_mgr).check_access_rights('read')
|
|
def test_manager_cannot_write_avl(self):
|
|
with self.assertRaises(AccessError):
|
|
self.env['fp.approved.vendor.list'].with_user(self.u_mgr).check_access_rights('write')
|
|
|
|
# Customer Spec: Manager read-only, QM full
|
|
def test_manager_can_read_customer_spec(self):
|
|
self.env['fusion.plating.customer.spec'].with_user(self.u_mgr).check_access_rights('read')
|
|
def test_manager_cannot_write_customer_spec(self):
|
|
with self.assertRaises(AccessError):
|
|
self.env['fusion.plating.customer.spec'].with_user(self.u_mgr).check_access_rights('write')
|
|
```
|
|
|
|
- [ ] **Step 2: Run — expect failures**
|
|
|
|
```bash
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin --test-tags fp_perms --test-enable --stop-after-init -u fusion_plating_quality\"'" 2>&1 | grep -E '(FAIL|PASS|ERROR)' | head -20
|
|
```
|
|
Expected: ~6 FAIL (the QM-only ones); the rest PASS.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating/tests/test_quality_split.py
|
|
git commit -m "test(plating-quality): assert Manager/QM split on CAPA/Audit/AVL/Spec"
|
|
```
|
|
|
|
### Task C2: Split CAPA ACL — Manager read-only, QM full
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating_quality/security/ir.model.access.csv`
|
|
|
|
- [ ] **Step 1: Find existing CAPA row(s)**
|
|
|
|
Use Grep to find lines containing `fusion.plating.capa`. There should be one Operator/Tech row + one Manager row OR a single row. Identify them by their xmlid in column 1 (e.g., `access_fusion_plating_capa_manager`).
|
|
|
|
- [ ] **Step 2: Modify the Manager row to read-only (1,0,0,0) and add a QM full-access row (1,1,1,1)**
|
|
|
|
Example structure (adjust IDs to match the existing file):
|
|
|
|
```csv
|
|
access_fusion_plating_capa_manager,fusion.plating.capa.manager,model_fusion_plating_capa,fusion_plating.group_fp_manager,1,0,0,0
|
|
access_fusion_plating_capa_qm,fusion.plating.capa.qm,model_fusion_plating_capa,fusion_plating.group_fp_quality_manager,1,1,1,1
|
|
```
|
|
|
|
- [ ] **Step 3: Repeat for Audit, AVL, Customer Spec, Doc Control**
|
|
|
|
Same pattern: Manager row → read-only (`1,0,0,0`), add new QM-full row (`1,1,1,1`). Use `access_<model_short>_qm` as the xmlid suffix for the new rows.
|
|
|
|
- [ ] **Step 4: Deploy + run tests**
|
|
|
|
```bash
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && \
|
|
su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin \
|
|
-u fusion_plating_quality --test-tags fp_perms --test-enable --stop-after-init\" && systemctl start odoo'" 2>&1 | tail -20
|
|
```
|
|
Expected: all quality-split tests PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating_quality/security/ir.model.access.csv
|
|
git commit -m "feat(plating-quality): split CAPA/Audit/AVL/Spec ACLs — Manager read, QM full"
|
|
```
|
|
|
|
### Task C3: Add FAIR/Nadcap ir.rule on certificates
|
|
|
|
**Files:**
|
|
- Modify or Create: `fusion_plating_certificates/security/fp_cert_security.xml`
|
|
- Modify: `fusion_plating_certificates/__manifest__.py` (add the file to `data` if newly created)
|
|
|
|
- [ ] **Step 1: Write the ir.rule**
|
|
|
|
```xml
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<odoo>
|
|
<data noupdate="0">
|
|
<record id="rule_fp_certificate_fair_nadcap_write_qm_only" model="ir.rule">
|
|
<field name="name">FP Certificate: FAIR/Nadcap write restricted to QM</field>
|
|
<field name="model_id" ref="model_fp_certificate"/>
|
|
<field name="domain_force">
|
|
[
|
|
'|',
|
|
('cert_type', 'not in', ('fair', 'nadcap')),
|
|
('id', 'in', user.partner_id.commercial_partner_id.id and []),
|
|
]
|
|
</field>
|
|
<field name="groups" eval="[(4, ref('fusion_plating.group_fp_manager'))]"/>
|
|
<field name="perm_read" eval="False"/>
|
|
<field name="perm_write" eval="True"/>
|
|
<field name="perm_create" eval="True"/>
|
|
<field name="perm_unlink" eval="True"/>
|
|
</record>
|
|
|
|
<record id="rule_fp_certificate_all_qm" model="ir.rule">
|
|
<field name="name">FP Certificate: QM sees all</field>
|
|
<field name="model_id" ref="model_fp_certificate"/>
|
|
<field name="domain_force">[(1, '=', 1)]</field>
|
|
<field name="groups" eval="[(4, ref('fusion_plating.group_fp_quality_manager'))]"/>
|
|
<field name="perm_read" eval="True"/>
|
|
<field name="perm_write" eval="True"/>
|
|
<field name="perm_create" eval="True"/>
|
|
<field name="perm_unlink" eval="True"/>
|
|
</record>
|
|
</data>
|
|
</odoo>
|
|
```
|
|
|
|
NOTE: the trick `('id', 'in', user.partner_id.commercial_partner_id.id and [])` returns an empty list, which means "no records match" — combined with the OR clause it creates "for Managers, ONLY allow records where cert_type is NOT in (fair, nadcap)". The QM rule then re-grants full access for QM via the second rule (ir.rules are OR'd within a group when multiple match).
|
|
|
|
- [ ] **Step 2: Verify XML parses, deploy, hand-test**
|
|
|
|
```bash
|
|
python -c "import lxml.etree as et; et.parse('fusion_plating_certificates/security/fp_cert_security.xml'); print('OK')"
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && \
|
|
su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin \
|
|
-u fusion_plating_certificates --stop-after-init\" && systemctl start odoo'"
|
|
```
|
|
|
|
- [ ] **Step 3: Add to manifest if newly created**
|
|
|
|
If the file is new, add `'security/fp_cert_security.xml'` to the `data` list in `fusion_plating_certificates/__manifest__.py` AFTER `'security/ir.model.access.csv'`.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating_certificates/security/fp_cert_security.xml \
|
|
fusion_plating_certificates/__manifest__.py
|
|
git commit -m "feat(plating-cert): restrict FAIR/Nadcap cert writes to Quality Manager"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase D — Menu / Submenu / Field Visibility (3-Layer Hide)
|
|
|
|
Adds explicit `groups=` to every top-level menu, submenu, and field/button per spec Section 2.E. No reliance on action-level ACLs for visibility.
|
|
|
|
### Task D1: Write menu-visibility tests
|
|
|
|
**Files:**
|
|
- Create: `fusion_plating/tests/test_menu_visibility.py`
|
|
|
|
- [ ] **Step 1: Write the test**
|
|
|
|
```python
|
|
from odoo.tests.common import TransactionCase, tagged
|
|
|
|
|
|
@tagged('-at_install', 'post_install', 'fp_perms')
|
|
class TestMenuVisibility(TransactionCase):
|
|
"""Section 2.F of spec: per-role menu render matrix."""
|
|
|
|
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'menu_{name}', 'name': f'Menu Test {name}',
|
|
'email': f'menu_{name}@example.com',
|
|
'groups_id': [(6, 0, [self.env.ref(xmlid).id])] if xmlid else [(6, 0, [])],
|
|
})
|
|
self.u_no = mk('no', None)
|
|
self.u_tech = mk('tech', 'fusion_plating.group_fp_technician')
|
|
self.u_sr = mk('sr', 'fusion_plating.group_fp_sales_rep')
|
|
self.u_sm = mk('sm', 'fusion_plating.group_fp_shop_manager_v2')
|
|
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')
|
|
|
|
def _visible(self, user, menu_xmlid):
|
|
menu = self.env.ref(menu_xmlid, raise_if_not_found=False)
|
|
if not menu:
|
|
return None
|
|
return menu.with_user(user)._filter_visible_menus() if hasattr(
|
|
menu, '_filter_visible_menus') else bool(
|
|
self.env['ir.ui.menu'].with_user(user).search([('id', '=', menu.id)]))
|
|
|
|
def test_no_sees_no_plating_root(self):
|
|
self.assertFalse(self._visible(self.u_no, 'fusion_plating.menu_fp_root'))
|
|
|
|
def test_technician_sees_shop_floor(self):
|
|
self.assertTrue(self._visible(self.u_tech, 'fusion_plating_shopfloor.menu_fp_shopfloor_root'))
|
|
|
|
def test_technician_does_not_see_sales(self):
|
|
self.assertFalse(self._visible(self.u_tech, 'fusion_plating_configurator.menu_fp_sales_root'))
|
|
|
|
def test_technician_does_not_see_team(self):
|
|
self.assertFalse(self._visible(self.u_tech, 'fusion_plating.menu_fp_team'))
|
|
|
|
def test_sales_rep_sees_sales(self):
|
|
self.assertTrue(self._visible(self.u_sr, 'fusion_plating_configurator.menu_fp_sales_root'))
|
|
|
|
def test_sales_rep_does_not_see_shop_floor(self):
|
|
self.assertFalse(self._visible(self.u_sr, 'fusion_plating_shopfloor.menu_fp_shopfloor_root'))
|
|
|
|
def test_manager_sees_quality(self):
|
|
self.assertTrue(self._visible(self.u_mgr, 'fusion_plating_quality.menu_fp_quality'))
|
|
|
|
def test_manager_does_not_see_compliance(self):
|
|
self.assertFalse(self._visible(self.u_mgr, 'fusion_plating_compliance.menu_fp_compliance_hub'))
|
|
|
|
def test_manager_does_not_see_audits_submenu(self):
|
|
# Manager sees Quality, but NOT the Audits child
|
|
self.assertFalse(self._visible(self.u_mgr, 'fusion_plating_quality.menu_fp_audits'))
|
|
|
|
def test_qm_sees_compliance(self):
|
|
self.assertTrue(self._visible(self.u_qm, 'fusion_plating_compliance.menu_fp_compliance_hub'))
|
|
|
|
def test_qm_sees_audits_submenu(self):
|
|
self.assertTrue(self._visible(self.u_qm, 'fusion_plating_quality.menu_fp_audits'))
|
|
|
|
def test_owner_sees_team(self):
|
|
self.assertTrue(self._visible(self.u_owner, 'fusion_plating.menu_fp_team'))
|
|
```
|
|
|
|
- [ ] **Step 2: Run — expect failures**
|
|
|
|
```bash
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin --test-tags fp_perms --test-enable --stop-after-init -u fusion_plating\"'" 2>&1 | grep -E '(FAIL|test_)'
|
|
```
|
|
Expected: several FAIL until D2-D6 done. The `menu_fp_team` test will ERROR (menu doesn't exist yet — Phase F creates it).
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating/tests/test_menu_visibility.py
|
|
git commit -m "test(plating-menu): per-role menu visibility matrix"
|
|
```
|
|
|
|
### Task D2: Layer 1 — top-level menu groups
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating/views/fp_menu.xml`
|
|
- Modify: `fusion_plating_configurator/views/fp_configurator_menu.xml`
|
|
- Modify: `fusion_plating_shopfloor/views/fp_shopfloor_menu.xml`
|
|
- Modify: `fusion_plating_receiving/views/fp_receiving_menu.xml`
|
|
- Modify: `fusion_plating_quality/views/fp_menu.xml`
|
|
- Modify: `fusion_plating_compliance/views/fp_menu.xml`
|
|
- Modify: `fusion_plating_kpi/views/fp_kpi_menu.xml`
|
|
|
|
- [ ] **Step 1: Set explicit `groups=` per spec Section 2.E table**
|
|
|
|
For each top-level menu, edit the `<menuitem>` element to add or update the `groups=` attribute:
|
|
|
|
| Menu xmlid | `groups=` value |
|
|
|---|---|
|
|
| `fusion_plating.menu_fp_root` | `"fusion_plating.group_fp_technician,fusion_plating.group_fp_sales_rep"` (covers everyone via implication) |
|
|
| `fusion_plating_configurator.menu_fp_sales_root` | `"fusion_plating.group_fp_sales_rep"` |
|
|
| `fusion_plating_shopfloor.menu_fp_shopfloor_root` | `"fusion_plating.group_fp_technician"` |
|
|
| `fusion_plating.menu_fp_operations` | `"fusion_plating.group_fp_technician"` |
|
|
| `fusion_plating_receiving.menu_fp_receiving_root` | `"fusion_plating.group_fp_shop_manager_v2"` |
|
|
| `fusion_plating_quality.menu_fp_quality` | `"fusion_plating.group_fp_manager"` |
|
|
| `fusion_plating_compliance.menu_fp_compliance_hub` | `"fusion_plating.group_fp_quality_manager"` |
|
|
| `fusion_plating_kpi.menu_fp_kpi` (or equivalent) | `"fusion_plating.group_fp_manager"` |
|
|
| `fusion_plating.menu_fp_config` | `"fusion_plating.group_fp_manager"` |
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating/views/fp_menu.xml \
|
|
fusion_plating_configurator/views/fp_configurator_menu.xml \
|
|
fusion_plating_shopfloor/views/fp_shopfloor_menu.xml \
|
|
fusion_plating_receiving/views/fp_receiving_menu.xml \
|
|
fusion_plating_quality/views/fp_menu.xml \
|
|
fusion_plating_compliance/views/fp_menu.xml \
|
|
fusion_plating_kpi/views/fp_kpi_menu.xml
|
|
git commit -m "feat(plating-menu): Layer 1 — explicit groups on top-level menus"
|
|
```
|
|
|
|
### Task D3: Layer 2 — submenu groups (quality and compliance hub)
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating_quality/views/fp_menu.xml`
|
|
- Modify: `fusion_plating_compliance/views/fp_menu.xml`
|
|
- Modify: `fusion_plating_compliance_on/views/fp_menu.xml` (if exists)
|
|
- Modify: `fusion_plating_aerospace/views/fp_menu.xml` (if exists)
|
|
- Modify: `fusion_plating_nuclear/views/fp_menu.xml` (if exists)
|
|
- Modify: `fusion_plating_safety/views/fp_menu.xml` (if exists)
|
|
|
|
- [ ] **Step 1: Add `groups=` to submenus that need different visibility from parent**
|
|
|
|
In `fusion_plating_quality/views/fp_menu.xml`, for the Audits / Customer Specs / Approved Vendor List submenus, set `groups="fusion_plating.group_fp_quality_manager"`. Leave other quality submenus to inherit (Manager+).
|
|
|
|
In `fusion_plating_compliance/views/fp_menu.xml`, set every child submenu of `menu_fp_compliance_hub` to `groups="fusion_plating.group_fp_quality_manager"`. Same for each vertical's submenu under the hub.
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating_quality/views/fp_menu.xml \
|
|
fusion_plating_compliance/views/fp_menu.xml \
|
|
fusion_plating_compliance_on/views/fp_menu.xml \
|
|
fusion_plating_aerospace/views/fp_menu.xml \
|
|
fusion_plating_nuclear/views/fp_menu.xml \
|
|
fusion_plating_safety/views/fp_menu.xml
|
|
git commit -m "feat(plating-menu): Layer 2 — QM-only submenus under Quality and Compliance"
|
|
```
|
|
|
|
### Task D4: Layer 2 — Operations submenu split (Tech vs Shop Mgr)
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating/views/fp_menu.xml` (or wherever the Operations submenus are defined)
|
|
|
|
- [ ] **Step 1: Set submenu groups per spec table**
|
|
|
|
| Submenu xmlid | `groups=` |
|
|
|---|---|
|
|
| `menu_fp_maintenance` (under Operations) | `"fusion_plating.group_fp_shop_manager_v2"` |
|
|
| `menu_fp_job_step_move` (Move Log) | `"fusion_plating.group_fp_shop_manager_v2"` |
|
|
| `menu_fp_job_step_timelog` (Labor History) | `"fusion_plating.group_fp_shop_manager_v2"` |
|
|
| `menu_fp_replenishment_suggestions` | `"fusion_plating.group_fp_manager"` |
|
|
|
|
(Leave Process Recipes, Baths, Chemistry Logs, Tanks, Racks visible to Technician via parent.)
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating/views/fp_menu.xml
|
|
git commit -m "feat(plating-menu): Layer 2 — shop-leadership submenus under Operations"
|
|
```
|
|
|
|
### Task D5: Layer 3 — field/button visibility
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating_configurator/views/sale_order_views.xml`
|
|
- Modify: `fusion_plating_invoicing/views/res_partner_views.xml`
|
|
- Modify: `fusion_plating_quality/views/fp_capa_views.xml`
|
|
- Modify: `fusion_plating_quality/views/fp_audit_views.xml`
|
|
- Modify: `fusion_plating_quality/views/fp_avl_views.xml`
|
|
- Modify: `fusion_plating_quality/views/fp_customer_spec_views.xml`
|
|
- Modify: `fusion_plating_certificates/views/fp_certificate_views.xml`
|
|
- Modify: `fusion_plating_cgp/views/*_views.xml`
|
|
|
|
- [ ] **Step 1: Add `groups=` to fields/buttons per spec Section 2.E Layer 3 table**
|
|
|
|
Specific changes:
|
|
|
|
| View | Element | `groups=` |
|
|
|---|---|---|
|
|
| sale_order_views.xml | `<button name="action_confirm" ...>` | `"fusion_plating.group_fp_sales_manager"` |
|
|
| sale_order_views.xml | `<field name="price_unit" .../>` on order_line | `"fusion_plating.group_fp_sales_rep"` |
|
|
| sale_order_views.xml | `<field name="price_subtotal" .../>` | `"fusion_plating.group_fp_sales_rep"` |
|
|
| sale_order_views.xml | `<field name="amount_total" .../>` | `"fusion_plating.group_fp_sales_rep"` |
|
|
| res_partner_views.xml | `<field name="x_fc_account_hold_override" .../>` | `"fusion_plating.group_fp_manager"` |
|
|
| fp_capa_views.xml | `<button name="action_close" .../>` | `"fusion_plating.group_fp_quality_manager"` |
|
|
| fp_capa_views.xml | Notebook fields editable | wrap in `<group groups="...">` |
|
|
| fp_audit_views.xml | All `<button>` elements | `"fusion_plating.group_fp_quality_manager"` |
|
|
| fp_avl_views.xml | `<button name="action_approve" .../>` and `action_disqualify` | `"fusion_plating.group_fp_quality_manager"` |
|
|
| fp_customer_spec_views.xml | Edit fields | `"fusion_plating.group_fp_quality_manager"` |
|
|
| fp_certificate_views.xml | `<button name="action_sign" .../>` for FAIR/Nadcap (use `invisible` with cert_type in domain too) | `"fusion_plating.group_fp_quality_manager"` |
|
|
| cgp views | Every button | `"fusion_plating.group_fp_quality_manager"` |
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating_configurator/views/sale_order_views.xml \
|
|
fusion_plating_invoicing/views/res_partner_views.xml \
|
|
fusion_plating_quality/views/ \
|
|
fusion_plating_certificates/views/fp_certificate_views.xml \
|
|
fusion_plating_cgp/views/
|
|
git commit -m "feat(plating-views): Layer 3 — field/button gates per role"
|
|
```
|
|
|
|
### Task D6: Deploy + run menu visibility tests
|
|
|
|
- [ ] **Step 1: Deploy all touched modules**
|
|
|
|
```bash
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && \
|
|
su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin \
|
|
-u fusion_plating,fusion_plating_configurator,fusion_plating_shopfloor,fusion_plating_receiving,fusion_plating_quality,fusion_plating_compliance,fusion_plating_compliance_on,fusion_plating_aerospace,fusion_plating_nuclear,fusion_plating_safety,fusion_plating_kpi,fusion_plating_invoicing,fusion_plating_certificates,fusion_plating_cgp \
|
|
--stop-after-init\" && systemctl start odoo'"
|
|
```
|
|
|
|
- [ ] **Step 2: Run menu visibility test suite**
|
|
|
|
```bash
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin --test-tags fp_perms --test-enable --stop-after-init -u fusion_plating\"'" 2>&1 | tail -20
|
|
```
|
|
Expected: all PASS except `test_owner_sees_team` (Team menu created in Phase F).
|
|
|
|
---
|
|
|
|
## Phase E — Landing Resolver
|
|
|
|
Replaces the existing 5-level resolver with role-based dispatch. Adds 4 net-new pickable actions. Tightens picklist domain to filter by user accessibility.
|
|
|
|
### Task E1: Write landing-resolver tests
|
|
|
|
**Files:**
|
|
- Create: `fusion_plating/tests/test_landing_resolver.py`
|
|
|
|
- [ ] **Step 1: Write the test**
|
|
|
|
```python
|
|
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')
|
|
|
|
def _resolve(self, user):
|
|
Server = self.env.ref('fusion_plating.action_fp_resolve_plating_landing')
|
|
return Server.with_user(user).with_context().run()
|
|
|
|
def test_owner_lands_on_manager_desk(self):
|
|
action = self._resolve(self.u_owner)
|
|
self.assertEqual(action.get('xml_id'),
|
|
'fusion_plating_shopfloor.action_fp_manager_dashboard')
|
|
|
|
def test_qm_lands_on_quality_dashboard(self):
|
|
action = self._resolve(self.u_qm)
|
|
self.assertEqual(action.get('xml_id'),
|
|
'fusion_plating_quality.action_fp_quality_dashboard')
|
|
|
|
def test_manager_lands_on_manager_desk(self):
|
|
action = self._resolve(self.u_mgr)
|
|
self.assertEqual(action.get('xml_id'),
|
|
'fusion_plating_shopfloor.action_fp_manager_dashboard')
|
|
|
|
def test_sales_manager_lands_on_sale_orders(self):
|
|
action = self._resolve(self.u_smg)
|
|
self.assertEqual(action.get('xml_id'),
|
|
'fusion_plating_configurator.action_fp_sale_orders')
|
|
|
|
def test_sales_rep_lands_on_quotations(self):
|
|
action = self._resolve(self.u_sr)
|
|
self.assertEqual(action.get('xml_id'),
|
|
'fusion_plating_configurator.action_fp_quotations')
|
|
|
|
def test_technician_lands_on_plant_kanban_v2(self):
|
|
self.env['ir.config_parameter'].sudo().set_param(
|
|
'fusion_plating_shopfloor.layout', 'v2')
|
|
action = self._resolve(self.u_tech)
|
|
self.assertEqual(action.get('xml_id'),
|
|
'fusion_plating_shopfloor.action_fp_plant_kanban')
|
|
|
|
def test_technician_lands_on_legacy_workstation(self):
|
|
self.env['ir.config_parameter'].sudo().set_param(
|
|
'fusion_plating_shopfloor.layout', 'legacy')
|
|
action = self._resolve(self.u_tech)
|
|
self.assertEqual(action.get('xml_id'),
|
|
'fusion_plating_shopfloor.action_fp_shopfloor_landing')
|
|
|
|
def test_user_override_wins(self):
|
|
self.u_tech.x_fc_plating_landing_action_id = self.env.ref(
|
|
'fusion_plating_configurator.action_fp_quotations')
|
|
action = self._resolve(self.u_tech)
|
|
self.assertEqual(action.get('xml_id'),
|
|
'fusion_plating_configurator.action_fp_quotations')
|
|
```
|
|
|
|
- [ ] **Step 2: Run — expect failures**
|
|
|
|
Expected: most FAIL because the new resolver isn't deployed yet.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating/tests/test_landing_resolver.py
|
|
git commit -m "test(plating-landing): per-role dispatch + layout flag + user override"
|
|
```
|
|
|
|
### Task E2: Replace landing resolver implementation
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating/data/fp_landing_data.xml`
|
|
- Modify: `fusion_plating/models/fp_landing.py`
|
|
|
|
- [ ] **Step 1: Replace the server action code**
|
|
|
|
In `fp_landing_data.xml`, find the `<record id="action_fp_resolve_plating_landing">` and replace the `code` field's body with a call to a helper method:
|
|
|
|
```xml
|
|
<field name="code">action = env['ir.actions.act_window']._fp_resolve_landing_for_current_user() or False</field>
|
|
```
|
|
|
|
- [ ] **Step 2: Implement `_fp_resolve_landing_for_current_user` on `ir.actions.act_window`**
|
|
|
|
In `fp_landing.py`, add the method (per spec Section 3 code):
|
|
|
|
```python
|
|
class IrActionsActWindow(models.Model):
|
|
_inherit = 'ir.actions.act_window'
|
|
|
|
def _fp_resolve_landing_for_current_user(self):
|
|
user = self.env.user
|
|
company = self.env.company
|
|
|
|
# 1. User override
|
|
if 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 company.x_fc_default_landing_action_id:
|
|
return company.x_fc_default_landing_action_id._render_resolved()
|
|
|
|
# 4. Last-ditch
|
|
return self.env.ref('fusion_plating_configurator.action_fp_sale_orders')._render_resolved()
|
|
|
|
def _fp_role_default_landing(self, user, company):
|
|
workstation = self._fp_workstation_action_for_layout(company)
|
|
Ref = self.env.ref
|
|
def safe(xmlid):
|
|
return 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
|
|
|
|
def _fp_workstation_action_for_layout(self, company):
|
|
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):
|
|
"""Return a dict shaped like an action that the resolver can return."""
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': self.name,
|
|
'res_model': self.res_model,
|
|
'views': self._get_views(),
|
|
'view_mode': self.view_mode,
|
|
'target': self.target,
|
|
'context': self.context,
|
|
'domain': self.domain,
|
|
'xml_id': self.get_external_id().get(self.id),
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating/data/fp_landing_data.xml fusion_plating/models/fp_landing.py
|
|
git commit -m "feat(plating-landing): role-based dispatch resolver"
|
|
```
|
|
|
|
### Task E3: Tag 4 new actions as pickable
|
|
|
|
**Files:**
|
|
- Modify: action XML files for `action_fp_manager_dashboard`, `action_fp_plant_kanban`, `action_fp_shopfloor_landing`, `action_fp_quality_dashboard`
|
|
|
|
- [ ] **Step 1: Add `<field name="x_fc_pickable_landing">True</field>` to each action record**
|
|
|
|
Find each `<record id="..." model="ir.actions.act_window">` and append the field inside the record. Locations:
|
|
|
|
- `fusion_plating_shopfloor/views/manager_dashboard_views.xml` (or wherever `action_fp_manager_dashboard` is)
|
|
- `fusion_plating_shopfloor/views/plant_kanban_views.xml`
|
|
- `fusion_plating_shopfloor/views/shopfloor_landing_views.xml`
|
|
- `fusion_plating_quality/views/fp_quality_dashboard_views.xml`
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating_shopfloor/views/ fusion_plating_quality/views/
|
|
git commit -m "feat(plating-landing): expand picklist (Manager Desk, Plant View, Workstation, QualityDash)"
|
|
```
|
|
|
|
### Task E4: Tighten picklist domain to user-accessible actions
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating/models/fp_landing.py` (where `x_fc_plating_landing_action_id` is defined on res.users)
|
|
|
|
- [ ] **Step 1: Change the field's `domain` to compute per-user**
|
|
|
|
```python
|
|
class ResUsers(models.Model):
|
|
_inherit = 'res.users'
|
|
|
|
x_fc_plating_landing_action_id = fields.Many2one(
|
|
'ir.actions.act_window',
|
|
string='Plating Landing Page',
|
|
domain="[('x_fc_pickable_landing', '=', True), ('id', 'in', accessible_action_ids)]",
|
|
help='Override the default Plating landing page for this user.')
|
|
|
|
accessible_action_ids = fields.Many2many(
|
|
'ir.actions.act_window',
|
|
compute='_compute_accessible_action_ids')
|
|
|
|
@api.depends('groups_id')
|
|
def _compute_accessible_action_ids(self):
|
|
Window = self.env['ir.actions.act_window']
|
|
pickable = Window.search([('x_fc_pickable_landing', '=', True)])
|
|
for user in self:
|
|
allowed = []
|
|
for action in pickable.with_user(user):
|
|
try:
|
|
self.env[action.res_model].with_user(user).check_access_rights('read')
|
|
allowed.append(action.id)
|
|
except Exception:
|
|
pass
|
|
user.accessible_action_ids = [(6, 0, allowed)]
|
|
```
|
|
|
|
- [ ] **Step 2: Deploy + run resolver tests**
|
|
|
|
```bash
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && \
|
|
su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin \
|
|
-u fusion_plating,fusion_plating_shopfloor,fusion_plating_quality \
|
|
--test-tags fp_perms --test-enable --stop-after-init\" && systemctl start odoo'" 2>&1 | tail -15
|
|
```
|
|
Expected: all resolver tests PASS.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating/models/fp_landing.py
|
|
git commit -m "feat(plating-landing): filter picklist by user-accessible actions"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase F — Owner-only Team Page
|
|
|
|
Single new Selection field on `res.users` + standard Odoo kanban/form views.
|
|
|
|
### Task F1: Write Team-page tests
|
|
|
|
**Files:**
|
|
- Create: `fusion_plating/tests/test_team_page.py`
|
|
|
|
- [ ] **Step 1: Write the test (covers compute, inverse, kanban access)**
|
|
|
|
```python
|
|
from odoo.tests.common import TransactionCase, tagged
|
|
|
|
|
|
@tagged('-at_install', 'post_install', 'fp_perms')
|
|
class TestTeamPage(TransactionCase):
|
|
|
|
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):
|
|
self.target.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_clears_old_role_and_sets_new(self):
|
|
self.target.with_user(self.owner).x_fc_plating_role = 'shop_manager'
|
|
# Technician group should be removed
|
|
tech = self.env.ref('fusion_plating.group_fp_technician')
|
|
sm = self.env.ref('fusion_plating.group_fp_shop_manager_v2')
|
|
self.assertNotIn(tech, self.target.groups_id - sm.trans_implied_ids)
|
|
self.assertIn(sm, self.target.groups_id)
|
|
|
|
def test_inverse_to_no_clears_all_plating_groups(self):
|
|
self.target.with_user(self.owner).x_fc_plating_role = 'no'
|
|
plating_groups = self.env['res.groups'].search([
|
|
('id', 'in', [
|
|
self.env.ref(f'fusion_plating.group_fp_{x}').id
|
|
for x in ('technician', 'sales_rep', 'shop_manager_v2',
|
|
'sales_manager', 'manager', 'quality_manager', 'owner')
|
|
])
|
|
])
|
|
for g in plating_groups:
|
|
self.assertNotIn(g, self.target.groups_id)
|
|
|
|
def test_inverse_posts_chatter_audit(self):
|
|
before_count = self.target.message_ids.search_count(
|
|
[('res_id', '=', self.target.id)])
|
|
self.target.with_user(self.owner).x_fc_plating_role = 'manager'
|
|
after_count = self.target.message_ids.search_count(
|
|
[('res_id', '=', self.target.id)])
|
|
self.assertGreater(after_count, before_count,
|
|
'Role change must post chatter audit')
|
|
|
|
def test_team_menu_visible_to_owner(self):
|
|
menu = self.env.ref('fusion_plating.menu_fp_team')
|
|
visible = self.env['ir.ui.menu'].with_user(self.owner).search([('id', '=', menu.id)])
|
|
self.assertTrue(visible)
|
|
|
|
def test_team_menu_hidden_from_manager(self):
|
|
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])],
|
|
})
|
|
menu = self.env.ref('fusion_plating.menu_fp_team')
|
|
visible = self.env['ir.ui.menu'].with_user(mgr).search([('id', '=', menu.id)])
|
|
self.assertFalse(visible)
|
|
```
|
|
|
|
- [ ] **Step 2: Run — expect total failure** (no field, no menu, no views yet)
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating/tests/test_team_page.py
|
|
git commit -m "test(plating-team): compute/inverse + chatter audit + menu visibility"
|
|
```
|
|
|
|
### Task F2: Add `x_fc_plating_role` Selection field on res.users
|
|
|
|
**Files:**
|
|
- Modify or Create: `fusion_plating/models/res_users.py`
|
|
|
|
- [ ] **Step 1: Add field + compute + inverse**
|
|
|
|
```python
|
|
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',
|
|
}
|
|
_FP_ROLE_PRECEDENCE = [ # highest → lowest
|
|
'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')
|
|
|
|
@api.depends('groups_id')
|
|
def _compute_plating_role(self):
|
|
for user in self:
|
|
role = 'no'
|
|
for candidate in _FP_ROLE_PRECEDENCE:
|
|
xmlid = _FP_PLATING_ROLE_TO_GROUP_XMLID[candidate]
|
|
grp = self.env.ref(xmlid, raise_if_not_found=False)
|
|
if grp and grp in user.groups_id:
|
|
role = candidate
|
|
break
|
|
user.x_fc_plating_role = role
|
|
|
|
def _inverse_plating_role(self):
|
|
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 from groups_id
|
|
to_remove = [
|
|
self.env.ref(xmlid, raise_if_not_found=False).id
|
|
for xmlid in _FP_PLATING_ROLE_TO_GROUP_XMLID.values()
|
|
if self.env.ref(xmlid, raise_if_not_found=False)
|
|
]
|
|
user.write({'groups_id': [(3, gid) for gid in to_remove if gid]})
|
|
|
|
# Add the new role's group (no-op for 'no')
|
|
if new_role and new_role != 'no':
|
|
target_xmlid = _FP_PLATING_ROLE_TO_GROUP_XMLID[new_role]
|
|
target_group = self.env.ref(target_xmlid, raise_if_not_found=False)
|
|
if target_group:
|
|
user.write({'groups_id': [(4, target_group.id)]})
|
|
|
|
# Post chatter audit
|
|
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')
|
|
```
|
|
|
|
Make sure `fusion_plating/models/__init__.py` imports `res_users`:
|
|
|
|
```python
|
|
from . import res_users
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating/models/res_users.py fusion_plating/models/__init__.py
|
|
git commit -m "feat(plating-team): x_fc_plating_role Selection with compute+inverse+audit"
|
|
```
|
|
|
|
### Task F3: Create Team kanban view + menu
|
|
|
|
**Files:**
|
|
- Create: `fusion_plating/views/fp_team_views.xml`
|
|
|
|
- [ ] **Step 1: Write the view file**
|
|
|
|
```xml
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<odoo>
|
|
<data>
|
|
<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"
|
|
on_create="quick_create" group_create="false" group_delete="false">
|
|
<field name="id"/>
|
|
<field name="x_fc_plating_role"/>
|
|
<field name="login"/>
|
|
<field name="email"/>
|
|
<field name="image_128"/>
|
|
<field name="login_date"/>
|
|
<progressbar field="x_fc_plating_role" colors='{"owner":"warning","quality_manager":"warning"}'/>
|
|
<templates>
|
|
<t t-name="kanban-box">
|
|
<div t-attf-class="oe_kanban_card">
|
|
<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><field name="email"/></div>
|
|
<div t-if="record.login_date.raw_value">
|
|
Last seen: <field name="login_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>
|
|
```
|
|
|
|
Add `'views/fp_team_views.xml'` to the manifest `data` list.
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating/views/fp_team_views.xml fusion_plating/__manifest__.py
|
|
git commit -m "feat(plating-team): Owner-only Team kanban + menu"
|
|
```
|
|
|
|
### Task F4: Add Designated Officials fields on res.company + form
|
|
|
|
**Files:**
|
|
- Modify or Create: `fusion_plating/models/res_company.py`
|
|
- Modify or Create: `fusion_plating/views/res_company_views.xml`
|
|
|
|
- [ ] **Step 1: Add fields**
|
|
|
|
```python
|
|
class ResCompany(models.Model):
|
|
_inherit = 'res.company'
|
|
|
|
x_fc_cgp_designated_official_id = fields.Many2one(
|
|
'res.users',
|
|
string='CGP Designated Official',
|
|
domain="[('groups_id', 'in', [%(qm)d, %(owner)d])]" % {
|
|
'qm': 0, 'owner': 0 # placeholders; real domain resolved at runtime
|
|
},
|
|
help="Person registered with PSPC as the Designated Official under Defence Production Act §22.",
|
|
tracking=True,
|
|
)
|
|
|
|
x_fc_nadcap_authority_user_id = fields.Many2one(
|
|
'res.users',
|
|
string='Nadcap Authority',
|
|
help="Person who signs Nadcap-specific certificates and audits.",
|
|
tracking=True,
|
|
)
|
|
```
|
|
|
|
(The domain placeholders above won't work — use a Python-computed domain via `@api.model` or a domain string evaluated at view-render time. Simplest approach: use a `_check_company` validator instead of a Many2one domain, OR use a domain string referencing groups by xmlid in the view:
|
|
|
|
```xml
|
|
<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'))])]"/>
|
|
```
|
|
|
|
)
|
|
|
|
- [ ] **Step 2: Surface fields on company form**
|
|
|
|
In `fusion_plating/views/res_company_views.xml`:
|
|
|
|
```xml
|
|
<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>
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating/models/res_company.py fusion_plating/views/res_company_views.xml \
|
|
fusion_plating/__manifest__.py fusion_plating/models/__init__.py
|
|
git commit -m "feat(plating-team): CGP DO + Nadcap Authority fields on res.company"
|
|
```
|
|
|
|
### Task F5: Deploy + run Team-page tests
|
|
|
|
- [ ] **Step 1: Deploy**
|
|
|
|
```bash
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && \
|
|
su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin \
|
|
-u fusion_plating --stop-after-init\" && systemctl start odoo'"
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests**
|
|
|
|
```bash
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin --test-tags fp_perms --test-enable --stop-after-init -u fusion_plating\"'" 2>&1 | tail -20
|
|
```
|
|
Expected: Team page tests PASS.
|
|
|
|
---
|
|
|
|
## Phase G — Sales Manager Confirm Gate + Bypass-Flag Typo Fixes
|
|
|
|
### Task G1: Test the SO confirm gate
|
|
|
|
**Files:**
|
|
- Create: `fusion_plating/tests/test_sales_manager_gate.py`
|
|
|
|
- [ ] **Step 1: Write the test**
|
|
|
|
```python
|
|
from odoo.tests.common import TransactionCase, tagged
|
|
from odoo.exceptions import UserError
|
|
|
|
|
|
@tagged('-at_install', 'post_install', 'fp_perms')
|
|
class TestSalesManagerGate(TransactionCase):
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
Users = self.env['res.users'].with_context(no_reset_password=True)
|
|
self.u_sr = Users.create({
|
|
'login': 'gate_sr', 'name': 'Gate SR',
|
|
'email': 'gate_sr@example.com',
|
|
'groups_id': [(6, 0, [self.env.ref('fusion_plating.group_fp_sales_rep').id])],
|
|
})
|
|
self.u_smg = Users.create({
|
|
'login': 'gate_smg', 'name': 'Gate SMg',
|
|
'email': 'gate_smg@example.com',
|
|
'groups_id': [(6, 0, [self.env.ref('fusion_plating.group_fp_sales_manager').id])],
|
|
})
|
|
partner = self.env['res.partner'].create({'name': 'Gate Test Customer'})
|
|
product = self.env['product.product'].create({'name': 'Gate Test Product'})
|
|
self.so = self.env['sale.order'].create({
|
|
'partner_id': partner.id,
|
|
'order_line': [(0, 0, {
|
|
'product_id': product.id, 'product_uom_qty': 1, 'price_unit': 100,
|
|
})],
|
|
})
|
|
|
|
def test_sales_rep_cannot_confirm(self):
|
|
with self.assertRaises(UserError):
|
|
self.so.with_user(self.u_sr).action_confirm()
|
|
|
|
def test_sales_manager_can_confirm(self):
|
|
self.so.with_user(self.u_smg).action_confirm()
|
|
self.assertEqual(self.so.state, 'sale')
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating/tests/test_sales_manager_gate.py
|
|
git commit -m "test(plating-sales): SO confirm gate (SR blocked, SMg allowed)"
|
|
```
|
|
|
|
### Task G2: Implement SO confirm gate
|
|
|
|
**Files:**
|
|
- Modify or Create: `fusion_plating/models/sale_order.py`
|
|
|
|
- [ ] **Step 1: Override action_confirm**
|
|
|
|
```python
|
|
from odoo import _, models
|
|
from odoo.exceptions import UserError
|
|
|
|
|
|
class SaleOrder(models.Model):
|
|
_inherit = 'sale.order'
|
|
|
|
def action_confirm(self):
|
|
if not self.env.user.has_group('fusion_plating.group_fp_sales_manager'):
|
|
raise UserError(_(
|
|
'Only Sales Manager or higher can confirm Sale Orders. '
|
|
'Please ask a Sales Manager to confirm this quote.'
|
|
))
|
|
return super().action_confirm()
|
|
```
|
|
|
|
Add to `fusion_plating/models/__init__.py`:
|
|
|
|
```python
|
|
from . import sale_order
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating/models/sale_order.py fusion_plating/models/__init__.py
|
|
git commit -m "feat(plating-sales): require Sales Manager+ to confirm SO"
|
|
```
|
|
|
|
### Task G3: Fix the `_administrator` typo bug (audit finding #11)
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating_invoicing/models/res_partner.py:33-35`
|
|
- Modify: `fusion_plating_configurator/wizard/fp_direct_order_wizard.py:467`
|
|
|
|
- [ ] **Step 1: Fix res_partner.py**
|
|
|
|
Find the line containing `has_group('fusion_plating.group_fusion_plating_administrator')` and replace with `has_group('fusion_plating.group_fp_manager')`. The `_administrator` xmlid never existed; the intent was Manager-and-above. New Manager group includes everything implied above.
|
|
|
|
- [ ] **Step 2: Fix fp_direct_order_wizard.py**
|
|
|
|
Same pattern, same fix.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating_invoicing/models/res_partner.py \
|
|
fusion_plating_configurator/wizard/fp_direct_order_wizard.py
|
|
git commit -m "fix(plating-sec): replace dead _administrator group check with group_fp_manager"
|
|
```
|
|
|
|
### Task G4: Sweep bypass-flag group checks across battle-test gate code
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating_jobs/models/fp_job_step.py`
|
|
- Modify: `fusion_plating_jobs/models/fp_job.py`
|
|
- Modify: `fusion_plating_quality/controllers/qc_controller.py`
|
|
- Modify: `fusion_plating_shopfloor/controllers/_tablet_audit.py`
|
|
- Any other file that checks `group_fusion_plating_manager` in Python
|
|
|
|
- [ ] **Step 1: Find all references**
|
|
|
|
```bash
|
|
grep -rn "group_fusion_plating_manager" --include="*.py" .
|
|
```
|
|
|
|
- [ ] **Step 2: For each match, replace with `group_fp_manager`**
|
|
|
|
The old xmlid `fusion_plating.group_fusion_plating_manager` still exists (Phase A kept it for backward-compat), so existing Python code still works — but using the NEW xmlid is correct going forward. Edit each file.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add -u
|
|
git commit -m "refactor(plating-sec): update Python has_group() calls to group_fp_manager"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase H — Migration Workflow (dry-run + Owner approval)
|
|
|
|
### Task H1: Define `fp.migration.preview` + `fp.migration.preview.line` models
|
|
|
|
**Files:**
|
|
- Create: `fusion_plating/models/fp_migration.py`
|
|
- Modify: `fusion_plating/models/__init__.py`
|
|
- Create: `fusion_plating/models/fp_role_constants.py`
|
|
|
|
- [ ] **Step 1: Write `fp_role_constants.py`**
|
|
|
|
```python
|
|
# Single source of truth for old-group xmlids that the migration sweeps
|
|
_FP_OLD_GROUP_XMLIDS = [
|
|
'fusion_plating.group_fusion_plating_operator',
|
|
'fusion_plating.group_fusion_plating_supervisor',
|
|
'fusion_plating.group_fusion_plating_manager',
|
|
'fusion_plating.group_fusion_plating_admin',
|
|
'fusion_plating_configurator.group_fp_estimator',
|
|
'fusion_plating_configurator.group_fp_shop_manager',
|
|
'fusion_plating_invoicing.group_fp_accounting',
|
|
'fusion_plating_receiving.group_fp_receiving',
|
|
'fusion_plating_cgp.group_fusion_plating_cgp_officer',
|
|
'fusion_plating_cgp.group_fusion_plating_cgp_designated_official',
|
|
'fusion_plating_jobs.group_fusion_plating_legacy_menus',
|
|
]
|
|
|
|
# Per spec Section 5: predicate-driven mapping from old → new role.
|
|
# Order matters; first match wins (highest-precedence first).
|
|
_FP_ROLE_MAPPING_RULES = [
|
|
# (label, predicate, new_role, capability_delta)
|
|
('uid_1_or_2',
|
|
lambda u: u.id in (1, 2),
|
|
'owner', None),
|
|
('admin',
|
|
lambda u: u.has_group('fusion_plating.group_fusion_plating_admin'),
|
|
'owner', None),
|
|
('cgp_do',
|
|
lambda u: u.has_group('fusion_plating_cgp.group_fusion_plating_cgp_designated_official'),
|
|
'owner', 'Was CGP DO; field set on res.company'),
|
|
('cgp_officer',
|
|
lambda u: u.has_group('fusion_plating_cgp.group_fusion_plating_cgp_officer'),
|
|
'quality_manager', None),
|
|
('manager',
|
|
lambda u: u.has_group('fusion_plating.group_fusion_plating_manager'),
|
|
'manager', None),
|
|
('shop_manager_old',
|
|
lambda u: u.has_group('fusion_plating_configurator.group_fp_shop_manager'),
|
|
'manager', None),
|
|
('accounting',
|
|
lambda u: u.has_group('fusion_plating_invoicing.group_fp_accounting'),
|
|
'manager', None),
|
|
('estimator_alone',
|
|
lambda u: (u.has_group('fusion_plating_configurator.group_fp_estimator')
|
|
and not u.has_group('fusion_plating.group_fusion_plating_manager')),
|
|
'sales_rep', 'Loses order-confirm authority'),
|
|
('supervisor',
|
|
lambda u: u.has_group('fusion_plating.group_fusion_plating_supervisor'),
|
|
'shop_manager', None),
|
|
('receiving',
|
|
lambda u: u.has_group('fusion_plating_receiving.group_fp_receiving'),
|
|
'shop_manager', None),
|
|
('operator',
|
|
lambda u: u.has_group('fusion_plating.group_fusion_plating_operator'),
|
|
'technician', None),
|
|
('catchall',
|
|
lambda u: True,
|
|
'no', None),
|
|
]
|
|
|
|
|
|
def fp_resolve_target_role(user):
|
|
"""Returns (role_key, capability_delta_or_None). First predicate match wins."""
|
|
for _label, predicate, role, delta in _FP_ROLE_MAPPING_RULES:
|
|
if predicate(user):
|
|
return role, delta
|
|
return 'no', None
|
|
```
|
|
|
|
- [ ] **Step 2: Write `fp_migration.py`**
|
|
|
|
```python
|
|
import json
|
|
from datetime import timedelta
|
|
from markupsafe import Markup
|
|
|
|
from odoo import _, api, fields, models
|
|
from odoo.exceptions import UserError
|
|
|
|
from .fp_role_constants import (
|
|
_FP_OLD_GROUP_XMLIDS,
|
|
_FP_ROLE_MAPPING_RULES,
|
|
fp_resolve_target_role,
|
|
)
|
|
|
|
|
|
_ROLE_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',
|
|
'no': None,
|
|
}
|
|
|
|
|
|
class FpMigrationPreview(models.Model):
|
|
_name = 'fp.migration.preview'
|
|
_description = 'Fusion Plating Role Migration Preview'
|
|
_inherit = ['mail.thread']
|
|
_order = 'create_date desc'
|
|
|
|
name = fields.Char(default=lambda s: _('Migration %s') % fields.Datetime.now())
|
|
state = fields.Selection([
|
|
('pending', 'Pending Review'),
|
|
('approved', 'Approved & Applied'),
|
|
('cancelled', 'Cancelled'),
|
|
('rolled_back', 'Rolled Back'),
|
|
], default='pending', tracking=True)
|
|
line_ids = fields.One2many('fp.migration.preview.line', 'preview_id')
|
|
user_count = fields.Integer(compute='_compute_counts', store=True)
|
|
warning_count = fields.Integer(compute='_compute_counts', store=True)
|
|
approved_by_id = fields.Many2one('res.users', readonly=True)
|
|
approved_at = fields.Datetime(readonly=True)
|
|
rollback_deadline = fields.Datetime(compute='_compute_rollback_deadline')
|
|
|
|
@api.depends('line_ids', 'line_ids.warning')
|
|
def _compute_counts(self):
|
|
for rec in self:
|
|
rec.user_count = len(rec.line_ids)
|
|
rec.warning_count = sum(1 for ln in rec.line_ids if ln.warning)
|
|
|
|
@api.depends('approved_at')
|
|
def _compute_rollback_deadline(self):
|
|
for rec in self:
|
|
rec.rollback_deadline = (rec.approved_at + timedelta(days=30)
|
|
if rec.approved_at else False)
|
|
|
|
def _fp_build_lines(self):
|
|
self.ensure_one()
|
|
Users = self.env['res.users']
|
|
for user in Users.search([('share', '=', False), ('active', '=', True)]):
|
|
role, delta = fp_resolve_target_role(user)
|
|
self.env['fp.migration.preview.line'].create({
|
|
'preview_id': self.id,
|
|
'user_id': user.id,
|
|
'proposed_role': role,
|
|
'capability_delta': delta or '',
|
|
'warning': bool(delta),
|
|
})
|
|
|
|
def _fp_notify_owners(self):
|
|
self.ensure_one()
|
|
owner_grp = self.env.ref('fusion_plating.group_fp_owner', raise_if_not_found=False)
|
|
if not owner_grp:
|
|
return
|
|
for owner in owner_grp.users:
|
|
self.env['mail.activity'].create({
|
|
'res_model_id': self.env.ref('fusion_plating.model_fp_migration_preview').id,
|
|
'res_id': self.id,
|
|
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
|
|
'summary': _('Review Fusion Plating role migration'),
|
|
'note': _('%d users affected, %d with capability changes.') % (
|
|
self.user_count, self.warning_count),
|
|
'user_id': owner.id,
|
|
'date_deadline': fields.Date.today(),
|
|
})
|
|
|
|
def action_approve_and_run(self):
|
|
self.ensure_one()
|
|
if not self.env.user.has_group('fusion_plating.group_fp_owner'):
|
|
raise UserError(_('Only Owners can approve role migrations.'))
|
|
if self.state != 'pending':
|
|
raise UserError(_('Migration is no longer pending — current state: %s') % self.state)
|
|
for line in self.line_ids:
|
|
user = line.user_id
|
|
line.applied_groups_snapshot = json.dumps(user.groups_id.ids)
|
|
# Remove old plating groups
|
|
old_ids = [
|
|
self.env.ref(x, raise_if_not_found=False).id
|
|
for x in _FP_OLD_GROUP_XMLIDS
|
|
if self.env.ref(x, raise_if_not_found=False)
|
|
]
|
|
user.write({'groups_id': [(3, gid) for gid in old_ids]})
|
|
# Add new role group (or just clear for 'no')
|
|
target_xmlid = _ROLE_XMLID.get(line.proposed_role)
|
|
if target_xmlid:
|
|
target = self.env.ref(target_xmlid, raise_if_not_found=False)
|
|
if target:
|
|
user.write({'groups_id': [(4, target.id)]})
|
|
user.message_post(body=Markup(_(
|
|
'Plating role assigned by migration: <b>%s</b>'
|
|
)) % line.proposed_role, message_type='notification')
|
|
# CGP DO field
|
|
if line.capability_delta and 'CGP DO' in line.capability_delta:
|
|
user.company_id.x_fc_cgp_designated_official_id = user.id
|
|
self.write({
|
|
'state': 'approved',
|
|
'approved_by_id': self.env.user.id,
|
|
'approved_at': fields.Datetime.now(),
|
|
})
|
|
|
|
def action_rollback(self):
|
|
self.ensure_one()
|
|
if self.state != 'approved':
|
|
raise UserError(_('Only approved migrations can be rolled back.'))
|
|
if fields.Datetime.now() > self.rollback_deadline:
|
|
raise UserError(_('Rollback window has expired (30 days after approval).'))
|
|
for line in self.line_ids:
|
|
if line.applied_groups_snapshot:
|
|
old_ids = json.loads(line.applied_groups_snapshot)
|
|
line.user_id.write({'groups_id': [(6, 0, old_ids)]})
|
|
self.state = 'rolled_back'
|
|
|
|
def action_cancel(self):
|
|
self.ensure_one()
|
|
if self.state != 'pending':
|
|
raise UserError(_('Only pending migrations can be cancelled.'))
|
|
self.state = 'cancelled'
|
|
|
|
@api.model
|
|
def _cron_purge_expired_migrations(self):
|
|
deadline = fields.Datetime.now() - timedelta(days=30)
|
|
expired = self.search([
|
|
('state', '=', 'approved'),
|
|
('approved_at', '<', deadline),
|
|
])
|
|
for preview in expired:
|
|
preview.line_ids.write({'applied_groups_snapshot': False})
|
|
# Unlink the old plating groups (only if no rollback is possible)
|
|
old_ids = [self.env.ref(x, raise_if_not_found=False).id
|
|
for x in _FP_OLD_GROUP_XMLIDS
|
|
if self.env.ref(x, raise_if_not_found=False)]
|
|
if expired and old_ids:
|
|
self.env['res.groups'].browse(old_ids).unlink()
|
|
|
|
|
|
class FpMigrationPreviewLine(models.Model):
|
|
_name = 'fp.migration.preview.line'
|
|
_description = 'Migration Preview Line'
|
|
|
|
preview_id = fields.Many2one('fp.migration.preview', required=True, ondelete='cascade')
|
|
user_id = fields.Many2one('res.users', required=True)
|
|
current_groups = fields.Char(compute='_compute_current_groups')
|
|
proposed_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'),
|
|
])
|
|
capability_delta = fields.Char()
|
|
warning = fields.Boolean()
|
|
notes = fields.Text()
|
|
applied_groups_snapshot = fields.Text()
|
|
|
|
@api.depends('user_id', 'user_id.groups_id')
|
|
def _compute_current_groups(self):
|
|
for line in self:
|
|
line.current_groups = ', '.join(line.user_id.groups_id.mapped('name')) \
|
|
if line.user_id else ''
|
|
```
|
|
|
|
- [ ] **Step 3: Register in `__init__.py`**
|
|
|
|
```python
|
|
from . import fp_role_constants # noqa: F401 — must come BEFORE fp_migration
|
|
from . import fp_migration
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating/models/fp_role_constants.py \
|
|
fusion_plating/models/fp_migration.py \
|
|
fusion_plating/models/__init__.py
|
|
git commit -m "feat(plating-migration): fp.migration.preview model + mapping rules"
|
|
```
|
|
|
|
### Task H2: ACL + views for the migration preview
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating/security/ir.model.access.csv` (add ACL rows for preview models)
|
|
- Create: `fusion_plating/views/fp_migration_views.xml`
|
|
|
|
- [ ] **Step 1: Add ACL rows**
|
|
|
|
```csv
|
|
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
|
|
```
|
|
|
|
- [ ] **Step 2: Write the view file**
|
|
|
|
```xml
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<odoo>
|
|
<data>
|
|
<record id="view_fp_migration_preview_form" model="ir.ui.view">
|
|
<field name="name">fp.migration.preview.form</field>
|
|
<field name="model">fp.migration.preview</field>
|
|
<field name="arch" type="xml">
|
|
<form>
|
|
<header>
|
|
<button name="action_approve_and_run" type="object"
|
|
string="Approve & Run"
|
|
class="oe_highlight"
|
|
invisible="state != 'pending'"
|
|
confirm="This will apply role changes to all listed users. Continue?"/>
|
|
<button name="action_cancel" type="object"
|
|
string="Cancel"
|
|
invisible="state != 'pending'"/>
|
|
<button name="action_rollback" type="object"
|
|
string="Rollback"
|
|
invisible="state != 'approved'"
|
|
confirm="This will restore all users to their pre-migration groups. Continue?"/>
|
|
<field name="state" widget="statusbar"/>
|
|
</header>
|
|
<sheet>
|
|
<group>
|
|
<group>
|
|
<field name="name"/>
|
|
<field name="user_count"/>
|
|
<field name="warning_count"/>
|
|
</group>
|
|
<group>
|
|
<field name="approved_by_id"/>
|
|
<field name="approved_at"/>
|
|
<field name="rollback_deadline"/>
|
|
</group>
|
|
</group>
|
|
<field name="line_ids">
|
|
<list editable="bottom">
|
|
<field name="user_id"/>
|
|
<field name="current_groups"/>
|
|
<field name="proposed_role"/>
|
|
<field name="capability_delta"/>
|
|
<field name="warning" widget="boolean_toggle"/>
|
|
<field name="notes"/>
|
|
</list>
|
|
</field>
|
|
</sheet>
|
|
<chatter/>
|
|
</form>
|
|
</field>
|
|
</record>
|
|
|
|
<record id="view_fp_migration_preview_list" model="ir.ui.view">
|
|
<field name="name">fp.migration.preview.list</field>
|
|
<field name="model">fp.migration.preview</field>
|
|
<field name="arch" type="xml">
|
|
<list>
|
|
<field name="name"/>
|
|
<field name="state" widget="badge"/>
|
|
<field name="user_count"/>
|
|
<field name="warning_count"/>
|
|
<field name="create_date"/>
|
|
<field name="approved_by_id"/>
|
|
<field name="approved_at"/>
|
|
</list>
|
|
</field>
|
|
</record>
|
|
|
|
<record id="action_fp_migration_preview" model="ir.actions.act_window">
|
|
<field name="name">Role Migrations</field>
|
|
<field name="res_model">fp.migration.preview</field>
|
|
<field name="view_mode">list,form</field>
|
|
</record>
|
|
|
|
<menuitem id="menu_fp_migration_preview"
|
|
name="Role Migrations"
|
|
parent="fusion_plating.menu_fp_config"
|
|
action="action_fp_migration_preview"
|
|
sequence="9"
|
|
groups="fusion_plating.group_fp_owner"/>
|
|
</data>
|
|
</odoo>
|
|
```
|
|
|
|
Add `'views/fp_migration_views.xml'` to manifest data.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating/security/ir.model.access.csv \
|
|
fusion_plating/views/fp_migration_views.xml \
|
|
fusion_plating/__manifest__.py
|
|
git commit -m "feat(plating-migration): preview form/list views + Owner menu"
|
|
```
|
|
|
|
### Task H3: Add post_init_hook + cron
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating/__manifest__.py` (declare hook)
|
|
- Modify: `fusion_plating/__init__.py` (define hook)
|
|
- Create: `fusion_plating/data/fp_migration_cron.xml`
|
|
|
|
- [ ] **Step 1: Declare hook in manifest**
|
|
|
|
Add `'post_init_hook': '_fp_post_init_role_migration',` to the manifest dict.
|
|
|
|
- [ ] **Step 2: Define hook in __init__.py**
|
|
|
|
```python
|
|
from . import models
|
|
|
|
|
|
def _fp_post_init_role_migration(env):
|
|
"""Idempotent: creates a fp.migration.preview if none is pending or applied."""
|
|
Preview = env['fp.migration.preview']
|
|
if Preview.search_count([('state', '=', 'pending')]):
|
|
return
|
|
if Preview.search_count([('state', '=', 'approved')]):
|
|
return # Already migrated previously
|
|
preview = Preview.create({})
|
|
preview._fp_build_lines()
|
|
preview._fp_notify_owners()
|
|
```
|
|
|
|
- [ ] **Step 3: Write cron data file**
|
|
|
|
```xml
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<odoo>
|
|
<data noupdate="1">
|
|
<record id="ir_cron_purge_expired_migrations" model="ir.cron">
|
|
<field name="name">Fusion Plating: Purge Expired Role Migrations</field>
|
|
<field name="model_id" ref="model_fp_migration_preview"/>
|
|
<field name="state">code</field>
|
|
<field name="code">model._cron_purge_expired_migrations()</field>
|
|
<field name="interval_number">1</field>
|
|
<field name="interval_type">days</field>
|
|
<field name="active" eval="True"/>
|
|
</record>
|
|
</data>
|
|
</odoo>
|
|
```
|
|
|
|
Add `'data/fp_migration_cron.xml'` to manifest.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating/__manifest__.py fusion_plating/__init__.py fusion_plating/data/fp_migration_cron.xml
|
|
git commit -m "feat(plating-migration): post_init_hook + 30-day purge cron"
|
|
```
|
|
|
|
### Task H4: Write migration tests
|
|
|
|
**Files:**
|
|
- Create: `fusion_plating/tests/test_migration_workflow.py`
|
|
|
|
- [ ] **Step 1: Write the test**
|
|
|
|
```python
|
|
import json
|
|
from odoo.tests.common import TransactionCase, tagged
|
|
from odoo.exceptions import UserError
|
|
|
|
|
|
@tagged('-at_install', 'post_install', 'fp_perms')
|
|
class TestMigrationWorkflow(TransactionCase):
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
Users = self.env['res.users'].with_context(no_reset_password=True)
|
|
self.owner = Users.create({
|
|
'login': 'mig_owner', 'name': 'Mig Owner',
|
|
'email': 'mig_owner@example.com',
|
|
'groups_id': [(6, 0, [self.env.ref('fusion_plating.group_fp_owner').id])],
|
|
})
|
|
|
|
def test_post_init_creates_pending_preview(self):
|
|
# After install, the post_init_hook should have already created a preview;
|
|
# but since tests run in a fresh transaction, we trigger manually
|
|
from odoo.addons.fusion_plating import _fp_post_init_role_migration
|
|
Preview = self.env['fp.migration.preview']
|
|
# Clear any existing
|
|
Preview.search([]).unlink()
|
|
_fp_post_init_role_migration(self.env)
|
|
self.assertEqual(Preview.search_count([('state', '=', 'pending')]), 1)
|
|
|
|
def test_only_owner_can_approve(self):
|
|
non_owner = self.env['res.users'].with_context(no_reset_password=True).create({
|
|
'login': 'mig_nonowner', 'name': 'Non Owner',
|
|
'email': 'mig_nonowner@example.com',
|
|
'groups_id': [(6, 0, [self.env.ref('fusion_plating.group_fp_manager').id])],
|
|
})
|
|
preview = self.env['fp.migration.preview'].create({})
|
|
preview._fp_build_lines()
|
|
with self.assertRaises(UserError):
|
|
preview.with_user(non_owner).action_approve_and_run()
|
|
|
|
def test_approve_advances_state(self):
|
|
preview = self.env['fp.migration.preview'].create({})
|
|
preview._fp_build_lines()
|
|
preview.with_user(self.owner).action_approve_and_run()
|
|
self.assertEqual(preview.state, 'approved')
|
|
|
|
def test_rollback_restores_groups(self):
|
|
# Create a test user with an old Manager group
|
|
old_mgr = self.env.ref('fusion_plating.group_fusion_plating_manager')
|
|
u = self.env['res.users'].with_context(no_reset_password=True).create({
|
|
'login': 'mig_rb', 'name': 'RB',
|
|
'email': 'mig_rb@example.com',
|
|
'groups_id': [(6, 0, [old_mgr.id])],
|
|
})
|
|
before = u.groups_id.ids
|
|
preview = self.env['fp.migration.preview'].create({})
|
|
preview._fp_build_lines()
|
|
preview.with_user(self.owner).action_approve_and_run()
|
|
# Now rollback
|
|
preview.with_user(self.owner).action_rollback()
|
|
u.invalidate_recordset()
|
|
self.assertEqual(set(u.groups_id.ids), set(before))
|
|
|
|
def test_estimator_warning_flagged(self):
|
|
est = self.env.ref('fusion_plating_configurator.group_fp_estimator')
|
|
u = self.env['res.users'].with_context(no_reset_password=True).create({
|
|
'login': 'mig_est', 'name': 'Est',
|
|
'email': 'mig_est@example.com',
|
|
'groups_id': [(6, 0, [est.id])],
|
|
})
|
|
preview = self.env['fp.migration.preview'].create({})
|
|
preview._fp_build_lines()
|
|
line = preview.line_ids.filtered(lambda l: l.user_id == u)
|
|
self.assertTrue(line.warning, 'Estimator-only user should be flagged for capability loss')
|
|
self.assertEqual(line.proposed_role, 'sales_rep')
|
|
```
|
|
|
|
- [ ] **Step 2: Run + iterate**
|
|
|
|
```bash
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin --test-tags fp_perms --test-enable --stop-after-init -u fusion_plating\"'" 2>&1 | tail -20
|
|
```
|
|
Expected: all migration workflow tests PASS.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add fusion_plating/tests/test_migration_workflow.py
|
|
git commit -m "test(plating-migration): dry-run, approve, rollback, estimator warning"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase I — Deploy + Verify on entech
|
|
|
|
### Task I1: Pre-deploy backup
|
|
|
|
- [ ] **Step 1: pg_dump entech DB**
|
|
|
|
```bash
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"pg_dump admin > /var/backups/admin_pre_perms_overhaul_$(date +%Y%m%d_%H%M).sql\"'"
|
|
```
|
|
|
|
- [ ] **Step 2: Verify backup exists and has reasonable size**
|
|
|
|
```bash
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'ls -lh /var/backups/admin_pre_perms_overhaul_*.sql'"
|
|
```
|
|
Expected: file size > 100MB (depends on DB).
|
|
|
|
### Task I2: Deploy all modules
|
|
|
|
- [ ] **Step 1: Deploy with `-u` chain**
|
|
|
|
```bash
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && \
|
|
su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin \
|
|
-u fusion_plating,fusion_plating_configurator,fusion_plating_invoicing,\
|
|
fusion_plating_receiving,fusion_plating_cgp,fusion_plating_quality,\
|
|
fusion_plating_aerospace,fusion_plating_nuclear,fusion_plating_safety,\
|
|
fusion_plating_shopfloor,fusion_plating_jobs,fusion_plating_certificates,\
|
|
fusion_plating_compliance,fusion_plating_kpi \
|
|
--stop-after-init\" && systemctl start odoo'" 2>&1 | tail -30
|
|
```
|
|
|
|
- [ ] **Step 2: Watch for errors**
|
|
|
|
If errors, log them, fix, redeploy. If clean, proceed.
|
|
|
|
### Task I3: Verify SQL state
|
|
|
|
- [ ] **Step 1: Run the post-deploy verification queries from spec Section "Migration Notes for entech"**
|
|
|
|
```bash
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql admin -c \\\"
|
|
SELECT state, user_count, warning_count, create_date
|
|
FROM fp_migration_preview ORDER BY id DESC LIMIT 1;
|
|
\\\"\"'"
|
|
```
|
|
Expected: one row in `pending` state with non-zero user_count.
|
|
|
|
- [ ] **Step 2: Verify Owner activity scheduled**
|
|
|
|
```bash
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql admin -c \\\"
|
|
SELECT count(*) FROM mail_activity
|
|
WHERE res_model = 'fp.migration.preview'
|
|
AND date_deadline >= CURRENT_DATE;
|
|
\\\"\"'"
|
|
```
|
|
Expected: count >= 1.
|
|
|
|
### Task I4: Owner approval workflow on entech
|
|
|
|
- [ ] **Step 1: Owner logs in to https://enplating.com as `admin`**
|
|
|
|
- [ ] **Step 2: Click home / activity dashboard → see "Review Fusion Plating role migration" activity**
|
|
|
|
- [ ] **Step 3: Click activity → opens preview screen**
|
|
|
|
- [ ] **Step 4: Review the line list — pay special attention to ⚠️ warnings**
|
|
|
|
If any Estimator-only users would lose order-confirm, manually change their proposed_role to `sales_manager` via the inline dropdown.
|
|
|
|
- [ ] **Step 5: Click "Approve & Run"**
|
|
|
|
- [ ] **Step 6: Verify state advanced to `approved`**
|
|
|
|
### Task I5: Post-approval SQL verification
|
|
|
|
- [ ] **Step 1: Confirm all users mapped**
|
|
|
|
```bash
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql admin -c \\\"
|
|
SELECT u.login, u.x_fc_plating_role
|
|
FROM res_users u WHERE u.share = false AND u.active = true
|
|
ORDER BY u.login;
|
|
\\\"\"'"
|
|
```
|
|
Expected: every user has a non-NULL `x_fc_plating_role`.
|
|
|
|
- [ ] **Step 2: Confirm no users still hold old plating groups directly**
|
|
|
|
```bash
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql admin -c \\\"
|
|
SELECT count(*) FROM res_groups_users_rel r
|
|
JOIN res_groups g ON g.id = r.gid
|
|
WHERE g.name LIKE '[DEPRECATED]%';
|
|
\\\"\"'"
|
|
```
|
|
Expected: 0 (users have new groups; old groups are reached only via `implied_ids`, not directly held).
|
|
|
|
- [ ] **Step 3: Confirm CGP DO field set if there was a CGP DO user**
|
|
|
|
```bash
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql admin -c \\\"
|
|
SELECT name, x_fc_cgp_designated_official_id FROM res_company;
|
|
\\\"\"'"
|
|
```
|
|
|
|
### Task I6: Sample login verification per role
|
|
|
|
- [ ] **Step 1: Owner login (admin@enplating.com) → verify lands on Manager Desk**
|
|
|
|
- [ ] **Step 2: Test login as a Technician user → verify lands on Plant Kanban or Workstation per layout flag**
|
|
|
|
- [ ] **Step 3: Test login as a Sales Manager (if any exist) → verify lands on Sale Orders**
|
|
|
|
- [ ] **Step 4: Confirm Configuration → Team menu visible only to admin**
|
|
|
|
### Task I7: Update CLAUDE.md
|
|
|
|
**Files:**
|
|
- Modify: `K:\Github\Odoo-Modules\fusion_plating\CLAUDE.md`
|
|
|
|
- [ ] **Step 1: Add a new top-level section "## Permissions Overhaul Phase 1 (shipped 2026-05-24)"**
|
|
|
|
Document:
|
|
- The 8 new role names with their XML IDs
|
|
- The new hierarchy (Owner > QM > Manager > [SM, SalesMgr] > [Tech, SR])
|
|
- The `x_fc_plating_role` field on res.users
|
|
- The CGP DO field on res.company
|
|
- The migration workflow model (`fp.migration.preview`)
|
|
- The 30-day rollback policy
|
|
- The bypass-flag-typo fix (`_administrator` → `group_fp_manager`)
|
|
|
|
- [ ] **Step 2: Update the existing "## Critical Rules" section if any rule needs adjustment**
|
|
|
|
Specifically:
|
|
- Rule about `_administrator` (now obsolete — bug fixed)
|
|
- Note that old group xmlids still resolve but are deprecated
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add K:\Github\Odoo-Modules\fusion_plating\CLAUDE.md
|
|
git commit -m "docs(plating): document Phase 1 permissions overhaul + new roles"
|
|
```
|
|
|
|
### Task I8: Push everything
|
|
|
|
- [ ] **Step 1: Push to remote**
|
|
|
|
```bash
|
|
cd K:/Github/Odoo-Modules/fusion_plating
|
|
git push origin main
|
|
```
|
|
|
|
---
|
|
|
|
## Self-Review
|
|
|
|
**Spec coverage:** I checked the spec section-by-section against this plan:
|
|
- ✅ Q1 (Quality split) → Phase C (Tasks C1-C3)
|
|
- ✅ Q2 (Verticals/CGP) → Tasks B5, F4 (CGP DO field), and ACL sweeps
|
|
- ✅ Q3 (Landing) → Phase E (E1-E4)
|
|
- ✅ Q4 (Team page) → Phase F (F1-F5)
|
|
- ✅ Q4b (3-layer menu hide) → Phase D (D1-D6)
|
|
- ✅ Q5 (Migration) → Phase H (H1-H4) + Phase I (I1-I8)
|
|
|
|
**Placeholder scan:** Searched plan for "TBD", "TODO", "implement later", "add appropriate" — none found.
|
|
|
|
**Type consistency:** Verified `x_fc_plating_role` field name used consistently across Tasks F2, H1, I5. The 8 role keys ('no', 'technician', 'sales_rep', 'shop_manager', 'sales_manager', 'manager', 'quality_manager', 'owner') match across model definition, mapping rules, ACL tests, and views.
|
|
|
|
**One concrete fix applied during review:** The CGP DO field's domain in F4 was originally written using Python-format placeholders (`%(qm)d`); changed to a runtime-evaluated domain in the view (`[('groups_id', 'in', [(ref('...'))])]`) which Odoo evaluates at view-render time and is the standard pattern.
|
|
|
|
**One risk noted but not fixed in plan (caller's discretion):** Tasks H4 and I4 depend on running the post_init_hook on a fresh DB or on a `-u`. If the post_init_hook fires DURING the `-u` of Task I2, the preview may already exist by the time Task I3 SQL runs. That's actually the intended flow. If the hook somehow doesn't fire, run `Preview.create({})._fp_build_lines()` manually via `odoo-shell -c /etc/odoo/odoo.conf -d admin`.
|
|
|
|
---
|
|
|
|
**Plan complete and saved to `K:\Github\Odoo-Modules\fusion_plating\docs\superpowers\plans\2026-05-24-permissions-overhaul-phase1-plan.md`. Two execution options:**
|
|
|
|
**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. Good for ~60-task plans like this one where each task is self-contained.
|
|
|
|
**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints. Slower but you see every step.
|
|
|
|
**Which approach?**
|