Files
Odoo-Modules/fusion_helpdesk/controllers/main.py
gsinghpal 586f05d567 chnages
2026-05-04 02:14:34 -04:00

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('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;')
)
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)},
}