Skip to content

Declaring models

A pyvelm model is a Python class. Subclass :class:~pyvelm.models.Model (Odoo-style from pyvelm import models) or :class:~pyvelm.BaseModel directly, set a dotted _name, and declare fields as class attributes. The framework takes care of the schema (a Postgres table named <dotted_to_snake>), the SQL plumbing, and the recordset machinery.

A first model

from pyvelm import Char, Integer, Boolean, Many2one, models


class Partner(models.Model):
    _name = "res.partner"

    name = Char(required=True, string="Name")
    age = Integer()
    active = Boolean(default=True)
    country_id = Many2one("res.country", ondelete="SET NULL")

Fluent field chains (v1.2+)

ORM fields also support method chaining when you call the field type with no constructor kwargs (or, for relations, only the comodel / inverse positional args):

class Partner(models.Model):
    _name = "res.partner"

    name = Char().required().string("Name").tracking()
    age = Integer().default(0)
    country_id = Many2one("res.country").required().ondelete("SET NULL")
    child_ids = One2many("res.partner").inverse("parent_id").list_view("child.list")

Char().required() works because required is a builder method, not the required bool on an already-built field (calling .required() on a live Field instance would hit the bool attribute and fail).

Constructor kwargs remain fully supported and mix cleanly with views that use pyvelm.builders.Field.make() for list/form archs — that is a different Field class (view presentation only).

Common chain methods: .string(), .required(), .default(), .readonly(), .tracking(), .compute(), .store(), .related(), .column(); Char-specific .size(), .choices(); Many2one .ondelete(); One2many .inverse(), .list_view(), .form_view(); Many2many .relation(), .column1(), .column2().

Outside a model class (unit tests, manual field setup), use Char.bare() to get a concrete field instance — Char() alone returns a builder. Char.make() is an alias for Char() when starting a chain.

The class lives inside a module's models/ package; the loader picks it up when the module installs. See Modules for the packaging story.

Once loaded, you operate on records through the env:

# create
alice = env["res.partner"].create({"name": "Alice", "age": 30})

# read — field access through the descriptor
print(alice.name, alice.age)

# write
alice.write({"age": 31})

# search — returns a recordset (domain list)
adults = env["res.partner"].search([("age", ">=", 18)])
for r in adults:
    print(r.name)

# fluent query builder (Laravel Eloquent-style) — same ACL/rules path as search
posts = (
    env["blog.post"]
    .query()
    .where("active", "=", True)
    .where("views", ">", 100)
    .order_by("published_at", "desc")
    .limit(20)
    .get()
)
# env.query("blog.post") is equivalent — always uses the registry class after _inherit

Use env["model.name"].query() or env.query("model.name") so queries hit the effective merged model, not a stale import from one module file.

Method Purpose
where(field, op, value) / where(field, value) AND constraint
or_where(...) OR with the current AND-group
where_in / where_not_in / where_null Common filters
where_any([leaves…]) OR group of leaves
order_by(field, "asc"\|"desc") SQL ORDER BY
limit / offset Pagination
get() Recordset (runs search())
first() / find(id) / find_or_fail(id) Single row
count() / exists() Aggregates without loading rows
pluck(field) / value(field) Scalar lists
paginate(page=, per_page=) Page with items, total, last_page
chunk(size) Batch recordsets

All execution methods delegate to search() / search_count() — record rules, ACL, and company scope still apply.

Collection search paths support universal quantification with a fourth leaf element: ("tag_ids.name", "!=", "VIP", {"all": True}) — every related row must match (see Architecture).

Recordsets behave like Python collections — iteration, len(), in, slicing — and they're always tied to a specific env.

Built-in field types

Field Stores Notes
Char(size=…, required=, default=, string=) text Variable-length string
Text text Like Char but no size hint
Integer(required=, default=) integer
Float(required=, default=) double precision
Boolean(default=) boolean
Many2one(comodel, ondelete=) integer (FK) Relationship to one record
One2many(comodel, inverse_name=) Reverse side of a Many2one
Many2many(comodel, relation=) junction table Symmetric many-to-many

Common kwargs across all field types:

  • string — human label used in views (defaults to the attribute name title-cased).
  • required — declared NOT NULL in the schema; also drives the red * in form views and server-side validation.
  • default — value applied on create when the caller doesn't set the field. Accepts a literal or a callable.
  • column — overrides the SQL column name (rarely needed).
  • compute + store — see Computed fields.

Relationships

Many2one

Pointer to a single record:

class Partner(BaseModel):
    _name = "res.partner"
    country_id = Many2one("res.country", ondelete="SET NULL")

The column is an integer FK. ondelete matches the SQL options ("CASCADE", "SET NULL", "RESTRICT").

Reading the field returns a recordset:

alice.country_id              # → res.country recordset (one or empty)
alice.country_id.name         # → "France"
alice.country_id = france     # assign a recordset
alice.write({"country_id": france.id})   # or an id

Dotted-path traversal works in search domains too:

env["res.partner"].search([
    ("country_id.region_id.name", "=", "Europe"),
])

Reads as: partners whose country’s region’s name is "Europe" (two LEFT JOINs, one query).

Search domains

Domains are a list of (field, operator, value) leaves, combined with implicit AND when no operators are present. Odoo-style prefix operators are supported:

Operator Meaning
& AND (binary)
\| OR (binary)
! NOT (unary)

Operators appear before their operands (prefix / Polish notation). Read | A B as “A or B”, & A B as “A and B”, ! A as “not A”. When several leaves sit next to each other with no operator, they are anded together.

Example — posted invoices whose name or ref matches the search term:

env["account.move"].search([
    ("state", "=", "posted"),
    "|",
    ("name", "ilike", term),
    ("ref", "ilike", term),
])

Reads as: state = 'posted' AND (name ILIKE term OR ref ILIKE term).

The first leaf stands alone; the | takes the next two leaves as its OR group; implicit AND combines that group with state.

Nested booleans:

[
    "&",
    ("company_id", "=", company.id),
    "|",
    ("name", "ilike", term),
    ("partner_id.name", "ilike", term),
]

Reads as: company_id = company.id AND (name ILIKE term OR partner_id.name ILIKE term).

Here the leading & explicitly joins two operands: (1) the company leaf and (2) the |-group built from the name and partner-name leaves. The dotted path partner_id.name is compiled via a LEFT JOIN — same as in a flat AND domain.

Negation:

Partner.search(["!", ("active", "=", False)])

Reads as: NOT (active = False) — i.e. partners that are not explicitly inactive (including rows where active is NULL, depending on your data).

The legacy ("__or__", "=", [sub_leaves…]) leaf still works (list search emits it); the compiler expands it to | groups. For example, ("__or__", "=", [("name", "ilike", t), ("code", "ilike", t)]) is the same as "|", ("name", "ilike", t), ("code", "ilike", t).

One2many

The reverse side of a Many2one. No column of its own; just a declaration that lets you walk the relationship in the other direction:

class Partner(BaseModel):
    _name = "res.partner"
    parent_id = Many2one("res.partner", ondelete="SET NULL")
    child_ids = One2many("res.partner", inverse_name="parent_id")

Read alice.child_ids to get the recordset of partners whose parent_id is alice. Write through the inverse: setting child.parent_id = alice is the canonical way to add a child.

When the field appears on a parent form, you can pin which list columns and form open in the embedded grid (defaults used to be “lowest ir.ui.view id” only):

line_ids = One2many(
    "account.move.line",
    "move_id",
    list_view="account.move.line.invoice",   # registered list view
    form_view="account.move.line.invoice.form",
)

Or set columns=[...] / list_view / form_view on field(...) in the form arch. See One2many on parent forms.

Many2many

A symmetric relationship backed by a junction table:

class Partner(BaseModel):
    _name = "res.partner"
    tag_ids = Many2many("res.tag")

The framework auto-generates a junction table named <model1>_<model2>_rel with two FK columns. Reading returns a recordset; writing replaces the set:

alice.tag_ids = vip + wholesale            # replace
alice.write({"tag_ids": [vip.id, wholesale.id]})   # by ids

(Incremental add/remove via the Odoo [(4, id)] tuple syntax is not yet shipped; replace-only for now.)

Mirror a value from a dotted path (Odoo-style related=). The field is not stored and reads/writes through the path:

company_currency_id = Many2one(
    "res.currency",
    related="company_id.currency_id",
)

Paths must use Many2one hops only (e.g. company_id.currency_id). The related field type must match the leaf. Writes update the leaf record.

Field readonly

Pass readonly=True on any field declaration to block write() and form edits (unless the view overrides with field(..., readonly=False)).

Primary key (id)

Every model has an implicit readonly id field (the table's SERIAL PRIMARY KEY). You never declare it in model classes; it is available for domains, ordering, and compute dependencies (e.g. @depends("id") on the default display_name when no _rec_name field exists).

Display name

Every model gets a computed display_name field automatically (Odoo-style). By default it reads the model's _rec_name field, which defaults to "name" when that column exists; otherwise it falls back to "<model> #<id>".

class Comment(BaseModel):
    _name = "my.comment"
    _rec_name = "body"   # use body instead of name
    body = Text()

Override _compute_display_name (with @depends) for richer labels — you do not need to redeclare the display_name field:

class Partner(BaseModel):
    _name = "res.partner"
    name = Char()
    country_id = Many2one("res.country")

    @depends("name", "country_id.code")
    def _compute_display_name(self):
        for r in self:
            code = r.country_id.code if r.country_id else None
            r.display_name = f"{r.name} [{code}]" if code else r.name

Automatic timestamps

Every :class:~pyvelm.model.BaseModel gets created_at and updated_at (readonly Datetime columns, UTC stored as naive timestamp). Both are set on create; only updated_at changes on write.

class Partner(BaseModel):
    _name = "res.partner"
    _timestamps = False  # opt out (legacy tables, import-only models)
    name = Char()

Customize column names with _CREATED_AT / _UPDATED_AT. New columns appear on Apps → Upgrade or pyvelm db migrate (additive DDL). Forms render timestamp fields as read-only in edit mode.

Use _guarded = ["id", "created_at", "updated_at"] (or _fillable) on models exposed to HTTP forms so callers cannot spoof system columns.

Mass assignment (_fillable / _guarded)

HTTP form writes pass through pyvelm.mass_assignment before they reach write():

Policy Behaviour
_fillable = ["name", "email"] Only listed keys are kept
_guarded = ["id", "created_at"] Listed keys are dropped
_guarded = ["*"] All keys dropped (read-only via forms)
_strict_fillable = True Blocked keys raise ValueError instead of being silently dropped

Declare either _fillable or _guarded, not both. The registry validates this at model registration.

Computed fields

A field becomes "computed" when you point it at a method via compute=:

class Partner(BaseModel):
    _name = "res.partner"
    name = Char()
    age = Integer()
    age_bucket = Char(compute="_compute_age_bucket", store=True)

    @depends("age")
    def _compute_age_bucket(self):
        ...

The @depends decorator declares which fields the compute reads. The framework invalidates display_name whenever any of them changes (across recordsets, across writes, across cache layers).

Two flavors:

  • Read-time compute (default): the value is recalculated on access; not stored. Cheap, no migration when you add one. Fine for display strings, simple formatting.
  • Stored compute (store=True): the value persists to a SQL column. Cache invalidation triggers a recompute + UPDATE on the next read. Use this when the field appears in domain clauses or needs to participate in indexes.

Dotted-path dependencies work — the same compiler that powers domain traversal walks back through relations:

@depends("country_id.region_id.name")
def _compute_region_label(self):
    for r in self:
        r.region_label = (
            r.country_id.region_id.name if r.country_id else ""
        )

A change to Europe.name invalidates region_label on every partner in a European country, two hops back.

Extending an existing model

_inherit lets a downstream module add fields, override methods, or replace compute implementations on a model someone else owns. No new table — the existing one gets ALTER TABLE ADD COLUMN.

Declare extensions Odoo-style with from pyvelm import models and subclass :class:~pyvelm.models.Model (an alias of :class:~pyvelm.BaseModel):

from pyvelm import models

class MyExtension(models.Model):
    _inherit = "res.partner"

New models with a _name use the same base: class Order(models.Model): _name = "sale.order".

# partners_pro/models/partner.py
from pyvelm import Char, depends, models


class PartnerPro(models.Model):
    _inherit = "res.partner"

    vip_note = Char()

    @depends("name", "vip_note")
    def _compute_display_name(self):
        # Override the base implementation but still chain to it
        # via super() so other modules can stack their own logic.
        super()._compute_display_name()
        for r in self:
            if r.vip_note:
                r.display_name = "★ " + r.display_name

The metaclass replaces the registry entry with a proper Python subclass, so super() works through the MRO the way you'd expect. Multiple modules can stack _inherit on the same target — each one becomes another link in the chain.

Super chaining (create / write / custom methods)

Override a method and call the next implementation in the stack with either Python's built-in super() or the Odoo-style recordset helper:

class PartnerPro(models.Model):
    _inherit = "res.partner"

    def write(self, vals):
        # before hooks …
        super().write(vals)          # or: self.super().write(vals)
        # after hooks …

    def button_cancel(self):
        # Odoo-style action override on a custom method
        res = super().button_cancel()
        # post-processing …
        return res

Both forms walk the merged model MRO (including mixins such as MailThread declared before BaseModel). The registry also records the linear _inherit stack — query it with env.registry.inherit_chain("res.partner") for introspection or module tooling.

Stacked extensions run outermost-first: if modules A, B, and C all override write and call super(), a write on the final class runs C → B → A → base BaseModel.write.

See the super_chain_demo example modules under examples/modules/ (install super_chain_demo, super_chain_demo_a, super_chain_demo_b) for a runnable three-layer button_cancel stack tested in pyvelm/tests/test_super_chain_example.py.

Multi-company scoping

Setting _company_scoped = True on a model adds an implicit company_id filter to every search:

class Partner(BaseModel):
    _name = "res.partner"
    _company_scoped = True

    company_id = Many2one("res.company", ondelete="SET NULL")

When env.company_id is set, queries are restricted to records matching that id. Useful for tenant-style isolation. The pyvelm_company cookie + the company switcher in the topbar drive the env.

Currencies

pyvelm.modules.base ships two collaborating models for money:

  • res.currencycode, name, symbol, rounding, active, and a rate_ids One2many to its history.
  • res.currency.ratecurrency_id (Many2one), date (datetime the rate becomes effective), and rate (units per the implicit reference). A computed name provides a human-readable label.

The install hook seeds USD / EUR / GBP / JPY with starter rates so a fresh database can do cross-currency math out of the box. Operators replace those rates from Settings → Currencies (or from a scripted seed) when accuracy matters.

Converting amounts

USD = env["res.currency"].search([("code", "=", "USD")], limit=1)
EUR = env["res.currency"].search([("code", "=", "EUR")], limit=1)

eur_amount = USD.convert(100.0, EUR)            # uses today's rate
back_then  = USD.convert(100.0, EUR, date=dt)   # uses the rate effective at dt

convert resolves the latest res.currency.rate row whose date <= date for both the source and target currency, then computes amount / from_rate * to_rate. Both sides ride the same implicit reference (USD = 1.0 in the seed), so the reference cancels in the arithmetic and never has to be named explicitly. If no rate is effective at or before date, convert raises ValueError.

Currency.convert and _rate_at require ensure_one() — call them on a single-record recordset (e.g. currency rather than the results of a multi-record search).

Refreshing rates from the ECB

base seeds an ECB rate fetcher server action + ir.cron entry that calls res.currency.rate.fetch_from_ecb(env). It pulls the European Central Bank's daily reference rates (XML, no API key) and writes one res.currency.rate row per configured currency, rebased against whichever currency carries rate = 1.0 (USD by default).

The cron is seeded inactive — fresh installs never make outbound HTTP requests until an admin flips active = True in Settings → Scheduled Actions. Currencies not in the ECB feed are silently skipped. Re-running the action on the same ECB publication date is a no-op (same currency + same date is treated as already-present).

For a one-off refresh without enabling the cron, call the classmethod directly:

env["res.currency.rate"].fetch_from_ecb(env)

Per-company currency

res.company carries a currency_id Many2one. The install hook points the seeded "My Company" at USD; the 0_10_to_0_11 migration backfills existing companies on upgrade. Slice C's Monetary field reads this to decide which currency a record's amount lives in by default:

class Invoice(BaseModel):
    _name = "account.invoice"
    _company_scoped = True

    company_id  = Many2one("res.company")
    currency_id = Many2one("res.currency")  # falls back to company.currency_id
    amount      = Monetary(currency_field="currency_id")

Operators change a company's currency from Settings → Companies.

Monetary amounts

Monetary is a Float subclass that pairs each amount with a sibling field naming the relevant currency:

from pyvelm import BaseModel, Char, Many2one, Monetary

class Invoice(BaseModel):
    _name = "account.invoice"

    name        = Char(required=True)
    currency_id = Many2one("res.currency")
    amount      = Monetary(currency_field="currency_id")  # default

currency_field defaults to "currency_id" to match the convention that res.company and most domain records already use. The SQL column is double precision — no rounding is applied on write, the field stores whatever the caller provides.

Snap an amount to its currency's rounding step (e.g. cents for USD, whole units for JPY) with the static helper:

amount = Monetary.round_with(12.345, invoice.currency_id)  # → 12.35 for USD
amount = Monetary.round_with(149.7,  jpy)                  # → 150.0

The display widget reads the sibling currency from the record, prefixes the configured symbol, and formats with the precision implied by rounding (0.01 → 2 decimals, 1.0 → 0). The edit widget sets the HTML step attribute from the same value so the browser's number input matches the currency's granularity.

Defining a custom field type

The built-ins cover most cases. When you need something specific (a Date, a Json, a Decimal with explicit precision, a validated Email), subclass Field or one of the existing concrete types.

A Field answers five questions:

Question Hook
What's my SQL column type? sql_type class attr, or override column_ddl()
Do I have a SQL column at all? is_stored
What's my SQL column name? column (defaulted from name)
How do I normalize for binding / cache? to_sql_param(value)
How do I shape values for Python consumers? to_python(value)

Example: a Date field

from datetime import date
from pyvelm.fields import Field


class Date(Field):
    sql_type = "date"
    python_type = date

    def to_python(self, value):
        if value is None:
            return None
        if isinstance(value, date):
            return value
        # psycopg returns date objects; accept ISO strings for
        # callers that round-trip JSON.
        return date.fromisoformat(value)

    def to_sql_param(self, value):
        if value is None or value is False:
            return None
        if isinstance(value, date):
            return value
        if isinstance(value, str):
            return date.fromisoformat(value)
        raise TypeError(
            f"Date {self.name!r}: cannot bind {type(value).__name__}")

That's the whole field. Field.__init__ already accepts the common kwargs (required, default, column, compute, store).

Example: a validated Email

When you want input-side validation without changing the SQL shape, subclass an existing concrete type and override to_sql_param:

import re
from pyvelm.fields import Char

_EMAIL_RE = re.compile(r"[^@\s]+@[^@\s]+\.[^@\s]+")


class Email(Char):
    def to_sql_param(self, value):
        if value is None or value is False:
            return None
        if not isinstance(value, str) or not _EMAIL_RE.match(value):
            raise ValueError(f"{self.name!r}: invalid email: {value!r}")
        return value

Every Char kwarg keeps working; the validation runs on every create/write (and on cache seed, so the cache can never hold a malformed value).

The cache rule

The value in env.cache is what _read would put there if it re-read from the database. create and write enforce this by caching the output of to_sql_param, not the user's input. If you ever add a write path that bypasses those (don't), apply the same normalization.

Relational fields

Relational types need more than to_sql_param / to_python — they also override __get__ and __set__ to return recordsets and accept polymorphic shapes (int, recordset, None, iterables). See pyvelm/fields.py for the three reference implementations.