add rpc addon and fix 19.0 compatibility issues

- add missing rpc addon (auto_install, required by server_wide_modules)
- add __init__.py -> init.py symlink for odoo package importability
- re-export image_process from odoo.tools (needed by web_editor)
- add backward-compatible slug/unslug functions in http_routing

🤖 assisted by claude
This commit is contained in:
Ernad Husremovic 2026-03-09 15:19:28 +01:00
parent 2d3ee4855a
commit 3037cab43e
11 changed files with 629 additions and 0 deletions

View file

@ -0,0 +1 @@
init.py

View file

@ -18,3 +18,4 @@ from .translate import _, html_translate, xml_translate, LazyTranslate
from .xml_utils import cleanup_xml_node, load_xsd_files_from_url, validate_xml_from_attachment
from .convert import convert_csv_import, convert_file, convert_sql_import, convert_xml_import
from .set_expression import SetDefinitions
from .image import image_process, image_data_uri, base64_to_image, image_to_base64

View file

@ -628,3 +628,14 @@ class IrHttp(models.AbstractModel):
except werkzeug.exceptions.NotFound:
new_url = path
return new_url or path, endpoint and endpoint[0]
# Backward-compatible module-level functions that delegate to IrHttp class methods.
# In Odoo 19.0 these were moved to IrHttp._slug/_unslug, but many addons
# still import them as standalone functions.
def slug(value):
return IrHttp._slug(value)
def unslug(value):
return IrHttp._unslug(value)

View file

@ -0,0 +1 @@
from . import controllers

View file

@ -0,0 +1,15 @@
{
'name': 'RPC endpoints',
'description': """\
Standard Odoo RPC endpoints to models
=====================================
This module provides the /xmlrpc and /jsonrpc endpoints used to
programmatically access models.
""",
'depends': ["base"],
'category': 'Extra Tools',
'auto_install': True,
'author': 'Odoo S.A.',
'license': 'LGPL-3',
}

View file

@ -0,0 +1,29 @@
import odoo.release
from odoo.http import request, route
from . import json2
RPC_DEPRECATION_NOTICE = """\
The /xmlrpc, /xmlrpc/2 and /jsonrpc endpoints are deprecated in Odoo 19 \
and scheduled for removal in Odoo 20. Please report the problem to the \
client making the request.
Mute this logger: --log-handler %s:ERROR
https://www.odoo.com/documentation/latest/developer/reference/external_api.html#migrating-from-xml-rpc-json-rpc"""
def _check_request():
if request.db:
request.env.cr.close()
from .jsonrpc import JSONRPC # noqa: E402
from .xmlrpc import XMLRPC # noqa: E402
class RPC(XMLRPC, JSONRPC):
@route(['/web/version', '/json/version'], type='http', auth='none', readonly=True)
def version(self):
return request.make_json_response({
'version_info': odoo.release.version_info,
'version': odoo.release.version,
})

View file

@ -0,0 +1,90 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import inspect
import logging
from collections.abc import Mapping, Sequence
from typing import Any
from werkzeug.exceptions import (
NotFound,
UnprocessableEntity,
)
from odoo import http
from odoo.http import request
from odoo.models import BaseModel
from odoo.service.model import get_public_method
from odoo.tools import frozendict
_logger = logging.getLogger(__name__)
class WebJson2Controller(http.Controller):
def _web_json_2_rpc_readonly(self, rule, args):
try:
model_name = args['__model__']
method_name = args['__method__']
Model = request.registry[model_name]
except KeyError:
# no need of a read/write cursor to send a 404 http error
return True
for cls in Model.mro():
method = getattr(cls, method_name, None)
if method is not None and hasattr(method, '_readonly'):
return method._readonly
return False
# Take over /json/<path:subpath>
@http.route(
['/json/2', '/json/2/<path:subpath>'],
auth='public',
type='json2',
readonly=True,
methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
)
def web_json_2_404(self, subpath=None):
e = "Did you mean POST /json/2/<model>/<method>?"
raise request.not_found(e)
@http.route(
'/json/2/<__model__>/<__method__>',
methods=['POST'],
auth='bearer',
type='json2',
readonly=_web_json_2_rpc_readonly,
save_session=False,
)
def web_json_2_rpc(
self,
__model__: str,
__method__: str,
ids: Sequence[int] = (),
context: Mapping[str, Any] = frozendict(),
**kwargs,
):
try:
Model = request.env[__model__].with_context(context)
except KeyError as exc:
e = f"the model {__model__!r} does not exist"
raise NotFound(e) from exc
try:
func = get_public_method(Model, __method__)
except AttributeError as exc:
raise NotFound(exc.args[0]) from exc
if hasattr(func, '_api_model') and ids:
e = f"cannot call {__model__}.{__method__} with ids"
raise UnprocessableEntity(e)
records = Model.browse(ids)
signature = inspect.signature(func)
try:
signature.bind(records, **kwargs)
except TypeError as exc:
raise UnprocessableEntity(exc.args[0])
result = func(records, **kwargs)
if isinstance(result, BaseModel):
result = result.ids
return result

View file

@ -0,0 +1,16 @@
import logging
from odoo.http import Controller, dispatch_rpc, route
from . import RPC_DEPRECATION_NOTICE, _check_request
logger = logging.getLogger(__name__)
class JSONRPC(Controller):
@route('/jsonrpc', type='jsonrpc', auth="none", save_session=False)
def jsonrpc(self, service, method, args):
""" Method used by client APIs to contact OpenERP. """
logger.warning(RPC_DEPRECATION_NOTICE, __name__)
_check_request()
return dispatch_rpc(service, method, args)

View file

@ -0,0 +1,169 @@
import logging
import sys
import traceback
import xmlrpc.client
from collections import defaultdict
from datetime import date, datetime
from markupsafe import Markup
import odoo.exceptions
from odoo.fields import Command, Date, Datetime
from odoo.http import Controller, Response, dispatch_rpc, request, route
from odoo.tools import lazy
from odoo.tools.misc import frozendict
from . import RPC_DEPRECATION_NOTICE, _check_request
logger = logging.getLogger(__name__)
# XML-RPC fault codes. Some care must be taken when changing these: the
# constants are also defined client-side and must remain in sync.
# User code must use the exceptions defined in ``odoo.exceptions`` (not
# create directly ``xmlrpc.client.Fault`` objects).
RPC_FAULT_CODE_CLIENT_ERROR = 1 # indistinguishable from app. error.
RPC_FAULT_CODE_APPLICATION_ERROR = 1
RPC_FAULT_CODE_WARNING = 2
RPC_FAULT_CODE_ACCESS_DENIED = 3
RPC_FAULT_CODE_ACCESS_ERROR = 4
# 0 to 31, excluding tab, newline, and carriage return
CONTROL_CHARACTERS = dict.fromkeys(set(range(32)) - {9, 10, 13})
def xmlrpc_handle_exception_int(e):
if isinstance(e, odoo.exceptions.RedirectWarning):
fault = xmlrpc.client.Fault(RPC_FAULT_CODE_WARNING, str(e))
elif isinstance(e, odoo.exceptions.AccessError):
fault = xmlrpc.client.Fault(RPC_FAULT_CODE_ACCESS_ERROR, str(e))
elif isinstance(e, odoo.exceptions.AccessDenied):
fault = xmlrpc.client.Fault(RPC_FAULT_CODE_ACCESS_DENIED, str(e))
elif isinstance(e, odoo.exceptions.UserError):
fault = xmlrpc.client.Fault(RPC_FAULT_CODE_WARNING, str(e))
else:
info = sys.exc_info()
formatted_info = "".join(traceback.format_exception(*info))
fault = xmlrpc.client.Fault(RPC_FAULT_CODE_APPLICATION_ERROR, formatted_info)
return dumps(fault)
def xmlrpc_handle_exception_string(e):
if isinstance(e, odoo.exceptions.RedirectWarning):
fault = xmlrpc.client.Fault(f'warning -- Warning\n\n{e}', '')
elif isinstance(e, odoo.exceptions.MissingError):
fault = xmlrpc.client.Fault(f'warning -- MissingError\n\n{e}', '')
elif isinstance(e, odoo.exceptions.AccessError):
fault = xmlrpc.client.Fault(f'warning -- AccessError\n\n{e}', '')
elif isinstance(e, odoo.exceptions.AccessDenied):
fault = xmlrpc.client.Fault('AccessDenied', str(e))
elif isinstance(e, odoo.exceptions.UserError):
fault = xmlrpc.client.Fault(f'warning -- UserError\n\n{e}', '')
# InternalError
else:
info = sys.exc_info()
formatted_info = "".join(traceback.format_exception(*info))
fault = xmlrpc.client.Fault(odoo.tools.exception_to_unicode(e), formatted_info)
return dumps(fault)
class OdooMarshaller(xmlrpc.client.Marshaller):
dispatch = dict(xmlrpc.client.Marshaller.dispatch)
def dump_frozen_dict(self, value, write):
value = dict(value)
self.dump_struct(value, write)
# By default, in xmlrpc, bytes are converted to xmlrpc.client.Binary object.
# Historically, odoo is sending binary as base64 string.
# In python 3, base64.b64{de,en}code() methods now works on bytes.
def dump_bytes(self, value, write):
self.dump_unicode(value.decode(), write)
def dump_datetime(self, value, write):
# override to marshall as a string for backwards compatibility
value = Datetime.to_string(value)
self.dump_unicode(value, write)
# convert date objects to strings in iso8061 format.
def dump_date(self, value, write):
value = Date.to_string(value)
self.dump_unicode(value, write)
def dump_lazy(self, value, write):
v = value._value
return self.dispatch[type(v)](self, v, write)
def dump_unicode(self, value, write):
# XML 1.0 disallows control characters, remove them otherwise they break clients
return super().dump_unicode(value.translate(CONTROL_CHARACTERS), write)
dispatch[frozendict] = dump_frozen_dict
dispatch[bytes] = dump_bytes
dispatch[datetime] = dump_datetime
dispatch[date] = dump_date
dispatch[lazy] = dump_lazy
dispatch[str] = dump_unicode
dispatch[Command] = dispatch[int]
dispatch[defaultdict] = dispatch[dict]
dispatch[Markup] = lambda self, value, write: self.dispatch[str](self, str(value), write)
def dumps(params: list | tuple | xmlrpc.client.Fault) -> str:
response = OdooMarshaller(allow_none=False).dumps(params)
return f"""\
<?xml version="1.0"?>
<methodResponse>
{response}
</methodResponse>
"""
# ==========================================================
# RPC Controller
# ==========================================================
class XMLRPC(Controller):
"""Handle RPC connections."""
def _xmlrpc(self, service):
"""Common method to handle an XML-RPC request."""
data = request.httprequest.get_data()
params, method = xmlrpc.client.loads(data, use_datetime=True)
result = dispatch_rpc(service, method, params)
return dumps((result,))
@route("/xmlrpc/<service>", auth="none", methods=["POST"], csrf=False, save_session=False)
def xmlrpc_1(self, service):
"""XML-RPC service that returns faultCode as strings.
This entrypoint is historical and non-compliant, but kept for
backwards-compatibility.
"""
logger.warning(RPC_DEPRECATION_NOTICE, __name__)
_check_request()
try:
response = self._xmlrpc(service)
except Exception as error:
error.error_response = Response(
response=xmlrpc_handle_exception_string(error),
mimetype='text/xml',
)
raise
return Response(response=response, mimetype='text/xml')
@route("/xmlrpc/2/<service>", auth="none", methods=["POST"], csrf=False, save_session=False)
def xmlrpc_2(self, service):
"""XML-RPC service that returns faultCode as int."""
logger.warning(RPC_DEPRECATION_NOTICE, __name__)
_check_request()
try:
response = self._xmlrpc(service)
except Exception as error:
error.error_response = Response(
response=xmlrpc_handle_exception_int(error),
mimetype='text/xml',
)
raise
return Response(response=response, mimetype='text/xml')

View file

@ -0,0 +1 @@
from . import test_xmlrpc

View file

@ -0,0 +1,295 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import collections
import datetime
import time
import odoo
import odoo.tools
from odoo.exceptions import AccessDenied, AccessError
from odoo.http import _request_stack
from odoo.service import common as auth
from odoo.service import model
from odoo.tests import common
from odoo.tools import DotDict, mute_logger
from odoo.addons.base.tests.common import SavepointCaseWithUserDemo
class TestExternalAPI(SavepointCaseWithUserDemo):
def test_call_kw(self):
"""kwargs is not modified by the execution of the call"""
partner = self.env['res.partner'].create({'name': 'MyPartner1'})
args = (partner.ids, ['name'])
kwargs = {'context': {'test': True}}
model.call_kw(self.env['res.partner'], 'read', args, kwargs)
self.assertEqual(kwargs, {'context': {'test': True}})
@common.tagged('post_install', '-at_install')
class TestXMLRPC(common.HttpCase):
def setUp(self):
super(TestXMLRPC, self).setUp()
self.admin_uid = self.env.ref('base.user_admin').id
ml_xml = mute_logger('odoo.addons.rpc.controllers.xmlrpc')
ml_xml.__enter__() # noqa: PLC2801
self.addCleanup(ml_xml.__exit__)
ml_json = mute_logger('odoo.addons.rpc.controllers.jsonrpc')
ml_json.__enter__() # noqa: PLC2801
self.addCleanup(ml_json.__exit__)
def xmlrpc(self, model, method, *args, **kwargs):
return self.xmlrpc_object.execute_kw(
common.get_db_name(), self.admin_uid, 'admin',
model, method, args, kwargs
)
def test_01_xmlrpc_login(self):
""" Try to login on the common service. """
db_name = common.get_db_name()
uid = self.xmlrpc_common.login(db_name, 'admin', 'admin')
self.assertEqual(uid, self.admin_uid)
def test_xmlrpc_ir_model_search(self):
""" Try a search on the object service. """
o = self.xmlrpc_object
db_name = common.get_db_name()
ids = o.execute(db_name, self.admin_uid, 'admin', 'ir.model', 'search', [])
self.assertIsInstance(ids, list)
ids = o.execute(db_name, self.admin_uid, 'admin', 'ir.model', 'search', [], {})
self.assertIsInstance(ids, list)
def test_xmlrpc_datetime(self):
""" Test that native datetime can be sent over xmlrpc
"""
m = self.env.ref('base.model_res_device_log')
self.env['ir.model.access'].create({
'name': "w/e",
'model_id': m.id,
'perm_read': True,
'perm_create': True,
})
now = datetime.datetime.now()
ids = self.xmlrpc(
'res.device.log', 'create',
{'session_identifier': "abc", 'first_activity': now, 'revoked': False}
)
[r] = self.xmlrpc(
'res.device.log', 'read',
ids, ['first_activity'],
)
self.assertEqual(r['first_activity'], now.isoformat(" ", "seconds"))
def test_xmlrpc_read_group(self):
self.xmlrpc_object.execute(
common.get_db_name(), self.admin_uid, 'admin',
'res.partner', 'formatted_read_group', [], ['parent_id'], ['color:sum'],
)
def test_xmlrpc_name_search(self):
self.xmlrpc_object.execute(
common.get_db_name(), self.admin_uid, 'admin',
'res.partner', 'name_search', "admin"
)
def test_xmlrpc_html_field(self):
sig = '<p>bork bork bork <span style="font-weight: bork">bork</span><br></p>'
r = self.env['res.users'].create({
'name': 'bob',
'login': 'bob',
'signature': sig
})
self.assertEqual(str(r.signature), sig)
[x] = self.xmlrpc('res.users', 'read', r.id, ['signature'])
self.assertEqual(x['signature'], sig)
def test_xmlrpc_frozendict_marshalling(self):
""" Test that the marshalling of a frozendict object works properly over XMLRPC """
self.env.ref('base.user_admin').tz = "Europe/Brussels"
ctx = self.xmlrpc_object.execute(
common.get_db_name(), self.admin_uid, 'admin',
'res.users', 'context_get',
)
self.assertEqual(ctx['lang'], 'en_US')
self.assertEqual(ctx['tz'], 'Europe/Brussels')
def test_xmlrpc_defaultdict_marshalling(self):
"""
Test that the marshalling of a collections.defaultdict object
works properly over XMLRPC
"""
self.patch(self.registry['res.users'], 'context_get',
odoo.api.model(lambda *_: collections.defaultdict(int)))
self.assertEqual(self.xmlrpc('res.users', 'context_get'), {})
def test_xmlrpc_remove_control_characters(self):
record = self.env['res.users'].create({
'name': 'bob with a control character: \x03',
'login': 'bob',
})
self.assertEqual(record.name, 'bob with a control character: \x03')
[record_data] = self.xmlrpc('res.users', 'read', record.id, ['name'])
self.assertEqual(record_data['name'], 'bob with a control character: ')
def test_jsonrpc_read_group(self):
self._json_call(
common.get_db_name(), self.admin_uid, 'admin',
'res.partner', 'formatted_read_group', [], ['parent_id'], ['color:sum'],
)
def test_jsonrpc_name_search(self):
# well that's some sexy sexy call right there
self._json_call(
common.get_db_name(),
self.admin_uid, 'admin',
'res.partner', 'name_search', 'admin'
)
def _json_call(self, *args):
self.url_open(f"{self.base_url()}/jsonrpc", json={
'jsonrpc': '2.0',
'id': None,
'method': 'call',
'params': {
'service': 'object',
'method': 'execute',
'args': args
}
})
def test_xmlrpc_attachment_raw(self):
ids = self.env['ir.attachment'].create({'name': 'n', 'raw': b'\x01\x09'}).ids
[att] = self.xmlrpc_object.execute(
common.get_db_name(), self.admin_uid, 'admin',
'ir.attachment', 'read', ids, ['raw'])
self.assertEqual(att['raw'], '\t',
"on read, binary data should be decoded as a string and stripped from control character")
# really just for the test cursor
@common.tagged('post_install', '-at_install')
class TestAPIKeys(common.HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls._user = cls.env['res.users'].create({
'name': "Bylan",
'login': 'byl',
'password': 'ananananan',
'tz': 'Australia/Eucla',
})
def setUp(self):
super().setUp()
def get_json_data():
raise ValueError("There is no json here")
# needs a fake request in order to call methods protected with check_identity
self.http_request_key = self.canonical_tag
fake_req = DotDict({
# various things go and access request items
'httprequest': DotDict({
'environ': {'REMOTE_ADDR': 'localhost'},
'cookies': {common.TEST_CURSOR_COOKIE_NAME: self.canonical_tag},
'args': {},
}),
'cookies': {common.TEST_CURSOR_COOKIE_NAME: self.canonical_tag},
# bypass check_identity flow
'session': {'identity-check-last': time.time()},
'geoip': {},
'get_json_data': get_json_data,
})
_request_stack.push(fake_req)
self.addCleanup(_request_stack.pop)
def test_trivial(self):
uid = auth.dispatch('authenticate', [self.env.cr.dbname, 'byl', 'ananananan', {}])
self.assertEqual(uid, self._user.id)
ctx = model.dispatch('execute_kw', [
self.env.cr.dbname, uid, 'ananananan',
'res.users', 'context_get', []
])
self.assertEqual(ctx['tz'], 'Australia/Eucla')
def test_wrongpw(self):
# User.authenticate raises but RPC.authenticate returns False
uid = auth.dispatch('authenticate', [self.env.cr.dbname, 'byl', 'aws', {}])
self.assertFalse(uid)
with self.assertRaises(AccessDenied):
model.dispatch('execute_kw', [
self.env.cr.dbname, self._user.id, 'aws',
'res.users', 'context_get', []
])
def test_key(self):
env = self.env(user=self._user)
r = env['res.users.apikeys.description'].create({
'name': 'a',
}).make_key()
k = r['context']['default_key']
uid = auth.dispatch('authenticate', [self.env.cr.dbname, 'byl', 'ananananan', {}])
self.assertEqual(uid, self._user.id)
uid = auth.dispatch('authenticate', [self.env.cr.dbname, 'byl', k, {}])
self.assertEqual(uid, self._user.id)
ctx = model.dispatch('execute_kw', [
self.env.cr.dbname, uid, k,
'res.users', 'context_get', []
])
self.assertEqual(ctx['tz'], 'Australia/Eucla')
api_key = model.call_kw(
model=self.env['res.users.apikeys.description'],
name='create',
args=[{'name': 'Name of the key'}],
kwargs={}
)
self.assertTrue(isinstance(api_key, int))
def test_delete(self):
env = self.env(user=self._user)
env['res.users.apikeys.description'].create({'name': 'b',}).make_key()
env['res.users.apikeys.description'].create({'name': 'b',}).make_key()
env['res.users.apikeys.description'].create({'name': 'b',}).make_key()
k0, k1, k2 = env['res.users.apikeys'].search([])
# user can remove their own keys
k0.remove()
self.assertFalse(k0.exists())
# admin can remove user keys
k1.with_user(self.env.ref('base.user_admin')).remove ()
self.assertFalse(k1.exists())
# other user can't remove user keys
u = self.env['res.users'].create({
'name': 'a',
'login': 'a',
'group_ids': self.env.ref('base.group_user').ids,
})
with self.assertRaises(AccessError):
k2.with_user(u).remove()
def test_disabled(self):
env = self.env(user=self._user)
k = env['res.users.apikeys.description'].create({'name': 'b',}).make_key()['context']['default_key']
self._user.active = False
with self.assertRaises(AccessDenied):
model.dispatch('execute_kw', [
self.env.cr.dbname, self._user.id, 'ananananan',
'res.users', 'context_get', []
])
with self.assertRaises(AccessDenied):
model.dispatch('execute_kw', [
self.env.cr.dbname, self._user.id, k,
'res.users', 'context_get', []
])