fix(plating-perms): deploy-time cascade fixes from entech I3

5 fixes discovered during the live deploy to entech LXC 111:

1. pre-migrate.py to rename old configurator's 'Shop Manager' group BEFORE
   new core 'Shop Manager v2' XML loads (cross-module name collision on
   res_groups_name_uniq).

2. res_company_views.xml: dropped ref() inside <field domain=> attribute
   (Odoo 19 view validator interprets it as a field name).

3. sale_order_views.xml: replaced 3 separate xpaths for amount_total /
   amount_untaxed / amount_tax with a single xpath on tax_totals widget
   (Odoo 19 sale.view_order_form uses one widget instead of separate fields).

4. fp_cert_security.xml: certificate_type field, not cert_type. FAIR is a
   separate model so the rule only restricts cert_type='nadcap_cert' now.

5. fp_certificate_views.xml + fp_capa_views.xml + fp_customer_spec_views.xml:
   stripped user_has_groups() from invisible= / readonly= attrs (Odoo 19
   view validator interprets as field name). Model-layer ACLs and ir.rules
   already enforce the same restrictions.

Also fixed res.groups.users -> user_ids in fp_migration.py (Odoo 19 rename,
caught when manually invoking _fp_notify_owners post-deploy).

CLAUDE.md updated with 4 new rules (13e cross-module name collisions,
13f ref() in domain, 13g tax_totals widget, 13h user_has_groups in attrs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-24 09:07:13 -04:00
parent 0047f49d2c
commit 7bcbcb4008
9 changed files with 120 additions and 48 deletions

View File

@@ -220,6 +220,29 @@ Use only: `name`, `model_id`, `state`, `code` (or `function`/`model`), `interval
Reference: `/usr/lib/python3/dist-packages/odoo/addons/web/static/src/views/kanban/kanban_arch_parser.js`. Pre-existing `fp_rack_views.xml` still uses the old name and would also fail at render — fix when next touched. Caught 2026-05-24 by final reviewer of permissions-overhaul branch.
13c. **`res.users.group_ids` NOT `groups_id`**: Odoo 19 renamed the m2m field. Old name doesn't resolve; `@api.depends('groups_id')` raises `ValueError` at module load. Also: domain on relational pickers should use `all_group_ids` (transitive set incl. implied) instead of `group_ids` (only directly-assigned) — otherwise an Owner user won't match a domain looking for QM members. See `feedback_odoo19_groups_id_renamed.md`.
13d. **`post_init_hook` ONLY fires on INSTALL, not UPGRADE** in Odoo 19. For logic that must run on `-u` of an existing install (entech case), add a `migrations/<version>/post-migrate.py` with a `migrate(cr, version)` function that calls the same helper. The hook still works on fresh install; the migration script bridges the gap on `-u`. Both should be idempotent so re-runs are safe.
13g. **Odoo 19's `sale.view_order_form` uses a single `<field name="tax_totals" widget="account-tax-totals-field"/>` widget instead of separate `amount_total` / `amount_untaxed` / `amount_tax` fields**. Inheriting xpaths targeting any of the three separate fields will fail at view load: `Element '<xpath expr="//field[@name='amount_total']">' cannot be located in parent view`. To gate or modify totals, target the `tax_totals` widget (one xpath hides the whole totals block). Other views in the file (kanban, list, pivot) DO still have the individual fields — only the FORM view consolidated to the widget. Same likely applies to `purchase.purchase_order_form` and `account.view_move_form` — verify per-view before porting Odoo 17/18 xpaths. Caught 2026-05-24.
13h. **`user_has_groups('xmlid')` is NOT available inside Odoo 19's `invisible=`/`readonly=`/`required=` attribute expressions**. The view validator parses `user_has_groups` as a field name on the host model and fails: `field 'user_has_groups' does not exist in model 'X'`. Group-based UI gating must use the `groups=` attribute on the element instead. To combine group-AND-state logic, EITHER split into two elements with mutually-exclusive `invisible` AND different `groups=`, OR enforce one half at the model layer (ir.rule / @api.constrains) and the other in the view. Caught 2026-05-24 when a single button used `invisible="state != 'draft' or (cert_type == 'nadcap' and not user_has_groups(...))"` — rewrote as a single button with `groups="group_fp_manager"` + `invisible="state != 'draft'"` and let the ir.rule enforce the Nadcap-write restriction (Manager clicking Issue on a Nadcap cert now raises AccessError).
13f. **Odoo 19 view validator rejects `ref('xmlid')` inside `<field domain="...">`**: the validator parses `ref(...)` as a field-access on the host model and fails with `field 'ref' does not exist in model 'X'`. Even though `ref()` IS resolved at runtime by the client, validation fires first and aborts module load. Workarounds (pick one):
- **Drop the domain** and enforce eligibility via `@api.constrains` on the Python side (simplest — used for `res.company.x_fc_cgp_designated_official_id` in this project; the Owner makes a deliberate choice and Python validates at save time).
- **Pre-compute eligible IDs** in a stored `Many2many` compute on the host model, then `domain="[('id', 'in', eligible_ids_field)]"`.
- Move the domain into the field definition in Python (`fields.Many2one(..., domain="[...]")`) — but Python-side domains have the same `ref()` limitation, so this isn't always an escape.
Caught 2026-05-24 deploying permissions-overhaul to entech.
13e. **`res_groups_name_uniq` constraint is `(privilege_id, name)` — cross-module display-name collisions during `-u` need a `pre-migrate.py` rename**. If a base module's new XML defines a group with the same display name as a DOWNSTREAM module's existing group (e.g. core adds new `Shop Manager (v2)` while configurator already has old `Shop Manager`), the new INSERT collides with the still-named-the-same downstream row, because Odoo loads modules in dep order and the downstream rename via XML hasn't happened yet. The fix is a `migrations/<version>/pre-migrate.py` in the BASE module that SQL-renames the downstream row before the new XML loads:
```python
def migrate(cr, version):
cr.execute(\"\"\"
UPDATE res_groups
SET name = jsonb_build_object('en_US', '[DEPRECATED] Shop Manager (...)')
WHERE id IN (
SELECT res_id FROM ir_model_data
WHERE module = 'fusion_plating_configurator'
AND name = 'group_fp_shop_manager'
AND model = 'res.groups'
)
AND (name IS NULL OR name->>'en_US' NOT LIKE '[DEPRECATED]%');
\"\"\")
```
Pre-migrate scripts run BEFORE the module's data files reload, so the constraint is clear by the time the new group XML INSERTs. Caught 2026-05-24 during permissions-overhaul deploy — `fp_security_v2.xml` claimed `'Shop Manager'` while old configurator's `group_fp_shop_manager` still held that display name in the DB. Same pattern applies to ANY base-module XML adding groups with names that overlap downstream-module groups.
13a. **Cross-module xmlid refs — base modules CANNOT forward-ref downstream xmlids**: A BASE module's data XML cannot `ref('downstream_module.some_xmlid')` because at fresh install, the base module loads FIRST and `ir.model.data` has no row for the downstream xmlid yet → `ValueError: External ID not found`. This bites on entech (existing DB has the row) but breaks fresh CI/test/demo/new-client installs. **Fix pattern: relocate the cross-module link to the downstream module's own security/data file, using an additive write to the BASE module's record:**
```xml
<!-- In downstream module's security XML -->

View File

@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
"""Phase A bootstrap: rename old configurator's Shop Manager before new
core group_fp_shop_manager_v2 tries to claim the 'Shop Manager' display name.
Load order:
1. fusion_plating loads -> fp_security.xml renames its own old groups (Operator,
Supervisor, Manager, Administrator) to '[DEPRECATED] X'. Then fp_security_v2.xml
creates new groups (Technician, ..., Shop Manager v2 with display name 'Shop Manager').
2. fusion_plating_configurator loads later -> would rename its own
group_fp_shop_manager to '[DEPRECATED] Shop Manager'.
But step 1 crashes because the OLD configurator's group is still named just
'Shop Manager' in the DB (the rename in step 2 hasn't run yet), and the unique
constraint res_groups_name_uniq blocks the new 'Shop Manager'.
This pre-migrate script runs BEFORE any of fusion_plating's data files reload,
patching the old configurator row's display name via SQL. After that, the
constraint is clear and fp_security_v2.xml can create its new groups safely.
The configurator's later -u will then push the canonical '[DEPRECATED] Shop
Manager' display name from its XML data.
"""
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
# Find old configurator Shop Manager row via ir.model.data and rename
# its display name to avoid the constraint collision.
cr.execute("""
UPDATE res_groups
SET name = jsonb_build_object('en_US', '[DEPRECATED] Shop Manager (Mgr+Estimator bundle)')
WHERE id IN (
SELECT res_id FROM ir_model_data
WHERE module = 'fusion_plating_configurator'
AND name = 'group_fp_shop_manager'
AND model = 'res.groups'
)
AND (name IS NULL OR name->>'en_US' NOT LIKE '[DEPRECATED]%');
""")
rows = cr.rowcount
if rows:
_logger.info(
'Fusion Plating: pre-migrate renamed %d old configurator Shop Manager '
'row(s) to clear name collision with new group_fp_shop_manager_v2',
rows,
)

View File

@@ -101,7 +101,7 @@ class FpMigrationPreview(models.Model):
owner_grp = self.env.ref('fusion_plating.group_fp_owner', raise_if_not_found=False)
if not owner_grp:
return
owners = owner_grp.users.filtered(lambda u: u.active and not u.share)
owners = owner_grp.user_ids.filtered(lambda u: u.active and not u.share)
if not owners:
_logger.warning('Fusion Plating migration preview %s: no Owner users to notify', self.id)
return
@@ -228,7 +228,7 @@ class FpMigrationPreview(models.Model):
safe_to_unlink = []
skipped = []
for old_group in self.env['res.groups'].browse(old_group_ids).exists():
active_users = old_group.users.filtered(lambda u: u.active and not u.share)
active_users = old_group.user_ids.filtered(lambda u: u.active and not u.share)
if active_users:
skipped.append((old_group.name, active_users.mapped('login')))
else:

View File

@@ -11,10 +11,13 @@
<page string="Plating Designated Officials"
groups="fusion_plating.group_fp_owner">
<group>
<field name="x_fc_cgp_designated_official_id"
domain="[('all_group_ids', 'in', [ref('fusion_plating.group_fp_quality_manager'), ref('fusion_plating.group_fp_owner')])]"/>
<field name="x_fc_nadcap_authority_user_id"
domain="[('all_group_ids', 'in', [ref('fusion_plating.group_fp_quality_manager'), ref('fusion_plating.group_fp_owner')])]"/>
<!-- No domain on the picker: Owner picks freely.
ref() in XML domains trips Odoo 19's view validator
(interpreted as field access on res.company).
The QM/Owner eligibility constraint is enforced
in Python via @api.constrains on res.company. -->
<field name="x_fc_cgp_designated_official_id"/>
<field name="x_fc_nadcap_authority_user_id"/>
</group>
</page>
</xpath>

View File

@@ -1,10 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<record id="rule_fp_certificate_fair_nadcap_qm_only" model="ir.rule">
<field name="name">FP Certificate: FAIR/Nadcap edit restricted to Quality Manager</field>
<!-- fp.certificate.certificate_type Selection values (per fp_certificate.py:27):
'coc', 'thickness_report', 'mill_test', 'nadcap_cert', 'customer_specific'.
FAIR is a separate model (fusion.plating.fair); no 'fair' value here.
Nadcap is the only QM-restricted type at the model level. -->
<record id="rule_fp_certificate_nadcap_qm_only" model="ir.rule">
<field name="name">FP Certificate: Nadcap edit restricted to Quality Manager</field>
<field name="model_id" ref="model_fp_certificate"/>
<field name="domain_force">[('cert_type', 'not in', ('fair', 'nadcap'))]</field>
<field name="domain_force">[('certificate_type', '!=', 'nadcap_cert')]</field>
<field name="groups" eval="[(4, ref('fusion_plating.group_fp_manager'))]"/>
<field name="perm_read" eval="False"/>
<field name="perm_write" eval="True"/>

View File

@@ -39,19 +39,17 @@
<field name="arch" type="xml">
<form>
<header>
<!-- Phase D5 — Nadcap certs are QM-only to Issue per spec
section 2.C (FAIR/Nadcap sign/issue restricted to
Quality Manager). Strategy B: single button visible
to all when state=draft and cert_type is routine
(coc/thickness_report/mill_test/customer_specific);
hidden for non-QM when cert_type=nadcap_cert. The
ir.rule from Phase C also restricts model writes on
FAIR/Nadcap so model-layer enforcement is independent.
No separate action_sign exists on this model — Issue
is the sign + publish action. -->
<!-- Phase D5 — Nadcap-cert restriction enforced at MODEL
layer via ir.rule (rule_fp_certificate_nadcap_qm_only
in fp_cert_security.xml). Single Issue button visible
to all Manager+ when state=draft. Manager clicking
Issue on a Nadcap cert gets AccessError from the rule.
(Strategy B with user_has_groups() inside invisible=
was rejected by Odoo 19 view validator — see CLAUDE.md
rule 13f.) -->
<button name="action_issue" string="Issue"
type="object" class="btn-primary"
invisible="state != 'draft' or (certificate_type == 'nadcap_cert' and not user_has_groups('fusion_plating.group_fp_quality_manager'))"/>
invisible="state != 'draft'"/>
<!-- Print = the same EN report action the gear-menu
Print > Certificate of Conformance (English)
calls. Routes through fusion_pdf_preview's

View File

@@ -378,13 +378,10 @@
<xpath expr="//field[@name='order_line']/list/field[@name='price_subtotal']" position="attributes">
<attribute name="groups">fusion_plating.group_fp_sales_rep</attribute>
</xpath>
<xpath expr="//field[@name='amount_total']" position="attributes">
<attribute name="groups">fusion_plating.group_fp_sales_rep</attribute>
</xpath>
<xpath expr="//field[@name='amount_untaxed']" position="attributes">
<attribute name="groups">fusion_plating.group_fp_sales_rep</attribute>
</xpath>
<xpath expr="//field[@name='amount_tax']" position="attributes">
<!-- Odoo 19: amount_total / amount_untaxed / amount_tax are rendered
by the single tax_totals widget; no separate fields. Gate the
widget itself to hide the entire totals block from non-Sales-Rep. -->
<xpath expr="//field[@name='tax_totals']" position="attributes">
<attribute name="groups">fusion_plating.group_fp_sales_rep</attribute>
</xpath>
</field>

View File

@@ -74,41 +74,41 @@
<group>
<group>
<field name="type"
readonly="not user_has_groups('fusion_plating.group_fp_quality_manager')"/>
/>
<field name="ncr_id"
readonly="not user_has_groups('fusion_plating.group_fp_quality_manager')"/>
/>
<field name="facility_id" readonly="1"/>
<field name="owner_id"
readonly="not user_has_groups('fusion_plating.group_fp_quality_manager')"/>
/>
</group>
<group>
<field name="due_date"
readonly="not user_has_groups('fusion_plating.group_fp_quality_manager')"/>
/>
<field name="is_overdue" readonly="1"/>
<field name="verification_date"
readonly="not user_has_groups('fusion_plating.group_fp_quality_manager')"/>
/>
<field name="verification_by_id"
readonly="not user_has_groups('fusion_plating.group_fp_quality_manager')"/>
/>
<field name="is_effective" readonly="1"/>
</group>
</group>
<notebook>
<page string="Description">
<field name="description"
readonly="not user_has_groups('fusion_plating.group_fp_quality_manager')"/>
/>
</page>
<page string="Root Cause Analysis">
<field name="root_cause_analysis"
placeholder="5 Whys, fishbone, or any other structured method."
readonly="not user_has_groups('fusion_plating.group_fp_quality_manager')"/>
/>
</page>
<page string="Action Plan">
<field name="action_plan"
readonly="not user_has_groups('fusion_plating.group_fp_quality_manager')"/>
/>
</page>
<page string="Effectiveness">
<field name="effectiveness_notes"
readonly="not user_has_groups('fusion_plating.group_fp_quality_manager')"/>
/>
</page>
</notebook>
</sheet>

View File

@@ -38,43 +38,43 @@
stays visible — only inputs lock for non-QM. -->
<label for="name"/>
<h1><field name="name"
readonly="not user_has_groups('fusion_plating.group_fp_quality_manager')"/></h1>
/></h1>
</div>
<group>
<group>
<field name="code"
readonly="not user_has_groups('fusion_plating.group_fp_quality_manager')"/>
/>
<field name="revision"
readonly="not user_has_groups('fusion_plating.group_fp_quality_manager')"/>
/>
<field name="spec_type"
readonly="not user_has_groups('fusion_plating.group_fp_quality_manager')"/>
/>
<field name="partner_id"
readonly="not user_has_groups('fusion_plating.group_fp_quality_manager')"/>
/>
</group>
<group>
<field name="effective_date"
readonly="not user_has_groups('fusion_plating.group_fp_quality_manager')"/>
/>
<field name="document_url" widget="url"
readonly="not user_has_groups('fusion_plating.group_fp_quality_manager')"/>
/>
</group>
</group>
<group string="Applicable Processes" name="applicable_processes">
<field name="process_type_ids" widget="many2many_tags" nolabel="1"
readonly="not user_has_groups('fusion_plating.group_fp_quality_manager')"/>
/>
</group>
<group string="Applicable Recipes" name="applicable_recipes">
<field name="recipe_ids" widget="many2many_tags" nolabel="1"
options="{'no_create_edit': True}"
readonly="not user_has_groups('fusion_plating.group_fp_quality_manager')"/>
/>
</group>
<group>
<field name="print_on_cert"
readonly="not user_has_groups('fusion_plating.group_fp_quality_manager')"/>
/>
</group>
<notebook>
<page string="Notes">
<field name="notes"
readonly="not user_has_groups('fusion_plating.group_fp_quality_manager')"/>
/>
</page>
</notebook>
</sheet>