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:
2
fusion_helpdesk_central/controllers/__init__.py
Normal file
2
fusion_helpdesk_central/controllers/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import engagement
|
||||
147
fusion_helpdesk_central/controllers/engagement.py
Normal file
147
fusion_helpdesk_central/controllers/engagement.py
Normal file
@@ -0,0 +1,147 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
"""Public portal routes for the owner-approval magic links.
|
||||
|
||||
Owner clicks the Approve / Reject button in the email -> GET lands here.
|
||||
Page shows ticket title + AI summary + comment box + Confirm button.
|
||||
POST records the decision via helpdesk.ticket._fc_finalize_engagement.
|
||||
|
||||
No login required; the UUID4 in the URL is the auth. Tokens are single-
|
||||
use (cleared on finalize), so the second click on the same link shows a
|
||||
friendly "link no longer valid" page instead of double-recording the
|
||||
decision.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from odoo import _, http
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionHelpdeskEngagementController(http.Controller):
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Token resolution — single source of truth for the GET + POST handlers.
|
||||
# ------------------------------------------------------------------
|
||||
def _resolve(self, token, decision):
|
||||
"""Return (ticket, decision_state) or (None, None) on any problem.
|
||||
|
||||
The "no problem" cases:
|
||||
- token is non-empty
|
||||
- decision is one of {'approve', 'reject'}
|
||||
- a single ticket matches the token AND is in state='pending'
|
||||
|
||||
Anything else -> (None, None), caller renders the friendly
|
||||
"link no longer valid" page.
|
||||
"""
|
||||
if not token or not isinstance(token, str):
|
||||
return (None, None)
|
||||
if decision not in ('approve', 'reject'):
|
||||
return (None, None)
|
||||
decision_state = 'approved' if decision == 'approve' else 'rejected'
|
||||
ticket = request.env['helpdesk.ticket'].sudo().search(
|
||||
[('x_fc_engagement_token', '=', token),
|
||||
('x_fc_engagement_state', '=', 'pending')],
|
||||
limit=1,
|
||||
)
|
||||
if not ticket:
|
||||
return (None, None)
|
||||
return (ticket, decision_state)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET — render the confirmation page (or invalid-link page).
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(
|
||||
'/fusion_helpdesk/engagement/<string:token>/<string:decision>',
|
||||
type='http', auth='public', methods=['GET'], csrf=False, sitemap=False,
|
||||
)
|
||||
def engagement_show(self, token, decision, **kw):
|
||||
ticket, decision_state = self._resolve(token, decision)
|
||||
if not ticket:
|
||||
return request.render(
|
||||
'fusion_helpdesk_central.engagement_invalid', {},
|
||||
)
|
||||
return request.render(
|
||||
'fusion_helpdesk_central.engagement_confirm',
|
||||
{
|
||||
'ticket': ticket,
|
||||
'decision': decision, # url-friendly string
|
||||
'decision_state': decision_state, # 'approved' / 'rejected'
|
||||
'token': token,
|
||||
},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST — record the decision, post chatter, clear token.
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(
|
||||
'/fusion_helpdesk/engagement/<string:token>/<string:decision>',
|
||||
type='http', auth='public', methods=['POST'], csrf=False, sitemap=False,
|
||||
)
|
||||
def engagement_submit(self, token, decision, **post):
|
||||
ticket, decision_state = self._resolve(token, decision)
|
||||
if not ticket:
|
||||
# Could be a second click on the same link, or a token rotated
|
||||
# by a re-engagement, or a typo. Same friendly page for all.
|
||||
return request.render(
|
||||
'fusion_helpdesk_central.engagement_invalid', {},
|
||||
)
|
||||
comment = (post.get('comment') or '').strip()
|
||||
owner_partner = self._find_or_create_owner_partner(ticket)
|
||||
try:
|
||||
ticket._fc_finalize_engagement(
|
||||
decision_state, owner_partner, comment=comment or None,
|
||||
)
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
'fusion_helpdesk_central: failed to finalize engagement '
|
||||
'for ticket %s (token=%s, decision=%s)',
|
||||
ticket.id, token, decision_state,
|
||||
)
|
||||
return request.render(
|
||||
'fusion_helpdesk_central.engagement_error', {},
|
||||
)
|
||||
return request.render(
|
||||
'fusion_helpdesk_central.engagement_done',
|
||||
{
|
||||
'ticket': ticket,
|
||||
'decision_state': decision_state,
|
||||
},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _find_or_create_owner_partner(self, ticket):
|
||||
"""Resolve the res.partner used to attribute the chatter message.
|
||||
|
||||
Find-or-create by snapshotted email — mirrors the customer-reply
|
||||
attribution pattern in fusion_helpdesk/controllers/main.py so the
|
||||
approval chatter shows up under a proper partner name (matters for
|
||||
the employee's My Tickets thread per the "fully visible" UX).
|
||||
Falls back to no author (= bot user) if email is empty or the
|
||||
partner create fails.
|
||||
"""
|
||||
email = (ticket.x_fc_engagement_email or '').strip().lower()
|
||||
name = (ticket.x_fc_engagement_name or '').strip()
|
||||
if not email:
|
||||
return None
|
||||
Partner = request.env['res.partner'].sudo()
|
||||
# Use exact match on lowercased email — the snapshot was already
|
||||
# normalised at engagement time.
|
||||
partner = Partner.search([('email', '=ilike', email)],
|
||||
order='id asc', limit=1)
|
||||
if partner:
|
||||
return partner
|
||||
try:
|
||||
return Partner.create({
|
||||
'name': name or email.split('@')[0].title(),
|
||||
'email': email,
|
||||
})
|
||||
except Exception:
|
||||
_logger.warning(
|
||||
'fusion_helpdesk_central: could not create owner partner '
|
||||
'for %s on ticket %s; chatter will be attributed to the '
|
||||
'service account.', email, ticket.id,
|
||||
)
|
||||
return None
|
||||
Reference in New Issue
Block a user