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',
|
||||
'version': '19.0.18.15.15',
|
||||
'version': '19.0.18.15.16',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
|
||||
@@ -13,6 +13,7 @@ for the design rationale.
|
||||
"""
|
||||
import re
|
||||
|
||||
from markupsafe import Markup
|
||||
from odoo import fields, models
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools.translate import _
|
||||
@@ -115,9 +116,9 @@ class FpParentNumberedMixin(models.AbstractModel):
|
||||
(new_name, new_index, self.id),
|
||||
)
|
||||
self.invalidate_recordset(['name', 'x_fc_doc_index'])
|
||||
so.message_post(body=_(
|
||||
so.message_post(body=Markup(_(
|
||||
'Issued <strong>%s</strong> to %s #%s.'
|
||||
) % (new_name, self._name, self.id))
|
||||
)) % (new_name, self._name, self.id))
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.8.26.0',
|
||||
'version': '19.0.8.27.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
|
||||
@@ -279,9 +279,9 @@ class SaleOrder(models.Model):
|
||||
'name': f'SO-{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>.'
|
||||
) % (old_name, so.name))
|
||||
)) % (old_name, so.name))
|
||||
result = super().action_confirm()
|
||||
for so in self:
|
||||
so._fp_auto_create_job()
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Quality (QMS)',
|
||||
'version': '19.0.4.13.0',
|
||||
'version': '19.0.4.14.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
|
||||
'internal audits, customer specs, document control. CE + EE compatible.',
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from markupsafe import Markup
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
@@ -22,9 +25,9 @@ class FpContractReview(models.Model):
|
||||
"""Contract Review (QA-005).
|
||||
|
||||
Per-part, two-section QA review: Section 2.0 Planning / Production
|
||||
Review (signed by a QA Assistant) and Section 3.0 Quality Review
|
||||
(signed by a QA Manager). Both sections must be signed for the
|
||||
review to be complete.
|
||||
Review (Planning Review stage, signed by a Planning Signer) and
|
||||
Section 3.0 Quality Review (QA Review stage, signed by a QA Signer).
|
||||
Both sections must be signed for the review to be complete.
|
||||
|
||||
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
|
||||
@@ -79,7 +82,7 @@ class FpContractReview(models.Model):
|
||||
qty = fields.Integer(string='Qty')
|
||||
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_capacity_to_process = fields.Boolean(string='Capacity to Process')
|
||||
@@ -118,7 +121,7 @@ class FpContractReview(models.Model):
|
||||
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_quality_clauses_supplied = fields.Boolean(string='Quality Clause(s) supplied')
|
||||
@@ -180,8 +183,9 @@ class FpContractReview(models.Model):
|
||||
|
||||
state = fields.Selection(
|
||||
[('draft', 'Draft'),
|
||||
('assistant_review', 'QA Assistant Review'),
|
||||
('manager_review', 'QA Manager Review'),
|
||||
('assistant_review', 'Planning Review'),
|
||||
('manager_review', 'QA Review'),
|
||||
('awaiting_info', 'Awaiting Client Info'),
|
||||
('complete', 'Complete'),
|
||||
('dismissed', 'Dismissed')],
|
||||
default='draft',
|
||||
@@ -189,6 +193,56 @@ class FpContractReview(models.Model):
|
||||
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 --------------------------------------------------------
|
||||
|
||||
_sql_constraints = [
|
||||
@@ -351,6 +405,133 @@ class FpContractReview(models.Model):
|
||||
'fusion_plating_quality.action_report_contract_review'
|
||||
).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 ------------------------------------------------------------
|
||||
|
||||
def _check_signer(self, section):
|
||||
|
||||
Reference in New Issue
Block a user