# -*- 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( '
%s' % _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 = '
| %s | ' '%s |