Files
Odoo-Modules/fusion_plating/fusion_plating_notifications/models/fp_notification_template.py
gsinghpal 0342535b9f feat(notifications): Sub 6 — contact profiles + communication routing
Five new boolean flags on res.partner applied to CHILD contacts:
x_fc_receives_certs, _qc, _quotes_so, _invoices, and _is_global_contact.
Single resolver helper res.partner._fp_resolve_notification_recipients
(stream, delivery_location=None) walks location contacts first then
company contacts, returning emails for contacts that opted into the
stream (or flagged themselves global). Falls back to partner.email
when no contact opts in so existing customers keep their exact
pre-Sub-6 routing.

fp.notification.template._dispatch now maps each trigger event to a
stream (so_confirmed→quotes_so, invoice_posted→invoices, shipped→
certs, etc.) and overrides the mail_template's email_to with the
resolved list. fp.delivery passes its delivery_address_id so the
shipped/CoC email routes through location-scoped contacts when they
exist.

Partner form gets a new "Communication Routing" tab on child contact
forms with the 5 flags (hides the per-stream checkboxes when
Global Contact is on, since it overrides them).

fusion_plating_certificates → 19.0.4.0.0 (adds the flag fields)
fusion_plating_notifications → 19.0.5.0.0 (+depends certificates)

Smoke on entech: 11/11 assertions pass including per-stream routing,
delivery-location scoping, zero-flag fallback, email-less skip,
unknown-stream + global behaviour, and case-insensitive dedup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:01:15 -04:00

338 lines
14 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.
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
TRIGGER_EVENTS = [
('quote_sent', 'Quotation Sent'),
('so_confirmed', 'Order Confirmed'),
('parts_received', 'Parts Received'),
('mo_complete', 'Manufacturing Complete'),
('shipped', 'Shipped / Delivered'),
('invoice_posted', 'Invoice Posted'),
('payment_received', 'Payment Received'),
('deposit_created', 'Deposit Required'),
]
# Sub 6 — map each trigger event to a communication stream. Contacts on
# the customer who opt into that stream (or flag themselves as global)
# receive the email. Unmapped events fall back to the company partner's
# own email, preserving pre-Sub-6 behaviour.
FP_TRIGGER_STREAM = {
'quote_sent': 'quotes_so',
'so_confirmed': 'quotes_so',
'parts_received': 'quotes_so',
'mo_complete': 'qc',
'shipped': 'certs',
'invoice_posted': 'invoices',
'payment_received': 'invoices',
'deposit_created': 'invoices',
}
class FpNotificationTemplate(models.Model):
"""Configurable notification wrapper.
Each record maps a trigger event to a mail.template and controls
whether the notification fires and what attachments are included.
"""
_name = 'fp.notification.template'
_description = 'Fusion Plating — Notification Template'
_order = 'trigger_event'
name = fields.Char(string='Template Name', required=True)
trigger_event = fields.Selection(
TRIGGER_EVENTS, string='Trigger Event', required=True,
)
mail_template_id = fields.Many2one(
'mail.template', string='Email Template',
help='The Odoo mail template used to render and send the email.',
)
active = fields.Boolean(string='Active', default=True)
attach_quotation = fields.Boolean(string='Attach Quotation PDF')
attach_sale_order = fields.Boolean(string='Attach Sales Order PDF')
attach_coc = fields.Boolean(string='Attach CoC')
attach_thickness_report = fields.Boolean(string='Attach Thickness Report')
attach_invoice = fields.Boolean(string='Attach Invoice')
attach_receipt = fields.Boolean(string='Attach Payment Receipt')
attach_packing_list = fields.Boolean(string='Attach Packing List')
attach_bol = fields.Boolean(string='Attach Bill of Lading')
attach_pod = fields.Boolean(string='Attach Proof of Delivery')
cc_internal_ids = fields.Many2many(
'res.users', 'fp_notification_template_cc_rel',
'template_id', 'user_id', string='CC (Internal)',
)
_sql_constraints = [
('fp_notification_trigger_uniq', 'unique(trigger_event)',
'Only one notification template per trigger event.'),
]
# ------------------------------------------------------------------
# Central dispatch helper — called from every hook.
# ------------------------------------------------------------------
@api.model
def _dispatch(self, trigger_event, record, partner=None, sale_order=None,
extra_attachment_ids=None, delivery_location=None):
"""Look up the template for this trigger, render it, and send.
Also logs the attempt in fp.notification.log.
Sub 6: recipient resolution now goes through
res.partner._fp_resolve_notification_recipients so per-contact
flags (certs / qc / quotes_so / invoices / global) and per-
delivery-location contacts are honoured. Customers who haven't
set any flags fall back to the company partner's email —
identical to pre-Sub-6 behaviour.
"""
template = self.search(
[('trigger_event', '=', trigger_event), ('active', '=', True)],
limit=1,
)
if not template or not template.mail_template_id:
return
partner = partner or getattr(record, 'partner_id', False)
# Build attachment list from template config
attachment_ids = list(extra_attachment_ids or [])
attachment_names = []
for att_id in template._collect_attachments(record):
attachment_ids.append(att_id)
if attachment_ids:
attachment_names = self.env['ir.attachment'].browse(attachment_ids).mapped('name')
# Sub 6 — resolve recipients via the contact-routing helper.
recipient_emails = []
if partner:
stream = FP_TRIGGER_STREAM.get(trigger_event)
if stream:
recipient_emails = partner._fp_resolve_notification_recipients(
stream, delivery_location=delivery_location,
)
elif partner.email:
recipient_emails = [partner.email]
recipient_str = ', '.join(recipient_emails)
email_values = {}
if attachment_ids:
email_values['attachment_ids'] = [(6, 0, attachment_ids)]
if recipient_str:
# Override the mail.template's default Jinja-rendered email_to
# with the resolved list. Setting email_to clears partner_ids
# inherited from the template so we don't double-send.
email_values['email_to'] = recipient_str
email_values['partner_ids'] = [(5, 0, 0)]
Log = self.env['fp.notification.log']
try:
mail_id = template.mail_template_id.send_mail(
record.id,
force_send=False,
email_values=email_values or None,
)
Log.create({
'template_id': template.id,
'trigger_event': trigger_event,
'sale_order_id': sale_order.id if sale_order else False,
'partner_id': partner.id if partner else False,
'recipient_email': recipient_str or (partner.email if partner else ''),
'attachment_names': ', '.join(attachment_names) if attachment_names else '',
'status': 'sent',
'mail_mail_id': mail_id,
})
except Exception as exc:
_logger.warning('FP notification failed (%s): %s', trigger_event, exc)
Log.create({
'template_id': template.id,
'trigger_event': trigger_event,
'sale_order_id': sale_order.id if sale_order else False,
'partner_id': partner.id if partner else False,
'recipient_email': recipient_str or (partner.email if partner else ''),
'status': 'failed',
'error_message': str(exc),
})
def _collect_attachments(self, record):
"""Return a list of ir.attachment ids to attach to the email based
on the template's attach_* flags and the record's context.
"""
self.ensure_one()
Attachment = self.env['ir.attachment']
ids = []
# Resolve related records (MO, portal job, SO) from `record`
portal_job = None
production = None
sale_order = None
invoice = None
delivery = None
payment = None
model = record._name
if model == 'sale.order':
sale_order = record
portal_job = self.env['fusion.plating.portal.job'].search(
[('name', 'in', record.mapped('picking_ids.origin'))], limit=1,
) or None
elif model == 'account.move':
invoice = record
if record.invoice_origin:
sale_order = self.env['sale.order'].search(
[('name', '=', record.invoice_origin)], limit=1,
) or None
elif model == 'account.payment':
payment = record
invoice = record.reconciled_invoice_ids[:1]
if invoice and invoice.invoice_origin:
sale_order = self.env['sale.order'].search(
[('name', '=', invoice.invoice_origin)], limit=1,
) or None
elif model == 'mrp.production':
production = record
portal_job = record.x_fc_portal_job_id
if record.origin:
sale_order = self.env['sale.order'].search(
[('name', '=', record.origin)], limit=1,
) or None
elif model == 'fusion.plating.delivery':
delivery = record
if record.job_ref:
portal_job = self.env['fusion.plating.portal.job'].search(
[('name', '=', record.job_ref)], limit=1,
) or None
elif model == 'fp.receiving':
sale_order = record.sale_order_id
def _render_report(xmlid, rec):
"""Render a PDF report and return an attachment id."""
if not rec:
return None
try:
report = self.env.ref(xmlid, raise_if_not_found=False)
if not report:
return None
pdf_bytes, _fmt = self.env['ir.actions.report']._render_qweb_pdf(
xmlid, res_ids=rec.ids,
)
import base64
att = Attachment.create({
'name': f'{report.name} - {rec.display_name}.pdf',
'type': 'binary',
'datas': base64.b64encode(pdf_bytes),
'mimetype': 'application/pdf',
'res_model': rec._name,
'res_id': rec.id,
})
return att.id
except Exception as exc:
_logger.warning('Failed to render %s: %s', xmlid, exc)
return None
# Resolve the customer record — partner-level flags gate certain
# documents so customer preferences override template defaults.
customer = None
if sale_order:
customer = sale_order.partner_id
elif portal_job:
customer = portal_job.partner_id
elif delivery:
customer = delivery.partner_id
elif invoice and getattr(invoice, 'partner_id', None):
customer = invoice.partner_id
elif production and portal_job:
customer = portal_job.partner_id
def _customer_wants(flag, default=True):
"""Respect the per-customer flag if the field exists; else default."""
if customer and flag in customer._fields:
return bool(getattr(customer, flag))
return default
# Both attach_quotation and attach_sale_order point at the same
# report today — render once to avoid double attachment.
if (self.attach_quotation or self.attach_sale_order) and sale_order:
att = _render_report(
'fusion_plating_reports.action_report_fp_sale_portrait', sale_order,
)
if att:
ids.append(att)
# CoC — gated by customer preference (x_fc_send_coc, default True).
# Prefer the rich PDF that mrp_production.button_mark_done already
# rendered against the fp.certificate (signatures, accreditation
# logos, thickness data). The legacy action_report_coc bound to
# fusion.plating.portal.job is only a header table; never use it
# when a real cert PDF exists.
if self.attach_coc and portal_job and _customer_wants('x_fc_send_coc'):
if portal_job.coc_attachment_id:
ids.append(portal_job.coc_attachment_id.id)
else:
# No pre-rendered cert (older job or cert-gen failed).
# Render the rich cert report against the most recent
# CoC fp.certificate, falling back to the bare portal_job
# template only if no cert exists at all.
Cert = self.env.get('fp.certificate')
cert = False
if Cert is not None and production:
cert = Cert.search([
('production_id', '=', production.id),
('certificate_type', '=', 'coc'),
], order='id desc', limit=1)
if cert:
lang = (cert.partner_id.lang or '').lower()
cert_xmlid = (
'fusion_plating_reports.action_report_coc_fr'
if lang.startswith('fr')
else 'fusion_plating_reports.action_report_coc_en'
)
att = _render_report(cert_xmlid, cert)
else:
att = _render_report(
'fusion_plating_reports.action_report_coc', portal_job,
)
if att:
ids.append(att)
# Thickness report — only attach when the customer opted OUT of
# CoC and ONLY wants thickness. The CoC PDF already embeds
# thickness data so attaching both would be a duplicate.
if (self.attach_thickness_report and portal_job
and _customer_wants('x_fc_send_thickness_report')
and not (self.attach_coc and _customer_wants('x_fc_send_coc'))):
att = _render_report(
'fusion_plating_reports.action_report_coc_en', portal_job,
)
if att:
ids.append(att)
if self.attach_invoice and invoice:
att = _render_report(
'fusion_plating_reports.action_report_fp_invoice_portrait', invoice,
)
if att:
ids.append(att)
if self.attach_receipt and payment:
att = _render_report(
'fusion_plating_reports.action_report_fp_receipt_portrait', payment,
)
if att:
ids.append(att)
# Packing slip — gated by customer preference (default True)
if self.attach_packing_list and delivery and _customer_wants('x_fc_send_packing_slip'):
att = _render_report(
'fusion_plating_reports.action_report_fp_packing_slip_portrait', delivery,
)
if att:
ids.append(att)
# BoL — gated by customer preference (default False)
if self.attach_bol and delivery and _customer_wants('x_fc_send_bol', default=False):
att = _render_report(
'fusion_plating_reports.action_report_fp_bol_portrait', delivery,
)
if att:
ids.append(att)
return ids