KomITi Academy

Odoo from 0 to Hero

Your First Odoo 19 Application — Step by Step

Table of Contents

  1. 1) Introduction
    1. What we did up to now
    2. What you have to know before starting the non-GUI Odoo development journey
  2. 2) Overview of the Library Project
  3. 3) Step 1 — Creating a New Addon Module
    1. 3.1) What is an Addon Module, Addons Directory and Addons Path?
    2. 3.2) Preparing the Addons Path
    3. 3.3) Recommended Module Structure in Odoo 19
    4. 3.4) The __init__.py Chain
    5. 3.5) Creating the Manifest File
    6. 3.6) Understanding depends
    7. 3.7) Understanding data File Load Order
    8. 3.8) Manifest Key Reference
    9. 3.9) Setting the Module Category
    10. 3.10) Choosing a License
    11. 3.11) Adding a Description and Icon
    12. 3.12) Installing a Module
    13. 3.13) Upgrading Modules
  4. 4) Step 2 — Creating a New Application
    1. 4.1) Adding a Top Menu Item
    2. 4.2) Adding Security Groups
  5. 5) Step 3 — Adding Automated Tests
    1. 5.1) Requirements for Tests
    2. 5.2) Adding Test Cases
    3. 5.3) Testing Access Security
    4. 5.4) Running Tests
  6. 6) Step 4 — Implementing the Model Layer
    1. 6.1) Creating a Data Model
    2. 6.2) Field Types Reference
    3. 6.3) Common Field Attributes
    4. 6.4) Custom Search Logic
    5. 6.5) Registering the Model File
  7. 7) Step 5 — Setting Up Access Security
    1. 7.1) Adding Access Control Rules
    2. 7.2) Row-Level Access Rules (Record Rules)
    3. 7.3) Security Quick Reference
  8. 8) Step 6 — Implementing the Backend View Layer
    1. 8.1) Creating a Form View
    2. 8.2) Form Structure — header and sheet
    3. 8.3) Two-Column Layout with Nested group
    4. 8.4) Conditional Attributes — attrs Removed in v19
    5. 8.5) Adding a List View
    6. 8.6) Adding a Search View
    7. 8.7) The context Attribute on Filters
    8. 8.8) How field Behaves Differently in a Search View
    9. 8.9) Adding Menu Items and Window Actions
    10. 8.10) Button Styling
  9. 9) Step 7 — Implementing the Business Logic Layer
    1. 9.1) Adding Business Logic — ISBN Validation
    2. 9.2) Connecting Logic to a Form Button
    3. 9.3) Why Three Methods for One Rule?
    4. 9.4) Constraints (@api.constrains)
  10. 10) Step 8 — Implementing the Website UI
    1. 10.1) Web Controllers
    2. 10.2) Adding a QWeb Template
    3. 10.3) JavaScript Frontend — OWL Components
  11. 11) Exercise — Add a "Number of Pages" Field
  12. 12) Summary

1) Introduction

Developing in Odoo usually means creating our own modules. In this chapter, the goal is to create a first Odoo application from scratch, learn the steps needed to make it available to Odoo, install it, and iteratively build it up. Odoo follows a Model-View-Controller (MVC)-like architecture, and the chapter walks through each of those layers one by one.

The topics covered in this chapter are:

Infrastructure requirements for Odoo 19: Python 3.12 or higher and PostgreSQL 15 or higher are required. Odoo 19 will not start on older versions of either.

1.1) What we did up to now

Before diving into Odoo module development, let us recap what you have already accomplished in the previous tutorials:

Your local environment is ready. Both Odoo instances are running and accessible in the browser. From this point on, you will be writing code that runs inside those containers.

1.2) What you have to know before starting the non-GUI Odoo development journey

Odoo can be customised in two ways:

Even when developing through code, you will often need to inspect what Odoo is doing under the hood. For that, you need developer mode.

Enabling the developer mode

Developer mode unlocks extra menus, technical information on fields, and debugging tools. To activate it:

  1. Log in to your Odoo instance (e.g. http://localhost:8069).
  2. Go to Settings → General Settings.
  3. Scroll down to the Developer Tools section.
  4. Click Activate the developer mode.
Settings → General Settings → Developer Tools → Activate the developer mode

Once activated, you will notice extra technical menus (e.g. Settings → Technical) and additional debug information throughout the interface. You can also append ?debug=1 to any Odoo URL to enable it directly.

2) Overview of the Library Project

The learning project for this chapter is a Library application — specifically a book catalog. The catalog allows keeping records of books in the library with their relevant details, and makes that catalog available through a public website where available books can be seen.

The book data requirements are:

Following the pattern used by standard Odoo base apps, the Library app has two user groups: Library User and Library Manager. The User level performs all daily operations; the Manager level additionally manages configurations. For the book catalog specifically:


3) Step 1 Creating a New Addon Module

3.1) What is an Addon Module, Addons Directory and Addons Path?

Some addon modules are featured as apps. Apps are top-level modules for a feature area in Odoo, shown in the top-level Apps menu (e.g., CRM, Project, HR). A non-app addon is expected to depend on an existing app and extend it. The distinction is: if the module adds major new functionality, it should be an app; if it modifies an existing app, it should be a regular addon module.

To develop a new module, you:

  1. Ensure the working directory is in the Odoo server addons path.
  2. Create the module directory containing the manifest file.
  3. Choose a license if you intend to distribute it.
  4. Add a description.
  5. Optionally, add an icon.

3.2) Preparing the Addons Path

The addons path is an Odoo configuration listing directories where the server looks for available addons. By default it contains Odoo's own bundled apps and base module. You extend it by adding directories for custom or community modules.

The odoo scaffold command provides a quick way to create a module skeleton:

python odoo-bin scaffold library ~/work19/library
Prerequisites: run this command from an Odoo source checkout where odoo-bin exists, use a Python environment with the project dependencies installed, and pass an existing addons directory as the destination path so Odoo can later load the generated module through its addons_path configuration.

3.3) Recommended Module Structure in Odoo 19

The scaffold command creates a basic skeleton. The full recommended structure for a production module in Odoo 19 is:

library/
├── __init__.py
├── __manifest__.py
├── controllers/
│   ├── __init__.py
│   └── main.py
├── data/                  # initial data loaded on install
├── demo/
│   └── demo.xml
├── i18n/                  # translation files (.pot / .po)
├── models/
│   ├── __init__.py
│   └── library_book.py
├── reports/               # PDF report templates
├── security/
│   ├── ir.model.access.csv
│   └── library_security.xml
├── static/
│   └── description/
│       └── icon.png
│   └── src/
│       ├── js/            # ES Module JavaScript files
│       ├── scss/          # SCSS stylesheets
│       ├── css/
│       └── xml/           # OWL component templates
├── tests/
│   ├── __init__.py
│   └── test_book.py
├── views/
│   ├── book_view.xml
│   ├── book_list_template.xml
│   └── library_menu.xml
└── wizards/               # transient model (wizard) files
Tip: The subdirectory structure is a convention, not a technical requirement. Odoo only mandates __manifest__.py and __init__.py. Following the convention makes your module easy for other developers to navigate.

Minimal valid module

The absolute minimum needed for Odoo to find and install a module is just two files:

my_module/
├── __init__.py        ← can be completely empty
└── __manifest__.py    ← {"name": "My Module", "depends": ["base"]}

This installs silently, adds nothing, but proves the module directory is on the addons path and Odoo can load it. Everything else — models, views, security — is added incrementally.

3.4) The __init__.py Chain

Every directory in an Odoo module that contains Python code must have an __init__.py file. This is a standard Python requirement: it turns the directory into a Python package so that its files can be imported with the from . import syntax.

How the import chain works

When Odoo loads a module it executes its root __init__.py. That file must explicitly import every sub-package that contains Python code. The chain for the library module looks like this:

# odoo/addons/ (Odoo server entry point)
#   └── loads library/__init__.py

# library/__init__.py
from . import models
from . import controllers   # only if controllers/ has Python files

# library/models/__init__.py
from . import library_book  # imports library/models/library_book.py

# library/controllers/__init__.py
from . import main          # imports library/controllers/main.py
Common mistake: Adding a new model file (e.g. models/library_member.py) but forgetting to add from . import library_member to models/__init__.py. The file exists on disk but Odoo never loads it — the model does not appear in the database and no error is shown.

What each __init__.py contains

FileTypical contentNotes
library/__init__.py from . import models, controllers Root package — imports all sub-packages that have Python code
library/models/__init__.py from . import library_book One line per model file. Add a line whenever you create a new model.
library/controllers/__init__.py from . import main One line per controller file.
library/tests/__init__.py from . import test_book One line per test file. Odoo's test runner discovers these automatically.
Tip: Directories that contain only XML/CSV data files — such as views/, security/, demo/ — do not need an __init__.py. Those files are loaded by the manifest's data list, not by Python's import system.

3.5) Creating the Manifest File

The manifest file (__manifest__.py) is the identity card of an Odoo module. It is a plain Python file that contains a single dictionary literal — no classes, no functions, just key-value pairs. When Odoo scans an addons directory it opens this file to decide what the module is called, which other modules it depends on, which data files it must load, and whether it should appear in the Apps list. Without a valid manifest Odoo will not recognise the directory as a module at all — the module simply does not exist from Odoo's point of view. You never import this file yourself; the Odoo server reads it automatically at startup and on every install or upgrade.

The complete manifest for v19:

{
    "name": "Library Management",
    "summary": "Manage library catalog and book lending.",
    "author": "Daniel Reis",
    "license": "AGPL-3",
    "website": "https://github.com/PacktPublishing",
    "version": "19.0.1.0.0",
    "category": "Services/Library",
    "depends": ["base", "website"],
    "application": True,
    "data": [
        "security/library_security.xml",
        "security/ir.model.access.csv",
        "views/book_view.xml",
        "views/library_menu.xml",
        "views/book_list_template.xml",
    ],
    "demo": [
        "demo/demo.xml",
    ],
    # NEW in v16: JS/CSS/SCSS assets declared here, not in XML templates
    "assets": {
        "web.assets_backend": [
            "library_app/static/src/js/*.js",
            "library_app/static/src/scss/*.scss",
            "library_app/static/src/xml/*.xml",
        ],
        "web.assets_frontend": [
            "library_app/static/src/css/website.css",
        ],
    },
    # NEW in v17: blocks install if Python packages are missing
    "external_dependencies": {
        "python": [],
    },
}
Removed in v16 The qweb manifest key is gone. In v15 you would write "qweb": ["static/src/xml/templates.xml"]. Do not use this key in v19. Move all QWeb/OWL template files into the assets dict under web.assets_backend.
Changed in v19 The license key must be a recognised value. Use one of: LGPL-3, AGPL-3, OPL-1, OEEL-1. Unknown strings will raise an error during installation.
New in v17 Additional manifest keys available:

3.6) Understanding depends

The depends list tells Odoo which other modules must be installed first. This has two practical consequences beyond mere load order:

Tip: Always start with the smallest possible depends list. Add entries only when you get an "External ID not found" or "unknown model" error. Unnecessary dependencies slow down installation and bloat the module's footprint.

3.7) Understanding data File Load Order

Files in the data list are loaded strictly top-to-bottom, one file at a time. A file can only reference records that were already created by a file earlier in the list (or by a dependency module). Getting this order wrong is one of the most common sources of install-time errors.

The most important rule is: security groups before ACL. Your ir.model.access.csv references group XML IDs like library.library_group_user. If the CSV is listed before the XML that defines those groups, Odoo raises "External ID not found" at install time. The correct order is always:

"data": [
    "security/library_security.xml",   # 1. define groups and record rules
    "security/ir.model.access.csv",    # 2. reference those groups
    "views/book_view.xml",              # 3. views reference models (already in DB after Python load)
    "views/library_menu.xml",          # 4. menus reference views
],

The same principle applies to any cross-file reference: a view that inherits another view must come after it; a record that sets a Many2one field pointing to another record must come after that record's file.

Common mistake: Putting ir.model.access.csv first, before the security XML. The CSV tries to look up a group that does not exist yet and raises ValueError: External ID not found in the system: library.library_group_user.
Tip — technical name vs display name: The manifest "name" key is the display name shown in the Apps list and Settings. The technical name is always the directory name on disk. The technical name is what you use in the -i / -u CLI flags (-i library), in Python imports (from odoo.addons.library import ...), and as the prefix in XML IDs (library.library_group_user). These two names can differ — calling -i "Library Management" will fail; you must use the directory name library.

3.8) Manifest Key Reference

KeyDescriptionv19 Notes
nameModule title.Unchanged
summaryOne-line description.Unchanged
authorCopyright holders (comma-separated).Unchanged
licenseDistribution license.Must be a known identifier in v19
websiteURL for docs or issues.Unchanged
versionSemantic version. Prefix with Odoo version: 19.0.1.0.0.Prefix must match installed Odoo version
categoryGroups module in Settings and App Store.Unchanged
dependsRequired addon modules. Must list all dependencies explicitly.Unchanged
applicationTrue to show in Apps list.Unchanged
dataData files (XML, CSV) loaded on install/upgrade.Unchanged
demoDemo data files.Unchanged
installableDefault True. Set False to hide from Apps.Unchanged
auto_installAuto-installs when all dependencies are present (glue modules).Unchanged
assetsJS/CSS/SCSS/OWL template assets. Dict of bundle name → file list.New in v16 — replaces XML asset templates
external_dependenciesPython package requirements.New/enforced in v17+
qwebOld QWeb template paths.Removed in v16 — use assets

3.9) Setting the Module Category

Categories help organise modules. The category name is set in the manifest and supports hierarchy, e.g. Services/Library. Odoo auto-generates the XML ID from the category path: for Services/Library the XML ID is base.module_category_services_library.

3.10) Choosing a License

3.11) Adding a Description and Icon

For apps (application: True), the description shown on the App Store comes from static/description/index.html — the description manifest key is ignored for apps. A module icon goes at static/description/icon.png.

3.12) Installing a Module

python odoo-bin -c ~/work19/library.conf -d library -i library

3.13) Upgrading Modules

Change TypeAction Required
Adding or changing model fieldsUpgrade (-u)
Changing Python logic / manifestServer restart
Changing XML or CSV filesUpgrade (-u)
When in doubtRestart AND upgrade
python odoo-bin -c ~/work19/library.conf -d library -u library

The --dev=all option is available in all versions including v19 and automates this during development: XML/CSV changes are instantly reloaded; Python changes trigger an automatic server restart.


4) Step 2 Creating a New Application

An app is expected to include characteristic elements beyond a plain addon module:

4.1) Adding a Top Menu Item

Menu items are stored as ir.ui.menu database records, loaded from XML data files. A top-level menu is defined in views/library_menu.xml:

<odoo>
    <menuitem id="menu_library" name="Library" />
</odoo>

How Odoo knows this is a top-level menu — the parent attribute rule

The rule is simple: a <menuitem> with no parent attribute is automatically a top-level entry. Odoo places it directly in the main navigation bar. You do not need any special flag or setting — the absence of parent is the signal.

Every deeper level is created by pointing parent to the XML ID of the menu one level above it. The full three-level chain used in the Library module looks like this:

<!-- Level 1: top menu — no parent attribute -->
<menuitem id="menu_library"
          name="Library" />

<!-- Level 2: section heading — parent points to the top menu -->
<menuitem id="menu_library_catalog"
          name="Catalog"
          parent="menu_library" />

<!-- Level 3: leaf item — opens a view via the action attribute -->
<menuitem id="menu_library_books"
          name="Books"
          parent="menu_library_catalog"
          action="action_library_book" />

Odoo builds the full menu tree at runtime by following these parent chains. A menu with no parent is the root; everything else is a descendant. There is no limit to nesting depth, but in practice three levels is the Odoo convention.

Common mistake: Writing parent="library.menu_library" (with the module prefix) when the parent is defined in the same file. Within a single XML file you can reference local IDs without the module prefix. The module prefix (library.) is only needed when you reference an ID defined in a different file or module.

Why are the menus split across multiple XML files?

The Library module defines its top menu in views/library_menu.xml and the child menus (+ their actions) inside views/book_view.xml. This split is a deliberate Odoo convention, not a technical requirement:

The practical benefit: when you add a second model (e.g. library.member), you create a new views/member_view.xml that also sets parent="menu_library". The top menu file never needs to change. Each model's view file is self-contained and can be added or removed independently.

Tip: In the manifest data list, library_menu.xml must come before book_view.xml, because the child menus in book_view.xml reference the parent ID defined in library_menu.xml. If the order is reversed Odoo raises "External ID not found: menu_library" at install time.
Tip: Menu items are only shown if there are visible submenu items. Lower-level menus that open views are only visible if the user has access rights to the corresponding model.
Visual change in v17 The backend UI was completely redesigned in Odoo 17 with a new rounded, modern theme. On Community Edition, the top menu renders as a compact navigation bar. On Enterprise Edition, it becomes the App Switcher. The XML definition is identical — only the visual rendering changed.

A <menuitem> by itself does nothing when clicked — it is just a label in the navigation bar. The action attribute is what connects a leaf menu item to an actual view. It holds the XML ID of a window action (ir.actions.act_window record), which tells Odoo which model to open, in which view modes, and with what default domain or context.

Top menu vs leaf menu

Not every <menuitem> has an action. Only the leaf items (the ones that actually open something) carry the action attribute. Container items — the top menu and any intermediate section headings — have no action; they exist only to group and nest the leaves beneath them.

<menuitem id="menu_library" name="Library" /> ← no action — container only
<menuitem id="menu_library_catalog" name="Catalog" parent="menu_library" /> ← no action — section heading
<menuitem id="menu_library_books" name="Books"
parent="menu_library_catalog"
action="action_library_book" /> ← has action — opens a view

What an ir.actions.act_window record looks like

The action record is defined in the same view XML file, usually placed just above the <menuitem> that references it:

<record id="action_library_book" model="ir.actions.act_window">
    <field name="name">Books</field>
    <field name="res_model">library.book</field>
    <field name="view_mode">list,form</field>
</record>

<menuitem id="menu_library_books" name="Books"
          parent="menu_library_catalog"
          action="action_library_book" />

Key fields of ir.actions.act_window

FieldWhat it controls
res_modelThe technical name of the model to open (e.g. library.book). Required.
view_modeComma-separated list of view types available: list,form, kanban,form, etc. The first one is the default.
domainOptional filter applied to every record load. E.g. [('active','=',True)].
contextDict passed to the view. Can set default field values: {'default_state': 'draft'}.
nameLabel shown in the breadcrumb and window title bar.

Why the action is defined separately from the menu

Actions are reusable. The same action_library_book can be referenced by multiple menu items, by a button on another form, or called programmatically. Keeping the action separate from the menu item lets you define what to open independently of where to navigate from.

Summary — what happens when a user clicks a leaf menu

User clicks Books in the navigation bar
  │
  ▼
Odoo reads action attribute → action_library_book
  │
  ▼
Loads ir.actions.act_window record → res_model=library.book, view_mode=list,form
  │
  ▼
Renders the list view of library.book (first mode in the list)
  │
  ▼
User can switch to form view by clicking a row

4.2) Adding Security Groups

Before features can be used by regular users, access must be granted through security groups. Security definitions are kept in security/library_security.xml.

Odoo is a multi-user business application. Every installation has users with different roles — accountants, salespeople, warehouse staff, managers, public website visitors. Without access control, any logged-in user could read, edit, or delete any data — including other companies' records, payroll data, or configuration tables.

Access security answers three questions:

  1. Who is allowed to use this feature at all?
  2. What operations can they perform (read / write / create / delete)?
  3. Which specific rows in the database can they see?

Odoo has one mechanism for each question.

Layer 1 — Security Groups (res.groups) → "Who?"

A group is a label attached to users. Groups control UI visibility: menu items, buttons, and fields can be made visible only to certain groups. If a user is not in the right group the menu simply does not appear — they do not even know the feature exists.

implied_ids is the inheritance mechanism. The library module defines three tiers:

All internal users ──in──► base.group_user → can see menu, create/edit books (no delete)

Library User ──implies──► library_group_user → same perms + record rule (active books only)

Library Manager ──implies──► library_group_manager → full access, can delete

Note that library_group_user itself implies base.group_user, and library_group_manager implies library_group_user. This means a manager inherits all user rights automatically — you only define the extra rights at each level.

Layer 2 — Access Control Lists (ir.model.access) → "What operations?"

Even if a user can see a menu, Odoo will refuse the database operation unless an ACL entry explicitly grants it. The four permission columns are perm_read, perm_write, perm_create, perm_unlink.

The library module defines three ACL rows:

If you define ACL rows only for your custom groups and no user is yet assigned to them, the menu will be invisible to everyone — even to admin. Always include a base.group_user row with at least perm_read=1.

Layer 3 — Record Rules (ir.rule) → "Which rows?"

ACLs work at the table level — "you can read the library_book table". Record rules work at the row level — "you can only read rows where active = True".

The module defines one record rule targeting library_group_user:

If a user in library_group_user calls Book.search([]), Odoo silently rewrites it to Book.search([('active','=',True)]) before hitting the database. Inactive books are invisible — even through direct API calls.

Why noupdate="1" on Record Rules?

Record rules are often customised by a system administrator after installation (e.g., restrict users to their own branch's books). Without noupdate="1", every module upgrade would overwrite those customisations. With it, the rule is created once on first install and then left alone.

How the Three Layers Work Together

HTTP request from Maria (in library_group_user)


1. Menu visibility check — base.group_user has perm_read=1 → menu shown ✓


2. ir.model.access check — library_group_user has perm_read=1 → query allowed ✓


3. ir.rule applied — book_user_rule: domain [('active','=',True)] appended


Maria sees only active books. Inactive books invisible to her.

If Maria tries to delete → perm_unlink=0 for library_group_user → blocked at step 2.

A plain internal user (only base.group_user, not assigned to library_group_user) passes steps 1 and 2 but has no record rule applied at step 3 — they can see all books including inactive ones.

Why Not Just Use Admin for Everything?

The Administrator bypasses most security checks. Developers often test only as admin and then deploy — then real users get access errors on every page.

Always test your module logged in as a regular user belonging to the correct group, not as admin.

The test file reflects this: one test case explicitly calls unlink() as a plain user to verify they correctly cannot delete a book.

Summary — Three Layers at a Glance

MechanismFileControlsApplied when
res.groupslibrary_security.xmlWho can see menus / buttons / fieldsUI rendering
ir.model.accessir.model.access.csvWhich CRUD operations are allowed per groupEvery ORM call
ir.rulelibrary_security.xmlWhich rows are visible (silently appended to every query)Every ORM query

ACL rows in this module:

GroupReadWriteCreateDeleteRecord Rule
base.group_user (all internal)none — sees all books
library_group_useractive=True filter applied
library_group_manageractive=True filter (via implied user group)
Removed in v17+ — category_id, users on res.groups and groups_id on res.users in data XML. Do not set any of these in module data XML — all three raise ValueError: Invalid field '...' at install time. The category in Settings comes from the manifest's category key. User-to-group assignment must be done via Settings → Users after installation. The admin user has superuser bypass and does not need explicit group assignment.
<odoo>
  <data>
    <!-- Library User Group -->
    <record id="library_group_user" model="res.groups">
        <field name="name">User</field>
        <field name="implied_ids"
               eval="[(4, ref('base.group_user'))]"/>
    </record>

    <!-- Library Manager Group -->
    <record id="library_group_manager" model="res.groups">
        <field name="name">Manager</field>
        <field name="implied_ids"
               eval="[(4, ref('library_group_user'))]"/>
    </record>

  </data>
</odoo>

This file defines two new rows in the res.groups database table — one for Library Users and one for Library Managers. Each <record> tag creates one group. The id attribute gives it an XML ID so other files can reference it. The name field is the display label visible in Settings → Users & Companies → Groups.

Understanding implied_ids and the eval syntax

implied_ids is the group inheritance field. Setting it on a group means: "anyone assigned to this group automatically also belongs to the listed groups". You never need to assign a user to both groups manually.

The value uses eval because it is Python code, not a plain string. The list [(4, ref('base.group_user'))] breaks down as:

So the full line says: "link this group to the existing Internal User group".

The full inheritance chain

Because library_group_manager implies library_group_user, which in turn implies base.group_user, a single group assignment cascades all the way down:

Assign user to library_group_manager
  │ automatically also member of
  ▼
library_group_user (Library User — can read/write/create, sees only active books)
  │ automatically also member of
  ▼
base.group_user (Internal User — can log in to the backend)

You only define the extra permissions at each level. A Manager inherits everything a User can do, plus the ability to delete records.

Key fields for a group record:

Important: The library_security.xml file must come before menu and view files in the manifest data list. Menus and views reference security groups, and those references must already be defined.
New in v17 Become Superuser: Developer mode now includes a "Become Superuser" option (Settings → Developer Tools → Become Superuser). This bypasses all access checks globally and is useful for testing views before security is fully set up.

5) Step 3 Adding Automated Tests

Programming best practices include having automated tests. This is especially important for Python, where there is no compilation step and syntax errors only surface at runtime. This chapter follows a test-driven development (TDD) approach: write tests first, confirm they fail, then develop the code to make them pass.

5.1) Requirements for Tests

  1. Tests go in a tests/ subdirectory. The test runner discovers this automatically.
  2. Test files must be named starting with test_ and imported from tests/__init__.py. Test classes derive from TransactionCase or another Odoo test base, imported from odoo.tests.common.
  3. Each test method must start with test_. All changes made in a TransactionCase test are rolled back after each test.
Tip: Do not use demo data in tests. If all test data is prepared in setUpClass(), the tests can run in any database, including empty databases or production database copies.

5.2) Adding Test Cases

The test file defines four tests inside the TestBook class:

The tests/__init__.py imports the test module:

from . import test_book

The test file tests/test_book.py — updated for Odoo 19 best practices:

from odoo.tests.common import TransactionCase
from odoo.tests import tagged

@tagged('library', 'post_install', '-at_install')
class TestBook(TransactionCase):

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.Book = cls.env["library.book"]
        cls.book1 = cls.Book.create({
            "name": "Odoo Development Essentials",
            "isbn": "879-1-78439-279-6"
        })

    def test_book_create(self):
        "New Books are active by default"
        self.assertEqual(self.book1.active, True)

    def test_book_name_required(self):
        "Creating a book without a name should raise an error"
        with self.assertRaises(Exception):
            self.Book.create({"name": False})

    def test_check_isbn_valid(self):
        "Check valid ISBN"
        self.assertTrue(self.book1._check_isbn())

    def test_check_isbn_invalid(self):
        "Check invalid ISBN"
        self.book1.isbn = "0000000000000"
        self.assertFalse(self.book1._check_isbn())

What this code does:

Recommended change since v17 Use setUpClass() instead of setUp(). The setUp() method runs before every individual test method, which is slow for large test suites involving database writes. The setUpClass() classmethod runs once per class and is significantly faster. Note: use cls. (not self.) inside setUpClass.
New in v17 — @tagged() decorator. Tags allow fine-grained test selection at runtime with the --test-tags flag. The built-in tag post_install runs the tests after the module is installed. The -at_install prefix (minus sign) means "do not run during install" — only run in the post-install phase.

5.3) Testing Access Security

By default, tests run with the internal __system__ user which bypasses access security. To test security properly, switch to a non-admin user in setUpClass():

@classmethod
def setUpClass(cls):
    super().setUpClass()
    user_demo = cls.env.ref("base.user_demo")
    cls.env = cls.env(user=user_demo)
    cls.Book = cls.env["library.book"]

5.4) Running Tests

# Run all tests for the module (v15 style — still works)
python odoo-bin -c ~/work19/library.conf -u library --test-enable

# Run only tests tagged 'library' (new in v17)
python odoo-bin -c ~/work19/library.conf -u library --test-enable --test-tags=library

# Run a specific test class
python odoo-bin -c ~/work19/library.conf -u library --test-enable --test-tags=library.TestBook

# Run a single test method
python odoo-bin -c ~/work19/library.conf -u library --test-enable --test-tags=library.TestBook.test_book_name_required

The --test-tags selector follows the pattern tag.ClassName.method_name. Each part is optional — you can stop at just the tag or the class. The example above runs only test_book_name_required inside TestBook, which is useful when debugging a failing constraint test without waiting for the full suite to complete.


6) Step 4 Implementing the Model Layer

Models describe and store business object data. A model describes a list of fields and can have specific business logic attached to it. Model classes derive from Odoo base classes; a model maps to a database table, and the Odoo ORM handles all database interactions automatically.

6.1) Creating a Data Model

Python files for models go inside a models/ subdirectory. The models/library_book.py file:

from odoo import api, fields, models

class Book(models.Model):
    _name = "library.book"
    _description = "Book"

    name = fields.Char("Title", required=True)
    isbn = fields.Char("ISBN")
    active = fields.Boolean("Active?", default=True)
    date_published = fields.Date()
    image = fields.Binary("Cover")
    publisher_id = fields.Many2one("res.partner", string="Publisher")
    author_ids = fields.Many2many("res.partner", string="Authors")

    # Automatically called to recompute the display name when name or isbn change.
    @api.depends("name", "isbn")
    def _compute_display_name(self):
        for book in self:
            book.display_name = (
                f"[{book.isbn}] {book.name}" if book.isbn else book.name
            )

_compute_display_name controls the label Odoo shows wherever it references a book — breadcrumbs, Many2one dropdowns, linked records. With this override a book with an ISBN appears as [879-1-78439-279-6] Odoo Development Essentials; without one it falls back to just the title.

Notice there is no display_name = fields.Char(...) declaration in the class. That field is already declared on every Odoo model by the base BaseModel class in the framework. You only override the compute method — adding the field declaration again would be redundant. This is specific to display_name and a few other built-in framework fields. For any field you invent yourself you always declare the field first.

Key model attributes:

Tip — _name and the database table name: Odoo converts the dot to an underscore for the actual PostgreSQL table name: library.book → table library_book. The convention is always <module_name>.<object_name>. The same string is used everywhere you reference this model — in Many2one("library.book"), in the ACL CSV as model_library_book, and in record rules. Omitting _name entirely causes the class to be treated as an abstract mixin — no database table is created.

What models.Model gives you

Writing class Book(models.Model) tells the Odoo framework to treat this class as a persistent data model. You write no SQL — Odoo handles everything automatically:

6.2) Field Types Reference

Field TypeDescriptionv19 Notes
CharShort text stringsUnchanged
BooleanTrue/False valuesUnchanged
DateDate valuesUnchanged
BinaryBinary data such as imagesUnchanged
Many2oneMany-to-one relation (foreign key)Unchanged
Many2manyMany-to-many relation (junction table)Unchanged
One2manyOne-to-many relation (inverse of Many2one)Unchanged
TextMulti-line textUnchanged
HtmlRich-text HTML contentUnchanged
IntegerInteger numbersUnchanged
FloatFloating point numbersUnchanged
MonetaryCurrency amountsUnchanged
SelectionDropdown list of choicesUnchanged
JsonNative JSON data (PostgreSQL jsonb)New in v18
Note: relational fields (Many2one, Many2many, One2many) do not store a value directly — they store a reference (a foreign key) that points to a record in another table. For example, publisher_id = fields.Many2one("res.partner") stores the ID of a row in the res_partner table. res.partner is Odoo's built-in model for all contacts — companies, individuals, customers, and suppliers. By pointing publisher and authors to it, your module reuses the existing contact database instead of duplicating names and addresses.

6.3) Common Field Attributes

Every field type accepts keyword arguments that control its behaviour. These are the most commonly used ones:

AttributeWhat it doesExample
stringLabel shown in the UI. If omitted, Odoo derives the label from the field name.string="Title"
requiredCannot be left empty. Enforced at ORM level — raises a validation error on save.required=True
defaultValue set when a new record is created. Can be a fixed value or a callable.default=True  /  default=lambda self: self.env.user
readonlyField is shown but cannot be edited in the UI. Does not prevent programmatic writes.readonly=True
helpTooltip text shown when the user hovers over the field label in a form view.help="International Standard Book Number"
indexCreates a database index on the column, speeding up searches on this field.index=True
copyWhether the value is copied when the record is duplicated. Default True for most fields, False for One2many.copy=False
trackingLogs changes to this field in the chatter (requires mail.thread in depends).tracking=True

6.4) Custom Search Logic

When is it called? When the user types in the Books search bar without selecting a specific filter first. In that case Odoo searches by display_name and goes through this method. Without the override, typing an ISBN number in the general search bar would return no results. This override makes Odoo also check the isbn column so the book is found either way.

Note: when the user clicks a specific filter — e.g. Search by: ISBN — Odoo queries the isbn column directly and this method is not involved.

def _search_display_name(self, operator, value):
    # Only intercept text-based searches (ilike = contains, = exact match)
    if operator in ("ilike", "=") and value:
        # Search the isbn field with whatever the user typed
        isbn_matches = self.search([("isbn", operator, value)])
        if isbn_matches:
            # ISBN match found — return those records immediately
            return [("id", "in", isbn_matches.ids)]
    # No ISBN match — fall back to the default title search
    return super()._search_display_name(operator, value)

The method receives the operator (ilike for a contains-search, = for exact match) and the value the user typed. It first tries to match against isbn. If that finds something it returns those record IDs immediately — Odoo stops there and shows them in the results. If nothing matched by ISBN, super() runs the default logic which searches by title.

Practical result: Typing 879-1-784 in the search bar finds the book by ISBN; typing Odoo falls through to the title search.

6.5) Registering the Model File

The models/__init__.py must import the new file:

from . import library_book

And the top-level __init__.py must import models:

from . import models

7) Step 5 Setting Up Access Security

Before the Library app features can be used by regular users, access rules must be defined. Odoo uses Access Control Lists (ACLs) to grant permissions on models to security groups.

7.1) Adding Access Control Rules

ACL data is stored in the ir.model.access model. The most convenient way to provide it is through a CSV file: security/ir.model.access.csv:

id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_book_internal,BookInternal,model_library_book,base.group_user,1,1,1,0
access_book_user,BookUser,model_library_book,library.library_group_user,1,1,1,0
access_book_manager,BookManager,model_library_book,library.library_group_manager,1,1,1,1

Field explanations:

The first line gives all internal users (base.group_user) read/write/create access — this ensures the menu and New button are visible without requiring manual group assignment after install. Library-specific groups add no extra permissions here but can be used for finer-grained record rules. Library managers get full access including delete.

Important — menu visibility in Odoo 17+ A top-level menu is hidden if the user has no read access to the model the action opens. If you only define ACL rows for custom groups (and no user is assigned to those groups), the menu will be invisible to everyone including the admin. Always include a base.group_user row with at least perm_read=1 so the menu appears for all internal users.
Important: The admin user also follows access security rules. Always test with a regular user account as well — testing only with admin can hide missing permissions.

7.2) Row-Level Access Rules (Record Rules)

Record Rules (ir.rule) provide row-level filtering — limiting which individual records a group can see. A record rule is placed inside a <data noupdate="1"> block so it is created on install but never overwritten on upgrades:

<data noupdate="1">
  <record id="book_user_rule" model="ir.rule">
    <field name="name">Library Book User Access</field>
    <field name="model_id" ref="model_library_book"/>
    <field name="domain_force">[('active', '=', True)]</field>
    <field name="groups" eval="[(4, ref('library_group_user'))]"/>
  </record>
</data>

The domain_force field uses Odoo's domain filter syntax. Here, library users can only see active books.

New in v17 — automatic company rules. For multi-company setups, add _check_company_auto = True and a company_id field to the model. Odoo will automatically generate the required record rules:
class Book(models.Model):
    _name = "library.book"
    _description = "Book"
    _check_company_auto = True     # auto-generates company record rules

    company_id = fields.Many2one(
        "res.company",
        default=lambda self: self.env.company
    )

7.3) Security Quick Reference

Internal System Models

ModelPurposeKey Fields
res.groupsSecurity groupsname, implied_ids, users
res.usersUsersname, groups_id
ir.model.accessAccess control (ACL)name, model_id, group_id, perm_read, perm_write, perm_create, perm_unlink
ir.ruleRecord rules (row-level)name, model_id, groups, domain_force
ir.module.categoryModule categories for groupsname, sequence

Key XML IDs for Security Groups

XML IDDescription
base.group_userInternal user — any backend user
base.group_systemSettings — the Administrator belongs here
base.group_no_oneTechnical feature, hides items from users
base.group_publicPublic — anonymous website visitors

Key XML IDs for Default Users

XML IDDescription
base.user_rootRoot system superuser (OdooBot)
base.user_adminDefault administrator
base.default_userTemplate for new backend users
base.default_public_userTemplate for new portal/public users

8) Step 6 Implementing the Backend View Layer

The view layer describes the UI. Views are defined in XML, used by the web client to generate data-aware HTML. The three most commonly used view types are: List, Form, and Search.

8.1) Creating a Form View

A form view is the single-record editing screen — the page that opens when a user clicks on one row in a list. It displays all the fields of one record and lets the user read or edit them. Every field shown in the form is directly bound to a column in the database table: reading the form reads from the database, saving the form writes back to it. There is no intermediate mapping step — Odoo's ORM handles the translation automatically.

Views are data records stored in ir.ui.view. The file views/book_view.xml defines the form view using Odoo's business document style with <header> and <sheet> sections:

<odoo>
  <record id="view_form_book" model="ir.ui.view">
    <field name="name">Book Form</field>
    <field name="model">library.book</field>
    <field name="arch" type="xml">
      <form string="Book">
        <header>
          <!-- invisible attribute uses direct Python expression (v19) -->
          <button name="button_check_isbn" type="object"
                  string="Check ISBN"
                  invisible="not isbn"/>
        </header>
        <sheet>
          <group name="group_top">
            <group name="group_left">
              <field name="name"/>
              <field name="author_ids" widget="many2many_tags"/>
              <field name="publisher_id"/>
              <field name="date_published"/>
            </group>
            <group name="group_right">
              <!-- required attribute uses direct expression (v19) -->
              <field name="isbn" required="active"/>
              <field name="active"/>
              <field name="image" widget="image"/>
            </group>
          </group>
        </sheet>
      </form>
    </field>
  </record>
</odoo>

8.2) Form Structure — <header> and <sheet>

Every Odoo business document form is divided into two structural sections:

Both are optional, but using them is the standard convention for any form that represents a business document (invoices, orders, records). Forms that are pure configuration dialogs sometimes omit them.

8.3) Two-Column Layout with Nested <group>

A single <group> inside a <sheet> renders all its fields as one column. To get the standard Odoo two-column layout, nest two <group> elements side by side inside a parent <group>:

<group name="group_top">          <!-- outer: splits into two columns -->
  <group name="group_left">        <!-- left column -->
    <field name="name"/>
    <field name="author_ids"/>
  </group>
  <group name="group_right">       <!-- right column -->
    <field name="isbn"/>
    <field name="active"/>
  </group>
</group>

The name attribute on a group is optional for rendering, but strongly recommended: it is the anchor point for other modules to inject fields into your form via view inheritance without breaking the layout.

8.4) Conditional Attributes — attrs Removed in v19

REMOVED in v19 — attrs dictionary conditionals. This is the most impactful view change. Every attrs="{'invisible': [...], 'readonly': [...], 'required': [...]}" must be rewritten using direct Python expression syntax.
Propertyv15 syntax (removed)v19 syntax (required)
Invisible attrs="{'invisible': [('active','=',False)]}" invisible="not active"
Readonly attrs="{'readonly': [('state','=','done')]}" readonly="state == 'done'"
Required attrs="{'required': [('active','=',True)]}" required="active"
Multiple conditions attrs="{'invisible': [('a','=',1),('b','=',2)]}" invisible="a == 1 and b == 2"
OR condition attrs="{'invisible': ['|',('a','=',1),('b','=',2)]}" invisible="a == 1 or b == 2"
Expression syntax rule: The value is a plain Python boolean expression where field names resolve to their current record values. No brackets, no tuples — just Python conditions.

8.5) Adding a List View

A list view is the multi-record overview screen — the table that appears when a user opens a menu item or navigates back from a form. Each row is one database record and each column maps directly to a field. The list view is always the first screen the user sees, which makes column selection important: show what helps the user identify and compare records at a glance, hide the rest behind optional.

Changed in v17 The list view tag is now <list> instead of <tree>. Also, the optional attribute lets users toggle column visibility.
<record id="view_list_book" model="ir.ui.view">
  <field name="name">Book List</field>
  <field name="model">library.book</field>
  <field name="arch" type="xml">
    <list>
      <field name="name"/>
      <field name="author_ids" widget="many2many_tags" optional="show"/>
      <field name="publisher_id" optional="show"/>
      <field name="date_published" optional="hide"/>
      <field name="isbn" optional="hide"/>
    </list>
  </field>
</record>

The optional="show" attribute makes the column visible by default but allows users to hide it. optional="hide" hides the column by default.

Default view: When a window action lists view_mode="list,form", Odoo opens the list view first because it appears first in the comma-separated value. If no explicit view record exists for a model, Odoo generates a minimal default one automatically — every field in declaration order, no optional, no widgets. Defining your own view record overrides this default completely for that view type.

8.6) Adding a Search View

A search view is the invisible third view type — it has no rows, no columns, and never displays records directly. Instead it controls the search bar at the top of the list view: which fields the user can search by, which quick-filter buttons are available, and which Group By options appear. Every list or kanban view silently pairs with a search view in the background.

What makes the search view unique is that it operates entirely on the query, not the result. A <field> element adds a searchable field to the dropdown. A <filter> with a domain pre-applies a WHERE clause. A <filter> with a context={'group_by': ...} groups the result set. None of these affect the record data — they only change what the ORM fetches. If no search view is defined for a model, Odoo generates a minimal default that searches only the name field.

Search views define which fields are searched when the user types, and provide predefined filter options:

<record id="view_search_book" model="ir.ui.view">
  <field name="name">Book Filters</field>
  <field name="model">library.book</field>
  <field name="arch" type="xml">
    <search>
      <field name="name"/>
      <field name="isbn"/>
      <field name="publisher_id"/>
      <filter name="filter_active" string="Active"
              domain="[('active','=',True)]"/>
      <filter name="filter_inactive" string="Inactive"
              domain="[('active','=',False)]"
              context="{'active_test': False}"/>
      <separator/>
      <filter name="group_publisher" string="Publisher"
              context="{'group_by': 'publisher_id'}"/>
    </search>
  </field>
</record>

Every <filter> element must have a unique name attribute — this has been required since Odoo 12 and remains true in v19.

Tip — domain syntax: A domain is a list of conditions written as Python-style tuples: [('field', 'operator', value)]. Each tuple is (field_name, comparison_operator, value). Multiple conditions are ANDed by default. Common operators: =, !=, >, <, >=, <=, in, not in, like, ilike (case-insensitive contains). Use a prefix '|' before two conditions for OR, or '&' for explicit AND: ['|', ('active','=',True), ('isbn','!=',False)]. An empty list [] means no restriction — all records match.
Removed in v17 — <group> wrapper inside <search> In Odoo 15/16 you would wrap Group By filters in <group expand="0" string="Group By">. This wrapper was removed in Odoo 17 — it raises Invalid view definition at install. The UI now automatically separates Filter and Group By items based on whether the filter uses domain or context={'group_by': ...}. Use <separator/> to add a visual divider between sections.
active_test context: Models with an active field have an implicit active=True filter applied by the ORM on every query. Without context="{'active_test': False}" on the Inactive filter, the filter appears to do nothing — the ORM overrides the domain before it reaches the database.

8.7) The context Attribute on Filters

Filters can do one of two things: restrict the records shown (domain), or change how the ORM queries and displays data (context). The context attribute injects key-value pairs into the query context when the filter is active:

Context keyEffect
{'group_by': 'publisher_id'} Groups results by the given field — turns the filter into a Group By option in the search dropdown
{'active_test': False} Disables the ORM's implicit active=True filter so archived records are included in results
{'default_publisher_id': 1} Pre-fills the publisher_id field when creating a new record from this filtered view

A filter can have both domain and context at the same time. Odoo's UI automatically places filters that use only context={'group_by': ...} in the "Group By" section of the search dropdown — no extra markup needed.

8.8) How <field> Behaves Differently in a Search View

A <field> element means something completely different depending on which view type it appears in:

View typeWhat <field name="name"/> does
Form view Renders an editable input widget for that field on the screen
List view Renders a column in the table for that field
Search view Adds a "Search by Title" option in the search bar dropdown — no input is shown until the user selects it. When selected, the typed text is applied as a filter on that field.

In a search view, omitting a field doesn't hide it — it just means the user cannot search by that field from the dropdown. The list of <field> elements in a search view defines the searchable fields menu, not the visible columns.

8.9) Adding Menu Items and Window Actions

At this point the library module already has the top-level "Library" entry in the navigation bar — that was defined in section 4 as a bare <menuitem id="menu_library" name="Library"/> with no parent attribute. What is missing is the child item that actually opens the book records. The menu hierarchy for the library module is:

<!-- Level 1 — already created in section 4 -->
Library   (menu_library)
  └── <!-- Level 2 — added here -->
      Books   (menu_library_book)  →  opens the Book list/form
Note: Odoo has several action types. Window actions (ir.actions.act_window) are the most frequent — they present views in the web client. Report actions (ir.actions.report) run PDF/HTML reports. Server actions (ir.actions.server) define automated tasks such as sending e-mails or executing Python code. In this tutorial we focus on window actions because they are what you need to connect a menu item to a view.

A leaf menu item cannot open a view on its own — it must point to a window action (ir.actions.act_window) that tells Odoo which model to open and in which view modes. The action is defined first, then the menu item references it via the action attribute. Edit views/library_menu.xml:

<!-- Action to open the Book list -->
<record id="action_library_book" model="ir.actions.act_window">
  <field name="name">Library Books</field>
  <field name="res_model">library.book</field>
  <field name="view_mode">list,form</field>
</record>

<!-- Menu item to open the Book list -->
<menuitem id="menu_library_book"
  name="Books"
  parent="menu_library"
  action="action_library_book"
/>

Each field in the window action record has a specific purpose:

FieldValueMeaning
name Library Books The label shown in the breadcrumb bar when the view is open
res_model library.book The model whose records this action opens — must match _name on the Python class
view_mode list,form Comma-separated list of allowed view types in display order. The first entry is the one opened initially. Switching between them uses the view-type icons in the top-right corner.
Changed in v17 Use view_mode="list,form" instead of tree,form. The tree keyword still works for backward compatibility but list is the current standard name.
Tip — res_model vs model: The window action uses <field name="res_model"> while view records use <field name="model">. Both point to the same thing — the technical name of the model — but each record type uses its own field name. res_model is the field on ir.actions.act_window; model is the field on ir.ui.view.

8.10) Button Styling

Changed in v17 The old class="oe_highlight" CSS class for primary buttons is replaced by Bootstrap-compatible class="btn-primary":
<button name="button_check_isbn" type="object"
        string="Check ISBN"
        class="btn-primary"
        invisible="not isbn"/>
Tip — button type values: The type attribute controls what name refers to. Use type="object" to call a Python method on the model (most common). Use type="action" to trigger a window or client action by its XML ID. Use type="url" to open an external URL in the browser. Using the wrong type is a common mistake — if a button does nothing when clicked, check type first.

9) Step 7 Implementing the Business Logic Layer

The business logic layer supports business rules such as validations and automation. Python methods added to the model class implement this behavior.

9.1) Adding Business Logic — ISBN Validation

Modern ISBNs have 13 digits; the last is a check digit computed from the first 12. The _check_isbn() method on the Book class validates this:

def _check_isbn(self):
    self.ensure_one()
    digits = [int(x) for x in self.isbn if x.isdigit()]
    if len(digits) == 13:
        ponderations = [1, 3] * 6
        terms = [a * b for a, b in zip(digits[:12], ponderations)]
        remain = sum(terms) % 10
        check = 10 - remain if remain != 0 else 0
        return digits[-1] == check
    return False

Line by line:

9.2) Connecting Logic to a Form Button

The Check ISBN button defined in the form view (see section 8 — Creating a Form View) uses type="object" and name="button_check_isbn". When the user clicks it, Odoo calls the Python method with that exact name on the current record. The method's job is to be the user-facing entry point: it iterates over the recordset, delegates the actual math to _check_isbn(), and raises a ValidationError with a human-readable message if something is wrong. Unlike the constraint, this method runs only on demand — it gives the user immediate feedback while they are still editing, before a save occurs.

from odoo.exceptions import ValidationError

def button_check_isbn(self):
    for book in self:
        if not book.isbn:
            raise ValidationError(
                f"Please provide an ISBN for {book.name}")
        if not book._check_isbn():
            raise ValidationError(
                f"{book.isbn} is not a valid ISBN-13")
    return True

Key points:

9.3) Why Three Methods for One Rule?

The ISBN validation is split across three methods on purpose — each one has a different trigger and a different role:

MethodTriggered byReturns
_check_isbn() Other methods — not called directly by Odoo True / False (pure computation)
button_check_isbn() User clicking the button on the form True, or raises ValidationError
_check_isbn_constraint() Odoo automatically on every create / write None, or raises ValidationError

This separation keeps each method focused: _check_isbn() is a reusable helper that knows only the math; the button and the constraint each call it and decide what to do with the result.

9.4) Constraints (@api.constrains)

Model-level constraints using the @api.constrains decorator are unchanged in v19:

@api.constrains("isbn")
def _check_isbn_constraint(self):
    for book in self:
        if book.isbn and not book._check_isbn():
            raise ValidationError(
                f"The ISBN {book.isbn} is not valid."
            )

Constraints run automatically on every save (both create and write). The button, by contrast, only runs when the user explicitly clicks it. That is why both exist: the constraint prevents invalid data from ever entering the database, while the button gives immediate on-demand feedback while the user is still filling in the form.

Tip — common Odoo method decorators: A decorator is placed directly above a function definition and modifies its behavior without changing the function's own code. In Odoo, api decorators tell the framework when to call a method automatically and how to treat self inside it — you write the logic once and the decorator decides the trigger. For example, a method decorated with @api.constrains('isbn') will be called automatically every time a record is saved and the isbn field was part of the write; a method decorated with @api.depends('name', 'isbn') will be scheduled for recomputation every time either of those fields changes.
DecoratorWhen it firesTypical use
@api.constrains('field')Every create and write that touches the listed fieldsValidate field values; raise ValidationError on bad data
@api.depends('field')When any of the listed fields changeMark a computed field for recomputation
@api.onchange('field')When the user edits the field in a form (client-side, before save)Update related fields live in the UI; show warnings
@api.modelNot record-specific — self is the model class, not a recordsetOverride create(), implement class-level helpers

10) Step 8 Implementing the Website UI

Odoo provides a web development framework for building website features closely integrated with backend apps. This step creates a web page displaying a list of active books at the /library/books URL.

10.1) Web Controllers

Web controllers are Python methods in an http.Controller-derived class, bound to URL endpoints using the @http.route decorator.

Why not use the backend list view?

The library module already has a list view defined in section 8 — so why does section 10 need a controller and a separate template for the same data? Because they are two completely different rendering systems that serve different audiences:

Backend views (section 8)Web controller + QWeb (section 10)
Rendered byOdoo JavaScript client running in the browserPython on the server — returns plain HTML
Requires loginYes — user must be authenticatedNo — works with auth='public'
URL pattern/odoo/... backend routesAny URL you define in @http.route
Typical audienceInternal staff using the backendPublic website visitors

When a browser requests /library/books, Odoo's web server looks up the registered route, calls the controller method, which queries the database, passes the data to a QWeb template, and returns the rendered HTML page. The backend list view is never involved — it only works inside the Odoo JS app shell, typically at /odoo/library-books, and requires a logged-in session. Both can coexist for the same model: an admin uses the backend list view; a public visitor uses the website page at /library/books.

How a controller works

Think of a controller as the bridge between a browser request and the database. When a visitor opens /library/books in their browser, Odoo's web server receives the HTTP request, looks up which method is registered for that URL, calls it, and sends the returned HTML page back to the browser. The controller method is where you query the database, prepare data, and choose which template to render — exactly the same job a view function does in Flask or Django.

First, add controllers to the top-level imports in library/__init__.py:

from . import models
from . import controllers

Then create controllers/__init__.py:

from . import main

The controller in controllers/main.py:

from odoo import http

class Books(http.Controller):

    @http.route("/library/books", auth="public", website=True)
    def list(self, **kwargs):
        Book = http.request.env["library.book"]
        books = Book.sudo().search([])
        return http.request.render(
            "library.book_list_template",
            {"books": books}
        )

    @http.route("/library/book/<int:book_id>", auth="public", website=True)
    def detail(self, book_id, **kwargs):
        book = http.request.env["library.book"].sudo().browse(book_id)
        if not book.exists() or not book.active:
            return http.request.not_found()
        return http.request.render(
            "library.book_detail_template",
            {"book": book}
        )

The Books controller registers two publicly accessible routes:

Both routes use auth="public", making them accessible without login — intentional for a public catalogue.

Both routes explicitly pass auth="public" — this is required since v16. The auth parameter is the key decision point for every route you write, because it controls who can reach the URL and whether the ORM environment carries identity:

Required since v16 You must explicitly set the auth parameter. In v15, omitting auth used permissive defaults. In v16+ this is enforced. Use auth='public' for pages accessible without login, auth='user' for pages that require a logged-in user.
ParameterDescription
auth='public'Accessible to anyone (logged in or anonymous). The ORM environment runs as the built-in public user who has no access rights by default — always use .sudo() for ORM calls.
auth='user'Requires login. Redirects to /web/login if not authenticated. The ORM env carries the logged-in user's identity and access rights, so no .sudo() bypass is normally needed. The route still renders a website QWeb template — not a backend form or list view. Backend views are a separate system that only activates through the Odoo JSON-RPC interface (/web#action=…), not through HTTP route controllers.
auth='none'No authentication check. Use only for completely open endpoints.
website=TrueWraps the response in the website layout (header, footer, theme).

10.2) Adding a QWeb Template

QWeb is Odoo's XML-based templating engine for rendering HTML pages. Unlike Jinja2 or Django templates that use {{ variable }} syntax, QWeb embeds logic through XML attributes — t-foreach, t-if, t-out — on standard HTML tags, keeping templates valid HTML throughout.

Add views/book_list_template.xml:

<odoo>
  <template id="book_list_template" name="Book List">
    <t t-call="website.layout">
    <div id="wrap" class="container mt-4">
      <h1 class="mb-4">Library Books</h1>
      <t t-if="not books">
        <p class="text-muted">No books are currently available.</p>
      </t>
      <t t-foreach="books" t-as="book">
        <div class="row border-bottom py-2">
          <div class="col-md-5"><strong><span t-field="book.name"/></strong></div>
          <div class="col-md-3"><span t-field="book.publisher_id"/></div>
          <div class="col-md-2"><span t-field="book.date_published"/></div>
          <div class="col-md-2"><span t-out="book.isbn or ''"/></div>
        </div>
      </t>
    </div>
    </t>
  </template>
</odoo>
<template> vs <record model="ir.ui.view">: In section 8, backend views were declared with the verbose <record model="ir.ui.view"> syntax. Website QWeb templates use the shorthand <template> element — Odoo expands it into the same ir.ui.view record automatically. The id attribute becomes the XML ID passed to http.request.render() as "module_name.template_id".
REMOVED in v19 — t-esc and t-raw. These attributes are completely removed. Replace t-esc with t-out. Replace t-raw with t-out plus a widget option.
v15 attributev19 replacementNotes
t-esc="value" t-out="value" t-out auto-escapes HTML — same safety as t-esc
t-raw="value" t-out="value" t-options="{'widget': 'html'}" Use the html widget to render unescaped HTML content
t-field="record.field" t-field="record.field" Unchanged — still the preferred way to render Odoo record fields
Rule of thumb for v19 templates: Use t-field for Odoo record fields (handles formatting, widgets, localization). Use t-out for Python expressions. Never use t-esc or t-raw.

The detail template — views/book_detail_template.xml

The controller's detail route extracts book_id from the URL (/library/book/<int:book_id>), fetches the matching record, and then calls http.request.render("library.book_detail_template", {"book": book}) — this is the template it expects. Create views/book_detail_template.xml:

<odoo>
  <template id="book_detail_template" name="Book Detail">
    <t t-call="website.layout">
    <div id="wrap" class="container">
      <h1 t-field="book.name"/>
      <dl>
        <dt>ISBN</dt>       <dd><span t-out="book.isbn or 'N/A'"/></dd>
        <dt>Published</dt> <dd><span t-field="book.date_published"/></dd>
        <dt>Publisher</dt> <dd><span t-field="book.publisher_id"/></dd>
      </dl>
      <a href="/library/books">Back to list</a>
    </div>
    </t>
  </template>
</odoo>
What is website.layout? It is a QWeb template defined by the website module that wraps your content in the full site chrome — the navigation bar, footer, SEO meta tags, and any theme styles. Calling it with <t t-call="website.layout"> is the equivalent of a Django {% extends "base.html" %}: everything inside the <t> tag becomes the page body, and website.layout injects the surrounding structure. Without this call the route still works but returns a bare HTML fragment with no header or footer.

Once both templates are written, register them in the manifest so Odoo loads them at startup — also ensure website is listed in depends:

"depends": ["base", "website"],
"data": [
    "security/library_security.xml",
    "security/ir.model.access.csv",
    "views/book_view.xml",
    "views/library_menu.xml",
    "views/book_list_template.xml",
    "views/book_detail_template.xml",
],

10.3) JavaScript Frontend — OWL Components

Major change since v16 The legacy Widget system is replaced by OWL (Odoo Web Library), a reactive component framework similar to Vue.js. Any custom JavaScript widget must be rewritten as an OWL component using ES Modules.
This code is not in the module. The library module built in this chapter has no static/ directory and no JavaScript files. The example below shows how you would add a live ISBN field widget if you wanted to extend the module — it is illustrative, not part of the running codebase.

A useful extension would be a custom OWL field widget for the ISBN field. The constraint added in section 9 validates the ISBN on save — but it only fires when the user clicks Save. A custom field widget adds live feedback inside the form field as the user types: it shows a green checkmark while the ISBN is valid and a red indicator while it is not, before any save happens. This is purely a UI enhancement; the server-side constraint remains the authoritative check.

Create static/src/js/isbn_widget.js:

// library/static/src/js/isbn_widget.js
import { Component, useState } from "@odoo/owl";
import { registry }            from "@web/core/registry";
import { standardFieldProps }  from "@web/views/fields/standard_field_props";

class IsbnWidget extends Component {
    static template = "library.IsbnWidget";
    static props    = { ...standardFieldProps };

    setup() {
        this.state = useState({ value: this.props.value || "" });
    }

    get isValid() {
        const v = this.state.value.replace(/-/g, "");
        return v.length === 13 && /^\d+$/.test(v);
    }

    onInput(ev) {
        this.state.value = ev.target.value;
        this.props.update(ev.target.value);
    }
}

registry.category("fields").add("isbn_widget", { component: IsbnWidget });

Every OWL component needs a companion XML template. Create static/src/xml/isbn_widget.xml:

<?xml version="1.0" encoding="utf-8"?>
<templates>
  <t t-name="library.IsbnWidget" owl="1">
    <div class="o_field_widget d-flex align-items-center">
      <input t-att-value="state.value" t-on-input="onInput" class="o_input"/>
      <span t-if="isValid"  class="text-success ms-2">&#10003; Valid</span>
      <span t-elif="state.value" class="text-danger ms-2">&#10007; Invalid</span>
    </div>
  </t>
</templates>
OWL template files are not Odoo view XML. They live under static/src/xml/ and use <templates> as the root element with owl="1" on each t-name. Odoo view XML (forms, lists, searches) uses <odoo> as root and is loaded server-side. OWL templates are loaded client-side as JavaScript assets.

To use the widget, add widget="isbn_widget" to the ISBN field in the form view XML (views/book_view.xml):

<field name="isbn" widget="isbn_widget"/>

The form view renderer looks up "isbn_widget" in registry.category("fields") at render time and mounts the OWL component in place of the default text input.

Both files must be declared in the manifest assets key — not in the data key, which is for XML view records only:

"assets": {
    "web.assets_backend": [
        "library/static/src/js/isbn_widget.js",
        "library/static/src/xml/isbn_widget.xml",
    ],
},

11) Exercise — Add a "Number of Pages" Field

Practice everything covered in this chapter by extending the module yourself. The goal is to add a Number of pages field to the library.book model and wire it through every layer: model, view, website template, and a basic validation rule.

Tasks

  1. Model layer — Add an Integer field num_pages to models/library_book.py.
    Hint: fields.Integer(string="Number of Pages", default=0)
  2. Form view — Add the field to views/book_view.xml inside the existing <group> that contains date_published.
  3. List view — Add num_pages as an optional column in the list view (<field name="num_pages" optional="show"/>).
  4. Business logic — Add an @api.constrains method that raises a ValidationError if num_pages is negative.
  5. Website template — Add a row for pages to views/book_detail_template.xml.
  6. Test — Add a test case in tests/test_book.py that verifies saving a book with num_pages=-1 raises ValidationError.

Solution

1. Model field — models/library_book.py

num_pages = fields.Integer(string="Number of Pages", default=0)

2 & 3. Form and list view — views/book_view.xml

<!-- inside the group that has date_published -->
<field name="num_pages"/>

<!-- inside the list view -->
<field name="num_pages" optional="show"/>

4. Constraint — models/library_book.py

@api.constrains("num_pages")
def _check_num_pages(self):
    for book in self:
        if book.num_pages < 0:
            raise ValidationError("Number of pages cannot be negative.")

Ensure ValidationError is imported: from odoo.exceptions import ValidationError

5. Website detail template — views/book_detail_template.xml

<dt>Pages</dt>
<dd><span t-out="book.num_pages or 'Unknown'"/></dd>

6. Test — tests/test_book.py

def test_negative_pages(self):
    with self.assertRaises(ValidationError):
        self.env["library.book"].create({
            "name": "Bad Book",
            "num_pages": -1,
        })

12) Summary

What We Built — Updated for Odoo 19

A Library book catalog application from scratch, covering all essential components of an Odoo module in v19.

5 Most Critical v15 → v19 Breaking Changes

#Whatv15v19
1 View conditionals attrs="{'invisible': [...]}" invisible="expression"
2 Display name def name_get(self) def _compute_display_name(self)
3 Template output t-esc="..." t-out="..."
4 Record creation @api.model def create(self, vals) @api.model_create_multi def create(self, vals_list)
5 Frontend assets XML template inheritance assets key in manifest + OWL ES Modules

Module Upgrade / Restart Rules — Always Remember

Change TypeAction Required
Adding / changing model fieldsUpgrade (-u module_name)
Changing Python logic (including manifest)Server restart
Changing XML or CSV filesUpgrade (-u module_name)
When in doubtRestart AND upgrade

The Library application built in this chapter is used as the base for all subsequent chapters, where each iteration adds another layer of features through extension and inheritance.