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).
148 lines
6.0 KiB
Python
148 lines
6.0 KiB
Python
# -*- 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
|