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>
564 lines
22 KiB
Python
564 lines
22 KiB
Python
# -*- 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,
|
||
})
|