Files
gsinghpal f07e1bcce1 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>
2026-05-13 08:41:39 -04:00

564 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# 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
# QA-005 Rev. 0 risk matrix, keyed (consequence, likelihood) → band.
# Reproduces the coloured 5×5 grid printed on the paper form.
_RISK_MATRIX = {
(1, 1): 'green', (2, 1): 'green', (3, 1): 'green', (4, 1): 'yellow', (5, 1): 'yellow',
(1, 2): 'green', (2, 2): 'green', (3, 2): 'yellow', (4, 2): 'yellow', (5, 2): 'yellow',
(1, 3): 'green', (2, 3): 'yellow', (3, 3): 'yellow', (4, 3): 'yellow', (5, 3): 'red',
(1, 4): 'green', (2, 4): 'yellow', (3, 4): 'yellow', (4, 4): 'red', (5, 4): 'red',
(1, 5): 'green', (2, 5): 'yellow', (3, 5): 'red', (4, 5): 'red', (5, 5): 'red',
}
class FpContractReview(models.Model):
"""Contract Review (QA-005).
Per-part, two-section QA review: Section 2.0 Planning / Production
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
QA-005 form.
"""
_name = 'fp.contract.review'
_description = 'Contract Review (QA-005)'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'create_date desc, id desc'
# ---- Identity & header ---------------------------------------------------
name = fields.Char(
string='Reference',
compute='_compute_name',
store=True,
)
part_id = fields.Many2one(
'fp.part.catalog',
string='Part',
required=True,
ondelete='cascade',
tracking=True,
)
customer_id = fields.Many2one(
related='part_id.partner_id',
store=True,
string='Customer',
)
part_number = fields.Char(
related='part_id.part_number',
store=True,
string='Part Number',
)
part_revision = fields.Char(
related='part_id.revision',
store=True,
string='Revision',
)
company_id = fields.Many2one(
'res.company',
required=True,
default=lambda s: s.env.company,
)
date_received = fields.Date(
string='Date Received',
default=fields.Date.context_today,
tracking=True,
)
contract_po_number = fields.Char(string='Contract / PO No.')
quote_or_job_number = fields.Char(string='Quote or Job #')
qty = fields.Integer(string='Qty')
due_date = fields.Date(string='Due')
# ---- 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')
s20_skills_to_process = fields.Boolean(string='Skills to Process')
s20_fixtures_required = fields.Boolean(string='Fixtures Required')
s20_prime_approvals = fields.Boolean(string='Prime approvals on file')
s20_pricing = fields.Boolean(string='Pricing')
s20_approved_technique = fields.Boolean(string='Approved Technique by Customer')
s20_drawings_available = fields.Boolean(string='Drawings available')
s20_process_type_class_grade = fields.Boolean(string='Process Type / Class / Grade')
s20_pre_post_processing_steps = fields.Boolean(string='Pre / Post Processing Steps')
s20_accepted = fields.Boolean(string='Accepted (Section 2.0)')
s20_comments = fields.Text(string='Comments')
s20_evaluate_risk = fields.Boolean(string='Evaluate Risk (Section 2.0)')
s20_risk_level = fields.Selection(
[('1', '1'), ('2', '2'), ('3', '3'), ('4', '4'), ('5', '5')],
string='Risk Level (Section 2.0)',
)
s20_signed_by = fields.Many2one(
'res.users',
string='Production Signature',
readonly=True,
copy=False,
tracking=True,
)
s20_signed_date = fields.Datetime(
string='Section 2.0 Signed On',
readonly=True,
copy=False,
tracking=True,
)
s20_locked = fields.Boolean(
string='Section 2.0 Locked',
readonly=True,
default=False,
copy=False,
)
# ---- 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')
s30_quality_clauses_attainable = fields.Boolean(string='Quality Clause(s) attainable')
s30_critical_tolerance = fields.Boolean(string='Critical Tolerance(s)')
s30_measuring_tooling = fields.Boolean(string='Measuring Tooling Available')
s30_quality_tests_verified = fields.Boolean(string='Quality Tests Requirements Verified')
s30_specification_revisions = fields.Boolean(string='Specification Revisions')
s30_certifications_requirements = fields.Boolean(string='Certifications Requirements')
s30_psd_rfd_reviewed = fields.Boolean(string='PSD, RFD etc. Reviewed')
s30_specification_deviations = fields.Boolean(string='Specification Deviations')
s30_design_authority = fields.Boolean(string='Design Authority')
s30_accepted = fields.Boolean(string='Accepted (Section 3.0)')
s30_evaluate_risk = fields.Boolean(string='Evaluate Risk (Section 3.0)')
s30_risk_consequence = fields.Selection(
[('1', '1 — Minimal'),
('2', '2 — Moderate'),
('3', '3 — Mod. / Applicable'),
('4', '4 — Major / Changes'),
('5', '5 — Unacceptable')],
string='Consequence',
)
s30_risk_likelihood = fields.Selection(
[('1', '1 — Not Likely'),
('2', '2 — Low Likelihood'),
('3', '3 — Likely'),
('4', '4 — Highly Likely'),
('5', '5 — Near Certainty')],
string='Likelihood',
)
s30_risk_band = fields.Selection(
[('green', 'Green'), ('yellow', 'Yellow'), ('red', 'Red')],
string='Risk Band',
compute='_compute_risk_band',
store=True,
)
s30_mitigation_plan_required = fields.Boolean(string='Mitigation Plan Required')
s30_signed_by = fields.Many2one(
'res.users',
string='Quality Signature',
readonly=True,
copy=False,
tracking=True,
)
s30_signed_date = fields.Datetime(
string='Section 3.0 Signed On',
readonly=True,
copy=False,
tracking=True,
)
s30_locked = fields.Boolean(
string='Section 3.0 Locked',
readonly=True,
default=False,
copy=False,
)
# ---- State --------------------------------------------------------------
state = fields.Selection(
[('draft', 'Draft'),
('assistant_review', 'Planning Review'),
('manager_review', 'QA Review'),
('awaiting_info', 'Awaiting Client Info'),
('complete', 'Complete'),
('dismissed', 'Dismissed')],
default='draft',
required=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 --------------------------------------------------------
_sql_constraints = [
('fp_contract_review_part_uniq',
'unique(part_id)',
'A Contract Review already exists for this part.'),
]
# ---- Computes -----------------------------------------------------------
@api.depends('part_id.part_number', 'part_id.revision', 'customer_id.name')
def _compute_name(self):
for rec in self:
if rec.part_id:
rec.name = _('QA-005 — %(pn)s Rev %(rev)s') % {
'pn': rec.part_id.part_number or _('(no part#)'),
'rev': rec.part_id.revision or _('(no rev)'),
}
else:
rec.name = _('QA-005 Contract Review')
@api.depends('s30_risk_consequence', 's30_risk_likelihood')
def _compute_risk_band(self):
for rec in self:
try:
c = int(rec.s30_risk_consequence) if rec.s30_risk_consequence else 0
l = int(rec.s30_risk_likelihood) if rec.s30_risk_likelihood else 0
except (ValueError, TypeError):
c = l = 0
rec.s30_risk_band = _RISK_MATRIX.get((c, l), False)
# ---- Actions ------------------------------------------------------------
def action_sign_section_20(self):
"""Lock Section 2.0 after roster check. Advance state."""
self.ensure_one()
if self.s20_locked:
raise UserError(_('Section 2.0 is already signed.'))
self._check_signer(20)
self.write({
's20_signed_by': self.env.user.id,
's20_signed_date': fields.Datetime.now(),
's20_locked': True,
'state': ('manager_review'
if self.state in ('draft', 'assistant_review')
else self.state),
})
self.message_post(body=_(
'Section 2.0 (Planning / Production Review) signed by %s.'
) % self.env.user.name)
return True
def action_sign_section_30(self):
"""Lock Section 3.0 after roster check. Mark complete."""
self.ensure_one()
if self.s30_locked:
raise UserError(_('Section 3.0 is already signed.'))
self._check_signer(30)
self.write({
's30_signed_by': self.env.user.id,
's30_signed_date': fields.Datetime.now(),
's30_locked': True,
'state': 'complete',
})
self.message_post(body=_(
'Section 3.0 (Quality Review) signed by %s. Review complete.'
) % self.env.user.name)
return True
# Checklist fields per section, for the "Check All" / "Clear All"
# bulk-toggle buttons. Only the checklist boxes are flipped —
# outcome fields (Accepted, Evaluate Risk, Risk Level / Matrix,
# Mitigation Plan Required) remain under the user's explicit
# decision so they don't get accidentally ticked.
_SECTION_20_CHECKLIST = (
's20_acceptable_lead_time',
's20_capacity_to_process',
's20_skills_to_process',
's20_fixtures_required',
's20_prime_approvals',
's20_pricing',
's20_approved_technique',
's20_drawings_available',
's20_process_type_class_grade',
's20_pre_post_processing_steps',
)
_SECTION_30_CHECKLIST = (
's30_source_control_docs',
's30_quality_clauses_supplied',
's30_quality_clauses_attainable',
's30_critical_tolerance',
's30_measuring_tooling',
's30_quality_tests_verified',
's30_specification_revisions',
's30_certifications_requirements',
's30_psd_rfd_reviewed',
's30_specification_deviations',
's30_design_authority',
)
def _bulk_toggle_checklist(self, fields_tuple, value, locked_field):
self.ensure_one()
if self[locked_field]:
raise UserError(_(
'Section is already signed — checklist is locked.'
))
self.write({f: value for f in fields_tuple})
return True
def action_check_all_section_20(self):
return self._bulk_toggle_checklist(
self._SECTION_20_CHECKLIST, True, 's20_locked')
def action_clear_all_section_20(self):
return self._bulk_toggle_checklist(
self._SECTION_20_CHECKLIST, False, 's20_locked')
def action_check_all_section_30(self):
return self._bulk_toggle_checklist(
self._SECTION_30_CHECKLIST, True, 's30_locked')
def action_clear_all_section_30(self):
return self._bulk_toggle_checklist(
self._SECTION_30_CHECKLIST, False, 's30_locked')
def action_reopen(self):
"""Clear all sign-off data and revert to draft. Manager only."""
self.ensure_one()
if not self.env.user.has_group('fusion_plating.group_fusion_plating_manager'):
raise UserError(_(
'Only a Plating Manager can re-open a signed Contract Review.'
))
self.write({
's20_signed_by': False,
's20_signed_date': False,
's20_locked': False,
's30_signed_by': False,
's30_signed_date': False,
's30_locked': False,
'state': 'assistant_review',
})
self.message_post(body=_(
'Contract Review re-opened by %s. Both sections cleared for '
're-signing.'
) % self.env.user.name)
return True
def action_dismiss(self):
"""Cancel the review. Banner stays off on the part (dismissed flag)."""
self.ensure_one()
self.write({'state': 'dismissed'})
if self.part_id:
self.part_id.x_fc_contract_review_dismissed = True
return True
def action_print_qa005(self):
"""Render the 1:1 QA-005 PDF."""
self.ensure_one()
return self.env.ref(
'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):
"""Enforce the settings-based signer roster.
Plating Managers override the roster so operations never stall
waiting on a designated signer who is away.
"""
self.ensure_one()
if self.env.user.has_group('fusion_plating.group_fusion_plating_manager'):
return
allowed = self.company_id._fp_get_qa_signers(section)
if self.env.user not in allowed:
names = ', '.join(allowed.mapped('name')) or _('(none configured)')
section_label = {
20: _('Section 2.0 (Planning / Production Review)'),
30: _('Section 3.0 (Quality Review)'),
}.get(section, _('this section'))
raise UserError(_(
'%(user)s is not authorised to sign %(section)s.\n\n'
'Authorised signers: %(allowed)s\n\n'
'Ask an authorised signer, or have a Plating Manager sign '
'on their behalf. Signer rosters are managed under '
'Settings → Fusion Plating → Contract Review.'
) % {
'user': self.env.user.name,
'section': section_label,
'allowed': names,
})