Initial commit: OCA Technical packages (595 packages)

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

View file

@ -0,0 +1,2 @@
from . import test_generic_extendable
from . import test_strict_extendable_base_model

View file

@ -0,0 +1,17 @@
# Copyright 2023 ACSONE SA/NV
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL).
from odoo.addons.extendable.tests.common import ExtendableMixin
from odoo.addons.fastapi.tests.common import (
FastAPITransactionCase as BaseFastAPITransactionCase,
)
class FastAPITransactionCase(BaseFastAPITransactionCase, ExtendableMixin):
"""Base class for FastAPI tests using extendable classes."""
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.init_extendable_registry()
cls.addClassCleanup(cls.reset_extendable_registry)

View file

@ -0,0 +1,100 @@
# Copyright 2023 ACSONE SA/NV
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
from typing import Annotated
from odoo import api
from odoo.addons.fastapi.dependencies import odoo_env
from fastapi import APIRouter, Depends
from .schemas import Customer, PrivateCustomer, PrivateUser, User, UserSearchResponse
demo_pydantic_router = APIRouter(tags=["demo_pydantic"])
@demo_pydantic_router.get("/{user_id}")
def get(env: Annotated[api.Environment, Depends(odoo_env)], user_id: int) -> User:
"""
Get a specific user using its Odoo id.
"""
user = env["res.users"].sudo().search([("id", "=", user_id)])
if not user:
raise ValueError("No user found")
return User.from_user(user)
@demo_pydantic_router.get("/private/{user_id}")
def get_private(
env: Annotated[api.Environment, Depends(odoo_env)], user_id: int
) -> User:
"""
Get a specific user using its Odoo id.
"""
user = env["res.users"].sudo().search([("id", "=", user_id)])
if not user:
raise ValueError("No user found")
return PrivateUser.from_user(user)
@demo_pydantic_router.post("/post_user")
def post_user(user: User) -> UserSearchResponse:
"""A demo endpoint to test the extendable pydantic model integration
with fastapi and odoo.
Type of the request body is User. This model is the base model
of ExtendedUser. At runtime, the documentation and the processing
of the request body should be done as if the type of the request body
was ExtendedUser.
"""
return UserSearchResponse(total=1, items=[user])
@demo_pydantic_router.post("/post_private_user")
def post_private_user(user: PrivateUser) -> User:
"""A demo endpoint to test the extendable pydantic model integration
with fastapi and odoo.
Type of the request body is PrivateUser. This model inherits base model
User but does not extend it.
This method will return attributes from the declared response type.
It will never return attribute of a derived type from the declared response
type, even if in the route implementation we return an instance of the
derived type.
"""
return user
@demo_pydantic_router.post("/post_private_user_generic")
def post_private_user_generic(user: PrivateUser) -> UserSearchResponse:
"""A demo endpoint to test the extendable pydantic model integration
with fastapi and odoo.
Type of the request body is PrivateUser. This model inherits base model
User but does not extend it.
This method will return attributes from the declared response type.
It will never return attribute of a derived type from the declared response
type, even if in the route implementation we return an instance of the
derived type. This assertion is also true with generics.
"""
return UserSearchResponse(total=1, items=[user])
@demo_pydantic_router.post("/post_private_customer")
def post_private_customer(customer: PrivateCustomer) -> Customer:
"""A demo endpoint to test the extendable pydantic model integration
with fastapi and odoo, and more particularly the extra="forbid" config parameter.
Type of the request body is PrivateCustomer. This model inherits base model
Customer but does not extend it.
This method will return attributes from the declared response type.
It will never return attribute of a derived type from the declared response
type, even if in the route implementation we return an instance of the
derived type.
Since Customer has extra fields forbidden, this route is not supposed to work.
"""
return customer

View file

@ -0,0 +1,94 @@
# Copyright 2023 ACSONE SA/NV
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
from typing import Generic, TypeVar
from extendable_pydantic import ExtendableBaseModel
# User models
class User(ExtendableBaseModel, revalidate_instances="always"):
"""
We MUST set revalidate_instances="always" to be sure that FastAPI validates
responses of this model type.
"""
name: str
@classmethod
def from_user(cls, user):
return cls.model_construct(name=user.name)
class ExtendedUser(User, extends=True):
address: str
@classmethod
def from_user(cls, user):
res = super().from_user(user)
if user.street or user.city:
# Dummy address construction
res.address = (user.street or "") + (user.city or "")
return res
class PrivateUser(User):
password: str
@classmethod
def from_user(cls, user):
res = super().from_user(user)
res.password = user.password
return res
_T = TypeVar("_T")
class SearchResponse(ExtendableBaseModel, Generic[_T]):
total: int
items: list[_T]
class UserSearchResponse(SearchResponse[User]):
"""We declare the generic type of the items of the list as User
which is the base model of the extended. When used, it should be resolved
to ExtendedUser, but items of PrivateUser class must stay private and not be returned"""
# Customer models: same as above but with extra="forbid"
class Customer(ExtendableBaseModel, revalidate_instances="always", extra="forbid"):
"""
Same hierarchy as User models, but with an extra config parameter:
forbid extra fields.
"""
name: str
@classmethod
def from_customer(cls, customer):
return cls.model_construct(name=customer.name)
class ExtendedCustomer(Customer, extends=True):
address: str
@classmethod
def from_customer(cls, customer):
res = super().from_customer(customer)
if customer.street or customer.city:
# Dummy address construction
res.address = (customer.street or "") + (customer.city or "")
return res
class PrivateCustomer(Customer):
password: str
@classmethod
def from_customer(cls, customer):
res = super().from_customer(customer)
res.password = customer.password
return res

View file

@ -0,0 +1,208 @@
# Copyright 2023 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from requests import Response
from odoo.tests.common import tagged
from fastapi.exceptions import ResponseValidationError
from .common import FastAPITransactionCase
from .routers import demo_pydantic_router
from .schemas import PrivateCustomer, PrivateUser, User
@tagged("post_install", "-at_install")
class TestUser(FastAPITransactionCase):
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
def test_app_components(self):
with self._create_test_client(router=demo_pydantic_router) as test_client:
to_openapi = test_client.app.openapi()
# Check post input and output types
self.assertEqual(
to_openapi["paths"]["/post_user"]["post"]["requestBody"]["content"][
"application/json"
]["schema"]["$ref"],
"#/components/schemas/User",
)
self.assertEqual(
to_openapi["paths"]["/post_user"]["post"]["responses"]["200"][
"content"
]["application/json"]["schema"]["$ref"],
"#/components/schemas/UserSearchResponse",
)
self.assertEqual(
to_openapi["paths"]["/post_private_user"]["post"]["requestBody"][
"content"
]["application/json"]["schema"]["$ref"],
"#/components/schemas/PrivateUser",
)
self.assertEqual(
to_openapi["paths"]["/post_private_user"]["post"]["responses"]["200"][
"content"
]["application/json"]["schema"]["$ref"],
"#/components/schemas/User",
)
self.assertEqual(
to_openapi["paths"]["/post_private_user_generic"]["post"][
"requestBody"
]["content"]["application/json"]["schema"]["$ref"],
"#/components/schemas/PrivateUser",
)
self.assertEqual(
to_openapi["paths"]["/post_private_user_generic"]["post"]["responses"][
"200"
]["content"]["application/json"]["schema"]["$ref"],
"#/components/schemas/UserSearchResponse",
)
# Check Pydantic model extension
self.assertEqual(
set(to_openapi["components"]["schemas"]["User"]["properties"].keys()),
{"name", "address"},
)
self.assertEqual(
set(
to_openapi["components"]["schemas"]["PrivateUser"][
"properties"
].keys()
),
{"name", "address", "password"},
)
self.assertEqual(
to_openapi["components"]["schemas"]["UserSearchResponse"]["properties"][
"items"
]["items"]["$ref"],
"#/components/schemas/User",
)
def test_post_user(self):
name = "Jean Dupont"
address = "Rue du Puits 12, 4000 Liège"
pydantic_data = User(name=name, address=address)
# Assert that class was correctly extended
self.assertTrue(pydantic_data.address)
with self._create_test_client(router=demo_pydantic_router) as test_client:
response: Response = test_client.post(
"/post_user", content=pydantic_data.model_dump_json()
)
self.assertEqual(response.status_code, 200)
res = response.json()
self.assertEqual(res["total"], 1)
user = res["items"][0]
self.assertEqual(user["name"], name)
self.assertEqual(user["address"], address)
self.assertFalse("password" in user.keys())
def test_post_private_user(self):
"""
/post_private_user return attributes from User, but not PrivateUser
Security check: this method should never return attributes from
derived type PrivateUser, even thought a PrivateUser object
is given as input.
"""
name = "Jean Dupont"
address = "Rue du Puits 12, 4000 Liège"
password = "dummy123"
pydantic_data = PrivateUser(name=name, address=address, password=password)
# Assert that class was correctly extended
self.assertTrue(pydantic_data.address)
self.assertTrue(pydantic_data.password)
with self._create_test_client(router=demo_pydantic_router) as test_client:
response: Response = test_client.post(
"/post_private_user", content=pydantic_data.model_dump_json()
)
self.assertEqual(response.status_code, 200)
user = response.json()
self.assertEqual(user["name"], name)
self.assertEqual(user["address"], address)
# Private attrs were not returned
self.assertFalse("password" in user.keys())
def test_post_private_user_generic(self):
"""
/post_private_user_generic return attributes from User, but not PrivateUser
Security check: this method should never return attributes from
derived type PrivateUser, even thought a PrivateUser object
is given as input.
This test is specifically made to test this assertion with generics.
"""
name = "Jean Dupont"
address = "Rue du Puits 12, 4000 Liège"
password = "dummy123"
pydantic_data = PrivateUser(name=name, address=address, password=password)
# Assert that class was correctly extended
self.assertTrue(pydantic_data.address)
self.assertTrue(pydantic_data.password)
with self._create_test_client(router=demo_pydantic_router) as test_client:
response: Response = test_client.post(
"/post_private_user_generic", content=pydantic_data.model_dump_json()
)
self.assertEqual(response.status_code, 200)
res = response.json()
self.assertEqual(res["total"], 1)
user = res["items"][0]
self.assertEqual(user["name"], name)
self.assertEqual(user["address"], address)
# Private attrs were not returned
self.assertFalse("password" in user.keys())
def test_get_user_failed_no_address(self):
"""
Try to get a specific user but having no address
-> Error because address is a required field on User (extended) class
:return:
"""
user = self.env["res.users"].create(
{
"name": "Michel Dupont",
"login": "michel",
}
)
with self._create_test_client(
router=demo_pydantic_router
) as test_client, self.assertRaises(ResponseValidationError):
test_client.get(f"/{user.id}")
def test_get_user_failed_no_pwd(self):
"""
Try to get a specific user having an address but no password.
-> No error because return type is User, not PrivateUser
:return:
"""
user = self.env["res.users"].create(
{
"name": "Michel Dupont",
"login": "michel",
"street": "Rue du Moulin",
}
)
self.assertFalse(user.password)
with self._create_test_client(router=demo_pydantic_router) as test_client:
response: Response = test_client.get(f"/private/{user.id}")
self.assertEqual(response.status_code, 200)
def test_extra_forbid_response_fails(self):
"""
If adding extra="forbid" to the User model, we cannot write
a router with a response type = User and returning PrivateUser
in the code
"""
name = "Jean Dupont"
address = "Rue du Puits 12, 4000 Liège"
password = "dummy123"
pydantic_data = PrivateCustomer(name=name, address=address, password=password)
with self.assertRaises(ResponseValidationError), self._create_test_client(
router=demo_pydantic_router
) as test_client:
test_client.post(
"/post_private_customer", content=pydantic_data.model_dump_json()
)

View file

@ -0,0 +1,63 @@
import warnings
from datetime import date
from extendable_pydantic import ExtendableBaseModel
from pydantic import ValidationError
from ..schemas import StrictExtendableBaseModel
from .common import FastAPITransactionCase
class TestStrictExtendableBaseModel(FastAPITransactionCase):
class Model(ExtendableBaseModel):
x: int
d: date | None
class StrictModel(StrictExtendableBaseModel):
x: int
d: date | None
def test_Model_revalidate_instance_never(self):
# Missing required fields but no re-validation
m = self.Model.model_construct()
self.assertEqual(m.model_validate(m).model_dump(), {})
def test_StrictModel_revalidate_instance_always(self):
# Missing required fields and always revalidate
m = self.StrictModel.model_construct()
with self.assertRaises(ValidationError):
m.model_validate(m)
def test_Model_validate_assignment_false(self):
# Wrong assignment but no re-validation at assignment
m = self.Model(x=1, d=None)
m.x = "TEST"
with warnings.catch_warnings():
# Disable 'Expected `int` but got `str`' warning
warnings.simplefilter("ignore")
self.assertEqual(m.model_dump(), {"x": "TEST", "d": None})
def test_StrictModel_validate_assignment_true(self):
# Wrong assignment and validation at assignment
m = self.StrictModel.model_construct()
m.x = 1 # Validate only this field -> OK even if m.d is not set
with self.assertRaises(ValidationError):
m.x = "TEST"
def test_Model_extra_ignored(self):
# Ignore extra fields
m = self.Model(x=1, z=3, d=None)
self.assertEqual(m.model_dump(), {"x": 1, "d": None})
def test_StrictModel_extra_forbidden(self):
# Forbid extra fields
with self.assertRaises(ValidationError):
self.StrictModel(x=1, z=3, d=None)
def test_StrictModel_strict_false(self):
# Coerce str->date is allowed to enable coercion from JSON
# by FastAPI
m = self.StrictModel(x=1, d=None)
m.d = "2023-01-01"
self.assertTrue(m.model_validate(m))