Sending an engagement triggered template.send_mail(), which logged the outbound email to the chatter as a `notification` message with the internal `Note` subtype. That's correct for nexa-side bookkeeping (we don't want the raw email body propagating to the customer), but it meant nothing public was posted — so the entech-side My Tickets inbox showed no activity. The employee couldn't tell their request had been escalated for approval. _fc_reset_engagement now posts a follow-up public message via message_post (subtype mail.mt_comment, message_type='comment') with: ⏳ Awaiting owner approval from <owner_name>. Their decision will appear here when they reply. Our reply: > <findings text> This survives the entech _public_messages filter (comment + non-internal subtype) and propagates to the employee's My Tickets thread, giving them context AND the engineer's reply without exposing the raw outbound email or the owner's email address. Smoke-tested live on ticket #54: re-engaged with the same owner, the new mail.message (id=348213) is subtype=Discussions / internal=False / message_type=comment, and contains both the awaiting-approval notice and the findings text. _public_messages would surface it. Bumps fusion_helpdesk_central to 19.0.2.4.1.
556 lines
24 KiB
Python
556 lines
24 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2026 Nexa Systems Inc.
|
|
# License OPL-1
|
|
"""Central-side helpdesk.ticket extensions for the customer follow-up flow
|
|
and the owner-approval engagement flow.
|
|
|
|
Adds:
|
|
- `x_fc_client_label` deployment tag (set by the in-app reporter so the
|
|
embedded inbox can scope per client).
|
|
- Branded acknowledgement email on create for in-app tickets.
|
|
- Auto-tag with Critical when priority=3 + has client_label.
|
|
- The full owner-approval engagement field set: state, token, snapshotted
|
|
owner email/name, AI summary, sent / reminded / decided timestamps, and a
|
|
stored computed turnaround for the reporting pivot.
|
|
- Upserts the client_key row's owner_email/owner_name from each incoming
|
|
ticket payload so the central always has the current owner contact
|
|
without a dedicated sync endpoint.
|
|
"""
|
|
import logging
|
|
import uuid
|
|
from datetime import timedelta
|
|
|
|
from odoo import _, api, fields, models
|
|
from odoo.exceptions import UserError
|
|
from odoo.tools import email_normalize
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class HelpdeskTicket(models.Model):
|
|
_inherit = 'helpdesk.ticket'
|
|
|
|
x_fc_client_label = fields.Char(
|
|
string='Client Deployment', index=True, copy=False,
|
|
help='Deployment tag (e.g. ENTECH) set by the fusion_helpdesk in-app '
|
|
'reporter. Scopes the embedded "My Tickets" inbox per client and '
|
|
'lets support filter tickets by originating deployment.',
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Owner-approval engagement fields
|
|
# ------------------------------------------------------------------
|
|
x_fc_engagement_state = fields.Selection(
|
|
selection=[
|
|
('none', 'None'),
|
|
('pending', 'Pending'),
|
|
('approved', 'Approved'),
|
|
('rejected', 'Rejected'),
|
|
],
|
|
string='Owner Approval', default='none', copy=False, index=True,
|
|
help='State of the owner-approval engagement: '
|
|
'none = never requested, pending = email sent / awaiting click, '
|
|
'approved / rejected = owner has decided.',
|
|
)
|
|
x_fc_engagement_email = fields.Char(
|
|
string='Engaged Owner Email', copy=False,
|
|
help='Snapshot of the owner email reached for THIS engagement. '
|
|
'Survives later edits to fusion.helpdesk.client.key.owner_email '
|
|
'so audit history stays correct.',
|
|
)
|
|
x_fc_engagement_name = fields.Char(
|
|
string='Engaged Owner Name', copy=False,
|
|
)
|
|
x_fc_engagement_token = fields.Char(
|
|
string='Engagement Token', copy=False, index=True,
|
|
help='UUID4 in the magic link. Single-use — cleared after the '
|
|
'owner confirms a decision in the portal.',
|
|
)
|
|
x_fc_engagement_sent_at = fields.Datetime(
|
|
string='Engagement Sent At', copy=False, readonly=True,
|
|
)
|
|
x_fc_engagement_reminded_at = fields.Datetime(
|
|
string='Engagement Reminded At', copy=False, readonly=True,
|
|
help='Set by the daily reminder cron. We send at most one '
|
|
'reminder per engagement to avoid spamming the owner.',
|
|
)
|
|
x_fc_engagement_decided_at = fields.Datetime(
|
|
string='Engagement Decided At', copy=False, readonly=True,
|
|
)
|
|
x_fc_ai_summary = fields.Text(
|
|
string='AI Summary', copy=False,
|
|
help='OpenAI-generated brief shown to the owner in the approval '
|
|
'email. Editable in the wizard before sending; frozen after.',
|
|
)
|
|
x_fc_engagement_findings = fields.Text(
|
|
string='Engagement Findings', copy=False,
|
|
help='The support engineer\'s reply / analysis (typed in the '
|
|
'wizard\'s Findings field). Sent to the owner in the '
|
|
'approval email alongside the original request and the AI '
|
|
'summary so they see the back-and-forth context, not just '
|
|
'a paraphrase. Frozen at send-time.',
|
|
)
|
|
x_fc_engagement_turnaround_hours = fields.Float(
|
|
string='Owner Turnaround (h)',
|
|
compute='_compute_engagement_turnaround',
|
|
store=True, copy=False, digits=(8, 2),
|
|
aggregator='avg', # Pivot default is SUM for Float — meaningless here
|
|
help='Hours between engagement-sent and owner decision. Stored so '
|
|
'the Owner Engagements pivot can aggregate without recomputing. '
|
|
'Aggregated as average across rows so the pivot reads "avg '
|
|
'turnaround per ticket", not "summed wait-time".',
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Owner-contact display + follower shortcut (read from client_key,
|
|
# never stored — single source of truth stays the client_key row).
|
|
# ------------------------------------------------------------------
|
|
x_fc_owner_display = fields.Char(
|
|
string='Owner Contact',
|
|
compute='_compute_owner_display',
|
|
help='The client deployment\'s decision-maker, pulled live from '
|
|
'their fusion.helpdesk.client.key row. Empty when no owner '
|
|
'is configured for this client.',
|
|
)
|
|
x_fc_owner_email_resolved = fields.Char(
|
|
compute='_compute_owner_display',
|
|
help='Internal helper field — drives view visibility for the '
|
|
'Add-as-Follower button. Email-only slice of '
|
|
'x_fc_owner_display.',
|
|
)
|
|
x_fc_owner_is_follower = fields.Boolean(
|
|
compute='_compute_owner_is_follower',
|
|
help='True when the configured owner is already subscribed to '
|
|
'this ticket\'s thread. Used to swap the button for a '
|
|
'"following" badge.',
|
|
)
|
|
|
|
# message_post-friendly index for the reminder cron + token resolution.
|
|
_engagement_state_idx = models.Index(
|
|
'(x_fc_engagement_state, x_fc_engagement_sent_at)'
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
@api.depends('x_fc_client_label')
|
|
def _compute_owner_display(self):
|
|
for rec in self:
|
|
email, name = (False, False)
|
|
if rec.x_fc_client_label:
|
|
email, name = rec._fc_owner_contact()
|
|
rec.x_fc_owner_email_resolved = email or ''
|
|
if email and name:
|
|
rec.x_fc_owner_display = '%s <%s>' % (name, email)
|
|
elif email:
|
|
rec.x_fc_owner_display = email
|
|
else:
|
|
rec.x_fc_owner_display = ''
|
|
|
|
@api.depends('x_fc_client_label', 'message_partner_ids',
|
|
'message_partner_ids.email')
|
|
def _compute_owner_is_follower(self):
|
|
for rec in self:
|
|
email = (rec.x_fc_owner_email_resolved or '').strip().lower()
|
|
if not email:
|
|
rec.x_fc_owner_is_follower = False
|
|
continue
|
|
rec.x_fc_owner_is_follower = any(
|
|
(p.email or '').strip().lower() == email
|
|
for p in rec.message_partner_ids
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ------------------------------------------------------------------
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
# Sync owner contact from payload BEFORE creating tickets so the
|
|
# client_key row reflects the latest contact even if ticket-create
|
|
# itself fails (e.g. validation error elsewhere).
|
|
self._fc_sync_owner_contacts(vals_list)
|
|
tickets = super().create(vals_list)
|
|
tickets._fc_send_ack_email()
|
|
tickets._fc_auto_tag_critical()
|
|
return tickets
|
|
|
|
@api.model
|
|
def _fc_sync_owner_contacts(self, vals_list):
|
|
"""Upsert fusion.helpdesk.client.key.owner_email/name from incoming
|
|
ticket vals so the central always has the latest owner contact.
|
|
|
|
Pulls keys 'x_fc_owner_email' / 'x_fc_owner_name' which the
|
|
fusion_helpdesk client controller piggybacks on every submit. These
|
|
are NOT real helpdesk.ticket fields — they're stripped here before
|
|
super().create() sees them so Odoo doesn't choke on unknown columns.
|
|
"""
|
|
ClientKey = self.env['fusion.helpdesk.client.key'].sudo()
|
|
for vals in vals_list:
|
|
# Pop the piggyback keys regardless of whether we use them.
|
|
owner_email = (vals.pop('x_fc_owner_email', None) or '').strip()
|
|
owner_name = (vals.pop('x_fc_owner_name', None) or '').strip()
|
|
label = (vals.get('x_fc_client_label') or '').strip()
|
|
if not label or not (owner_email or owner_name):
|
|
continue
|
|
row = ClientKey.search([('client_label', '=', label)], limit=1)
|
|
if not row:
|
|
# Don't auto-create a client_key row from a ticket — that
|
|
# would bypass API-key issuance. Just log and move on.
|
|
_logger.info(
|
|
'fusion_helpdesk_central: ticket carried owner contact '
|
|
'for unknown client_label "%s"; skipping sync.', label,
|
|
)
|
|
continue
|
|
updates = {}
|
|
if owner_email and owner_email != (row.owner_email or ''):
|
|
updates['owner_email'] = owner_email
|
|
if owner_name and owner_name != (row.owner_name or ''):
|
|
updates['owner_name'] = owner_name
|
|
if updates:
|
|
row.write(updates)
|
|
|
|
@api.depends('x_fc_engagement_sent_at', 'x_fc_engagement_decided_at')
|
|
def _compute_engagement_turnaround(self):
|
|
for rec in self:
|
|
sent = rec.x_fc_engagement_sent_at
|
|
decided = rec.x_fc_engagement_decided_at
|
|
if sent and decided and decided > sent:
|
|
delta = decided - sent
|
|
rec.x_fc_engagement_turnaround_hours = (
|
|
delta.total_seconds() / 3600.0
|
|
)
|
|
else:
|
|
rec.x_fc_engagement_turnaround_hours = 0.0
|
|
|
|
# ------------------------------------------------------------------
|
|
# Owner engagement plumbing
|
|
# ------------------------------------------------------------------
|
|
def _fc_owner_contact(self):
|
|
"""Return (email, name) for this ticket's client_key owner contact,
|
|
or (False, False) if the client_key is missing / unconfigured.
|
|
|
|
Single source of truth for the wizard + the form button's enable
|
|
check — we never read directly from the ticket's snapshot fields
|
|
for *new* engagements (those snapshot AT engagement time).
|
|
"""
|
|
self.ensure_one()
|
|
if not self.x_fc_client_label:
|
|
return (False, False)
|
|
row = self.env['fusion.helpdesk.client.key'].sudo().search(
|
|
[('client_label', '=', self.x_fc_client_label)], limit=1,
|
|
)
|
|
if not row:
|
|
return (False, False)
|
|
return (row.owner_email or False, row.owner_name or False)
|
|
|
|
def _fc_new_engagement_token(self):
|
|
"""Allocate a fresh single-use token. Centralised so tests can
|
|
monkeypatch it for deterministic assertions."""
|
|
return uuid.uuid4().hex
|
|
|
|
def _fc_reset_engagement(self, owner_email, owner_name, ai_summary,
|
|
findings=''):
|
|
"""Stamp a fresh pending engagement on this ticket — invalidates any
|
|
previous token + clears decided/reminded timestamps so the cron and
|
|
the reporting view see a clean slate.
|
|
|
|
Owner email is normalised here (lowercase, rejected if not a valid
|
|
single address) so a typo'd contact like "kris@x; jim@y" can't end
|
|
up as the snapshot. If normalisation fails, we still proceed using
|
|
the raw value — the email will probably bounce but state is
|
|
consistent and re-engaging fixes it.
|
|
|
|
`findings` is the support engineer's reply text from the wizard —
|
|
stored on the ticket so the mail template can show it as the
|
|
"My Reply" section without context-magic, and so it survives as
|
|
audit history once the engagement is decided.
|
|
|
|
After writing the state, posts a PUBLIC chatter message (subtype
|
|
mt_comment) so the message propagates to the employee's My Tickets
|
|
inbox on the client side — without it, the engagement email goes
|
|
out internally (logged as an auto-generated Note subtype) and
|
|
the entech-side inbox shows nothing happened. The employee deserves
|
|
to know we're waiting on their owner.
|
|
"""
|
|
self.ensure_one()
|
|
normalised = email_normalize(owner_email or '') or (owner_email or '')
|
|
owner_name_clean = (owner_name or '').strip()
|
|
self.write({
|
|
'x_fc_engagement_state': 'pending',
|
|
'x_fc_engagement_email': normalised,
|
|
'x_fc_engagement_name': owner_name_clean,
|
|
'x_fc_engagement_token': self._fc_new_engagement_token(),
|
|
'x_fc_engagement_sent_at': fields.Datetime.now(),
|
|
'x_fc_engagement_reminded_at': False,
|
|
'x_fc_engagement_decided_at': False,
|
|
'x_fc_ai_summary': ai_summary or '',
|
|
'x_fc_engagement_findings': findings or '',
|
|
})
|
|
self._fc_post_engagement_notice(owner_name_clean, findings or '')
|
|
|
|
def _fc_post_engagement_notice(self, owner_name, findings):
|
|
"""Public chatter message posted when an engagement is sent.
|
|
|
|
Two purposes:
|
|
- Tells the employee (via the entech My Tickets inbox, which only
|
|
reads public comments) that their request has been escalated
|
|
for approval and who's deciding.
|
|
- Captures our reply (findings) on the public thread so the
|
|
employee can see what support said — same content the owner
|
|
receives in the engagement email, just without the AI summary
|
|
(which is a decision-aid for the owner, not the employee).
|
|
|
|
Posted via message_post with subtype_xmlid='mail.mt_comment' so
|
|
it survives the entech-side _public_messages filter
|
|
(message_type='comment' + non-internal subtype).
|
|
"""
|
|
from markupsafe import Markup, escape
|
|
self.ensure_one()
|
|
name_html = escape(owner_name or 'the owner')
|
|
body = (
|
|
'<p>⏳ <b>Awaiting owner approval from %s.</b> '
|
|
'Their decision will appear here when they reply.</p>'
|
|
) % name_html
|
|
text = (findings or '').strip()
|
|
if text:
|
|
body += (
|
|
'<p><b>Our reply:</b></p>'
|
|
'<blockquote style="margin:6px 0 0 0; padding:6px 12px; '
|
|
'border-left:3px solid #c7dcfa; color:#1e3a5f; '
|
|
'background:#f4f8ff;">%s</blockquote>'
|
|
) % escape(text).replace('\n', '<br/>')
|
|
self.message_post(
|
|
body=Markup(body),
|
|
message_type='comment',
|
|
subtype_xmlid='mail.mt_comment',
|
|
)
|
|
|
|
def action_add_owner_as_follower(self):
|
|
"""Find-or-create the owner partner and subscribe them as a follower
|
|
on this ticket. One-click "loop the owner in" — different from the
|
|
engagement flow which gates on a magic-link decision; this is just
|
|
"they should be on the thread".
|
|
|
|
Idempotent: if the owner is already following, no-op. Uses the same
|
|
find-or-create-by-email pattern as the engagement portal so the
|
|
owner partner is consistent between flows.
|
|
"""
|
|
self.ensure_one()
|
|
email, name = self._fc_owner_contact()
|
|
if not email:
|
|
raise UserError(_(
|
|
'No owner contact configured for client "%s". Ask the client '
|
|
'to set it in Settings → Fusion Helpdesk → Owner Approval.'
|
|
) % (self.x_fc_client_label or '(unset)',))
|
|
norm = email_normalize(email) or email.strip().lower()
|
|
Partner = self.env['res.partner'].sudo()
|
|
partner = Partner.search(
|
|
[('email', '=ilike', norm)], order='id asc', limit=1,
|
|
)
|
|
if not partner:
|
|
partner = Partner.create({
|
|
'name': (name or '').strip() or norm.split('@')[0].title(),
|
|
'email': norm,
|
|
})
|
|
if partner.id not in self.message_partner_ids.ids:
|
|
self.message_subscribe(partner_ids=[partner.id])
|
|
# Force the compute to refresh on the next form render.
|
|
self.invalidate_recordset(['x_fc_owner_is_follower'])
|
|
return True
|
|
|
|
def action_open_engagement_wizard(self):
|
|
"""Form-button handler: open the wizard targeting this single ticket.
|
|
|
|
Validation lives on the wizard's default_get so the error path is
|
|
symmetrical with the bulk action — same UserError messages, same
|
|
soft fallback when AI is unavailable."""
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _('Request Owner Approval'),
|
|
'res_model': 'fusion.helpdesk.engagement.wizard',
|
|
'view_mode': 'form',
|
|
'target': 'new',
|
|
'context': {
|
|
'default_ticket_id': self.id,
|
|
'active_id': self.id,
|
|
'active_model': 'helpdesk.ticket',
|
|
},
|
|
}
|
|
|
|
@api.model
|
|
def action_open_engagement_wizard_bulk(self):
|
|
"""Server-action handler: open the wizard targeting the list-view
|
|
selection. Bound from a server action XML record. Reads ids from
|
|
the env context (`active_ids`) — the action ensures it's only
|
|
callable from a list/kanban with selection."""
|
|
ticket_ids = self.env.context.get('active_ids') or []
|
|
if not ticket_ids:
|
|
raise UserError(_('Select at least one ticket first.'))
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _('Request Owner Approval (Bulk)'),
|
|
'res_model': 'fusion.helpdesk.engagement.wizard',
|
|
'view_mode': 'form',
|
|
'target': 'new',
|
|
'context': {
|
|
'default_ticket_ids': ticket_ids,
|
|
'active_ids': ticket_ids,
|
|
'active_model': 'helpdesk.ticket',
|
|
'fhc_bulk': True,
|
|
},
|
|
}
|
|
|
|
@api.model
|
|
def _fc_send_engagement_reminders(self):
|
|
"""Cron entry-point: re-send one reminder for stale pending engagements.
|
|
|
|
N days configurable via ICP `engagement_reminder_days` (default 3,
|
|
0 = disabled). Single-shot per engagement — `reminded_at` set after
|
|
send so we never spam. Same token, same magic links, so the owner
|
|
can click whichever email is in front of them.
|
|
|
|
Idempotent on its own: a second cron run within the same day won't
|
|
re-find anything because `reminded_at` is now non-NULL.
|
|
"""
|
|
ICP = self.env['ir.config_parameter'].sudo()
|
|
try:
|
|
N = int(ICP.get_param(
|
|
'fusion_helpdesk_central.engagement_reminder_days') or 3)
|
|
except (TypeError, ValueError):
|
|
N = 3
|
|
if N <= 0:
|
|
_logger.info('fusion_helpdesk_central: reminder cron disabled '
|
|
'(engagement_reminder_days <= 0); skipping.')
|
|
return 0
|
|
cutoff = fields.Datetime.now() - timedelta(days=N)
|
|
stale = self.search([
|
|
('x_fc_engagement_state', '=', 'pending'),
|
|
('x_fc_engagement_sent_at', '<=', cutoff),
|
|
('x_fc_engagement_reminded_at', '=', False),
|
|
])
|
|
if not stale:
|
|
return 0
|
|
template = self.env.ref(
|
|
'fusion_helpdesk_central.mail_template_engagement',
|
|
raise_if_not_found=False,
|
|
)
|
|
if not template:
|
|
_logger.warning(
|
|
'fusion_helpdesk_central: reminder cron found %s stale '
|
|
'engagements but the mail template is missing; aborting.',
|
|
len(stale),
|
|
)
|
|
return 0
|
|
now = fields.Datetime.now()
|
|
sent = 0
|
|
for ticket in stale:
|
|
# Per-row savepoint: a DB failure on one ticket (constraint hit,
|
|
# mail-server hiccup that propagates as an OperationalError, etc.)
|
|
# would otherwise leave the whole cron transaction in an aborted
|
|
# state — every subsequent row's `reminded_at` write would fail
|
|
# silently with InFailedSqlTransaction. CLAUDE.md rule #14: use
|
|
# `cr.savepoint()` not `cr.commit()` inside the loop (commits
|
|
# raise inside TransactionCase).
|
|
try:
|
|
with self.env.cr.savepoint():
|
|
template.with_context(
|
|
fhc_is_reminder=True,
|
|
fhc_personal_note='',
|
|
).send_mail(ticket.id, force_send=False)
|
|
ticket.x_fc_engagement_reminded_at = now
|
|
sent += 1
|
|
except Exception: # noqa: BLE001 — never break the batch
|
|
_logger.exception(
|
|
'fusion_helpdesk_central: reminder send failed for '
|
|
'ticket %s; will retry next run.', ticket.id,
|
|
)
|
|
_logger.info(
|
|
'fusion_helpdesk_central: reminder cron sent %s reminder(s) '
|
|
'out of %s candidate(s).', sent, len(stale),
|
|
)
|
|
return sent
|
|
|
|
def _fc_finalize_engagement(self, decision, owner_partner, comment=None):
|
|
"""Apply the owner's decision: post chatter (public), write state +
|
|
decided_at. Called from the public portal controller AFTER the
|
|
controller has already atomically claimed (cleared) the token via
|
|
UPDATE...RETURNING — so we don't clear it again here; doing so
|
|
would race with a re-engagement that happened to rotate the token
|
|
between our write and the controller's claim.
|
|
|
|
Chatter is posted as a public comment (subtype mail.mt_comment) so
|
|
it propagates to the employee's My Tickets thread per the
|
|
"fully visible" UX choice in the spec.
|
|
"""
|
|
from odoo.addons.fusion_helpdesk_central.utils import (
|
|
format_engagement_chatter,
|
|
)
|
|
self.ensure_one()
|
|
body = format_engagement_chatter(
|
|
decision, self.x_fc_engagement_name, comment,
|
|
)
|
|
author_id = owner_partner.id if owner_partner else False
|
|
self.message_post(
|
|
body=body,
|
|
message_type='comment',
|
|
subtype_xmlid='mail.mt_comment',
|
|
author_id=author_id,
|
|
)
|
|
self.write({
|
|
'x_fc_engagement_state': decision,
|
|
'x_fc_engagement_decided_at': fields.Datetime.now(),
|
|
})
|
|
|
|
# ------------------------------------------------------------------
|
|
# Existing customer-followup hooks (unchanged behaviour)
|
|
# ------------------------------------------------------------------
|
|
def _fc_auto_tag_critical(self):
|
|
"""Auto-apply the Critical tag on in-app tickets that were filed with
|
|
priority='3' (Urgent — the client-side "Mark as Critical" toggle).
|
|
|
|
Scoped to tickets carrying `x_fc_client_label` so support staff who
|
|
manually set priority='3' on their own internal tickets aren't
|
|
silently tagged. Best-effort: if the data XML hasn't loaded the tag
|
|
yet (e.g. partial install), skip without raising — the ticket is
|
|
already filed with priority='3' which is the load-bearing signal."""
|
|
critical = self.filtered(
|
|
lambda t: t.priority == '3' and t.x_fc_client_label
|
|
)
|
|
if not critical:
|
|
return
|
|
tag = self.env.ref(
|
|
'fusion_helpdesk_central.tag_critical', raise_if_not_found=False,
|
|
)
|
|
if not tag:
|
|
_logger.warning(
|
|
'fusion_helpdesk_central: tag_critical not found, skipping '
|
|
'auto-tag on %s critical ticket(s).', len(critical),
|
|
)
|
|
return
|
|
critical.write({'tag_ids': [(4, tag.id)]})
|
|
|
|
def _fc_send_ack_email(self):
|
|
"""Send the branded acknowledgement (with magic link) to the customer.
|
|
|
|
Only fires for in-app-channel tickets (those tagged with a client
|
|
label) that have a customer email — external web-form submissions
|
|
rely on the native website confirmation, so this won't double-send.
|
|
The whole thing is best-effort: a template/mail failure must never
|
|
block ticket creation, so we log and move on.
|
|
"""
|
|
template = self.env.ref(
|
|
'fusion_helpdesk_central.mail_template_ticket_ack',
|
|
raise_if_not_found=False,
|
|
)
|
|
if not template:
|
|
return
|
|
for ticket in self:
|
|
if not (ticket.x_fc_client_label and ticket.partner_email):
|
|
continue
|
|
try:
|
|
template.send_mail(ticket.id, force_send=False)
|
|
except Exception: # noqa: BLE001 — ack must never block create
|
|
_logger.exception(
|
|
'fusion_helpdesk_central: acknowledgement email failed '
|
|
'for ticket %s (%s)', ticket.id, ticket.x_fc_client_label,
|
|
)
|