Files
Odoo-Modules/fusion_helpdesk_central/models/engagement_wizard.py
gsinghpal 0104e87750 fix(fusion_helpdesk_central): Generate Summary crashed wizard with self-id collision
Repro: open the engagement wizard on a ticket, write findings, click
'Generate Summary from Findings'. Notification: "Ticket N no longer
exists" and the whole dialog closes — even though the ticket clearly
exists in the DB.

Root cause was two compounding bugs:

1. action_generate_summary returned an act_window dict with
   res_id=self.id to "stay open after writing the summary field". The
   web client honoured that by opening a NEW act_window — and the new
   action's context inherited active_id=<wizard_id> (because that's
   the res_id of the action being opened). Wizard ids are not ticket
   ids, but our default_get didn't know the difference.

2. default_get read ctx.get('active_id') unconditionally, without
   first checking ctx.get('active_model') == 'helpdesk.ticket'. So
   when active_id pointed at the wizard's own id, default_get fed
   that to _default_get_single, which raised "Ticket <wizard_id> no
   longer exists" — and the user saw a confusing error about a
   ticket that obviously DID exist (just not with that id).

Two fixes:

(a) action_generate_summary + action_generate_all_summaries now
    return True. The form field write is visible to the client via
    the call response; the wizard re-renders with the new
    ai_summary populated. No spurious navigation, no context
    pollution.

(b) default_get only consults active_id / active_ids when
    active_model is helpdesk.ticket. Explicit
    default_ticket_id[s] context keys still take precedence and
    aren't gated by active_model (they're the caller's strong
    signal).

Verified live: opening the wizard with active_id=99999 and NO
active_model no longer raises 'Ticket 99999 no longer exists' —
just creates the wizard cleanly. The normal flow (default_ticket_id
+ active_model='helpdesk.ticket') still works as before.

Bumps fusion_helpdesk_central to 19.0.2.3.3.
2026-05-27 14:30:53 -04:00

473 lines
21 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 True (no navigation) so the
wizard stays open and the form view re-renders with the new
summary populated. DO NOT return an `act_window` with
`res_id=self.id` here — that puts the WIZARD's id into the new
action's `active_id`, which then collides with default_get's
ticket lookup and raises "Ticket <wizard_id> no longer exists".
"""
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 True
def action_generate_all_summaries(self):
"""Bulk mode: fire OpenAI per-ticket in parallel using each line's
own findings. Lines that already have a non-empty ai_summary get
regenerated too (the user clicked the button — they meant it).
Returns True for the same reason as action_generate_summary:
keep the dialog open; don't re-navigate.
"""
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 True
# ------------------------------------------------------------------
# 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 '')
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.'))
summary_by_id = {line.ticket_id.id: line.ai_summary or ''
for line in self.line_ids}
for ticket in self.ticket_ids:
ticket._fc_reset_engagement(
email, name, summary_by_id.get(ticket.id, ''),
)
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.',
)