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.
501 lines
22 KiB
Python
501 lines
22 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2026 Nexa Systems Inc.
|
|
# License OPL-1
|
|
"""Wizard that drives the owner-approval engagement flow.
|
|
|
|
Two modes, one wizard:
|
|
|
|
- Single-ticket: opened from the ticket form button. `ticket_id` is set,
|
|
`ticket_ids` is empty. One AI summary, one email, one engagement.
|
|
- Bulk: opened from the list-view server action. `ticket_ids` is set,
|
|
`ticket_id` is empty. One AI summary per ticket (via a child transient
|
|
model), one combined email with one card per ticket, each card with
|
|
its own approve/reject tokens.
|
|
|
|
The wizard generates the AI summary on `default_get` so the user sees a
|
|
ready-to-edit brief the moment the modal opens, then sends mail + writes
|
|
the engagement state on `action_send`.
|
|
"""
|
|
import concurrent.futures
|
|
import logging
|
|
|
|
from odoo import _, api, fields, models
|
|
from odoo.exceptions import UserError
|
|
from odoo.tools import html2plaintext
|
|
|
|
from odoo.addons.fusion_helpdesk_central.utils import (
|
|
build_summary_prompt,
|
|
call_openai_chat,
|
|
truncate_for_openai,
|
|
)
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
# Parallel OpenAI calls for bulk mode. Five is enough to keep the wizard
|
|
# snappy without slamming OpenAI's rate limits on a single deployment.
|
|
_BULK_AI_WORKERS = 5
|
|
_BULK_AI_TIMEOUT = 30 # seconds; overall cap for the parallel summary fan-out
|
|
|
|
|
|
class FusionHelpdeskEngagementWizard(models.TransientModel):
|
|
_name = 'fusion.helpdesk.engagement.wizard'
|
|
_description = 'Fusion Helpdesk — Owner Engagement Wizard'
|
|
|
|
# Mode: single vs bulk. The view branches on `mode`; it's a computed
|
|
# store=False field so we don't need to set it on default_get manually
|
|
# in every action.
|
|
mode = fields.Selection(
|
|
[('single', 'Single ticket'), ('bulk', 'Bulk')],
|
|
compute='_compute_mode', store=False,
|
|
)
|
|
ticket_id = fields.Many2one(
|
|
'helpdesk.ticket', string='Ticket', ondelete='cascade',
|
|
)
|
|
ticket_ids = fields.Many2many(
|
|
'helpdesk.ticket', string='Tickets (bulk)',
|
|
)
|
|
line_ids = fields.One2many(
|
|
'fusion.helpdesk.engagement.wizard.line', 'wizard_id',
|
|
string='Per-Ticket Summaries',
|
|
)
|
|
|
|
personal_note = fields.Char(
|
|
string='Personal Note',
|
|
help='One-line note from you, prepended above the AI summary in the '
|
|
'email body. Optional. Skip if the summary speaks for itself.',
|
|
)
|
|
findings = fields.Text(
|
|
string='Your Findings',
|
|
help='Your engineering analysis: scope, limitations, recommended '
|
|
'approach, effort, risks — anything the original reporter '
|
|
'would not have known. The AI weighs these MORE HEAVILY than '
|
|
'the ticket description when generating the owner summary. '
|
|
'Optional but strongly recommended: without it, the summary '
|
|
'is just the AI restating the user\'s report.',
|
|
)
|
|
ai_summary = fields.Text(
|
|
string='Summary to Send',
|
|
help='Brief shown to the owner in the approval email. Either '
|
|
'generated from your findings + ticket (click "Generate '
|
|
'Summary") or written by hand. Edit freely before sending.',
|
|
)
|
|
|
|
owner_email_display = fields.Char(
|
|
string='Owner Email', compute='_compute_owner_display', store=False,
|
|
)
|
|
owner_name_display = fields.Char(
|
|
string='Owner Name', compute='_compute_owner_display', store=False,
|
|
)
|
|
|
|
ai_unavailable = fields.Boolean(
|
|
string='AI unavailable', store=False,
|
|
help='True when OpenAI returned no summary on wizard open. The view '
|
|
'shows a soft banner so the user knows to write a manual brief.',
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
@api.depends('ticket_id', 'ticket_ids')
|
|
def _compute_mode(self):
|
|
for w in self:
|
|
w.mode = 'bulk' if w.ticket_ids else 'single'
|
|
|
|
@api.depends('ticket_id', 'ticket_ids')
|
|
def _compute_owner_display(self):
|
|
for w in self:
|
|
ticket = w.ticket_id or (w.ticket_ids[:1] if w.ticket_ids else None)
|
|
if ticket:
|
|
email, name = ticket._fc_owner_contact()
|
|
else:
|
|
email, name = (False, False)
|
|
w.owner_email_display = email or ''
|
|
w.owner_name_display = name or ''
|
|
|
|
# ------------------------------------------------------------------
|
|
# Wizard open: pull tickets from context, generate AI summary(ies).
|
|
# ------------------------------------------------------------------
|
|
@api.model
|
|
def default_get(self, fields_list):
|
|
vals = super().default_get(fields_list)
|
|
ctx = self.env.context or {}
|
|
active_model = ctx.get('active_model')
|
|
|
|
# ONLY trust active_id/active_ids when active_model says they're
|
|
# helpdesk tickets. Without this guard, opening the wizard via any
|
|
# act_window that left an unrelated active_id in context (e.g. a
|
|
# button on the wizard returning a re-open action whose res_id is
|
|
# the wizard's OWN id) silently reinterprets that id as a ticket
|
|
# — and `_default_get_single` then raises "Ticket N no longer
|
|
# exists" against the wizard's own id. The explicit
|
|
# `default_ticket_id[s]` context keys are still the strong signal.
|
|
ticket_id = ctx.get('default_ticket_id')
|
|
if not ticket_id and active_model == 'helpdesk.ticket':
|
|
ticket_id = ctx.get('active_id')
|
|
ticket_ids = ctx.get('default_ticket_ids') or []
|
|
if not ticket_ids and active_model == 'helpdesk.ticket':
|
|
ticket_ids = ctx.get('active_ids') or []
|
|
|
|
# Disambiguate single vs bulk by what the caller actually selected.
|
|
# The list-view server action passes active_ids; the form button
|
|
# passes a single active_id via a deliberate context key.
|
|
if ctx.get('fhc_bulk') and ticket_ids:
|
|
return self._default_get_bulk(vals, ticket_ids)
|
|
if active_model == 'helpdesk.ticket' and ticket_ids and not ticket_id:
|
|
# Edge: opened from list selection without our explicit context
|
|
# key. If exactly one, treat as single; otherwise bulk.
|
|
if len(ticket_ids) == 1:
|
|
ticket_id = ticket_ids[0]
|
|
else:
|
|
return self._default_get_bulk(vals, ticket_ids)
|
|
if ticket_id:
|
|
return self._default_get_single(vals, ticket_id)
|
|
return vals
|
|
|
|
def _default_get_single(self, vals, ticket_id):
|
|
# No AI on open — user writes findings first, then clicks
|
|
# "Generate Summary" to fire OpenAI. Summary starts empty so the
|
|
# view's Send button can be disabled until either Generate runs
|
|
# or the user types one manually.
|
|
ticket = self.env['helpdesk.ticket'].browse(ticket_id)
|
|
if not ticket.exists():
|
|
raise UserError(_('Ticket %s no longer exists.') % ticket_id)
|
|
self._validate_engagement_target(ticket)
|
|
vals.update({
|
|
'ticket_id': ticket.id,
|
|
'ai_summary': '',
|
|
'findings': '',
|
|
'ai_unavailable': False,
|
|
})
|
|
return vals
|
|
|
|
def _default_get_bulk(self, vals, ticket_ids):
|
|
# Same as single: no AI on open. User fills findings per ticket
|
|
# then hits "Generate Summary" on each line (or "Generate All" —
|
|
# see action_generate_all_summaries).
|
|
tickets = self.env['helpdesk.ticket'].browse(ticket_ids).exists()
|
|
self._validate_bulk_targets(tickets)
|
|
vals.update({
|
|
'ticket_ids': [(6, 0, tickets.ids)],
|
|
'line_ids': [
|
|
(0, 0, {'ticket_id': t.id, 'findings': '', 'ai_summary': ''})
|
|
for t in tickets
|
|
],
|
|
'ai_unavailable': False,
|
|
})
|
|
return vals
|
|
|
|
# ------------------------------------------------------------------
|
|
# Validation gates — run BEFORE we waste an OpenAI call.
|
|
# ------------------------------------------------------------------
|
|
def _validate_engagement_target(self, ticket):
|
|
if not ticket.x_fc_client_label:
|
|
raise UserError(_(
|
|
'This ticket is not tagged with a client deployment, so the '
|
|
'central has no owner contact to send to. Owner-approval is '
|
|
'only available on in-app tickets.'
|
|
))
|
|
email, _name = ticket._fc_owner_contact()
|
|
if not email:
|
|
raise UserError(_(
|
|
'No owner contact configured for client "%s". Ask the client '
|
|
'to fill it in under Settings → Fusion Helpdesk → Owner '
|
|
'Approval, then file any ticket from that deployment so the '
|
|
'central learns the contact.'
|
|
) % (ticket.x_fc_client_label,))
|
|
|
|
def _validate_bulk_targets(self, tickets):
|
|
if not tickets:
|
|
raise UserError(_('No tickets selected.'))
|
|
labels = {t.x_fc_client_label for t in tickets}
|
|
labels.discard(False)
|
|
labels.discard('')
|
|
if len(labels) != 1:
|
|
raise UserError(_(
|
|
'Cannot bulk-engage tickets across different deployments — '
|
|
'one owner per engagement. Selected labels: %s.'
|
|
) % (', '.join(sorted(labels)) or '(none)'))
|
|
blockers = tickets.filtered(
|
|
lambda t: t.x_fc_engagement_state in ('pending', 'approved')
|
|
)
|
|
if blockers:
|
|
raise UserError(_(
|
|
'%(n)s of the selected tickets already have a pending or '
|
|
'approved engagement. Re-engage them individually from the '
|
|
'ticket form. Tickets: %(ids)s'
|
|
) % {'n': len(blockers),
|
|
'ids': ', '.join('#%s' % t.id for t in blockers)})
|
|
# Reuse single validation for the owner-contact check (any one
|
|
# ticket suffices since they share the same client_label).
|
|
self._validate_engagement_target(tickets[0])
|
|
|
|
# ------------------------------------------------------------------
|
|
# AI summary generation
|
|
# ------------------------------------------------------------------
|
|
def _summary_inputs(self, ticket):
|
|
"""Pull the data we feed to OpenAI: title + plain-text description +
|
|
plain-text public messages (oldest first). Internal notes excluded."""
|
|
msgs = self.env['mail.message'].search([
|
|
('model', '=', 'helpdesk.ticket'),
|
|
('res_id', '=', ticket.id),
|
|
('message_type', 'in', ('comment', 'email')),
|
|
('subtype_id.internal', '=', False),
|
|
], order='id asc')
|
|
msg_data = [{
|
|
'author': m.author_id.name or m.email_from or 'unknown',
|
|
'date': fields.Datetime.to_string(m.date) if m.date else '',
|
|
'body_plain': html2plaintext(m.body or '') or '',
|
|
} for m in msgs]
|
|
return (
|
|
ticket.name or '',
|
|
html2plaintext(ticket.description or '') or '',
|
|
msg_data,
|
|
)
|
|
|
|
def _generate_summary(self, ticket, findings=''):
|
|
"""Single-ticket summary. Returns '' on any failure — the wizard
|
|
treats empty as "AI unavailable" and shows the manual fallback.
|
|
|
|
`findings` is the user's free-text analysis from the wizard. The
|
|
prompt explicitly tells the model to weight it more than the
|
|
original user report — see SUMMARY_PROMPT in utils.py."""
|
|
ICP = self.env['ir.config_parameter'].sudo()
|
|
api_key = (ICP.get_param(
|
|
'fusion_helpdesk_central.openai_api_key') or '').strip()
|
|
if not api_key:
|
|
return ''
|
|
model = (ICP.get_param(
|
|
'fusion_helpdesk_central.openai_model') or 'gpt-4o-mini').strip()
|
|
name, desc, msgs = self._summary_inputs(ticket)
|
|
prompt = truncate_for_openai(
|
|
build_summary_prompt(name, desc, msgs, findings=findings)
|
|
)
|
|
return call_openai_chat(api_key, model, prompt)
|
|
|
|
def _generate_summaries_parallel(self, ticket_findings):
|
|
"""{ticket_id: summary_or_empty} for the bulk wizard.
|
|
|
|
`ticket_findings` is a dict {ticket_id: (ticket_recordset, findings_str)}
|
|
so each parallel call uses its own per-ticket findings.
|
|
"""
|
|
ICP = self.env['ir.config_parameter'].sudo()
|
|
api_key = (ICP.get_param(
|
|
'fusion_helpdesk_central.openai_api_key') or '').strip()
|
|
if not api_key:
|
|
return {tid: '' for tid in ticket_findings}
|
|
model = (ICP.get_param(
|
|
'fusion_helpdesk_central.openai_model') or 'gpt-4o-mini').strip()
|
|
|
|
# Build inputs serially (DB-bound, fast) before fanning out the
|
|
# HTTP calls in parallel.
|
|
inputs = {}
|
|
for tid, (ticket, findings) in ticket_findings.items():
|
|
name, desc, msgs = self._summary_inputs(ticket)
|
|
inputs[tid] = truncate_for_openai(
|
|
build_summary_prompt(name, desc, msgs, findings=findings))
|
|
|
|
results = {tid: '' for tid in ticket_findings}
|
|
# Cancel pending futures on overall timeout so a slow OpenAI day
|
|
# doesn't block the wizard for ceil(N/workers) * 15s.
|
|
pool = concurrent.futures.ThreadPoolExecutor(
|
|
max_workers=_BULK_AI_WORKERS)
|
|
try:
|
|
futures = {
|
|
pool.submit(call_openai_chat, api_key, model, p): tid
|
|
for tid, p in inputs.items()
|
|
}
|
|
try:
|
|
for fut in concurrent.futures.as_completed(
|
|
futures, timeout=_BULK_AI_TIMEOUT):
|
|
tid = futures[fut]
|
|
try:
|
|
results[tid] = fut.result() or ''
|
|
except Exception as e: # noqa: BLE001 — log + continue
|
|
_logger.warning(
|
|
'fusion_helpdesk_central: bulk AI summary for '
|
|
'ticket %s failed: %s', tid, e,
|
|
)
|
|
except concurrent.futures.TimeoutError:
|
|
_logger.warning(
|
|
'fusion_helpdesk_central: bulk AI summary fan-out timed '
|
|
'out after %ss; remaining tickets will get empty '
|
|
'summaries.', _BULK_AI_TIMEOUT,
|
|
)
|
|
finally:
|
|
# py3.9+: cancel_futures stops queued tasks from starting;
|
|
# in-flight urlopen calls still finish their 15s per-call cap
|
|
# and drop their result on the floor.
|
|
pool.shutdown(wait=False, cancel_futures=True)
|
|
return results
|
|
|
|
# ------------------------------------------------------------------
|
|
# User-driven AI trigger — explicit "Generate" buttons in the view.
|
|
# ------------------------------------------------------------------
|
|
def action_generate_summary(self):
|
|
"""Single mode: fire OpenAI with the current findings, drop the
|
|
result into ai_summary. Returns an act_window dict that re-opens
|
|
the SAME wizard record — the only Odoo-19 idiom that reliably
|
|
keeps a target='new' wizard dialog open after a button click.
|
|
Returning True/None auto-closes the modal (Odoo's "wizard done"
|
|
convention).
|
|
|
|
The previous self-id collision bug (where active_id of the
|
|
re-opened action was the wizard's own id, which default_get
|
|
mis-read as a ticket id) is now prevented by default_get's
|
|
active_model=='helpdesk.ticket' guard.
|
|
"""
|
|
self.ensure_one()
|
|
if self.mode != 'single' or not self.ticket_id:
|
|
raise UserError(_(
|
|
'Generate Summary only works in single-ticket mode. Use '
|
|
'the per-line Generate buttons on the bulk wizard.'
|
|
))
|
|
summary = self._generate_summary(
|
|
self.ticket_id, findings=self.findings or '',
|
|
)
|
|
self.ai_summary = summary
|
|
self.ai_unavailable = not bool(summary)
|
|
return self._reopen_action()
|
|
|
|
def action_generate_all_summaries(self):
|
|
"""Bulk mode: fire OpenAI per-ticket in parallel using each line's
|
|
own findings. Returns an act_window dict to keep the dialog open
|
|
(see action_generate_summary for why we can't just return True).
|
|
"""
|
|
self.ensure_one()
|
|
if self.mode != 'bulk' or not self.line_ids:
|
|
raise UserError(_(
|
|
'Generate All only works in bulk mode.'
|
|
))
|
|
ticket_findings = {
|
|
line.ticket_id.id: (line.ticket_id, line.findings or '')
|
|
for line in self.line_ids
|
|
}
|
|
results = self._generate_summaries_parallel(ticket_findings)
|
|
for line in self.line_ids:
|
|
line.ai_summary = results.get(line.ticket_id.id, '')
|
|
any_ok = any(results.values())
|
|
self.ai_unavailable = not any_ok
|
|
return self._reopen_action()
|
|
|
|
def _reopen_action(self):
|
|
"""Return the act_window dict that re-opens the same wizard record.
|
|
|
|
Passes an EXPLICITLY EMPTY context override so the parent ticket
|
|
form's active_id / active_model don't leak in and confuse any
|
|
future default_get call. The wizard record is loaded directly by
|
|
res_id; default_get won't be called for a load (Odoo 19 only
|
|
invokes it when creating new records), but the empty context is
|
|
belt-and-suspenders against future regressions.
|
|
"""
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _('Request Owner Approval'),
|
|
'res_model': self._name,
|
|
'res_id': self.id,
|
|
'view_mode': 'form',
|
|
'target': 'new',
|
|
'context': {},
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Send: write engagement state + queue mail
|
|
# ------------------------------------------------------------------
|
|
def action_send(self):
|
|
self.ensure_one()
|
|
if self.mode == 'bulk':
|
|
return self._action_send_bulk()
|
|
return self._action_send_single()
|
|
|
|
def _action_send_single(self):
|
|
ticket = self.ticket_id
|
|
if not ticket:
|
|
raise UserError(_('Wizard has no ticket attached.'))
|
|
# Re-resolve owner from client_key in case it changed between
|
|
# default_get and Send (small window, but the source of truth wins).
|
|
email, name = ticket._fc_owner_contact()
|
|
if not email:
|
|
raise UserError(_('Owner contact disappeared since you opened '
|
|
'the wizard. Refresh and try again.'))
|
|
ticket._fc_reset_engagement(
|
|
email, name, self.ai_summary or '',
|
|
findings=self.findings or '',
|
|
)
|
|
template = self.env.ref(
|
|
'fusion_helpdesk_central.mail_template_engagement',
|
|
raise_if_not_found=False,
|
|
)
|
|
if not template:
|
|
raise UserError(_('Engagement mail template not found — was '
|
|
'fusion_helpdesk_central installed cleanly?'))
|
|
# Pass the personal note + is_reminder=False into the template's
|
|
# rendering context. Use `with_context(**data)` per CLAUDE.md —
|
|
# `ctx=...` won't reach the template body.
|
|
template.with_context(
|
|
fhc_personal_note=self.personal_note or '',
|
|
fhc_is_reminder=False,
|
|
).send_mail(ticket.id, force_send=False)
|
|
return {'type': 'ir.actions.act_window_close'}
|
|
|
|
def _action_send_bulk(self):
|
|
if not self.ticket_ids:
|
|
raise UserError(_('Wizard has no tickets attached.'))
|
|
# Snapshot owner from the first ticket — bulk is locked to a single
|
|
# client_label by validation, so they all share an owner.
|
|
email, name = self.ticket_ids[0]._fc_owner_contact()
|
|
if not email:
|
|
raise UserError(_('Owner contact disappeared since you opened '
|
|
'the wizard. Refresh and try again.'))
|
|
by_id = {
|
|
line.ticket_id.id: (line.ai_summary or '', line.findings or '')
|
|
for line in self.line_ids
|
|
}
|
|
for ticket in self.ticket_ids:
|
|
summary, findings = by_id.get(ticket.id, ('', ''))
|
|
ticket._fc_reset_engagement(
|
|
email, name, summary, findings=findings,
|
|
)
|
|
template = self.env.ref(
|
|
'fusion_helpdesk_central.mail_template_engagement_bulk',
|
|
raise_if_not_found=False,
|
|
)
|
|
if not template:
|
|
raise UserError(_('Bulk engagement mail template not found — '
|
|
'was fusion_helpdesk_central installed cleanly?'))
|
|
# The bulk template renders once against the FIRST ticket but reads
|
|
# the full set from context. Each ticket already has its own token
|
|
# (snapped above), so the template iterates self.env['helpdesk.ticket']
|
|
# by the ids we pass in.
|
|
template.with_context(
|
|
fhc_personal_note=self.personal_note or '',
|
|
fhc_is_reminder=False,
|
|
fhc_bulk_ticket_ids=self.ticket_ids.ids,
|
|
).send_mail(self.ticket_ids[0].id, force_send=False)
|
|
return {'type': 'ir.actions.act_window_close'}
|
|
|
|
|
|
class FusionHelpdeskEngagementWizardLine(models.TransientModel):
|
|
_name = 'fusion.helpdesk.engagement.wizard.line'
|
|
_description = 'Fusion Helpdesk — Per-Ticket Bulk Engagement Line'
|
|
|
|
wizard_id = fields.Many2one(
|
|
'fusion.helpdesk.engagement.wizard', required=True,
|
|
ondelete='cascade',
|
|
)
|
|
ticket_id = fields.Many2one(
|
|
'helpdesk.ticket', required=True, ondelete='cascade',
|
|
)
|
|
ticket_name = fields.Char(
|
|
related='ticket_id.name', readonly=True,
|
|
)
|
|
findings = fields.Text(
|
|
string='Your Findings',
|
|
help='Per-ticket engineering findings. The Generate button on this '
|
|
'line uses these as the most authoritative input for the AI '
|
|
'summary.',
|
|
)
|
|
ai_summary = fields.Text(
|
|
string='Summary to Send',
|
|
help='Per-ticket summary — generated from findings + ticket, or '
|
|
'written by hand. Edit before send.',
|
|
)
|