Series: Odoo 18 Development for Python Developers Target Audience: Python developers who are new to web development and want to learn Odoo Prerequisites: Lesson 1–3 completed,
library.bookmodel with basic fields
Why Relational Fields Matter
In Lesson 3, we built a library.book model with fields like name, pages, and price. But a book doesn’t exist in isolation — it has an author, a publisher, and it belongs to categories.
In a database, these connections are called relationships. If you’ve worked with SQL, you know about foreign keys and join tables. Odoo’s ORM gives you the same power, but through Python fields.
The Three Types of Relationships
Let’s use the library as an example:
One Book has ONE Author → Many2one (book → author)
One Author has MANY Books → One2many (author → books)
One Book has MANY Tags,
and One Tag has MANY Books → Many2many (book ↔ tags)
Or visually:
┌──────────────┐ ┌──────────────┐
│ library.book│ Many2one│library.author │
│ │────────►│ │
│ author_id │ │ name │
│ name │◄────────│ book_ids │
│ tag_ids │ One2many│ │
└──────┬───────┘ └──────────────┘
│
│ Many2many
│
┌──────▼───────┐
│ library.tag │
│ │
│ name │
│ book_ids │
└──────────────┘
Let’s learn each one in detail.
Many2one Fields — The Foreign Key of Odoo
Many2one is the most common relational field. It creates a link from one record in your model to one record in another model.
The Concept
Think of it this way: Many books can have one author. So from the book’s perspective, it points to one author.
In database terms, this creates a foreign key column in the library_book table that references the library_author table.
Basic Syntax
# In library_app/models/book.py
class LibraryBook(models.Model):
_name = 'library.book'
_description = 'Library Book'
name = fields.Char(string='Title', required=True)
# Many2one: Each book has ONE author
author_id = fields.Many2one(
comodel_name='library.author', # The model we're pointing to
string='Author', # Label in the UI
)
What’s happening step by step:
fields.Many2one(...)— Tells Odoo this field links to another modelcomodel_name='library.author'— The target model (must exist or be defined in a dependency)- In the database, this creates a column
author_idof typeINTEGERthat stores theidof the related author record - In the UI, this renders as a dropdown (or search-enabled select box) showing all authors
Naming Convention
# Convention: field_name ends with '_id' for Many2one fields
author_id = fields.Many2one('library.author', string='Author') # ✅ Good
publisher_id = fields.Many2one('library.publisher', string='Publisher') # ✅ Good
category_id = fields.Many2one('library.category', string='Category') # ✅ Good
# Not following convention (works, but confusing):
author = fields.Many2one('library.author', string='Author') # ❌ Avoid
The _id suffix is a strong convention in Odoo. When you see author_id, you immediately know it’s a Many2one field.
Using Many2one in Python
# Create a book with an author
book = env['library.book'].create({
'name': 'The Hobbit',
'author_id': 1, # Set by ID (the author's database id)
})
# Access the related record
print(book.author_id) # Returns a recordset: library.author(1,)
print(book.author_id.name) # 'J.R.R. Tolkien' — access the author's name directly
print(book.author_id.id) # 1 — the author's database id
# You can chain through relations:
print(book.author_id.email) # Access the author's email field
# Check if a Many2one is set
if book.author_id: # ✅ Truthy if set, Falsy if empty
print(f"Author: {book.author_id.name}")
else:
print("No author assigned")
Key insight: When you access book.author_id, you don’t get an integer (the ID). You get a recordset — an Odoo object that represents the related author. This means you can directly access any field on the author without writing a separate query.
What Happens in the Database
-- Odoo creates this column:
ALTER TABLE library_book ADD COLUMN author_id INTEGER REFERENCES library_author(id);
-- When you set author_id = 1:
UPDATE library_book SET author_id = 1 WHERE id = 42;
-- When you access book.author_id.name, Odoo runs:
SELECT name FROM library_author WHERE id = 1;
You never write this SQL — the ORM does it all. But understanding what happens underneath helps you debug issues.
Common Many2one Attributes
author_id = fields.Many2one(
comodel_name='library.author',
string='Author',
required=True, # A book MUST have an author
ondelete='restrict', # What happens when the author is deleted?
index=True, # Create an index (recommended for Many2one)
default=lambda self: self.env.ref('library_app.default_author', raise_if_not_found=False),
# Default to a specific author defined in XML data
)
The ondelete Parameter
This is crucial. It defines what happens to the book if its author is deleted:
| Value | Behavior | When to Use |
|---|---|---|
'set null' |
Set author_id to NULL (empty) |
Default. Safe choice — the book stays, just loses its author |
'restrict' |
Block the deletion — show an error | When the relation must always exist. “You can’t delete this author because books reference them” |
'cascade' |
Delete the book too | Dangerous! Only use when the child can’t exist without the parent |
# Examples:
author_id = fields.Many2one('library.author', ondelete='set null')
# If the author is deleted → book.author_id becomes empty
author_id = fields.Many2one('library.author', ondelete='restrict')
# If someone tries to delete the author → ERROR: "Cannot delete, books reference this author"
author_id = fields.Many2one('library.author', ondelete='cascade')
# If the author is deleted → ALL their books are deleted too! ⚠️
Recommendation: Use 'restrict' for important relationships and 'set null' (the default) for optional relationships. Avoid 'cascade' unless you’re sure.
One2many Fields — The Reverse Relation
One2many is the inverse of Many2one. If a book has author_id pointing to an author, the author can have book_ids pointing back to all their books.
The Concept
One author has many books. From the author’s perspective, they can see all books that reference them.
Important: One2many does NOT create a column in the database. It’s a virtual field that queries the other table’s Many2one field.
Basic Syntax
# In library_app/models/author.py
class LibraryAuthor(models.Model):
_name = 'library.author'
_description = 'Library Author'
name = fields.Char(string='Name', required=True)
email = fields.Char(string='Email')
# One2many: This author has MANY books
book_ids = fields.One2many(
comodel_name='library.book', # The model that has the Many2one
inverse_name='author_id', # The Many2one field name in that model
string='Books',
)
What’s happening:
comodel_name='library.book'— Look in thelibrary.bookmodelinverse_name='author_id'— Find all books whereauthor_idpoints to this author- No column is created in
library_author— this field is “computed” by queryinglibrary_book
The Many2one ↔ One2many Pair
These two fields always work as a pair:
# In library.book:
author_id = fields.Many2one('library.author', string='Author')
# ↕ These are connected ↕
# In library.author:
book_ids = fields.One2many('library.book', 'author_id', string='Books')
The Many2one field (author_id) is the real field — it creates a database column. The One2many field (book_ids) is the virtual reverse — it just queries the database using the Many2one.
Naming Convention
# Convention: field_name ends with '_ids' for One2many and Many2many fields
book_ids = fields.One2many(...) # ✅ Good — plural with _ids
book_id = fields.One2many(...) # ❌ Bad — singular, looks like Many2one
books = fields.One2many(...) # ❌ Bad — no _ids suffix
Using One2many in Python
# Get an author
author = env['library.author'].browse(1)
# Access all their books
print(author.book_ids) # Returns a recordset: library.book(1, 2, 3)
print(len(author.book_ids)) # 3 — the author has 3 books
# Iterate over the books
for book in author.book_ids:
print(f" - {book.name} ({book.pages} pages)")
# Output:
# - The Hobbit (310 pages)
# - The Lord of the Rings (1178 pages)
# - The Silmarillion (365 pages)
# Filter the author's books
available_books = author.book_ids.filtered(lambda b: b.state == 'available')
print(f"Available: {len(available_books)}")
Adding and Removing One2many Records
When creating or updating records with One2many fields, Odoo uses a special command syntax:
# Creating an author WITH books (in one operation):
author = env['library.author'].create({
'name': 'J.R.R. Tolkien',
'book_ids': [
(0, 0, {'name': 'The Hobbit', 'pages': 310}),
(0, 0, {'name': 'The Lord of the Rings', 'pages': 1178}),
],
})
# (0, 0, {values}) means: CREATE a new book with these values
Here’s the full list of commands:
| Command | Syntax | Meaning |
|---|---|---|
| Create | (0, 0, {values}) |
Create a new record and link it |
| Update | (1, id, {values}) |
Update an existing linked record |
| Delete | (2, id, 0) |
Delete the record from the database |
| Unlink | (3, id, 0) |
Remove the link (set foreign key to NULL) |
| Link | (4, id, 0) |
Link an existing record |
| Unlink all | (5, 0, 0) |
Remove all links |
| Replace | (6, 0, [ids]) |
Replace all links with this list of IDs |
The most commonly used commands:
# Add an existing book to the author
author.write({
'book_ids': [(4, book_id, 0)], # Link existing book
})
# Create a new book and add it
author.write({
'book_ids': [(0, 0, {'name': 'New Book', 'pages': 200})],
})
# Remove a book from the author (unlink, don't delete)
author.write({
'book_ids': [(3, book_id, 0)], # Sets book.author_id = NULL
})
# Delete a book entirely
author.write({
'book_ids': [(2, book_id, 0)], # Deletes the book record
})
# Replace all books with a specific set
author.write({
'book_ids': [(6, 0, [1, 2, 3])], # Author now has exactly books 1, 2, 3
})
This command syntax looks strange, but you’ll use it often. The tuple format (command_number, id, value) is one of Odoo’s most distinctive patterns. Bookmark this table — you’ll come back to it.
Many2many Fields — Join Tables Made Easy
Many2many creates a relationship where many records on both sides can be linked to many records on the other side.
The Concept
A book can have many tags (Fiction, Fantasy, Classic), and a tag can apply to many books. Neither side “owns” the relationship — it’s symmetric.
In database terms, this creates a join table (also called a junction table or pivot table).
Basic Syntax
# In library_app/models/book.py
class LibraryBook(models.Model):
_name = 'library.book'
# ... other fields ...
# Many2many: A book can have many tags, a tag can have many books
tag_ids = fields.Many2many(
comodel_name='library.tag', # The other model
string='Tags',
)
# In library_app/models/tag.py
class LibraryTag(models.Model):
_name = 'library.tag'
_description = 'Library Tag'
name = fields.Char(string='Tag Name', required=True)
color = fields.Integer(string='Color Index')
# 'color' is a special field used by the tag widget in the UI
# It's an integer (0-11) that maps to predefined colors
What happens in the database:
Odoo automatically creates a join table:
-- Odoo creates this table automatically:
CREATE TABLE library_book_library_tag_rel (
library_book_id INTEGER REFERENCES library_book(id),
library_tag_id INTEGER REFERENCES library_tag(id),
PRIMARY KEY (library_book_id, library_tag_id)
);
-- When you add tag 3 to book 1:
INSERT INTO library_book_library_tag_rel (library_book_id, library_tag_id) VALUES (1, 3);
You never create or manage this table — the ORM handles it automatically.
Using Many2many in Python
# Create some tags
fiction = env['library.tag'].create({'name': 'Fiction', 'color': 1})
fantasy = env['library.tag'].create({'name': 'Fantasy', 'color': 2})
classic = env['library.tag'].create({'name': 'Classic', 'color': 3})
# Add tags to a book
book = env['library.book'].browse(1)
book.write({
'tag_ids': [(4, fiction.id, 0), (4, fantasy.id, 0)],
# (4, id, 0) = link existing record
})
# Or replace all tags at once
book.write({
'tag_ids': [(6, 0, [fiction.id, fantasy.id, classic.id])],
# (6, 0, [ids]) = set exactly these tags
})
# Read the tags
print(book.tag_ids) # library.tag(1, 2, 3)
for tag in book.tag_ids:
print(f" Tag: {tag.name}")
# Check if a specific tag is assigned
if fiction in book.tag_ids:
print("This book is fiction!")
# Remove a tag (without deleting it)
book.write({
'tag_ids': [(3, classic.id, 0)], # (3, id, 0) = unlink
})
The same command syntax from One2many applies to Many2many. The commands (0,0,{}), (3,id,0), (4,id,0), (5,0,0), and (6,0,[ids]) are the most commonly used.
Optional: Both Sides Can Have a Many2many
You can optionally add a Many2many on the tag side too:
# In library_app/models/tag.py
class LibraryTag(models.Model):
_name = 'library.tag'
_description = 'Library Tag'
name = fields.Char(string='Tag Name', required=True)
color = fields.Integer(string='Color Index')
# Optional: see all books with this tag
book_ids = fields.Many2many(
comodel_name='library.book',
string='Books',
)
Wait — will this create TWO join tables? No! Odoo is smart enough to detect that both sides reference each other and uses the same join table. You can define Many2many on one side or both — it’s the same underlying data.
Relational Field Parameters Deep Dive
Many2one Parameters
author_id = fields.Many2one(
comodel_name='library.author', # Required: target model
string='Author', # UI label
ondelete='restrict', # What happens when target is deleted
index=True, # Database index (recommended)
domain=[('active', '=', True)], # Filter: only show active authors in dropdown
context={'default_country': 'US'}, # Pass context when opening related form
check_company=True, # Multi-company: restrict to same company
)
One2many Parameters
book_ids = fields.One2many(
comodel_name='library.book', # Required: target model
inverse_name='author_id', # Required: the Many2one field in target model
string='Books', # UI label
domain=[('state', '!=', 'lost')], # Only show non-lost books
copy=True, # Copy child records when duplicating parent
# Note: One2many has NO 'ondelete' — that's on the Many2one side
)
Many2many Parameters
# Simple version (Odoo auto-generates the join table):
tag_ids = fields.Many2many(
comodel_name='library.tag',
string='Tags',
)
# Explicit version (you control the join table name and column names):
tag_ids = fields.Many2many(
comodel_name='library.tag',
relation='library_book_tag_rel', # Join table name
column1='book_id', # Column for this model's ID
column2='tag_id', # Column for the other model's ID
string='Tags',
)
When to use the explicit version? Usually never — the auto-generated names work fine. But if you have two Many2many fields on the same model pointing to the same comodel, you need to specify different relation names to avoid conflicts:
# Two Many2many fields pointing to the same model → must specify different relations
favorite_tag_ids = fields.Many2many(
'library.tag',
relation='library_book_favorite_tags', # Custom join table 1
column1='book_id',
column2='tag_id',
string='Favorite Tags',
)
all_tag_ids = fields.Many2many(
'library.tag',
relation='library_book_all_tags', # Custom join table 2
column1='book_id',
column2='tag_id',
string='All Tags',
)
Summary: Relational Field Comparison
| Feature | Many2one | One2many | Many2many |
|---|---|---|---|
| Creates DB column | ✅ INTEGER FK |
❌ Virtual | ❌ Join table |
| Direction | This → One target | Target → This (reverse) | This ↔ Target |
| Requires partner field | No | Yes (inverse_name) |
No |
| Returns | Single record (or empty) | Recordset (0+) | Recordset (0+) |
| Naming convention | _id suffix |
_ids suffix |
_ids suffix |
ondelete |
✅ Yes | ❌ N/A | ❌ N/A |
| UI widget | Dropdown | Inline list/table | Tag chips |
Delegation Inheritance with _inherits (Brief Intro)
Before moving to domains, let’s briefly mention _inherits — Odoo’s delegation inheritance. This is not something you’ll use often, but knowing it exists prevents confusion.
What is _inherits?
_inherits lets one model embed another model. The child model gets all the fields of the parent, but they’re stored in the parent’s table via a Many2one link.
class LibraryMember(models.Model):
_name = 'library.member'
_inherits = {'res.partner': 'partner_id'}
# This means: a library.member IS a res.partner
# The member "delegates" to the partner for fields like name, email, phone
partner_id = fields.Many2one(
'res.partner',
string='Related Partner',
required=True,
ondelete='cascade',
)
# Additional fields specific to library members
membership_number = fields.Char(string='Membership Number')
member_since = fields.Date(string='Member Since')
What this does:
- Creates a
library_membertable withpartner_id,membership_number,member_since - A
library.memberrecord automatically creates ares.partnerrecord - You can access
member.name,member.email,member.phone— these are actually stored inres.partner, but accessed transparently through delegation
When to use it: When your model is a specialized version of an existing model and you want to reuse all its fields. For example, a library member is a contact (partner) with extra fields.
Don’t confuse with _inherit: _inherit (without the ‘s’) is Odoo’s extension inheritance — much more common and covered in Lesson 9. _inherits (with the ‘s’) is delegation and is used rarely.
We won’t use _inherits in our hands-on for now, but we’ll revisit it in Lesson 9 when we cover all inheritance patterns.
Domains: Odoo’s Filter Language
Now let’s learn one of the most distinctive features of Odoo: domains. If you’ve used Django’s filter() or SQLAlchemy’s filter(), domains are the Odoo equivalent — but with a very different syntax.
What is a Domain?
A domain is a list of conditions used to filter records. It’s like a WHERE clause in SQL, but written in a specific format.
# SQL:
# SELECT * FROM library_book WHERE pages > 200 AND state = 'available'
# Odoo domain:
domain = [('pages', '>', 200), ('state', '=', 'available')]
books = env['library.book'].search(domain)
The Tuple Format
Each condition in a domain is a tuple with three elements:
(field_name, operator, value)
# Examples:
('name', '=', 'The Hobbit') # name equals 'The Hobbit'
('pages', '>', 200) # pages greater than 200
('state', 'in', ['available', 'borrowed']) # state is 'available' OR 'borrowed'
('author_id.name', 'like', 'Tolkien') # author's name contains 'Tolkien'
All Available Operators
| Operator | Meaning | Example |
|---|---|---|
= |
Equals | ('state', '=', 'draft') |
!= |
Not equals | ('state', '!=', 'lost') |
> |
Greater than | ('pages', '>', 200) |
>= |
Greater than or equal | ('pages', '>=', 200) |
< |
Less than | ('price', '<', 50.0) |
<= |
Less than or equal | ('price', '<=', 50.0) |
like |
Contains (case-sensitive) | ('name', 'like', 'hobbit') |
ilike |
Contains (case-insensitive) | ('name', 'ilike', 'hobbit') |
not like |
Does not contain (case-sensitive) | ('name', 'not like', 'test') |
not ilike |
Does not contain (case-insensitive) | ('name', 'not ilike', 'test') |
=like |
Pattern match with % and _ |
('isbn', '=like', '978-%') |
=ilike |
Pattern match (case-insensitive) | ('name', '=ilike', 'the %') |
in |
Value in list | ('state', 'in', ['draft', 'available']) |
not in |
Value not in list | ('state', 'not in', ['lost']) |
child_of |
Is a descendant of (hierarchical) | ('category_id', 'child_of', 5) |
parent_of |
Is an ancestor of (hierarchical) | ('category_id', 'parent_of', 5) |
Searching Through Relations (Dotted Paths)
One of the most powerful features of domains is the ability to traverse relationships using dot notation:
# Find books by a specific author name
# (traverses book → author_id → name)
domain = [('author_id.name', '=', 'J.R.R. Tolkien')]
# Find books by authors from the UK
# (traverses book → author_id → country_id → name)
domain = [('author_id.country_id.name', '=', 'United Kingdom')]
# Find books with a specific tag
# (traverses book → tag_ids → name)
domain = [('tag_ids.name', 'in', ['Fiction', 'Fantasy'])]
How deep can you go? There's no strict limit, but each dot adds a database JOIN. Going more than 2-3 levels deep can impact performance.
Empty Domain = All Records
# An empty domain returns ALL records
all_books = env['library.book'].search([])
# This is equivalent to: SELECT * FROM library_book (with security filters applied)
Writing Complex Domains with &, |, !
By default, when you write multiple conditions in a domain, they're combined with AND:
# Implicit AND: pages > 200 AND state = 'available'
domain = [('pages', '>', 200), ('state', '=', 'available')]
But what if you need OR logic? Or NOT? That's where domain operators come in.
Polish Notation (Prefix Notation)
Odoo domains use Polish notation (prefix notation) for logical operators. This means the operator comes before the operands, not between them.
If you've never seen Polish notation, here's the comparison:
# Infix notation (what you're used to):
# pages > 200 AND state = 'available'
# Polish notation (Odoo domains):
# AND (pages > 200) (state = 'available')
# Written as:
['&', ('pages', '>', 200), ('state', '=', 'available')]
The & (AND) Operator
# Explicit AND (same as implicit, but explicit):
domain = ['&', ('pages', '>', 200), ('state', '=', 'available')]
# You usually don't write '&' explicitly because AND is the default.
# These are equivalent:
domain = [('pages', '>', 200), ('state', '=', 'available')] # Implicit AND
domain = ['&', ('pages', '>', 200), ('state', '=', 'available')] # Explicit AND
The | (OR) Operator
# Books that are available OR borrowed
domain = ['|', ('state', '=', 'available'), ('state', '=', 'borrowed')]
# This is equivalent to SQL:
# WHERE state = 'available' OR state = 'borrowed'
# Note: For this specific case, 'in' is simpler:
domain = [('state', 'in', ['available', 'borrowed'])]
The ! (NOT) Operator
# Books that are NOT in draft state
domain = [('state', '!=', 'draft')] # Simple: use != operator
# Using the ! operator (negates the next condition):
domain = ['!', ('state', '=', 'draft')] # Same result, different syntax
Combining Operators: Where It Gets Tricky
The key rule: each & and | operator applies to the next TWO conditions.
# (state = 'available') OR (state = 'borrowed')
domain = ['|', ('state', '=', 'available'), ('state', '=', 'borrowed')]
# | ──────── first ────────── ──────── second ─────────
# └── applies to these two conditions
# (pages > 200) AND ((state = 'available') OR (state = 'borrowed'))
domain = [
('pages', '>', 200),
'|',
('state', '=', 'available'),
('state', '=', 'borrowed'),
]
# Remember: implicit AND between ('pages', '>', 200) and the '|' block
# The '|' applies to the next two conditions after it
# SQL equivalent:
# WHERE pages > 200 AND (state = 'available' OR state = 'borrowed')
More Complex Examples
# (A AND B AND C) — three conditions with AND (implicit):
domain = [
('pages', '>', 100),
('state', '=', 'available'),
('price', '<', 30),
]
# SQL: WHERE pages > 100 AND state = 'available' AND price < 30
# (A OR B OR C) — three conditions with OR:
domain = [
'|', '|',
('state', '=', 'available'),
('state', '=', 'borrowed'),
('state', '=', 'draft'),
]
# Wait, why two '|' operators?
# Because each '|' only joins TWO things:
# First '|': joins (second '|' result) with ('state', '=', 'draft')
# Second '|': joins ('state', '=', 'available') with ('state', '=', 'borrowed')
# Result: available OR borrowed OR draft
#
# But again, for this case, 'in' is much simpler:
domain = [('state', 'in', ['available', 'borrowed', 'draft'])]
# (A AND B) OR (C AND D):
domain = [
'|',
'&', ('pages', '>', 200), ('state', '=', 'available'),
'&', ('pages', '<', 100), ('price', '<', 10),
]
# SQL: WHERE (pages > 200 AND state = 'available') OR (pages < 100 AND price < 10)
Domain Debugging Tip
If you're confused by a domain, read it like a tree:
domain = ['|', '&', ('A'), ('B'), ('C')]
# Tree structure:
# |
# / \
# & C
# / \
# A B
#
# Result: (A AND B) OR C
Each & or | consumes the next two items (which can themselves be operators consuming more items).
Common Domain Patterns
# Books by a specific author
[('author_id', '=', author_id)]
# Books without an author
[('author_id', '=', False)]
# Books that have an author (any author)
[('author_id', '!=', False)]
# Books published this year
[('date_published', '>=', '2024-01-01'), ('date_published', '<=', '2024-12-31')]
# Search by partial name (case-insensitive)
[('name', 'ilike', 'hobbit')]
# Active books that are available or borrowed
[('active', '=', True), '|', ('state', '=', 'available'), ('state', '=', 'borrowed')]
# Books with more than 3 tags (count-based — requires computed field, shown in Lesson 5)
# This can't be done with a simple domain — you'd need a computed field + store=True
Using Domains in Fields, Actions, and Security Rules
Domains aren't just for search() in Python. They appear everywhere in Odoo:
Domain on Many2one Fields
# Only allow selecting active authors in the dropdown
author_id = fields.Many2one(
'library.author',
domain=[('active', '=', True)],
# The dropdown will only show active authors
)
# Dynamic domain (using current record values):
# In XML views, you can use dynamic domains that reference other fields
# <field name="author_id" domain="[('country_id', '=', country_id)]"/>
# This shows only authors from the same country as the book
Domain on One2many Fields
# Only show available books in the author's book list
book_ids = fields.One2many(
'library.book', 'author_id',
domain=[('state', '=', 'available')],
)
Domain in Window Actions (XML)
<!-- Show only available books when this menu is clicked -->
<record id="library_book_available_action" model="ir.actions.act_window">
<field name="name">Available Books</field>
<field name="res_model">library.book</field>
<field name="view_mode">list,form</field>
<field name="domain">[('state', '=', 'available')]</field>
<!-- This pre-filters the list view -->
</record>
Domain in Search Views (Default Filters)
<!-- In the search view, define a quick filter button -->
<filter name="available" string="Available"
domain="[('state', '=', 'available')]"/>
<filter name="this_year" string="Published This Year"
domain="[('date_published', '>=', '2024-01-01')]"/>
Domain in Security Rules (Record Rules)
<!-- Users can only see books from their own company -->
<record id="library_book_company_rule" model="ir.rule">
<field name="name">Books: company rule</field>
<field name="model_id" ref="model_library_book"/>
<field name="domain_force">[('company_id', '=', company_id)]</field>
<!-- 'company_id' here refers to the current user's company -->
</record>
Domains are truly Odoo's universal filter language. Learning to write them fluently will make you productive in every part of Odoo development.
Hands-on: Add Author, Publisher, and Tags to library.book
Let's put everything together. We'll create new models and add relational fields to our library.book model.
Step 1: Create the Author Model
Create the file library_app/models/author.py:
# library_app/models/author.py
from odoo import models, fields
class LibraryAuthor(models.Model):
"""An author who writes books."""
_name = 'library.author'
_description = 'Library Author'
_order = 'name ASC'
# ---------------------------
# Fields
# ---------------------------
name = fields.Char(
string='Name',
required=True,
index=True,
)
email = fields.Char(string='Email')
biography = fields.Text(
string='Biography',
help='A short biography of the author',
)
date_of_birth = fields.Date(string='Date of Birth')
nationality = fields.Char(string='Nationality')
active = fields.Boolean(string='Active', default=True)
# ---------------------------
# Relational Fields
# ---------------------------
book_ids = fields.One2many(
comodel_name='library.book', # Target model
inverse_name='author_id', # The Many2one field in library.book
string='Books',
help='Books written by this author',
)
Step 2: Create the Tag Model
Create the file library_app/models/tag.py:
# library_app/models/tag.py
from odoo import models, fields
class LibraryTag(models.Model):
"""A tag/category for classifying books (e.g., Fiction, Science, History)."""
_name = 'library.tag'
_description = 'Library Tag'
_order = 'name ASC'
name = fields.Char(
string='Tag Name',
required=True,
index=True,
)
color = fields.Integer(
string='Color Index',
default=0,
help='Color index for the tag badge (0-11)',
# In the UI, Odoo uses this number to assign a color to tag badges.
# 0 = no color, 1 = red, 2 = orange, 3 = yellow, ... up to 11.
)
description = fields.Text(string='Description')
active = fields.Boolean(string='Active', default=True)
Step 3: Create the Publisher Model
Create the file library_app/models/publisher.py:
# library_app/models/publisher.py
from odoo import models, fields
class LibraryPublisher(models.Model):
"""A publishing house that publishes books."""
_name = 'library.publisher'
_description = 'Library Publisher'
_order = 'name ASC'
name = fields.Char(
string='Publisher Name',
required=True,
index=True,
)
website = fields.Char(string='Website')
country = fields.Char(string='Country')
active = fields.Boolean(string='Active', default=True)
# One2many: all books published by this publisher
book_ids = fields.One2many(
comodel_name='library.book',
inverse_name='publisher_id',
string='Published Books',
)
Step 4: Add Relational Fields to the Book Model
Update library_app/models/book.py — add these fields after the existing fields:
# library_app/models/book.py
# ADD these fields to the existing LibraryBook class (don't replace the file!)
# ---------------------------
# Relational Fields
# ---------------------------
author_id = fields.Many2one(
comodel_name='library.author',
string='Author',
ondelete='restrict',
# 'restrict' prevents deleting an author who has books
index=True,
help='The main author of the book',
)
publisher_id = fields.Many2one(
comodel_name='library.publisher',
string='Publisher',
ondelete='set null',
# 'set null' means: if the publisher is deleted, just clear this field
index=True,
)
tag_ids = fields.Many2many(
comodel_name='library.tag',
string='Tags',
help='Classify the book with tags like Fiction, Science, etc.',
)
Step 5: Update __init__.py
Update library_app/models/__init__.py to import the new files:
# library_app/models/__init__.py
from . import book
from . import author
from . import tag
from . import publisher
Step 6: Upgrade the Module
# Docker method:
docker compose exec odoo odoo -d odoo18dev -u library_app --stop-after-init
docker compose restart odoo
# Source method:
python odoo/odoo-bin -c odoo.conf -d odoo18dev -u library_app
Step 7: Verify in the Odoo Shell
# Open the shell
# Docker: docker compose exec odoo odoo shell -d odoo18dev
# ------ Create Authors ------
tolkien = env['library.author'].create({
'name': 'J.R.R. Tolkien',
'nationality': 'British',
'date_of_birth': '1892-01-03',
'biography': 'English writer and philologist, best known for The Hobbit and The Lord of the Rings.',
})
rowling = env['library.author'].create({
'name': 'J.K. Rowling',
'nationality': 'British',
'date_of_birth': '1965-07-31',
})
print(f"Created authors: {tolkien.name} (ID: {tolkien.id}), {rowling.name} (ID: {rowling.id})")
# ------ Create Tags ------
fiction = env['library.tag'].create({'name': 'Fiction', 'color': 1})
fantasy = env['library.tag'].create({'name': 'Fantasy', 'color': 2})
classic = env['library.tag'].create({'name': 'Classic', 'color': 3})
children = env['library.tag'].create({'name': "Children's", 'color': 4})
# ------ Create a Publisher ------
publisher = env['library.publisher'].create({
'name': 'Allen & Unwin',
'country': 'United Kingdom',
})
# ------ Create Books with Relations ------
hobbit = env['library.book'].create({
'name': 'The Hobbit',
'isbn': '978-0547928227',
'pages': 310,
'price': 14.99,
'author_id': tolkien.id,
'publisher_id': publisher.id,
'tag_ids': [(6, 0, [fiction.id, fantasy.id, classic.id])],
# (6, 0, [ids]) = set exactly these tags
'state': 'available',
'date_published': '1937-09-21',
})
print(f"\nCreated: {hobbit.name}")
print(f" Author: {hobbit.author_id.name}")
print(f" Publisher: {hobbit.publisher_id.name}")
print(f" Tags: {[tag.name for tag in hobbit.tag_ids]}")
# ------ Navigate Relations ------
# From author → books (One2many)
print(f"\nBooks by {tolkien.name}:")
for book in tolkien.book_ids:
print(f" - {book.name}")
# ------ Use Domains ------
# Find all fantasy books
fantasy_books = env['library.book'].search([('tag_ids.name', '=', 'Fantasy')])
print(f"\nFantasy books: {[b.name for b in fantasy_books]}")
# Find books by British authors
british_books = env['library.book'].search([('author_id.nationality', '=', 'British')])
print(f"Books by British authors: {[b.name for b in british_books]}")
# Find available books with more than 200 pages
big_available = env['library.book'].search([
('pages', '>', 200),
('state', '=', 'available'),
])
print(f"Available books > 200 pages: {[b.name for b in big_available]}")
# Don't forget to commit!
env.cr.commit()
Summary & What's Next
Key Takeaways
- Many2one creates a foreign key column. One book → one author. Named with
_idsuffix. - One2many is the virtual reverse of Many2one. One author → many books. Named with
_idssuffix. Needsinverse_name. - Many2many creates a join table. Many books ↔ many tags. Named with
_idssuffix. - Command syntax for writing to One2many and Many2many:
(0,0,{values})create,(4,id,0)link,(3,id,0)unlink,(6,0,[ids])replace. - Domains are Odoo's filter language: a list of
(field, operator, value)tuples. - Polish notation for complex domains:
'|'for OR,'&'for AND,'!'for NOT — each applies to the next two conditions. - Dot notation lets you filter through relations:
('author_id.name', '=', 'Tolkien'). - Domains are everywhere: search(), field definitions, window actions, search views, and security rules.
Relational Cheat Sheet
# Many2one — "this book has one author"
author_id = fields.Many2one('library.author', ondelete='restrict')
# One2many — "this author has many books"
book_ids = fields.One2many('library.book', 'author_id')
# Many2many — "this book has many tags"
tag_ids = fields.Many2many('library.tag')
Domain Cheat Sheet
[('field', '=', value)] # Equals
[('field', '!=', False)] # Is set (not empty)
[('field', 'ilike', 'text')] # Contains (case-insensitive)
[('field', 'in', ['a', 'b'])] # In list
[('related.field', '=', value)] # Through relation
['|', ('field1', '=', 'x'), ('field2', '=', 'y')] # OR
What's Next?
In Lesson 5: The ORM — Part 3: CRUD, Recordsets & Business Logic, we'll learn how to:
- Use
create(),read(),write(),unlink()for database operations - Work with Recordsets — filtering, mapping, sorting
- Write computed fields with
@api.depends - Add
@api.onchangemethods for dynamic UI behavior - Add constraints with
@api.constrainsand_sql_constraints - Use
self.env,sudo(), andwith_context()
This is where your models start coming alive with business logic!
Exercises: 1. Create 3 more books in the Odoo Shell, each with different authors and tags. Practice using the
(4, id, 0)and(6, 0, [ids])command syntax. 2. Write domains to find: - All books without an author:[('author_id', '=', False)]- Books published before the year 2000: try it yourself - Books that are either "Fiction" OR "Classic": try it yourself - Books by authors born after 1960 with more than 300 pages: try it yourself (hint: use dotted paths and implicit AND) 3. Test theondeletebehavior: - Try to delete an author who has books (withondelete='restrict'). What error do you get? - Changeondeleteto'set null', upgrade, and try again. What happens to the book? 4. Create a new modellibrary.categorywith aparent_idfield (Many2one pointing to itself — a self-referential relationship). This creates a tree structure. Can you create categories like "Fiction → Fantasy → Epic Fantasy"? ``python class LibraryCategory(models.Model): _name = 'library.category' _description = 'Library Category' name = fields.Char(required=True) parent_id = fields.Many2one('library.category', string='Parent Category') child_ids = fields.One2many('library.category', 'parent_id', string='Subcategories')``
Previous lesson: Lesson 3 — The ORM: Models & Fields Next lesson: Lesson 5 — The ORM: CRUD, Recordsets & Business Logic
