Ships the design spec at docs/superpowers/specs/2026-05-27-owner-approval-flow-design.md. What's new on central (fusion_helpdesk_central 19.0.1.2.0 -> 19.0.2.0.0): - Engagement model: 8 new fields on helpdesk.ticket (state, snapshotted owner email/name, single-use UUID4 token, sent/reminded/decided timestamps, AI summary, stored-computed turnaround hours). - Wizard: single + bulk modes on one fusion.helpdesk.engagement.wizard TransientModel with a child wizard.line for per-ticket bulk summaries. default_get pulls the OpenAI summary on open; AI fan-out for bulk is parallel via ThreadPoolExecutor (max 5 workers, 30s overall cap). - OpenAI client in utils.py — stdlib urllib, 15s per-call timeout, every failure collapses to '' so the wizard's manual-summary fallback fires. - Public portal: /fusion_helpdesk/engagement/<token>/<decision> GET + POST, four branded standalone QWeb pages (confirm/done/invalid/error). Token is single-use, cleared on confirm. Decision posts a public comment attributed to the resolved owner partner; chatter propagates to the employee's My Tickets thread per the "fully visible" UX choice. - Mail templates (single + bulk) with magic-link buttons. Bulk template renders one card per ticket, each with its own approve/reject URL. - Reminder cron: daily, single-shot per engagement, configurable via fusion_helpdesk_central.engagement_reminder_days ICP (default 3, 0 disables). - Reporting dashboard: pivot/graph/list/kanban over helpdesk.ticket filtered to engaged ones, with avg-turnaround measure. Menu lives under Helpdesk > Reporting > Owner Engagements. - Client_key extended with owner_email/owner_name fields; ticket.create upserts them from the client-side piggyback (no new sync endpoint). - 100% coverage on utils + integration tests on wizard, controllers, re-engagement, cron, computed turnaround. OpenAI mocked in CI. What's new on client (fusion_helpdesk 19.0.1.7.1 -> 19.0.2.0.0): - Two new ICP settings: fusion_helpdesk.owner_email / .owner_name with a new "Owner Approval" block in Settings > Fusion Helpdesk. - controllers/main.py::submit piggybacks both keys on every ticket payload so central keeps client_key.owner_email/name fresh automatically. Verified live end-to-end on entech -> nexa: payload upsert, wizard with mocked AI, action_send, portal GET/POST/GET-again cycle, second click hits the friendly invalid-token page. Token entropy = 122 bits (UUID4).
381 lines
16 KiB
Python
381 lines
16 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.',
|
|
)
|
|
ai_summary = fields.Text(
|
|
string='AI Summary',
|
|
help='OpenAI-generated brief. Edit before sending if you want to '
|
|
'tweak the framing. Empty? The wizard fell back to manual — '
|
|
'type your own brief, send normally.',
|
|
)
|
|
|
|
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 {}
|
|
ticket_id = ctx.get('default_ticket_id') or ctx.get('active_id')
|
|
ticket_ids = ctx.get('default_ticket_ids') or ctx.get('active_ids') or []
|
|
active_model = ctx.get('active_model')
|
|
|
|
# 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):
|
|
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)
|
|
summary = self._generate_summary(ticket)
|
|
vals.update({
|
|
'ticket_id': ticket.id,
|
|
'ai_summary': summary,
|
|
'ai_unavailable': not bool(summary),
|
|
})
|
|
return vals
|
|
|
|
def _default_get_bulk(self, vals, ticket_ids):
|
|
tickets = self.env['helpdesk.ticket'].browse(ticket_ids).exists()
|
|
self._validate_bulk_targets(tickets)
|
|
# One summary per ticket, fanned out in parallel so the modal doesn't
|
|
# block for N * 15s. If the fan-out itself times out we still open
|
|
# the wizard — the user just has to fill in summaries manually.
|
|
summaries = self._generate_summaries_parallel(tickets)
|
|
any_ok = any(s for s in summaries.values())
|
|
vals.update({
|
|
'ticket_ids': [(6, 0, tickets.ids)],
|
|
'line_ids': [
|
|
(0, 0, {'ticket_id': t.id,
|
|
'ai_summary': summaries.get(t.id, '')})
|
|
for t in tickets
|
|
],
|
|
'ai_unavailable': not any_ok,
|
|
})
|
|
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):
|
|
"""Single-ticket summary. Returns '' on any failure — the wizard
|
|
treats empty as "AI unavailable" and shows the manual fallback."""
|
|
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))
|
|
return call_openai_chat(api_key, model, prompt)
|
|
|
|
def _generate_summaries_parallel(self, tickets):
|
|
"""{ticket_id: summary_or_empty} for the bulk wizard.
|
|
|
|
Submits N calls in parallel via a thread pool. Each call has its own
|
|
15s timeout; the whole batch is capped at _BULK_AI_TIMEOUT so a slow
|
|
single call doesn't hold up the rest. Anything still pending at the
|
|
cap returns ''."""
|
|
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 {t.id: '' for t in tickets}
|
|
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 t in tickets:
|
|
name, desc, msgs = self._summary_inputs(t)
|
|
inputs[t.id] = truncate_for_openai(
|
|
build_summary_prompt(name, desc, msgs))
|
|
|
|
results = {t.id: '' for t in tickets}
|
|
with concurrent.futures.ThreadPoolExecutor(
|
|
max_workers=_BULK_AI_WORKERS) as pool:
|
|
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,
|
|
)
|
|
return results
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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,
|
|
)
|
|
ai_summary = fields.Text(
|
|
string='AI Summary',
|
|
help='Per-ticket summary — edit before send.',
|
|
)
|