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,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