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: "<b>QA Review failed</b> by Garry Singh. Awaiting
client information.<br/><b>Reason:</b><br/>
<div data-oe-version=\"2.0\">Need to get updated
drawing...</div>"
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 "<b>..." 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:
@@ -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': """
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user