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

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Helpdesk Reporter',
'version': '19.0.1.7.1',
'version': '19.0.2.0.0',
'category': 'Productivity',
'summary': 'One-click in-app bug reporting & feature requesting — '
'auto-creates a helpdesk.ticket on a central Odoo Helpdesk.',

View File

@@ -101,6 +101,15 @@ class FusionHelpdeskController(http.Controller):
company_name=request.env.company.name,
is_critical=bool(is_critical),
)
# Piggyback the configured owner contact on every submission so the
# central can keep fusion.helpdesk.client.key.owner_email/name in
# sync without a dedicated endpoint. The central's create override
# pops these keys before super().create() sees them — they're not
# real helpdesk.ticket columns, just a sync side-channel.
if cfg.get('owner_email'):
ticket_vals['x_fc_owner_email'] = cfg['owner_email']
if cfg.get('owner_name'):
ticket_vals['x_fc_owner_name'] = cfg['owner_name']
# ---- Talk to remote Odoo --------------------------------------
try:
@@ -219,6 +228,12 @@ class FusionHelpdeskController(http.Controller):
'client_label': (
ICP.get_param('fusion_helpdesk.client_label') or ''
).strip(),
'owner_email': (
ICP.get_param('fusion_helpdesk.owner_email') or ''
).strip(),
'owner_name': (
ICP.get_param('fusion_helpdesk.owner_name') or ''
).strip(),
}
def _authenticate(self, cfg):

View File

@@ -50,3 +50,22 @@ class ResConfigSettings(models.TransientModel):
'can tell which client deployment a ticket came from. '
'e.g. "ENTECH""[ENTECH] My subject"',
)
# Owner contact for the central engagement / approval flow. Optional —
# leaving these blank disables the "Request Owner Approval" button on
# the central side for this client. Both values piggyback on every
# ticket submission (see controllers/main.py::submit) so central always
# has the latest contact without a dedicated sync endpoint.
fhd_owner_email = fields.Char(
string='Owner Email',
config_parameter='fusion_helpdesk.owner_email',
help='Email of the real decision-maker at your company — the '
'person who can approve feature requests or bug-fix scope. '
'Used when central support hits a ticket that needs sign-off. '
'Leave blank if your deployment doesn\'t require approvals.',
)
fhd_owner_name = fields.Char(
string='Owner Name',
config_parameter='fusion_helpdesk.owner_name',
help='Display name for the owner — shown in the approval email '
'greeting and in the chatter attribution after they decide.',
)

View File

@@ -43,6 +43,19 @@
<field name="fhd_client_label" placeholder="ENTECH"/>
</setting>
</block>
<block title="Owner Approval"
name="fhd_owner_approval">
<setting id="fhd_owner_name"
string="Owner Name"
help="Display name of the real decision-maker at your company. Used in approval emails and chatter attribution.">
<field name="fhd_owner_name" placeholder="Jane Doe"/>
</setting>
<setting id="fhd_owner_email"
string="Owner Email"
help="Email of the real decision-maker. Used when central support requests approval for a ticket that needs sign-off. Leave blank to disable approval requests for this deployment.">
<field name="fhd_owner_email" placeholder="owner@yourcompany.com"/>
</setting>
</block>
</app>
</xpath>
</field>

View File

@@ -1,2 +1,3 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers

View File

@@ -3,7 +3,7 @@
# License OPL-1
{
'name': 'Fusion Helpdesk Central — Client API Keys',
'version': '19.0.1.2.0',
'version': '19.0.2.0.0',
'category': 'Productivity',
'summary': 'Admin UI on the central Odoo for issuing per-client API '
'keys used by fusion_helpdesk client deployments.',
@@ -29,9 +29,14 @@ Depends only on `helpdesk`. No client-side install needed.
'security/ir.model.access.csv',
'data/ir_config_parameter_data.xml',
'data/mail_template_ack.xml',
'data/mail_template_engagement.xml',
'data/helpdesk_tag_critical.xml',
'data/ir_cron_engagement_reminder.xml',
'views/fusion_helpdesk_client_key_views.xml',
'views/helpdesk_ticket_views.xml',
'views/engagement_wizard_views.xml',
'views/engagement_reporting_views.xml',
'views/portal_templates.xml',
],
'installable': True,
'auto_install': False,

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import engagement

View 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

View File

@@ -10,4 +10,19 @@
<field name="value">support@nexasystems.ca</field>
</record>
<!--
Owner-approval defaults. openai_api_key intentionally NOT seeded —
admin sets it via Settings before the wizard can generate summaries.
Default model is the cheap/fast tier; bump for harder summarisation.
Reminder days = 0 disables the cron entirely.
-->
<record id="fhc_default_openai_model" model="ir.config_parameter">
<field name="key">fusion_helpdesk_central.openai_model</field>
<field name="value">gpt-4o-mini</field>
</record>
<record id="fhc_default_reminder_days" model="ir.config_parameter">
<field name="key">fusion_helpdesk_central.engagement_reminder_days</field>
<field name="value">3</field>
</record>
</odoo>

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1
Single-shot reminder cron for the owner-approval engagement flow.
Runs once a day; for each pending engagement older than N days that
hasn't been reminded yet, re-sends the same template with
is_reminder=True (different subject + soft "still waiting" intro,
same magic links). Sets reminded_at so we never send a 2nd reminder.
Odoo 19: no `numbercall` field (dropped). The cron keeps running
while active=True; once-a-day cadence is enforced by interval_*.
-->
<odoo>
<data noupdate="1">
<record id="ir_cron_engagement_reminder" model="ir.cron">
<field name="name">Fusion Helpdesk — Owner Engagement Reminder</field>
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="state">code</field>
<field name="code">model._fc_send_engagement_reminders()</field>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active" eval="True"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,169 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1
Two mail templates for the owner-approval engagement flow:
* mail_template_engagement — single ticket
* mail_template_engagement_bulk — multiple tickets, one card per ticket,
per-ticket approve/reject buttons
Both use {{ ctx.fhc_personal_note }} + {{ ctx.fhc_is_reminder }} from the
wizard's with_context(**data) so dynamic per-send data reaches the body.
noupdate=1 so support's later tweaks (e.g. a different reply-to)
survive every fusion_helpdesk_central upgrade.
-->
<odoo>
<data noupdate="1">
<!-- ============== SINGLE TICKET ENGAGEMENT ============== -->
<record id="mail_template_engagement" model="mail.template">
<field name="name">Helpdesk: Owner Approval Request (Single)</field>
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="subject">{{ ctx.get('fhc_is_reminder') and 'Reminder: still waiting on your approval' or 'Action needed: please review' }} — "{{ object.name }}"</field>
<field name="email_to">{{ object.x_fc_engagement_email }}</field>
<field name="reply_to">{{ (object.user_id.email or object.team_id.alias_email or object.company_id.email or '') }}</field>
<field name="lang">en_US</field>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
<div style="margin:0; padding:0; font-family:Arial, Helvetica, sans-serif; color:#21252b; font-size:14px;">
<p>Hi <t t-out="object.x_fc_engagement_name or 'there'"/>,</p>
<t t-if="ctx.get('fhc_is_reminder')">
<p style="background:#fff8e6; border-left:3px solid #d4a017; padding:8px 12px; margin:12px 0;">
Following up on the request below — the original
email was sent a few days ago and we haven't
heard back yet. Same Approve / Reject buttons,
same one click.
</p>
</t>
<t t-elif="ctx.get('fhc_personal_note')">
<p style="font-style:italic; color:#444;">
<t t-out="ctx.get('fhc_personal_note')"/>
</p>
</t>
<p>
Your team at <b><t t-out="object.x_fc_client_label or 'your deployment'"/></b>
has filed a request that needs your sign-off before
our team proceeds. A quick AI-prepared summary is
below; the full thread expands at the bottom if you
want the detail.
</p>
<div style="margin:14px 0; padding:12px 14px; background:#f9fafb; border:1px solid #e5e7eb; border-radius:6px;">
<div style="font-size:0.78rem; font-weight:700; text-transform:uppercase; letter-spacing:0.06em; color:#6c757d; margin-bottom:6px;">Summary</div>
<div style="white-space:pre-wrap; line-height:1.5;"><t t-out="object.x_fc_ai_summary or '(no AI summary available — see full thread below)'"/></div>
</div>
<div style="margin:18px 0;">
<div style="font-size:0.78rem; font-weight:700; text-transform:uppercase; letter-spacing:0.06em; color:#6c757d; margin-bottom:6px;">Request</div>
<div style="font-weight:600; font-size:1.02rem;"><t t-out="object.name"/></div>
</div>
<table cellpadding="0" cellspacing="0" style="margin:18px 0;">
<tr>
<td style="padding-right:8px;">
<a t-attf-href="{{ object.get_base_url() }}/fusion_helpdesk/engagement/{{ object.x_fc_engagement_token }}/approve"
target="_blank"
style="background:linear-gradient(135deg, #5cc66f 0%, #28a745 100%); padding:12px 22px; text-decoration:none; color:#ffffff; border-radius:6px; font-weight:700; font-size:14px; display:inline-block;">
✓ Approve
</a>
</td>
<td>
<a t-attf-href="{{ object.get_base_url() }}/fusion_helpdesk/engagement/{{ object.x_fc_engagement_token }}/reject"
target="_blank"
style="background:linear-gradient(135deg, #e85d68 0%, #dc3545 100%); padding:12px 22px; text-decoration:none; color:#ffffff; border-radius:6px; font-weight:700; font-size:14px; display:inline-block;">
✗ Reject
</a>
</td>
</tr>
</table>
<details style="margin:18px 0; color:#444; font-size:13px;">
<summary style="cursor:pointer; color:#2c89e9;">View original request &amp; full thread</summary>
<div style="margin-top:10px; padding:10px 14px; border-left:3px solid #d8dadd;">
<t t-out="object.description or ''"/>
</div>
</details>
<p style="color:#6c757d; font-size:12px; margin-top:24px;">
This Approve / Reject link is single-use and will
stop working once you've clicked it.
</p>
<p style="color:#6c757d; font-size:12px;">— Nexa Systems Support</p>
</div>
</field>
</record>
<!-- ============== BULK ENGAGEMENT ============== -->
<record id="mail_template_engagement_bulk" model="mail.template">
<field name="name">Helpdesk: Owner Approval Request (Bulk)</field>
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="subject">Action needed: {{ len(ctx.get('fhc_bulk_ticket_ids') or []) }} requests need your sign-off</field>
<field name="email_to">{{ object.x_fc_engagement_email }}</field>
<field name="reply_to">{{ (object.user_id.email or object.team_id.alias_email or object.company_id.email or '') }}</field>
<field name="lang">en_US</field>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
<div style="margin:0; padding:0; font-family:Arial, Helvetica, sans-serif; color:#21252b; font-size:14px;">
<p>Hi <t t-out="object.x_fc_engagement_name or 'there'"/>,</p>
<t t-if="ctx.get('fhc_personal_note')">
<p style="font-style:italic; color:#444;">
<t t-out="ctx.get('fhc_personal_note')"/>
</p>
</t>
<p>
<t t-out="len(ctx.get('fhc_bulk_ticket_ids') or [])"/>
requests from <b><t t-out="object.x_fc_client_label or 'your deployment'"/></b>
need your sign-off. Each can be approved or rejected
independently — clicking a button on one card only
acts on that card.
</p>
<t t-set="bulk_tickets" t-value="object.env['helpdesk.ticket'].browse(ctx.get('fhc_bulk_ticket_ids') or [])"/>
<t t-foreach="bulk_tickets" t-as="bt">
<div style="margin:18px 0; padding:14px 16px; background:#fff; border:1px solid #d8dadd; border-radius:8px;">
<div style="font-size:0.75rem; color:#6c757d; margin-bottom:6px;">Request <t t-out="bt_index + 1"/> of <t t-out="bt_size"/></div>
<div style="font-weight:600; font-size:1.02rem; margin-bottom:8px;"><t t-out="bt.name"/></div>
<div style="white-space:pre-wrap; line-height:1.5; background:#f9fafb; padding:10px 12px; border-radius:6px; border:1px solid #e5e7eb; font-size:0.92rem; margin-bottom:12px;">
<t t-out="bt.x_fc_ai_summary or '(no AI summary — open the full ticket via the team if needed)'"/>
</div>
<table cellpadding="0" cellspacing="0">
<tr>
<td style="padding-right:8px;">
<a t-attf-href="{{ object.get_base_url() }}/fusion_helpdesk/engagement/{{ bt.x_fc_engagement_token }}/approve"
target="_blank"
style="background:linear-gradient(135deg, #5cc66f 0%, #28a745 100%); padding:10px 18px; text-decoration:none; color:#ffffff; border-radius:6px; font-weight:700; font-size:13px; display:inline-block;">
✓ Approve
</a>
</td>
<td>
<a t-attf-href="{{ object.get_base_url() }}/fusion_helpdesk/engagement/{{ bt.x_fc_engagement_token }}/reject"
target="_blank"
style="background:linear-gradient(135deg, #e85d68 0%, #dc3545 100%); padding:10px 18px; text-decoration:none; color:#ffffff; border-radius:6px; font-weight:700; font-size:13px; display:inline-block;">
✗ Reject
</a>
</td>
</tr>
</table>
</div>
</t>
<p style="color:#6c757d; font-size:12px; margin-top:24px;">
Each Approve / Reject link is single-use. Tap the
button on the card you want to decide on; the others
stay live in this email until you act on them or we
send a fresh request.
</p>
<p style="color:#6c757d; font-size:12px;">— Nexa Systems Support</p>
</div>
</field>
</record>
</data>
</odoo>

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

View File

@@ -1,2 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fhc_client_key_admin,fusion.helpdesk.client.key.admin,model_fusion_helpdesk_client_key,base.group_system,1,1,1,1
access_fhc_engagement_wizard_user,fusion.helpdesk.engagement.wizard.user,model_fusion_helpdesk_engagement_wizard,base.group_user,1,1,1,1
access_fhc_engagement_wizard_line_user,fusion.helpdesk.engagement.wizard.line.user,model_fusion_helpdesk_engagement_wizard_line,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fhc_client_key_admin fusion.helpdesk.client.key.admin model_fusion_helpdesk_client_key base.group_system 1 1 1 1
3 access_fhc_engagement_wizard_user fusion.helpdesk.engagement.wizard.user model_fusion_helpdesk_engagement_wizard base.group_user 1 1 1 1
4 access_fhc_engagement_wizard_line_user fusion.helpdesk.engagement.wizard.line.user model_fusion_helpdesk_engagement_wizard_line base.group_user 1 1 1 1

View File

@@ -1,2 +1,4 @@
# -*- coding: utf-8 -*-
from . import test_identity
from . import test_utils
from . import test_engagement

View File

@@ -0,0 +1,414 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Integration-ish tests for the owner-approval engagement flow.
These need an env (helpdesk.ticket, client_key, wizard, portal controller),
so they run as TransactionCase. OpenAI is mocked at the utils boundary —
no live API calls in CI. HTTP requests use the standard Odoo test client.
"""
from datetime import timedelta
from unittest.mock import patch
from odoo import fields
from odoo.exceptions import UserError
from odoo.tests import TransactionCase, HttpCase, tagged
def _patch_openai(return_value='• summary bullet one\n• bullet two'):
"""Mock the OpenAI client used by the wizard, returning a deterministic
summary so tests don't depend on network or API keys."""
return patch(
'odoo.addons.fusion_helpdesk_central.models.engagement_wizard.'
'call_openai_chat',
return_value=return_value,
)
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestEngagementBase(TransactionCase):
"""Shared fixtures: a client_key with an owner, an ENTECH ticket."""
@classmethod
def setUpClass(cls):
super().setUpClass()
# client_key.create() requires an actual bot user; set bot login
# to the admin so the model can find it during setUp.
cls.env['ir.config_parameter'].sudo().set_param(
'fusion_helpdesk.bot_login', cls.env.user.login,
)
cls.client_key = cls.env['fusion.helpdesk.client.key'].create({
'client_label': 'TESTCLIENT',
'owner_email': 'owner@testclient.com',
'owner_name': 'Test Owner',
})
cls.team = cls.env['helpdesk.team'].create({
'name': 'Test Team for engagement',
})
def _make_ticket(self, **overrides):
vals = {
'name': '[TESTCLIENT] Bug Report: Test ticket for engagement',
'team_id': self.team.id,
'x_fc_client_label': 'TESTCLIENT',
'description': '<p>Steps to reproduce: do X, see Y.</p>',
}
vals.update(overrides)
return self.env['helpdesk.ticket'].create(vals)
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestOwnerContactSync(TestEngagementBase):
def test_sync_owner_contacts_from_payload(self):
# Simulate the client-side submit piggyback: x_fc_owner_email +
# x_fc_owner_name on create vals. Central must consume them
# (pop from vals) and upsert the client_key row.
self._make_ticket(
x_fc_owner_email='newowner@testclient.com',
x_fc_owner_name='New Owner',
)
self.client_key.invalidate_recordset()
self.assertEqual(self.client_key.owner_email, 'newowner@testclient.com')
self.assertEqual(self.client_key.owner_name, 'New Owner')
def test_sync_no_owner_payload_leaves_client_key_alone(self):
# No piggyback keys → existing client_key contacts must NOT be
# nuked. (We had a bug like this in the customer-followup ship.)
original_email = self.client_key.owner_email
self._make_ticket()
self.client_key.invalidate_recordset()
self.assertEqual(self.client_key.owner_email, original_email)
def test_sync_unknown_client_label_is_silently_skipped(self):
# If a ticket arrives for a client_label we don't have a row for,
# we must not create one (would bypass API-key issuance). Just
# log and move on without raising.
self._make_ticket(
x_fc_client_label='UNKNOWN_CLIENT',
x_fc_owner_email='wat@example.com',
)
# No client_key row was created for UNKNOWN_CLIENT
unknown = self.env['fusion.helpdesk.client.key'].search(
[('client_label', '=', 'UNKNOWN_CLIENT')])
self.assertFalse(unknown)
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestEngagementReset(TestEngagementBase):
def test_reset_engagement_sets_all_fields(self):
t = self._make_ticket()
t._fc_reset_engagement('o@x.com', 'Owner', 'Summary text')
self.assertEqual(t.x_fc_engagement_state, 'pending')
self.assertEqual(t.x_fc_engagement_email, 'o@x.com')
self.assertEqual(t.x_fc_engagement_name, 'Owner')
self.assertEqual(t.x_fc_ai_summary, 'Summary text')
self.assertTrue(t.x_fc_engagement_token)
self.assertTrue(t.x_fc_engagement_sent_at)
self.assertFalse(t.x_fc_engagement_reminded_at)
self.assertFalse(t.x_fc_engagement_decided_at)
def test_re_engagement_rotates_token_and_clears_decision(self):
t = self._make_ticket()
t._fc_reset_engagement('o@x.com', 'Owner', 'summary 1')
original_token = t.x_fc_engagement_token
# Simulate the owner having decided…
t.write({
'x_fc_engagement_state': 'rejected',
'x_fc_engagement_decided_at': fields.Datetime.now(),
'x_fc_engagement_reminded_at': fields.Datetime.now(),
})
# …then re-engage. State must reset, token must rotate.
t._fc_reset_engagement('o@x.com', 'Owner', 'summary 2')
self.assertEqual(t.x_fc_engagement_state, 'pending')
self.assertNotEqual(t.x_fc_engagement_token, original_token)
self.assertFalse(t.x_fc_engagement_reminded_at)
self.assertFalse(t.x_fc_engagement_decided_at)
def test_token_is_unique_per_call(self):
t = self._make_ticket()
tokens = set()
for _ in range(20):
t._fc_reset_engagement('o@x.com', 'Owner', '')
tokens.add(t.x_fc_engagement_token)
self.assertEqual(len(tokens), 20)
def test_finalize_posts_chatter_and_clears_token(self):
t = self._make_ticket()
t._fc_reset_engagement('o@x.com', 'Owner', 's')
partner = self.env['res.partner'].create({
'name': 'Owner', 'email': 'o@x.com',
})
before_count = self.env['mail.message'].search_count(
[('res_id', '=', t.id), ('model', '=', 'helpdesk.ticket')])
t._fc_finalize_engagement('approved', partner, comment='LGTM')
after_count = self.env['mail.message'].search_count(
[('res_id', '=', t.id), ('model', '=', 'helpdesk.ticket')])
self.assertGreater(after_count, before_count)
self.assertEqual(t.x_fc_engagement_state, 'approved')
self.assertFalse(t.x_fc_engagement_token)
self.assertTrue(t.x_fc_engagement_decided_at)
def test_turnaround_hours_computed(self):
t = self._make_ticket()
now = fields.Datetime.now()
t.write({
'x_fc_engagement_sent_at': now - timedelta(hours=5),
'x_fc_engagement_decided_at': now,
})
self.assertAlmostEqual(
t.x_fc_engagement_turnaround_hours, 5.0, places=1,
)
def test_turnaround_zero_when_not_decided(self):
t = self._make_ticket()
t.write({
'x_fc_engagement_sent_at': fields.Datetime.now() - timedelta(hours=2),
})
self.assertEqual(t.x_fc_engagement_turnaround_hours, 0.0)
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestEngagementWizard(TestEngagementBase):
def test_single_send_via_wizard(self):
t = self._make_ticket()
with _patch_openai():
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_id=t.id,
active_id=t.id,
active_model='helpdesk.ticket',
).create({})
self.assertEqual(wizard.mode, 'single')
self.assertIn('summary bullet', wizard.ai_summary)
wizard.personal_note = 'please review'
result = wizard.action_send()
# action returns the standard close-modal action
self.assertEqual(result.get('type'), 'ir.actions.act_window_close')
self.assertEqual(t.x_fc_engagement_state, 'pending')
self.assertEqual(t.x_fc_engagement_email, 'owner@testclient.com')
self.assertTrue(t.x_fc_engagement_token)
def test_single_send_uses_current_client_key_owner(self):
# The wizard must read the FRESH owner contact from client_key,
# not a stale snapshot — if the client_key is updated between
# default_get and Send, Send wins.
t = self._make_ticket()
with _patch_openai():
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_id=t.id,
).create({})
self.client_key.owner_email = 'changed@testclient.com'
wizard.action_send()
self.assertEqual(t.x_fc_engagement_email, 'changed@testclient.com')
def test_wizard_rejects_ticket_without_client_label(self):
t = self._make_ticket(x_fc_client_label=False)
with _patch_openai(), self.assertRaises(UserError):
self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_id=t.id,
).create({})
def test_wizard_rejects_when_owner_contact_missing(self):
self.client_key.write({'owner_email': False, 'owner_name': False})
t = self._make_ticket()
with _patch_openai(), self.assertRaises(UserError):
self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_id=t.id,
).create({})
def test_wizard_marks_ai_unavailable_when_summary_empty(self):
t = self._make_ticket()
with _patch_openai(return_value=''):
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_id=t.id,
).create({})
self.assertTrue(wizard.ai_unavailable)
self.assertEqual(wizard.ai_summary, '')
def test_bulk_send_creates_one_engagement_per_ticket(self):
ts = self.env['helpdesk.ticket']
for i in range(3):
ts |= self._make_ticket(name='[TESTCLIENT] Bug %s' % i)
with _patch_openai():
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_ids=ts.ids,
active_ids=ts.ids,
active_model='helpdesk.ticket',
fhc_bulk=True,
).create({})
self.assertEqual(wizard.mode, 'bulk')
self.assertEqual(len(wizard.line_ids), 3)
wizard.action_send()
for t in ts:
self.assertEqual(t.x_fc_engagement_state, 'pending')
self.assertTrue(t.x_fc_engagement_token)
# Each ticket must have its OWN token
tokens = {t.x_fc_engagement_token for t in ts}
self.assertEqual(len(tokens), 3)
def test_bulk_rejects_mixed_clients(self):
t1 = self._make_ticket()
# Need another client_key for the mix to be valid otherwise the
# owner-contact check fires first.
self.env['fusion.helpdesk.client.key'].create({
'client_label': 'OTHERCLIENT',
'owner_email': 'other@x.com', 'owner_name': 'Other',
})
t2 = self._make_ticket(
name='[OTHERCLIENT] x', x_fc_client_label='OTHERCLIENT')
with _patch_openai(), self.assertRaises(UserError):
self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_ids=[t1.id, t2.id],
fhc_bulk=True,
).create({})
def test_bulk_rejects_already_pending_in_selection(self):
t1 = self._make_ticket()
t1._fc_reset_engagement('o@x.com', 'Owner', '') # already pending
t2 = self._make_ticket(name='[TESTCLIENT] B')
with _patch_openai(), self.assertRaises(UserError):
self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_ids=[t1.id, t2.id],
fhc_bulk=True,
).create({})
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestReminderCron(TestEngagementBase):
def test_reminder_fires_for_stale_pending_only(self):
# 1 stale (should be reminded), 1 recent (no reminder), 1 already
# reminded (no second reminder), 1 already-decided (no reminder).
old = fields.Datetime.now() - timedelta(days=10)
recent = fields.Datetime.now() - timedelta(hours=2)
stale = self._make_ticket()
stale._fc_reset_engagement('o@x.com', 'Owner', '')
stale.x_fc_engagement_sent_at = old
too_recent = self._make_ticket(name='[TESTCLIENT] too recent')
too_recent._fc_reset_engagement('o@x.com', 'Owner', '')
too_recent.x_fc_engagement_sent_at = recent
already_reminded = self._make_ticket(name='[TESTCLIENT] already')
already_reminded._fc_reset_engagement('o@x.com', 'Owner', '')
already_reminded.write({
'x_fc_engagement_sent_at': old,
'x_fc_engagement_reminded_at': old,
})
decided = self._make_ticket(name='[TESTCLIENT] decided')
decided._fc_reset_engagement('o@x.com', 'Owner', '')
decided.write({
'x_fc_engagement_sent_at': old,
'x_fc_engagement_state': 'approved',
})
# Default ICP is 3 days, so >=10 days qualifies.
sent = self.env['helpdesk.ticket']._fc_send_engagement_reminders()
self.assertEqual(sent, 1)
self.assertTrue(stale.x_fc_engagement_reminded_at)
self.assertFalse(too_recent.x_fc_engagement_reminded_at)
# already_reminded's reminded_at must not have moved
self.assertEqual(
already_reminded.x_fc_engagement_reminded_at, old,
)
def test_reminder_disabled_when_days_zero(self):
self.env['ir.config_parameter'].sudo().set_param(
'fusion_helpdesk_central.engagement_reminder_days', '0')
t = self._make_ticket()
t._fc_reset_engagement('o@x.com', 'Owner', '')
t.x_fc_engagement_sent_at = fields.Datetime.now() - timedelta(days=30)
sent = self.env['helpdesk.ticket']._fc_send_engagement_reminders()
self.assertEqual(sent, 0)
self.assertFalse(t.x_fc_engagement_reminded_at)
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestEngagementPortal(HttpCase):
"""HTTP-layer tests for the public approve/reject portal pages."""
def setUp(self):
super().setUp()
self.env['ir.config_parameter'].sudo().set_param(
'fusion_helpdesk.bot_login', self.env.user.login,
)
self.client_key = self.env['fusion.helpdesk.client.key'].create({
'client_label': 'PORTALCLIENT',
'owner_email': 'owner@portalclient.com',
'owner_name': 'Portal Owner',
})
self.team = self.env['helpdesk.team'].create({
'name': 'Test team portal',
})
def _make_pending_ticket(self):
t = self.env['helpdesk.ticket'].create({
'name': '[PORTALCLIENT] Bug Report: portal smoke',
'team_id': self.team.id,
'x_fc_client_label': 'PORTALCLIENT',
'description': '<p>nothing fancy</p>',
})
t._fc_reset_engagement('owner@portalclient.com', 'Portal Owner', 'sm')
# Make sure cursor sees it for the public request
self.env.cr.commit()
return t
def test_get_with_valid_token_renders_confirm(self):
t = self._make_pending_ticket()
try:
r = self.url_open(
'/fusion_helpdesk/engagement/%s/approve' % t.x_fc_engagement_token,
timeout=10,
)
self.assertEqual(r.status_code, 200)
self.assertIn('Confirm Approval', r.text)
self.assertIn(t.name, r.text)
finally:
t.unlink()
self.env.cr.commit()
def test_get_with_bad_token_renders_invalid(self):
r = self.url_open(
'/fusion_helpdesk/engagement/bogus-token/approve', timeout=10,
)
self.assertEqual(r.status_code, 200)
self.assertIn('Link no longer valid', r.text)
def test_get_with_bad_decision_renders_invalid(self):
t = self._make_pending_ticket()
try:
r = self.url_open(
'/fusion_helpdesk/engagement/%s/sideways'
% t.x_fc_engagement_token, timeout=10,
)
self.assertEqual(r.status_code, 200)
self.assertIn('Link no longer valid', r.text)
finally:
t.unlink()
self.env.cr.commit()
def test_post_records_decision_and_invalidates_token(self):
t = self._make_pending_ticket()
token = t.x_fc_engagement_token
try:
r = self.url_open(
'/fusion_helpdesk/engagement/%s/approve' % token,
data={'comment': 'looks good'}, timeout=10,
)
self.assertEqual(r.status_code, 200)
self.assertIn('Approval recorded', r.text)
t.invalidate_recordset()
self.assertEqual(t.x_fc_engagement_state, 'approved')
self.assertFalse(t.x_fc_engagement_token)
# Second click on the same URL must now show the invalid page.
r2 = self.url_open(
'/fusion_helpdesk/engagement/%s/approve' % token, timeout=10,
)
self.assertIn('Link no longer valid', r2.text)
finally:
t.unlink()
self.env.cr.commit()

View File

@@ -0,0 +1,193 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Pure-helper tests for fusion_helpdesk_central.utils.
No Odoo env, no network — these run in any environment with `markupsafe`
installed. Mirrors the pattern in fusion_helpdesk/tests/test_utils.py.
"""
from unittest.mock import patch
from odoo.tests import TransactionCase, tagged
from odoo.addons.fusion_helpdesk_central.utils import (
SUMMARY_PROMPT,
OPENAI_PROMPT_MAX_CHARS,
build_summary_prompt,
call_openai_chat,
format_engagement_chatter,
truncate_for_openai,
)
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestBuildSummaryPrompt(TransactionCase):
def test_includes_title_description_and_messages(self):
prompt = build_summary_prompt(
'My ticket',
'Detailed description here',
[{'author': 'Alice', 'date': '2026-05-27 10:00:00',
'body_plain': 'first reply'}],
)
self.assertIn('My ticket', prompt)
self.assertIn('Detailed description here', prompt)
self.assertIn('Alice', prompt)
self.assertIn('first reply', prompt)
def test_empty_messages_becomes_explicit_marker(self):
# The model needs to know "no replies yet" — a blank section would
# invite hallucination.
prompt = build_summary_prompt('t', 'd', [])
self.assertIn('(no replies yet)', prompt)
def test_empty_title_and_description_use_fallbacks(self):
prompt = build_summary_prompt('', '', [])
self.assertIn('(untitled)', prompt)
self.assertIn('(no description)', prompt)
def test_empty_message_body_is_marked_not_dropped(self):
prompt = build_summary_prompt('t', 'd', [
{'author': 'A', 'date': 'X', 'body_plain': ''},
])
# An empty body must still produce a line so author + date context
# survives; '(empty)' marker keeps the model honest.
self.assertIn('(empty)', prompt)
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestTruncateForOpenAI(TransactionCase):
def test_no_truncation_when_under_limit(self):
prompt = 'x' * 100
self.assertEqual(truncate_for_openai(prompt, max_chars=200), prompt)
def test_truncation_appends_marker_and_respects_cap(self):
prompt = 'x' * 500
out = truncate_for_openai(prompt, max_chars=200)
self.assertLessEqual(len(out), 200)
self.assertIn('truncated', out.lower())
def test_default_cap_is_8000(self):
# Regression guard — flipping this default has real $$ implications.
self.assertEqual(OPENAI_PROMPT_MAX_CHARS, 8000)
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestFormatEngagementChatter(TransactionCase):
def test_approve_without_comment(self):
out = str(format_engagement_chatter('approved', 'Kris'))
self.assertIn('Approved', out)
self.assertIn('Kris', out)
self.assertIn('', out)
# No blockquote when no comment.
self.assertNotIn('blockquote', out)
def test_reject_with_comment(self):
out = str(format_engagement_chatter(
'rejected', 'Kris', comment='not in scope this quarter'))
self.assertIn('Rejected', out)
self.assertIn('', out)
self.assertIn('blockquote', out)
self.assertIn('not in scope this quarter', out)
def test_comment_html_escaped(self):
# XSS guard: a malicious comment must not inject script tags.
out = str(format_engagement_chatter(
'approved', 'Kris', comment='<script>alert(1)</script>'))
self.assertNotIn('<script>', out)
self.assertIn('&lt;script&gt;', out)
def test_owner_name_html_escaped(self):
# Same XSS guard on the owner name path.
out = str(format_engagement_chatter(
'approved', '<b>Pwn</b>'))
self.assertNotIn('<b>Pwn</b>', out)
self.assertIn('&lt;b&gt;', out)
def test_missing_owner_name_falls_back(self):
out = str(format_engagement_chatter('approved', None))
self.assertIn('the owner', out)
def test_invalid_decision_raises(self):
# Typo upstream should fail loud, not silently post a malformed line.
with self.assertRaises(ValueError):
format_engagement_chatter('maybe', 'Kris')
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestCallOpenAIChat(TransactionCase):
"""OpenAI client unit tests — mock urlopen, never hit the network."""
def test_empty_api_key_returns_empty_string(self):
# No key configured? Don't even try to call OpenAI.
self.assertEqual(call_openai_chat('', 'gpt-4o-mini', 'hi'), '')
def test_empty_prompt_returns_empty_string(self):
self.assertEqual(call_openai_chat('sk-x', 'gpt-4o-mini', ''), '')
def test_happy_path_returns_message_content(self):
fake_response = type('R', (), {})()
fake_response.read = lambda: (
b'{"choices":[{"message":{"content":"bullet one\\nbullet two"}}]}'
)
fake_response.__enter__ = lambda s: s
fake_response.__exit__ = lambda *a: None
with patch(
'odoo.addons.fusion_helpdesk_central.utils.urllib.request.urlopen',
return_value=fake_response,
):
out = call_openai_chat('sk-x', 'gpt-4o-mini', 'prompt')
self.assertEqual(out, 'bullet one\nbullet two')
def test_network_error_returns_empty(self):
# All exceptions must collapse to '' so the wizard's fallback fires.
import urllib.error
with patch(
'odoo.addons.fusion_helpdesk_central.utils.urllib.request.urlopen',
side_effect=urllib.error.URLError('boom'),
):
self.assertEqual(call_openai_chat('sk-x', 'm', 'p'), '')
def test_http_error_returns_empty(self):
import urllib.error
with patch(
'odoo.addons.fusion_helpdesk_central.utils.urllib.request.urlopen',
side_effect=urllib.error.HTTPError(
'url', 429, 'rate limited', {}, None,
),
):
self.assertEqual(call_openai_chat('sk-x', 'm', 'p'), '')
def test_malformed_json_returns_empty(self):
fake_response = type('R', (), {})()
fake_response.read = lambda: b'not json at all'
fake_response.__enter__ = lambda s: s
fake_response.__exit__ = lambda *a: None
with patch(
'odoo.addons.fusion_helpdesk_central.utils.urllib.request.urlopen',
return_value=fake_response,
):
self.assertEqual(call_openai_chat('sk-x', 'm', 'p'), '')
def test_empty_choices_returns_empty(self):
fake_response = type('R', (), {})()
fake_response.read = lambda: b'{"choices":[]}'
fake_response.__enter__ = lambda s: s
fake_response.__exit__ = lambda *a: None
with patch(
'odoo.addons.fusion_helpdesk_central.utils.urllib.request.urlopen',
return_value=fake_response,
):
self.assertEqual(call_openai_chat('sk-x', 'm', 'p'), '')
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestPromptConstant(TransactionCase):
def test_summary_prompt_has_required_placeholders(self):
# The wizard calls .format(name=, description_plain=, messages_plain=)
# — silently dropping any of these would yield a useless prompt.
for placeholder in ('{name}', '{description_plain}', '{messages_plain}'):
self.assertIn(placeholder, SUMMARY_PROMPT)

View File

@@ -0,0 +1,168 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Pure helpers for the owner-approval engagement flow.
No Odoo environment, no `request`, no network — just data in, data out.
Everything here is unit-testable in isolation, which is the same pattern
fusion_helpdesk/utils.py already follows. The OpenAI client lives in
`openai_client.py` so this file stays IO-free and trivially testable.
"""
import json
import logging
import urllib.error
import urllib.request
from markupsafe import Markup, escape
_logger = logging.getLogger(__name__)
# Frozen as a Python constant rather than an ICP key — the prompt is
# load-bearing on output quality and we want every change reviewed in git.
# Use {placeholders} to template; see build_summary_prompt for the fill.
SUMMARY_PROMPT = """You are summarising a customer support ticket for a busy executive
who needs to decide whether to approve the work.
Output rules:
- 4-6 short bullet points, plain text (no markdown).
- First bullet: the ask, in one sentence.
- Second bullet: the business impact if approved.
- Third bullet: the business impact if NOT approved (or "none material").
- Optional bullets: cost / effort signals if any are mentioned.
- Final bullet: open questions the approver should think about.
- Do not invent facts. If the thread doesn't say, write "not stated".
- No greetings, no sign-offs, no preamble.
Ticket title: {name}
Original report:
{description_plain}
Replies so far:
{messages_plain}
"""
# Bound the prompt sent to OpenAI so a runaway thread doesn't blow cost or
# context. 8000 chars * ~0.25 tokens/char ~= 2000 tokens, well under any model
# cap and cheap on gpt-4o-mini.
OPENAI_PROMPT_MAX_CHARS = 8000
def build_summary_prompt(ticket_name, description_plain, messages):
"""Render SUMMARY_PROMPT with ticket + chatter content.
`messages` is a list of dicts with at minimum {author, date, body_plain}.
We render one line per message so the model can attribute who said what.
Empty inputs collapse to "(none)" so the prompt never has bare blanks
that the model could interpret as a missing section.
"""
name = (ticket_name or '').strip() or '(untitled)'
desc = (description_plain or '').strip() or '(no description)'
if messages:
lines = []
for m in messages:
author = (m.get('author') or 'unknown').strip()
date = (m.get('date') or '').strip()
body = (m.get('body_plain') or '').strip() or '(empty)'
lines.append('%s (%s): %s' % (author, date, body))
msgs = '\n---\n'.join(lines)
else:
msgs = '(no replies yet)'
return SUMMARY_PROMPT.format(
name=name, description_plain=desc, messages_plain=msgs,
)
def truncate_for_openai(prompt, max_chars=OPENAI_PROMPT_MAX_CHARS):
"""Cap prompt length so a 50-message thread can't run up the bill.
Truncates from the END (keeps the title + description + earliest messages),
appending a marker so the model knows context was cut. Earliest replies
are usually the most relevant for an approval decision; later replies tend
to be operational chatter.
"""
if len(prompt) <= max_chars:
return prompt
marker = '\n\n[...truncated for length...]'
keep = max_chars - len(marker)
if keep <= 0:
return marker[:max_chars]
return prompt[:keep] + marker
def format_engagement_chatter(decision, owner_name, comment=None):
"""Build the chatter message posted on the ticket when the owner decides.
Returns Markup so message_post renders it as HTML (not escaped). Decision
must be 'approved' or 'rejected' — anything else raises ValueError so a
typo upstream fails loud instead of posting a half-rendered message.
"""
if decision == 'approved':
prefix, label = '', 'Approved'
elif decision == 'rejected':
prefix, label = '', 'Rejected'
else:
raise ValueError(
'format_engagement_chatter: decision must be "approved" or '
'"rejected", got %r' % (decision,)
)
name = escape((owner_name or '').strip() or 'the owner')
text = (comment or '').strip()
body = '<p><b>%s %s by %s</b></p>' % (prefix, label, name)
if text:
body += '<blockquote style="margin:6px 0 0 0; padding:6px 12px; ' \
'border-left:3px solid #d8dadd; color:#444;"><i>%s</i></blockquote>' \
% escape(text).replace('\n', '<br/>')
return Markup(body)
def call_openai_chat(api_key, model, prompt, timeout=15):
"""Single non-streaming call to OpenAI's chat-completions endpoint.
Returns the response text on success, or '' on any failure (network,
timeout, non-2xx, malformed JSON, empty choices). Never raises — the
caller treats '' as "AI unavailable" and shows the manual-summary
fallback in the wizard.
Uses urllib.request from the stdlib so we don't add a pip dependency
just for one HTTP call. JSON shape per OpenAI's chat-completions spec
as of 2026: messages, model, response choices[0].message.content.
"""
if not api_key or not prompt:
return ''
payload = json.dumps({
'model': model or 'gpt-4o-mini',
'messages': [{'role': 'user', 'content': prompt}],
'temperature': 0.2, # we want deterministic-ish summaries
}).encode('utf-8')
req = urllib.request.Request(
'https://api.openai.com/v1/chat/completions',
data=payload,
headers={
'Authorization': 'Bearer %s' % api_key,
'Content-Type': 'application/json',
},
method='POST',
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode('utf-8'))
except urllib.error.HTTPError as e:
_logger.warning(
'fusion_helpdesk_central: OpenAI returned HTTP %s', e.code,
)
return ''
except (urllib.error.URLError, TimeoutError, OSError) as e:
_logger.warning(
'fusion_helpdesk_central: OpenAI network error: %s', e,
)
return ''
except (ValueError, json.JSONDecodeError) as e:
_logger.warning(
'fusion_helpdesk_central: OpenAI returned malformed JSON: %s', e,
)
return ''
choices = (data or {}).get('choices') or []
if not choices:
return ''
content = (choices[0].get('message') or {}).get('content') or ''
return content.strip()

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>