chnages
This commit is contained in:
3
fusion_helpdesk/__init__.py
Normal file
3
fusion_helpdesk/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import controllers
|
||||
from . import models
|
||||
47
fusion_helpdesk/__manifest__.py
Normal file
47
fusion_helpdesk/__manifest__.py
Normal file
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Helpdesk Reporter',
|
||||
'version': '19.0.1.2.0',
|
||||
'category': 'Productivity',
|
||||
'summary': 'One-click in-app bug reporting & feature requesting — '
|
||||
'auto-creates a helpdesk.ticket on a central Odoo Helpdesk.',
|
||||
'description': """
|
||||
Fusion Helpdesk Reporter
|
||||
========================
|
||||
A standalone module that gives every backend user a quick "Report an
|
||||
Issue / Request a Feature" button in the Odoo top systray.
|
||||
|
||||
Submissions are forwarded over JSON-RPC / XML-RPC to a central Odoo
|
||||
instance (typically the support shop's main Odoo) and become
|
||||
`helpdesk.ticket` records with screenshots, file attachments, and
|
||||
diagnostic context (URL, user agent, current user/company, error
|
||||
code) automatically captured.
|
||||
|
||||
Designed to ship alongside any other Fusion / Nexa Systems client
|
||||
module bundle. No dependencies on the rest of Fusion Plating.
|
||||
""",
|
||||
'author': 'Nexa Systems Inc.',
|
||||
'website': 'https://www.nexasystems.ca',
|
||||
'license': 'OPL-1',
|
||||
'depends': ['base', 'web', 'mail'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/ir_config_parameter_data.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'fusion_helpdesk/static/src/scss/fusion_helpdesk.scss',
|
||||
'fusion_helpdesk/static/src/xml/fusion_helpdesk_systray.xml',
|
||||
'fusion_helpdesk/static/src/xml/fusion_helpdesk_dialog.xml',
|
||||
'fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js',
|
||||
'fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js',
|
||||
],
|
||||
},
|
||||
'images': ['static/description/icon.png'],
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
'application': False,
|
||||
}
|
||||
2
fusion_helpdesk/controllers/__init__.py
Normal file
2
fusion_helpdesk/controllers/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import main
|
||||
402
fusion_helpdesk/controllers/main.py
Normal file
402
fusion_helpdesk/controllers/main.py
Normal file
@@ -0,0 +1,402 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
"""HTTP routes for the Fusion Helpdesk Reporter.
|
||||
|
||||
`/fusion_helpdesk/submit` accepts the dialog payload, forwards it to a
|
||||
remote Odoo Helpdesk over XML-RPC, attaches uploaded files +
|
||||
screenshot, and returns the resulting ticket id/url to the OWL dialog.
|
||||
|
||||
Why XML-RPC and not JSON-RPC? Helpdesk's external API surface is
|
||||
exposed via Odoo's standard `/xmlrpc/2/object` endpoint which is
|
||||
the most stable cross-version contract. JSON-RPC `/jsonrpc` works
|
||||
too but historically had quirkier error handling on file payloads.
|
||||
"""
|
||||
import base64
|
||||
import logging
|
||||
import socket
|
||||
import ssl
|
||||
import xmlrpc.client
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from odoo import _, http
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionHelpdeskController(http.Controller):
|
||||
|
||||
@http.route(
|
||||
'/fusion_helpdesk/submit',
|
||||
type='jsonrpc', auth='user', methods=['POST'],
|
||||
)
|
||||
def submit(self, kind, subject, description,
|
||||
error_code=None, attachments=None,
|
||||
page_url=None, user_agent=None):
|
||||
"""Forward a bug report or feature request to the central Odoo
|
||||
Helpdesk and return {ok, ticket_id, ticket_url, error}.
|
||||
|
||||
Args:
|
||||
kind: 'bug' or 'feature'.
|
||||
subject: short title.
|
||||
description: long-form. May contain HTML or plain text.
|
||||
error_code: optional traceback / error string.
|
||||
attachments: list of {name, mimetype, data_b64} dicts.
|
||||
page_url: client-side window.location.href at submit time.
|
||||
user_agent: client-side navigator.userAgent.
|
||||
"""
|
||||
cfg = self._read_config()
|
||||
if not all([cfg['url'], cfg['db'], cfg['login'], cfg['password']]):
|
||||
return {
|
||||
'ok': False,
|
||||
'error': 'config_missing',
|
||||
'message': _(
|
||||
'Fusion Helpdesk is not fully configured. Ask an '
|
||||
'administrator to fill in the remote URL, database, '
|
||||
'login and password under Settings → Fusion Helpdesk.'
|
||||
),
|
||||
}
|
||||
|
||||
# ---- Build the ticket payload ---------------------------------
|
||||
prefix = ('[%s] ' % cfg['client_label']) if cfg['client_label'] else ''
|
||||
kind_label = 'Bug Report' if kind == 'bug' else 'Feature Request'
|
||||
full_subject = '%s%s: %s' % (prefix, kind_label, subject or '(untitled)')
|
||||
|
||||
body_parts = []
|
||||
if description:
|
||||
body_parts.append(
|
||||
'<h4>Description</h4><div>%s</div>' % _html_escape(description)
|
||||
)
|
||||
if error_code:
|
||||
body_parts.append(
|
||||
'<h4>Error Code / Traceback</h4>'
|
||||
'<pre style="background:#f5f5f5;padding:8px;border-radius:4px;'
|
||||
'white-space:pre-wrap;">%s</pre>' % _html_escape(error_code)
|
||||
)
|
||||
body_parts.append(self._build_diag_block(page_url, user_agent))
|
||||
|
||||
ticket_vals = {
|
||||
'name': full_subject,
|
||||
'description': '\n'.join(body_parts),
|
||||
}
|
||||
if cfg['team_id']:
|
||||
ticket_vals['team_id'] = cfg['team_id']
|
||||
|
||||
# ---- Talk to remote Odoo --------------------------------------
|
||||
try:
|
||||
uid, models_proxy = self._authenticate(cfg)
|
||||
except _RemoteError as e:
|
||||
return e.to_response()
|
||||
|
||||
try:
|
||||
ticket_id = models_proxy.execute_kw(
|
||||
cfg['db'], uid, cfg['password'],
|
||||
'helpdesk.ticket', 'create', [ticket_vals],
|
||||
)
|
||||
except xmlrpc.client.Fault as e:
|
||||
fault = (e.faultString or '').strip()
|
||||
_logger.warning(
|
||||
'fusion_helpdesk: helpdesk.ticket.create failed: %s',
|
||||
fault,
|
||||
)
|
||||
# The Helpdesk app might not be installed on the remote.
|
||||
if 'helpdesk.ticket' in fault and 'does not exist' in fault.lower():
|
||||
return {
|
||||
'ok': False, 'error': 'helpdesk_not_installed',
|
||||
'message': _(
|
||||
'The Helpdesk app is not installed on the central '
|
||||
'Odoo at %s. Install the Helpdesk app there before '
|
||||
'submitting tickets.'
|
||||
) % cfg['url'],
|
||||
}
|
||||
if 'access' in fault.lower() or 'rights' in fault.lower():
|
||||
return {
|
||||
'ok': False, 'error': 'permission_denied',
|
||||
'message': _(
|
||||
'The remote service account "%s" does not have '
|
||||
'permission to create helpdesk tickets. Ask a '
|
||||
'central Odoo admin to grant Helpdesk Officer '
|
||||
'rights to that user.'
|
||||
) % cfg['login'],
|
||||
}
|
||||
return {
|
||||
'ok': False, 'error': 'create_failed',
|
||||
'message': _('The remote Helpdesk rejected the ticket: %s'
|
||||
) % fault,
|
||||
}
|
||||
except (socket.timeout, OSError, ssl.SSLError) as e:
|
||||
_logger.warning(
|
||||
'fusion_helpdesk: ticket create network error: %s', e,
|
||||
)
|
||||
return _network_error_response(cfg['url'], e)
|
||||
|
||||
# ---- Push attachments -----------------------------------------
|
||||
attached = 0
|
||||
for att in attachments or []:
|
||||
data_b64 = (att or {}).get('data_b64')
|
||||
name = (att or {}).get('name') or 'attachment.bin'
|
||||
mimetype = (att or {}).get('mimetype') or 'application/octet-stream'
|
||||
if not data_b64:
|
||||
continue
|
||||
try:
|
||||
models_proxy.execute_kw(
|
||||
cfg['db'], uid, cfg['password'],
|
||||
'ir.attachment', 'create', [{
|
||||
'name': name,
|
||||
'datas': data_b64,
|
||||
'res_model': 'helpdesk.ticket',
|
||||
'res_id': ticket_id,
|
||||
'mimetype': mimetype,
|
||||
}],
|
||||
)
|
||||
attached += 1
|
||||
except xmlrpc.client.Fault as e:
|
||||
_logger.warning(
|
||||
'fusion_helpdesk: attachment "%s" upload failed: %s',
|
||||
name, e.faultString,
|
||||
)
|
||||
|
||||
ticket_url = urljoin(
|
||||
cfg['url'].rstrip('/') + '/',
|
||||
'odoo/helpdesk/%s' % ticket_id,
|
||||
)
|
||||
_logger.info(
|
||||
'fusion_helpdesk: created remote ticket #%s (%s attachments) '
|
||||
'on %s for user %s',
|
||||
ticket_id, attached, cfg['url'],
|
||||
request.env.user.login,
|
||||
)
|
||||
return {
|
||||
'ok': True,
|
||||
'ticket_id': ticket_id,
|
||||
'ticket_url': ticket_url,
|
||||
'attached': attached,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _read_config(self):
|
||||
"""Return the active config as a plain dict. Run as sudo so
|
||||
regular users can submit even without read-access on
|
||||
ir.config_parameter (the password row is system-write but
|
||||
readable by anyone with backend access)."""
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
return {
|
||||
'url': (ICP.get_param('fusion_helpdesk.remote_url') or '').strip(),
|
||||
'db': (ICP.get_param('fusion_helpdesk.remote_db') or '').strip(),
|
||||
'login': (ICP.get_param('fusion_helpdesk.remote_login') or '').strip(),
|
||||
'password': ICP.get_param('fusion_helpdesk.remote_password') or '',
|
||||
'team_id': int(
|
||||
ICP.get_param('fusion_helpdesk.remote_team_id') or 0
|
||||
) or False,
|
||||
'client_label': (
|
||||
ICP.get_param('fusion_helpdesk.client_label') or ''
|
||||
).strip(),
|
||||
}
|
||||
|
||||
def _authenticate(self, cfg):
|
||||
"""Authenticate against the remote and return (uid, models_proxy).
|
||||
|
||||
Raises _RemoteError with a granular `error` code and a friendly
|
||||
end-user message so the dialog can show what actually broke
|
||||
(network vs. credentials vs. server problem).
|
||||
"""
|
||||
url = cfg['url'].rstrip('/')
|
||||
try:
|
||||
common = xmlrpc.client.ServerProxy(
|
||||
'%s/xmlrpc/2/common' % url, allow_none=True,
|
||||
)
|
||||
try:
|
||||
uid = common.authenticate(
|
||||
cfg['db'], cfg['login'], cfg['password'], {},
|
||||
)
|
||||
except xmlrpc.client.ProtocolError as e:
|
||||
# Server returned an HTTP error status.
|
||||
_logger.warning(
|
||||
'fusion_helpdesk: HTTP %s from %s during authenticate: %s',
|
||||
e.errcode, url, e.errmsg,
|
||||
)
|
||||
if e.errcode in (401, 403):
|
||||
raise _RemoteError(
|
||||
'auth_failed',
|
||||
_('The central Odoo at %s rejected the login. '
|
||||
'Check the Service Login and API Key in '
|
||||
'Settings → Fusion Helpdesk.') % url,
|
||||
)
|
||||
if e.errcode == 404:
|
||||
raise _RemoteError(
|
||||
'endpoint_not_found',
|
||||
_('The XML-RPC endpoint at %s/xmlrpc/2/common '
|
||||
'returned 404. Verify the Remote URL points '
|
||||
'at an Odoo server (no trailing path).') % url,
|
||||
)
|
||||
raise _RemoteError(
|
||||
'remote_http_error',
|
||||
_('The central Odoo returned HTTP %(code)s while '
|
||||
'authenticating. Try again, or check that '
|
||||
'%(url)s is reachable from a browser.'
|
||||
) % {'code': e.errcode, 'url': url},
|
||||
)
|
||||
except xmlrpc.client.Fault as e:
|
||||
# Server-side application error — usually wrong DB name.
|
||||
fault = (e.faultString or '').strip()
|
||||
_logger.warning(
|
||||
'fusion_helpdesk: XML-RPC fault during authenticate: %s',
|
||||
fault,
|
||||
)
|
||||
if 'database' in fault.lower() and (
|
||||
'not exist' in fault.lower() or 'unknown' in fault.lower()
|
||||
):
|
||||
raise _RemoteError(
|
||||
'wrong_database',
|
||||
_('Database "%(db)s" does not exist on %(url)s. '
|
||||
'Verify the Remote DB in Settings → Fusion '
|
||||
'Helpdesk.') % {'db': cfg['db'], 'url': url},
|
||||
)
|
||||
raise _RemoteError(
|
||||
'auth_failed',
|
||||
_('The central Odoo rejected authentication: %s'
|
||||
) % fault,
|
||||
)
|
||||
except ssl.SSLError as e:
|
||||
_logger.warning(
|
||||
'fusion_helpdesk: TLS error against %s: %s', url, e,
|
||||
)
|
||||
raise _RemoteError(
|
||||
'tls_error',
|
||||
_('TLS / SSL handshake with %(url)s failed: '
|
||||
'%(msg)s. Either the server cert is invalid or '
|
||||
'the system cert store on this host is out of '
|
||||
'date.') % {'url': url, 'msg': str(e)},
|
||||
)
|
||||
except socket.gaierror as e:
|
||||
_logger.warning(
|
||||
'fusion_helpdesk: DNS lookup failed for %s: %s', url, e,
|
||||
)
|
||||
raise _RemoteError(
|
||||
'dns_error',
|
||||
_('Could not resolve "%(host)s". Check Settings → '
|
||||
'Fusion Helpdesk → Remote URL, or your server\'s '
|
||||
'DNS / outbound network.'
|
||||
) % {'host': urlparse(url).hostname or url},
|
||||
)
|
||||
except (ConnectionRefusedError, socket.timeout) as e:
|
||||
_logger.warning(
|
||||
'fusion_helpdesk: connection problem to %s: %s', url, e,
|
||||
)
|
||||
raise _RemoteError(
|
||||
'unreachable',
|
||||
_('Could not reach %(url)s — connection refused or '
|
||||
'timed out. Check that this server can make '
|
||||
'outbound HTTPS to %(host)s (firewall, proxy).'
|
||||
) % {'url': url, 'host': urlparse(url).hostname or url},
|
||||
)
|
||||
except OSError as e:
|
||||
# Catch-all for socket / network issues we didn't classify.
|
||||
_logger.warning(
|
||||
'fusion_helpdesk: network error to %s: %s', url, e,
|
||||
)
|
||||
raise _RemoteError(
|
||||
'unreachable',
|
||||
_('Network error reaching %(url)s: %(msg)s. Verify '
|
||||
'this server has outbound internet access.'
|
||||
) % {'url': url, 'msg': str(e)},
|
||||
)
|
||||
except _RemoteError:
|
||||
raise
|
||||
except Exception as e:
|
||||
_logger.exception(
|
||||
'fusion_helpdesk: unexpected error during authenticate '
|
||||
'against %s', url,
|
||||
)
|
||||
raise _RemoteError(
|
||||
'unknown_error',
|
||||
_('Unexpected error contacting the central Helpdesk: %s'
|
||||
) % str(e),
|
||||
)
|
||||
|
||||
if not uid:
|
||||
raise _RemoteError(
|
||||
'auth_failed',
|
||||
_('The central Odoo at %(url)s did not accept the '
|
||||
'login "%(login)s". Verify the Service Login and '
|
||||
'API Key in Settings → Fusion Helpdesk.'
|
||||
) % {'url': url, 'login': cfg['login']},
|
||||
)
|
||||
|
||||
models_proxy = xmlrpc.client.ServerProxy(
|
||||
'%s/xmlrpc/2/object' % url, allow_none=True,
|
||||
)
|
||||
return uid, models_proxy
|
||||
|
||||
def _build_diag_block(self, page_url, user_agent):
|
||||
env = request.env
|
||||
company = env.company
|
||||
user = env.user
|
||||
rows = [
|
||||
('User', '%s (#%s, %s)' % (user.name, user.id, user.login)),
|
||||
('Company', '%s (#%s)' % (company.name, company.id)),
|
||||
('Source page', page_url or '—'),
|
||||
('User agent', user_agent or '—'),
|
||||
('Source DB', request.env.cr.dbname),
|
||||
('Source host', request.httprequest.host_url),
|
||||
]
|
||||
body = '<h4>Diagnostic context</h4><table>'
|
||||
for k, v in rows:
|
||||
body += (
|
||||
'<tr><td style="padding:2px 8px;color:#666;">%s</td>'
|
||||
'<td style="padding:2px 8px;"><code>%s</code></td></tr>'
|
||||
) % (_html_escape(k), _html_escape(str(v)))
|
||||
body += '</table>'
|
||||
return body
|
||||
|
||||
|
||||
def _html_escape(s):
|
||||
return (
|
||||
(s or '')
|
||||
.replace('&', '&')
|
||||
.replace('<', '<')
|
||||
.replace('>', '>')
|
||||
)
|
||||
|
||||
|
||||
class _RemoteError(Exception):
|
||||
"""Typed wrapper that carries an `error` code + a friendly message
|
||||
so the dialog can show the right thing."""
|
||||
def __init__(self, code, message):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
def to_response(self):
|
||||
return {'ok': False, 'error': self.code, 'message': self.message}
|
||||
|
||||
|
||||
def _network_error_response(url, err):
|
||||
"""Map a raw network exception raised mid-RPC into the same
|
||||
response shape the dialog already knows how to render."""
|
||||
host = urlparse(url).hostname or url
|
||||
if isinstance(err, ssl.SSLError):
|
||||
return {
|
||||
'ok': False, 'error': 'tls_error',
|
||||
'message': _('TLS / SSL error talking to %(url)s: %(msg)s'
|
||||
) % {'url': url, 'msg': str(err)},
|
||||
}
|
||||
if isinstance(err, socket.gaierror):
|
||||
return {
|
||||
'ok': False, 'error': 'dns_error',
|
||||
'message': _('Could not resolve "%s" — check DNS / outbound network.'
|
||||
) % host,
|
||||
}
|
||||
if isinstance(err, socket.timeout):
|
||||
return {
|
||||
'ok': False, 'error': 'unreachable',
|
||||
'message': _('Timed out talking to %s — check the firewall '
|
||||
'allows outbound HTTPS.') % url,
|
||||
}
|
||||
return {
|
||||
'ok': False, 'error': 'unreachable',
|
||||
'message': _('Network error talking to %(url)s: %(msg)s'
|
||||
) % {'url': url, 'msg': str(err)},
|
||||
}
|
||||
32
fusion_helpdesk/data/ir_config_parameter_data.xml
Normal file
32
fusion_helpdesk/data/ir_config_parameter_data.xml
Normal file
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1
|
||||
|
||||
Defaults for the Fusion Helpdesk Reporter. noupdate=1 so admins
|
||||
can override on the Settings page without losing their values
|
||||
on `-u`.
|
||||
-->
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="fhd_default_remote_url" model="ir.config_parameter">
|
||||
<field name="key">fusion_helpdesk.remote_url</field>
|
||||
<field name="value">https://erp.nexasystems.ca</field>
|
||||
</record>
|
||||
|
||||
<record id="fhd_default_remote_db" model="ir.config_parameter">
|
||||
<field name="key">fusion_helpdesk.remote_db</field>
|
||||
<field name="value">nexamain</field>
|
||||
</record>
|
||||
|
||||
<record id="fhd_default_remote_login" model="ir.config_parameter">
|
||||
<field name="key">fusion_helpdesk.remote_login</field>
|
||||
<field name="value">helpdesk_bot@nexasystems.ca</field>
|
||||
</record>
|
||||
|
||||
<record id="fhd_default_client_label" model="ir.config_parameter">
|
||||
<field name="key">fusion_helpdesk.client_label</field>
|
||||
<field name="value"></field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
2
fusion_helpdesk/models/__init__.py
Normal file
2
fusion_helpdesk/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import res_config_settings
|
||||
52
fusion_helpdesk/models/res_config_settings.py
Normal file
52
fusion_helpdesk/models/res_config_settings.py
Normal file
@@ -0,0 +1,52 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
"""Configuration for the Fusion Helpdesk Reporter.
|
||||
|
||||
Stores the central Odoo Helpdesk endpoint that submissions are
|
||||
forwarded to. Defaults point at erp.nexasystems.ca / nexamain;
|
||||
each client deployment can override per system parameter.
|
||||
"""
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
fhd_remote_url = fields.Char(
|
||||
string='Helpdesk Remote URL',
|
||||
config_parameter='fusion_helpdesk.remote_url',
|
||||
help='Base URL of the central Odoo running the Helpdesk app, '
|
||||
'e.g. https://erp.nexasystems.ca',
|
||||
)
|
||||
fhd_remote_db = fields.Char(
|
||||
string='Helpdesk Remote DB',
|
||||
config_parameter='fusion_helpdesk.remote_db',
|
||||
help='Database name on the remote Odoo (e.g. nexamain).',
|
||||
)
|
||||
fhd_remote_login = fields.Char(
|
||||
string='Helpdesk Remote Login',
|
||||
config_parameter='fusion_helpdesk.remote_login',
|
||||
help='Service-account login on the remote Odoo. Needs create '
|
||||
'rights on helpdesk.ticket and ir.attachment.',
|
||||
)
|
||||
fhd_remote_password = fields.Char(
|
||||
string='Helpdesk Remote Password / API Key',
|
||||
config_parameter='fusion_helpdesk.remote_password',
|
||||
help='Service-account password or API key. Stored in '
|
||||
'ir.config_parameter — restrict read access if needed.',
|
||||
)
|
||||
fhd_remote_team_id = fields.Integer(
|
||||
string='Helpdesk Team ID',
|
||||
config_parameter='fusion_helpdesk.remote_team_id',
|
||||
help='Optional. ID of the helpdesk.team on the remote that '
|
||||
'should own all incoming tickets. Leave blank to use '
|
||||
'the remote default routing.',
|
||||
)
|
||||
fhd_client_label = fields.Char(
|
||||
string='Client Label (auto-prepended to subject)',
|
||||
config_parameter='fusion_helpdesk.client_label',
|
||||
help='Short tag prefixed onto the ticket subject so support '
|
||||
'can tell which client deployment a ticket came from. '
|
||||
'e.g. "ENTECH" → "[ENTECH] My subject"',
|
||||
)
|
||||
1
fusion_helpdesk/security/ir.model.access.csv
Normal file
1
fusion_helpdesk/security/ir.model.access.csv
Normal file
@@ -0,0 +1 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
|
BIN
fusion_helpdesk/static/description/icon.png
Normal file
BIN
fusion_helpdesk/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
244
fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js
Normal file
244
fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js
Normal file
@@ -0,0 +1,244 @@
|
||||
/** @odoo-module **/
|
||||
// Fusion Helpdesk — submission dialog. Lets the user pick Bug or
|
||||
// Feature, fill in subject + description, paste an error code, attach
|
||||
// files, and capture a screenshot via the browser's getDisplayMedia
|
||||
// API. On submit, the payload is POSTed to /fusion_helpdesk/submit
|
||||
// which forwards it (XML-RPC) to a central Odoo Helpdesk.
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
const MAX_BYTES_PER_FILE = 10 * 1024 * 1024; // 10 MB hard cap per file
|
||||
|
||||
export class FusionHelpdeskDialog extends Component {
|
||||
static template = "fusion_helpdesk.Dialog";
|
||||
static components = { Dialog };
|
||||
static props = {
|
||||
close: Function,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.state = useState({
|
||||
kind: "bug", // 'bug' | 'feature'
|
||||
subject: "",
|
||||
description: "",
|
||||
errorCode: "",
|
||||
attachments: [], // [{name, mimetype, sizeLabel, iconClass, data_b64}]
|
||||
capturing: false,
|
||||
submitting: false,
|
||||
error: "",
|
||||
success: false,
|
||||
ticketId: null,
|
||||
ticketUrl: "",
|
||||
attached: 0,
|
||||
});
|
||||
}
|
||||
|
||||
get dialogTitle() {
|
||||
return this.state.kind === "bug"
|
||||
? _t("Report a Bug")
|
||||
: _t("Request a Feature");
|
||||
}
|
||||
|
||||
setKind(kind) {
|
||||
this.state.kind = kind;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// File input → b64
|
||||
async onFilesPicked(ev) {
|
||||
const files = Array.from(ev.target.files || []);
|
||||
for (const f of files) {
|
||||
if (f.size > MAX_BYTES_PER_FILE) {
|
||||
this.notification.add(
|
||||
_t("File '%s' is over 10 MB and was skipped.").replace("%s", f.name),
|
||||
{ type: "warning" }
|
||||
);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const b64 = await this._fileToB64(f);
|
||||
this._addAttachment({
|
||||
name: f.name,
|
||||
mimetype: f.type || "application/octet-stream",
|
||||
data_b64: b64,
|
||||
rawSize: f.size,
|
||||
});
|
||||
} catch (err) {
|
||||
this.notification.add(
|
||||
_t("Could not read '%s'.").replace("%s", f.name),
|
||||
{ type: "danger" }
|
||||
);
|
||||
}
|
||||
}
|
||||
// Reset the input so picking the same file again re-fires onchange.
|
||||
ev.target.value = "";
|
||||
}
|
||||
|
||||
_fileToB64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result || "";
|
||||
const idx = result.indexOf(",");
|
||||
resolve(idx >= 0 ? result.slice(idx + 1) : result);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Screenshot capture via getDisplayMedia
|
||||
async onTakeScreenshot() {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
|
||||
this.notification.add(
|
||||
_t("Your browser doesn't support screen capture. Use Attach files instead."),
|
||||
{ type: "warning" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.state.capturing = true;
|
||||
let stream;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: { displaySurface: "browser" },
|
||||
audio: false,
|
||||
});
|
||||
const blob = await this._streamToBlob(stream);
|
||||
const b64 = await this._blobToB64(blob);
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
this._addAttachment({
|
||||
name: `screenshot-${ts}.png`,
|
||||
mimetype: "image/png",
|
||||
data_b64: b64,
|
||||
rawSize: blob.size,
|
||||
});
|
||||
} catch (err) {
|
||||
// User cancelled the picker — silently swallow. Other errors → notify.
|
||||
if (err && err.name !== "NotAllowedError" && err.name !== "AbortError") {
|
||||
this.notification.add(
|
||||
_t("Screenshot failed: %s").replace("%s", err.message || err),
|
||||
{ type: "danger" }
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
}
|
||||
this.state.capturing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _streamToBlob(stream) {
|
||||
const video = document.createElement("video");
|
||||
video.srcObject = stream;
|
||||
await video.play();
|
||||
// Give the browser one frame to settle the picker chrome.
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
return await new Promise((resolve) =>
|
||||
canvas.toBlob((b) => resolve(b), "image/png", 0.92)
|
||||
);
|
||||
}
|
||||
|
||||
_blobToB64(blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result || "";
|
||||
const idx = result.indexOf(",");
|
||||
resolve(idx >= 0 ? result.slice(idx + 1) : result);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
_addAttachment({ name, mimetype, data_b64, rawSize }) {
|
||||
this.state.attachments.push({
|
||||
name,
|
||||
mimetype,
|
||||
data_b64,
|
||||
sizeLabel: this._formatBytes(rawSize),
|
||||
iconClass: this._iconForMime(mimetype),
|
||||
});
|
||||
}
|
||||
|
||||
removeAttachment(idx) {
|
||||
this.state.attachments.splice(idx, 1);
|
||||
}
|
||||
|
||||
_formatBytes(n) {
|
||||
if (!n) return "";
|
||||
if (n < 1024) return n + " B";
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
||||
return (n / (1024 * 1024)).toFixed(1) + " MB";
|
||||
}
|
||||
|
||||
_iconForMime(mt) {
|
||||
mt = (mt || "").toLowerCase();
|
||||
if (mt.startsWith("image/")) return "fa fa-file-image-o";
|
||||
if (mt.startsWith("video/")) return "fa fa-file-video-o";
|
||||
if (mt.startsWith("audio/")) return "fa fa-file-audio-o";
|
||||
if (mt.includes("pdf")) return "fa fa-file-pdf-o";
|
||||
if (mt.includes("zip") || mt.includes("tar") || mt.includes("rar")) return "fa fa-file-archive-o";
|
||||
if (mt.includes("text")) return "fa fa-file-text-o";
|
||||
return "fa fa-file-o";
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Submit
|
||||
async onSubmit() {
|
||||
if (this.state.submitting) return;
|
||||
const subject = (this.state.subject || "").trim();
|
||||
if (!subject) {
|
||||
this.state.error = _t("Subject is required.");
|
||||
return;
|
||||
}
|
||||
this.state.error = "";
|
||||
this.state.success = false;
|
||||
this.state.submitting = true;
|
||||
try {
|
||||
const payload = {
|
||||
kind: this.state.kind,
|
||||
subject,
|
||||
description: this.state.description || "",
|
||||
error_code: this.state.kind === "bug" ? this.state.errorCode || "" : "",
|
||||
attachments: this.state.attachments.map((a) => ({
|
||||
name: a.name,
|
||||
mimetype: a.mimetype,
|
||||
data_b64: a.data_b64,
|
||||
})),
|
||||
page_url: window.location.href,
|
||||
user_agent: navigator.userAgent,
|
||||
};
|
||||
const res = await rpc("/fusion_helpdesk/submit", payload);
|
||||
if (!res.ok) {
|
||||
this.state.error = res.message || _t("Submission failed.");
|
||||
} else {
|
||||
this.state.success = true;
|
||||
this.state.ticketId = res.ticket_id;
|
||||
this.state.ticketUrl = res.ticket_url;
|
||||
this.state.attached = res.attached || 0;
|
||||
// Reset the editable fields so user can file another if they want.
|
||||
this.state.subject = "";
|
||||
this.state.description = "";
|
||||
this.state.errorCode = "";
|
||||
this.state.attachments = [];
|
||||
}
|
||||
} catch (err) {
|
||||
this.state.error = (err && err.message) || _t("Network error.");
|
||||
} finally {
|
||||
this.state.submitting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
33
fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js
Normal file
33
fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js
Normal file
@@ -0,0 +1,33 @@
|
||||
/** @odoo-module **/
|
||||
// Fusion Helpdesk — top systray icon. Sequence chosen so the icon
|
||||
// appears to the LEFT of the attendance check-in button. Odoo
|
||||
// systray ordering is by sequence ascending (lower = leftmost in the
|
||||
// systray bar). hr_attendance ships at sequence 100, so we use 99.
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { FusionHelpdeskDialog } from "./fusion_helpdesk_dialog";
|
||||
|
||||
class FusionHelpdeskSystray extends Component {
|
||||
static template = "fusion_helpdesk.SystrayItem";
|
||||
static props = {};
|
||||
|
||||
setup() {
|
||||
this.dialog = useService("dialog");
|
||||
}
|
||||
|
||||
onClick() {
|
||||
this.dialog.add(FusionHelpdeskDialog, {});
|
||||
}
|
||||
}
|
||||
|
||||
export const fusionHelpdeskSystrayItem = {
|
||||
Component: FusionHelpdeskSystray,
|
||||
};
|
||||
|
||||
registry
|
||||
.category("systray")
|
||||
.add("fusion_helpdesk.report_button", fusionHelpdeskSystrayItem, {
|
||||
sequence: 99,
|
||||
});
|
||||
172
fusion_helpdesk/static/src/scss/fusion_helpdesk.scss
Normal file
172
fusion_helpdesk/static/src/scss/fusion_helpdesk.scss
Normal file
@@ -0,0 +1,172 @@
|
||||
// Fusion Helpdesk Reporter — systray button + dialog styling.
|
||||
// Dark-mode aware via Odoo's $o-webclient-color-scheme branch.
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
$_fhd-text-hex: #21252b;
|
||||
$_fhd-muted-hex: #6c757d;
|
||||
$_fhd-bg-hex: #ffffff;
|
||||
$_fhd-border-hex: #d8dadd;
|
||||
$_fhd-hover-hex: #f3f4f6;
|
||||
$_fhd-accent-hex: #2c89e9;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_fhd-text-hex: #e6e9ef !global;
|
||||
$_fhd-muted-hex: #9aa3ad !global;
|
||||
$_fhd-bg-hex: #22262d !global;
|
||||
$_fhd-border-hex: #3a3f47 !global;
|
||||
$_fhd-hover-hex: #2c313a !global;
|
||||
$_fhd-accent-hex: #4ea3ff !global;
|
||||
}
|
||||
|
||||
$fhd-text: var(--fhd-text, $_fhd-text-hex);
|
||||
$fhd-muted: var(--fhd-muted, $_fhd-muted-hex);
|
||||
$fhd-bg: var(--fhd-bg, $_fhd-bg-hex);
|
||||
$fhd-border: var(--fhd-border, $_fhd-border-hex);
|
||||
$fhd-hover: var(--fhd-hover, $_fhd-hover-hex);
|
||||
$fhd-accent: var(--fhd-accent, $_fhd-accent-hex);
|
||||
|
||||
// Systray icon
|
||||
.o_fhd_systray {
|
||||
.o_fhd_systray_btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0 0.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover .o_fhd_systray_img {
|
||||
transform: scale(1.08);
|
||||
filter: drop-shadow(0 0 2px rgba(78, 163, 255, 0.45));
|
||||
}
|
||||
}
|
||||
|
||||
.o_fhd_systray_img {
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
object-fit: contain;
|
||||
transition: transform 120ms ease, filter 120ms ease;
|
||||
}
|
||||
|
||||
// Hide the dropdown caret Bootstrap injects.
|
||||
.dropdown-toggle::after { display: none; }
|
||||
}
|
||||
|
||||
// Dialog
|
||||
.o_fhd_dialog {
|
||||
color: $fhd-text;
|
||||
|
||||
.o_fhd_kind_row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.o_fhd_kind_chip {
|
||||
flex: 1;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background-color: $fhd-bg;
|
||||
border: 1px solid $fhd-border;
|
||||
border-radius: 6px;
|
||||
color: $fhd-text;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover { background-color: $fhd-hover; }
|
||||
|
||||
&.o_fhd_kind_active {
|
||||
border-color: $fhd-accent;
|
||||
color: $fhd-accent;
|
||||
background-color: rgba(44, 137, 233, 0.10);
|
||||
}
|
||||
}
|
||||
|
||||
.o_fhd_field {
|
||||
margin-bottom: 0.85rem;
|
||||
|
||||
> label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
color: $fhd-text;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fhd_hint {
|
||||
font-weight: 400;
|
||||
font-size: 0.8rem;
|
||||
color: $fhd-muted;
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
.o_fhd_mono {
|
||||
font-family: ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.o_fhd_actions_row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.o_fhd_btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid $fhd-border;
|
||||
background-color: $fhd-bg;
|
||||
color: $fhd-text;
|
||||
|
||||
&:hover:not(:disabled) { background-color: $fhd-hover; }
|
||||
|
||||
&:disabled { opacity: 0.6; cursor: default; }
|
||||
}
|
||||
|
||||
.o_fhd_attach_list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.o_fhd_attach_item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
background-color: $fhd-hover;
|
||||
border: 1px solid $fhd-border;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.o_fhd_attach_name {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.o_fhd_attach_size {
|
||||
color: $fhd-muted;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.o_fhd_attach_remove {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: $fhd-muted;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.25rem;
|
||||
|
||||
&:hover { color: #d32f2f; }
|
||||
}
|
||||
}
|
||||
110
fusion_helpdesk/static/src/xml/fusion_helpdesk_dialog.xml
Normal file
110
fusion_helpdesk/static/src/xml/fusion_helpdesk_dialog.xml
Normal file
@@ -0,0 +1,110 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_helpdesk.Dialog">
|
||||
<Dialog title="dialogTitle" size="'lg'">
|
||||
<div class="o_fhd_dialog">
|
||||
<!-- Kind selector -->
|
||||
<div class="o_fhd_kind_row">
|
||||
<button type="button"
|
||||
class="o_fhd_kind_chip"
|
||||
t-att-class="{ 'o_fhd_kind_active': state.kind === 'bug' }"
|
||||
t-on-click="() => this.setKind('bug')">
|
||||
<i class="fa fa-bug me-1"/> Report a Bug
|
||||
</button>
|
||||
<button type="button"
|
||||
class="o_fhd_kind_chip"
|
||||
t-att-class="{ 'o_fhd_kind_active': state.kind === 'feature' }"
|
||||
t-on-click="() => this.setKind('feature')">
|
||||
<i class="fa fa-lightbulb-o me-1"/> Request a Feature
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Subject -->
|
||||
<div class="o_fhd_field">
|
||||
<label>Subject *</label>
|
||||
<input type="text" class="form-control"
|
||||
t-att-value="state.subject"
|
||||
t-on-input="(ev) => state.subject = ev.target.value"
|
||||
t-att-placeholder="state.kind === 'bug' ? 'Short summary of what went wrong' : 'Short summary of the feature you want'"/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="o_fhd_field">
|
||||
<label t-esc="state.kind === 'bug' ? 'What were you doing? What did you expect?' : 'Describe the desired behaviour and the use case'"/>
|
||||
<textarea class="form-control" rows="5"
|
||||
t-att-value="state.description"
|
||||
t-on-input="(ev) => state.description = ev.target.value"
|
||||
placeholder="Steps to reproduce, expected vs. actual, business impact…"/>
|
||||
</div>
|
||||
|
||||
<!-- Error code (bug only) -->
|
||||
<div class="o_fhd_field" t-if="state.kind === 'bug'">
|
||||
<label>
|
||||
Error code / traceback
|
||||
<span class="o_fhd_hint">paste any error message or stack trace</span>
|
||||
</label>
|
||||
<textarea class="form-control o_fhd_mono" rows="3"
|
||||
t-att-value="state.errorCode"
|
||||
t-on-input="(ev) => state.errorCode = ev.target.value"
|
||||
placeholder="e.g. TypeError: Cannot read property 'foo' of undefined …"/>
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<div class="o_fhd_field">
|
||||
<label>Attachments</label>
|
||||
<div class="o_fhd_actions_row">
|
||||
<label class="o_fhd_btn o_fhd_btn_secondary">
|
||||
<i class="fa fa-paperclip me-1"/> Attach files
|
||||
<input type="file" multiple="multiple" class="d-none"
|
||||
t-on-change="onFilesPicked"/>
|
||||
</label>
|
||||
<button type="button" class="o_fhd_btn o_fhd_btn_secondary"
|
||||
t-on-click="onTakeScreenshot"
|
||||
t-att-disabled="state.capturing">
|
||||
<i class="fa fa-camera me-1"/>
|
||||
<t t-if="state.capturing">Capturing…</t>
|
||||
<t t-else="">Capture screenshot</t>
|
||||
</button>
|
||||
</div>
|
||||
<div t-if="state.attachments.length" class="o_fhd_attach_list">
|
||||
<div t-foreach="state.attachments" t-as="att" t-key="att_index"
|
||||
class="o_fhd_attach_item">
|
||||
<i t-att-class="att.iconClass"/>
|
||||
<span class="o_fhd_attach_name" t-esc="att.name"/>
|
||||
<span class="o_fhd_attach_size" t-esc="att.sizeLabel"/>
|
||||
<button type="button" class="o_fhd_attach_remove"
|
||||
t-on-click="() => this.removeAttachment(att_index)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result feedback -->
|
||||
<div t-if="state.error" class="alert alert-danger mt-2">
|
||||
<i class="fa fa-exclamation-triangle me-1"/> <t t-esc="state.error"/>
|
||||
</div>
|
||||
<div t-if="state.success" class="alert alert-success mt-2">
|
||||
<i class="fa fa-check-circle me-1"/>
|
||||
Thanks — ticket
|
||||
<a t-att-href="state.ticketUrl" target="_blank">
|
||||
#<t t-esc="state.ticketId"/>
|
||||
</a> created<t t-if="state.attached"> with <t t-esc="state.attached"/> attachment(s)</t>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<t t-set-slot="footer">
|
||||
<button class="btn btn-primary"
|
||||
t-on-click="onSubmit"
|
||||
t-att-disabled="state.submitting or !state.subject.trim()">
|
||||
<t t-if="state.submitting"><i class="fa fa-spinner fa-spin me-1"/></t>
|
||||
<t t-else=""><i class="fa fa-paper-plane me-1"/></t>
|
||||
Submit
|
||||
</button>
|
||||
<button class="btn btn-secondary" t-on-click="props.close">
|
||||
Close
|
||||
</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
17
fusion_helpdesk/static/src/xml/fusion_helpdesk_systray.xml
Normal file
17
fusion_helpdesk/static/src/xml/fusion_helpdesk_systray.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_helpdesk.SystrayItem">
|
||||
<div class="o_fhd_systray dropdown">
|
||||
<button type="button"
|
||||
class="o_fhd_systray_btn dropdown-toggle"
|
||||
title="Report a bug or request a feature"
|
||||
t-on-click="onClick">
|
||||
<img src="/fusion_helpdesk/static/description/icon.png"
|
||||
alt="Help"
|
||||
class="o_fhd_systray_img"/>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
51
fusion_helpdesk/views/res_config_settings_views.xml
Normal file
51
fusion_helpdesk/views/res_config_settings_views.xml
Normal file
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="res_config_settings_view_form_fhd" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.fusion.helpdesk</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//form" position="inside">
|
||||
<app data-string="Fusion Helpdesk"
|
||||
string="Fusion Helpdesk"
|
||||
name="fusion_helpdesk">
|
||||
<block title="Central Helpdesk Endpoint"
|
||||
name="fhd_endpoint">
|
||||
<setting id="fhd_remote_url"
|
||||
string="Remote URL"
|
||||
help="Base URL of the Odoo instance running the Helpdesk app.">
|
||||
<field name="fhd_remote_url" placeholder="https://erp.nexasystems.ca"/>
|
||||
</setting>
|
||||
<setting id="fhd_remote_db"
|
||||
string="Remote Database">
|
||||
<field name="fhd_remote_db" placeholder="nexamain"/>
|
||||
</setting>
|
||||
<setting id="fhd_remote_login"
|
||||
string="Service Login">
|
||||
<field name="fhd_remote_login" placeholder="helpdesk_bot@nexasystems.ca"/>
|
||||
</setting>
|
||||
<setting id="fhd_remote_password"
|
||||
string="Service Password / API Key">
|
||||
<field name="fhd_remote_password" password="True"/>
|
||||
</setting>
|
||||
<setting id="fhd_remote_team_id"
|
||||
string="Helpdesk Team ID (optional)">
|
||||
<field name="fhd_remote_team_id"/>
|
||||
</setting>
|
||||
<setting id="fhd_client_label"
|
||||
string="Client Label"
|
||||
help="Tag prefixed to every ticket subject so support can identify the source deployment.">
|
||||
<field name="fhd_client_label" placeholder="ENTECH"/>
|
||||
</setting>
|
||||
</block>
|
||||
</app>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user