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:
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Helpdesk Reporter',
|
'name': 'Fusion Helpdesk Reporter',
|
||||||
'version': '19.0.1.7.1',
|
'version': '19.0.2.0.0',
|
||||||
'category': 'Productivity',
|
'category': 'Productivity',
|
||||||
'summary': 'One-click in-app bug reporting & feature requesting — '
|
'summary': 'One-click in-app bug reporting & feature requesting — '
|
||||||
'auto-creates a helpdesk.ticket on a central Odoo Helpdesk.',
|
'auto-creates a helpdesk.ticket on a central Odoo Helpdesk.',
|
||||||
|
|||||||
@@ -101,6 +101,15 @@ class FusionHelpdeskController(http.Controller):
|
|||||||
company_name=request.env.company.name,
|
company_name=request.env.company.name,
|
||||||
is_critical=bool(is_critical),
|
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 --------------------------------------
|
# ---- Talk to remote Odoo --------------------------------------
|
||||||
try:
|
try:
|
||||||
@@ -219,6 +228,12 @@ class FusionHelpdeskController(http.Controller):
|
|||||||
'client_label': (
|
'client_label': (
|
||||||
ICP.get_param('fusion_helpdesk.client_label') or ''
|
ICP.get_param('fusion_helpdesk.client_label') or ''
|
||||||
).strip(),
|
).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):
|
def _authenticate(self, cfg):
|
||||||
|
|||||||
@@ -50,3 +50,22 @@ class ResConfigSettings(models.TransientModel):
|
|||||||
'can tell which client deployment a ticket came from. '
|
'can tell which client deployment a ticket came from. '
|
||||||
'e.g. "ENTECH" → "[ENTECH] My subject"',
|
'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.',
|
||||||
|
)
|
||||||
|
|||||||
@@ -43,6 +43,19 @@
|
|||||||
<field name="fhd_client_label" placeholder="ENTECH"/>
|
<field name="fhd_client_label" placeholder="ENTECH"/>
|
||||||
</setting>
|
</setting>
|
||||||
</block>
|
</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>
|
</app>
|
||||||
</xpath>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from . import models
|
from . import models
|
||||||
|
from . import controllers
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1
|
# License OPL-1
|
||||||
{
|
{
|
||||||
'name': 'Fusion Helpdesk Central — Client API Keys',
|
'name': 'Fusion Helpdesk Central — Client API Keys',
|
||||||
'version': '19.0.1.2.0',
|
'version': '19.0.2.0.0',
|
||||||
'category': 'Productivity',
|
'category': 'Productivity',
|
||||||
'summary': 'Admin UI on the central Odoo for issuing per-client API '
|
'summary': 'Admin UI on the central Odoo for issuing per-client API '
|
||||||
'keys used by fusion_helpdesk client deployments.',
|
'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',
|
'security/ir.model.access.csv',
|
||||||
'data/ir_config_parameter_data.xml',
|
'data/ir_config_parameter_data.xml',
|
||||||
'data/mail_template_ack.xml',
|
'data/mail_template_ack.xml',
|
||||||
|
'data/mail_template_engagement.xml',
|
||||||
'data/helpdesk_tag_critical.xml',
|
'data/helpdesk_tag_critical.xml',
|
||||||
|
'data/ir_cron_engagement_reminder.xml',
|
||||||
'views/fusion_helpdesk_client_key_views.xml',
|
'views/fusion_helpdesk_client_key_views.xml',
|
||||||
'views/helpdesk_ticket_views.xml',
|
'views/helpdesk_ticket_views.xml',
|
||||||
|
'views/engagement_wizard_views.xml',
|
||||||
|
'views/engagement_reporting_views.xml',
|
||||||
|
'views/portal_templates.xml',
|
||||||
],
|
],
|
||||||
'installable': True,
|
'installable': True,
|
||||||
'auto_install': False,
|
'auto_install': False,
|
||||||
|
|||||||
2
fusion_helpdesk_central/controllers/__init__.py
Normal file
2
fusion_helpdesk_central/controllers/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import engagement
|
||||||
147
fusion_helpdesk_central/controllers/engagement.py
Normal file
147
fusion_helpdesk_central/controllers/engagement.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1
|
||||||
|
"""Public portal routes for the owner-approval magic links.
|
||||||
|
|
||||||
|
Owner clicks the Approve / Reject button in the email -> GET lands here.
|
||||||
|
Page shows ticket title + AI summary + comment box + Confirm button.
|
||||||
|
POST records the decision via helpdesk.ticket._fc_finalize_engagement.
|
||||||
|
|
||||||
|
No login required; the UUID4 in the URL is the auth. Tokens are single-
|
||||||
|
use (cleared on finalize), so the second click on the same link shows a
|
||||||
|
friendly "link no longer valid" page instead of double-recording the
|
||||||
|
decision.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import _, http
|
||||||
|
from odoo.http import request
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FusionHelpdeskEngagementController(http.Controller):
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Token resolution — single source of truth for the GET + POST handlers.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _resolve(self, token, decision):
|
||||||
|
"""Return (ticket, decision_state) or (None, None) on any problem.
|
||||||
|
|
||||||
|
The "no problem" cases:
|
||||||
|
- token is non-empty
|
||||||
|
- decision is one of {'approve', 'reject'}
|
||||||
|
- a single ticket matches the token AND is in state='pending'
|
||||||
|
|
||||||
|
Anything else -> (None, None), caller renders the friendly
|
||||||
|
"link no longer valid" page.
|
||||||
|
"""
|
||||||
|
if not token or not isinstance(token, str):
|
||||||
|
return (None, None)
|
||||||
|
if decision not in ('approve', 'reject'):
|
||||||
|
return (None, None)
|
||||||
|
decision_state = 'approved' if decision == 'approve' else 'rejected'
|
||||||
|
ticket = request.env['helpdesk.ticket'].sudo().search(
|
||||||
|
[('x_fc_engagement_token', '=', token),
|
||||||
|
('x_fc_engagement_state', '=', 'pending')],
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
if not ticket:
|
||||||
|
return (None, None)
|
||||||
|
return (ticket, decision_state)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET — render the confirmation page (or invalid-link page).
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(
|
||||||
|
'/fusion_helpdesk/engagement/<string:token>/<string:decision>',
|
||||||
|
type='http', auth='public', methods=['GET'], csrf=False, sitemap=False,
|
||||||
|
)
|
||||||
|
def engagement_show(self, token, decision, **kw):
|
||||||
|
ticket, decision_state = self._resolve(token, decision)
|
||||||
|
if not ticket:
|
||||||
|
return request.render(
|
||||||
|
'fusion_helpdesk_central.engagement_invalid', {},
|
||||||
|
)
|
||||||
|
return request.render(
|
||||||
|
'fusion_helpdesk_central.engagement_confirm',
|
||||||
|
{
|
||||||
|
'ticket': ticket,
|
||||||
|
'decision': decision, # url-friendly string
|
||||||
|
'decision_state': decision_state, # 'approved' / 'rejected'
|
||||||
|
'token': token,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST — record the decision, post chatter, clear token.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(
|
||||||
|
'/fusion_helpdesk/engagement/<string:token>/<string:decision>',
|
||||||
|
type='http', auth='public', methods=['POST'], csrf=False, sitemap=False,
|
||||||
|
)
|
||||||
|
def engagement_submit(self, token, decision, **post):
|
||||||
|
ticket, decision_state = self._resolve(token, decision)
|
||||||
|
if not ticket:
|
||||||
|
# Could be a second click on the same link, or a token rotated
|
||||||
|
# by a re-engagement, or a typo. Same friendly page for all.
|
||||||
|
return request.render(
|
||||||
|
'fusion_helpdesk_central.engagement_invalid', {},
|
||||||
|
)
|
||||||
|
comment = (post.get('comment') or '').strip()
|
||||||
|
owner_partner = self._find_or_create_owner_partner(ticket)
|
||||||
|
try:
|
||||||
|
ticket._fc_finalize_engagement(
|
||||||
|
decision_state, owner_partner, comment=comment or None,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
_logger.exception(
|
||||||
|
'fusion_helpdesk_central: failed to finalize engagement '
|
||||||
|
'for ticket %s (token=%s, decision=%s)',
|
||||||
|
ticket.id, token, decision_state,
|
||||||
|
)
|
||||||
|
return request.render(
|
||||||
|
'fusion_helpdesk_central.engagement_error', {},
|
||||||
|
)
|
||||||
|
return request.render(
|
||||||
|
'fusion_helpdesk_central.engagement_done',
|
||||||
|
{
|
||||||
|
'ticket': ticket,
|
||||||
|
'decision_state': decision_state,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _find_or_create_owner_partner(self, ticket):
|
||||||
|
"""Resolve the res.partner used to attribute the chatter message.
|
||||||
|
|
||||||
|
Find-or-create by snapshotted email — mirrors the customer-reply
|
||||||
|
attribution pattern in fusion_helpdesk/controllers/main.py so the
|
||||||
|
approval chatter shows up under a proper partner name (matters for
|
||||||
|
the employee's My Tickets thread per the "fully visible" UX).
|
||||||
|
Falls back to no author (= bot user) if email is empty or the
|
||||||
|
partner create fails.
|
||||||
|
"""
|
||||||
|
email = (ticket.x_fc_engagement_email or '').strip().lower()
|
||||||
|
name = (ticket.x_fc_engagement_name or '').strip()
|
||||||
|
if not email:
|
||||||
|
return None
|
||||||
|
Partner = request.env['res.partner'].sudo()
|
||||||
|
# Use exact match on lowercased email — the snapshot was already
|
||||||
|
# normalised at engagement time.
|
||||||
|
partner = Partner.search([('email', '=ilike', email)],
|
||||||
|
order='id asc', limit=1)
|
||||||
|
if partner:
|
||||||
|
return partner
|
||||||
|
try:
|
||||||
|
return Partner.create({
|
||||||
|
'name': name or email.split('@')[0].title(),
|
||||||
|
'email': email,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
_logger.warning(
|
||||||
|
'fusion_helpdesk_central: could not create owner partner '
|
||||||
|
'for %s on ticket %s; chatter will be attributed to the '
|
||||||
|
'service account.', email, ticket.id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
@@ -10,4 +10,19 @@
|
|||||||
<field name="value">support@nexasystems.ca</field>
|
<field name="value">support@nexasystems.ca</field>
|
||||||
</record>
|
</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>
|
</odoo>
|
||||||
|
|||||||
30
fusion_helpdesk_central/data/ir_cron_engagement_reminder.xml
Normal file
30
fusion_helpdesk_central/data/ir_cron_engagement_reminder.xml
Normal 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>
|
||||||
169
fusion_helpdesk_central/data/mail_template_engagement.xml
Normal file
169
fusion_helpdesk_central/data/mail_template_engagement.xml
Normal 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 & 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>
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from . import fusion_helpdesk_client_key
|
from . import fusion_helpdesk_client_key
|
||||||
from . import helpdesk_ticket
|
from . import helpdesk_ticket
|
||||||
|
from . import engagement_wizard
|
||||||
|
|||||||
380
fusion_helpdesk_central/models/engagement_wizard.py
Normal file
380
fusion_helpdesk_central/models/engagement_wizard.py
Normal 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.',
|
||||||
|
)
|
||||||
@@ -33,6 +33,24 @@ class FusionHelpdeskClientKey(models.Model):
|
|||||||
string='Notes',
|
string='Notes',
|
||||||
help='Optional. Stamp deployment URL, contact, install date.',
|
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(
|
bot_user_id = fields.Many2one(
|
||||||
'res.users', string='Bot User', readonly=True,
|
'res.users', string='Bot User', readonly=True,
|
||||||
ondelete='restrict',
|
ondelete='restrict',
|
||||||
|
|||||||
@@ -1,15 +1,28 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
# Copyright 2026 Nexa Systems Inc.
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
# License OPL-1
|
# 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
|
Adds:
|
||||||
the embedded inbox can scope per client) and sends a branded acknowledgement
|
- `x_fc_client_label` deployment tag (set by the in-app reporter so the
|
||||||
email — carrying the portal magic link — when an in-app ticket is created.
|
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 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__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -24,13 +37,311 @@ class HelpdeskTicket(models.Model):
|
|||||||
'lets support filter tickets by originating deployment.',
|
'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
|
@api.model_create_multi
|
||||||
def create(self, vals_list):
|
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 = super().create(vals_list)
|
||||||
tickets._fc_send_ack_email()
|
tickets._fc_send_ack_email()
|
||||||
tickets._fc_auto_tag_critical()
|
tickets._fc_auto_tag_critical()
|
||||||
return tickets
|
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):
|
def _fc_auto_tag_critical(self):
|
||||||
"""Auto-apply the Critical tag on in-app tickets that were filed with
|
"""Auto-apply the Critical tag on in-app tickets that were filed with
|
||||||
priority='3' (Urgent — the client-side "Mark as Critical" toggle).
|
priority='3' (Urgent — the client-side "Mark as Critical" toggle).
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
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_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,2 +1,4 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from . import test_identity
|
from . import test_identity
|
||||||
|
from . import test_utils
|
||||||
|
from . import test_engagement
|
||||||
|
|||||||
414
fusion_helpdesk_central/tests/test_engagement.py
Normal file
414
fusion_helpdesk_central/tests/test_engagement.py
Normal 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()
|
||||||
193
fusion_helpdesk_central/tests/test_utils.py
Normal file
193
fusion_helpdesk_central/tests/test_utils.py
Normal 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('<script>', 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('<b>', 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)
|
||||||
168
fusion_helpdesk_central/utils.py
Normal file
168
fusion_helpdesk_central/utils.py
Normal 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()
|
||||||
143
fusion_helpdesk_central/views/engagement_reporting_views.xml
Normal file
143
fusion_helpdesk_central/views/engagement_reporting_views.xml
Normal 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 > 7 days" name="stuck"
|
||||||
|
domain="[('x_fc_engagement_state', '=', 'pending'),
|
||||||
|
('x_fc_engagement_sent_at', '<=',
|
||||||
|
(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>
|
||||||
67
fusion_helpdesk_central/views/engagement_wizard_views.xml
Normal file
67
fusion_helpdesk_central/views/engagement_wizard_views.xml
Normal 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>
|
||||||
@@ -27,8 +27,100 @@
|
|||||||
<field name="x_fc_client_label"/>
|
<field name="x_fc_client_label"/>
|
||||||
<filter string="Client Deployment" name="group_client_label"
|
<filter string="Client Deployment" name="group_client_label"
|
||||||
context="{'group_by': 'x_fc_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>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</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>
|
</odoo>
|
||||||
|
|||||||
132
fusion_helpdesk_central/views/portal_templates.xml
Normal file
132
fusion_helpdesk_central/views/portal_templates.xml
Normal 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>
|
||||||
Reference in New Issue
Block a user