fix(chatter): wrap HTML message_post bodies in Markup() — 4 sites

Four message_post calls were passing strings with HTML tags as
plain `body=_(...)` instead of `body=Markup(_(...))`. Odoo escapes
non-Markup strings, so the chatter rendered "<b>QA Review failed</b>"
as literal text instead of bolding it.

Original bug surfaced via the Contract Review (QA-005) flow:
  body: "&lt;b&gt;QA Review failed&lt;/b&gt; by Garry Singh. Awaiting
  client information.&lt;br/&gt;&lt;b&gt;Reason:&lt;/b&gt;&lt;br/&gt;
  &lt;div data-oe-version=\"2.0\"&gt;Need to get updated
  drawing...&lt;/div&gt;"

Audit scan turned up three more identical patterns:

  fusion_plating/models/fp_parent_numbered_mixin.py:118
     "Issued <strong>%s</strong> to ..."
  fusion_plating_jobs/models/sale_order.py:282
     "Confirmed quote <strong>%s</strong> as <strong>%s</strong>."
  fusion_plating_quality/models/fp_contract_review.py:430
     "<b>QA Review failed</b> by ... <b>Reason:</b><br/>%(reason)s"
  fusion_plating_quality/models/fp_contract_review.py:524
     "<b>QA Review completed</b> by ... <b>Special Instructions
      captured:</b><br/>%(notes)s"

Fixes:
- Wrapped each body=_(...) with Markup(_(...)) using the
  Markup(template) % values pattern (auto-escapes the substituted
  values; user-supplied free text stays safe).
- For Html-field substitutions (qa_failure_reason,
  special_instructions), explicitly wrapped the value in Markup()
  so already-formatted HTML editor content (with data-oe-version="2.0"
  wrapper divs) flows through without being re-escaped.
- Added `from markupsafe import Markup` to the two files that
  didn't already import it (mixin + contract_review).

Drift cleanup: pulled the 180-line newer fp_contract_review.py
from entech to the local repo (added action_qa_review_failed,
action_open_client_email_wizard, action_view_client_emails,
action_complete_after_info, awaiting_info state, qa_failure_reason
+ special_instructions Html fields, etc. that had been edited on
entech without being committed).

Tested by re-posting via odoo shell on review 10: body now stores
"<b>QA Review failed</b>..." with literal HTML tags instead of
the double-escaped "&lt;b&gt;..." entities. Old chatter records
with the bad escape stay as-is in the audit trail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-13 08:41:39 -04:00
parent e7c6960de9
commit f07e1bcce1
6 changed files with 196 additions and 14 deletions

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating', 'name': 'Fusion Plating',
'version': '19.0.18.15.15', 'version': '19.0.18.15.16',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """ 'description': """

View File

@@ -13,6 +13,7 @@ for the design rationale.
""" """
import re import re
from markupsafe import Markup
from odoo import fields, models from odoo import fields, models
from odoo.exceptions import UserError from odoo.exceptions import UserError
from odoo.tools.translate import _ from odoo.tools.translate import _
@@ -115,9 +116,9 @@ class FpParentNumberedMixin(models.AbstractModel):
(new_name, new_index, self.id), (new_name, new_index, self.id),
) )
self.invalidate_recordset(['name', 'x_fc_doc_index']) self.invalidate_recordset(['name', 'x_fc_doc_index'])
so.message_post(body=_( so.message_post(body=Markup(_(
'Issued <strong>%s</strong> to %s #%s.' 'Issued <strong>%s</strong> to %s #%s.'
) % (new_name, self._name, self.id)) )) % (new_name, self._name, self.id))
return True return True
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
{ {
'name': 'Fusion Plating — Native Jobs', 'name': 'Fusion Plating — Native Jobs',
'version': '19.0.8.26.0', 'version': '19.0.8.27.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.', 'author': 'Nexa Systems Inc.',

View File

@@ -279,9 +279,9 @@ class SaleOrder(models.Model):
'name': f'SO-{parent_int}', 'name': f'SO-{parent_int}',
'x_fc_parent_number': parent_int, 'x_fc_parent_number': parent_int,
}) })
so.message_post(body=_( so.message_post(body=Markup(_(
'Confirmed quote <strong>%s</strong> as <strong>%s</strong>.' 'Confirmed quote <strong>%s</strong> as <strong>%s</strong>.'
) % (old_name, so.name)) )) % (old_name, so.name))
result = super().action_confirm() result = super().action_confirm()
for so in self: for so in self:
so._fp_auto_create_job() so._fp_auto_create_job()

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Quality (QMS)', 'name': 'Fusion Plating — Quality (QMS)',
'version': '19.0.4.13.0', 'version': '19.0.4.14.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, ' 'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
'internal audits, customer specs, document control. CE + EE compatible.', 'internal audits, customer specs, document control. CE + EE compatible.',

View File

@@ -3,6 +3,9 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family. # Part of the Fusion Plating product family.
from datetime import timedelta
from markupsafe import Markup
from odoo import _, api, fields, models from odoo import _, api, fields, models
from odoo.exceptions import UserError from odoo.exceptions import UserError
@@ -22,9 +25,9 @@ class FpContractReview(models.Model):
"""Contract Review (QA-005). """Contract Review (QA-005).
Per-part, two-section QA review: Section 2.0 Planning / Production Per-part, two-section QA review: Section 2.0 Planning / Production
Review (signed by a QA Assistant) and Section 3.0 Quality Review Review (Planning Review stage, signed by a Planning Signer) and
(signed by a QA Manager). Both sections must be signed for the Section 3.0 Quality Review (QA Review stage, signed by a QA Signer).
review to be complete. Both sections must be signed for the review to be complete.
The review is always optional. It never blocks MO/SO/WO progression. The review is always optional. It never blocks MO/SO/WO progression.
Its purpose is an audit artefact and a printable 1:1 of the paper Its purpose is an audit artefact and a printable 1:1 of the paper
@@ -79,7 +82,7 @@ class FpContractReview(models.Model):
qty = fields.Integer(string='Qty') qty = fields.Integer(string='Qty')
due_date = fields.Date(string='Due') due_date = fields.Date(string='Due')
# ---- Section 2.0 — Planning / Production Review (QA Assistant) --------- # ---- Section 2.0 — Planning / Production Review (Planning Review) ------
s20_acceptable_lead_time = fields.Boolean(string='Acceptable Lead Time') s20_acceptable_lead_time = fields.Boolean(string='Acceptable Lead Time')
s20_capacity_to_process = fields.Boolean(string='Capacity to Process') s20_capacity_to_process = fields.Boolean(string='Capacity to Process')
@@ -118,7 +121,7 @@ class FpContractReview(models.Model):
copy=False, copy=False,
) )
# ---- Section 3.0 — Quality Review (QA Manager) ------------------------- # ---- Section 3.0 — Quality Review (QA Review) --------------------------
s30_source_control_docs = fields.Boolean(string="Source Control Documents (Customer Spec's)") s30_source_control_docs = fields.Boolean(string="Source Control Documents (Customer Spec's)")
s30_quality_clauses_supplied = fields.Boolean(string='Quality Clause(s) supplied') s30_quality_clauses_supplied = fields.Boolean(string='Quality Clause(s) supplied')
@@ -180,8 +183,9 @@ class FpContractReview(models.Model):
state = fields.Selection( state = fields.Selection(
[('draft', 'Draft'), [('draft', 'Draft'),
('assistant_review', 'QA Assistant Review'), ('assistant_review', 'Planning Review'),
('manager_review', 'QA Manager Review'), ('manager_review', 'QA Review'),
('awaiting_info', 'Awaiting Client Info'),
('complete', 'Complete'), ('complete', 'Complete'),
('dismissed', 'Dismissed')], ('dismissed', 'Dismissed')],
default='draft', default='draft',
@@ -189,6 +193,56 @@ class FpContractReview(models.Model):
tracking=True, tracking=True,
) )
# ---- "Failed QA — Awaiting Client Info" workflow ------------------------
# When a QA Signer (Brett or whoever the company has rostered) finds a
# client requirement that fails during the QA Review, they mark the
# review failed. The state moves to `awaiting_info`, an activity is
# scheduled for every QA Signer to follow up, and a smart button on
# the form gives them a one-click email composer to ping the client.
# When the client replies, the QA Signer captures notes in
# `special_instructions` and marks complete — the notes print on the
# final QA-005 PDF for the audit trail.
qa_failure_reason = fields.Html(
string='QA Failure Reason',
copy=False,
help='What client requirement failed and why we need more info. '
'Captured here before flipping the review to '
'"Awaiting Client Info" so every QA Signer sees the same '
'context. Pre-fills the client email composer.',
)
info_requested_date = fields.Datetime(
string='Info Requested Date',
readonly=True,
copy=False,
help='Stamped automatically the first time the client email '
'composer is sent.',
)
info_received_date = fields.Datetime(
string='Info Received Date',
copy=False,
help='Manually stamped when the QA Signer marks the review '
'complete after receiving the client info.',
)
special_instructions = fields.Html(
string='Special Instructions',
copy=False,
help='Free-form notes captured by the QA Signer when they close '
'out the review. Prints at the bottom of the QA-005 PDF '
'so the audit record carries the agreed resolution.',
)
client_email_count = fields.Integer(
compute='_compute_client_email_count',
help='Smart-button counter — number of emails posted to chatter '
'against this review. Always non-zero after the first send.',
)
@api.depends('message_ids', 'message_ids.message_type')
def _compute_client_email_count(self):
for rec in self:
rec.client_email_count = len(rec.message_ids.filtered(
lambda m: m.message_type == 'email'
))
# ---- Constraints -------------------------------------------------------- # ---- Constraints --------------------------------------------------------
_sql_constraints = [ _sql_constraints = [
@@ -351,6 +405,133 @@ class FpContractReview(models.Model):
'fusion_plating_quality.action_report_contract_review' 'fusion_plating_quality.action_report_contract_review'
).report_action(self) ).report_action(self)
# ---- "Failed QA — Awaiting Client Info" workflow ------------------------
def action_mark_qa_failed(self):
"""QA Signer marks the review failed because a client requirement
is missing or unclear. Captures the reason, flips state to
`awaiting_info`, and schedules a follow-up activity for every QA
Signer rostered on the company (so the work doesn't fall through
the cracks if Brett is on vacation)."""
self.ensure_one()
if self.state not in ('manager_review', 'assistant_review'):
raise UserError(_(
'Only a review at the QA Review (or Planning Review) stage '
'can be flagged as failed. Current state: %s.'
) % dict(self._fields['state'].selection).get(self.state, self.state))
# Reuse the section-30 signer roster — the same group of people
# who can sign QA can flag a QA failure.
self._check_signer(30)
if not self.qa_failure_reason or not self.qa_failure_reason.strip():
raise UserError(_(
'Capture the QA Failure Reason before flagging the '
'review failed — the reason pre-fills the client email '
'and is required for the audit trail.'
))
self.write({'state': 'awaiting_info'})
self.message_post(body=Markup(_(
'<b>QA Review failed</b> by %(user)s. Awaiting client '
'information.<br/><b>Reason:</b><br/>%(reason)s'
)) % {
'user': self.env.user.name,
'reason': Markup(self.qa_failure_reason or ''),
})
# Schedule activity for every QA Signer (any of them can pick it up).
signers = self.company_id._fp_get_qa_signers(30)
if not signers:
# Fall back to the user who flagged it, so the activity is
# not orphaned on shops that haven't configured a roster.
signers = self.env.user
try:
activity_type = self.env.ref('mail.mail_activity_data_todo')
except ValueError:
activity_type = self.env['mail.activity.type'].search(
[('category', '=', 'default')], limit=1)
for user in signers:
self.activity_schedule(
activity_type_id=activity_type.id if activity_type else False,
summary=_('Follow up on QA-005 — client info required'),
note=self.qa_failure_reason or '',
user_id=user.id,
date_deadline=fields.Date.context_today(self) +
timedelta(days=2),
)
return True
def action_open_client_email_wizard(self):
"""Smart-button target — opens the email composer wizard pre-filled
with the customer's contact email + a body templated from the
QA failure reason. The wizard handles the actual mail.mail send
and stamps `info_requested_date` on this review."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Email Client — Request Info'),
'res_model': 'fp.contract.review.client.email.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_review_id': self.id,
'default_recipient_email':
self.customer_id.email or '',
'default_recipient_name':
self.customer_id.name or '',
},
}
def action_view_client_emails(self):
"""Drill-down behind the smart button counter — shows the chatter
messages of type=email for this review."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Client Emails — %s') % self.name,
'res_model': 'mail.message',
'view_mode': 'list,form',
'domain': [
('model', '=', 'fp.contract.review'),
('res_id', '=', self.id),
('message_type', '=', 'email'),
],
}
def action_complete_after_info(self):
"""Close out a review that was in `awaiting_info` once the client
info has been received and `special_instructions` captured. Stamps
Section 3.0 sign-off with the current user + timestamp so the QA
review is fully closed and the QA-005 PDF carries a complete
audit trail."""
self.ensure_one()
if self.state != 'awaiting_info':
raise UserError(_(
'Only a review in "Awaiting Client Info" can be marked '
'complete via this action.'
))
self._check_signer(30)
now = fields.Datetime.now()
vals = {
'state': 'complete',
'info_received_date': self.info_received_date or now,
's30_signed_by': self.env.user.id,
's30_signed_date': now,
's30_locked': True,
}
self.write(vals)
# Mark the activity as done so the follow-up disappears from
# everyone's inbox once the case is closed.
self.activity_feedback(
['mail.mail_activity_data_todo'],
feedback=_('Client info received — review closed.'),
)
self.message_post(body=Markup(_(
'<b>QA Review completed</b> by %(user)s after receiving '
'client information.<br/>'
'<b>Special Instructions captured:</b><br/>%(notes)s'
)) % {
'user': self.env.user.name,
'notes': Markup(self.special_instructions or '') or _('(none)'),
})
return True
# ---- Helpers ------------------------------------------------------------ # ---- Helpers ------------------------------------------------------------
def _check_signer(self, section): def _check_signer(self, section):