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