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

@@ -0,0 +1,143 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1
Reporting dashboard for owner engagements. No new model — every view
is over helpdesk.ticket filtered to records where engagement_state is
not 'none'. The stored computed field x_fc_engagement_turnaround_hours
makes the pivot's average measure free.
-->
<odoo>
<!-- Pivot: rows = client, columns = state, measure = count + avg turnaround. -->
<record id="view_engagement_pivot" model="ir.ui.view">
<field name="name">fhc.engagement.pivot</field>
<field name="model">helpdesk.ticket</field>
<field name="arch" type="xml">
<pivot string="Owner Engagements" sample="1">
<field name="x_fc_client_label" type="row"/>
<field name="x_fc_engagement_state" type="col"/>
<field name="x_fc_engagement_turnaround_hours" type="measure"/>
</pivot>
</field>
</record>
<record id="view_engagement_graph" model="ir.ui.view">
<field name="name">fhc.engagement.graph</field>
<field name="model">helpdesk.ticket</field>
<field name="arch" type="xml">
<graph string="Owner Engagements" type="bar" sample="1">
<field name="x_fc_engagement_sent_at" interval="month"/>
<field name="x_fc_client_label" type="col"/>
</graph>
</field>
</record>
<record id="view_engagement_list" model="ir.ui.view">
<field name="name">fhc.engagement.list</field>
<field name="model">helpdesk.ticket</field>
<field name="arch" type="xml">
<list string="Owner Engagements" create="0" sample="1">
<field name="ticket_ref" optional="show"/>
<field name="name"/>
<field name="x_fc_client_label" string="Client"/>
<field name="x_fc_engagement_name" string="Owner"/>
<field name="x_fc_engagement_email" string="Owner Email" optional="show"/>
<field name="x_fc_engagement_state" widget="badge"
decoration-warning="x_fc_engagement_state == 'pending'"
decoration-success="x_fc_engagement_state == 'approved'"
decoration-danger="x_fc_engagement_state == 'rejected'"/>
<field name="x_fc_engagement_sent_at"/>
<field name="x_fc_engagement_reminded_at" optional="hide"/>
<field name="x_fc_engagement_decided_at" optional="show"/>
<field name="x_fc_engagement_turnaround_hours" string="Turnaround (h)"/>
</list>
</field>
</record>
<record id="view_engagement_kanban" model="ir.ui.view">
<field name="name">fhc.engagement.kanban</field>
<field name="model">helpdesk.ticket</field>
<field name="arch" type="xml">
<kanban default_group_by="x_fc_engagement_state" create="0"
sample="1" group_create="0" group_edit="0" group_delete="0">
<field name="name"/>
<field name="x_fc_client_label"/>
<field name="x_fc_engagement_name"/>
<field name="x_fc_engagement_sent_at"/>
<field name="x_fc_engagement_turnaround_hours"/>
<templates>
<t t-name="card">
<div class="o_kanban_record_top">
<strong><field name="name"/></strong>
</div>
<div>
<span class="text-muted"><field name="x_fc_client_label"/></span>
·
<field name="x_fc_engagement_name"/>
</div>
<div class="text-muted small">
sent <field name="x_fc_engagement_sent_at"/>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="view_engagement_search" model="ir.ui.view">
<field name="name">fhc.engagement.search</field>
<field name="model">helpdesk.ticket</field>
<field name="arch" type="xml">
<search string="Owner Engagements">
<field name="name"/>
<field name="x_fc_client_label"/>
<field name="x_fc_engagement_email"/>
<separator/>
<filter string="Pending" name="state_pending"
domain="[('x_fc_engagement_state', '=', 'pending')]"/>
<filter string="Approved" name="state_approved"
domain="[('x_fc_engagement_state', '=', 'approved')]"/>
<filter string="Rejected" name="state_rejected"
domain="[('x_fc_engagement_state', '=', 'rejected')]"/>
<separator/>
<filter string="Pending &gt; 7 days" name="stuck"
domain="[('x_fc_engagement_state', '=', 'pending'),
('x_fc_engagement_sent_at', '&lt;=',
(context_today() - relativedelta(days=7)).strftime('%Y-%m-%d %H:%M:%S'))]"/>
<separator/>
<!-- Odoo 19: <group expand="0">…</group> is no longer valid
in search views. Group-By filters work directly. -->
<filter string="Group by Client" name="group_client"
context="{'group_by': 'x_fc_client_label'}"/>
<filter string="Group by State" name="group_state"
context="{'group_by': 'x_fc_engagement_state'}"/>
<filter string="Group by Sent (month)" name="group_sent_month"
context="{'group_by': 'x_fc_engagement_sent_at:month'}"/>
</search>
</field>
</record>
<record id="action_owner_engagements" model="ir.actions.act_window">
<field name="name">Owner Engagements</field>
<field name="res_model">helpdesk.ticket</field>
<field name="view_mode">pivot,graph,list,kanban,form</field>
<field name="search_view_id" ref="view_engagement_search"/>
<field name="domain">[('x_fc_engagement_state', '!=', 'none')]</field>
<field name="context">{
'search_default_state_pending': 1
}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">No owner engagements yet.</p>
<p>Click "Request Owner Approval" on any in-app ticket to start one.</p>
</field>
</record>
<menuitem id="menu_owner_engagements"
name="Owner Engagements"
parent="helpdesk.helpdesk_ticket_report_menu_main"
action="action_owner_engagements"
sequence="50"/>
</odoo>

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1
The owner-approval engagement wizard, opened from the ticket form
button OR the list-view bulk server action. Branches on `mode` to
show either the single-ticket layout (one AI summary field) or the
bulk layout (an editable line per ticket).
-->
<odoo>
<record id="view_engagement_wizard_form" model="ir.ui.view">
<field name="name">fusion.helpdesk.engagement.wizard.form</field>
<field name="model">fusion.helpdesk.engagement.wizard</field>
<field name="arch" type="xml">
<form string="Request Owner Approval">
<field name="mode" invisible="1"/>
<field name="ticket_id" invisible="1"/>
<group>
<group>
<field name="owner_name_display" string="Owner"/>
<field name="owner_email_display" string="Owner Email" widget="email"/>
</group>
</group>
<div class="alert alert-warning" role="alert"
invisible="not ai_unavailable">
<strong>AI summary unavailable.</strong>
OpenAI didn't return a summary (no API key set, rate limit,
or network error). Write a quick brief below before sending —
everything else still works.
</div>
<!-- Single mode: one summary field for the one ticket. -->
<group invisible="mode != 'single'">
<field name="personal_note"
placeholder="One-line note that appears above the summary in the email…"/>
<field name="ai_summary" string="Summary to send"
placeholder="Bullet-point summary that the owner will read first." />
</group>
<!-- Bulk mode: per-ticket lines, each with its own summary. -->
<group invisible="mode != 'bulk'">
<field name="personal_note"
placeholder="One-line note that appears once at the top of the combined email…"/>
</group>
<field name="line_ids" invisible="mode != 'bulk'" nolabel="1">
<list editable="bottom" create="0" delete="0">
<field name="ticket_id" readonly="1"/>
<field name="ticket_name" readonly="1"/>
<field name="ai_summary"/>
</list>
</field>
<footer>
<button name="action_send" type="object"
string="Send Engagement"
class="btn-primary"/>
<button special="cancel" string="Cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -27,8 +27,100 @@
<field name="x_fc_client_label"/>
<filter string="Client Deployment" name="group_client_label"
context="{'group_by': 'x_fc_client_label'}"/>
<separator/>
<filter string="Awaiting Owner Approval" name="fhc_pending_engagement"
domain="[('x_fc_engagement_state', '=', 'pending')]"/>
<filter string="Owner Approved" name="fhc_approved_engagement"
domain="[('x_fc_engagement_state', '=', 'approved')]"/>
<filter string="Owner Rejected" name="fhc_rejected_engagement"
domain="[('x_fc_engagement_state', '=', 'rejected')]"/>
</xpath>
</field>
</record>
<!--
Inherited ticket form. Adds:
* Header button "Request Owner Approval" (form-button entry into
the wizard). Disabled when there's no client_label OR no owner
contact on the client_key — see the action_open_engagement_wizard
method which raises a UserError with the same explanation.
* State pill in the title row showing pending / approved / rejected.
* Collapsible "Owner Engagement" group with the audit fields.
Inherited views in Odoo 19 cannot carry `groups`/`group_ids` on the
record (raises ParseError); per-node `groups=` attributes are fine.
-->
<record id="fhc_ticket_form_engagement" model="ir.ui.view">
<field name="name">fhc.helpdesk.ticket.form.engagement</field>
<field name="model">helpdesk.ticket</field>
<field name="inherit_id" ref="helpdesk.helpdesk_ticket_view_form"/>
<field name="arch" type="xml">
<!-- Header button + state badge. Placed at the top of the
header so it's the first thing support sees. -->
<xpath expr="//header" position="inside">
<button name="action_open_engagement_wizard"
type="object"
string="Request Owner Approval"
class="oe_highlight"
invisible="not x_fc_client_label or x_fc_engagement_state == 'pending'"
groups="base.group_user"/>
<button name="action_open_engagement_wizard"
type="object"
string="Re-engage Owner"
invisible="x_fc_engagement_state != 'pending'"
groups="base.group_user"/>
<field name="x_fc_engagement_state" widget="statusbar"
invisible="x_fc_engagement_state == 'none'"
statusbar_visible="pending,approved,rejected"/>
</xpath>
<!-- Collapsible Owner Engagement page on the notebook. -->
<xpath expr="//notebook" position="inside">
<page string="Owner Engagement"
name="fhc_engagement_page"
invisible="x_fc_engagement_state == 'none'">
<group>
<group>
<field name="x_fc_engagement_state" readonly="1"/>
<field name="x_fc_engagement_name" readonly="1"/>
<field name="x_fc_engagement_email" readonly="1" widget="email"/>
</group>
<group>
<field name="x_fc_engagement_sent_at" readonly="1"/>
<field name="x_fc_engagement_reminded_at" readonly="1"/>
<field name="x_fc_engagement_decided_at" readonly="1"/>
<field name="x_fc_engagement_turnaround_hours" readonly="1"/>
</group>
</group>
<separator string="AI Summary (snapshotted at engagement)"/>
<field name="x_fc_ai_summary" readonly="1" nolabel="1"/>
</page>
</xpath>
</field>
</record>
<!--
Kanban dot on the main Helpdesk kanban deferred — the underlying
view's structure varies between Helpdesk versions and reaching into
it with xpath is fragile. The Reporting → Owner Engagements kanban
(grouped by state) gives the same at-a-glance signal until we have
a stable hook in helpdesk's main kanban.
-->
<!--
Server action — bulk "Request Owner Approval" available from the
helpdesk.ticket list view. Validation happens in the wizard's
default_get; the server action just opens the wizard with the
selection in context.
-->
<record id="action_engagement_bulk" model="ir.actions.server">
<field name="name">Request Owner Approval (Bulk)</field>
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="binding_model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="binding_view_types">list,kanban</field>
<field name="state">code</field>
<field name="code">action = model.action_open_engagement_wizard_bulk()</field>
</record>
</odoo>

View File

@@ -0,0 +1,132 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1
Minimal branded portal pages for the owner-approval flow. NOT using the
heavy portal.frontend_layout — the owner sees this once on their phone
and we want sub-200ms render, so each page is a self-contained HTML doc
with inline CSS. Branding kept consistent with the ack email's button
style (same blue, same border-radius).
-->
<odoo>
<!-- Shared shell: page wrapper, header strip, inline CSS reset. The
DOCTYPE is omitted — Odoo's QWeb XML parser rejects it inside
<template>, and modern browsers handle no-doctype pages fine for
a minimal confirmation page like this. Inline CSS keeps the
page self-contained, no external bundles to load. -->
<template id="engagement_layout" name="Owner Engagement Layout">
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Nexa Systems Support</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: Arial, Helvetica, sans-serif; color: #21252b; background: #f3f4f6; -webkit-font-smoothing: antialiased; }
.fhc-header { background: #1e3a5f; color: #fff; padding: 14px 22px; font-weight: 700; letter-spacing: 0.02em; }
.fhc-card { max-width: 640px; margin: 28px auto; background: #fff; border: 1px solid #d8dadd; border-radius: 8px; padding: 22px 26px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
.fhc-title { font-size: 1.15rem; font-weight: 700; margin: 0 0 6px 0; }
.fhc-meta { color: #6c757d; font-size: 0.85rem; margin-bottom: 14px; }
.fhc-section-h { font-size: 0.78rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #6c757d; margin: 16px 0 6px 0; }
.fhc-summary { white-space: pre-wrap; line-height: 1.5; background: #f9fafb; padding: 12px 14px; border-radius: 6px; border: 1px solid #e5e7eb; font-size: 0.92rem; }
.fhc-comment { width: 100%; min-height: 90px; padding: 8px 10px; font-family: inherit; font-size: 0.95rem; border: 1px solid #d8dadd; border-radius: 6px; resize: vertical; }
.fhc-btn { display: inline-block; padding: 12px 26px; font-size: 1rem; font-weight: 700; border-radius: 6px; border: 0; color: #fff; cursor: pointer; }
.fhc-btn-approve { background: linear-gradient(135deg, #5cc66f 0%, #28a745 100%); }
.fhc-btn-reject { background: linear-gradient(135deg, #e85d68 0%, #dc3545 100%); }
.fhc-actions { display: flex; gap: 10px; margin-top: 14px; }
.fhc-foot { color: #9aa3ad; font-size: 0.78rem; text-align: center; margin-top: 18px; }
.fhc-bad { color: #b02a37; }
.fhc-good { color: #1e7e34; }
</style>
</head>
<body>
<div class="fhc-header">Nexa Systems Support</div>
<div class="fhc-card">
<t t-out="0"/>
</div>
<div class="fhc-foot">If this link doesn't look right, contact <a href="mailto:support@nexasystems.ca">support@nexasystems.ca</a>.</div>
</body>
</html>
</template>
<!-- Confirmation page: owner has clicked the magic link and we want
one last confirmation + optional comment before recording the
decision. POST goes back to the same URL. -->
<template id="engagement_confirm" name="Engagement Confirm">
<t t-call="fusion_helpdesk_central.engagement_layout">
<div class="fhc-title" t-esc="ticket.name"/>
<div class="fhc-meta">
Confirm your decision on this request — your name and any
comment below will be posted on the ticket so the team sees
it immediately.
</div>
<div class="fhc-section-h">Summary</div>
<div class="fhc-summary" t-esc="ticket.x_fc_ai_summary or 'No AI summary was attached to this request.'"/>
<form method="POST" action="">
<div class="fhc-section-h">Comment (optional)</div>
<textarea class="fhc-comment" name="comment"
placeholder="Add a note for the team (e.g. 'go ahead, this fits Q2 budget')"/>
<div class="fhc-actions">
<t t-if="decision_state == 'approved'">
<button class="fhc-btn fhc-btn-approve" type="submit">✓ Confirm Approval</button>
</t>
<t t-else="">
<button class="fhc-btn fhc-btn-reject" type="submit">✗ Confirm Rejection</button>
</t>
</div>
</form>
</t>
</template>
<!-- Done: decision recorded. Owner sees a friendly receipt. -->
<template id="engagement_done" name="Engagement Done">
<t t-call="fusion_helpdesk_central.engagement_layout">
<t t-if="decision_state == 'approved'">
<div class="fhc-title fhc-good">✓ Approval recorded</div>
</t>
<t t-else="">
<div class="fhc-title fhc-bad">✗ Rejection recorded</div>
</t>
<div class="fhc-meta">
Thanks — your decision on
"<b><t t-esc="ticket.name"/></b>"
has been posted to the ticket and the team has been notified.
You can safely close this tab. If you also received a bulk
email with multiple requests, come back to it and decide on
the others independently.
</div>
</t>
</template>
<!-- Invalid: token unknown, already used, or wrong state. -->
<template id="engagement_invalid" name="Engagement Invalid">
<t t-call="fusion_helpdesk_central.engagement_layout">
<div class="fhc-title fhc-bad">Link no longer valid</div>
<div class="fhc-meta">
This approval link has already been used, was replaced by a
newer request, or is otherwise no longer active.
If you think this is a mistake, please contact
<a href="mailto:support@nexasystems.ca">support@nexasystems.ca</a>
and we'll sort it out.
</div>
</t>
</template>
<!-- Server-side error -->
<template id="engagement_error" name="Engagement Error">
<t t-call="fusion_helpdesk_central.engagement_layout">
<div class="fhc-title fhc-bad">Something went wrong</div>
<div class="fhc-meta">
We couldn't record your decision because of an unexpected
error on our side. Please try the link again in a minute —
if it still fails, reply to the original email or contact
<a href="mailto:support@nexasystems.ca">support@nexasystems.ca</a>.
</div>
</t>
</template>
</odoo>