Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ exclude: |
^base_rest_auth_api_key/|
^base_rest_pydantic/|
^extendable/|
^fastapi/|
^pydantic/|
^rest_log/|
# END NOT INSTALLABLE ADDONS
Expand Down
633 changes: 317 additions & 316 deletions fastapi/README.rst

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions fastapi/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"name": "Odoo FastAPI",
"summary": """
Odoo FastAPI endpoint""",
"version": "18.0.1.3.0",
"version": "19.0.1.0.0",
"license": "LGPL-3",
"author": "ACSONE SA/NV,Odoo Community Association (OCA)",
"maintainers": ["lmignon"],
Expand All @@ -30,5 +30,5 @@
]
},
"development_status": "Beta",
"installable": False,
"installable": True,
}
9 changes: 6 additions & 3 deletions fastapi/demo/fastapi_endpoint_demo.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
>
<field name="name">My Demo Endpoint User</field>
<field name="login">my_demo_app_user</field>
<field name="groups_id" eval="[(6, 0, [])]" />
<field name="group_ids" eval="[Command.set([])]" />
</record>

<!-- This is the group that will be used to run the demo app
Expand All @@ -20,8 +20,11 @@
-->
<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'))]" />
<field name="user_ids" eval="[Command.link(ref('my_demo_app_user'))]" />
<field
name="implied_ids"
eval="[Command.link(ref('group_fastapi_endpoint_runner'))]"
/>
</record>

<!-- This is the endpoint that will be used to run the demo app
Expand Down
33 changes: 18 additions & 15 deletions fastapi/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
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 odoo.addons.base.models.res_partner import ResPartner
from odoo.addons.base.models.res_users import ResUsers

from fastapi import Depends, Header, HTTPException, Query, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
Expand Down Expand Up @@ -35,29 +35,31 @@ def odoo_env(company_id: Annotated[int | None, Depends(company_id)]) -> Environm
yield env


def authenticated_partner_impl() -> Partner:
def authenticated_partner_impl() -> ResPartner:
"""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:
def optionally_authenticated_partner_impl() -> ResPartner | 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)],
partner: Annotated[ResPartner, 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)],
partner: Annotated[
ResPartner | 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
Expand All @@ -69,9 +71,9 @@ def optionally_authenticated_partner_env(


def authenticated_partner(
partner: Annotated[Partner, Depends(authenticated_partner_impl)],
partner: Annotated[ResPartner, Depends(authenticated_partner_impl)],
partner_env: Annotated[Environment, Depends(authenticated_partner_env)],
) -> Partner:
) -> ResPartner:
"""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.
Expand All @@ -85,9 +87,11 @@ def authenticated_partner(


def optionally_authenticated_partner(
partner: Annotated[Partner | None, Depends(optionally_authenticated_partner_impl)],
partner: Annotated[
ResPartner | None, Depends(optionally_authenticated_partner_impl)
],
partner_env: Annotated[Environment, Depends(optionally_authenticated_partner_env)],
) -> Partner | None:
) -> ResPartner | 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.
Expand All @@ -110,21 +114,20 @@ def paging(
def basic_auth_user(
credential: Annotated[HTTPBasicCredentials, Depends(HTTPBasic())],
env: Annotated[Environment, Depends(odoo_env)],
) -> Users:
) -> ResUsers:
username = credential.username
password = credential.password
try:
response = (
env["res.users"]
.sudo()
.authenticate(
db=env.cr.dbname,
credential={
"type": "password",
"login": username,
"password": password,
},
user_agent_env=None,
user_agent_env={"interactive": False},
)
)
return env["res.users"].browse(response.get("uid"))
Expand All @@ -137,9 +140,9 @@ def basic_auth_user(


def authenticated_partner_from_basic_auth_user(
user: Annotated[Users, Depends(basic_auth_user)],
user: Annotated[ResUsers, Depends(basic_auth_user)],
env: Annotated[Environment, Depends(odoo_env)],
) -> Partner:
) -> ResPartner:
return env["res.partner"].browse(user.sudo().partner_id.id)


Expand Down
6 changes: 3 additions & 3 deletions fastapi/error_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,17 @@ def convert_exception_to_status_body(exc: Exception) -> tuple[int, dict]:
status_code = exc.status_code
details = exc.detail
elif isinstance(exc, RequestValidationError):
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
status_code = status.HTTP_422_UNPROCESSABLE_CONTENT
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"
details = exc.args[0]
elif isinstance(exc, MissingError):
status_code = status.HTTP_404_NOT_FOUND
details = "MissingError"
details = exc.args[0]
elif isinstance(exc, UserError):
status_code = status.HTTP_400_BAD_REQUEST
details = exc.args[0]
Expand Down
21 changes: 19 additions & 2 deletions fastapi/models/fastapi_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
from starlette.middleware import Middleware
from starlette.routing import Mount

from odoo import _, api, exceptions, fields, models, tools
from odoo import api, exceptions, fields, models, tools
from odoo.tools import convert

from fastapi import APIRouter, Depends, FastAPI

Expand Down Expand Up @@ -88,7 +89,7 @@ def _check_root_path(self):
for rec in self:
if rec.root_path in self._blacklist_root_paths:
raise exceptions.UserError(
_(
self.env._(
"`%(name)s` uses a blacklisted root_path = `%(root_path)s`",
name=rec.name,
root_path=rec.root_path,
Expand Down Expand Up @@ -338,3 +339,19 @@ def _get_fastapi_app_middlewares(self) -> list[Middleware]:
def _get_fastapi_app_dependencies(self) -> list[Depends]:
"""Return the dependencies to use for the fastapi app."""
return [Depends(dependencies.accept_language)]

# test utility
@api.model
def has_demo_data(self):
return (
self.env.ref("fastapi.fastapi_endpoint_demo", raise_if_not_found=False)
is not None
)

def _load_demo_data(self):
if self.has_demo_data():
return
# Load demo data
convert.convert_file(
self.env, "fastapi", "demo/fastapi_endpoint_demo.xml", None, mode="init"
)
8 changes: 4 additions & 4 deletions fastapi/models/fastapi_endpoint_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL).
from typing import Annotated, Any

from odoo import _, api, fields, models
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 odoo.addons.base.models.res_partner import ResPartner

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import APIKeyHeader
Expand Down Expand Up @@ -40,7 +40,7 @@ def _valdiate_demo_auth_method(self):
for rec in self:
if rec.app == "demo" and not rec.demo_auth_method:
raise ValidationError(
_(
self.env._(
"The authentication method is required for app %(app)s",
app=rec.app,
)
Expand Down Expand Up @@ -90,7 +90,7 @@ def api_key_based_authenticated_partner_impl(
),
],
env: Annotated[Environment, Depends(odoo_env)],
) -> Partner:
) -> ResPartner:
"""A dummy implementation that look for a user with the same login
as the provided api key
"""
Expand Down
4 changes: 2 additions & 2 deletions fastapi/routers/demo_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
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 odoo.addons.base.models.res_partner import ResPartner

from fastapi import APIRouter, Depends, File, HTTPException, Query, status
from fastapi.responses import JSONResponse
Expand Down Expand Up @@ -67,7 +67,7 @@ async def get_lang(env: Annotated[Environment, Depends(odoo_env)]):

@router.get("/demo/who_ami")
async def who_ami(
partner: Annotated[Partner, Depends(authenticated_partner)],
partner: Annotated[ResPartner, Depends(authenticated_partner)],
) -> DemoUserInfo:
"""Who am I?

Expand Down
15 changes: 12 additions & 3 deletions fastapi/security/ir_rule+acl.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
<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'))]" />
<field
name="groups"
eval="[Command.link(ref('group_fastapi_endpoint_runner'))]"
/>
</record>

<!-- give access to the user running the demo app to the res.partner model -->
Expand All @@ -40,7 +43,10 @@
<field
name="domain_force"
> ['|', ('user_ids', '=', user.id), ('id', '=', authenticated_partner_id)]</field>
<field name="groups" eval="[(4, ref('group_fastapi_endpoint_runner'))]" />
<field
name="groups"
eval="[Command.link(ref('group_fastapi_endpoint_runner'))]"
/>
</record>

<!-- give access by the user running the demo app to the fastapi.enddoint model -->
Expand All @@ -59,6 +65,9 @@
<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'))]" />
<field
name="groups"
eval="[Command.link(ref('group_fastapi_endpoint_runner'))]"
/>
</record>
</odoo>
20 changes: 13 additions & 7 deletions fastapi/security/res_groups.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,32 @@
<field name="sequence">99</field>
</record>

<record id="privilege_fastapi" model="res.groups.privilege">
<field name="name">FastAPI</field>
<field name="category_id" ref="module_category_fastapi" />
<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" />
<field name="implied_ids" eval="[Command.link(ref('base.group_user'))]" />
<field name="privilege_id" ref="privilege_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="privilege_id" ref="privilege_fastapi" />
<field name="implied_ids" eval="[Command.link(ref('group_fastapi_user'))]" />
<field
name="users"
eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"
name="user_ids"
eval="[Command.link(ref('base.user_root')), Command.link(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" />
<field name="privilege_id" ref="privilege_fastapi" />
</record>
</odoo>
Loading
Loading