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:
gsinghpal
2026-05-27 13:03:23 -04:00
parent eb186cac3c
commit 396170b438
24 changed files with 2346 additions and 7 deletions

View File

@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
from . import fusion_helpdesk_client_key
from . import helpdesk_ticket
from . import engagement_wizard

View 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.',
)

View File

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

View File

@@ -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).