Series: Odoo 18 Development for Python Developers Target Audience: Python developers who are new to web development and want to learn Odoo Prerequisites: Lessons 1–9 completed,
library_appmodule with views, security, chatter, andres.partnerextension
XML Data Files vs CSV Data Files — When to Use Which
Odoo supports two file formats for loading data: XML and CSV. Both are listed in __manifest__.py, but they serve different purposes.
CSV Files
CSV files are best for loading simple tabular data — records with flat fields, no nesting, no complex relations.
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_book_user,book.user,model_library_book,group_library_user,1,1,0,0
Best for:
- Access Control Lists (
ir.model.access.csv) — this is almost always CSV - Simple data imports (list of countries, categories, codes)
- Any data that’s naturally a spreadsheet/table
Limitations:
- Can’t define nested structures (e.g., a record with child records)
- Can’t use
evalfor Python expressions - Can’t set
noupdateon individual records - Can’t create complex Many2many or One2many relations easily
XML Files
XML files handle everything CSV can do, plus complex structures:
<odoo>
<record id="tag_fiction" model="library.tag">
<field name="name">Fiction</field>
<field name="color">1</field>
</record>
</odoo>
Best for:
- Views, actions, menus (always XML)
- Security groups and record rules (always XML)
- Records with relational fields
- Records with
noupdaterequirements - Demo data with interconnected records
- Any data that’s more complex than a flat table
Decision Guide
| Data Type | Format | Why |
|---|---|---|
ACLs (ir.model.access) |
CSV | Flat table, standard convention |
| Views, Actions, Menus | XML | Complex nested structures |
| Security Groups, Record Rules | XML | Need eval, relational fields |
| Default tags/categories | XML or CSV | Either works; XML if colors/relations needed |
| Demo data | XML | Usually has relations between records |
| Translation files | PO | Special format (.po files in i18n/) |
Loading Data: data vs demo in __manifest__.py
The __manifest__.py has two lists for data files: data and demo. They behave differently.
data — Always Loaded
'data': [
'security/library_security.xml',
'security/ir.model.access.csv',
'views/book_views.xml',
'views/menu_views.xml',
'data/library_tag_data.xml', # Default tags
'data/library_sequence_data.xml', # Sequences
],
Data files are loaded:
- When the module is installed for the first time
- When the module is upgraded
- In every database (production, staging, development)
Use for: Views, menus, security, default configuration, sequences — anything that’s essential for the module to function.
demo — Only in Demo Mode
'demo': [
'demo/library_demo.xml', # Sample books, authors, tags
],
Demo files are loaded:
- Only when the database was created with the “Load demonstration data” checkbox enabled
- On module install (not on upgrade)
- Typically only in development/testing databases
Use for: Sample records that help showcase the module — example books, test authors, sample members. Never put essential data here.
How to Check if Demo Mode is Enabled
In the Odoo Shell:
# Check if the current database has demo data enabled
demo_enabled = self.env['ir.module.module'].search([
('name', '=', 'base'),
('demo', '=', True),
])
print(bool(demo_enabled)) # True if demo mode is on
Loading Order Summary
When a module is installed:
1. Python code (via __init__.py chain)
2. 'data' files (in the order listed in __manifest__.py)
3. 'demo' files (only if demo mode is on, in order listed)
When a module is upgraded:
1. Python code (via __init__.py chain)
2. 'data' files (re-loaded, respecting noupdate flags)
3. 'demo' files are NOT re-loaded on upgrade
noupdate="1" — Protecting User-Modified Data
This is one of the most confusing aspects of Odoo data management for beginners. Let’s demystify it.
The Problem
Imagine you ship a module with default tag data:
<record id="tag_fiction" model="library.tag">
<field name="name">Fiction</field>
<field name="color">1</field>
</record>
After installation, a user renames the tag from “Fiction” to “Novels”. Then you upgrade the module. What should happen?
- Option A: Overwrite the user’s change back to “Fiction” — BAD (user loses their customization)
- Option B: Keep the user’s version “Novels” — GOOD (respect user modifications)
The noupdate flag controls this behavior.
The noupdate Flag
<odoo>
<!-- noupdate="0" (default): Records ARE overwritten on upgrade -->
<data noupdate="0">
<record id="my_view" model="ir.ui.view">
<!-- This view will be updated every time the module is upgraded -->
</record>
</data>
<!-- noupdate="1": Records are NOT overwritten on upgrade -->
<data noupdate="1">
<record id="tag_fiction" model="library.tag">
<!-- This tag is created on install, then NEVER touched again -->
<!-- User modifications are preserved -->
</record>
</data>
</odoo>
When to Use Each
| Content | noupdate |
Why |
|---|---|---|
| Views | 0 (default) |
Views should always be updated to the latest version |
| Actions | 0 (default) |
Actions should stay in sync with the code |
| Menus | 0 (default) |
Menus should reflect the latest structure |
| Security groups | 0 (default) |
Groups should be updated with new permissions |
| Record rules | 1 |
Users/admins may customize rules |
| Default data (tags, categories) | 1 |
Users may rename or modify defaults |
| Sequences | 1 |
Users may change the format/numbering |
| Email templates | 1 |
Users heavily customize email content |
| Server actions | 1 |
Users may modify automation logic |
A Common Pattern
Many XML files mix both noupdate values:
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Views: always update -->
<data noupdate="0">
<record id="my_view" model="ir.ui.view">
<!-- ... -->
</record>
</data>
<!-- Default data: don't overwrite user changes -->
<data noupdate="1">
<record id="tag_fiction" model="library.tag">
<field name="name">Fiction</field>
</record>
</data>
</odoo>
The Development Headache
During development, noupdate="1" can be frustrating because your data changes don’t take effect on upgrade. Solutions:
Option 1: Temporarily use noupdate="0" during development, then switch to "1" before release.
Option 2: Delete the record manually, then upgrade:
# In the Odoo Shell:
record = env.ref('library_app.tag_fiction', raise_if_not_found=False)
if record:
record.unlink()
# Now upgrade the module — the record will be recreated
Option 3: Use the --init flag instead of --update:
# -i forces full reinstall (ignores noupdate)
docker compose exec odoo odoo -d odoo18dev -i library_app --stop-after-init
# WARNING: This may reset ALL data in the module, including user modifications
External IDs (XML IDs) — The Glue of Odoo Data
External IDs (also called XML IDs) are one of the most important concepts in Odoo. They provide a stable way to reference records across modules and upgrades.
What is an External ID?
Every tag has an id attribute. This creates an External ID:
<record id="tag_fiction" model="library.tag">
<field name="name">Fiction</field>
</record>
The full External ID is: library_app.tag_fiction
library_app.tag_fiction
│ │
│ └── The id you wrote in the XML
└── Your module name (added automatically)
Where External IDs are Stored
External IDs are stored in the ir.model.data table:
SELECT * FROM ir_model_data WHERE module = 'library_app' AND name = 'tag_fiction';
-- Returns: module='library_app', name='tag_fiction', model='library.tag', res_id=1
This mapping tells Odoo: “The XML ID library_app.tag_fiction refers to library.tag record with id=1.”
Why External IDs Matter
- Stable references: Database IDs (1, 2, 3) can differ between databases. External IDs are consistent everywhere.
- Cross-module references: Module A can reference records from Module B using External IDs.
- Upgrade safety: When you upgrade a module, Odoo uses External IDs to find and update existing records (instead of creating duplicates).
- Data deletion: When you uninstall a module, Odoo deletes all records with that module’s External IDs.
Viewing External IDs
In Developer Mode, you can see any record’s External ID:
- Open any record in form view
- Click the debug icon (bug icon) in the top menu
- Click “View Metadata”
- You’ll see the External ID (XML ID) and other technical info
Or in the Odoo Shell:
# Find External ID for a record
tag = env['library.tag'].search([('name', '=', 'Fiction')], limit=1)
external_id = tag.get_external_id()
print(external_id) # {1: 'library_app.tag_fiction'}
# Get a record by its External ID
fiction_tag = env.ref('library_app.tag_fiction')
print(fiction_tag.name) # 'Fiction'
The ref() Function and Cross-Module References
Using ref in XML
The ref attribute links to another record by its External ID:
<!-- Reference a record from the SAME module -->
<field name="group_id" ref="group_library_user"/>
<!-- Odoo expands this to 'library_app.group_library_user' -->
<!-- Reference a record from ANOTHER module -->
<field name="group_id" ref="base.group_user"/>
<!-- 'base' is the module, 'group_user' is the record -->
<!-- In a Many2many field using eval -->
<field name="groups" eval="[(4, ref('group_library_user'))]"/>
<!-- ref() inside eval returns the database ID -->
Using ref in eval
For fields that need Python expressions, use eval with ref():
<!-- Set a Many2one field -->
<field name="author_id" ref="author_tolkien"/>
<!-- Set a Many2many field: add multiple records -->
<field name="tag_ids" eval="[
(4, ref('tag_fiction')),
(4, ref('tag_fantasy')),
(4, ref('tag_classic')),
]"/>
<!-- Set a Many2many field: replace all with specific records -->
<field name="tag_ids" eval="[(6, 0, [
ref('tag_fiction'),
ref('tag_fantasy'),
])]"/>
<!-- Set a default value referencing another record -->
<field name="user_id" eval="ref('base.user_admin')"/>
Using env.ref() in Python
# Get a record by External ID
admin_user = self.env.ref('base.user_admin')
# Safe version: returns False if not found
record = self.env.ref('library_app.tag_fiction', raise_if_not_found=False)
# Use in a default value
class LibraryBook(models.Model):
_name = 'library.book'
tag_ids = fields.Many2many(
'library.tag',
default=lambda self: self.env.ref(
'library_app.tag_fiction', raise_if_not_found=False
),
)
Cross-Module Reference Examples
<!-- Reference the admin user from the 'base' module -->
<field name="user_id" ref="base.user_admin"/>
<!-- Reference a view from 'base' for inheritance -->
<field name="inherit_id" ref="base.view_partner_form"/>
<!-- Reference a group from the 'sale' module -->
<field name="groups" eval="[(4, ref('sale.group_sale_salesman'))]"/>
<!-- Reference a mail template from 'mail' module -->
<field name="template_id" ref="mail.mail_template_data_notification_email_default"/>
Important: When referencing records from other modules, make sure that module is in your depends list in __manifest__.py. Otherwise, the reference might not exist.
Sequences (ir.sequence) — Auto-Numbering Records
Sequences generate auto-incrementing identifiers like BOOK-0001, BOOK-0002, LIB-MEM-0001, etc. They’re used for reference numbers, membership IDs, invoice numbers, and more.
Defining a Sequence
<data noupdate="1">
<record id="sequence_library_book" model="ir.sequence">
<field name="name">Library Book Reference</field>
<field name="code">library.book</field>
<field name="prefix">BOOK-</field>
<field name="padding">4</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
</record>
</data>
Fields explained:
| Field | Value | Result |
|---|---|---|
name |
Human-readable name | Shown in Settings → Technical → Sequences |
code |
Technical identifier | Used in Python to fetch the sequence |
prefix |
Text before the number | BOOK- |
padding |
Number of digits (zero-padded) | 4 → 0001, 0002, … |
number_next |
Next number to generate | Starts at 1 |
number_increment |
Step between numbers | 1 → 1, 2, 3; 10 → 10, 20, 30 |
This sequence generates: BOOK-0001, BOOK-0002, BOOK-0003, …
Advanced Sequence Patterns
<!-- Date-based prefix -->
<record id="sequence_library_borrow" model="ir.sequence">
<field name="name">Library Borrow Reference</field>
<field name="code">library.borrow</field>
<field name="prefix">BRW/%(year)s/%(month)s/</field>
<field name="padding">4</field>
</record>
<!-- Generates: BRW/2024/03/0001, BRW/2024/03/0002, BRW/2024/04/0001, ... -->
<!-- With suffix -->
<record id="sequence_library_member" model="ir.sequence">
<field name="name">Library Member Number</field>
<field name="code">library.member</field>
<field name="prefix">LIB-</field>
<field name="suffix">-MEM</field>
<field name="padding">5</field>
</record>
<!-- Generates: LIB-00001-MEM, LIB-00002-MEM, ... -->
Available date variables for prefix/suffix:
| Variable | Meaning | Example |
|---|---|---|
%(year)s |
4-digit year | 2024 |
%(month)s |
2-digit month | 03 |
%(day)s |
2-digit day | 15 |
%(y)s |
2-digit year | 24 |
%(h24)s |
Hour (24h) | 14 |
%(sec)s |
Seconds | 30 |
Using Sequences in Python
class LibraryBook(models.Model):
_name = 'library.book'
reference = fields.Char(
string='Reference',
readonly=True,
copy=False,
default='New',
)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('reference', 'New') == 'New':
vals['reference'] = self.env['ir.sequence'].next_by_code('library.book') or 'New'
return super().create(vals_list)
How next_by_code() works:
- Looks up the sequence with
code='library.book' - Gets the current counter value
- Formats the result:
BOOK-+ zero-padded number - Increments the counter for next time
- Returns the formatted string (e.g.,
'BOOK-0001')
If no sequence is found, next_by_code() returns False. The or 'New' fallback handles this case.
Sequence in res.partner for Membership Numbers
Let’s improve the membership number generation from Lesson 9:
<record id="sequence_library_member" model="ir.sequence">
<field name="name">Library Membership Number</field>
<field name="code">library.member</field>
<field name="prefix">LIB-</field>
<field name="padding">4</field>
</record>
Then in models/res_partner.py, replace the manual numbering with:
def action_make_library_member(self):
for partner in self:
if not partner.is_library_member:
partner.write({
'is_library_member': True,
'membership_number': self.env['ir.sequence'].next_by_code('library.member'),
'member_since': fields.Date.today(),
})
Much cleaner! The sequence handles auto-incrementing and formatting.
Server Actions and Automated Actions
Server Actions
A server action is a configurable action that runs Python code, sends emails, creates records, or performs other operations. They can be triggered from buttons, menus, or automated rules.
<record id="action_mark_all_draft_available" model="ir.actions.server">
<field name="name">Mark Draft Books as Available</field>
<field name="model_id" ref="model_library_book"/>
<field name="binding_model_id" ref="model_library_book"/>
<field name="binding_view_types">list</field>
<field name="state">code</field>
<field name="code">
for book in records:
if book.state == 'draft':
book.action_mark_available()
</field>
</record>
What this does:
- Creates an action visible in the “Action” dropdown on the book list view
- When a user selects books and runs this action, it marks all draft books as available
binding_model_id— Makes this action appear in the Action menu for this modelbinding_view_types— Which views show this action (list,form, orlist,form)records— A special variable containing the selected records
Available Variables in Server Action Code
| Variable | Type | Description |
|---|---|---|
records |
Recordset | The selected records |
record |
Record | First selected record (for single-record actions) |
env |
Environment | The Odoo environment |
model |
Model | The model class |
time |
module | Python time module |
datetime |
module | Python datetime module |
dateutil |
module | Python dateutil module |
log |
function | Logging function |
UserError |
exception | For raising user-facing errors |
Automated Actions (Scheduled / Event-Triggered)
Automated actions run automatically based on triggers: time-based (cron jobs) or event-based (record created, updated, etc.).
Event-Based Automated Action
<record id="automation_new_book_notification" model="base.automation">
<field name="name">Notify on New Book</field>
<field name="model_id" ref="model_library_book"/>
<field name="trigger">on_create</field>
<field name="state">code</field>
<field name="code">
for record in records:
record.message_post(
body=f"New book added: {record.name}",
message_type='notification',
)
</field>
</record>
Trigger options:
| Trigger | Fires When |
|---|---|
on_create |
A new record is created |
on_write |
An existing record is updated |
on_create_or_write |
A record is created or updated |
on_unlink |
A record is deleted |
on_change |
A specific field value changes |
on_time |
At scheduled intervals (cron-like) |
Time-Based Automated Action (Cron Job)
<record id="automation_check_overdue_books" model="base.automation">
<field name="name">Check Overdue Books</field>
<field name="model_id" ref="model_library_book"/>
<field name="trigger">on_time</field>
<field name="trg_date_id" ref="field_library_book__date_added"/>
<field name="trg_date_range">30</field>
<field name="trg_date_range_type">day</field>
<field name="filter_domain">[('state', '=', 'borrowed')]</field>
<field name="state">code</field>
<field name="code">
for record in records:
record.message_post(
body="This book has been borrowed for over 30 days!",
message_type='notification',
)
</field>
</record>
Time-based fields:
| Field | Purpose |
|---|---|
trg_date_id |
The date field to watch |
trg_date_range |
Number of units after the date |
trg_date_range_type |
Unit: day, hour, month |
filter_domain |
Only apply to records matching this domain |
This example checks all borrowed books and posts a notification if they’ve been borrowed for more than 30 days.
Field-Change Trigger
<record id="automation_state_change_log" model="base.automation">
<field name="name">Log Book State Changes</field>
<field name="model_id" ref="model_library_book"/>
<field name="trigger">on_change</field>
<field name="trigger_field_ids" eval="[(4, ref('field_library_book__state'))]"/>
<field name="state">code</field>
<field name="code">
for record in records:
log(f"Book '{record.name}' state changed to '{record.state}'")
</field>
</record>
This fires only when the state field changes — not on every write.
Hands-on: Add Demo Books, Sequences, and an Automated Action
Step 1: Create data/library_sequence_data.xml
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data noupdate="1">
<!-- Sequence for book references -->
<record id="sequence_library_book" model="ir.sequence">
<field name="name">Library Book Reference</field>
<field name="code">library.book</field>
<field name="prefix">BOOK-</field>
<field name="padding">4</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
</record>
<!-- Sequence for membership numbers -->
<record id="sequence_library_member" model="ir.sequence">
<field name="name">Library Membership Number</field>
<field name="code">library.member</field>
<field name="prefix">LIB-</field>
<field name="padding">4</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
</record>
</data>
</odoo>
Step 2: Create data/library_tag_data.xml
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data noupdate="1">
<!-- Default tags loaded on module install -->
<record id="tag_fiction" model="library.tag">
<field name="name">Fiction</field>
<field name="color">1</field>
</record>
<record id="tag_non_fiction" model="library.tag">
<field name="name">Non-Fiction</field>
<field name="color">2</field>
</record>
<record id="tag_fantasy" model="library.tag">
<field name="name">Fantasy</field>
<field name="color">3</field>
</record>
<record id="tag_science" model="library.tag">
<field name="name">Science</field>
<field name="color">4</field>
</record>
<record id="tag_history" model="library.tag">
<field name="name">History</field>
<field name="color">5</field>
</record>
<record id="tag_biography" model="library.tag">
<field name="name">Biography</field>
<field name="color">6</field>
</record>
<record id="tag_children" model="library.tag">
<field name="name">Children's</field>
<field name="color">7</field>
</record>
<record id="tag_classic" model="library.tag">
<field name="name">Classic</field>
<field name="color">8</field>
</record>
</data>
</odoo>
Step 3: Create data/library_automation_data.xml
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data noupdate="1">
<!-- Server Action: Mark selected draft books as available -->
<record id="action_server_mark_available" model="ir.actions.server">
<field name="name">Mark as Available</field>
<field name="model_id" ref="model_library_book"/>
<field name="binding_model_id" ref="model_library_book"/>
<field name="binding_view_types">list,form</field>
<field name="state">code</field>
<field name="code">
for book in records:
if book.state == 'draft':
book.action_mark_available()
</field>
</record>
<!-- Automated Action: Notify when a new book is added -->
<record id="automation_new_book_notify" model="base.automation">
<field name="name">New Book Notification</field>
<field name="model_id" ref="model_library_book"/>
<field name="trigger">on_create</field>
<field name="state">code</field>
<field name="code">
for record in records:
record.message_post(
body=f"📚 New book added to the library: <b>{record.name}</b>",
message_type='notification',
subtype_xmlid='mail.mt_note',
)
</field>
</record>
</data>
</odoo>
Step 4: Create demo/library_demo.xml
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data noupdate="1">
<!-- ==================== -->
<!-- AUTHORS -->
<!-- ==================== -->
<record id="author_tolkien" model="library.author">
<field name="name">J.R.R. Tolkien</field>
<field name="nationality">British</field>
<field name="date_of_birth">1892-01-03</field>
<field name="email">tolkien@example.com</field>
<field name="biography">English writer, poet, philologist, and academic, best known as the author of the high fantasy works The Hobbit and The Lord of the Rings.</field>
</record>
<record id="author_herbert" model="library.author">
<field name="name">Frank Herbert</field>
<field name="nationality">American</field>
<field name="date_of_birth">1920-10-08</field>
<field name="biography">American science-fiction author best known for the novel Dune and its five sequels.</field>
</record>
<record id="author_orwell" model="library.author">
<field name="name">George Orwell</field>
<field name="nationality">British</field>
<field name="date_of_birth">1903-06-25</field>
<field name="biography">English novelist, essayist, journalist, and critic. Best known for the allegorical novella Animal Farm and the dystopian novel 1984.</field>
</record>
<record id="author_asimov" model="library.author">
<field name="name">Isaac Asimov</field>
<field name="nationality">American</field>
<field name="date_of_birth">1920-01-02</field>
<field name="biography">American writer and professor of biochemistry, known for his works of science fiction and popular science.</field>
</record>
<!-- ==================== -->
<!-- PUBLISHER -->
<!-- ==================== -->
<record id="publisher_allen_unwin" model="library.publisher">
<field name="name">Allen & Unwin</field>
<field name="country">United Kingdom</field>
</record>
<record id="publisher_chilton" model="library.publisher">
<field name="name">Chilton Books</field>
<field name="country">United States</field>
</record>
<record id="publisher_secker" model="library.publisher">
<field name="name">Secker & Warburg</field>
<field name="country">United Kingdom</field>
</record>
<!-- ==================== -->
<!-- BOOKS -->
<!-- ==================== -->
<record id="book_hobbit" model="library.book">
<field name="name">The Hobbit</field>
<field name="isbn">978-0547928227</field>
<field name="pages">310</field>
<field name="price">14.99</field>
<field name="rating">4.7</field>
<field name="date_published">1937-09-21</field>
<field name="author_id" ref="author_tolkien"/>
<field name="publisher_id" ref="publisher_allen_unwin"/>
<field name="cover_type">paperback</field>
<field name="state">available</field>
<field name="synopsis">Bilbo Baggins, a comfortable hobbit, is swept into an epic quest to reclaim the lost Dwarf Kingdom of Erebor from the fearsome dragon Smaug.</field>
<field name="tag_ids" eval="[
(4, ref('tag_fiction')),
(4, ref('tag_fantasy')),
(4, ref('tag_classic')),
]"/>
</record>
<record id="book_lotr" model="library.book">
<field name="name">The Lord of the Rings</field>
<field name="isbn">978-0618640157</field>
<field name="pages">1178</field>
<field name="price">29.99</field>
<field name="rating">4.9</field>
<field name="date_published">1954-07-29</field>
<field name="author_id" ref="author_tolkien"/>
<field name="publisher_id" ref="publisher_allen_unwin"/>
<field name="cover_type">hardcover</field>
<field name="state">available</field>
<field name="synopsis">An epic high-fantasy novel following the quest to destroy the One Ring and defeat the Dark Lord Sauron.</field>
<field name="tag_ids" eval="[
(4, ref('tag_fiction')),
(4, ref('tag_fantasy')),
(4, ref('tag_classic')),
]"/>
</record>
<record id="book_dune" model="library.book">
<field name="name">Dune</field>
<field name="isbn">978-0441013593</field>
<field name="pages">412</field>
<field name="price">16.99</field>
<field name="rating">4.5</field>
<field name="date_published">1965-08-01</field>
<field name="author_id" ref="author_herbert"/>
<field name="publisher_id" ref="publisher_chilton"/>
<field name="cover_type">paperback</field>
<field name="state">available</field>
<field name="synopsis">Set in the distant future, the novel tells the story of young Paul Atreides on the desert planet Arrakis.</field>
<field name="tag_ids" eval="[
(4, ref('tag_fiction')),
(4, ref('tag_science')),
]"/>
</record>
<record id="book_1984" model="library.book">
<field name="name">Nineteen Eighty-Four</field>
<field name="isbn">978-0451524935</field>
<field name="pages">328</field>
<field name="price">12.99</field>
<field name="rating">4.6</field>
<field name="date_published">1949-06-08</field>
<field name="author_id" ref="author_orwell"/>
<field name="publisher_id" ref="publisher_secker"/>
<field name="cover_type">paperback</field>
<field name="state">borrowed</field>
<field name="synopsis">A dystopian novel set in Airstrip One, a province of the superstate Oceania, depicting a society under omnipresent government surveillance.</field>
<field name="tag_ids" eval="[
(4, ref('tag_fiction')),
(4, ref('tag_classic')),
]"/>
</record>
<record id="book_foundation" model="library.book">
<field name="name">Foundation</field>
<field name="isbn">978-0553293357</field>
<field name="pages">244</field>
<field name="price">15.99</field>
<field name="rating">4.4</field>
<field name="date_published">1951-05-01</field>
<field name="author_id" ref="author_asimov"/>
<field name="cover_type">paperback</field>
<field name="state">draft</field>
<field name="synopsis">The story of a group of scientists who seek to preserve knowledge as the Galactic Empire crumbles.</field>
<field name="tag_ids" eval="[
(4, ref('tag_fiction')),
(4, ref('tag_science')),
(4, ref('tag_classic')),
]"/>
</record>
</data>
</odoo>
Step 5: Add reference Field to Book Model
Update models/book.py to use the sequence:
# Add this field to the LibraryBook class:
reference = fields.Char(
string='Reference',
readonly=True,
copy=False,
default='New',
help='Auto-generated book reference number',
)
# Update or add the create method:
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('reference', 'New') == 'New':
vals['reference'] = (
self.env['ir.sequence'].next_by_code('library.book') or 'New'
)
return super().create(vals_list)
Also add the reference field to the form view (in views/book_views.xml), inside the title area:
<!-- Add after the <h1> with the name field, inside oe_title div -->
<h3>
<field name="reference"/>
</h3>
Step 6: Update Membership Number to Use Sequence
In models/res_partner.py, simplify the action_make_library_member method:
def action_make_library_member(self):
for partner in self:
if not partner.is_library_member:
partner.write({
'is_library_member': True,
'membership_number': self.env['ir.sequence'].next_by_code('library.member'),
'member_since': fields.Date.today(),
})
Step 7: Update __manifest__.py
'data': [
'security/library_security.xml',
'security/ir.model.access.csv',
'data/library_sequence_data.xml', # ← Sequences (before views)
'data/library_tag_data.xml', # ← Default tags
'data/library_automation_data.xml', # ← Server actions & automations
'views/book_views.xml',
'views/author_views.xml',
'views/tag_views.xml',
'views/res_partner_views.xml',
'views/menu_views.xml',
],
'demo': [
'demo/library_demo.xml', # ← Demo data (authors + books)
],
Step 8: Upgrade and Test
docker compose exec odoo odoo -d odoo18dev -u library_app --stop-after-init
docker compose restart odoo
What to test:
- Default tags — Go to Library → Configuration → Tags. You should see 8 pre-created tags with colors.
- Demo data — If your database has demo mode enabled, go to Library → Books. You should see 5 classic books with authors, publishers, and tags already assigned.
- Book reference — Create a new book. The “Reference” field should auto-fill with
BOOK-0001(or the next available number). - Membership sequence — Go to Contacts, open a contact, check “Library Member.” The membership number should be
LIB-0001. - Server action — Go to the book list, select some draft books, click Action → “Mark as Available.” They should change to Available.
- Automated action — Create a new book. Check the chatter — a notification should appear automatically saying “New book added.”
Summary & What’s Next
Key Takeaways
- XML for complex data, CSV for flat tables. ACLs go in CSV; views, actions, and default data go in XML.
datais always loaded;demois only loaded in demo databases. Never put essential data indemo.noupdate="1"protects user modifications from being overwritten on module upgrade. Use it for default data, sequences, and email templates. Don’t use it for views and menus.- External IDs (XML IDs) are the stable way to reference records. Format:
module_name.record_id. They survive database migrations and cross-module references. ref()is how you reference External IDs in XML (ref="tag_fiction") and Python (self.env.ref('library_app.tag_fiction')).- Sequences (
ir.sequence) auto-generate formatted reference numbers. Define in XML, use in Python withnext_by_code(). - Server actions add operations to the Action dropdown menu. Automated actions run code on triggers (create, update, time-based).
What’s Next?
In Lesson 11: Business Logic Patterns — Workflows & State Machines, we’ll build on what we have and learn:
- State machine patterns for book borrowing workflows
- Button actions with state transition constraints
- Wizards (TransientModel) for multi-step user interactions
- Mail thread integration for notifications
- The
confirmattribute on dangerous buttons
Our library module now has data, sequences, and automation. Next, we’ll add real business logic for borrowing and returning books.
Exercises: 1. Add a date-based prefix to the book reference sequence:
BOOK/%(year)s/so references look likeBOOK/2024/0001. Remember to delete the existing sequence record first (or use a new database). 2. Create an automated action that fires when a book’sstatechanges to'lost'and posts a warning message in the chatter. 3. Add more demo data: create 5 additional books from different authors and genres. Practice usingref()to link to existing tags and authors. 4. Create a server action called “Archive Old Books” that archives (setsactive=False) all books withstate='lost'. Bind it to the book list view. 5. Explore the External IDs in your database: go to Settings → Technical → Sequences & Identifiers → External Identifiers. Filter by modulelibrary_app. How many records does your module manage? 6. Try thenoupdateexperiment: – Change a default tag name (e.g., rename “Fiction” to “Novels”) in the UI – Upgrade the module – Check if the tag was reverted to “Fiction” or kept as “Novels” – Now changenoupdate="1"tonoupdate="0"in the XML, upgrade again – What happened? Why?
Previous lesson: Lesson 9 — Inheritance: Odoo’s Most Powerful Pattern Next lesson: Lesson 11 — Business Logic: Workflows & State Machines
