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:
@@ -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 -->
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user