mirror of
https://github.com/bringout/oca-technical.git
synced 2026-04-18 11:12:04 +02:00
Initial commit: OCA Technical packages (595 packages)
This commit is contained in:
commit
2cc02aac6e
24950 changed files with 2318079 additions and 0 deletions
|
|
@ -0,0 +1,4 @@
|
|||
from .fastapi_endpoint import FastapiEndpoint
|
||||
from . import fastapi_endpoint_demo
|
||||
from . import ir_rule
|
||||
from . import res_lang
|
||||
|
|
@ -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)]
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue