Files
Odoo-Modules/fusion_helpdesk_central/controllers/engagement.py
gsinghpal 396170b438 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).
2026-05-27 13:03:23 -04:00

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