From f07e1bcce152e0517a7e5b7d23f5f8531dd8aa9f Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 13 May 2026 08:41:39 -0400 Subject: [PATCH] =?UTF-8?q?fix(chatter):=20wrap=20HTML=20message=5Fpost=20?= =?UTF-8?q?bodies=20in=20Markup()=20=E2=80=94=204=20sites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 "QA Review failed" 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 %s to ..." fusion_plating_jobs/models/sale_order.py:282 "Confirmed quote %s as %s." fusion_plating_quality/models/fp_contract_review.py:430 "QA Review failed by ... Reason:
%(reason)s" fusion_plating_quality/models/fp_contract_review.py:524 "QA Review completed by ... Special Instructions captured:
%(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 "QA Review failed..." 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) --- fusion_plating/fusion_plating/__manifest__.py | 2 +- .../models/fp_parent_numbered_mixin.py | 5 +- .../fusion_plating_jobs/__manifest__.py | 2 +- .../fusion_plating_jobs/models/sale_order.py | 4 +- .../fusion_plating_quality/__manifest__.py | 2 +- .../models/fp_contract_review.py | 195 +++++++++++++++++- 6 files changed, 196 insertions(+), 14 deletions(-) diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index a015b56a..c7feda08 100644 --- a/fusion_plating/fusion_plating/__manifest__.py +++ b/fusion_plating/fusion_plating/__manifest__.py @@ -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': """ diff --git a/fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py b/fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py index c5c0ccdb..0d6af25c 100644 --- a/fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py +++ b/fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py @@ -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 %s to %s #%s.' - ) % (new_name, self._name, self.id)) + )) % (new_name, self._name, self.id)) return True # ------------------------------------------------------------------ diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index a854d8f5..e4e415b3 100644 --- a/fusion_plating/fusion_plating_jobs/__manifest__.py +++ b/fusion_plating/fusion_plating_jobs/__manifest__.py @@ -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.', diff --git a/fusion_plating/fusion_plating_jobs/models/sale_order.py b/fusion_plating/fusion_plating_jobs/models/sale_order.py index 477a2f21..eb114c35 100644 --- a/fusion_plating/fusion_plating_jobs/models/sale_order.py +++ b/fusion_plating/fusion_plating_jobs/models/sale_order.py @@ -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 %s as %s.' - ) % (old_name, so.name)) + )) % (old_name, so.name)) result = super().action_confirm() for so in self: so._fp_auto_create_job() diff --git a/fusion_plating/fusion_plating_quality/__manifest__.py b/fusion_plating/fusion_plating_quality/__manifest__.py index 612e9127..2ca3ccb3 100644 --- a/fusion_plating/fusion_plating_quality/__manifest__.py +++ b/fusion_plating/fusion_plating_quality/__manifest__.py @@ -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.', diff --git a/fusion_plating/fusion_plating_quality/models/fp_contract_review.py b/fusion_plating/fusion_plating_quality/models/fp_contract_review.py index 541427d2..725955e8 100644 --- a/fusion_plating/fusion_plating_quality/models/fp_contract_review.py +++ b/fusion_plating/fusion_plating_quality/models/fp_contract_review.py @@ -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(_( + 'QA Review failed by %(user)s. Awaiting client ' + 'information.
Reason:
%(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(_( + 'QA Review completed by %(user)s after receiving ' + 'client information.
' + 'Special Instructions captured:
%(notes)s' + )) % { + 'user': self.env.user.name, + 'notes': Markup(self.special_instructions or '') or _('(none)'), + }) + return True + # ---- Helpers ------------------------------------------------------------ def _check_signer(self, section):