feat(fusion_helpdesk): owner-approval engagement flow + AI summary + reporting
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).
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import fusion_helpdesk_client_key
|
||||
from . import helpdesk_ticket
|
||||
from . import engagement_wizard
|
||||
|
||||
380
fusion_helpdesk_central/models/engagement_wizard.py
Normal file
380
fusion_helpdesk_central/models/engagement_wizard.py
Normal file
@@ -0,0 +1,380 @@
|
||||
# -*- 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.',
|
||||
)
|
||||
@@ -33,6 +33,24 @@ class FusionHelpdeskClientKey(models.Model):
|
||||
string='Notes',
|
||||
help='Optional. Stamp deployment URL, contact, install date.',
|
||||
)
|
||||
# Owner contact for the engagement / approval flow. Auto-refreshed
|
||||
# from each incoming ticket's payload (see helpdesk_ticket.create
|
||||
# override) so support always has the current owner without manual
|
||||
# sync. Manual overrides on this row stick until the next ticket
|
||||
# carries different values.
|
||||
owner_email = fields.Char(
|
||||
string='Owner Email',
|
||||
help='Email of the client\'s real decision-maker (the person paying '
|
||||
'the bill, not the Odoo "Manager" role). Used to send approval '
|
||||
'requests when central support hits a feature that needs '
|
||||
'sign-off. Auto-populated from the client\'s entech settings '
|
||||
'on every ticket submission.',
|
||||
)
|
||||
owner_name = fields.Char(
|
||||
string='Owner Name',
|
||||
help='Display name for the owner — used in email greeting and the '
|
||||
'chatter attribution when they approve / reject.',
|
||||
)
|
||||
bot_user_id = fields.Many2one(
|
||||
'res.users', string='Bot User', readonly=True,
|
||||
ondelete='restrict',
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
"""Central-side helpdesk.ticket extensions for the customer follow-up flow.
|
||||
"""Central-side helpdesk.ticket extensions for the customer follow-up flow
|
||||
and the owner-approval engagement flow.
|
||||
|
||||
Adds the `x_fc_client_label` deployment tag (set by the in-app reporter so
|
||||
the embedded inbox can scope per client) and sends a branded acknowledgement
|
||||
email — carrying the portal magic link — when an in-app ticket is created.
|
||||
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 import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools import email_normalize
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -24,13 +37,311 @@ class HelpdeskTicket(models.Model):
|
||||
'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_turnaround_hours = fields.Float(
|
||||
string='Owner Turnaround (h)',
|
||||
compute='_compute_engagement_turnaround',
|
||||
store=True, copy=False, digits=(8, 2),
|
||||
help='Hours between engagement-sent and owner decision. Stored so '
|
||||
'the Owner Engagements pivot can aggregate without recomputing.',
|
||||
)
|
||||
|
||||
# message_post-friendly index for the reminder cron + token resolution.
|
||||
_engagement_state_idx = models.Index(
|
||||
'(x_fc_engagement_state, x_fc_engagement_sent_at)'
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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):
|
||||
"""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.
|
||||
"""
|
||||
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 '',
|
||||
})
|
||||
|
||||
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()
|
||||
for ticket in stale:
|
||||
try:
|
||||
template.with_context(
|
||||
fhc_is_reminder=True,
|
||||
fhc_personal_note='',
|
||||
).send_mail(ticket.id, force_send=False)
|
||||
ticket.x_fc_engagement_reminded_at = now
|
||||
except Exception: # noqa: BLE001 — reminder must never break cron loop
|
||||
_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).',
|
||||
len(stale),
|
||||
)
|
||||
return len(stale)
|
||||
|
||||
def _fc_finalize_engagement(self, decision, owner_partner, comment=None):
|
||||
"""Apply the owner's decision: post chatter (public), clear token,
|
||||
write state + decided_at. Called from the public portal controller
|
||||
after a magic link is clicked + confirmed.
|
||||
|
||||
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(),
|
||||
'x_fc_engagement_token': False,
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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).
|
||||
|
||||
Reference in New Issue
Block a user