mirror of
https://github.com/bringout/oca-technical.git
synced 2026-04-18 04:32:01 +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
1608
odoo-bringout-oca-rest-framework-fastapi/fastapi/README.rst
Normal file
1608
odoo-bringout-oca-rest-framework-fastapi/fastapi/README.rst
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,3 @@
|
|||
from . import models
|
||||
from . import fastapi_dispatcher
|
||||
from . import error_handlers
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
# Copyright 2022 ACSONE SA/NV
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL).
|
||||
|
||||
{
|
||||
"name": "Odoo FastAPI",
|
||||
"summary": """
|
||||
Odoo FastAPI endpoint""",
|
||||
"version": "16.0.1.7.0",
|
||||
"license": "LGPL-3",
|
||||
"author": "ACSONE SA/NV,Odoo Community Association (OCA)",
|
||||
"maintainers": ["lmignon"],
|
||||
"website": "https://github.com/OCA/rest-framework",
|
||||
"depends": ["endpoint_route_handler"],
|
||||
"data": [
|
||||
"security/res_groups.xml",
|
||||
"security/fastapi_endpoint.xml",
|
||||
"security/ir_rule+acl.xml",
|
||||
"views/fastapi_menu.xml",
|
||||
"views/fastapi_endpoint.xml",
|
||||
"views/fastapi_endpoint_demo.xml",
|
||||
],
|
||||
"demo": ["demo/fastapi_endpoint_demo.xml"],
|
||||
"external_dependencies": {
|
||||
"python": [
|
||||
"fastapi>=0.110.0",
|
||||
"python-multipart",
|
||||
"ujson",
|
||||
"a2wsgi>=1.10.6",
|
||||
"parse-accept-language",
|
||||
]
|
||||
},
|
||||
"development_status": "Beta",
|
||||
}
|
||||
10
odoo-bringout-oca-rest-framework-fastapi/fastapi/context.py
Normal file
10
odoo-bringout-oca-rest-framework-fastapi/fastapi/context.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Copyright 2022 ACSONE SA/NV
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL).
|
||||
|
||||
# define context vars to hold the odoo env
|
||||
|
||||
from contextvars import ContextVar
|
||||
|
||||
from odoo.api import Environment
|
||||
|
||||
odoo_env_ctx: ContextVar[Environment] = ContextVar("odoo_env_ctx")
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<!-- Copyright 2022 ACSONE SA/NV
|
||||
License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). -->
|
||||
<odoo>
|
||||
<!-- This is the user that will be used to run the demo app -->
|
||||
<record
|
||||
id="my_demo_app_user"
|
||||
model="res.users"
|
||||
context="{'no_reset_password': True, 'no_reset_password': True}"
|
||||
>
|
||||
<field name="name">My Demo Endpoint User</field>
|
||||
<field name="login">my_demo_app_user</field>
|
||||
<field name="groups_id" eval="[(6, 0, [])]" />
|
||||
</record>
|
||||
|
||||
<!-- This is the group that will be used to run the demo app
|
||||
This group will only depend on the "group_fastapi_endpoint_runner" group
|
||||
that provides the minimal access rights to retrieve the user running the
|
||||
endpoint handlers and performs authentication.
|
||||
-->
|
||||
<record id="my_demo_app_group" model="res.groups">
|
||||
<field name="name">My Demo Endpoint Group</field>
|
||||
<field name="users" eval="[(4, ref('my_demo_app_user'))]" />
|
||||
<field name="implied_ids" eval="[(4, ref('group_fastapi_endpoint_runner'))]" />
|
||||
</record>
|
||||
|
||||
<!-- This is the endpoint that will be used to run the demo app
|
||||
This endpoint will be registered on the "/fastapi_demo" path
|
||||
-->
|
||||
|
||||
<record model="fastapi.endpoint" id="fastapi_endpoint_demo">
|
||||
<field name="name">Fastapi Demo Endpoint</field>
|
||||
<field
|
||||
name="description"
|
||||
><![CDATA[
|
||||
# A Dummy FastApi Demo
|
||||
|
||||
This demo endpoint has been created by inhering from "fastapi.endpoint", registering
|
||||
a new app into the app selection field and implementing the `_get_fastapi_routers`
|
||||
methods. See documentation to learn more about how to create a new app.
|
||||
]]></field>
|
||||
<field name="app">demo</field>
|
||||
<field name="root_path">/fastapi_demo</field>
|
||||
<field name="demo_auth_method">http_basic</field>
|
||||
<field name="user_id" ref="my_demo_app_user" />
|
||||
</record>
|
||||
|
||||
<record id="fastapi_endpoint_multislash_demo" model="fastapi.endpoint">
|
||||
<field name="name">Fastapi Multi-Slash Demo Endpoint</field>
|
||||
<field name="description">
|
||||
Like the other demo endpoint but with multi-slash
|
||||
</field>
|
||||
<field name="app">demo</field>
|
||||
<field name="root_path">/fastapi/demo-multi</field>
|
||||
<field name="demo_auth_method">http_basic</field>
|
||||
<field name="user_id" ref="my_demo_app_user" />
|
||||
</record>
|
||||
</odoo>
|
||||
171
odoo-bringout-oca-rest-framework-fastapi/fastapi/dependencies.py
Normal file
171
odoo-bringout-oca-rest-framework-fastapi/fastapi/dependencies.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
# Copyright 2022 ACSONE SA/NV
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL).
|
||||
|
||||
from typing import TYPE_CHECKING, Annotated
|
||||
|
||||
from odoo.api import Environment
|
||||
from odoo.exceptions import AccessDenied
|
||||
|
||||
from odoo.addons.base.models.res_partner import Partner
|
||||
from odoo.addons.base.models.res_users import Users
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Query, status
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
|
||||
from .context import odoo_env_ctx
|
||||
from .schemas import Paging
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .models.fastapi_endpoint import FastapiEndpoint
|
||||
|
||||
|
||||
def company_id() -> int | None:
|
||||
"""This method may be overriden by the FastAPI app to set the allowed company
|
||||
in the Odoo env of the endpoint. By default, the company defined on the
|
||||
endpoint record is used.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
def odoo_env(company_id: Annotated[int | None, Depends(company_id)]) -> Environment:
|
||||
env = odoo_env_ctx.get()
|
||||
if company_id is not None:
|
||||
env = env(context=dict(env.context, allowed_company_ids=[company_id]))
|
||||
|
||||
yield env
|
||||
|
||||
|
||||
def authenticated_partner_impl() -> Partner:
|
||||
"""This method has to be overriden when you create your fastapi app
|
||||
to declare the way your partner will be provided. In some case, this
|
||||
partner will come from the authentication mechanism (ex jwt token) in other cases
|
||||
it could comme from a lookup on an email received into an HTTP header ...
|
||||
See the fastapi_endpoint_demo for an example"""
|
||||
|
||||
|
||||
def optionally_authenticated_partner_impl() -> Partner | None:
|
||||
"""This method has to be overriden when you create your fastapi app
|
||||
and you need to get an optional authenticated partner into your endpoint.
|
||||
"""
|
||||
|
||||
|
||||
def authenticated_partner_env(
|
||||
partner: Annotated[Partner, Depends(authenticated_partner_impl)]
|
||||
) -> Environment:
|
||||
"""Return an environment with the authenticated partner id in the context"""
|
||||
return partner.with_context(authenticated_partner_id=partner.id).env
|
||||
|
||||
|
||||
def optionally_authenticated_partner_env(
|
||||
partner: Annotated[Partner | None, Depends(optionally_authenticated_partner_impl)],
|
||||
env: Annotated[Environment, Depends(odoo_env)],
|
||||
) -> Environment:
|
||||
"""Return an environment with the authenticated partner id in the context if
|
||||
the partner is not None
|
||||
"""
|
||||
if partner:
|
||||
return partner.with_context(authenticated_partner_id=partner.id).env
|
||||
return env
|
||||
|
||||
|
||||
def authenticated_partner(
|
||||
partner: Annotated[Partner, Depends(authenticated_partner_impl)],
|
||||
partner_env: Annotated[Environment, Depends(authenticated_partner_env)],
|
||||
) -> Partner:
|
||||
"""If you need to get access to the authenticated partner into your
|
||||
endpoint, you can add a dependency into the endpoint definition on this
|
||||
method.
|
||||
This method is a safe way to declare a dependency without requiring a
|
||||
specific implementation. It depends on `authenticated_partner_impl`. The
|
||||
concrete implementation of authenticated_partner_impl has to be provided
|
||||
when the FastAPI app is created.
|
||||
This method return a partner into the authenticated_partner_env
|
||||
"""
|
||||
return partner_env["res.partner"].browse(partner.id)
|
||||
|
||||
|
||||
def optionally_authenticated_partner(
|
||||
partner: Annotated[Partner | None, Depends(optionally_authenticated_partner_impl)],
|
||||
partner_env: Annotated[Environment, Depends(optionally_authenticated_partner_env)],
|
||||
) -> Partner | None:
|
||||
"""If you need to get access to the authenticated partner if the call is
|
||||
authenticated, you can add a dependency into the endpoint definition on this
|
||||
method.
|
||||
|
||||
This method defer from authenticated_partner by the fact that it returns
|
||||
None if the partner is not authenticated .
|
||||
"""
|
||||
if partner:
|
||||
return partner_env["res.partner"].browse(partner.id)
|
||||
return None
|
||||
|
||||
|
||||
def paging(
|
||||
page: Annotated[int, Query(ge=1)] = 1, page_size: Annotated[int, Query(ge=1)] = 80
|
||||
) -> Paging:
|
||||
"""Return a Paging object from the page and page_size parameters"""
|
||||
return Paging(limit=page_size, offset=(page - 1) * page_size)
|
||||
|
||||
|
||||
def basic_auth_user(
|
||||
credential: Annotated[HTTPBasicCredentials, Depends(HTTPBasic())],
|
||||
env: Annotated[Environment, Depends(odoo_env)],
|
||||
) -> Users:
|
||||
username = credential.username
|
||||
password = credential.password
|
||||
try:
|
||||
uid = (
|
||||
env["res.users"]
|
||||
.sudo()
|
||||
.authenticate(
|
||||
db=env.cr.dbname, login=username, password=password, user_agent_env=None
|
||||
)
|
||||
)
|
||||
return env["res.users"].browse(uid)
|
||||
except AccessDenied as ad:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Basic"},
|
||||
) from ad
|
||||
|
||||
|
||||
def authenticated_partner_from_basic_auth_user(
|
||||
user: Annotated[Users, Depends(basic_auth_user)],
|
||||
env: Annotated[Environment, Depends(odoo_env)],
|
||||
) -> Partner:
|
||||
return env["res.partner"].browse(user.sudo().partner_id.id)
|
||||
|
||||
|
||||
def fastapi_endpoint_id() -> int:
|
||||
"""This method is overriden by the FastAPI app to make the fastapi.endpoint record
|
||||
available for your endpoint method. To get the fastapi.endpoint record
|
||||
in your method, you just need to add a dependency on the fastapi_endpoint method
|
||||
defined below
|
||||
"""
|
||||
|
||||
|
||||
def fastapi_endpoint(
|
||||
_id: Annotated[int, Depends(fastapi_endpoint_id)],
|
||||
env: Annotated[Environment, Depends(odoo_env)],
|
||||
) -> "FastapiEndpoint":
|
||||
"""Return the fastapi.endpoint record"""
|
||||
return env["fastapi.endpoint"].browse(_id)
|
||||
|
||||
|
||||
def accept_language(
|
||||
accept_language: Annotated[
|
||||
str | None,
|
||||
Header(
|
||||
alias="Accept-Language",
|
||||
description="The Accept-Language header is used to specify the language "
|
||||
"of the content to be returned. If a language is not available, the "
|
||||
"server will return the content in the default language.",
|
||||
),
|
||||
] = None,
|
||||
) -> str:
|
||||
"""This dependency is used at application level to document the way the language
|
||||
to use for the response is specified. The header is processed outside of the
|
||||
fastapi app to initialize the odoo environment with the right language.
|
||||
"""
|
||||
return accept_language
|
||||
12
odoo-bringout-oca-rest-framework-fastapi/fastapi/depends.py
Normal file
12
odoo-bringout-oca-rest-framework-fastapi/fastapi/depends.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Copyright 2023 ACSONE SA/NV
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL).
|
||||
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"The 'depends' package is deprecated. Please use 'dependencies' instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
from .dependencies import * # noqa: F403, F401, E402
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
# Copyright 2022 ACSONE SA/NV
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL).
|
||||
from typing import Tuple
|
||||
|
||||
from starlette import status
|
||||
from starlette.exceptions import HTTPException, WebSocketException
|
||||
from starlette.middleware.errors import ServerErrorMiddleware
|
||||
from starlette.middleware.exceptions import ExceptionMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.websockets import WebSocket
|
||||
from werkzeug.exceptions import HTTPException as WerkzeugHTTPException
|
||||
|
||||
from odoo.exceptions import AccessDenied, AccessError, MissingError, UserError
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.exceptions import RequestValidationError, WebSocketRequestValidationError
|
||||
from fastapi.utils import is_body_allowed_for_status_code
|
||||
|
||||
|
||||
def convert_exception_to_status_body(exc: Exception) -> Tuple[int, dict]:
|
||||
body = {}
|
||||
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
details = "Internal Server Error"
|
||||
|
||||
if isinstance(exc, WerkzeugHTTPException):
|
||||
status_code = exc.code
|
||||
details = exc.description
|
||||
elif isinstance(exc, HTTPException):
|
||||
status_code = exc.status_code
|
||||
details = exc.detail
|
||||
elif isinstance(exc, RequestValidationError):
|
||||
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
details = jsonable_encoder(exc.errors())
|
||||
elif isinstance(exc, WebSocketRequestValidationError):
|
||||
status_code = status.WS_1008_POLICY_VIOLATION
|
||||
details = jsonable_encoder(exc.errors())
|
||||
elif isinstance(exc, (AccessDenied, AccessError)):
|
||||
status_code = status.HTTP_403_FORBIDDEN
|
||||
details = "AccessError"
|
||||
elif isinstance(exc, MissingError):
|
||||
status_code = status.HTTP_404_NOT_FOUND
|
||||
details = "MissingError"
|
||||
elif isinstance(exc, UserError):
|
||||
status_code = status.HTTP_400_BAD_REQUEST
|
||||
details = exc.args[0]
|
||||
|
||||
if is_body_allowed_for_status_code(status_code):
|
||||
# use the same format as in
|
||||
# fastapi.exception_handlers.http_exception_handler
|
||||
body = {"detail": details}
|
||||
return status_code, body
|
||||
|
||||
|
||||
# we need to monkey patch the ServerErrorMiddleware and ExceptionMiddleware classes
|
||||
# to ensure that all the exceptions that are handled by these specific
|
||||
# middlewares are let to bubble up to the retrying mechanism and the
|
||||
# dispatcher error handler to ensure that appropriate action are taken
|
||||
# regarding the transaction, environment, and registry. These middlewares
|
||||
# are added by default by FastAPI when creating an application and it's not
|
||||
# possible to remove them. So we need to monkey patch them.
|
||||
|
||||
|
||||
def pass_through_exception_handler(
|
||||
self, request: Request, exc: Exception
|
||||
) -> JSONResponse:
|
||||
raise exc
|
||||
|
||||
|
||||
def pass_through_websocket_exception_handler(
|
||||
self, websocket: WebSocket, exc: WebSocketException
|
||||
) -> None:
|
||||
raise exc
|
||||
|
||||
|
||||
ServerErrorMiddleware.error_response = pass_through_exception_handler
|
||||
ExceptionMiddleware.http_exception = pass_through_exception_handler
|
||||
ExceptionMiddleware.websocket_exception = pass_through_websocket_exception_handler
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
# Copyright 2022 ACSONE SA/NV
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL).
|
||||
|
||||
from contextlib import contextmanager
|
||||
from io import BytesIO
|
||||
|
||||
from odoo.http import Dispatcher, request
|
||||
|
||||
from .context import odoo_env_ctx
|
||||
from .error_handlers import convert_exception_to_status_body
|
||||
from .pools import fastapi_app_pool
|
||||
|
||||
|
||||
class FastApiDispatcher(Dispatcher):
|
||||
routing_type = "fastapi"
|
||||
|
||||
def __init__(self, request):
|
||||
super().__init__(request)
|
||||
# Store exception to later raise it in the dispatch method if needed
|
||||
self.inner_exception = None
|
||||
|
||||
@classmethod
|
||||
def is_compatible_with(cls, request):
|
||||
return True
|
||||
|
||||
def dispatch(self, endpoint, args):
|
||||
# don't parse the httprequest let starlette parse the stream
|
||||
self.request.params = {} # dict(self.request.get_http_params(), **args)
|
||||
environ = self._get_environ()
|
||||
path = environ["PATH_INFO"]
|
||||
# TODO store the env into contextvar to be used by the odoo_env
|
||||
# depends method
|
||||
with fastapi_app_pool.get_app(env=request.env, root_path=path) as app:
|
||||
uid = request.env["fastapi.endpoint"].sudo().get_uid(path)
|
||||
data = BytesIO()
|
||||
with self._manage_odoo_env(uid):
|
||||
for r in app(environ, self._make_response):
|
||||
data.write(r)
|
||||
if self.inner_exception:
|
||||
raise self.inner_exception
|
||||
return self.request.make_response(
|
||||
data.getvalue(), headers=self.headers, status=self.status
|
||||
)
|
||||
|
||||
def handle_error(self, exc):
|
||||
headers = getattr(exc, "headers", None)
|
||||
status_code, body = convert_exception_to_status_body(exc)
|
||||
return self.request.make_json_response(
|
||||
body, status=status_code, headers=headers
|
||||
)
|
||||
|
||||
def _make_response(self, status_mapping, headers_tuple, content):
|
||||
self.status = status_mapping[:3]
|
||||
self.headers = headers_tuple
|
||||
self.inner_exception = None
|
||||
# in case of exception, the method asgi_done_callback of the
|
||||
# ASGIResponder will trigger an "a2wsgi.error" event with the exception
|
||||
# instance stored in a tuple with the type of the exception and the traceback.
|
||||
# The event loop will then be notified and then call the `error_response`
|
||||
# method of the ASGIResponder. This method will then call the
|
||||
# `_make_response` method provided as callback to the app with the tuple
|
||||
# of the exception as content. In this case, we store the exception
|
||||
# instance in the `inner_exception` attribute to be able to raise it
|
||||
# in the `dispatch` method.
|
||||
if (
|
||||
isinstance(content, tuple)
|
||||
and len(content) == 3
|
||||
and isinstance(content[1], Exception)
|
||||
):
|
||||
self.inner_exception = content[1]
|
||||
|
||||
def _get_environ(self):
|
||||
try:
|
||||
# normal case after
|
||||
# https://github.com/odoo/odoo/commit/cb1d057dcab28cb0b0487244ba99231ee292502e
|
||||
httprequest = self.request.httprequest._HTTPRequest__wrapped
|
||||
except AttributeError:
|
||||
# fallback for older odoo versions
|
||||
# The try except is the most efficient way to handle this
|
||||
# as we expect that most of the time the attribute will be there
|
||||
# and this code will no more be executed if it runs on an up to
|
||||
# date odoo version. (EAFP: Easier to Ask for Forgiveness than Permission)
|
||||
httprequest = self.request.httprequest
|
||||
environ = httprequest.environ
|
||||
stream = httprequest._get_stream_for_parsing()
|
||||
# Check if the stream supports seeking
|
||||
if hasattr(stream, "seekable") and stream.seekable():
|
||||
# Reset the stream to the beginning to ensure it can be consumed
|
||||
# again by the application in case of a retry mechanism
|
||||
stream.seek(0)
|
||||
else:
|
||||
# If the stream does not support seeking, we need wrap it
|
||||
# in a BytesIO object. This way we can seek back to the beginning
|
||||
# of the stream to read the data again if needed.
|
||||
if not hasattr(httprequest, "_cached_stream"):
|
||||
httprequest._cached_stream = BytesIO(stream.read())
|
||||
stream = httprequest._cached_stream
|
||||
stream.seek(0)
|
||||
environ["wsgi.input"] = stream
|
||||
return environ
|
||||
|
||||
@contextmanager
|
||||
def _manage_odoo_env(self, uid=None):
|
||||
env = request.env
|
||||
accept_language = request.httprequest.headers.get("Accept-language")
|
||||
context = env.context
|
||||
if accept_language:
|
||||
lang = (
|
||||
env["res.lang"].sudo()._get_lang_from_accept_language(accept_language)
|
||||
)
|
||||
if lang:
|
||||
env = env(context=dict(context, lang=lang))
|
||||
if uid:
|
||||
env = env(user=uid)
|
||||
token = odoo_env_ctx.set(env)
|
||||
try:
|
||||
yield
|
||||
# Flush here to ensure all pending computations are being executed with
|
||||
# authenticated fastapi user before exiting this context manager, as it
|
||||
# would otherwise be done using the public user on the commit of the DB
|
||||
# cursor, what could potentially lead to inconsistencies or AccessError.
|
||||
env.flush_all()
|
||||
finally:
|
||||
odoo_env_ctx.reset(token)
|
||||
251
odoo-bringout-oca-rest-framework-fastapi/fastapi/i18n/bs.po
Normal file
251
odoo-bringout-oca-rest-framework-fastapi/fastapi/i18n/bs.po
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * fastapi
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 16.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,help:fastapi.field_fastapi_endpoint__description
|
||||
msgid "A short description of the API. It can use Markdown"
|
||||
msgstr "Kratki opis API-ja. Može koristiti Markdown"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__active
|
||||
msgid "Active"
|
||||
msgstr "Aktivan"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:res.groups,name:fastapi.group_fastapi_manager
|
||||
msgid "Administrator"
|
||||
msgstr "Administrator"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields.selection,name:fastapi.selection__fastapi_endpoint__demo_auth_method__api_key
|
||||
msgid "Api Key"
|
||||
msgstr "API ključ"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__app
|
||||
#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_search_view
|
||||
msgid "App"
|
||||
msgstr "Aplikacija"
|
||||
|
||||
#. module: fastapi
|
||||
#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_form_view
|
||||
#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_search_view
|
||||
msgid "Archived"
|
||||
msgstr "Arhivirano"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__demo_auth_method
|
||||
msgid "Authentication method"
|
||||
msgstr "Metoda autentifikacije"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__company_id
|
||||
msgid "Company"
|
||||
msgstr "Preduzeće"
|
||||
|
||||
#. module: fastapi
|
||||
#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_demo_form_view
|
||||
msgid "Configuration"
|
||||
msgstr "Konfiguracija"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__create_uid
|
||||
msgid "Created by"
|
||||
msgstr "Kreirao"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__create_date
|
||||
msgid "Created on"
|
||||
msgstr "Kreirano"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields.selection,name:fastapi.selection__fastapi_endpoint__app__demo
|
||||
msgid "Demo Endpoint"
|
||||
msgstr "Demo krajnja točka"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__description
|
||||
msgid "Description"
|
||||
msgstr "Opis"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__display_name
|
||||
msgid "Display Name"
|
||||
msgstr "Prikazani naziv"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__docs_url
|
||||
msgid "Docs Url"
|
||||
msgstr "URL dokumentacije"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.module.category,name:fastapi.module_category_fastapi
|
||||
#: model:ir.ui.menu,name:fastapi.menu_fastapi_root
|
||||
msgid "FastAPI"
|
||||
msgstr "FastAPI"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.actions.act_window,name:fastapi.fastapi_endpoint_act_window
|
||||
#: model:ir.model,name:fastapi.model_fastapi_endpoint
|
||||
#: model:ir.ui.menu,name:fastapi.fastapi_endpoint_menu
|
||||
msgid "FastAPI Endpoint"
|
||||
msgstr "FastAPI krajnja točka"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:res.groups,name:fastapi.group_fastapi_endpoint_runner
|
||||
msgid "FastAPI Endpoint Runner"
|
||||
msgstr "FastAPI pokretač krajnjih točaka"
|
||||
|
||||
#. module: fastapi
|
||||
#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_search_view
|
||||
msgid "Group by..."
|
||||
msgstr "Grupiši po..."
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields.selection,name:fastapi.selection__fastapi_endpoint__demo_auth_method__http_basic
|
||||
msgid "HTTP Basic"
|
||||
msgstr "HTTP Basic"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.module.category,description:fastapi.module_category_fastapi
|
||||
msgid "Helps you manage your Fastapi Endpoints"
|
||||
msgstr "Pomaže vam upravljati vašim FastAPI krajnjim točkama"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__id
|
||||
msgid "ID"
|
||||
msgstr "ID"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model,name:fastapi.model_res_lang
|
||||
msgid "Languages"
|
||||
msgstr "Jezici"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint____last_update
|
||||
msgid "Last Modified on"
|
||||
msgstr "Zadnje mijenjano"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__write_uid
|
||||
msgid "Last Updated by"
|
||||
msgstr "Zadnji ažurirao"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__write_date
|
||||
msgid "Last Updated on"
|
||||
msgstr "Zadnje ažurirano"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:res.groups,name:fastapi.my_demo_app_group
|
||||
msgid "My Demo Endpoint Group"
|
||||
msgstr "Moja demo grupa krajnjih točaka"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__name
|
||||
msgid "Name"
|
||||
msgstr "Naziv:"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,help:fastapi.field_fastapi_endpoint__registry_sync
|
||||
msgid ""
|
||||
"ON: the record has been modified and registry was not notified.\n"
|
||||
"No change will be active until this flag is set to false via proper action.\n"
|
||||
"\n"
|
||||
"OFF: record in line with the registry, nothing to do."
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__openapi_url
|
||||
msgid "Openapi Url"
|
||||
msgstr "Openapi URL"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model,name:fastapi.model_ir_rule
|
||||
msgid "Record Rule"
|
||||
msgstr "Pravilo zapisa"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__redoc_url
|
||||
msgid "Redoc Url"
|
||||
msgstr "Redoc URL"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__registry_sync
|
||||
msgid "Registry Sync"
|
||||
msgstr "Sinhronizacija registra"
|
||||
|
||||
#. module: fastapi
|
||||
#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_form_view
|
||||
msgid "Registry Sync Required"
|
||||
msgstr "Potrebna sinhronizacija registra"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__root_path
|
||||
msgid "Root Path"
|
||||
msgstr "Osnovna putanja"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__save_http_session
|
||||
msgid "Save HTTP Session"
|
||||
msgstr "Snimanje HTTP sesije"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.actions.server,name:fastapi.fastapi_endpoint_action_sync_registry
|
||||
#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_form_view
|
||||
#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_tree_view
|
||||
msgid "Sync Registry"
|
||||
msgstr "Sinhroniziraj registar"
|
||||
|
||||
#. module: fastapi
|
||||
#. odoo-python
|
||||
#: code:addons/fastapi/models/fastapi_endpoint_demo.py:0
|
||||
#, python-format
|
||||
msgid "The authentication method is required for app %(app)s"
|
||||
msgstr "Metoda autentifikacije je potrebna za aplikaciju %(app)s"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,help:fastapi.field_fastapi_endpoint__name
|
||||
msgid "The title of the API."
|
||||
msgstr "Naslov API-ja."
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,help:fastapi.field_fastapi_endpoint__user_id
|
||||
msgid "The user to use to execute the API calls."
|
||||
msgstr "Korisnik koji će se koristiti za izvršavanje API poziva."
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__user_id
|
||||
#: model:res.groups,name:fastapi.group_fastapi_user
|
||||
msgid "User"
|
||||
msgstr "Korisnik"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,help:fastapi.field_fastapi_endpoint__save_http_session
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#. odoo-python
|
||||
#: code:addons/fastapi/models/fastapi_endpoint.py:0
|
||||
#, python-format
|
||||
msgid "`%(name)s` uses a blacklisted root_path = `%(root_path)s`"
|
||||
msgstr "`%(name)s` koristi zabranjenu root_path = `%(root_path)s`"
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * fastapi
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 16.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,help:fastapi.field_fastapi_endpoint__description
|
||||
msgid "A short description of the API. It can use Markdown"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__active
|
||||
msgid "Active"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:res.groups,name:fastapi.group_fastapi_manager
|
||||
msgid "Administrator"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields.selection,name:fastapi.selection__fastapi_endpoint__demo_auth_method__api_key
|
||||
msgid "Api Key"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__app
|
||||
#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_search_view
|
||||
msgid "App"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_form_view
|
||||
#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_search_view
|
||||
msgid "Archived"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__demo_auth_method
|
||||
msgid "Authentication method"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__company_id
|
||||
msgid "Company"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_demo_form_view
|
||||
msgid "Configuration"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__create_uid
|
||||
msgid "Created by"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__create_date
|
||||
msgid "Created on"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields.selection,name:fastapi.selection__fastapi_endpoint__app__demo
|
||||
msgid "Demo Endpoint"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__description
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__display_name
|
||||
msgid "Display Name"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__docs_url
|
||||
msgid "Docs Url"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.module.category,name:fastapi.module_category_fastapi
|
||||
#: model:ir.ui.menu,name:fastapi.menu_fastapi_root
|
||||
msgid "FastAPI"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.actions.act_window,name:fastapi.fastapi_endpoint_act_window
|
||||
#: model:ir.model,name:fastapi.model_fastapi_endpoint
|
||||
#: model:ir.ui.menu,name:fastapi.fastapi_endpoint_menu
|
||||
msgid "FastAPI Endpoint"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:res.groups,name:fastapi.group_fastapi_endpoint_runner
|
||||
msgid "FastAPI Endpoint Runner"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_search_view
|
||||
msgid "Group by..."
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields.selection,name:fastapi.selection__fastapi_endpoint__demo_auth_method__http_basic
|
||||
msgid "HTTP Basic"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.module.category,description:fastapi.module_category_fastapi
|
||||
msgid "Helps you manage your Fastapi Endpoints"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__id
|
||||
msgid "ID"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model,name:fastapi.model_res_lang
|
||||
msgid "Languages"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint____last_update
|
||||
msgid "Last Modified on"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__write_uid
|
||||
msgid "Last Updated by"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__write_date
|
||||
msgid "Last Updated on"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:res.groups,name:fastapi.my_demo_app_group
|
||||
msgid "My Demo Endpoint Group"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__name
|
||||
msgid "Name"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,help:fastapi.field_fastapi_endpoint__registry_sync
|
||||
msgid ""
|
||||
"ON: the record has been modified and registry was not notified.\n"
|
||||
"No change will be active until this flag is set to false via proper action.\n"
|
||||
"\n"
|
||||
"OFF: record in line with the registry, nothing to do."
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__openapi_url
|
||||
msgid "Openapi Url"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model,name:fastapi.model_ir_rule
|
||||
msgid "Record Rule"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__redoc_url
|
||||
msgid "Redoc Url"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__registry_sync
|
||||
msgid "Registry Sync"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_form_view
|
||||
msgid "Registry Sync Required"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__root_path
|
||||
msgid "Root Path"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__save_http_session
|
||||
msgid "Save HTTP Session"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.actions.server,name:fastapi.fastapi_endpoint_action_sync_registry
|
||||
#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_form_view
|
||||
#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_tree_view
|
||||
msgid "Sync Registry"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#. odoo-python
|
||||
#: code:addons/fastapi/models/fastapi_endpoint_demo.py:0
|
||||
#, python-format
|
||||
msgid "The authentication method is required for app %(app)s"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,help:fastapi.field_fastapi_endpoint__name
|
||||
msgid "The title of the API."
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,help:fastapi.field_fastapi_endpoint__user_id
|
||||
msgid "The user to use to execute the API calls."
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__user_id
|
||||
#: model:res.groups,name:fastapi.group_fastapi_user
|
||||
msgid "User"
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,help:fastapi.field_fastapi_endpoint__save_http_session
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#. module: fastapi
|
||||
#. odoo-python
|
||||
#: code:addons/fastapi/models/fastapi_endpoint.py:0
|
||||
#, python-format
|
||||
msgid "`%(name)s` uses a blacklisted root_path = `%(root_path)s`"
|
||||
msgstr ""
|
||||
269
odoo-bringout-oca-rest-framework-fastapi/fastapi/i18n/it.po
Normal file
269
odoo-bringout-oca-rest-framework-fastapi/fastapi/i18n/it.po
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * fastapi
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 16.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2025-06-04 09:40+0000\n"
|
||||
"Last-Translator: mymage <stefano.consolaro@mymage.it>\n"
|
||||
"Language-Team: none\n"
|
||||
"Language: it\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Generator: Weblate 5.10.4\n"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,help:fastapi.field_fastapi_endpoint__description
|
||||
msgid "A short description of the API. It can use Markdown"
|
||||
msgstr "Una breve descrizione dell'API. Può contenere Markdown"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__active
|
||||
msgid "Active"
|
||||
msgstr "Attivo"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:res.groups,name:fastapi.group_fastapi_manager
|
||||
msgid "Administrator"
|
||||
msgstr "Amministratore"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields.selection,name:fastapi.selection__fastapi_endpoint__demo_auth_method__api_key
|
||||
msgid "Api Key"
|
||||
msgstr "Chiave API"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__app
|
||||
#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_search_view
|
||||
msgid "App"
|
||||
msgstr "Applicazione"
|
||||
|
||||
#. module: fastapi
|
||||
#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_form_view
|
||||
#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_search_view
|
||||
msgid "Archived"
|
||||
msgstr "In archivio"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__demo_auth_method
|
||||
msgid "Authentication method"
|
||||
msgstr "Metodo autenticazione"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__company_id
|
||||
msgid "Company"
|
||||
msgstr "Azienda"
|
||||
|
||||
#. module: fastapi
|
||||
#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_demo_form_view
|
||||
msgid "Configuration"
|
||||
msgstr "Configurazione"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__create_uid
|
||||
msgid "Created by"
|
||||
msgstr "Creato da"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__create_date
|
||||
msgid "Created on"
|
||||
msgstr "Creato il"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields.selection,name:fastapi.selection__fastapi_endpoint__app__demo
|
||||
msgid "Demo Endpoint"
|
||||
msgstr "Endpoint esempio"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__description
|
||||
msgid "Description"
|
||||
msgstr "Descrizione"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__display_name
|
||||
msgid "Display Name"
|
||||
msgstr "Nome visualizzato"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__docs_url
|
||||
msgid "Docs Url"
|
||||
msgstr "URL documenti"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.module.category,name:fastapi.module_category_fastapi
|
||||
#: model:ir.ui.menu,name:fastapi.menu_fastapi_root
|
||||
msgid "FastAPI"
|
||||
msgstr "FastAPI"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.actions.act_window,name:fastapi.fastapi_endpoint_act_window
|
||||
#: model:ir.model,name:fastapi.model_fastapi_endpoint
|
||||
#: model:ir.ui.menu,name:fastapi.fastapi_endpoint_menu
|
||||
msgid "FastAPI Endpoint"
|
||||
msgstr "Endpoint FastAPI"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:res.groups,name:fastapi.group_fastapi_endpoint_runner
|
||||
msgid "FastAPI Endpoint Runner"
|
||||
msgstr "Esecutore endopoint FastAPI"
|
||||
|
||||
#. module: fastapi
|
||||
#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_search_view
|
||||
msgid "Group by..."
|
||||
msgstr "Raggruppa per..."
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields.selection,name:fastapi.selection__fastapi_endpoint__demo_auth_method__http_basic
|
||||
msgid "HTTP Basic"
|
||||
msgstr "Base HTTP"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.module.category,description:fastapi.module_category_fastapi
|
||||
msgid "Helps you manage your Fastapi Endpoints"
|
||||
msgstr "Aiuta nella gestione degli endpoint FastAPI"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__id
|
||||
msgid "ID"
|
||||
msgstr "ID"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model,name:fastapi.model_res_lang
|
||||
msgid "Languages"
|
||||
msgstr "Lingue"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint____last_update
|
||||
msgid "Last Modified on"
|
||||
msgstr "Ultima modifica il"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__write_uid
|
||||
msgid "Last Updated by"
|
||||
msgstr "Ultimo aggiornamento di"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__write_date
|
||||
msgid "Last Updated on"
|
||||
msgstr "Ultimo aggiornamento il"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:res.groups,name:fastapi.my_demo_app_group
|
||||
msgid "My Demo Endpoint Group"
|
||||
msgstr "Il mio gruppo endpoint esempio"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__name
|
||||
msgid "Name"
|
||||
msgstr "Nome"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,help:fastapi.field_fastapi_endpoint__registry_sync
|
||||
msgid ""
|
||||
"ON: the record has been modified and registry was not notified.\n"
|
||||
"No change will be active until this flag is set to false via proper action.\n"
|
||||
"\n"
|
||||
"OFF: record in line with the registry, nothing to do."
|
||||
msgstr ""
|
||||
"Acceso: il record è stato modificato e il registro non è stato notificato.\n"
|
||||
"Nessuna modifica sarà attiva finchè questa opzione è impostata a falso "
|
||||
"attraverso un'azione opportuna.\n"
|
||||
"\n"
|
||||
"Spento: record allineato con il registro, non c'è niente da fare."
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__openapi_url
|
||||
msgid "Openapi Url"
|
||||
msgstr "URL OpenAPI"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model,name:fastapi.model_ir_rule
|
||||
msgid "Record Rule"
|
||||
msgstr "Regola su record"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__redoc_url
|
||||
msgid "Redoc Url"
|
||||
msgstr "URL record"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__registry_sync
|
||||
msgid "Registry Sync"
|
||||
msgstr "Sincro registro"
|
||||
|
||||
#. module: fastapi
|
||||
#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_form_view
|
||||
msgid "Registry Sync Required"
|
||||
msgstr "Sincro registro richiesto"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__root_path
|
||||
msgid "Root Path"
|
||||
msgstr "Percorso radice"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__save_http_session
|
||||
msgid "Save HTTP Session"
|
||||
msgstr "Salva sessione HTTP"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.actions.server,name:fastapi.fastapi_endpoint_action_sync_registry
|
||||
#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_form_view
|
||||
#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_tree_view
|
||||
msgid "Sync Registry"
|
||||
msgstr "Sincronizza registro"
|
||||
|
||||
#. module: fastapi
|
||||
#. odoo-python
|
||||
#: code:addons/fastapi/models/fastapi_endpoint_demo.py:0
|
||||
#, python-format
|
||||
msgid "The authentication method is required for app %(app)s"
|
||||
msgstr "Il metodo di autenticazione è richiesto per l'app %(app)s"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,help:fastapi.field_fastapi_endpoint__name
|
||||
msgid "The title of the API."
|
||||
msgstr "Titolo dell'API."
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,help:fastapi.field_fastapi_endpoint__user_id
|
||||
msgid "The user to use to execute the API calls."
|
||||
msgstr "Utente da utilizzare per eseguire la chiamata API."
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__user_id
|
||||
#: model:res.groups,name:fastapi.group_fastapi_user
|
||||
msgid "User"
|
||||
msgstr "Utente"
|
||||
|
||||
#. module: fastapi
|
||||
#: model:ir.model.fields,help:fastapi.field_fastapi_endpoint__save_http_session
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
"Se la sessione deve essere salvata nell'archivio sessioni. Questo è "
|
||||
"necessario se, ad esempio, si utilizza il meccanismo di autenticazione di "
|
||||
"Odoo. In caso contrario, è probabile che non se ne abbia bisogno e si può "
|
||||
"disattivare questo comportamento. Inoltre, disattivando questa opzione si "
|
||||
"impediranno operazioni di I/O inutili durante l'archiviazione e la lettura "
|
||||
"della sessione sul disco e si impedirà un consumo inaspettato di spazio su "
|
||||
"disco."
|
||||
|
||||
#. module: fastapi
|
||||
#. odoo-python
|
||||
#: code:addons/fastapi/models/fastapi_endpoint.py:0
|
||||
#, python-format
|
||||
msgid "`%(name)s` uses a blacklisted root_path = `%(root_path)s`"
|
||||
msgstr "`%(name)s` utilizza un root_path bloccato = `%(root_path)s`"
|
||||
|
||||
#~ msgid "Authenciation method"
|
||||
#~ msgstr "Metodo autenticazione"
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
# Copyright 2025 ACSONE SA/NV
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL).
|
||||
"""
|
||||
ASGI middleware for FastAPI.
|
||||
|
||||
This module provides an ASGI middleware for FastAPI applications. The middleware
|
||||
is designed to ensure managed the lifecycle of the threads used to as event loop
|
||||
for the ASGI application.
|
||||
|
||||
"""
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
import a2wsgi
|
||||
from a2wsgi.asgi import ASGIResponder
|
||||
from a2wsgi.asgi_typing import ASGIApp
|
||||
from a2wsgi.wsgi_typing import Environ, StartResponse
|
||||
|
||||
from .pools import event_loop_pool
|
||||
|
||||
|
||||
class ASGIMiddleware(a2wsgi.ASGIMiddleware):
|
||||
def __init__(
|
||||
self,
|
||||
app: ASGIApp,
|
||||
wait_time: float | None = None,
|
||||
) -> None:
|
||||
# We don't want to use the default event loop policy
|
||||
# because we want to manage the event loop ourselves
|
||||
# using the event loop pool.
|
||||
# Since the the base class check if the given loop is
|
||||
# None, we can pass False to avoid the initialization
|
||||
# of the default event loop
|
||||
super().__init__(app, wait_time, False)
|
||||
|
||||
def __call__(
|
||||
self, environ: Environ, start_response: StartResponse
|
||||
) -> Iterable[bytes]:
|
||||
with event_loop_pool.get_event_loop() as loop:
|
||||
return ASGIResponder(self.app, loop)(environ, start_response)
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
from .event_loop import EventLoopPool
|
||||
from .fastapi_app import FastApiAppPool
|
||||
from odoo.service.server import CommonServer
|
||||
|
||||
event_loop_pool = EventLoopPool()
|
||||
fastapi_app_pool = FastApiAppPool()
|
||||
|
||||
|
||||
CommonServer.on_stop(event_loop_pool.shutdown)
|
||||
|
||||
__all__ = ["event_loop_pool", "fastapi_app_pool"]
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
# Copyright 2025 ACSONE SA/NV
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL).
|
||||
|
||||
import asyncio
|
||||
import queue
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from typing import Generator
|
||||
|
||||
|
||||
class EventLoopPool:
|
||||
def __init__(self):
|
||||
self.pool = queue.Queue[tuple[asyncio.AbstractEventLoop, threading.Thread]]()
|
||||
|
||||
def __get_event_loop_and_thread(
|
||||
self,
|
||||
) -> tuple[asyncio.AbstractEventLoop, threading.Thread]:
|
||||
"""
|
||||
Get an event loop from the pool. If no event loop is available, create a new one.
|
||||
"""
|
||||
try:
|
||||
return self.pool.get_nowait()
|
||||
except queue.Empty:
|
||||
loop = asyncio.new_event_loop()
|
||||
thread = threading.Thread(target=loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
return loop, thread
|
||||
|
||||
def __return_event_loop(
|
||||
self, loop: asyncio.AbstractEventLoop, thread: threading.Thread
|
||||
) -> None:
|
||||
"""
|
||||
Return an event loop to the pool for reuse.
|
||||
"""
|
||||
self.pool.put((loop, thread))
|
||||
|
||||
def shutdown(self):
|
||||
"""
|
||||
Shutdown all event loop threads in the pool.
|
||||
"""
|
||||
while not self.pool.empty():
|
||||
loop, thread = self.pool.get_nowait()
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
thread.join()
|
||||
loop.close()
|
||||
|
||||
@contextmanager
|
||||
def get_event_loop(self) -> Generator[asyncio.AbstractEventLoop, None, None]:
|
||||
"""
|
||||
Get an event loop from the pool. If no event loop is available, create a new one.
|
||||
|
||||
After the context manager exits, the event loop is returned to the pool for reuse.
|
||||
"""
|
||||
loop, thread = self.__get_event_loop_and_thread()
|
||||
try:
|
||||
yield loop
|
||||
finally:
|
||||
self.__return_event_loop(loop, thread)
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
# Copyright 2025 ACSONE SA/NV
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL).
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from contextlib import contextmanager
|
||||
from typing import Generator
|
||||
|
||||
from odoo.api import Environment
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FastApiAppPool:
|
||||
"""Pool of FastAPI apps.
|
||||
|
||||
This class manages a pool of FastAPI apps. The pool is organized by database name
|
||||
and root path. Each pool is a queue of FastAPI apps.
|
||||
|
||||
The pool is used to reuse FastAPI apps across multiple requests. This is useful
|
||||
to avoid the overhead of creating a new FastAPI app for each request. The pool
|
||||
ensures that only one request at a time uses an app.
|
||||
|
||||
The proper way to use the pool is to use the get_app method as a context manager.
|
||||
This ensures that the app is returned to the pool after the context manager exits.
|
||||
The get_app method is designed to ensure that the app made available to the
|
||||
caller is unique and not used by another caller at the same time.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
with fastapi_app_pool.get_app(env=request.env, root_path=root_path) as app:
|
||||
# use the app
|
||||
|
||||
The pool is invalidated when the cache registry is updated. This ensures that
|
||||
the pool is always up-to-date with the latest app configuration. It also
|
||||
ensures that the invalidation is done even in the case of a modification occurring
|
||||
in a different worker process or thread or server instance. This mechanism
|
||||
works because every time an attribute of the fastapi.endpoint model is modified
|
||||
and this attribute is part of the list returned by the `_fastapi_app_fields`,
|
||||
or `_routing_impacting_fields` methods, we reset the cache of a marker method
|
||||
`_reset_app_cache_marker`. As side effect, the cache registry is marked to be
|
||||
updated by the increment of the `cache_sequence` SQL sequence. This cache sequence
|
||||
on the registry is reloaded from the DB on each request made to a specific database.
|
||||
When an app is retrieved from the pool, we always compare the cache sequence of
|
||||
the pool with the cache sequence of the registry. If the two sequences are different,
|
||||
we invalidate the pool and save the new cache sequence on the pool.
|
||||
|
||||
The cache is based on a defaultdict of defaultdict of queue.Queue. We are cautious
|
||||
that the use of defaultdict is not thread-safe for operations that modify the
|
||||
dictionary. However the only operation that modifies the dictionary is the
|
||||
first access to a new key. If two threads access the same key at the same time,
|
||||
the two threads will create two different queues. This is not a problem since
|
||||
at the time of returning an app to the pool, we are sure that a queue exists
|
||||
for the key into the cache and all the created apps are returned to the same
|
||||
valid queue. And the end, the lack of thread-safety for the defaultdict could
|
||||
only lead to a negligible overhead of creating a new queue that will never be
|
||||
used. This is why we consider that the use of defaultdict is safe in this context.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._queue_by_db_by_root_path: dict[
|
||||
str, dict[str, queue.Queue[FastAPI]]
|
||||
] = defaultdict(lambda: defaultdict(queue.Queue))
|
||||
self.__cache_sequence = 0
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def __get_pool(self, env: Environment, root_path: str) -> queue.Queue[FastAPI]:
|
||||
db_name = env.cr.dbname
|
||||
return self._queue_by_db_by_root_path[db_name][root_path]
|
||||
|
||||
def __get_app(self, env: Environment, root_path: str) -> FastAPI:
|
||||
pool = self.__get_pool(env, root_path)
|
||||
try:
|
||||
return pool.get_nowait()
|
||||
except queue.Empty:
|
||||
return env["fastapi.endpoint"].sudo().get_app(root_path)
|
||||
|
||||
def __return_app(self, env: Environment, app: FastAPI, root_path: str) -> None:
|
||||
pool = self.__get_pool(env, root_path)
|
||||
pool.put(app)
|
||||
|
||||
@contextmanager
|
||||
def get_app(
|
||||
self, env: Environment, root_path: str
|
||||
) -> Generator[FastAPI, None, None]:
|
||||
"""Return a FastAPI app to be used in a context manager.
|
||||
|
||||
The app is retrieved from the pool if available, otherwise a new one is created.
|
||||
The app is returned to the pool after the context manager exits.
|
||||
|
||||
When used into the FastApiDispatcher class this ensures that the app is reused
|
||||
across multiple requests but only one request at a time uses an app.
|
||||
"""
|
||||
self._check_cache(env)
|
||||
app = self.__get_app(env, root_path)
|
||||
try:
|
||||
yield app
|
||||
finally:
|
||||
self.__return_app(env, app, root_path)
|
||||
|
||||
@property
|
||||
def cache_sequence(self) -> int:
|
||||
return self.__cache_sequence
|
||||
|
||||
@cache_sequence.setter
|
||||
def cache_sequence(self, value: int) -> None:
|
||||
if value != self.__cache_sequence:
|
||||
with self._lock:
|
||||
self.__cache_sequence = value
|
||||
|
||||
def _check_cache(self, env: Environment) -> None:
|
||||
cache_sequence = env.registry.cache_sequence
|
||||
if cache_sequence != self.cache_sequence and self.cache_sequence != 0:
|
||||
_logger.info(
|
||||
"Cache registry updated, reset fastapi_app pool for the current "
|
||||
"database"
|
||||
)
|
||||
self.invalidate(env)
|
||||
self.cache_sequence = cache_sequence
|
||||
|
||||
def invalidate(self, env: Environment, root_path: str | None = None) -> None:
|
||||
db_name = env.cr.dbname
|
||||
if root_path:
|
||||
self._queue_by_db_by_root_path[db_name][root_path] = queue.Queue()
|
||||
elif db_name in self._queue_by_db_by_root_path:
|
||||
del self._queue_by_db_by_root_path[db_name]
|
||||
|
|
@ -0,0 +1 @@
|
|||
* Laurent Mignon <laurent.mignon@acsone.eu>
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
This addon provides the basis to smoothly integrate the `FastAPI`_
|
||||
framework into Odoo.
|
||||
|
||||
This integration allows you to use all the goodies from `FastAPI`_ to build custom
|
||||
APIs for your Odoo server based on standard Python type hints.
|
||||
|
||||
**What is building an API?**
|
||||
|
||||
An API is a set of functions that can be called from the outside world. The
|
||||
goal of an API is to provide a way to interact with your application from the
|
||||
outside world without having to know how it works internally. A common mistake
|
||||
when you are building an API is to expose all the internal functions of your
|
||||
application and therefore create a tight coupling between the outside world and
|
||||
your internal datamodel and business logic. This is not a good idea because it
|
||||
makes it very hard to change your internal datamodel and business logic without
|
||||
breaking the outside world.
|
||||
|
||||
When you are building an API, you define a contract between the outside world
|
||||
and your application. This contract is defined by the functions that you expose
|
||||
and the parameters that you accept. This contract is the API. When you change
|
||||
your internal datamodel and business logic, you can still keep the same API
|
||||
contract and therefore you don't break the outside world. Even if you change
|
||||
your implementation, as long as you keep the same API contract, the outside
|
||||
world will still work. This is the beauty of an API and this is why it is so
|
||||
important to design a good API.
|
||||
|
||||
A good API is designed to be stable and to be easy to use. It's designed to
|
||||
provide high-level functions related to a specific use case. It's designed to
|
||||
be easy to use by hiding the complexity of the internal datamodel and business
|
||||
logic. A common mistake when you are building an API is to expose all the internal
|
||||
functions of your application and let the oustide world deal with the complexity
|
||||
of your internal datamodel and business logic. Don't forget that on a transactional
|
||||
point of view, each call to an API function is a transaction. This means that
|
||||
if a specific use case requires multiple calls to your API, you should provide
|
||||
a single function that does all the work in a single transaction. This why APIs
|
||||
methods are called high-level and atomic functions.
|
||||
|
||||
.. _FastAPI: https://fastapi.tiangolo.com/
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
16.0.1.4.3 (2024-10-01)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
**Features**
|
||||
|
||||
- * A new parameter is now available on the endpoint model to let you disable the creation and the store of session files used by Odoo for calls to your application endpoint. This is usefull to prevent disk space consumption and IO operations if your application doesn't need to use this sessions files which are mainly used by Odoo by to store the session info of logged in users. (`#442 <https://github.com/OCA/rest-framework/issues/442>`_)
|
||||
|
||||
|
||||
16.0.1.4.1 (2024-07-08)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Fix issue with the retry of a POST request with a body content.
|
||||
|
||||
Prior to this fix the retry of a POST request with a body content would
|
||||
stuck in a loop and never complete. This was due to the fact that the
|
||||
request input stream was not reset after a failed attempt to process the
|
||||
request. (`#440 <https://github.com/OCA/rest-framework/issues/440>`_)
|
||||
|
||||
|
||||
16.0.1.4.0 (2024-06-06)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- This change is a complete rewrite of the way the transactions are managed when
|
||||
integrating a fastapi application into Odoo.
|
||||
|
||||
In the previous implementation, specifics error handlers were put in place to
|
||||
catch exception occurring in the handling of requests made to a fastapi application
|
||||
and to rollback the transaction in case of error. This was done by registering
|
||||
specifics error handlers methods to the fastapi application using the 'add_exception_handler'
|
||||
method of the fastapi application. In this implementation, the transaction was
|
||||
rolled back in the error handler method.
|
||||
|
||||
This approach was not working as expected for several reasons:
|
||||
|
||||
- The handling of the error at the fastapi level prevented the retry mechanism
|
||||
to be triggered in case of a DB concurrency error. This is because the error
|
||||
was catch at the fastapi level and never bubbled up to the early stage of the
|
||||
processing of the request where the retry mechanism is implemented.
|
||||
- The cleanup of the environment and the registry was not properly done in case
|
||||
of error. In the **'odoo.service.model.retrying'** method, you can see that
|
||||
the cleanup process is different in case of error raised by the database
|
||||
and in case of error raised by the application.
|
||||
|
||||
This change fix these issues by ensuring that errors are no more catch at the
|
||||
fastapi level and bubble up the fastapi processing stack through the event loop
|
||||
required to transform WSGI to ASGI. As result the transactional nature of the
|
||||
requests to the fastapi applications is now properly managed by the Odoo framework. (`#422 <https://github.com/OCA/rest-framework/issues/422>`_)
|
||||
|
||||
|
||||
16.0.1.2.6 (2024-02-20)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Fix compatibility issues with the latest Odoo version
|
||||
|
||||
From https://github.com/odoo/odoo/commit/cb1d057dcab28cb0b0487244ba99231ee292502e
|
||||
the original werkzeug HTTPRequest class has been wrapped in a new class to keep
|
||||
under control the attributes developers use. This changes take care of this
|
||||
new implementation but also keep compatibility with the old ones. (`#414 <https://github.com/OCA/rest-framework/issues/414>`_)
|
||||
|
||||
|
||||
16.0.1.2.5 (2024-01-17)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Odoo has done an update and now, it checks domains of ir.rule on creation and modification.
|
||||
|
||||
The ir.rule 'Fastapi: Running user rule' uses a field (authenticate_partner_id) that comes from the context.
|
||||
This field wasn't always set and this caused an error when Odoo checked the domain.
|
||||
So now it is set to *False* by default. (`#410 <https://github.com/OCA/rest-framework/issues/410>`_)
|
||||
|
||||
|
||||
16.0.1.2.3 (2023-12-21)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- In case of exception in endpoint execution, close the database cursor after rollback.
|
||||
|
||||
This is to ensure that the *retrying* method in *service/model.py* does not try
|
||||
to flush data to the database. (`#405 <https://github.com/OCA/rest-framework/issues/405>`_)
|
||||
|
||||
|
||||
16.0.1.2.2 (2023-12-12)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- When using the 'FastAPITransactionCase' class, allows to specify a specific
|
||||
override of the 'authenticated_partner_impl' method into the list of
|
||||
overrides to apply. Before this change, the 'authenticated_partner_impl'
|
||||
override given in the 'overrides' parameter was always overridden in the
|
||||
'_create_test_client' method of the 'FastAPITransactionCase' class. It's now
|
||||
only overridden if the 'authenticated_partner_impl' method is not already
|
||||
present in the list of overrides to apply and no specific partner is given.
|
||||
If a specific partner is given at same time of an override for the
|
||||
'authenticated_partner_impl' method, an error is raised. (`#396 <https://github.com/OCA/rest-framework/issues/396>`_)
|
||||
|
||||
|
||||
16.0.1.2.1 (2023-11-03)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Fix a typo in the Field declaration of the 'count' attribute of the 'PagedCollection' schema.
|
||||
|
||||
Misspelt parameter was triggering a deprecation warning due to recent versions of Pydantic seeing it as an arbitrary parameter. (`#389 <https://github.com/OCA/rest-framework/issues/389>`_)
|
||||
|
||||
|
||||
16.0.1.2.0 (2023-10-13)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
**Features**
|
||||
|
||||
- The field *total* in the *PagedCollection* schema is replaced by the field *count*.
|
||||
The field *total* is now deprecated and will be removed in the next major version.
|
||||
This change is backward compatible. The json document returned will now
|
||||
contain both fields *total* and *count* with the same value. In your python
|
||||
code the field *total*, if used, will fill the field *count* with the same
|
||||
value. You are encouraged to use the field *count* instead of *total* and adapt
|
||||
your code accordingly. (`#380 <https://github.com/OCA/rest-framework/issues/380>`_)
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
The `roadmap <https://github.com/OCA/rest-framework/issues?q=is%3Aopen+is%3Aissue+label%3Aenhancement+label%3Afastapi>`_
|
||||
and `known issues <https://github.com/OCA/rest-framework/issues?q=is%3Aopen+is%3Aissue+label%3Abug+label%3Afastapi>`_ can
|
||||
be found on GitHub.
|
||||
|
||||
The **FastAPI** module provides an easy way to use WebSockets. Unfortunately, this
|
||||
support is not 'yet' available in the **Odoo** framework. The challenge is high
|
||||
because the integration of the fastapi is based on the use of a specific middleware
|
||||
that convert the WSGI request consumed by odoo to a ASGI request. The question
|
||||
is to know if it is also possible to develop the same kind of bridge for the
|
||||
WebSockets and to stream large responses.
|
||||
1334
odoo-bringout-oca-rest-framework-fastapi/fastapi/readme/USAGE.rst
Normal file
1334
odoo-bringout-oca-rest-framework-fastapi/fastapi/readme/USAGE.rst
Normal file
File diff suppressed because it is too large
Load diff
0
odoo-bringout-oca-rest-framework-fastapi/fastapi/readme/newsfragments/.gitignore
vendored
Normal file
0
odoo-bringout-oca-rest-framework-fastapi/fastapi/readme/newsfragments/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .demo_router import router as demo_router
|
||||
from .demo_router import __doc__ as demo_router_doc
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
# Copyright 2023 ACSONE SA/NV
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL).
|
||||
"""
|
||||
The demo router is a router that demonstrates how to use the fastapi
|
||||
integration with odoo.
|
||||
"""
|
||||
from typing import Annotated
|
||||
|
||||
from psycopg2 import errorcodes
|
||||
from psycopg2.errors import OperationalError
|
||||
|
||||
from odoo.api import Environment
|
||||
from odoo.exceptions import AccessError, MissingError, UserError, ValidationError
|
||||
from odoo.service.model import MAX_TRIES_ON_CONCURRENCY_FAILURE
|
||||
|
||||
from odoo.addons.base.models.res_partner import Partner
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from ..dependencies import authenticated_partner, fastapi_endpoint, odoo_env
|
||||
from ..models import FastapiEndpoint
|
||||
from ..schemas import DemoEndpointAppInfo, DemoExceptionType, DemoUserInfo
|
||||
|
||||
router = APIRouter(tags=["demo"])
|
||||
|
||||
|
||||
@router.get("/demo")
|
||||
async def hello_word():
|
||||
"""Hello World!"""
|
||||
return {"Hello": "World"}
|
||||
|
||||
|
||||
@router.get("/demo/exception")
|
||||
async def exception(exception_type: DemoExceptionType, error_message: str):
|
||||
"""Raise an exception
|
||||
|
||||
This method is used in the test suite to check that any exception
|
||||
is correctly handled by the fastapi endpoint and that the transaction
|
||||
is roll backed.
|
||||
"""
|
||||
exception_classes = {
|
||||
DemoExceptionType.user_error: UserError,
|
||||
DemoExceptionType.validation_error: ValidationError,
|
||||
DemoExceptionType.access_error: AccessError,
|
||||
DemoExceptionType.missing_error: MissingError,
|
||||
DemoExceptionType.http_exception: HTTPException,
|
||||
DemoExceptionType.bare_exception: NotImplementedError,
|
||||
}
|
||||
exception_cls = exception_classes[exception_type]
|
||||
if exception_cls is HTTPException:
|
||||
raise exception_cls(status_code=status.HTTP_409_CONFLICT, detail=error_message)
|
||||
raise exception_classes[exception_type](error_message)
|
||||
|
||||
|
||||
@router.get("/demo/lang")
|
||||
async def get_lang(env: Annotated[Environment, Depends(odoo_env)]):
|
||||
"""Returns the language according to the available languages in Odoo and the
|
||||
Accept-Language header.
|
||||
|
||||
This method is used in the test suite to check that the language is correctly
|
||||
set in the Odoo environment according to the Accept-Language header
|
||||
"""
|
||||
return env.context.get("lang")
|
||||
|
||||
|
||||
@router.get("/demo/who_ami")
|
||||
async def who_ami(
|
||||
partner: Annotated[Partner, Depends(authenticated_partner)]
|
||||
) -> DemoUserInfo:
|
||||
"""Who am I?
|
||||
|
||||
Returns the authenticated partner
|
||||
"""
|
||||
# This method show you how you can rget the authenticated partner without
|
||||
# depending on a specific implementation.
|
||||
return DemoUserInfo(name=partner.name, display_name=partner.display_name)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/demo/endpoint_app_info",
|
||||
dependencies=[Depends(authenticated_partner)],
|
||||
)
|
||||
async def endpoint_app_info(
|
||||
endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)],
|
||||
) -> DemoEndpointAppInfo:
|
||||
"""Returns the current endpoint configuration"""
|
||||
# This method show you how to get access to current endpoint configuration
|
||||
# It also show you how you can specify a dependency to force the security
|
||||
# even if the method doesn't require the authenticated partner as parameter
|
||||
return DemoEndpointAppInfo.model_validate(endpoint)
|
||||
|
||||
|
||||
_CPT = 0
|
||||
|
||||
|
||||
@router.get("/demo/retrying")
|
||||
async def retrying(
|
||||
nbr_retries: Annotated[int, Query(gt=1, lt=MAX_TRIES_ON_CONCURRENCY_FAILURE)],
|
||||
) -> int:
|
||||
"""This method is used in the test suite to check that the retrying
|
||||
functionality in case of concurrency error on the database is working
|
||||
correctly for retryable exceptions.
|
||||
|
||||
The output will be the number of retries that have been done.
|
||||
|
||||
This method is mainly used to test the retrying functionality
|
||||
"""
|
||||
global _CPT
|
||||
if _CPT < nbr_retries:
|
||||
_CPT += 1
|
||||
raise FakeConcurrentUpdateError("fake error")
|
||||
tryno = _CPT
|
||||
_CPT = 0
|
||||
return tryno
|
||||
|
||||
|
||||
@router.post("/demo/retrying")
|
||||
async def retrying_post(
|
||||
nbr_retries: Annotated[int, Query(gt=1, lt=MAX_TRIES_ON_CONCURRENCY_FAILURE)],
|
||||
file: Annotated[bytes, File()],
|
||||
) -> JSONResponse:
|
||||
"""This method is used in the test suite to check that the retrying
|
||||
functionality in case of concurrency error on the database is working
|
||||
correctly for retryable exceptions.
|
||||
|
||||
The output will be the number of retries that have been done.
|
||||
|
||||
This method is mainly used to test the retrying functionality
|
||||
"""
|
||||
global _CPT
|
||||
if _CPT < nbr_retries:
|
||||
_CPT += 1
|
||||
raise FakeConcurrentUpdateError("fake error")
|
||||
tryno = _CPT
|
||||
_CPT = 0
|
||||
return JSONResponse(content={"retries": tryno, "file": file.decode("utf-8")})
|
||||
|
||||
|
||||
class FakeConcurrentUpdateError(OperationalError):
|
||||
@property
|
||||
def pgcode(self):
|
||||
return errorcodes.SERIALIZATION_FAILURE
|
||||
67
odoo-bringout-oca-rest-framework-fastapi/fastapi/schemas.py
Normal file
67
odoo-bringout-oca-rest-framework-fastapi/fastapi/schemas.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Copyright 2022 ACSONE SA/NV
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL).
|
||||
import warnings
|
||||
from enum import Enum
|
||||
from typing import Annotated, Generic, List, Optional, TypeVar
|
||||
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class PagedCollection(BaseModel, Generic[T]):
|
||||
count: Annotated[
|
||||
int,
|
||||
Field(
|
||||
...,
|
||||
description="Count of items into the system.\n "
|
||||
"Replaces the total field which is deprecated",
|
||||
validation_alias=AliasChoices("count", "total"),
|
||||
),
|
||||
]
|
||||
items: List[T]
|
||||
|
||||
@computed_field()
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return self.count
|
||||
|
||||
@total.setter
|
||||
def total(self, value: int):
|
||||
warnings.warn(
|
||||
"The total field is deprecated, please use count instead",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
self.count = value
|
||||
|
||||
|
||||
class Paging(BaseModel):
|
||||
limit: Optional[int] = None
|
||||
offset: Optional[int] = None
|
||||
|
||||
|
||||
#############################################################
|
||||
# here above you can find models only used for the demo app #
|
||||
#############################################################
|
||||
class DemoUserInfo(BaseModel):
|
||||
name: str
|
||||
display_name: str
|
||||
|
||||
|
||||
class DemoEndpointAppInfo(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
app: str
|
||||
auth_method: str = Field(alias="demo_auth_method")
|
||||
root_path: str
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class DemoExceptionType(str, Enum):
|
||||
user_error = "UserError"
|
||||
validation_error = "ValidationError"
|
||||
access_error = "AccessError"
|
||||
missing_error = "MissingError"
|
||||
http_exception = "HTTPException"
|
||||
bare_exception = "BareException"
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<!-- Copyright 2022 ACSONE SA/NV
|
||||
License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). -->
|
||||
<odoo>
|
||||
|
||||
<record model="ir.model.access" id="fastapi_endpoint_access_view">
|
||||
<field name="name">fastapi.endpoint view</field>
|
||||
<field name="model_id" ref="model_fastapi_endpoint" />
|
||||
<field name="group_id" ref="group_fastapi_user" />
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_create" eval="0" />
|
||||
<field name="perm_write" eval="0" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<record model="ir.model.access" id="fastapi_endpoint_access_manage">
|
||||
<field name="name">fastapi.endpoint manage</field>
|
||||
<field name="model_id" ref="model_fastapi_endpoint" />
|
||||
<field name="group_id" ref="group_fastapi_manager" />
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<!-- Copyright 2022 ACSONE SA/NV
|
||||
License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). -->
|
||||
<odoo>
|
||||
<!-- give access to the user running the demo app to the res.user model -->
|
||||
<record id="res_user_fastapi_running_user" model="ir.model.access">
|
||||
<field name="name">Fastapi: Running user read users</field>
|
||||
<field name="model_id" ref="base.model_res_users" />
|
||||
<field name="group_id" ref="group_fastapi_endpoint_runner" />
|
||||
<field name="perm_read" eval="True" />
|
||||
<field name="perm_write" eval="False" />
|
||||
<field name="perm_create" eval="False" />
|
||||
<field name="perm_unlink" eval="False" />
|
||||
</record>
|
||||
|
||||
<!-- restrict the access by the user running the demo app to its own user -->
|
||||
<record id="res_user_fastapi_running_user_rule" model="ir.rule">
|
||||
<field name="name">Fastapi: Running user rule</field>
|
||||
<field name="model_id" ref="base.model_res_users" />
|
||||
<field name="domain_force"> [('id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fastapi_endpoint_runner'))]" />
|
||||
</record>
|
||||
|
||||
<!-- give access to the user running the demo app to the res.partner model -->
|
||||
<record id="res_partner_fastapi_running_user" model="ir.model.access">
|
||||
<field name="name">Fastapi: Running user read partners</field>
|
||||
<field name="model_id" ref="base.model_res_partner" />
|
||||
<field name="group_id" ref="group_fastapi_endpoint_runner" />
|
||||
<field name="perm_read" eval="True" />
|
||||
<field name="perm_write" eval="False" />
|
||||
<field name="perm_create" eval="False" />
|
||||
<field name="perm_unlink" eval="False" />
|
||||
</record>
|
||||
|
||||
<!-- restrict the access by the user running the demo app to its own partner
|
||||
and the authenticated one-->
|
||||
<record id="res_partner_fastapi_running_user_rule" model="ir.rule">
|
||||
<field name="name">Fastapi: Running user rule</field>
|
||||
<field name="model_id" ref="base.model_res_partner" />
|
||||
<field
|
||||
name="domain_force"
|
||||
> ['|', ('user_ids', '=', user.id), ('id', '=', authenticated_partner_id)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fastapi_endpoint_runner'))]" />
|
||||
</record>
|
||||
|
||||
<!-- give access by the user running the demo app to the fastapi.enddoint model -->
|
||||
<record id="fastapi_endpoint_fastapi_running_user" model="ir.model.access">
|
||||
<field name="name">Fastapi: Running user read endpoints</field>
|
||||
<field name="model_id" ref="fastapi.model_fastapi_endpoint" />
|
||||
<field name="group_id" ref="group_fastapi_endpoint_runner" />
|
||||
<field name="perm_read" eval="True" />
|
||||
<field name="perm_write" eval="False" />
|
||||
<field name="perm_create" eval="False" />
|
||||
<field name="perm_unlink" eval="False" />
|
||||
</record>
|
||||
|
||||
<!-- restrict the access by the user running the demo app to its own endpoints -->
|
||||
<record id="fastapi_endpoint_fastapi_running_user_rule" model="ir.rule">
|
||||
<field name="name">Fastapi: Running user rule</field>
|
||||
<field name="model_id" ref="fastapi.model_fastapi_endpoint" />
|
||||
<field name="domain_force"> [('user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fastapi_endpoint_runner'))]" />
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<!-- Copyright 2022 ACSONE SA/NV
|
||||
License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). -->
|
||||
<odoo>
|
||||
<record model="ir.module.category" id="module_category_fastapi">
|
||||
<field name="name">FastAPI</field>
|
||||
<field name="description">Helps you manage your Fastapi Endpoints</field>
|
||||
<field name="sequence">99</field>
|
||||
</record>
|
||||
|
||||
<record id="group_fastapi_user" model="res.groups">
|
||||
<field name="name">User</field>
|
||||
<field name="implied_ids" eval="[(4, ref('base.group_user'))]" />
|
||||
<field name="category_id" ref="module_category_fastapi" />
|
||||
</record>
|
||||
|
||||
<record id="group_fastapi_manager" model="res.groups">
|
||||
<field name="name">Administrator</field>
|
||||
<field name="category_id" ref="module_category_fastapi" />
|
||||
<field name="implied_ids" eval="[(4, ref('group_fastapi_user'))]" />
|
||||
<field
|
||||
name="users"
|
||||
eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"
|
||||
/>
|
||||
</record>
|
||||
|
||||
<!-- create a basic group providing the minimal access rights to retrieve
|
||||
the user running the endpoint handlers and performs authentication -->
|
||||
<record id="group_fastapi_endpoint_runner" model="res.groups">
|
||||
<field name="name">FastAPI Endpoint Runner</field>
|
||||
<field name="category_id" ref="module_category_fastapi" />
|
||||
</record>
|
||||
</odoo>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,2 @@
|
|||
from . import test_fastapi
|
||||
from . import test_fastapi_demo
|
||||
164
odoo-bringout-oca-rest-framework-fastapi/fastapi/tests/common.py
Normal file
164
odoo-bringout-oca-rest-framework-fastapi/fastapi/tests/common.py
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
# Copyright 2023 ACSONE SA/NV
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL).
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from functools import partial
|
||||
from typing import Any, Callable, Dict
|
||||
|
||||
from starlette import status
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
|
||||
from odoo.api import Environment
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
from odoo.addons.base.models.res_partner import Partner
|
||||
from odoo.addons.base.models.res_users import Users
|
||||
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ..context import odoo_env_ctx
|
||||
from ..dependencies import (
|
||||
authenticated_partner_impl,
|
||||
optionally_authenticated_partner_impl,
|
||||
)
|
||||
from ..error_handlers import convert_exception_to_status_body
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def default_exception_handler(request: Request, exc: Exception) -> Response:
|
||||
"""
|
||||
Default exception handler that returns a response with the exception details.
|
||||
"""
|
||||
status_code, body = convert_exception_to_status_body(exc)
|
||||
|
||||
if status_code == status.HTTP_500_INTERNAL_SERVER_ERROR:
|
||||
# In testing we want to see the exception details of 500 errors
|
||||
_logger.error("[%d] Error occurred: %s", exc_info=exc)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content=body,
|
||||
)
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class FastAPITransactionCase(TransactionCase):
|
||||
"""
|
||||
This class is a base class for FastAPI tests.
|
||||
|
||||
It defines default values for the attributes used to create the test client.
|
||||
The default values can be overridden by setting the corresponding class attributes.
|
||||
Default attributes are:
|
||||
- default_fastapi_app: the FastAPI app to use to create the test client
|
||||
- default_fastapi_router: the FastAPI router to use to create the test client
|
||||
- default_fastapi_odoo_env: the Odoo environment that will be used to run
|
||||
the endpoint implementation
|
||||
- default_fastapi_running_user: the user that will be used to run the endpoint
|
||||
implementation
|
||||
- default_fastapi_authenticated_partner: the partner that will be used to run
|
||||
to build the authenticated_partner and authenticated_partner_env dependencies
|
||||
- default_fastapi_dependency_overrides: a dict of dependency overrides that will
|
||||
be applied to the app when creating the test client
|
||||
|
||||
The test client is created by calling the _create_test_client method. When
|
||||
calling this method, the default values are used unless they are overridden by
|
||||
passing the corresponding arguments.
|
||||
|
||||
Even if you can provide a default value for the default_fastapi_app and
|
||||
default_fastapi_router attributes, you should always provide only one of them.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))
|
||||
cls.default_fastapi_app: FastAPI | None = None
|
||||
cls.default_fastapi_router: APIRouter | None = None
|
||||
cls.default_fastapi_odoo_env: Environment = cls.env
|
||||
cls.default_fastapi_running_user: Users | None = None
|
||||
cls.default_fastapi_authenticated_partner: Partner | None = None
|
||||
cls.default_fastapi_dependency_overrides: Dict[
|
||||
Callable[..., Any], Callable[..., Any]
|
||||
] = {}
|
||||
|
||||
@contextmanager
|
||||
def _create_test_client(
|
||||
self,
|
||||
app: FastAPI | None = None,
|
||||
router: APIRouter | None = None,
|
||||
user: Users | None = None,
|
||||
partner: Partner | None = None,
|
||||
env: Environment = None,
|
||||
dependency_overrides: Dict[Callable[..., Any], Callable[..., Any]] = None,
|
||||
raise_server_exceptions: bool = True,
|
||||
testclient_kwargs=None,
|
||||
):
|
||||
"""
|
||||
Create a test client for the given app or router.
|
||||
|
||||
This method is a context manager that yields the test client. It
|
||||
ensures that the Odoo environment is properly set up when running
|
||||
the endpoint implementation, and cleaned up after the test client is
|
||||
closed.
|
||||
|
||||
Pay attention to the **'raise_server_exceptions'** argument. It's
|
||||
default value is **True**. This means that if the endpoint implementation
|
||||
raises an exception, the test client will raise it. That also means
|
||||
that if you app includes specific exception handlers, they will not
|
||||
be called. If you want to test your exception handlers, you should
|
||||
set this argument to **False**. In this case, the test client will
|
||||
not raise the exception, but will return it in the response and the
|
||||
exception handlers will be called.
|
||||
"""
|
||||
env = env or self.default_fastapi_odoo_env
|
||||
user = user or self.default_fastapi_running_user
|
||||
dependencies = self.default_fastapi_dependency_overrides.copy()
|
||||
if dependency_overrides:
|
||||
dependencies.update(dependency_overrides)
|
||||
if user:
|
||||
env = env(user=user)
|
||||
partner = (
|
||||
partner
|
||||
or self.default_fastapi_authenticated_partner
|
||||
or self.env["res.partner"]
|
||||
)
|
||||
if partner and authenticated_partner_impl in dependencies:
|
||||
raise ValueError(
|
||||
"You cannot provide an override for the authenticated_partner_impl "
|
||||
"dependency when creating a test client with a partner."
|
||||
)
|
||||
if partner or authenticated_partner_impl not in dependencies:
|
||||
dependencies[authenticated_partner_impl] = partial(lambda a: a, partner)
|
||||
if partner and optionally_authenticated_partner_impl in dependencies:
|
||||
raise ValueError(
|
||||
"You cannot provide an override for the optionally_authenticated_partner_impl "
|
||||
"dependency when creating a test client with a partner."
|
||||
)
|
||||
if partner or optionally_authenticated_partner_impl not in dependencies:
|
||||
dependencies[optionally_authenticated_partner_impl] = partial(
|
||||
lambda a: a, partner
|
||||
)
|
||||
app = app or self.default_fastapi_app or FastAPI()
|
||||
router = router or self.default_fastapi_router
|
||||
if router:
|
||||
app.include_router(router)
|
||||
app.dependency_overrides = dependencies
|
||||
|
||||
if not raise_server_exceptions:
|
||||
# Handle exceptions as in FastAPIDispatcher
|
||||
app.exception_handlers.setdefault(Exception, default_exception_handler)
|
||||
|
||||
ctx_token = odoo_env_ctx.set(env)
|
||||
testclient_kwargs = testclient_kwargs or {}
|
||||
try:
|
||||
yield TestClient(
|
||||
app,
|
||||
raise_server_exceptions=raise_server_exceptions,
|
||||
**testclient_kwargs
|
||||
)
|
||||
finally:
|
||||
odoo_env_ctx.reset(ctx_token)
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
# Copyright 2022 ACSONE SA/NV
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL).
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from contextlib import contextmanager
|
||||
|
||||
from odoo import sql_db
|
||||
from odoo.tests.common import HttpCase
|
||||
from odoo.tools import mute_logger
|
||||
|
||||
from fastapi import status
|
||||
|
||||
from ..schemas import DemoExceptionType
|
||||
|
||||
|
||||
@unittest.skipIf(os.getenv("SKIP_HTTP_CASE"), "EndpointHttpCase skipped")
|
||||
class FastAPIHttpCase(HttpCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.fastapi_demo_app = cls.env.ref("fastapi.fastapi_endpoint_demo")
|
||||
cls.fastapi_multi_demo_app = cls.env.ref(
|
||||
"fastapi.fastapi_endpoint_multislash_demo"
|
||||
)
|
||||
cls.fastapi_apps = cls.fastapi_demo_app + cls.fastapi_multi_demo_app
|
||||
cls.fastapi_apps._handle_registry_sync()
|
||||
lang = (
|
||||
cls.env["res.lang"]
|
||||
.with_context(active_test=False)
|
||||
.search([("code", "=", "fr_BE")])
|
||||
)
|
||||
lang.active = True
|
||||
|
||||
@contextmanager
|
||||
def _mocked_commit(self):
|
||||
with unittest.mock.patch.object(
|
||||
sql_db.TestCursor, "commit", return_value=None
|
||||
) as mocked_commit:
|
||||
yield mocked_commit
|
||||
|
||||
def _assert_expected_lang(self, accept_language, expected_lang):
|
||||
route = "/fastapi_demo/demo/lang"
|
||||
response = self.url_open(route, headers={"Accept-language": accept_language})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.content, expected_lang)
|
||||
|
||||
def test_call(self):
|
||||
route = "/fastapi_demo/demo/"
|
||||
response = self.url_open(route)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.content, b'{"Hello":"World"}')
|
||||
|
||||
def test_lang(self):
|
||||
self._assert_expected_lang("fr,en;q=0.7,en-GB;q=0.3", b'"fr_BE"')
|
||||
self._assert_expected_lang("en,fr;q=0.7,en-GB;q=0.3", b'"en_US"')
|
||||
self._assert_expected_lang("fr-FR,en;q=0.7,en-GB;q=0.3", b'"fr_BE"')
|
||||
self._assert_expected_lang("fr-FR;q=0.1,en;q=1.0,en-GB;q=0.8", b'"en_US"')
|
||||
|
||||
def test_retrying(self):
|
||||
"""Test that the retrying mechanism is working as expected with the
|
||||
FastAPI endpoints.
|
||||
"""
|
||||
nbr_retries = 3
|
||||
route = f"/fastapi_demo/demo/retrying?nbr_retries={nbr_retries}"
|
||||
response = self.url_open(route, timeout=20)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(int(response.content), nbr_retries)
|
||||
|
||||
def test_retrying_post(self):
|
||||
"""Test that the retrying mechanism is working as expected with the
|
||||
FastAPI endpoints in case of POST request with a file.
|
||||
"""
|
||||
nbr_retries = 3
|
||||
route = f"/fastapi_demo/demo/retrying?nbr_retries={nbr_retries}"
|
||||
response = self.url_open(
|
||||
route, timeout=20, files={"file": ("test.txt", b"test")}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertDictEqual(response.json(), {"retries": nbr_retries, "file": "test"})
|
||||
|
||||
@mute_logger("odoo.http")
|
||||
def assert_exception_processed(
|
||||
self,
|
||||
exception_type: DemoExceptionType,
|
||||
error_message: str,
|
||||
expected_message: str,
|
||||
expected_status_code: int,
|
||||
) -> None:
|
||||
with self._mocked_commit() as mocked_commit:
|
||||
route = (
|
||||
"/fastapi_demo/demo/exception?"
|
||||
f"exception_type={exception_type.value}&error_message={error_message}"
|
||||
)
|
||||
response = self.url_open(route, timeout=200)
|
||||
mocked_commit.assert_not_called()
|
||||
self.assertDictEqual(
|
||||
response.json(),
|
||||
{
|
||||
"detail": expected_message,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, expected_status_code)
|
||||
|
||||
def test_user_error(self) -> None:
|
||||
self.assert_exception_processed(
|
||||
exception_type=DemoExceptionType.user_error,
|
||||
error_message="test",
|
||||
expected_message="test",
|
||||
expected_status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def test_validation_error(self) -> None:
|
||||
self.assert_exception_processed(
|
||||
exception_type=DemoExceptionType.validation_error,
|
||||
error_message="test",
|
||||
expected_message="test",
|
||||
expected_status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def test_bare_exception(self) -> None:
|
||||
self.assert_exception_processed(
|
||||
exception_type=DemoExceptionType.bare_exception,
|
||||
error_message="test",
|
||||
expected_message="Internal Server Error",
|
||||
expected_status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
def test_access_error(self) -> None:
|
||||
self.assert_exception_processed(
|
||||
exception_type=DemoExceptionType.access_error,
|
||||
error_message="test",
|
||||
expected_message="AccessError",
|
||||
expected_status_code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
def test_missing_error(self) -> None:
|
||||
self.assert_exception_processed(
|
||||
exception_type=DemoExceptionType.missing_error,
|
||||
error_message="test",
|
||||
expected_message="MissingError",
|
||||
expected_status_code=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
def test_http_exception(self) -> None:
|
||||
self.assert_exception_processed(
|
||||
exception_type=DemoExceptionType.http_exception,
|
||||
error_message="test",
|
||||
expected_message="test",
|
||||
expected_status_code=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
@mute_logger("odoo.http")
|
||||
def test_request_validation_error(self) -> None:
|
||||
with self._mocked_commit() as mocked_commit:
|
||||
route = "/fastapi_demo/demo/exception?exception_type=BAD&error_message="
|
||||
response = self.url_open(route, timeout=200)
|
||||
mocked_commit.assert_not_called()
|
||||
self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
|
||||
def test_no_commit_on_exception(self) -> None:
|
||||
# this test check that the way we mock the cursor is working as expected
|
||||
# and that the transaction is rolled back in case of exception.
|
||||
with self._mocked_commit() as mocked_commit:
|
||||
url = "/fastapi_demo/demo"
|
||||
response = self.url_open(url, timeout=600)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
mocked_commit.assert_called_once()
|
||||
|
||||
self.assert_exception_processed(
|
||||
exception_type=DemoExceptionType.http_exception,
|
||||
error_message="test",
|
||||
expected_message="test",
|
||||
expected_status_code=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
def test_url_matching(self):
|
||||
# Test the URL mathing method on the endpoint
|
||||
paths = ["/fastapi", "/fastapi_demo", "/fastapi/v1"]
|
||||
EndPoint = self.env["fastapi.endpoint"]
|
||||
self.assertEqual(
|
||||
EndPoint._find_first_matching_url_path(paths, "/fastapi_demo/test"),
|
||||
"/fastapi_demo",
|
||||
)
|
||||
self.assertEqual(
|
||||
EndPoint._find_first_matching_url_path(paths, "/fastapi/test"), "/fastapi"
|
||||
)
|
||||
self.assertEqual(
|
||||
EndPoint._find_first_matching_url_path(paths, "/fastapi/v2/test"),
|
||||
"/fastapi",
|
||||
)
|
||||
self.assertEqual(
|
||||
EndPoint._find_first_matching_url_path(paths, "/fastapi/v1/test"),
|
||||
"/fastapi/v1",
|
||||
)
|
||||
|
||||
def test_multi_slash(self):
|
||||
route = "/fastapi/demo-multi/demo/"
|
||||
response = self.url_open(route, timeout=20)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(self.fastapi_multi_demo_app.root_path, str(response.url))
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
# Copyright 2022 ACSONE SA/NV
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL).
|
||||
|
||||
from functools import partial
|
||||
|
||||
from requests import Response
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools.misc import mute_logger
|
||||
|
||||
from fastapi import status
|
||||
|
||||
from ..dependencies import fastapi_endpoint
|
||||
from ..routers import demo_router
|
||||
from ..schemas import DemoEndpointAppInfo, DemoExceptionType
|
||||
from .common import FastAPITransactionCase
|
||||
|
||||
|
||||
class FastAPIDemoCase(FastAPITransactionCase):
|
||||
"""The fastapi lib comes with a useful testclient that let's you
|
||||
easily test your endpoints. Moreover, the dependency overrides functionality
|
||||
allows you to provide specific implementation for part of the code to avoid
|
||||
to rely on some tricky http stuff for example: authentication
|
||||
|
||||
This test class is an example on how you can test your own code
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
super().setUpClass()
|
||||
cls.default_fastapi_router = demo_router
|
||||
cls.default_fastapi_running_user = cls.env.ref("fastapi.my_demo_app_user")
|
||||
cls.default_fastapi_authenticated_partner = cls.env["res.partner"].create(
|
||||
{"name": "FastAPI Demo"}
|
||||
)
|
||||
|
||||
def test_hello_world(self) -> None:
|
||||
with self._create_test_client() as test_client:
|
||||
response: Response = test_client.get("/demo/")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertDictEqual(response.json(), {"Hello": "World"})
|
||||
|
||||
def test_who_ami(self) -> None:
|
||||
with self._create_test_client() as test_client:
|
||||
response: Response = test_client.get("/demo/who_ami")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
partner = self.default_fastapi_authenticated_partner
|
||||
self.assertDictEqual(
|
||||
response.json(),
|
||||
{
|
||||
"name": partner.name,
|
||||
"display_name": partner.display_name,
|
||||
},
|
||||
)
|
||||
|
||||
def test_endpoint_info(self) -> None:
|
||||
demo_app = self.env.ref("fastapi.fastapi_endpoint_demo")
|
||||
with self._create_test_client(
|
||||
dependency_overrides={fastapi_endpoint: partial(lambda a: a, demo_app)}
|
||||
) as test_client:
|
||||
response: Response = test_client.get("/demo/endpoint_app_info")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertDictEqual(
|
||||
response.json(),
|
||||
DemoEndpointAppInfo.model_validate(demo_app).model_dump(by_alias=True),
|
||||
)
|
||||
|
||||
def test_exception_raised(self) -> None:
|
||||
with self.assertRaisesRegex(UserError, "User Error"):
|
||||
with self._create_test_client() as test_client:
|
||||
test_client.get(
|
||||
"/demo/exception",
|
||||
params={
|
||||
"exception_type": DemoExceptionType.user_error.value,
|
||||
"error_message": "User Error",
|
||||
},
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(NotImplementedError, "Bare Exception"):
|
||||
with self._create_test_client() as test_client:
|
||||
test_client.get(
|
||||
"/demo/exception",
|
||||
params={
|
||||
"exception_type": DemoExceptionType.bare_exception.value,
|
||||
"error_message": "Bare Exception",
|
||||
},
|
||||
)
|
||||
|
||||
@mute_logger("odoo.addons.fastapi.tests.common")
|
||||
def test_exception_not_raised(self) -> None:
|
||||
with self._create_test_client(raise_server_exceptions=False) as test_client:
|
||||
response: Response = test_client.get(
|
||||
"/demo/exception",
|
||||
params={
|
||||
"exception_type": DemoExceptionType.user_error.value,
|
||||
"error_message": "User Error",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertDictEqual(response.json(), {"detail": "User Error"})
|
||||
|
||||
with self._create_test_client(raise_server_exceptions=False) as test_client:
|
||||
response: Response = test_client.get(
|
||||
"/demo/exception",
|
||||
params={
|
||||
"exception_type": DemoExceptionType.bare_exception.value,
|
||||
"error_message": "Bare Exception",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
self.assertDictEqual(response.json(), {"detail": "Internal Server Error"})
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<!-- Copyright 2022 ACSONE SA/NV
|
||||
License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). -->
|
||||
<odoo>
|
||||
|
||||
<record model="ir.ui.view" id="fastapi_endpoint_form_view">
|
||||
<field name="name">fastapi.endpoint.form (in fastapi)</field>
|
||||
<field name="model">fastapi.endpoint</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
</header>
|
||||
<sheet>
|
||||
<field name="active" invisible="1" />
|
||||
<field name="registry_sync" invisible="1" />
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button
|
||||
name="action_sync_registry"
|
||||
string="Sync Registry"
|
||||
type="object"
|
||||
class="oe_highlight"
|
||||
icon="fa-refresh"
|
||||
attrs="{'invisible': [('registry_sync', '=', True)]}"
|
||||
/>
|
||||
</div>
|
||||
<widget
|
||||
name="web_ribbon"
|
||||
title="Archived"
|
||||
bg_color="bg-danger"
|
||||
attrs="{'invisible': [('active', '=', True)]}"
|
||||
/>
|
||||
<widget
|
||||
name="web_ribbon"
|
||||
title="Registry Sync Required"
|
||||
bg_color="bg-warning"
|
||||
attrs="{'invisible': [('registry_sync', '=', True)]}"
|
||||
/>
|
||||
<div class="oe_title">
|
||||
<label for="name" />
|
||||
<h1><field name="name" /></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="app" />
|
||||
<field name="root_path" />
|
||||
<field name="user_id" />
|
||||
<field name="company_id" />
|
||||
<field name="description" />
|
||||
<field name="save_http_session" />
|
||||
</group>
|
||||
<group name="resoures">
|
||||
<field name="docs_url" widget="url" />
|
||||
<field name="redoc_url" widget="url" />
|
||||
<field name="openapi_url" widget="url" />
|
||||
</group>
|
||||
</group>
|
||||
<span name="configuration" />
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="fastapi_endpoint_search_view">
|
||||
<field name="name">fastapi.endpoint.search (in fastapi)</field>
|
||||
<field name="model">fastapi.endpoint</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name" />
|
||||
<field name="app" />
|
||||
<filter
|
||||
string="Archived"
|
||||
name="inactive"
|
||||
domain="[('active','=',False)]"
|
||||
/>
|
||||
<group expand='0' string='Group by...'>
|
||||
<filter
|
||||
string='App'
|
||||
name="groupby_app"
|
||||
context="{'group_by': 'app'}"
|
||||
/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="fastapi_endpoint_tree_view">
|
||||
<field name="name">fastapi.endpoint.tree (in fastapi)</field>
|
||||
<field name="model">fastapi.endpoint</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree decoration-warning="not registry_sync" decoration-muted="not active">
|
||||
<field name="active" invisible="1" />
|
||||
<field name="registry_sync" invisible="1" />
|
||||
<field name="name" />
|
||||
<field name="app" />
|
||||
<field name="root_path" />
|
||||
<field name="docs_url" widget="url" />
|
||||
<field name="redoc_url" widget="url" />
|
||||
<field name="openapi_url" widget="url" />
|
||||
<button
|
||||
name="action_sync_registry"
|
||||
string="Sync Registry"
|
||||
type="object"
|
||||
class="oe_highlight"
|
||||
icon="fa-refresh"
|
||||
attrs="{'invisible': [('registry_sync', '=', True)]}"
|
||||
/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.actions.server" id="fastapi_endpoint_action_sync_registry">
|
||||
<field name="name">Sync Registry</field>
|
||||
<field name="model_id" ref="model_fastapi_endpoint" />
|
||||
<field name="binding_model_id" ref="model_fastapi_endpoint" />
|
||||
<field name="binding_type">action</field>
|
||||
<field name="state">code</field>
|
||||
<field name="code">
|
||||
records.action_sync_registry()
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.actions.act_window" id="fastapi_endpoint_act_window">
|
||||
<field name="name">FastAPI Endpoint</field>
|
||||
<field name="res_model">fastapi.endpoint</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="domain">[]</field>
|
||||
<field name="context">{}</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.menu" id="fastapi_endpoint_menu">
|
||||
<field name="name">FastAPI Endpoint</field>
|
||||
<field name="parent_id" ref="menu_fastapi_root" />
|
||||
<field name="action" ref="fastapi_endpoint_act_window" />
|
||||
<field name="sequence" eval="16" />
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<!-- Copyright 2022 ACSONE SA/NV
|
||||
License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). -->
|
||||
<odoo>
|
||||
|
||||
<record model="ir.ui.view" id="fastapi_endpoint_demo_form_view">
|
||||
<field name="name">fastapi.endpoint.demo.form (in fastapi)</field>
|
||||
<field name="model">fastapi.endpoint</field>
|
||||
<field name="inherit_id" ref="fastapi_endpoint_form_view" />
|
||||
<field name="arch" type="xml">
|
||||
<span name="configuration" position="after">
|
||||
<group
|
||||
name="demo_app_configuration"
|
||||
title="Configuration"
|
||||
attrs="{'invisible': [('app', '!=', 'demo')]}"
|
||||
>
|
||||
<field name="demo_auth_method" />
|
||||
</group>
|
||||
</span>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<!-- Copyright 2022 ACSONE SA/NV
|
||||
License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). -->
|
||||
<odoo>
|
||||
<record id="menu_fastapi_root" model="ir.ui.menu">
|
||||
<field name="name">FastAPI</field>
|
||||
<field name="sequence" eval="400" />
|
||||
<field name="web_icon">fastapi,static/description/icon.png</field>
|
||||
<field name="groups_id" eval="[(4, ref('group_fastapi_user'))]" />
|
||||
</record>
|
||||
</odoo>
|
||||
Loading…
Add table
Add a link
Reference in a new issue