Files
Odoo-Modules/fusion_helpdesk_central/models/helpdesk_ticket.py
gsinghpal 32c7026558 feat(fusion_helpdesk_central): owner email shows 3 sections — Request / Reply / Summary
The owner only saw the AI summary, which was a paraphrase of the user
report — they couldn't see the actual request OR what we said back.
Restructure the engagement email into three sections so the owner can
read the conversation and not just the AI's take:

  1. Original Request (from the reporter) — ticket.description, no
     longer buried in a <details> collapsible at the bottom
  2. Our Reply — the wizard's "Your Findings" text, now persisted on
     the ticket so the email template can render it directly. This is
     the engineer's analysis / response to the request.
  3. Summary for the Decision — the AI-generated brief

Approve / Reject buttons stay below all three. Bulk email mirrors the
same per-card structure.

New ticket field x_fc_engagement_findings (Text, copy=False) stores
the findings at send-time so they survive as audit history. Wizard's
_action_send_single / _action_send_bulk pass findings into
_fc_reset_engagement; bulk uses per-line findings + per-line summary.

Mail templates are in <data noupdate="1"> so a plain -u doesn't
re-import them. Pre-migration in migrations/19.0.2.4.0/pre-migration.py
deletes the existing template records + ir_model_data so the upgrade's
data load re-creates them with the new body_html. Pre- (not post-)
because data load happens between the two phases.

Smoke-tested live on nexa: rendered template HTML contains all three
section headers at the expected positions with their expected content
markers (ORIGINAL FROM RIYA in Original Request, REPLY-FROM-GURPREET
in Our Reply, the summary text in Summary for the Decision).

Bumps fusion_helpdesk_central to 19.0.2.4.0.
2026-05-27 15:26:26 -04:00

510 lines
22 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.
"""
self.ensure_one()
normalised = email_normalize(owner_email or '') or (owner_email or '')
self.write({
'x_fc_engagement_state': 'pending',
'x_fc_engagement_email': normalised,
'x_fc_engagement_name': (owner_name or '').strip(),
'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 '',
})
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,
)