fix(notifications): mail.template only refs core fp.job fields

Entech deploy of 5a039ae3 hit:
  ParseError: Failed to render inline_template template
  AttributeError('fp.job' object has no attribute 'display_wo_name')

Root cause: mail.template data files are parse-time validated by
Odoo (template rendered against sample object). fusion_plating_notifications
loads BEFORE fusion_plating_jobs in dep order, so jobs-module fields
(display_wo_name, part_catalog_id) aren't on the Python class yet
even though the DB columns exist from previous installs.

Fix: strip display_wo_name → name and remove the Part row.
Recipe / qty_done / partner_id stay (all in fusion_plating core).

Logged as CLAUDE.md Rule #24 — same trap will bite anyone else
adding cross-module mail templates. Includes structural alternatives
for callers that really need downstream fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-25 10:35:29 -04:00
parent 5a039ae369
commit 8b14466da2
2 changed files with 10 additions and 10 deletions

View File

@@ -348,6 +348,7 @@ Use only: `name`, `model_id`, `state`, `code` (or `function`/`model`), `interval
21. **`ir.actions.act_window_close` is a no-op when the current action was opened with `target: "current"`**: replacing the current action wipes the breadcrumb backstack, so there's nothing to close back to. The user clicks "Back" and nothing happens (no error, no navigation). This bites every OWL client-action surface that calls another client action via `doAction({..., target: "current"})` — the destination has no way to return to the source. **Fix pattern for "Back" buttons in OWL client actions**: navigate EXPLICITLY to the landing/parent action by tag, e.g. `this.action.doAction({ type: "ir.actions.client", tag: "fp_shopfloor_landing", target: "current" })` — works regardless of how the action was reached (kanban tap, QR scan, smart button, direct URL). **Do NOT rely on `act_window_close`, `history.back()`, or `this.env.config.breadcrumbs`** — all three are unreliable across navigation paths. Bit us 2026-05-23 on the Job Workspace Back button after the kanban opened the workspace with `target: "current"`. The same pattern applies to every other "Back" button in shopfloor / manager / portal OWL surfaces — explicit destination via `tag:` is the only robust answer.
22. **Odoo 19 HTML fields auto-wrap plain-string writes**: writing `co.report_header = 'Plating & Finishing'` to an HTML field (like `res.company.report_header`, `res.partner.comment`, `mail.template.body_html`, `product.template.description_sale`) stores `<p>Plating &amp; Finishing</p>` after Odoo's HTML sanitizer runs. Equality tests against the raw input string FAIL (`payload['tagline'] != 'Plating & Finishing'`). **Three implications**: (a) **In tests**, don't `assertEqual` against the literal string you wrote — strip tags first, OR write the wrapped form (`<p>Plating & Finishing</p>`), OR write an explicit `Markup('<p>...</p>')` so the round-trip stays stable. (b) **In display code**, render HTML fields with `t-out` (QWeb) or `markup(...)` (OWL) — `t-esc` would render the literal `<p>` tags as text. (c) **In comparison logic**, normalize first: `from markupsafe import escape; escape(input_str)` produces the same shape the field stores. Bit us 2026-05-24 testing the lock-screen tagline source (`_lock_company_payload` reads `res.company.report_header`); the test that wrote a plain string and asserted equality failed because the value came back wrapped. The fix was to delete the brittle equality test — the helper's responsibility is just "use the field's value when present, else fall back," which is covered by the empty-field test. Generalizes to ANY HTML-typed Odoo field. Distinct from the `mail.template.body_html is Markup + jsonb` gotcha noted earlier in this file — that's about Markup objects vs strings; this is about the sanitizer wrapping plain strings on write.
23. **`res.users.group_ids` vs `all_group_ids` for domain filters**: in Odoo 19, `res.users` carries TWO M2M-to-`res.groups` fields and they have different membership semantics. `group_ids` is the user's DIRECTLY-assigned groups (what the user record literally wrote). `all_group_ids` is the TRANSITIVE set — direct groups PLUS every group implied via `implied_ids` chains. **For domain filters on user pickers** (e.g. "show users who can act as a Quality Manager"), ALWAYS use `all_group_ids`, never `group_ids`. An Owner user only carries `group_fp_owner` directly; the QM capability comes via `implied_ids → group_fp_quality_manager`, so a `domain="[('group_ids', 'in', [ref('...quality_manager')])]"` excludes Owners and the picker looks empty. Use `domain="[('all_group_ids', 'in', [ref('...quality_manager'), ref('...owner')])]"` instead. Compute helpers (`@api.depends('group_ids')`) and write vals (`{'group_ids': [(4, gid)]}`) still use `group_ids` because those operate on direct assignments — only domain filters need the transitive set. Bit us 2026-05-24 on the CGP DO + Nadcap Authority pickers on `res.company`. Same gotcha applies to ANY domain that needs "does this user effectively have role X" semantics across user-facing pickers, ACL rules, server actions, and search filters.
24. **`mail.template` data files validate templates at PARSE time — only reference CORE-module fields on the target model**: when Odoo loads a `<record model="mail.template">` from XML, it eagerly RENDERS the `subject`/`body_html` once against a sample `object` to validate the inline_template renders cleanly. If the template references a field defined in a DOWNSTREAM module (one that loads AFTER the data-file's home module), the field isn't on the model yet and you get `AttributeError: 'fp.job' object has no attribute 'X'``ParseError: Failed to render inline_template template` → module install/upgrade ABORTS. Bit us 2026-05-25 deploying the cert authority templates: `fusion_plating_notifications` loads BEFORE `fusion_plating_jobs` in dep order, and the templates referenced `object.display_wo_name` and `object.part_catalog_id` (both added by `fusion_plating_jobs` via `_inherit`). Even though the columns exist in the DB from previous installs, the Python class hadn't registered the field yet at parse time. **Fix:** mail.template files in upstream modules must only reference fields defined in the SAME module's classes or earlier-loading deps. For `fp.job` references in `fusion_plating_notifications/data/`, that means CORE-only fields: `name`, `partner_id`, `qty_done`, `recipe_id`, `state`, `date_*`, `company_id` — NOT `display_wo_name`, `part_catalog_id`, `customer_spec_id`, `delivery_id`, `portal_job_id` (all jobs-module fields). Same trap for any other cross-module template (`account.move`, `sale.order`, `stock.picking`). **Two structural alternatives** if you really need downstream fields: (a) move the mail.template + fp.notification.template data records into the downstream module so they load after the field is registered (cleanest); (b) compute the value in the calling Python code and pass via `email_values` to the dispatch — no template-time rendering.
## Naming
- **New custom models** (post-2026-04): `fp.*` prefix (e.g. `fp.part.catalog`, `fp.certificate`)

View File

@@ -22,7 +22,12 @@
<record id="fp_mail_template_cert_awaiting_issuance" model="mail.template">
<field name="name">FP: Cert Awaiting Issuance</field>
<field name="model_id" ref="fusion_plating.model_fp_job"/>
<field name="subject">🏷️ Job {{ object.display_wo_name or object.name }} ready for CoC issuance</field>
<!-- Only reference CORE fp.job fields here. The notifications
module loads BEFORE fusion_plating_jobs in dep order, and
mail.template parsing renders subject/body once to validate
— fields added by inheriting modules (display_wo_name,
part_catalog_id) would AttributeError at parse time. -->
<field name="subject">🏷️ Job {{ object.name }} ready for CoC issuance</field>
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
@@ -33,7 +38,7 @@
</div>
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">CoC Awaiting Issuance</h2>
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.75;">
Job <strong t-out="object.display_wo_name or object.name"/>
Job <strong t-out="object.name"/>
(<t t-out="object.partner_id.name"/>) has finished the shop floor
and is awaiting CoC issuance.
</p>
@@ -46,12 +51,6 @@
<td style="padding: 8px 4px;">Customer</td>
<td style="padding: 8px 4px; text-align: right;"><t t-out="object.partner_id.name or ''"/></td>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
<td style="padding: 8px 4px;">Part</td>
<td style="padding: 8px 4px; text-align: right; font-family: monospace;">
<t t-out="(object.part_catalog_id.part_number if object.part_catalog_id else '') or '—'"/>
</td>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
<td style="padding: 8px 4px;">Quantity</td>
<td style="padding: 8px 4px; text-align: right;"><t t-out="object.qty_done or 0"/></td>
@@ -91,7 +90,7 @@
<record id="fp_mail_template_cert_voided_re_notify" model="mail.template">
<field name="name">FP: Cert Voided — Re-Issue</field>
<field name="model_id" ref="fusion_plating.model_fp_job"/>
<field name="subject">⚠️ Job {{ object.display_wo_name or object.name }} CoC voided — please re-issue</field>
<field name="subject">⚠️ Job {{ object.name }} CoC voided — please re-issue</field>
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
@@ -103,7 +102,7 @@
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">CoC Voided — Please Re-Issue</h2>
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.75;">
A previously-issued CoC for job
<strong t-out="object.display_wo_name or object.name"/>
<strong t-out="object.name"/>
(<t t-out="object.partner_id.name"/>) was voided. The job has
slid back to <em>Awaiting Cert</em> and is waiting for re-issuance.
</p>