chnages
This commit is contained in:
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)},
|
||||
}
|
||||
Reference in New Issue
Block a user