403 lines
16 KiB
Python
403 lines
16 KiB
Python
# -*- 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)},
|
|
}
|