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).
395 lines
17 KiB
Python
395 lines
17 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2026 Nexa Systems Inc.
|
|
# License OPL-1
|
|
"""Central-side helpdesk.ticket extensions for the customer follow-up flow
|
|
and the owner-approval engagement flow.
|
|
|
|
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.exceptions import UserError
|
|
from odoo.tools import email_normalize
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class HelpdeskTicket(models.Model):
|
|
_inherit = 'helpdesk.ticket'
|
|
|
|
x_fc_client_label = fields.Char(
|
|
string='Client Deployment', index=True, copy=False,
|
|
help='Deployment tag (e.g. ENTECH) set by the fusion_helpdesk in-app '
|
|
'reporter. Scopes the embedded "My Tickets" inbox per client and '
|
|
'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).
|
|
|
|
Scoped to tickets carrying `x_fc_client_label` so support staff who
|
|
manually set priority='3' on their own internal tickets aren't
|
|
silently tagged. Best-effort: if the data XML hasn't loaded the tag
|
|
yet (e.g. partial install), skip without raising — the ticket is
|
|
already filed with priority='3' which is the load-bearing signal."""
|
|
critical = self.filtered(
|
|
lambda t: t.priority == '3' and t.x_fc_client_label
|
|
)
|
|
if not critical:
|
|
return
|
|
tag = self.env.ref(
|
|
'fusion_helpdesk_central.tag_critical', raise_if_not_found=False,
|
|
)
|
|
if not tag:
|
|
_logger.warning(
|
|
'fusion_helpdesk_central: tag_critical not found, skipping '
|
|
'auto-tag on %s critical ticket(s).', len(critical),
|
|
)
|
|
return
|
|
critical.write({'tag_ids': [(4, tag.id)]})
|
|
|
|
def _fc_send_ack_email(self):
|
|
"""Send the branded acknowledgement (with magic link) to the customer.
|
|
|
|
Only fires for in-app-channel tickets (those tagged with a client
|
|
label) that have a customer email — external web-form submissions
|
|
rely on the native website confirmation, so this won't double-send.
|
|
The whole thing is best-effort: a template/mail failure must never
|
|
block ticket creation, so we log and move on.
|
|
"""
|
|
template = self.env.ref(
|
|
'fusion_helpdesk_central.mail_template_ticket_ack',
|
|
raise_if_not_found=False,
|
|
)
|
|
if not template:
|
|
return
|
|
for ticket in self:
|
|
if not (ticket.x_fc_client_label and ticket.partner_email):
|
|
continue
|
|
try:
|
|
template.send_mail(ticket.id, force_send=False)
|
|
except Exception: # noqa: BLE001 — ack must never block create
|
|
_logger.exception(
|
|
'fusion_helpdesk_central: acknowledgement email failed '
|
|
'for ticket %s (%s)', ticket.id, ticket.x_fc_client_label,
|
|
)
|