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–5 completed,
library.bookmodel with fields, relations, computed fields, and business methods
How Odoo Renders UI — The Full Pipeline
After five lessons of pure Python, it’s time to give our module a face. In Odoo, the UI is defined in XML, not HTML. Let’s understand why and how.
The Pipeline: From Click to Screen
When a user interacts with Odoo, here’s the complete chain:
User clicks a Menu Item
│
▼
Menu triggers a Window Action
│
▼
Action tells the web client: "Show model X with view types [list, form]"
│
▼
Web client asks the server: "Give me the list view for model X"
│
▼
Server finds the view XML, processes it, and sends it as JSON
│
▼
Web client (JavaScript/OWL) renders the view in the browser
│
▼
User sees the page!
As a developer, you define:
- Views (XML) — What the page looks like
- Actions (XML) — What happens when something is clicked
- Menus (XML) — Where the user clicks
The web client handles all the rendering. You never write HTML, CSS, or JavaScript for standard views.
Where XML Files Live
All view XML files go in the views/ directory of your module, and they’re registered in __manifest__.py:
# In __manifest__.py
'data': [
'security/library_security.xml',
'security/ir.model.access.csv',
'views/book_views.xml', # Views for library.book
'views/author_views.xml', # Views for library.author
'views/tag_views.xml', # Views for library.tag
'views/menu_views.xml', # Menu structure (load last!)
],
Order matters! Views must be loaded before menus that reference them. Security files should be loaded before views (because views may use group-based visibility).
Understanding XML Data Files
Before diving into views, let’s understand the XML syntax that Odoo uses for all data files.
The Basic Structure
Every Odoo XML data file follows this structure:
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Your data records go here -->
</odoo>
The tag is the root element. Everything inside is processed by Odoo’s data loader.
The Tag
The tag creates or updates a record in a specific model:
<record id="unique_xml_id" model="target.model">
<field name="field_name">value</field>
<field name="another_field">another value</field>
</record>
Attributes:
id— A unique identifier (called XML ID or External ID). Format:module_name.id(the module prefix is added automatically)model— The Odoo model where this record will be created
Example — creating a view:
<record id="library_book_form" model="ir.ui.view">
<field name="name">library.book.form</field>
<field name="model">library.book</field>
<field name="arch" type="xml">
<!-- View definition goes here -->
</field>
</record>
What’s happening:
- We’re creating a record in the
ir.ui.viewmodel (where all views are stored) id="library_book_form"— The XML ID we can use to reference this view later— The view’s internal name— Which model this view is for— The view’s architecture — the actual XML that defines the UI
The ref Attribute
When a field references another record, you use the ref attribute:
<field name="view_id" ref="library_book_form"/>
<!-- This sets view_id to the record with XML ID 'library_book_form' -->
The eval Attribute
For Python expressions:
<field name="domain" eval="[('state', '=', 'available')]"/>
<field name="context" eval="{'default_state': 'draft'}"/>
<field name="sequence" eval="10"/>
Now let’s use this knowledge to build actual views.
Form View: Building the Book Detail Page
The form view is what users see when they open a single record — like a detail page for editing a book.
Basic Form View Structure
<record id="library_book_form" model="ir.ui.view">
<field name="name">library.book.form</field>
<field name="model">library.book</field>
<field name="arch" type="xml">
<form string="Book">
<!-- The form content goes here -->
</form>
</field>
</record>
The Element
Inside , the element creates the white card that holds the main content:
<form string="Book">
<sheet>
<!-- Main content inside the white card -->
</sheet>
</form>
Without , the form looks plain. With it, you get the standard Odoo card layout with proper padding and styling.
The Element — Layout Columns
is how you organize fields into columns:
<sheet>
<!-- A single group: fields stack vertically, label on left, value on right -->
<group>
<field name="name"/>
<field name="isbn"/>
<field name="author_id"/>
</group>
</sheet>
Nested groups create columns:
<sheet>
<!-- Outer group splits into two columns -->
<group>
<!-- Left column -->
<group string="Basic Info">
<field name="name"/>
<field name="isbn"/>
<field name="author_id"/>
</group>
<!-- Right column -->
<group string="Details">
<field name="pages"/>
<field name="price"/>
<field name="rating"/>
</group>
</group>
</sheet>
This renders as a two-column layout — very common in Odoo forms:
┌─────────────────────────────────────────────────────┐
│ Basic Info │ Details │
│ Title: [The Hobbit ] │ Pages: [310 ] │
│ ISBN: [978-054... ] │ Price: [14.99 ] │
│ Author: [J.R.R. Tol. ] │ Rating: [4.70 ] │
└─────────────────────────────────────────────────────┘
The and Elements — Tabs
For forms with lots of fields, use tabs:
<sheet>
<group>
<!-- Main fields (always visible) -->
<group>
<field name="name"/>
<field name="author_id"/>
</group>
<group>
<field name="state"/>
<field name="price"/>
</group>
</group>
<!-- Tabbed sections -->
<notebook>
<page string="Description" name="page_description">
<field name="synopsis"/>
<field name="description"/>
</page>
<page string="Publication Details" name="page_publication">
<group>
<group>
<field name="isbn"/>
<field name="pages"/>
<field name="cover_type"/>
</group>
<group>
<field name="publisher_id"/>
<field name="date_published"/>
<field name="date_added"/>
</group>
</group>
</page>
<page string="Tags" name="page_tags">
<field name="tag_ids"/>
</page>
<page string="Internal Notes" name="page_notes">
<field name="notes" placeholder="Add internal notes here..."/>
</page>
</notebook>
</sheet>
What this creates:
┌─────────────────────────────────────────────────────┐
│ Title: [The Hobbit ] State: [Available ] │
│ Author: [J.R.R. Tolk. ] Price: [14.99 ] │
├─────────────────────────────────────────────────────┤
│ [Description] [Publication Details] [Tags] [Notes] │
│ │
│ Synopsis: A hobbit goes on an unexpected │
│ adventure with a group of dwarves... │
│ │
│ Full Description: │
│ [Rich text editor with formatting buttons] │
└─────────────────────────────────────────────────────┘
The Image and Title Area
Most Odoo forms show an image on the left and a title at the top:
<sheet>
<field name="cover_image" widget="image" class="oe_avatar"
options="{'preview_image': 'cover_image', 'size': [90, 90]}"/>
<div class="oe_title">
<label for="name"/>
<h1>
<field name="name" placeholder="Book Title"/>
</h1>
</div>
<!-- Rest of the form... -->
</sheet>
What’s happening:
widget="image"— Renders the Binary/Image field as an imageclass="oe_avatar"— Positions the image on the left, like a profile picture- — Styles the title area
— Makes the book name appear as a large headingplaceholder— Shows gray text when the field is emptyField Attributes in XML
You can control field behavior directly in the view XML:
<!-- Basic field --> <field name="name"/> <!-- With placeholder text --> <field name="isbn" placeholder="e.g., 978-0547928227"/> <!-- Readonly based on condition (Odoo 18 syntax) --> <field name="isbn" readonly="state != 'draft'"/> <!-- ISBN is editable only when state is 'draft' --> <!-- Invisible based on condition --> <field name="date_published" invisible="state == 'draft'"/> <!-- Hide published date for draft books --> <!-- Required based on condition --> <field name="isbn" required="state == 'available'"/> <!-- ISBN is required only for available books --> <!-- With a specific widget --> <field name="tag_ids" widget="many2many_tags"/> <!-- Shows tags as colored chips instead of a list --> <!-- With options --> <field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> <!-- Tags with colors from the 'color' field -->Dynamic Visibility in Odoo 18
Odoo 18 simplified how dynamic visibility works. Instead of the old
attrssyntax, you write conditions directly:<!-- Odoo 18 syntax (new and cleaner): --> <field name="isbn" readonly="state != 'draft'"/> <field name="price" invisible="state == 'lost'"/> <group invisible="state == 'draft'" string="Publication"> <field name="date_published"/> </group> <!-- Old Odoo 16/17 syntax (deprecated in 18): <field name="isbn" attrs="{'readonly': [('state', '!=', 'draft')]}"/> Don't use this in Odoo 18! -->The new syntax is much more readable. The expression is evaluated in JavaScript on the client side, so it updates in real-time as the user changes fields.
Form View: Buttons, Statusbar, and Header
The
ElementThe header section sits above the sheet and typically contains:
- A status bar showing the record’s state
- Action buttons for state transitions
<form string="Book"> <header> <!-- Action buttons --> <button name="action_mark_available" type="object" string="Mark Available" class="btn-primary" invisible="state != 'draft'"/> <button name="action_mark_borrowed" type="object" string="Borrow" invisible="state != 'available'"/> <button name="action_mark_returned" type="object" string="Return" class="btn-primary" invisible="state != 'borrowed'"/> <button name="action_mark_lost" type="object" string="Mark as Lost" class="btn-danger" invisible="state not in ('available', 'borrowed')"/> <!-- Status bar --> <field name="state" widget="statusbar" statusbar_visible="draft,available,borrowed"/> </header> <sheet> <!-- ... form content ... --> </sheet> </form>Button Attributes Explained
<button name="action_mark_available" type="object" string="Mark Available" class="btn-primary" invisible="state != 'draft'" confirm="Are you sure you want to make this book available?"/>Attribute Value Meaning nameaction_mark_availableThe Python method to call on the model typeobjectCall a Python method (vs actionfor a window action)stringMark AvailableThe button text classbtn-primaryCSS class — btn-primary(blue),btn-danger(red), or nothing (gray)invisiblestate != 'draft'Only show this button when state is ‘draft’ confirmAre you sure...Show a confirmation dialog before executing How it connects to Python:
When the user clicks the “Mark Available” button:
- The web client sends a request to the server: “Call
action_mark_availableonlibrary.bookrecord X” - Odoo calls
self.action_mark_available()whereselfis the current record - The method we wrote in Lesson 5 runs:
self.state = 'available' - The web client refreshes the form
The Statusbar Widget
<field name="state" widget="statusbar" statusbar_visible="draft,available,borrowed"/>This renders a visual pipeline at the top of the form:
● Draft ──────► ○ Available ──────► ○ Borrowed
widget="statusbar"— Renders as a horizontal pipelinestatusbar_visible— Which states to show as steps (others are hidden but still selectable)- The current state is highlighted with a filled circle
- Users can click on a state to jump to it (if clickable is not disabled)
List (Tree) View: The Book Table
The list view (historically called “tree view” in Odoo) shows records as a table — like a spreadsheet.
Basic List View
<record id="library_book_list" model="ir.ui.view"> <field name="name">library.book.list</field> <field name="model">library.book</field> <field name="arch" type="xml"> <list string="Books"> <field name="name"/> <field name="author_id"/> <field name="isbn"/> <field name="pages"/> <field name="price" widget="monetary"/> <field name="state" decoration-success="state == 'available'" decoration-info="state == 'borrowed'" decoration-danger="state == 'lost'" decoration-muted="state == 'draft'" widget="badge"/> <field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> </list> </field> </record>List View Features
Column decorations — color-code rows or cells:
<!-- Color entire rows based on conditions --> <list decoration-danger="state == 'lost'" decoration-success="state == 'available'" decoration-muted="active == False"> <field name="name"/> <!-- ... --> </list>Decoration Color Use for decoration-successGreen Positive states (available, done, paid) decoration-infoBlue Informational (in progress, borrowed) decoration-warningOrange/Yellow Warnings (expiring soon, low stock) decoration-dangerRed Negative states (lost, overdue, failed) decoration-mutedGray Inactive or secondary items decoration-bfBold Emphasis (not a color, just bold) Editable list — edit inline without opening the form:
<list editable="bottom"> <!-- 'bottom' = new rows added at the bottom --> <!-- 'top' = new rows added at the top --> <field name="name"/> <field name="pages"/> <field name="price"/> </list>Optional columns — user can choose which columns to show:
<list> <field name="name"/> <field name="author_id"/> <field name="isbn" optional="show"/> <!-- Shown by default, can be hidden --> <field name="pages" optional="hide"/> <!-- Hidden by default, can be shown --> <field name="price" optional="show"/> <field name="rating" optional="hide"/> </list>Column width:
<field name="name" width="300px"/> <field name="isbn" width="150px"/>
Search View: Filters, Group By, and Search Fields
The search view defines what appears in the search bar above list and kanban views — the search fields, predefined filters, and group-by options.
Basic Search View
<record id="library_book_search" model="ir.ui.view"> <field name="name">library.book.search</field> <field name="model">library.book</field> <field name="arch" type="xml"> <search string="Search Books"> <!-- Search fields: what the user can type to search --> <field name="name" string="Title"/> <field name="isbn"/> <field name="author_id"/> <field name="publisher_id"/> <field name="tag_ids"/> <!-- Separator between search fields and filters --> <separator/> <!-- Predefined filters: clickable buttons --> <filter name="filter_available" string="Available" domain="[('state', '=', 'available')]"/> <filter name="filter_borrowed" string="Borrowed" domain="[('state', '=', 'borrowed')]"/> <filter name="filter_draft" string="Draft" domain="[('state', '=', 'draft')]"/> <separator/> <filter name="filter_long_books" string="Long Books (500+ pages)" domain="[('pages', '>=', 500)]"/> <filter name="filter_archived" string="Archived" domain="[('active', '=', False)]"/> <!-- Group By options --> <separator/> <group expand="0" string="Group By"> <filter name="group_author" string="Author" context="{'group_by': 'author_id'}"/> <filter name="group_state" string="Status" context="{'group_by': 'state'}"/> <filter name="group_publisher" string="Publisher" context="{'group_by': 'publisher_id'}"/> <filter name="group_cover_type" string="Cover Type" context="{'group_by': 'cover_type'}"/> <filter name="group_published_month" string="Published Month" context="{'group_by': 'date_published:month'}"/> </group> </search> </field> </record>Search Fields Explained
<field name="name" string="Title"/>
When the user types in the search bar, Odoo searches across all
entries. Thenamefield matches the model field, andstringcustomizes the label shown in the search dropdown.For relational fields, Odoo automatically searches the related model:
<field name="author_id"/> <!-- Typing "Tolkien" will search library.author records matching "Tolkien" --> <!-- You can customize which fields to search in the related model: --> <field name="author_id" filter_domain="['|', ('author_id.name', 'ilike', self), ('author_id.email', 'ilike', self)]"/> <!-- 'self' is replaced by whatever the user typed -->Filter Buttons Explained
<filter name="filter_available" string="Available" domain="[('state', '=', 'available')]"/>name— Technical name (used for referencing in code or as default filters)string— The label the user seesdomain— The filter condition applied when clicked
Multiple filters can be active at once:
- Filters in the same group are combined with OR (click both “Available” and “Borrowed” = show both)
- Filters in different groups (separated by
) are combined with AND
Group By Explained
<filter name="group_author" string="Author" context="{'group_by': 'author_id'}"/>This adds a “Group By” option. When selected, the list view groups records by the specified field, showing subtotals and collapsible sections.
Date grouping with granularity:
<filter name="group_published_month" string="Published Month" context="{'group_by': 'date_published:month'}"/> <!-- Options: day, week, month, quarter, year -->Setting a Default Filter
You can set a default filter in the window action:
<record id="library_book_action" model="ir.actions.act_window"> <field name="name">Books</field> <field name="res_model">library.book</field> <field name="view_mode">list,form</field> <field name="context">{'search_default_filter_available': 1}</field> <!-- 'search_default_' + filter name = activate by default --> </record>
Window Actions — Linking Models to Views
A window action is the bridge between menus and views. It tells the web client: “Open this model with these view types.”
Basic Window Action
<record id="library_book_action" 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>What each field means:
Field Value Purpose nameBooksTitle shown in the breadcrumb and window title res_modellibrary.bookThe model to display view_modelist,formView types to show (first one is the default) Window Action with More Options
<record id="library_book_action" model="ir.actions.act_window"> <field name="name">Books</field> <field name="res_model">library.book</field> <field name="view_mode">list,form,kanban</field> <!-- Pre-filter records --> <field name="domain">[('active', '=', True)]</field> <!-- Set default values for new records --> <field name="context">{ 'default_state': 'draft', 'search_default_filter_available': 1, }</field> <!-- Help text shown when no records exist --> <field name="help" type="html"> <p class="o_view_nocontent_smiling_face"> Create your first book! </p> <p> Add books to your library catalog to track availability, authors, and borrowing. </p> </field> </record>The
helpfield: Shows a friendly message with an icon when the list is empty. Theo_view_nocontent_smiling_faceclass adds a smiley icon — a nice Odoo touch.Linking a Specific View to an Action
By default, Odoo picks the view with the lowest priority for each view type. If you want to force a specific view:
<record id="library_book_action" model="ir.actions.act_window"> <field name="name">Books</field> <field name="res_model">library.book</field> <field name="view_mode">list,form</field> <field name="view_ids" eval="[ (5, 0, 0), (0, 0, {'view_mode': 'list', 'view_id': ref('library_book_list')}), (0, 0, {'view_mode': 'form', 'view_id': ref('library_book_form')}), ]"/> </record>Usually you don’t need this — the default auto-detection works fine for most cases.
Menu Items — Building Navigation
Menus create the navigation structure in Odoo’s top bar and sidebar.
Menu Hierarchy
Odoo menus are hierarchical — typically 3 levels deep:
Top Menu (App) → "Library" ├── First-level menu → "Catalog" │ ├── Second-level → "Books" │ └── Second-level → "Authors" └── First-level menu → "Configuration" └── Second-level → "Tags"Creating Menus with
<!-- Level 0: Top menu (the app menu in the top bar) --> <menuitem id="library_root_menu" name="Library" sequence="100"/> <!-- No action — this is just a container --> <!-- sequence controls the order in the top bar --> <!-- Level 1: First submenu (shows in the sidebar when the app is open) --> <menuitem id="library_catalog_menu" name="Catalog" parent="library_root_menu" sequence="10"/> <!-- parent links this to the root menu --> <!-- Level 2: Actual menu items (link to actions) --> <menuitem id="library_book_menu" name="Books" parent="library_catalog_menu" action="library_book_action" sequence="10"/> <!-- action links to the window action that opens the book list/form --> <menuitem id="library_author_menu" name="Authors" parent="library_catalog_menu" action="library_author_action" sequence="20"/> <!-- Level 1: Configuration section --> <menuitem id="library_config_menu" name="Configuration" parent="library_root_menu" sequence="90"/> <menuitem id="library_tag_menu" name="Tags" parent="library_config_menu" action="library_tag_action" sequence="10"/>Menu Attributes
Attribute Purpose Required? idXML ID (unique identifier) Yes nameDisplay text Yes parentID of the parent menu No (root menus have no parent) actionID of the window action to trigger No (container menus have no action) sequenceOrder (lower = first) No (default: 10) groupsSecurity groups that can see this menu No (visible to all by default) Security on Menus
<menuitem id="library_config_menu" name="Configuration" parent="library_root_menu" groups="base.group_system" sequence="90"/> <!-- Only system administrators can see the Configuration menu -->
Hands-on: Create Complete Views and Menus for library.book
Let’s build the complete UI for our library module. We’ll create views for all three models (Book, Author, Tag) plus the menu structure.
Step 1: Create
views/book_views.xml<?xml version="1.0" encoding="UTF-8"?> <odoo> <!-- ============================================ --> <!-- FORM VIEW --> <!-- ============================================ --> <record id="library_book_view_form" model="ir.ui.view"> <field name="name">library.book.form</field> <field name="model">library.book</field> <field name="arch" type="xml"> <form string="Book"> <header> <button name="action_mark_available" type="object" string="Mark Available" class="btn-primary" invisible="state != 'draft'"/> <button name="action_mark_borrowed" type="object" string="Borrow" invisible="state != 'available'"/> <button name="action_mark_returned" type="object" string="Return" class="btn-primary" invisible="state != 'borrowed'"/> <button name="action_mark_lost" type="object" string="Mark as Lost" class="btn-danger" invisible="state not in ('available', 'borrowed')" confirm="Are you sure this book is lost?"/> <field name="state" widget="statusbar" statusbar_visible="draft,available,borrowed"/> </header> <sheet> <!-- Cover image + Title area --> <field name="cover_image" widget="image" class="oe_avatar"/> <div class="oe_title"> <label for="name"/> <h1> <field name="name" placeholder="Enter the book title..."/> </h1> <h3> <field name="author_id" placeholder="Select an author..." options="{'no_create': False}"/> </h3> </div> <!-- Main fields in two columns --> <group> <group string="Book Details"> <field name="isbn" readonly="state != 'draft'" placeholder="e.g., 978-0547928227"/> <field name="pages"/> <field name="cover_type"/> <field name="is_long_book"/> </group> <group string="Pricing & Rating"> <field name="price"/> <field name="price_per_page"/> <field name="rating"/> </group> </group> <group> <group string="Publishing"> <field name="publisher_id"/> <field name="date_published"/> <field name="date_added"/> </group> <group string="Classification"> <field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> <field name="tag_count"/> </group> </group> <!-- Tabbed sections --> <notebook> <page string="Description" name="page_description"> <group> <field name="synopsis" placeholder="Write a brief synopsis..."/> </group> <group> <field name="description" placeholder="Full description with formatting..."/> </group> </page> <page string="Internal Notes" name="page_notes"> <field name="notes" placeholder="Internal notes (not visible to members)..."/> </page> </notebook> </sheet> </form> </field> </record> <!-- ============================================ --> <!-- LIST VIEW --> <!-- ============================================ --> <record id="library_book_view_list" model="ir.ui.view"> <field name="name">library.book.list</field> <field name="model">library.book</field> <field name="arch" type="xml"> <list decoration-danger="state == 'lost'" decoration-muted="active == False" decoration-success="state == 'available'" multi_edit="1"> <field name="name"/> <field name="author_id"/> <field name="isbn" optional="show"/> <field name="pages" optional="hide"/> <field name="price" optional="show"/> <field name="cover_type" optional="hide"/> <field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> <field name="state" widget="badge" decoration-success="state == 'available'" decoration-info="state == 'borrowed'" decoration-danger="state == 'lost'" decoration-muted="state == 'draft'"/> </list> </field> </record> <!-- ============================================ --> <!-- SEARCH VIEW --> <!-- ============================================ --> <record id="library_book_view_search" model="ir.ui.view"> <field name="name">library.book.search</field> <field name="model">library.book</field> <field name="arch" type="xml"> <search string="Search Books"> <field name="name" string="Title"/> <field name="isbn"/> <field name="author_id"/> <field name="publisher_id"/> <field name="tag_ids"/> <separator/> <filter name="filter_available" string="Available" domain="[('state', '=', 'available')]"/> <filter name="filter_borrowed" string="Borrowed" domain="[('state', '=', 'borrowed')]"/> <filter name="filter_draft" string="Draft" domain="[('state', '=', 'draft')]"/> <separator/> <filter name="filter_long_books" string="Long Books (500+ pages)" domain="[('pages', '>=', 500)]"/> <filter name="filter_archived" string="Archived" domain="[('active', '=', False)]"/> <separator/> <group expand="0" string="Group By"> <filter name="group_author" string="Author" context="{'group_by': 'author_id'}"/> <filter name="group_state" string="Status" context="{'group_by': 'state'}"/> <filter name="group_publisher" string="Publisher" context="{'group_by': 'publisher_id'}"/> <filter name="group_cover_type" string="Cover Type" context="{'group_by': 'cover_type'}"/> </group> </search> </field> </record> <!-- ============================================ --> <!-- WINDOW ACTION --> <!-- ============================================ --> <record id="library_book_action" model="ir.actions.act_window"> <field name="name">Books</field> <field name="res_model">library.book</field> <field name="view_mode">list,form</field> <field name="context">{'search_default_filter_available': 1}</field> <field name="help" type="html"> <p class="o_view_nocontent_smiling_face"> Add your first book to the library! </p> <p> Start building your library catalog by adding books with their authors, tags, and details. </p> </field> </record> </odoo>Step 2: Create
views/author_views.xml<?xml version="1.0" encoding="UTF-8"?> <odoo> <!-- Author Form View --> <record id="library_author_view_form" model="ir.ui.view"> <field name="name">library.author.form</field> <field name="model">library.author</field> <field name="arch" type="xml"> <form string="Author"> <sheet> <div class="oe_title"> <label for="name"/> <h1> <field name="name" placeholder="Author name..."/> </h1> </div> <group> <group string="Personal Info"> <field name="email" widget="email"/> <field name="nationality"/> <field name="date_of_birth"/> </group> <group string="Statistics"> <field name="book_count"/> <field name="active"/> </group> </group> <notebook> <page string="Biography" name="page_bio"> <field name="biography" placeholder="Write the author's biography..."/> </page> <page string="Books" name="page_books"> <field name="book_ids"> <list> <field name="name"/> <field name="isbn"/> <field name="state" widget="badge"/> </list> </field> </page> </notebook> </sheet> </form> </field> </record> <!-- Author List View --> <record id="library_author_view_list" model="ir.ui.view"> <field name="name">library.author.list</field> <field name="model">library.author</field> <field name="arch" type="xml"> <list> <field name="name"/> <field name="email"/> <field name="nationality"/> <field name="book_count"/> </list> </field> </record> <!-- Author Search View --> <record id="library_author_view_search" model="ir.ui.view"> <field name="name">library.author.search</field> <field name="model">library.author</field> <field name="arch" type="xml"> <search> <field name="name"/> <field name="email"/> <field name="nationality"/> <filter name="filter_archived" string="Archived" domain="[('active', '=', False)]"/> </search> </field> </record> <!-- Author Action --> <record id="library_author_action" model="ir.actions.act_window"> <field name="name">Authors</field> <field name="res_model">library.author</field> <field name="view_mode">list,form</field> <field name="help" type="html"> <p class="o_view_nocontent_smiling_face"> Add your first author! </p> </field> </record> </odoo>Step 3: Create
views/tag_views.xml<?xml version="1.0" encoding="UTF-8"?> <odoo> <!-- Tag Form View --> <record id="library_tag_view_form" model="ir.ui.view"> <field name="name">library.tag.form</field> <field name="model">library.tag</field> <field name="arch" type="xml"> <form string="Tag"> <sheet> <group> <group> <field name="name"/> <field name="color" widget="color_picker"/> </group> <group> <field name="active"/> </group> </group> <group> <field name="description" placeholder="Describe this tag..."/> </group> </sheet> </form> </field> </record> <!-- Tag List View --> <record id="library_tag_view_list" model="ir.ui.view"> <field name="name">library.tag.list</field> <field name="model">library.tag</field> <field name="arch" type="xml"> <list editable="bottom"> <field name="name"/> <field name="color" widget="color_picker"/> <field name="active"/> </list> </field> </record> <!-- Tag Action --> <record id="library_tag_action" model="ir.actions.act_window"> <field name="name">Tags</field> <field name="res_model">library.tag</field> <field name="view_mode">list,form</field> </record> </odoo>Step 4: Create
views/menu_views.xml<?xml version="1.0" encoding="UTF-8"?> <odoo> <!-- =========================== --> <!-- TOP MENU (App Menu) --> <!-- =========================== --> <menuitem id="library_root_menu" name="Library" sequence="100"/> <!-- =========================== --> <!-- CATALOG SECTION --> <!-- =========================== --> <menuitem id="library_catalog_menu" name="Catalog" parent="library_root_menu" sequence="10"/> <menuitem id="library_book_menu" name="Books" parent="library_catalog_menu" action="library_book_action" sequence="10"/> <menuitem id="library_author_menu" name="Authors" parent="library_catalog_menu" action="library_author_action" sequence="20"/> <!-- =========================== --> <!-- CONFIGURATION SECTION --> <!-- =========================== --> <menuitem id="library_config_menu" name="Configuration" parent="library_root_menu" sequence="90"/> <menuitem id="library_tag_menu" name="Tags" parent="library_config_menu" action="library_tag_action" sequence="10"/> </odoo>Step 5: Update
__manifest__.pyUncomment and add the view files to the
datalist:'data': [ # 'security/ir.model.access.csv', # We'll add this in Lesson 8 'views/book_views.xml', 'views/author_views.xml', 'views/tag_views.xml', 'views/menu_views.xml', # Must be LAST (references actions from above) ],Step 6: Upgrade and Test
# Docker: docker compose exec odoo odoo -d odoo18dev -u library_app --stop-after-init docker compose restart odoo # Source: python odoo/odoo-bin -c odoo.conf -d odoo18dev -u library_app
Now open http://localhost:8069 in your browser.
You should see a “Library” app in the top navigation bar. Click it, and you’ll see:
- Catalog → Books — A list view of books with the “Available” filter active by default
- Click any book → The form view with the status bar, buttons, and tabbed layout
- Catalog → Authors — Authors with their book count and embedded book list
- Configuration → Tags — An editable inline list for managing tags
Troubleshooting
Problem Solution “Library” menu not visible Check __manifest__.py— are the XML files listed indata?“Access Denied” or “You are not allowed to access this model” We haven’t set up security yet (Lesson 8). Temporary fix: use the admin user Button click does nothing Check the button’s namematches the Python method name exactlyView error on upgrade Check XML syntax — missing closing tags, wrong attribute names Fields not showing Make sure the field exists in the Python model and the module is upgraded About the “Access Denied” error: If you’re logged in as admin, you might not see this. But if you log in as a regular user, you’ll get errors because we haven’t created security rules yet. We’ll fix this in Lesson 8. For now, use the admin account for testing.
Summary & What’s Next
Key Takeaways
- Odoo UI is defined in XML, not HTML. Views, actions, and menus are records in the database loaded from XML files.
- Form views use
,,, andfor layout. Theholds buttons and the status bar. - List views show records as tables. Use
decoration-*for row colors,optionalfor hideable columns,editablefor inline editing. - Search views define search fields, predefined filters (
domain), and group-by options (context={'group_by': 'field'}). - Window actions connect models to views. Set
view_mode,domain,context, andhelp. - Menu items create 3-level navigation: Root → Section → Item. Only leaf menus have actions.
- File loading order in
__manifest__.pymatters: security → views → menus. - Odoo 18 syntax for dynamic visibility:
invisible="state != 'draft'"directly on the element (noattrs).
Files Created in This Lesson
File Contains views/book_views.xmlForm, List, Search views + Window Action for books views/author_views.xmlForm, List, Search views + Window Action for authors views/tag_views.xmlForm, List views + Window Action for tags views/menu_views.xmlComplete menu hierarchy What’s Next?
In Lesson 7: Views — Part 2: Advanced Views & Widgets, we’ll add:
- Kanban view — Card-based layout (like Trello)
- Calendar, Pivot, and Graph views — Data visualization
- Activity and Chatter — Communication tracking
- View inheritance — Extending existing views with XPath
Our library module is now usable! In the next lesson, we’ll make it beautiful.
Exercises: 1. Create a simple form and list view for
library.publisher. Add it to the Configuration menu. 2. Try adding themulti_edit="1"attribute to the book list view. This lets users edit multiple records at once by selecting rows and changing a field. 3. Experiment with thewidgetattribute on different fields: –— Makes the email clickable –— Makes the URL clickable –— Shows with currency symbol –— Shows as stars 4. Add a second window actionlibrary_book_borrowed_actionthat only shows borrowed books (pre-filter withdomain). Create a new menu item “Borrowed Books” under Catalog that uses this action. 5. In the book form view, try making the “Publication Details” group invisible when the state is “draft”. Use the Odoo 18 syntax:.
Previous lesson: Lesson 5 — CRUD, Recordsets & Business Logic Next lesson: Lesson 7 — Advanced Views & Widgets
