Skip to content

Models & Type Safety

Zenoo RPC provides type-safe Pydantic models for Odoo records, giving you IDE support, runtime validation, and a better development experience.

Overview

Instead of working with raw dictionaries like in odoorpc, Zenoo RPC uses Pydantic models that provide:

  • Type Safety: Full type hints and validation
  • IDE Support: Autocomplete and error detection
  • Runtime Validation: Automatic data validation
  • Documentation: Self-documenting code
  • Serialization: Easy conversion to/from JSON

Built-in Models

Zenoo RPC includes pre-defined models for common Odoo objects:

from zenoo_rpc.models.common import (
    ResPartner,          # res.partner
    ResCountry,          # res.country
    ResCountryState,     # res.country.state
    ResCurrency,         # res.currency
    ResUsers,            # res.users
    ResGroups,           # res.groups
    ProductProduct,      # product.product
    ProductCategory,     # product.category
    SaleOrder,           # sale.order
    SaleOrderLine,       # sale.order.line
)

Using Models

Basic Model Usage

from zenoo_rpc.models.common import ResPartner

async with ZenooClient("localhost", port=8069) as client:
    await client.login("demo", "admin", "admin")

    # Get typed results
    partners = await client.model(ResPartner).filter(
        is_company=True
    ).limit(5).all()

    # Type-safe field access
    for partner in partners:
        print(f"Name: {partner.name}")           # str
        print(f"Email: {partner.email}")         # Optional[str]
        print(f"Is Company: {partner.is_company}") # bool
        print(f"ID: {partner.id}")               # int

Field Types and Validation

# ResPartner model fields (examples)
class ResPartner(OdooModel):
    _odoo_name: ClassVar[str] = "res.partner"

    # Basic fields
    id: int
    name: str
    email: Optional[str] = None
    phone: Optional[str] = None
    is_company: bool = False
    active: bool = True

    # Date fields
    create_date: Optional[datetime] = None
    write_date: Optional[datetime] = None

    # Numeric fields
    customer_rank: int = 0
    supplier_rank: int = 0

    # Selection fields
    lang: Optional[str] = None

    # Relationship fields
    country_id: Optional[Many2OneField[ResCountry]] = None
    state_id: Optional[Many2OneField[ResCountryState]] = None
    child_ids: One2ManyField[List[ResPartner]] = []
    category_id: Many2ManyField[List[ResPartnerCategory]] = []

Field Types

Basic Field Types

from typing import Optional
from datetime import datetime, date
from decimal import Decimal

class ExampleModel(OdooModel):
    # Text fields
    name: str                           # Required string
    description: Optional[str] = None   # Optional string

    # Numeric fields
    sequence: int = 0                   # Integer with default
    price: float = 0.0                  # Float
    amount: Decimal = Decimal('0.00')   # Decimal for precision

    # Boolean fields
    active: bool = True                 # Boolean with default

    # Date/Time fields
    date_field: Optional[date] = None
    datetime_field: Optional[datetime] = None

    # Selection fields (using Literal)
    state: Literal['draft', 'confirmed', 'done'] = 'draft'

Relationship Fields

from zenoo_rpc.models.fields import Many2OneField, One2ManyField, Many2ManyField

class ResPartner(OdooModel):
    # Many2One - Single related record
    country_id: Optional[Many2OneField[ResCountry]] = None
    parent_id: Optional[Many2OneField["ResPartner"]] = None  # Self-reference

    # One2Many - List of related records
    child_ids: One2ManyField[List["ResPartner"]] = []
    invoice_ids: One2ManyField[List[AccountMove]] = []

    # Many2Many - List of related records
    category_id: Many2ManyField[List[ResPartnerCategory]] = []
    user_ids: Many2ManyField[List[ResUsers]] = []

Working with Relationships

Many2One Fields

# Access Many2One relationships
partner = await client.model(ResPartner).get(1)

# Check if relationship exists
if partner.country_id:
    # Lazy loading - loads when accessed
    country = await partner.country_id
    print(f"Country: {country.name}")
    print(f"Country Code: {country.code}")

# Direct access to ID without loading
if partner.country_id:
    country_id = partner.country_id.id
    print(f"Country ID: {country_id}")

One2Many Fields

# Access One2Many relationships
partner = await client.model(ResPartner).get(1)

# Get all children
children = await partner.child_ids.all()
for child in children:
    print(f"Child: {child.name}")

# Filter children
active_children = await partner.child_ids.filter(active=True).all()

# Count children
child_count = await partner.child_ids.count()

Many2Many Fields

# Access Many2Many relationships
partner = await client.model(ResPartner).get(1)

# Get all categories
categories = await partner.category_id.all()
for category in categories:
    print(f"Category: {category.name}")

# Add categories
await partner.category_id.add([category1.id, category2.id])

# Remove categories
await partner.category_id.remove([category1.id])

# Set categories (replace all)
await partner.category_id.set([category1.id, category2.id])

Creating Custom Models

Basic Custom Model

from zenoo_rpc.models.base import OdooModel
from typing import ClassVar, Optional

class CustomModel(OdooModel):
    _odoo_name: ClassVar[str] = "custom.model"

    # Define fields
    name: str
    description: Optional[str] = None
    active: bool = True

    # Custom methods
    def display_name(self) -> str:
        return f"{self.name} ({'Active' if self.active else 'Inactive'})"

# Register the model
from zenoo_rpc.models.registry import register_model
register_model(CustomModel)

# Use the model
async with ZenooClient("localhost", port=8069) as client:
    await client.login("demo", "admin", "admin")

    records = await client.model(CustomModel).all()
    for record in records:
        print(record.display_name())

Advanced Custom Model

from pydantic import Field, validator
from datetime import datetime

class ProjectTask(OdooModel):
    _odoo_name: ClassVar[str] = "project.task"

    # Basic fields
    name: str = Field(..., description="Task name")
    description: Optional[str] = Field(None, description="Task description")

    # Dates
    date_start: Optional[datetime] = None
    date_end: Optional[datetime] = None

    # Relationships
    project_id: Optional[Many2OneField[Project]] = None
    user_id: Optional[Many2OneField[ResUsers]] = None

    # Computed properties
    @property
    def is_overdue(self) -> bool:
        if not self.date_end:
            return False
        return datetime.now() > self.date_end

    # Validators
    @validator('date_end')
    def validate_end_date(cls, v, values):
        if v and values.get('date_start') and v < values['date_start']:
            raise ValueError('End date must be after start date')
        return v

    # Custom methods
    async def mark_done(self, client):
        """Mark task as done"""
        return await client.model(ProjectTask).update(self.id, {
            'stage_id': 'done_stage_id'  # Replace with actual stage ID
        })

Model Validation

Field Validation

from pydantic import validator, Field

class ValidatedModel(OdooModel):
    _odoo_name: ClassVar[str] = "validated.model"

    email: Optional[str] = Field(None, regex=r'^[^@]+@[^@]+\.[^@]+$')
    phone: Optional[str] = Field(None, min_length=10, max_length=15)
    age: int = Field(..., ge=0, le=150)

    @validator('email')
    def validate_email(cls, v):
        if v and '@' not in v:
            raise ValueError('Invalid email format')
        return v

    @validator('phone')
    def validate_phone(cls, v):
        if v and not v.replace('+', '').replace('-', '').replace(' ', '').isdigit():
            raise ValueError('Phone must contain only digits, +, -, and spaces')
        return v

Model-Level Validation

from pydantic import root_validator

class OrderLine(OdooModel):
    _odoo_name: ClassVar[str] = "sale.order.line"

    product_id: Many2OneField[ProductProduct]
    quantity: float = Field(..., gt=0)
    price_unit: float = Field(..., ge=0)
    discount: float = Field(0, ge=0, le=100)

    @root_validator
    def validate_order_line(cls, values):
        # Custom business logic validation
        if values.get('discount', 0) > 50 and values.get('quantity', 0) < 10:
            raise ValueError('High discount only allowed for large quantities')
        return values

Serialization and Deserialization

Converting to/from Dictionaries

# From Odoo data to model
odoo_data = {
    'id': 1,
    'name': 'Test Partner',
    'email': 'test@example.com',
    'is_company': True
}

partner = ResPartner(**odoo_data)

# Model to dictionary
partner_dict = partner.dict()
print(partner_dict)

# Model to dictionary (exclude None values)
partner_dict = partner.dict(exclude_none=True)

# Model to dictionary (only specific fields)
partner_dict = partner.dict(include={'name', 'email'})

JSON Serialization

import json

# Model to JSON
partner_json = partner.json()
print(partner_json)

# JSON to model
partner_from_json = ResPartner.parse_raw(partner_json)

# Pretty JSON
partner_json = partner.json(indent=2, exclude_none=True)

Model Registry

Registering Models

from zenoo_rpc.models.registry import register_model, get_model_class

# Register custom model
register_model(CustomModel)

# Get model class by name
ModelClass = get_model_class("custom.model")

# Check if model is registered
from zenoo_rpc.models.registry import ModelRegistry
registry = ModelRegistry()
if "custom.model" in registry:
    print("Model is registered")

Dynamic Model Creation

from zenoo_rpc.models.base import create_model_class

# Create model dynamically
DynamicModel = create_model_class(
    "dynamic.model",
    {
        "name": (str, ...),
        "value": (int, 0),
        "active": (bool, True)
    }
)

# Register and use
register_model(DynamicModel)

Best Practices

1. Use Type Hints

# ✅ Good - Clear type hints
async def get_companies(client: ZenooClient) -> List[ResPartner]:
    return await client.model(ResPartner).filter(is_company=True).all()

# ❌ Bad - No type hints
async def get_companies(client):
    return await client.model(ResPartner).filter(is_company=True).all()

2. Handle Optional Fields

# ✅ Good - Check for None
partner = await client.model(ResPartner).get(1)
if partner.email:
    send_email(partner.email)

# ❌ Bad - Might raise AttributeError
send_email(partner.email)  # Could be None

3. Use Relationship Loading Efficiently

# ✅ Good - Load related data when needed
partners = await client.model(ResPartner).filter(is_company=True).all()
for partner in partners:
    if partner.country_id:
        country = await partner.country_id
        print(f"{partner.name} - {country.name}")

# ✅ Better - Prefetch related data
partners = await client.model(ResPartner).filter(
    is_company=True
).prefetch('country_id').all()

4. Validate Data Early

# ✅ Good - Validate before saving
try:
    partner = ResPartner(
        name="Test Company",
        email="invalid-email"  # Will raise validation error
    )
except ValidationError as e:
    print(f"Validation error: {e}")

Next Steps