Previous engagement notice used <blockquote> to style the findings quote. Odoo's mail.thread renderer auto-tags every <blockquote> with data-o-mail-quote-node="1" and the chatter UI then HIDES the content behind a "..." widget — exactly the wrong UX since the findings are the load-bearing content, not throwaway quoted text. Swapped both quote blocks for styled <div>s with the same visual treatment (left border, light background, padding) so they render fully inline with no toggle. Also expanded the notice to mirror more of what the owner sees in the engagement email: now includes BOTH "Our reply" (the findings) and "Summary sent to the owner" (the AI summary). The employee can see the full context being used for the decision, not just the engineer's reply. Skipped the Original Request section because the employee wrote it themselves — would just clutter the thread. white-space:pre-wrap preserves multi-line findings/summaries that the engineer typed with line breaks. The two sections are visually distinct: findings in light blue (matching the email's "Our Reply" treatment), summary in light grey (matching "Summary for the Decision" in the email). Verified live on ticket #54: new message body has no <blockquote>, no data-o-mail-quote attribute, and contains both section headers with their content rendered inline. Bumps fusion_helpdesk_central to 19.0.2.4.2.
574 lines
25 KiB
Python
574 lines
25 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.
|
|
|
|
Mirrors the engagement email body so the chatter shows the same
|
|
context the owner is reading: the engineer's reply (findings)
|
|
and the summary that's being used for the decision. We skip the
|
|
Original Request section that's in the email — the employee
|
|
filed the ticket, they already know what they asked for.
|
|
|
|
Critically: uses styled <div>s instead of <blockquote>. Odoo
|
|
auto-flags <blockquote> content as "quoted" (data-o-mail-quote-
|
|
node="1") and the chatter UI then collapses it behind a "..."
|
|
widget — exactly the wrong UX here, since the findings + summary
|
|
are the load-bearing content, not throwaway quotation. Plain
|
|
divs with the same visual treatment render fully inline.
|
|
|
|
Posted with subtype_xmlid='mail.mt_comment' so it passes the
|
|
entech-side _public_messages filter (message_type='comment' +
|
|
non-internal subtype) and reaches the employee's My Tickets
|
|
inbox.
|
|
"""
|
|
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
|
|
# Section 1: our reply (the wizard's findings)
|
|
text = (findings or '').strip()
|
|
if text:
|
|
body += (
|
|
'<p style="margin:12px 0 4px 0;"><b>Our reply:</b></p>'
|
|
'<div style="margin:0; padding:10px 14px; '
|
|
'border-left:3px solid #c7dcfa; color:#1e3a5f; '
|
|
'background:#f4f8ff; border-radius:4px; '
|
|
'white-space:pre-wrap;">%s</div>'
|
|
) % escape(text)
|
|
# Section 2: the AI summary the owner is seeing for the decision
|
|
summary = (self.x_fc_ai_summary or '').strip()
|
|
if summary:
|
|
body += (
|
|
'<p style="margin:14px 0 4px 0;">'
|
|
'<b>Summary sent to the owner:</b></p>'
|
|
'<div style="margin:0; padding:10px 14px; '
|
|
'border-left:3px solid #e5e7eb; color:#444; '
|
|
'background:#f9fafb; border-radius:4px; '
|
|
'white-space:pre-wrap;">%s</div>'
|
|
) % escape(summary)
|
|
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,
|
|
)
|