Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[14.0] [ADD] sixteen in fourteen and fastapi_backport #307

Open
wants to merge 11 commits into
base: 14.0
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions fastapi_backport/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Copyright 2024 Akretion (http://www.akretion.com).
# @author Florian Mounier <florian.mounier@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

import logging
from . import models
from . import http
from starlette.responses import JSONResponse
from odoo import SUPERUSER_ID, api
from odoo.addons.extendable.models.ir_http import IrHttp
from odoo.addons.fastapi.fastapi_dispatcher import FastApiDispatcher
from odoo.addons.fastapi.tests.common import FastAPITransactionCase
from odoo.tests.common import SavepointCase

_logger = logging.getLogger(__name__)


# Use SavepointCase instead of TransactionCase (16.0 merge)
# And use test mode to avoid deadlock in envioronment RLock
class TestModeSavepointCase(SavepointCase):
def setUp(self):
super().setUp()
self.registry.enter_test_mode(self.env.cr)

def tearDown(self):
self.registry.leave_test_mode()
super().tearDown()

@classmethod
def _patch_app_to_handle_exception(cls, app):
def handle_error(request, exc):

def make_json_response(body, status, headers):
response = JSONResponse(body, status_code=status)
if status == 500:
_logger.error("Error in test request", exc_info=exc)
if headers:
response.headers.update(headers)
return response

request.make_json_response = make_json_response
return FastApiDispatcher(request).handle_error(exc)

app.exception_handlers = {Exception: handle_error}


FastAPITransactionCase.__bases__ = (TestModeSavepointCase,)


@classmethod
def _dispatch(cls):
with cls._extendable_context_registry():
return super(IrHttp, cls)._dispatch()


IrHttp._dispatch = _dispatch


def post_init_hook(cr, registry):
env = api.Environment(cr, SUPERUSER_ID, {})
# this is the trigger that sends notifications when jobs change
_logger.info("Resyncing registries")
endpoints_ids = env["fastapi.endpoint"].search([]).ids
env["fastapi.endpoint"]._handle_registry_sync(endpoints_ids)
21 changes: 21 additions & 0 deletions fastapi_backport/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Copyright 2024 Akretion (http://www.akretion.com).
# @author Florian Mounier <florian.mounier@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

{
"name": "Fastapi Backport",
"summary": "Backport of FastAPI to Odoo 14.0",
"version": "14.0.1.0.0",
"author": " Akretion",
"license": "AGPL-3",
"depends": [
"sixteen_in_fourteen",
"base_contextvars",
"base_future_response",
"fastapi",
"pydantic",
"extendable_fastapi",
"extendable",
],
"post_init_hook": "post_init_hook",
}
113 changes: 113 additions & 0 deletions fastapi_backport/http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Copyright 2023 ACSONE SA/NV
# Copyright 2024 Akretion (http://www.akretion.com).
# @author Florian Mounier <florian.mounier@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

import json
import logging
from functools import lru_cache

import werkzeug.datastructures

import odoo
from odoo import http
from odoo.tools import date_utils

from odoo.addons.fastapi.fastapi_dispatcher import FastApiDispatcher

_logger = logging.getLogger(__name__)


class FastapiRootPaths:
_root_paths_by_db = {}

@classmethod
def set_root_paths(cls, db, root_paths):
cls._root_paths_by_db[db] = root_paths
cls.is_fastapi_path.cache_clear()

@classmethod
@lru_cache(maxsize=1024)
def is_fastapi_path(cls, db, path):
return any(
path.startswith(root_path)
for root_path in cls._root_paths_by_db.get(db, [])
)


class FastapiRequest(http.WebRequest):
_request_type = "fastapi"

def __init__(self, *args):
super().__init__(*args)
self.params = {}
self._dispatcher = http._dispatchers.get("fastapi", FastApiDispatcher)(self)
# Ensure inner_exception exists on dispatcher since the flow on error
# is quite different
self._dispatcher.inner_exception = None

def make_response(self, data, headers=None, cookies=None, status=200):
"""Helper for non-HTML responses, or HTML responses with custom
response headers or cookies.

While handlers can just return the HTML markup of a page they want to
send as a string if non-HTML data is returned they need to create a
complete response object, or the returned data will not be correctly
interpreted by the clients.

:param basestring data: response body
:param headers: HTTP headers to set on the response
:type headers: ``[(name, value)]``
:param collections.abc.Mapping cookies: cookies to set on the client
"""
response = http.Response(data, status=status, headers=headers)
if cookies:
for k, v in cookies.items():
response.set_cookie(k, v)
return response

def make_json_response(self, data, headers=None, cookies=None, status=200):
"""Helper for JSON responses, it json-serializes ``data`` and
sets the Content-Type header accordingly if none is provided.

:param data: the data that will be json-serialized into the response body
:param int status: http status code
:param List[(str, str)] headers: HTTP headers to set on the response
:param collections.abc.Mapping cookies: cookies to set on the client
:rtype: :class:`~odoo.http.Response`
"""
data = json.dumps(data, ensure_ascii=False, default=date_utils.json_default)

headers = werkzeug.datastructures.Headers(headers)
headers["Content-Length"] = len(data)
if "Content-Type" not in headers:
headers["Content-Type"] = "application/json; charset=utf-8"

return self.make_response(data, headers.to_wsgi_list(), cookies, status)

def dispatch(self):
return self._dispatcher.dispatch(None, None)

def _handle_exception(self, exception):
_logger.exception(
"Exception during fastapi request handling", exc_info=exception
)
return self._dispatcher.handle_error(exception)


ori_get_request = http.root.__class__.get_request


def get_request(self, httprequest):
db = httprequest.session.db
if db and odoo.service.db.exp_db_exist(db):
# on the very first request processed by a worker,
# registry is not loaded yet
# so we enforce its loading here.
odoo.registry(db)
if FastapiRootPaths.is_fastapi_path(db, httprequest.path):
return FastapiRequest(httprequest)
return ori_get_request(self, httprequest)


http.root.__class__.get_request = get_request
1 change: 1 addition & 0 deletions fastapi_backport/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import fastapi_endpoint
33 changes: 33 additions & 0 deletions fastapi_backport/models/fastapi_endpoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Copyright 2024 Akretion (http://www.akretion.com).
# @author Florian Mounier <florian.mounier@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).


from odoo import api, models

from ..http import FastapiRootPaths


class FastapiEndpoint(models.Model):
_inherit = "fastapi.endpoint"

@api.model
def _update_root_paths_registry(self):
root_paths = self.env["fastapi.endpoint"].search([]).mapped("root_path")
FastapiRootPaths.set_root_paths(self.env.cr.dbname, root_paths)

def _register_hook(self):
super()._register_hook()
self._update_root_paths_registry()

def _inverse_root_path(self):
super()._inverse_root_path()
self._update_root_paths_registry()

@api.depends("root_path")
def _compute_urls(self):
base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url")
for rec in self:
rec.docs_url = f"{base_url}{rec.root_path}/docs"
rec.redoc_url = f"{base_url}{rec.root_path}/redoc"
rec.openapi_url = f"{base_url}{rec.root_path}/openapi.json"
1 change: 1 addition & 0 deletions setup/fastapi_backport/odoo/addons/fastapi_backport
6 changes: 6 additions & 0 deletions setup/fastapi_backport/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import setuptools

setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)
1 change: 1 addition & 0 deletions setup/sixteen_in_fourteen/odoo/addons/sixteen_in_fourteen
6 changes: 6 additions & 0 deletions setup/sixteen_in_fourteen/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import setuptools

setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)
83 changes: 83 additions & 0 deletions sixteen_in_fourteen/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Copyright 2024 Akretion (http://www.akretion.com).
# @author Florian Mounier <florian.mounier@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

import sys
import importlib
from contextlib import contextmanager

from . import models # noqa: F401

MOVED_MODULES = {
"odoo.addons.sale.models.sale_order_line": "odoo.addons.sale.models.sale",
"odoo.addons.sale.models.sale_order": "odoo.addons.sale.models.sale",
}
EXTENDED_MODULES = [
"odoo.tools.float_utils",
"odoo.tests.common",
"odoo.http",
"odoo.fields",
]


def extend(module, name):
extended_module = importlib.import_module(f"odoo.addons.sixteen_in_fourteen.{name}")
module.__dict__.update(
{
key: value
for key, value in extended_module.__dict__.items()
if not key.startswith("__")
}
)


class SixteenInFourteenMovedHook(object):
def find_module(self, name, path=None):
if name in MOVED_MODULES:
return self

def load_module(self, name):
assert name not in sys.modules
odoo_module = sys.modules.get(name)
if not odoo_module:
odoo_module = importlib.import_module(MOVED_MODULES[name])
sys.modules[name] = odoo_module
return odoo_module


class SixteenInFourteenExtendedHook(object):
def __init__(self):
self._ignore = []

def find_module(self, name, path=None):
if name in EXTENDED_MODULES and name not in self._ignore:
return self

def load_module(self, name):
assert name not in sys.modules
odoo_module = sys.modules.get(name)

if not odoo_module:
with self.ignore(name):
odoo_module = importlib.import_module(name)
extend(odoo_module, name)

sys.modules[name] = odoo_module
return odoo_module

@contextmanager
def ignore(self, name):
try:
self._ignore.append(name)
yield
finally:
self._ignore.remove(name)


sys.meta_path.insert(0, SixteenInFourteenMovedHook())
sys.meta_path.insert(0, SixteenInFourteenExtendedHook())

# Also patch already imported modules
for mod in EXTENDED_MODULES:
if mod in sys.modules:
extend(sys.modules[mod], mod)
19 changes: 19 additions & 0 deletions sixteen_in_fourteen/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Copyright 2024 Akretion (http://www.akretion.com).
# @author Florian Mounier <florian.mounier@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

{
"name": "Sixteen in Fourteen",
"summary": "Layer of compat to run 16.0 modules in 14.0",
"version": "14.0.1.0.0",
"category": "Technical",
"website": "https://github.com/akretion/ak-odoo-incubator",
"author": " Akretion",
"license": "AGPL-3",
"application": False,
"installable": True,
"depends": [
"base",
],
"data": [],
}
1 change: 1 addition & 0 deletions sixteen_in_fourteen/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import base
Loading
Loading