Initial commit: OCA Technical packages (595 packages)

This commit is contained in:
Ernad Husremovic 2025-08-29 15:43:03 +02:00
commit 2cc02aac6e
24950 changed files with 2318079 additions and 0 deletions

View file

@ -0,0 +1,4 @@
from .fastapi_endpoint import FastapiEndpoint
from . import fastapi_endpoint_demo
from . import ir_rule
from . import res_lang

View file

@ -0,0 +1,346 @@
# Copyright 2022 ACSONE SA/NV
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL).
import logging
from functools import partial
from itertools import chain
from typing import Any, Callable, Dict, List, Tuple
from starlette.middleware import Middleware
from starlette.routing import Mount
from odoo import _, api, exceptions, fields, models, tools
from fastapi import APIRouter, Depends, FastAPI
from .. import dependencies
from ..middleware import ASGIMiddleware
_logger = logging.getLogger(__name__)
class FastapiEndpoint(models.Model):
_name = "fastapi.endpoint"
_inherit = "endpoint.route.sync.mixin"
_description = "FastAPI Endpoint"
name: str = fields.Char(required=True, help="The title of the API.")
description: str = fields.Text(
help="A short description of the API. It can use Markdown"
)
root_path: str = fields.Char(
required=True,
index=True,
compute="_compute_root_path",
inverse="_inverse_root_path",
readonly=False,
store=True,
copy=False,
)
app: str = fields.Selection(selection=[], required=True)
user_id = fields.Many2one(
comodel_name="res.users",
string="User",
help="The user to use to execute the API calls.",
default=lambda self: self.env.ref("base.public_user"),
)
docs_url: str = fields.Char(compute="_compute_urls")
redoc_url: str = fields.Char(compute="_compute_urls")
openapi_url: str = fields.Char(compute="_compute_urls")
company_id = fields.Many2one(
"res.company",
compute="_compute_company_id",
store=True,
readonly=False,
domain="[('user_ids', 'in', user_id)]",
)
save_http_session = fields.Boolean(
string="Save HTTP Session",
help="Whether session should be saved into the session store. This is "
"required if for example you use the Odoo's authentication mechanism. "
"Oherwise chance are high that you don't need it and could turn off "
"this behaviour. Additionaly turning off this option will prevent useless "
"IO operation when storing and reading the session on the disk and prevent "
"unexpecteed disk space consumption.",
default=True,
)
@api.depends("root_path")
def _compute_root_path(self):
for rec in self:
rec.root_path = rec._clean_root_path()
def _inverse_root_path(self):
for rec in self:
rec.root_path = rec._clean_root_path()
def _clean_root_path(self):
root_path = (self.root_path or "").strip()
if not root_path.startswith("/"):
root_path = "/" + root_path
return root_path
_blacklist_root_paths = {"/", "/web", "/website"}
@api.constrains("root_path")
def _check_root_path(self):
for rec in self:
if rec.root_path in self._blacklist_root_paths:
raise exceptions.UserError(
_(
"`%(name)s` uses a blacklisted root_path = `%(root_path)s`",
name=rec.name,
root_path=rec.root_path,
)
)
@api.depends("root_path")
def _compute_urls(self):
for rec in self:
rec.docs_url = f"{rec.root_path}/docs"
rec.redoc_url = f"{rec.root_path}/redoc"
rec.openapi_url = f"{rec.root_path}/openapi.json"
@api.depends("user_id")
def _compute_company_id(self):
for endpoint in self:
endpoint.company_id = endpoint.user_id.company_id
#
# endpoint.route.sync.mixin methods implementation
#
def _prepare_endpoint_rules(self, options=None):
return [rec._make_routing_rule(options=options) for rec in self]
def _registered_endpoint_rule_keys(self):
res = []
for rec in self:
routing = rec._get_routing_info()
res.append(rec._endpoint_registry_route_unique_key(routing))
return tuple(res)
@api.model
def _routing_impacting_fields(self) -> Tuple[str, ...]:
"""The list of fields requiring to refresh the mount point of the pp
into odoo if modified"""
return ("root_path", "save_http_session")
#
# end of endpoint.route.sync.mixin methods implementation
#
def write(self, vals):
res = super().write(vals)
self._handle_route_updates(vals)
return res
def action_sync_registry(self):
self.filtered(lambda e: not e.registry_sync).write({"registry_sync": True})
def _handle_route_updates(self, vals):
observed_fields = [self._routing_impacting_fields(), self._fastapi_app_fields()]
refresh_fastapi_app = any([x in vals for x in chain(*observed_fields)])
if refresh_fastapi_app:
self._reset_app()
if "user_id" in vals:
self.get_uid.clear_cache(self)
return False
@api.model
def _fastapi_app_fields(self) -> List[str]:
"""The list of fields requiring to refresh the fastapi app if modified"""
return []
def _make_routing_rule(self, options=None):
"""Generator of rule"""
self.ensure_one()
routing = self._get_routing_info()
options = options or self._default_endpoint_options()
route = "|".join(routing["routes"])
key = self._endpoint_registry_route_unique_key(routing)
endpoint_hash = hash(route)
return self._endpoint_registry.make_rule(
key, route, options, routing, endpoint_hash
)
def _default_endpoint_options(self):
options = {"handler": self._default_endpoint_options_handler()}
return options
def _default_endpoint_options_handler(self):
# The handler is useless in the context of a fastapi endpoint since the
# routing type is "fastapi" and the routing is handled by a dedicated
# dispatcher that will forward the request to the fastapi app.
base_path = "odoo.addons.endpoint_route_handler.controllers.main"
return {
"klass_dotted_path": f"{base_path}.EndpointNotFoundController",
"method_name": "auto_not_found",
}
def _get_routing_info(self):
self.ensure_one()
return {
"type": "fastapi",
"auth": "public",
"methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
"routes": [
f"{self.root_path}/",
f"{self.root_path}/<path:application_path>",
],
"save_session": self.save_http_session,
# csrf ?????
}
def _endpoint_registry_route_unique_key(self, routing: Dict[str, Any]):
route = "|".join(routing["routes"])
path = route.replace(self.root_path, "")
return f"{self._name}:{self.id}:{path}"
def _reset_app(self):
self._get_id_by_root_path_map.clear_cache(self)
self._get_id_for_path.clear_cache(self)
self._reset_app_cache_marker.clear_cache(self)
@tools.ormcache()
def _reset_app_cache_marker(self):
"""This methos is used to get a way to mark the orm cache as dirty
when the app is reset. By marking the cache as dirty, the system
will signal to others instances that the cache is not up to date
and that they should invalidate their cache as well. This is required
to ensure that any change requiring a reset of the app is propagated
to all the running instances.
"""
@api.model
def _normalize_url_path(self, path) -> str:
"""
Normalize a URL path:
* Remove redundant slashes,
* Remove trailing slash (unless it's the root),
* Lowercase for case-insensitive matching
"""
parts = [part.lower() for part in path.strip().split("/") if part]
return "/" + "/".join(parts)
@api.model
def _is_suburl(self, path, prefix) -> bool:
"""
Check if 'path' is a subpath of 'prefix' in URL logic:
* Must start with the prefix followed by a slash
This will ensure that the matching is done one the path
parts and ensures that e.g. /a/b is not prefix of /a/bc.
"""
path = self._normalize_url_path(path)
prefix = self._normalize_url_path(prefix)
if path == prefix:
return True
if path.startswith(prefix + "/"):
return True
return False
@api.model
def _find_first_matching_url_path(self, paths, prefix) -> str | None:
"""
Return the first path that is a subpath of 'prefix',
ordered by longest URL path first (most number of segments).
"""
# Sort by number of segments (shallowest first)
sorted_paths = sorted(
paths,
key=lambda p: len(self._normalize_url_path(p).split("/")),
reverse=True,
)
for path in sorted_paths:
if self._is_suburl(prefix, path):
return path
return None
@api.model
@tools.ormcache()
def _get_id_by_root_path_map(self):
return {r.root_path: r.id for r in self.search([])}
@api.model
@tools.ormcache("path")
def _get_id_for_path(self, path):
id_by_path = self._get_id_by_root_path_map()
root_path = self._find_first_matching_url_path(id_by_path.keys(), path)
return id_by_path.get(root_path)
@api.model
def _get_endpoint(self, path):
id_ = self._get_id_for_path(path)
return self.browse(id_) if id_ else None
@api.model
def get_app(self, path):
record = self._get_endpoint(path)
if not record:
return None
app = FastAPI()
app.mount(record.root_path, record._get_app())
self._clear_fastapi_exception_handlers(app)
return ASGIMiddleware(app)
def _clear_fastapi_exception_handlers(self, app: FastAPI) -> None:
"""
Clear the exception handlers of the given fastapi app.
This method is used to ensure that the exception handlers are handled
by odoo and not by fastapi. We therefore need to remove all the handlers
added by default when instantiating a FastAPI app. Since apps can be
mounted recursively, we need to apply this method to all the apps in the
mounted tree.
"""
app.exception_handlers = {}
for route in app.routes:
if isinstance(route, Mount):
self._clear_fastapi_exception_handlers(route.app)
@api.model
@tools.ormcache("path")
def get_uid(self, path):
record = self._get_endpoint(path)
if not record:
return None
return record.user_id.id
def _get_app(self) -> FastAPI:
app = FastAPI(**self._prepare_fastapi_app_params())
for router in self._get_fastapi_routers():
app.include_router(router=router)
app.dependency_overrides.update(self._get_app_dependencies_overrides())
return app
def _get_app_dependencies_overrides(self) -> Dict[Callable, Callable]:
return {
dependencies.fastapi_endpoint_id: partial(lambda a: a, self.id),
dependencies.company_id: partial(lambda a: a, self.company_id.id),
}
def _prepare_fastapi_app_params(self) -> Dict[str, Any]:
"""Return the params to pass to the Fast API app constructor"""
return {
"title": self.name,
"description": self.description,
"middleware": self._get_fastapi_app_middlewares(),
"dependencies": self._get_fastapi_app_dependencies(),
}
def _get_fastapi_routers(self) -> List[APIRouter]:
"""Return the api routers to use for the instance.
This method must be implemented when registering a new api type.
"""
return []
def _get_fastapi_app_middlewares(self) -> List[Middleware]:
"""Return the middlewares to use for the fastapi app."""
return []
def _get_fastapi_app_dependencies(self) -> List[Depends]:
"""Return the dependencies to use for the fastapi app."""
return [Depends(dependencies.accept_language)]

View file

@ -0,0 +1,105 @@
# Copyright 2022 ACSONE SA/NV
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL).
from typing import Annotated, Any, List
from odoo import _, api, fields, models
from odoo.api import Environment
from odoo.exceptions import ValidationError
from odoo.addons.base.models.res_partner import Partner
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import APIKeyHeader
from ..dependencies import (
authenticated_partner_from_basic_auth_user,
authenticated_partner_impl,
odoo_env,
)
from ..routers import demo_router, demo_router_doc
class FastapiEndpoint(models.Model):
_inherit = "fastapi.endpoint"
app: str = fields.Selection(
selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"}
)
demo_auth_method = fields.Selection(
selection=[("api_key", "Api Key"), ("http_basic", "HTTP Basic")],
string="Authenciation method",
)
def _get_fastapi_routers(self) -> List[APIRouter]:
if self.app == "demo":
return [demo_router]
return super()._get_fastapi_routers()
@api.constrains("app", "demo_auth_method")
def _valdiate_demo_auth_method(self):
for rec in self:
if rec.app == "demo" and not rec.demo_auth_method:
raise ValidationError(
_(
"The authentication method is required for app %(app)s",
app=rec.app,
)
)
@api.model
def _fastapi_app_fields(self) -> List[str]:
fields = super()._fastapi_app_fields()
fields.append("demo_auth_method")
return fields
def _get_app(self):
app = super()._get_app()
if self.app == "demo":
# Here we add the overrides to the authenticated_partner_impl method
# according to the authentication method configured on the demo app
if self.demo_auth_method == "http_basic":
authenticated_partner_impl_override = (
authenticated_partner_from_basic_auth_user
)
else:
authenticated_partner_impl_override = (
api_key_based_authenticated_partner_impl
)
app.dependency_overrides[
authenticated_partner_impl
] = authenticated_partner_impl_override
return app
def _prepare_fastapi_app_params(self) -> dict[str, Any]:
params = super()._prepare_fastapi_app_params()
if self.app == "demo":
tags_metadata = params.get("openapi_tags", []) or []
tags_metadata.append({"name": "demo", "description": demo_router_doc})
params["openapi_tags"] = tags_metadata
return params
def api_key_based_authenticated_partner_impl(
api_key: Annotated[
str,
Depends(
APIKeyHeader(
name="api-key",
description="In this demo, you can use a user's login as api key.",
)
),
],
env: Annotated[Environment, Depends(odoo_env)],
) -> Partner:
"""A dummy implementation that look for a user with the same login
as the provided api key
"""
partner = (
env["res.users"].sudo().search([("login", "=", api_key)], limit=1).partner_id
)
if not partner:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect API Key"
)
return partner

View file

@ -0,0 +1,27 @@
# Copyright 2022 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, models
class IrRule(models.Model):
"""Add authenticated_partner_id in record rule evaluation context.
This come from the env current odoo environment which is
populated by dependency method authenticated_partner_env or authenticated_partner
when a route handler depends one of them and is called by the FastAPI service layer.
"""
_inherit = "ir.rule"
@api.model
def _eval_context(self):
ctx = super()._eval_context()
ctx["authenticated_partner_id"] = self.env.context.get(
"authenticated_partner_id", False
)
return ctx
def _compute_domain_keys(self):
"""Return the list of context keys to use for caching ``_compute_domain``."""
return super()._compute_domain_keys() + ["authenticated_partner_id"]

View file

@ -0,0 +1,46 @@
# Copyright 2023 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from collections import defaultdict
from accept_language import parse_accept_language
from odoo import api, models, tools
class ResLang(models.Model):
_inherit = "res.lang"
@api.model
@tools.ormcache("accept_language")
def _get_lang_from_accept_language(self, accept_language):
"""Get the language from the Accept-Language header.
:param accept_language: The Accept-Language header.
:return: The language code.
"""
if not accept_language:
return
parsed_accepted_langs = parse_accept_language(accept_language)
installed_locale_langs = set()
installed_locale_by_lang = defaultdict(list)
for lang_code, _name in self.get_installed():
installed_locale_langs.add(lang_code)
installed_locale_by_lang[lang_code.split("_")[0]].append(lang_code)
# parsed_acccepted_langs is sorted by priority (higher first)
for lang in parsed_accepted_langs:
# we first check if a locale (en_GB) is available into the list of
# available locales into Odoo
locale = None
if lang.locale in installed_locale_langs:
locale = lang.locale
# if no locale language is installed, we look for an available
# locale for the given language (en). We return the first one
# found for this language.
else:
locales = installed_locale_by_lang.get(lang.language)
if locales:
locale = locales[0]
if locale:
return locale