
Odoo 18
- 1.2k installs
- 118 repo stars
- Updated July 14, 2026
- unclecatvn/agent-skills
odoo-18 provides documented workflows for >
About
The odoo-18 skill Odoo 18 Skill Master Index Master index for all Odoo 18 development guides Read the appropriate guide from references based on your task Quick Reference Topic File When to Use Actions references odoo-18-actions-guide md Creating actions menus scheduled jobs server actions API Decorators references odoo-18-decorator-guide md Using api decorators compute fields validation Controllers references odoo-18-controller-guide md Writing HTTP endpoints routes web controllers Data Files references odoo-18-data-guide md XML CSV data files records shortcuts Development references odoo-18-development-guide md Creating modules manifest reports security wizards Field Types references odoo-18-field-guide md Defining model fields choosing field types Manifest references odoo-18-manifest-guide md __manifest__ py configuration dependencies hooks Migration references odoo-18-migration-guide md Upgrading modules data migration version changes Mixins references odoo-18-mixins-guide md mail thread activities email aliases tracking Model Methods references odoo-18-model-guide md Writing ORM queries CRUD operations domain filters OWL Components references odoo-18-owl-guide md Building OWL.
- `odoo/models.py` - ORM implementation
- `odoo/fields.py` - Field types
- `odoo/api.py` - Decorators
- `odoo/http.py` - HTTP layer
- `odoo/exceptions.py` - Exception types
Odoo 18 by the numbers
- 1,214 all-time installs (skills.sh)
- +22 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #357 of 4,348 Backend & APIs skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 2, 2026 (Skillselion catalog sync)
odoo-18 capabilities & compatibility
- Capabilities
- `odoo/models.py` orm implementation · `odoo/fields.py` field types · `odoo/api.py` decorators · `odoo/http.py` http layer · `odoo/exceptions.py` exception types
- Use cases
- documentation
What odoo-18 says it does
# Odoo 18 Skill - Master Index Master index for all Odoo 18 development guides.
Read the appropriate guide from `references/` based on your task.
npx skills add https://github.com/unclecatvn/agent-skills --skill odoo-18Add your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.2k |
|---|---|
| repo stars | ★ 118 |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 14, 2026 |
| Repository | unclecatvn/agent-skills ↗ |
How do I use odoo-18 for the task described in its SKILL.md triggers?
>
Who is it for?
Teams invoking odoo-18 when the user request matches documented triggers and prerequisites.
Skip if: Skip when cached docs are missing, the request is a negative trigger, or another sibling skill owns the workflow.
When should I use this skill?
>
What you get
Step-by-step guidance grounded in odoo-18 documentation and reference files.
- Odoo module scaffolding guidance
- ORM model patterns
- XML view templates
By the numbers
- Targets Odoo 18.0 release branch
- Installable via npx skills add unclecatvn/agent-skills
Files
Odoo 18 Skill - Master Index
Master index for all Odoo 18 development guides. Read the appropriate guide from references/ based on your task.
Quick Reference
| Topic | File | When to Use |
|---|---|---|
| Actions | references/odoo-18-actions-guide.md | Creating actions, menus, scheduled jobs, server actions |
| API Decorators | references/odoo-18-decorator-guide.md | Using @api decorators, compute fields, validation |
| Controllers | references/odoo-18-controller-guide.md | Writing HTTP endpoints, routes, web controllers |
| Data Files | references/odoo-18-data-guide.md | XML/CSV data files, records, shortcuts |
| Development | references/odoo-18-development-guide.md | Creating modules, manifest, reports, security, wizards |
| Field Types | references/odoo-18-field-guide.md | Defining model fields, choosing field types |
| Manifest | references/odoo-18-manifest-guide.md | __manifest__.py configuration, dependencies, hooks |
| Migration | references/odoo-18-migration-guide.md | Upgrading modules, data migration, version changes |
| Mixins | references/odoo-18-mixins-guide.md | mail.thread, activities, email aliases, tracking |
| Model Methods | references/odoo-18-model-guide.md | Writing ORM queries, CRUD operations, domain filters |
| OWL Components | references/odoo-18-owl-guide.md | Building OWL UI components, hooks, services |
| Performance | references/odoo-18-performance-guide.md | Optimizing queries, fixing slow code, preventing N+1 |
| Reports | references/odoo-18-reports-guide.md | QWeb reports, PDF/HTML, templates, paper formats |
| Security | references/odoo-18-security-guide.md | Access rights, record rules, field permissions |
| Testing | references/odoo-18-testing-guide.md | Writing tests, mocking, assertions, browser testing |
| Transactions | references/odoo-18-transaction-guide.md | Handling database errors, savepoints, UniqueViolation |
| Translation | references/odoo-18-translation-guide.md | Adding translations, localization, i18n |
| Views & XML | references/odoo-18-view-guide.md | Writing XML views, actions, menus, QWeb templates |
File Structure
skills/odoo-18.0/
├── SKILL.md # This file - master index
└── references/ # Development guides
├── odoo-18-actions-guide.md
├── odoo-18-controller-guide.md
├── odoo-18-data-guide.md
├── odoo-18-decorator-guide.md
├── odoo-18-development-guide.md
├── odoo-18-field-guide.md
├── odoo-18-manifest-guide.md
├── odoo-18-migration-guide.md
├── odoo-18-mixins-guide.md
├── odoo-18-model-guide.md
├── odoo-18-owl-guide.md
├── odoo-18-performance-guide.md
├── odoo-18-reports-guide.md
├── odoo-18-security-guide.md
├── odoo-18-testing-guide.md
├── odoo-18-transaction-guide.md
├── odoo-18-translation-guide.md
└── odoo-18-view-guide.mdBase Code Reference (Odoo 18)
All guides are based on analysis of Odoo 18 source code:
odoo/models.py- ORM implementationodoo/fields.py- Field typesodoo/api.py- Decoratorsodoo/http.py- HTTP layerodoo/exceptions.py- Exception typesodoo/tools/translate.py- Translation systemodoo/addons/base/models/res_lang.py- Language modeladdons/web/static/src/core/l10n/translation.js- JS translations
External Documentation
Odoo 18 Documentation - AI Agents Setup
Setup guide for using Odoo 18 documentation with AI coding assistants (Cursor, Claude Code, Windsurf, Aider, etc.).
Quick Start
Install via skills.sh (Recommended)
# Add Odoo 18 skill to your project
npx skills add unclecatvn/agent-skillsVisit https://skills.sh/ for more installation options.
Cursor IDE - Remote Rule
Configure once in Cursor settings:
Settings→Rules→Add Remote Rule- Source:
Git Repository - URL:
git@github.com:unclecatvn/agent-skills.git - Branch:
18.0 - Subfolder:
skills/odoo-18.0/
---
Documentation Structure
skills/odoo-18.0/
├── SKILL.md # Master index (all agents)
├── references/ # Development guides (18 files)
│ ├── odoo-18-actions-guide.md # ir.actions.*, cron, bindings
│ ├── odoo-18-controller-guide.md # HTTP, routing, controllers
│ ├── odoo-18-data-guide.md # XML/CSV data files, records
│ ├── odoo-18-decorator-guide.md # @api decorators
│ ├── odoo-18-development-guide.md # Manifest, wizards (overview)
│ ├── odoo-18-field-guide.md # Field types, parameters
│ ├── odoo-18-manifest-guide.md # __manifest__.py reference
│ ├── odoo-18-mixins-guide.md # mail.thread, activities, etc.
│ ├── odoo-18-model-guide.md # ORM, CRUD, search, domain
│ ├── odoo-18-migration-guide.md # Migration scripts, hooks
│ ├── odoo-18-owl-guide.md # OWL components, services
│ ├── odoo-18-performance-guide.md # N+1 prevention, optimization
│ ├── odoo-18-reports-guide.md # QWeb reports, PDF/HTML
│ ├── odoo-18-security-guide.md # ACL, record rules, security
│ ├── odoo-18-testing-guide.md # Test classes, decorators
│ ├── odoo-18-transaction-guide.md # Savepoints, errors
│ ├── odoo-18-translation-guide.md # Translations, i18n
│ └── odoo-18-view-guide.md # XML views, QWeb
├── CLAUDE.md # Claude Code specific
└── AGENTS.md # THIS FILE - setup guide---
Guide Reference
| File | Purpose | When to Use |
|---|---|---|
SKILL.md | Master index for all guides | Find the right guide for your task |
references/odoo-18-actions-guide.md | Actions (window, URL, server, cron) | Creating actions, menus, scheduled jobs |
references/odoo-18-controller-guide.md | HTTP controllers, routing | Writing endpoints |
references/odoo-18-data-guide.md | XML/CSV data files, records | Creating data files |
references/odoo-18-decorator-guide.md | @api decorators usage | Using @api decorators |
references/odoo-18-development-guide.md | Module structure, wizards | Creating new modules |
references/odoo-18-field-guide.md | Field types, parameters | Defining model fields |
references/odoo-18-manifest-guide.md | __manifest__.py reference | Configuring module manifest |
references/odoo-18-mixins-guide.md | mail.thread, activities, mixins | Adding messaging, activities |
references/odoo-18-model-guide.md | ORM methods, CRUD, domains | Writing model methods |
references/odoo-18-migration-guide.md | Migration scripts, hooks | Upgrading modules |
references/odoo-18-owl-guide.md | OWL components, hooks, services | Building OWL UI |
references/odoo-18-performance-guide.md | Performance optimization | Fixing slow code |
references/odoo-18-reports-guide.md | QWeb reports, templates | Creating reports |
references/odoo-18-security-guide.md | ACL, record rules, security | Configuring security |
references/odoo-18-testing-guide.md | Test classes, decorators, mocking | Writing tests |
references/odoo-18-transaction-guide.md | Database transactions, error handling | Savepoints, UniqueViolation |
references/odoo-18-translation-guide.md | Translations, localization, i18n | Adding translations |
references/odoo-18-view-guide.md | XML views, actions, menus | Writing view XML |
---
AI Agent Configuration
Cursor IDE
| Setting | Value |
|---|---|
| Source | Git Repository |
| URL | git@github.com:unclecatvn/agent-skills.git |
| Branch | 18.0 |
| Subfolder | skills/odoo-18.0/ |
Globs patterns used by Cursor:
| File | globs Pattern |
|---|---|
SKILL.md | **/*.{py,xml} |
references/odoo-18-actions-guide.md | **/*.{py,xml} |
references/odoo-18-controller-guide.md | **/controllers/**/*.py |
references/odoo-18-data-guide.md | **/*.{xml,csv} |
references/odoo-18-decorator-guide.md | **/models/**/*.py |
references/odoo-18-development-guide.md | **/*.{py,xml,csv} |
references/odoo-18-field-guide.md | **/models/**/*.py |
references/odoo-18-manifest-guide.md | **/__manifest__.py |
references/odoo-18-mixins-guide.md | **/models/**/*.py |
references/odoo-18-model-guide.md | **/models/**/*.py |
references/odoo-18-migration-guide.md | **/migrations/**/*.py |
references/odoo-18-owl-guide.md | static/src/**/*.{js,xml} |
references/odoo-18-performance-guide.md | **/*.{py,xml} |
references/odoo-18-reports-guide.md | **/report/**/*.xml |
references/odoo-18-security-guide.md | **/security/**/*.{csv,xml} |
references/odoo-18-testing-guide.md | **/tests/**/*.py |
references/odoo-18-transaction-guide.md | **/models/**/*.py |
references/odoo-18-translation-guide.md | **/*.{py,js,xml} |
references/odoo-18-view-guide.md | **/views/**/*.xml |
Claude Code
# Install via skills.sh
npx skills add unclecatvn/agent-skillsClaude Code reads:
CLAUDE.md- Project overview and quick referenceSKILL.md- Master index for all guides- Individual guides in
references/- Detailed information
Other Agents
| Agent | Setup |
|---|---|
| Windsurf | Same as Cursor (uses .mdc files) |
| Continue | Place CLAUDE.md or SKILL.md in root |
| Aider | Place CLAUDE.md or add to prompt |
| OpenCode | Copy skill folder to project - no additional config needed |
---
Cursor / Claude Skills Folder
After installing via npx skills add unclecatvn/agent-skills, the skill is placed at:
.cursor/skills/
└── odoo-18/
└── SKILL.md
.claude/skills/
└── odoo-18/
└── SKILL.md---
Key Odoo 18 Changes
| Change | Old | New |
|---|---|---|
| List view tag | <tree> | <list> |
| Dynamic attributes | attrs="{'invisible': [...]}" | invisible="..." |
| Delete validation | Override unlink() | @api.ondelete(at_uninstall=False) |
| Field aggregation | group_operator= | aggregator= |
| SQL queries | cr.execute() | SQL class with execute_query_dict() |
---
Repository
URL: git@github.com:unclecatvn/agent-skills.git Branch: 18.0 License: MIT
Odoo 18 Development Guide
This file provides guidance to AI agents when working with Odoo 18 code in this repository.
For setup instructions with different AI IDEs, see [AGENTS.md](./AGENTS.md)
Documentation Structure
The skills/odoo-18.0/references/ directory contains modular guides for Odoo 18 development:
skills/odoo-18.0/
├── SKILL.md # Master index
├── references/ # Development guides (18 files)
│ ├── odoo-18-actions-guide.md # ir.actions.*, cron, bindings
│ ├── odoo-18-controller-guide.md # HTTP, routing, controllers
│ ├── odoo-18-data-guide.md # XML/CSV data files, records
│ ├── odoo-18-decorator-guide.md # @api decorators
│ ├── odoo-18-development-guide.md # Manifest, wizards (overview)
│ ├── odoo-18-field-guide.md # Field types, parameters
│ ├── odoo-18-manifest-guide.md # __manifest__.py reference
│ ├── odoo-18-mixins-guide.md # mail.thread, activities, etc.
│ ├── odoo-18-model-guide.md # ORM, CRUD, search, domain
│ ├── odoo-18-migration-guide.md # Migration scripts, hooks
│ ├── odoo-18-owl-guide.md # OWL components, services
│ ├── odoo-18-performance-guide.md # N+1 prevention, optimization
│ ├── odoo-18-reports-guide.md # QWeb reports, PDF/HTML
│ ├── odoo-18-security-guide.md # ACL, record rules, security
│ ├── odoo-18-testing-guide.md # Test classes, decorators
│ ├── odoo-18-transaction-guide.md # Savepoints, errors
│ ├── odoo-18-translation-guide.md # Translations, i18n
│ └── odoo-18-view-guide.md # XML views, QWeb
├── CLAUDE.md # This file
└── AGENTS.md # AI agents setupWhich Guide to Use
| Task | Guide |
|---|---|
| Creating actions, menus, cron jobs | references/odoo-18-actions-guide.md |
| Creating a new module | references/odoo-18-development-guide.md |
| Configuring __manifest__.py | references/odoo-18-manifest-guide.md |
| Creating XML/CSV data files | references/odoo-18-data-guide.md |
| Writing ORM queries/search | references/odoo-18-model-guide.md |
| Defining model fields | references/odoo-18-field-guide.md |
| Using @api decorators | references/odoo-18-decorator-guide.md |
| Writing XML views | references/odoo-18-view-guide.md |
| Fixing slow code/N+1 queries | references/odoo-18-performance-guide.md |
| Handling database errors | references/odoo-18-transaction-guide.md |
| Creating HTTP endpoints | references/odoo-18-controller-guide.md |
| Building OWL components | references/odoo-18-owl-guide.md |
| Upgrading modules/migrating data | references/odoo-18-migration-guide.md |
| Using mail.thread, activities, mixins | references/odoo-18-mixins-guide.md |
| Creating QWeb reports | references/odoo-18-reports-guide.md |
| Configuring security (ACL, rules) | references/odoo-18-security-guide.md |
| Writing tests | references/odoo-18-testing-guide.md |
| Adding translations/localization | references/odoo-18-translation-guide.md |
Key Odoo 18 Changes
| Change | Old (Odoo 17-) | New (Odoo 18) |
|---|---|---|
| List view tag | <tree> | <list> |
| Dynamic attributes | attrs="{'invisible': [...]}" | invisible="..." (direct) |
| Delete validation | Override unlink() | @api.ondelete(at_uninstall=False) |
| Field aggregation | group_operator= | aggregator= |
| SQL queries | cr.execute() | SQL class with execute_query_dict() |
| Batch create | Single dict | List of dicts (create([{...}, {...}])) |
Critical Anti-Patterns
| Anti-Pattern | Why Bad | Correct Approach |
|---|---|---|
attrs="{'invisible': [...]}" | Deprecated in Odoo 18 | Use invisible="..." direct attribute |
@api.depends('partner_id') then accessing partner_id.email | N queries per record | Add @api.depends('partner_id.email') |
search() inside loop | N+1 queries | Use search() with IN domain or read_group() |
Using _read_group() instead of read_group() | Returns raw tuples, no lazy grouping, no metadata | Use read_group() for normal aggregation |
create() in loop | N INSERT statements | Batch: create([{...}, {...}]) |
Overriding unlink() for validation | Breaks module uninstall | Use @api.ondelete(at_uninstall=False) |
Using <tree> in Odoo 18 | Deprecated tag | Use <list> instead |
@api Decorator Decision Tree
Need to define field behavior?
├── Field computed from other fields → @api.depends
│ └── CAN use dotted paths: `@api.depends('partner_id.email')`
├── Validate data → @api.constrains
│ └── CANNOT use dotted paths: only simple field names
├── Prevent record deletion → @api.ondelete (Odoo 18)
└── Update form UI → @api.onchange
└── NO CRUD operations allowed
Need to define method behavior?
├── Method-level, doesn't depend on self → @api.model
└── Normal record method → no decorator neededCommon Patterns Reference
N+1 Query Prevention
# BAD: search in loop
for order in orders:
payments = self.env['payment'].search([('order_id', '=', order.id)])
# GOOD: single query
payments = self.env['payment'].search_read([('order_id', 'in', orders.ids)])List View (Odoo 18)
<list string="Records" editable="bottom" multi_edit="1">
<field name="state" decoration-success="state == 'done'"/>
<field name="phone" optional="show"/>
</list>Delete Validation (Odoo 18)
@api.ondelete(at_uninstall=False)
def _unlink_if_not_draft(self):
if any(rec.state != 'draft' for rec in self):
raise UserError("Cannot delete non-draft records")Module Structure
my_module/
├── __init__.py
├── __manifest__.py
├── models/
│ ├── __init__.py
│ └── my_model.py
├── views/
│ └── my_model_views.xml
├── security/
│ ├── ir.model.access.csv
│ └── my_module_security.xml
├── data/
│ └── my_module_data.xml
├── migrations/
│ └── 18.0.1.0/
│ └── post-migrate_data.py
├── tests/
│ ├── __init__.py
│ └── test_my_model.py
├── wizard/
│ ├── __init__.py
│ └── my_wizard.py
├── controllers/
│ ├── __init__.py
│ └── my_controller.py
└── static/
└── src/
├── js/
│ └── my_component.js
├── xml/
│ └── my_component.xml
└── scss/
└── my_component.scssBase Code Reference
The guides are based on Odoo 18 source code. Reference these files in your Odoo installation:
odoo/models.py- ORM implementationodoo/fields.py- Field typesodoo/api.py- Decoratorsodoo/http.py- HTTP layerodoo/exceptions.py- Exception types
Odoo 18 API Highlights
Use this file as the version-specific ruleset when the resolved Odoo version is 18.0. It supplements — not replaces — the general review checklist.
Views
- List view tag: `<list>` —
<tree>is deprecated in 18. Use<list>everywhere, includingxpathexpressions and actionview_mode="list,form". - Direct-expression attrs only — legacy
attrs=/states=are rejected (carried from 17). Useinvisible="...",readonly="...",required="...". - Reference:
references/odoo-18-view-guide.md.
Fields
- Aggregation parameter: `aggregator=` (replaces
group_operator=from v17). Numeric fields default to'sum'. - Reference:
references/odoo-18-field-guide.md.
Decorators
- `@api.ondelete(at_uninstall=False)` — preferred over overriding
unlink()for validation. Overridingunlink()for checks breaks module uninstallation. - `@api.model_create_multi` — overriding
create()without it emits a deprecation warning in 18. - Reference:
references/odoo-18-decorator-guide.md.
Quick review checks (v18-specific)
- ❌
<tree>tag — must be<list>in 18. - ❌
attrs="..."/states="..."— rewrite to direct expressions. - ❌
group_operator=— useaggregator=in 18. - ❌ Overriding
unlink()for validation — use@api.ondelete. - ❌ Overriding
create()without@api.model_create_multi. - ✅
<list>in view records, xpath, and actionview_mode. - ✅
@api.ondelete(at_uninstall=False)for delete rules. - ✅
@api.model_create_multifor batch create.
Odoo 18 Actions Guide
Complete reference for Odoo 18 actions: window, URL, server, report, client, and scheduled actions with bindings.
Table of Contents
1. Action Basics 2. Window Actions 3. URL Actions 4. Server Actions 5. Report Actions 6. Client Actions 7. Scheduled Actions 8. Action Bindings
---
Action Basics
What are Actions?
Actions define the behavior of the system in response to user actions: login, action button, selection of an invoice, etc.
Common Action Attributes
All actions share these mandatory attributes:
| Attribute | Type | Description |
|---|---|---|
type | string | Category of the action (determines available fields) |
name | string | Short user-readable description |
Action Forms
A client can receive actions in 4 forms:
| Form | Description |
|---|---|
False | Close any open action dialog |
| String | Client action tag or number |
| Number | Database ID or external ID of an action record |
| Dictionary | Client action descriptor to execute |
---
Window Actions
ir.actions.act_window - Most Common Action
The most common action type, used to present visualizations of a model through views.
Window Action Fields
| Field | Type | Description |
|---|---|---|
res_model | string | Model to present views for (required) |
views | list | List of [(view_id, view_type)] pairs |
res_id | int | Record to load for form views (optional) |
search_view_id | (id, name) | Specific search view to load (optional) |
target | string | Where to open: current, fullscreen, new, main |
context | dict | Additional context data for views |
domain | list | Filtering domain for search queries |
limit | int | Records to display in lists (default: 80) |
View Types
| Type | Description |
|---|---|
list | List view (formerly tree in Odoo 17) |
form | Form view |
graph | Graph view |
pivot | Pivot view |
kanban | Kanban view |
calendar | Calendar view |
gantt | Gantt view |
map | Map view |
activity | Activity view |
search | Search view |
Window Action Examples
Basic List and Form Views
<record id="action_customer" model="ir.actions.act_window">
<field name="name">Customers</field>
<field name="res_model">res.partner</field>
<field name="view_mode">list,form</field>
<field name="domain">[('customer', '=', True)]</field>
</record>Using Dictionary (Python)
{
"type": "ir.actions.act_window",
"res_model": "res.partner",
"views": [[False, "list"], [False, "form"]],
"domain": [["customer", "=", true]],
}Open Specific Record in Dialog
{
"type": "ir.actions.act_window",
"res_model": "product.product",
"views": [[False, "form"]],
"res_id": a_product_id,
"target": "new",
}Custom Search View
<record id="action_sale_order" model="ir.actions.act_window">
<field name="name">Sales Orders</field>
<field name="res_model">sale.order</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="sale_view_search"/>
<field name="context">{'default_user_id': uid}</field>
</record>In-Database Window Action Fields
These fields are used in XML data files:
| Field | Description |
|---|---|
view_mode | Comma-separated view types (no spaces!) |
view_ids | M2M to view objects for initial views |
view_id | Specific view to add if in view_mode |
<record model="ir.actions.act_window" id="test_action">
<field name="name">A Test Action</field>
<field name="res_model">some.model</field>
<field name="view_mode">graph</field>
<field name="view_id" ref="my_specific_view"/>
</record>ir.actions.act_window.view (Cleaner Approach)
<record model="ir.actions.act_window.view" id="test_action_tree">
<field name="sequence" eval="1"/>
<field name="view_mode">list</field>
<field name="view_id" ref="view_test_list"/>
<field name="act_window_id" ref="test_action"/>
</record>---
URL Actions
ir.actions.act_url - Open Web Pages
Allow opening a URL (website/web page) via an Odoo action.
| Field | Type | Description |
|---|---|---|
url | string | Address to open (required) |
target | string | new, self, or download (default: new) |
URL Action Examples
<record id="action_open_documentation" model="ir.actions.act_url">
<field name="name">Documentation</field>
<field name="url">https://odoo.com</field>
<field name="target">new</field>
</record>{
"type": "ir.actions.act_url",
"url": "https://odoo.com",
"target": "self", # Replaces current content
}Target Values
| Value | Description |
|---|---|
new | Opens URL in new window/page |
self | Replaces current window/page content |
download | Redirects to a download URL |
---
Server Actions
ir.actions.server - Execute Python Code
Allow triggering complex server code from any valid action location.
Server Action Fields
| Field | Type | Description |
|---|---|---|
id | int | In-database identifier of the server action |
model_id | Many2one | Odoo model linked to the action |
state | Selection | Type of action: code, object_create, object_write, multi |
code | Text | Python code to execute (for code state) |
crud_model_id | Many2one | Model for create actions |
child_ids | One2many | Sub-actions for multi state |
Server Action States
code - Execute Python Code
<record model="ir.actions.server" id="print_instance">
<field name="name">Res Partner Server Action</field>
<field name="model_id" ref="model_res_partner"/>
<field name="state">code</field>
<field name="code">
raise Warning(record.name)
</field>
</record>Returning Next Action
<record model="ir.actions.server" id="open_related">
<field name="name">Open Related Record</field>
<field name="model_id" ref="model_res_partner"/>
<field name="state">code</field>
<field name="code">
if record.some_condition():
action = {
"type": "ir.actions.act_window",
"view_mode": "form",
"res_model": record._name,
"res_id": record.id,
}
</field>
</record>object_create - Create New Record
<record model="ir.actions.server" id="create_task">
<field name="name">Create Task from Lead</field>
<field name="model_id" ref="model_crm_lead"/>
<field name="state">object_create</field>
<field name="crud_model_id" ref="model_project_task"/>
<field name="link_field_id" ref="field_project_task_lead_id"/>
<!-- fields_lines specifications -->
</record>object_write - Update Current Record
<record model="ir.actions.server" id="mark_done">
<field name="name">Mark as Done</field>
<field name="model_id" ref="model_my_model"/>
<field name="state">object_write</field>
<!-- fields_lines specifications -->
</record>multi - Execute Multiple Actions
<record model="ir.actions.server" id="multi_action">
<field name="name">Multi Action</field>
<field name="model_id" ref="model_res_partner"/>
<field name="state">multi</field>
<field name="child_ids" eval="[
ref('action_create'),
ref('action_notify'),
]"/>
</record>Evaluation Context
Available variables in server action code:
| Variable | Description |
|---|---|
model | Model object linked via model_id |
record / records | Record/recordset action is triggered on (can be empty) |
env | Odoo Environment |
datetime, dateutil, time, timezone | Python modules |
log(message, level) | Logging function (writes to ir.logging) |
Warning | Constructor for Warning exception |
---
Report Actions
ir.actions.report - Print Reports
Triggers the printing of a report.
Report Action Fields
| Field | Type | Description |
|---|---|---|
name | string | File name (if print_report_name not specified) |
model | string | Model the report is about (required) |
report_type | string | qweb-pdf or qweb-html (default: qweb-pdf) |
report_name | string | External ID of the QWeb template (required) |
print_report_name | string | Python expression for report file name |
groups_id | Many2many | Groups allowed to view/use the report |
multi | boolean | If True, action not displayed on form view |
paperformat_id | Many2one | Paper format to use |
attachment_use | boolean | Generate once, then reprint from stored report |
attachment | string | Python expression for attachment name |
Report Action Examples
<report
id="account_invoices"
model="account.move"
string="Invoices"
report_type="qweb-pdf"
name="account.report_invoice"
file="account_report_invoice"
print_report_name="'Invoice-{}-{}'.format(object.number or 'n/a', object.state)"
groups_id="account.group_account_user"
paperformat_id="account.paperformat_euro"
attachment_use="True"
attachment="'Invoice-'+str(object.number)+'.pdf'"/>Binding to Print Menu
To show in Print menu, specify binding_model_id:
<record id="report_my_report" model="ir.actions.report">
<field name="name">My Report</field>
<field name="model">my.model</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">my_module.my_report</field>
<field name="binding_model_id" ref="model_my_model"/>
<!-- binding_type defaults to 'report' automatically -->
</record>---
Client Actions
ir.actions.client - Client-Side Actions
Triggers an action implemented entirely in the client (JavaScript).
| Field | Type | Description |
|---|---|---|
tag | string | Client-side identifier (arbitrary string) |
params | dict | Additional data for the client |
target | string | current, fullscreen, or new |
Client Action Examples
{
"type": "ir.actions.client",
"tag": "pos.ui"
}<record id="action_client" model="ir.actions.client">
<field name="name">Open POS</field>
<field name="tag">pos.ui</field>
</record>Common Client Action Tags
| Tag | Description |
|---|---|
pos.ui | Point of Sale interface |
web_dashboard.open | Open dashboard |
account.reload_view | Reload account view |
bus.bus.reload | Reload bus communication |
---
Scheduled Actions
ir.cron - Automated Actions
Actions triggered automatically on a predefined frequency.
Scheduled Action Fields
| Field | Type | Description |
|---|---|---|
name | string | Name of the scheduled action |
interval_number | int | Number of interval units between executions |
interval_type | selection | minutes, hours, days, weeks, months |
model_id | Many2one | Model on which action is called |
code | Text | Code content to execute |
nextcall | datetime | Next planned execution date |
priority | int | Priority when executing multiple actions simultaneously |
Scheduled Action Examples
<record id="ir_cron_send_quotation_email" model="ir.cron">
<field name="name">Send Quotation Email</field>
<field name="model_id" ref="model_sale_order"/>
<field name="state">code</field>
<field name="code">model._send_quotation_email()</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field name="active" eval="True"/>
</record>Advanced: Batching
For long-running cron jobs, use batching API:
self.env['ir.cron']._notify_progress(done=50, remaining=100)This allows the scheduler to:
- Know if progress was made
- Determine if there's remaining work
- Process up to 10 batches in one sitting by default
Advanced: Triggers
Trigger scheduled actions from business code:
action_record._trigger(at=datetime(2025, 1, 1))Security Measures
- If a scheduled action encounters an error/timeout 3 consecutive times → skip execution, mark as failed
- If a scheduled action fails 5 consecutive times over at least 7 days → deactivate and notify DB admin
---
Action Bindings
Binding Attributes
Actions can be bound to contextual menus of models.
| Attribute | Type | Description |
|---|---|---|
binding_model_id | Many2one | Model the action is bound to |
binding_type | selection | action (default) or report |
binding_view_types | string | Comma-separated: list, form, list,form (default) |
Binding Examples
Action Binding (More Menu)
<record id="action_custom" model="ir.actions.server">
<field name="name">Custom Action</field>
<field name="model_id" ref="model_sale_order"/>
<field name="state">code</field>
<field name="code">
# Do something
</field>
<field name="binding_model_id" ref="model_sale_order"/>
<field name="binding_type">action</field>
<field name="binding_view_types">list</field>
</record>Report Binding (Print Menu)
<record id="report_custom" model="ir.actions.report">
<field name="name">Custom Report</field>
<field name="model">sale.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">sale.report_custom</field>
<field name="binding_model_id" ref="model_sale_order"/>
<!-- binding_type automatically 'report' for ir.actions.report -->
</record>Binding View Types
| Value | Description |
|---|---|
list | Shows in list view menu |
form | Shows in form view menu |
list,form | Shows in both (default) |
---
Quick Reference
Action Types Summary
| Type | Model | Use Case |
|---|---|---|
| Window | ir.actions.act_window | Open views for a model |
| URL | ir.actions.act_url | Open web page |
| Server | ir.actions.server | Execute Python code |
| Report | ir.actions.report | Print/generate report |
| Client | ir.actions.client | Execute JavaScript |
| Scheduled | ir.cron | Automated recurring action |
Common Target Values
| Target | Description |
|---|---|
current | Open in main content area |
main | Open in main area, clear breadcrumbs |
new | Open in dialog/popup |
fullscreen | Open in full screen mode |
Returning Actions from Python
# Window action
return {
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'view_mode': 'form',
'res_id': self.id,
}
# Refresh current view
return {
'type': 'ir.actions.act_window_close',
}
# Reload entire client
return {
'type': 'ir.actions.client',
'tag': 'reload',
}---
For more Odoo 18 guides, see [SKILL.md](../SKILL.md)
Odoo 18 Controller Guide
Complete reference for Odoo 18 HTTP controllers, routing, and request handling.
Table of Contents
1. Controller Basics 2. @route Decorator 3. Authentication Types 4. Request/Response Types 5. CSRF Handling 6. Common Patterns
---
Controller Basics
Controller Class Structure
from odoo import http
from odoo.http import request
class MyController(http.Controller):
@http.route('/my/path', type='http', auth='user')
def my_handler(self, **kwargs):
return request.render('my_module.template', {
'records': request.env['my.model'].search([]),
})Key points:
- Extend
http.Controller - Use
@http.route()decorator - Access
requestfor environment and data - Return appropriate response type
Request Object
# Environment access (same as model.env)
request.env # Current environment
request.env.user # Current user
request.env.company # Current company
request.env.lang # Current language
# Session access
request.session # Current session dict
request.session['key'] = 'value' # Set session value
request.session.get('key') # Get session value
# HTTP data
request.httprequest # Werkzeug request object
request.params # URL parameters
request.csrf_token() # Current CSRF token
# Database
request.db # Current database name
request.cr # Database cursor (rarely needed)---
@route Decorator
Basic Route
from odoo import http
@http.route('/hello', type='http', auth='user')
def hello(self):
return "Hello World!"URL Parameters
# Path parameter
@http.route('/order/<int:order_id>', type='http', auth='user')
def order_view(self, order_id):
order = request.env['sale.order'].browse(order_id)
if not order.exists():
return request.not_found()
return request.render('sale.order_view', {'order': order})
# Query parameters
@http.route('/search', type='http', auth='user')
def search_orders(self, **kwargs):
domain = []
if kwargs.get('name'):
domain.append(('name', 'ilike', kwargs['name']))
orders = request.env['sale.order'].search(domain)
return request.render('sale.order_list', {'orders': orders})Route Options
@http.route(
'/my/path', # Route path
type='http', # 'http' or 'json'
auth='user', # 'public', 'user', 'none'
methods=['GET', 'POST'], # Allowed HTTP methods
csrf=True, # CSRF validation
website=True, # Website route (render with website layout)
sitemap=False, # Include in sitemap
save_session=True, # Save session after request
)
def my_handler(self):
passMultiroute (Same Handler, Multiple Paths)
@http.route('/path1')
@http.route('/path2')
def my_handler(self):
return "Same handler for both paths"---
Authentication Types
auth='user' (Default)
Requires: Logged-in user
@http.route('/my/orders', type='http', auth='user')
def my_orders(self):
# request.env.user is available
orders = request.env['sale.order'].search([
('user_id', '=', request.env.user.id)
])
return request.render('my_orders', {'orders': orders})Behavior:
- Redirects to login if not authenticated
request.env.uidis the logged-in user- Normal record access rules apply
auth='public'
Allows: Access without login (with access rights)
@http.route('/shop/products', type='http', auth='public')
def shop_products(self):
# Public can access, but respects access rights
products = request.env['product.product'].search([
('website_published', '=', True)
])
return request.render('shop_products', {'products': products})Behavior:
- No redirect to login
request.env.uidis anonymous (usually 3-4)- Access rights still enforced (public user has limited access)
- Use
sudo()to bypass access rights if needed
auth='none'
Allows: No environment, no access rights
@http.route('/web/webclient/locale', type='http', auth='none')
def get_locale(self):
# No environment available - no request.env
# Return static data
return request.make_json_response({
'lang': 'en_US',
'direction': 'ltr',
})Behavior:
request.envis NOT available- No database access
- For truly public, static endpoints
- Used for login pages, health checks
---
Request/Response Types
type='http' - HTML/Text Response
from odoo.http import request
# Render QWeb template
@http.route('/page', type='http', auth='user')
def my_page(self):
return request.render('my_module.template', {
'records': request.env['my.model'].search([]),
})
# Return plain text
@http.route('/ping', type='http', auth='none')
def ping(self):
return "PONG"
# Return HTML directly
@http.route('/html', type='http', auth='user')
def html_response(self):
return "<h1>Hello</h1>"
# Make response with headers
@http.route('/download', type='http', auth='user')
def download_file(self):
return request.make_response(
data,
headers=[
('Content-Type', 'application/pdf'),
('Content-Disposition', 'attachment; filename="file.pdf"'),
]
)
# Redirect
@http.route('/redirect', type='http', auth='user')
def redirect_example(self):
return request.redirect('/target/url')type='json' - JSON-RPC Response
@http.route('/api/action', type='json', auth='user')
def json_action(self, **kwargs):
# For JSON-RPC, return data directly (converted to JSON)
record = request.env['my.model'].browse(kwargs.get('id'))
return {
'status': 'success',
'data': {
'name': record.name,
'value': record.value,
}
}
# JSON endpoints are called from frontend
// Frontend call
this.rpc('/api/action', {id: 123}).then(result => {
console.log(result);
});type='json' behavior:
- Automatically serializes return value to JSON
- Used for frontend JavaScript calls
- CSRF token handled automatically from frontend
---
CSRF Handling
CSRF Protection (Default)
# CSRF enabled by default for POST
@http.route('/form/submit', type='http', auth='user', methods=['POST'])
def form_submit(self, **kwargs):
# CSRF token validated automatically
# Process form data...
return "Form submitted"Disable CSRF (Use Carefully)
# For external webhooks, payment callbacks
@http.route('/webhook/payment', type='http', auth='none', csrf=False)
def payment_webhook(self):
# Verify request another way (signature, IP whitelist)
# Process webhook...
return "OK"CSRF Token in Forms
<!-- QWeb template with CSRF token -->
<form t-action="/form/submit" method="POST">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<!-- other fields -->
</form>---
Common Patterns
JSON Endpoint for Frontend
from odoo import http
from odoo.http import request
class MyController(http.Controller):
@http.route('/my/data', type='json', auth='user')
def get_data(self, domain=None, fields=None):
"""JSON endpoint for frontend widgets"""
domain = domain or []
fields = fields or ['id', 'name', 'date']
records = request.env['my.model'].search_read(domain, fields)
return {
'records': records,
'count': len(records),
}
@http.route('/my/action', type='json', auth='user')
def do_action(self, record_id, action_type):
"""Handle action from frontend"""
record = request.env['my.model'].browse(record_id)
if not record.exists():
return {'error': 'Record not found'}
if action_type == 'validate':
record.action_validate()
elif action_type == 'cancel':
record.action_cancel()
return {'success': True, 'status': record.state}File Download
from odoo import http
from odoo.http import request
class DownloadController(http.Controller):
@http.route('/download/report/<int:report_id>', type='http', auth='user')
def download_report(self, report_id):
"""Download generated report"""
report = request.env['ir.actions.report'].browse(report_id)
# Get the report content
pdf_content, _ = report._render_qweb_pdf([report_id])
return request.make_response(
pdf_content,
headers=[
('Content-Type', 'application/pdf'),
('Content-Disposition', f'attachment; filename="{report.name}.pdf"'),
]
)
@http.route('/download/attachment/<int:attachment_id>', type='http', auth='user')
def download_attachment(self, attachment_id):
"""Download attachment"""
attachment = request.env['ir.attachment'].browse(attachment_id)
if not attachment.exists():
return request.not_found()
return request.make_response(
attachment.datas,
headers=[
('Content-Type', attachment.mimetype),
('Content-Disposition', f'attachment; filename="{attachment.name}"'),
]
)Website Page
from odoo import http
from odoo.http import request
class WebsiteController(http.Controller):
@http.route('/shop', type='http', auth='public', website=True)
def shop(self, **kwargs):
"""Website shop page"""
products = request.env['product.product'].search([
('website_published', '=', True),
('sale_ok', '=', True),
])
# Get cart
cart = request.website.sale_get_order()
return request.render('website_shop.shop', {
'products': products,
'cart': cart,
})
@http.route('/shop/product/<model("product.product"):product>', type='http', auth='public', website=True)
def product(self, product, **kwargs):
"""Product detail page"""
return request.render('website_shop.product', {
'product': product,
'related_products': product.product_tmpl_id.product_variant_ids,
})API Endpoint (External Integration)
from odoo import http
from odoo.http import request
class ApiController(http.Controller):
@http.route('/api/v1/orders', type='json', auth='user', csrf=False)
def api_orders(self, domain=None, limit=80):
"""External API endpoint"""
# Use sudo() to ensure access, or validate access manually
orders = request.env['sale.order'].sudo().search(
domain or [],
limit=limit
)
return orders.read(['name', 'state', 'amount_total'])
@http.route('/api/v1/order/<int:order_id>', type='json', auth='user', methods=['GET'])
def api_order_get(self, order_id):
"""Get single order"""
order = request.env['sale.order'].sudo().browse(order_id)
if not order.exists():
return request.make_json_response(
{'error': 'Order not found'},
status=404
)
return order.read([])[0]Error Handling
from odoo import http
from odoo.http import request
from odoo.exceptions import UserError, AccessError
class MyController(http.Controller):
@http.route('/action', type='json', auth='user')
def do_action(self, record_id):
try:
record = request.env['my.model'].browse(record_id)
record.action_validate()
return {'success': True}
except AccessError:
return {
'error': 'Access denied',
'error_type': 'access_error'
}
except UserError as e:
return {
'error': str(e),
'error_type': 'user_error'
}
except Exception as e:
return {
'error': 'An error occurred',
'error_type': 'system_error'
}
@http.route('/page', type='http', auth='user')
def my_page(self):
try:
data = self._get_data()
return request.render('template', {'data': data})
except Exception:
return request.redirect('/error')Response Methods Reference
# Render template with website layout
request.render('module.template', values)
# Render with custom response
request.make_response(html, headers=[...])
# JSON response
request.make_json_response({'key': 'value'})
# Redirect
request.redirect('/target/url')
# 404 Not Found
request.not_found()
# HTTP error
request.make_json_response({'error': 'message'}, status=400)---
Controller Best Practices
1. Keep controllers thin - Move business logic to models 2. Use appropriate auth - Don't use sudo() unless necessary 3. Validate input - Check parameters before database operations 4. Handle exceptions - Return meaningful error messages 5. Use correct type - json for frontend, http for pages 6. Respect CSRF - Only disable for external APIs 6. Return proper responses - Use correct response methods
Odoo 18 Data Files Guide
Complete reference for Odoo 18 data files: XML structure, records, fields, shortcuts, and CSV files.
Table of Contents
1. Data File Structure 2. record Tag 3. field Tag 4. delete Tag 5. function Tag 6. Shortcuts 7. CSV Data Files 8. noupdate Attribute 9. Data Processing Patterns (Partner Merge)
---
Data File Structure
Basic XML Data File
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data noupdate="1">
<!-- Operations here -->
</data>
<!-- (Re)Loaded at install and update -->
<operation/>
</odoo>Root Element
All data files must have <odoo> as root element containing operations.
Data Elements
Operations are executed sequentially:
- Earlier operations can be referenced by later operations
- Later operations cannot reference earlier operations
File Locations
| Location | When Used |
|---|---|
data/ | Always loaded at install/update |
demo/ | Only in demo mode |
---
record Tag
Creating Records
<record id="partner_1" model="res.partner">
<field name="name">Odoo</field>
<field name="email">info@odoo.com</field>
<field name="is_company" eval="True"/>
<field name="customer" eval="False"/>
</record>Record Attributes
| Attribute | Type | Required | Description |
|---|---|---|---|
id | string | No* | External identifier (strongly recommended) |
model | string | Yes | Model name |
context | dict | No | Context for creation |
forcecreate | bool | No | Create if doesn't exist in update mode (default: True) |
Updating Records
If id exists, record is updated instead of created:
<!-- First time: creates -->
<record id="partner_1" model="res.partner">
<field name="name">Odoo</field>
</record>
<!-- Second time: updates -->
<record id="partner_1" model="res.partner">
<field name="email">newemail@odoo.com</field>
</record>No Fields = No Change
<!-- Does nothing on update -->
<record id="existing_record" model="res.partner"/>---
field Tag
field Attributes
| Attribute | Type | Description |
|---|---|---|
name | string | Field name (required) |
ref | string | External ID reference |
search | domain | Search for relational field |
eval | expression | Python expression |
type | string | Interpretation type |
Setting Values
No Value = False
<record id="record" model="my.model">
<field name="my_field"/> <!-- Sets to False -->
</record>Direct Value
<field name="name">My Name</field>
<field name="code">123</field>
<field name="active">True</field>eval - Python Expression
<field name="value" eval="42"/>
<field name="total" eval="10 + 20"/>
<field name="now" eval="datetime.datetime.now()"/>
<field name="list" eval="[(4, ref('base.group_user'))]"/>eval Context
Available in eval:
| Variable | Description |
|---|---|
time | Python time module |
datetime | Python datetime module |
timedelta | Python timedelta module |
relativedelta | dateutil.relativedelta |
ref() | Resolve external ID |
obj | Current model (for field-specific) |
<field name="date" eval="datetime.date.today()"/>
<field name="next_week" eval="datetime.date.today() + relativedelta.relativedelta(weeks=1)"/>ref - External ID Reference
<field name="user_id" ref="base.user_admin"/>
<field name="group_id" ref="base.group_user"/>
<field name="view_id" ref="my_module.my_view"/><!-- With ref in eval -->
<field name="groups_id" eval="[(6, 0, [ref('base.group_user'), ref('base.group_system')])]"/>search - Domain Search
For relational fields, search for records:
<!-- Search for partner -->
<record id="record" model="my.model">
<field name="partner_id" search="[('name', '=', 'Odoo')]"/>
</record>
<!-- Search with multiple results (first used for Many2one) -->
<field name="country_id" search="[('code', '=', 'US')]"/>type - Interpretation Type
| Type | Description |
|---|---|
xml / html | Extract children as document |
file | File path (stores as module,path) |
char | Direct string value |
base64 | Base64 encode content |
int | Convert to integer |
float | Convert to float |
list / tuple | List of values |
type="xml" / type="html"
<field name="description" type="xml">
<p>This is <strong>formatted</strong> content.</p>
<a href="%(link)s">Click here</a>
</field>type="file"
<field name="image" type="file" name="my_module/static/img/logo.png"/>
<!-- Stores as: my_module,/static/img/logo.png -->type="base64"
<field name="file_data" type="base64" file="my_module/static/data/file.bin"/>type="list"
<field name="my_list" type="list">
<value>1</value>
<value>2</value>
<value>3</value>
</field>type="int" / type="float"
<field name="count" type="int">42</field>
<field name="price" type="float">19.99</field>Relational Fields
Many2one
<field name="partner_id" ref="base.main_partner"/>
<field name="user_id" ref="base.user_admin"/>
<field name="category_id" search="[('name', '=', 'Customers')]"/>One2many / Many2many
Using Command values:
| Command | Description | Format |
|---|---|---|
| 0 | Create | (0, 0, {values}) |
| 1 | Update | (1, id, {values}) |
| 2 | Remove | (2, id) |
| 3 | Unlink | (3, id) |
| 4 | Link | (4, id) |
| 5 | Clear | (5, ) |
| 6 | Replace | (6, 0, [ids]) |
<record id="my_record" model="my.model">
<!-- Create new line -->
<field name="line_ids" eval="[
(0, 0, {'name': 'Line 1', 'price': 100}),
(0, 0, {'name': 'Line 2', 'price': 200}),
]"/>
<!-- Link existing records -->
<field name="tag_ids" eval="[(6, 0, [ref('tag_1'), ref('tag_2')])]"/>
<!-- Clear all -->
<field name="line_ids" eval="[(5,)]"/>
<!-- Replace with new set -->
<field name="tag_ids" eval="[(6, 0, [ref('tag_3')])]"/>
</record>---
delete Tag
Deleting Records
<delete model="res.partner" id="partner_to_delete"/>delete Attributes
| Attribute | Type | Required | Description |
|---|---|---|---|
model | string | Yes | Model to delete from |
id | string | No* | External ID to delete |
search | domain | No* | Domain to find records |
id and search are mutually exclusive.
Delete by External ID
<delete model="ir.ui.view" id="my_module.old_view"/>Delete by Search
<delete model="res.partner" search="[('name', '=', 'Test Partner')]"/>Delete Multiple
<delete model="ir.rule" search="[('domain_force', '=', False)]"/>---
function Tag
Calling Model Methods
<function model="res.partner" name="send_notification">
<!-- Parameters via value elements -->
<value eval="[[ref('partner_1'), ref('partner_2')]]"/>
</function>function Attributes
| Attribute | Type | Required | Description |
|---|---|---|---|
model | string | Yes | Model to call method on |
name | string | Yes | Method name |
eval | expression | No | Parameters as expression |
Parameters via eval
<function model="res.partner" name="create" eval="[
{'name': 'Partner from XML', 'email': 'test@example.com'}
]"/>Parameters via value
<function model="my.model" name="my_method">
<value>arg1</value>
<value>arg2</value>
</function>Nested function
<function model="res.partner" name="send_vip_inscription_notice">
<function eval="[[('vip', '=', True)]]" model="res.partner" name="search"/>
</function>Common Use Cases
<!-- Recompute views -->
<function model="ir.ui.view" name="search"/>
<!-- Clear cache -->
<function model="ir.cron" name="_process_job"/>
<!-- Call module hook -->
<function model="my.module" name="post_init_hook">
<value eval="[]"/>
</function>---
Shortcuts
menuitem Shortcut
Creates ir.ui.menu with defaults:
<menuitem id="menu_root" name="My Module"/>menuitem Attributes
| Attribute | Description |
|---|---|
id | External ID |
name | Menu name (defaults to id if not set) |
parent | Parent menu (external ID or name path) |
action | Action to execute (external ID) |
groups | Comma-separated group external IDs (prefix with - to remove) |
Menu Hierarchy
<!-- Top level -->
<menuitem id="menu_root" name="My Module" sequence="10"/>
<!-- Child (using parent) -->
<menuitem id="menu_sub" name="Sub Menu" parent="menu_root" sequence="1"/>
<!-- Child (using path - auto-creates intermediate) -->
<menuitem id="menu_deep" name="Deep Menu" parent="menu_root/Sub Menu" action="action_my"/>
<!-- With action -->
<menuitem id="menu_action" name="My Action" action="action_my_model"/>Groups
<!-- Only for managers -->
<menuitem id="menu_manager" name="Manager" groups="base.group_system"/>
<!-- Remove group from menu -->
<menuitem id="menu_employees" name="Employees" groups="base.group_user,-base.group_system"/>template Shortcut
Creates ir.ui.view for QWeb template:
<template id="my_template" name="My Template">
<div>
<h1>Hello World</h1>
</div>
</template>template Attributes
| Attribute | Description |
|---|---|
id | External ID (required) |
name | View name |
inherit_id | Parent template to inherit from |
priority | View priority |
primary | Set as primary view with inheritance |
groups | Comma-separated group external IDs |
active | Whether view is active |
<!-- Basic template -->
<template id="website_my_template">
<div>
<t t-foreach="docs" t-as="doc">
<span t-field="doc.name"/>
</t>
</div>
</template>
<!-- Inheritance -->
<template id="inherit_template" inherit_id="website.my_template" priority="10">
<xpath expr="//div" position="inside">
<p>Inherited content</p>
</xpath>
</template>
<!-- Primary inheritance -->
<template id="primary_template" inherit_id="base.template" primary="True">
<!-- Primary template for this view -->
</template>
<!-- With groups -->
<template id="manager_template" groups="base.group_system">
<!-- Only visible to system group -->
</template>
<!-- Active control -->
<template id="conditional_template" active="False">
<!-- Inactive by default (XPath rules won't apply) -->
</template>asset Shortcut
Creates ir.asset record:
<asset id="website_something.style_asset" name="Some Style Asset">
<bundle>web.assets_frontend</bundle>
<path>website_something/static/src/some_style.scss</path>
</asset>asset Attributes
| Attribute | Description |
|---|---|
id | External ID (required) |
name | Asset name |
active | Whether asset is active |
asset Child Elements
| Element | Description |
|---|---|
<bundle> | Asset bundle name |
<path> | File path |
<directive> | Directive (include, replace, etc.) |
<asset id="my_module.assets" name="My Assets" active="True">
<bundle>web.assets_frontend</bundle>
<path>my_module/static/src/js/main.js</path>
<path>my_module/static/src/scss/style.scss</path>
<field name="directive">replace</field>
</asset>---
CSV Data Files
CSV Structure
my_module/
└── data/
└── res_country_state.csvCSV Format
- File name:
{model_name}.csv - First row: Field names including
idfor external IDs - Each subsequent row: One record
Example: Country States
id,country_id,name,code
state_au_nsw,country_au,New South Wales,NSW
state_au_vic,country_au,Victoria,VIC
state_au_qld,country_au,Queensland,QLDCSV Fields
| Column | Description |
|---|---|
id | External ID (for create/update) |
country_id:id | Reference to country (external ID) |
name | State name |
code | State code |
CSV for Access Rights
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_trip_user,trip.user,model_business_trip,base.group_user,1,0,0,0
access_trip_manager,trip.manager,model_business_trip,module.group_manager,1,1,1,1CSV vs XML
| CSV | XML |
|---|---|
| Simpler for bulk data | More flexible |
| Good for flat structures | Good for complex structures |
| Easier to edit | Better for relationships |
| Limited to simple values | Supports eval, search, etc. |
---
noupdate Attribute
noupdate="1"
Data in <data noupdate="1"> is only loaded at installation:
<odoo>
<!-- Loaded at install and update -->
<record id="core_data" model="my.model">
<field name="name">Core Data</field>
</record>
<!-- Loaded only at install -->
<data noupdate="1">
<record id="demo_data" model="my.model">
<field name="name">Demo Data</field>
</record>
</data>
</odoo>When to Use noupdate
| Use Case | noupdate |
|---|---|
| Core module data | 0 (default) |
| User-editable data | 1 |
| Default records | 1 |
| Demo data | 1 |
| Configuration | 1 |
| Views, actions, menus | 0 |
noupdate Examples
<!-- Core data: always update -->
<record id="ir_cron_send_email" model="ir.cron">
<field name="name">Send Email</field>
</record>
<!-- User data: never update after install -->
<data noupdate="1">
<record id="default_warehouse" model="stock.warehouse">
<field name="name">Default Warehouse</field>
</record>
</data>
<!-- Demo data: only in demo mode -->
<data noupdate="1" demo="true">
<record id="demo_partner" model="res.partner">
<field name="name">Demo Partner</field>
</record>
</data>---
Data Processing Patterns (Partner Merge)
Patterns below are extracted from odoo/addons/base/wizard/base_partner_merge.py and are useful when building safe data-migration or merge logic in custom modules.
1) Normalize Before Grouping
When finding duplicates, normalize values first so grouping is stable:
lower(name)/lower(email)for case-insensitive comparisonsreplace(vat, ' ', '')to ignore formatting spaces
This avoids false negatives when data is logically equal but formatted differently.
2) SQL for Candidate Detection, ORM for Access-Safe Filtering
base_partner_merge uses SQL (min(id), array_agg(id)) to detect duplicate groups quickly, then re-reads partners with ORM:
self._cr.execute(query)
for min_id, aggr_ids in self._cr.fetchall():
partners = self.env['res.partner'].search([('id', 'in', aggr_ids)])This pattern combines performance (SQL) with Odoo security/record-rule behavior (ORM).
3) Generic FK Rewrite with Savepoint Fallback
For merge operations across many tables:
1. Discover FK relations dynamically 2. Try bulk UPDATE ... SET fk = dst_id WHERE fk IN src_ids 3. On unique constraint collision, fallback to deleting conflicting rows 4. Keep each table operation isolated in a savepoint
This prevents one collision from rolling back the whole merge process.
4) Reference Field Rewrite (model,res_id)
For reference fields and models like ir.attachment, mail.followers, mail.activity, mail.message, update links from source records to destination records in batch. Always use guarded writes and savepoints.
5) Company-Dependent JSONB Data Migration
For company-dependent many2one values stored as JSONB:
- Update per-company values in SQL (
jsonb_each,jsonb_object_agg) - Merge source JSONB values into destination in deterministic order
- Flush environment after SQL updates
This is safer than partial ORM-only writes when data is stored in JSONB structures.
6) Deterministic Destination Selection
The wizard selects destination partner by ordered criteria (active, create_date) before merge. In custom dedup flows, define and document an explicit winner strategy to keep behavior predictable.
7) Defensive Checks Before Merge
Before data merge, validate constraints to avoid corrupt states:
- Do not merge parent with child (
child_ofchecks) - Block conflicting user links
- Enforce consistency checks (for example, same email unless admin)
- Limit merge group size for operational safety
---
Quick Reference
record Tag
<record id="external_id" model="model.name" context="{}">
<field name="field_name">value</field>
<field name="field_ref" ref="module.external_id"/>
<field name="field_eval" eval="True"/>
<field name="field_search" search="[('name', '=', 'Value')]"/>
<field name="field_xml" type="xml"><p>content</p></field>
</record>field Tag Value Types
| Type | Usage |
|---|---|
| Direct | <field name="name">Value</field> |
| eval | <field name="active" eval="True"/> |
| ref | <field name="user" ref="base.user_admin"/> |
| search | <field name="country" search="[('code', '=', 'US')]"/> |
| type="xml" | <field name="desc" type="xml"><p>HTML</p></field> |
| type="file" | <field name="image" type="file" name="path.png"/> |
| type="base64" | <field name="data" type="base64" file="file.bin"/> |
| type="int" | <field name="count" type="int">42</field> |
Relational Commands
| Command | Use |
|---|---|
(0, 0, {...}) | Create new |
(1, id, {...}) | Update |
(2, id) | Remove |
(3, id) | Unlink |
(4, id) | Link |
(5,) | Clear all |
(6, 0, [ids]) | Replace set |
Shortcuts
<!-- Menu -->
<menuitem id="menu_id" name="Label" parent="parent_id" action="action_id"/>
<!-- Template -->
<template id="template_id" inherit_id="parent_template" active="True"/>
<!-- Asset -->
<asset id="asset_id" name="Name" active="True">
<bundle>web.assets_frontend</bundle>
<path>path/to/file</path>
</asset>---
For more Odoo 18 guides, see [SKILL.md](../SKILL.md)
Odoo 18 Decorator Guide
Complete reference for Odoo 18 API decorators and their proper usage.
Table of Contents
1. @api.model 2. @api.depends 3. @api.depends_context 4. @api.constrains 5. @api.onchange 6. @api.ondelete 7. @api.returns
---
@api.model
Purpose: Decorate methods where self is a recordset, but the actual records don't matter - only the model class.
from odoo import api, models
class SaleOrder(models.Model):
_name = 'sale.order'
@api.model
def get_default_values(self):
"""Return default values for new orders"""
return {
'state': 'draft',
'date_order': fields.Datetime.now(),
}
@api.model
def create_from_csv(self, csv_data):
"""Class method alternative"""
for row in csv_data:
self.create(row)When to use:
- Factory methods that create records
- Methods that don't depend on
selfcontent - Utility methods for the model
Common pattern - Default value callable:
partner_id = fields.Many2one(
'res.partner',
default=lambda self: self.env.user.partner_id.id,
)
# Equivalent with @api.model
@api.model
def _default_partner_id(self):
return self.env.user.partner_id.id---
@api.depends
Purpose: Declare dependencies for computed fields. The method is re-computed when any dependency changes.
from odoo import api, fields, models
class SaleOrder(models.Model):
_name = 'sale.order'
amount_untaxed = fields.Float(string='Untaxed Amount')
tax_amount = fields.Float(string='Tax Amount')
discount_amount = fields.Float(string='Discount')
# Basic depends
amount_total = fields.Float(
string='Total',
compute='_compute_amount_total',
store=True,
)
@api.depends('amount_untaxed', 'tax_amount', 'discount_amount')
def _compute_amount_total(self):
for order in self:
order.amount_total = (
order.amount_untaxed
+ order.tax_amount
- order.discount_amount
)Relational field dependencies:
@api.depends('partner_id.name', 'partner_id.email')
def _compute_partner_display(self):
for order in self:
if order.partner_id:
order.partner_display = f"{order.partner_id.name} <{order.partner_id.email}>"
else:
order.partner_display = ''One2many traversal:
@api.depends('line_ids.price_subtotal')
def _compute_amount_total(self):
for order in self:
order.amount_total = sum(order.line_ids.mapped('price_subtotal'))Nested dependencies:
@api.depends('line_ids.product_id.list_price')
def _compute_max_price(self):
for order in self:
prices = order.line_ids.mapped('product_id.list_price')
order.max_price = max(prices) if prices else 0.0Important rules: 1. Cannot depend on `id` - use depends_context('uid') instead 2. Must list all dependencies - missed dependencies cause stale values 3. Dot notation for relations - partner_id.name not just partner_id 4. No dotted path in @constrains - only @api.depends supports dotted paths
---
@api.depends_context
Purpose: Make computed field depend on context values. Field recomputed when context changes.
from odoo import api, fields, models
class ProductProduct(models.Model):
_name = 'product.product'
# Price depends on pricelist in context
price = fields.Float(
string='Price',
compute='_compute_price',
)
@api.depends_context('pricelist')
def _compute_price(self):
pricelist_id = self.env.context.get('pricelist')
if pricelist_id:
pricelist = self.env['product.pricelist'].browse(pricelist_id)
for product in self:
product.price = pricelist.get_product_price(product, 1.0)
else:
for product in self:
product.price = product.list_priceBuilt-in context keys:
# Company context
@api.depends_context('company')
def _compute_company_field(self):
self.company_field = self.env.company.id
# User context
@api.depends_context('uid')
def _compute_user_field(self):
self.user_field = self.env.user.id
# Language context
@api.depends_context('lang')
def _compute_translated_name(self):
lang = self.env.context.get('lang', 'en_US')
self.translated_name = self.name_with_lang(lang)
# Active test context
@api.depends_context('active_test')
def _compute_all_records(self):
# When active_test=False, include archived records
domain = [] if self.env.context.get('active_test') else []
self.all_records = self.search_count(domain)Custom context keys:
@api.depends_context('show_prices')
def _compute_display_price(self):
show_prices = self.env.context.get('show_prices', True)
for product in self:
product.display_price = product.price if show_prices else 0.0---
@api.constrains
Purpose: Validate data integrity. Raise ValidationError if validation fails.
from odoo import api, models, ValidationError
from odoo.exceptions import ValidationError
class SaleOrder(models.Model):
_name = 'sale.order'
@api.constrains('date_order', 'date_validity')
def _check_dates(self):
for order in self:
if order.date_validity and order.date_order > order.date_validity:
raise ValidationError(
"Order date cannot be after validity date."
)
@api.constrains('partner_id', 'payment_term_id')
def _check_payment_term(self):
for order in self:
if order.partner_id.property_payment_term_id:
if order.payment_term_id != order.partner_id.property_payment_term_id:
raise ValidationError(
"Payment term must match partner's default."
)Validation with relational fields:
@api.constrains('line_ids')
def _check_lines(self):
for order in self:
if not order.line_ids:
raise ValidationError("Order must have at least one line.")
# Check for duplicate products
products = order.line_ids.mapped('product_id')
if len(products) != len(order.line_ids):
raise ValidationError("Duplicate products not allowed.")Limitations: 1. No dotted paths - partner_id.name won't work 2. Must use simple field names - only direct fields on the model 3. Only triggers on included fields - if field not in create/write, constraint won't run
Workaround for full validation:
# Override create/write to ensure constraints always run
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
records._check_full_validation() # Your full constraint method
return records---
@api.onchange
Purpose: Update form fields dynamically when another field changes.
from odoo import api, models
class SaleOrderLine(models.Model):
_name = 'sale.order.line'
product_id = fields.Many2one('product.product', string='Product')
price_unit = fields.Float(string='Unit Price')
description = fields.Text(string='Description')
@api.onchange('product_id')
def _onchange_product_id(self):
if self.product_id:
self.price_unit = self.product_id.list_price
self.description = self.product_id.description_sale
else:
self.price_unit = 0.0
self.description = ''
@api.onchange('product_id', 'quantity')
def _onchange_product_quantity(self):
if self.product_id and self.quantity:
self.price_unit = self.product_id.get_price(quantity=self.quantity)Return warning/notification:
@api.onchange('discount')
def _onchange_discount(self):
if self.discount > 50:
return {
'warning': {
'title': "High Discount",
'message': "Discount over 50% requires approval.",
'type': 'notification', # 'dialog' or 'notification'
}
}Update domain:
@api.onchange('partner_id')
def _onchange_partner_id(self):
domain = {}
if self.partner_id:
domain['shipping_id'] = [
('partner_id', '=', self.partner_id.id),
('type', '=', 'delivery'),
]
else:
domain['shipping_id'] = []
return {'domain': domain}Limitations: 1. No CRUD operations - cannot call create(), read(), write(), unlink() 2. Only simple field names - dotted paths not supported 3. Pseudo-record - self is a single pseudo-record, not saved to DB
Correct pattern:
# GOOD: Set field values
@api.onchange('partner_id')
def _onchange_partner_id(self):
if self.partner_id:
self.pricelist_id = self.partner_id.property_product_pricelist
self.payment_term_id = self.partner_id.property_payment_term_id
# BAD: CRUD operations
@api.onchange('partner_id')
def _onchange_partner_id(self):
self.env['sale.order'].create({}) # ERROR - undefined behavior---
@api.ondelete
Purpose: Validate before allowing record deletion. Supports module uninstallation.
from odoo import api, models
from odoo.exceptions import UserError
class SaleOrder(models.Model):
_name = 'sale.order'
@api.ondelete(at_uninstall=False)
def _unlink_if_not_confirmed(self):
"""Prevent deletion of confirmed orders"""
if any(order.state == 'confirmed' for order in self):
raise UserError(
"Cannot delete confirmed orders. "
"Cancel them first."
)
@api.ondelete(at_uninstall=False)
def _unlink_except_draft(self):
"""Alternative naming convention"""
if any(order.state != 'draft' for order in self):
raise UserError("Only draft orders can be deleted.")`at_uninstall` parameter:
| Value | Behavior |
|---|---|
False (default) | Check runs during normal use, NOT during module uninstall |
True | Check runs always, including during module uninstall |
When to use `at_uninstall=True`:
- System-critical data (default language, main company)
- Data that would break basic functionality if deleted
# Example: Prevent deleting default language
@api.ondelete(at_uninstall=True)
def _unlink_if_default_language(self):
if self.env.ref('base.lang_en').id in self.ids:
raise UserError("Cannot delete the default English language.")Why not override `unlink()`?:
- Overriding
unlink()breaks module uninstallation @api.ondeleteis smart about module lifecycle- Prevents leftover data after uninstall
---
@api.returns
Purpose: Specify the return model of a method for API compatibility.
from odoo import api, models
class SaleOrder(models.Model):
_name = 'sale.order'
@api.returns('res.partner')
def get_partner(self):
"""Returns partner record(s)"""
return self.mapped('partner_id')
@api.returns('self')
def copy(self, default=None):
"""Returns new record(s) of same model"""
return super().copy(default)Common usage in Odoo base:
# Many methods use @api.returns
@api.returns('mail.message', lambda value: value.id)
def message_post(self, ...):
# Post a message, return the message
return message---
Decorator Combination Patterns
Computed field with search and inverse
full_name = fields.Char(
string='Full Name',
compute='_compute_full_name',
inverse='_inverse_full_name',
search='_search_full_name',
)
@api.depends('first_name', 'last_name')
def _compute_full_name(self):
for record in self:
record.full_name = f"{record.first_name} {record.last_name}"
def _inverse_full_name(self):
for record in self:
parts = record.full_name.split(' ', 1)
record.first_name = parts[0]
record.last_name = parts[1] if len(parts) > 1 else ''
def _search_full_name(self, operator, value):
return ['|',
('first_name', operator, value),
('last_name', operator, value)]Model method with constrains
@api.model
@api.constrains('code')
def _check_code_format(self):
"""Model method with constraint"""
for record in self:
if record.code and not record.code.isalnum():
raise ValidationError("Code must be alphanumeric.")---
@api.model_create_multi (Odoo 18)
Purpose: Decorate batch create method. The method expects a list of dicts and can be called with either a single dict or a list.
from odoo import api
@api.model_create_multi
def create(self, vals_list):
"""Batch create - receives list of vals, returns recordset"""
# Add default values
for vals in vals_list:
vals.setdefault('state', 'draft')
vals.setdefault('date', fields.Datetime.now())
records = super().create(vals_list)
# Post-processing
for record in records:
record._compute_something()
return records
# Usage:
# record = model.create({'name': 'Test'}) # Single dict
# records = model.create([{'name': 'A'}, ...]) # List of dictsNote: If you override create() without @api.model_create_multi, Odoo 18 will show a deprecation warning.
---
@api.readonly
Purpose: Decorate a method where self.env.cr can be a readonly cursor.
@api.readonly
def get_statistics(self):
"""This method can be called with readonly cursor"""
self.env.cr.execute("SELECT COUNT(*) FROM my_table WHERE ...")
return self.env.cr.fetchone()[0]Use this decorator for methods that only read from database and don't need write access.
---
@api.private
Purpose: Decorate a method to indicate it cannot be called using RPC.
@api.private
def _internal_method(self):
"""This method cannot be called over RPC"""
# Only callable internally from Python code
passBest practice: Prefix business methods that should not be called over RPC with _ instead of using this decorator.
---
@api.autovacuum
Purpose: Decorate a method to be called by the daily vacuum cron job (model ir.autovacuum).
@api.autovacuum
def _gc_expired_records(self):
"""Called daily to clean up old records"""
expired_date = fields.Datetime.now() - relativedelta(days=30)
self.search([('create_date', '<', expired_date)]).unlink()Requirements:
- Method name must start with
_(private) - Use for garbage-collection-like tasks that don't deserve a specific cron job
---
All API Decorators Reference
| Decorator | Purpose | Odoo Version |
|---|---|---|
@api.model | Model-level method (self not relevant) | All |
@api.depends | Computed field dependencies | All |
@api.depends_context | Context dependencies | All |
@api.constrains | Data validation | All |
@api.onchange | Form UI updates | All |
@api.ondelete | Delete validation (Odoo 18) | 18+ |
@api.returns | Return type specification | All |
@api.model_create_multi | Batch create | 18+ |
@api.readonly | Readonly cursor | 18+ |
@api.private | Non-RPC callable | 18+ |
@api.autovacuum | Daily vacuum job | 18+ |
---
Decorator Decision Tree (Updated for Odoo 18)
Need to define field behavior?
├── Field value comes from other fields → @api.depends
│ └── Depends on context → also @api.depends_context
│ └── Needs to be searchable → add store=True, search=...
│ └── Can be edited → add inverse=...
├── Validate data integrity → @api.constrains
│ └── Prevent deletion → @api.ondelete
├── Form UI update → @api.onchange
│
Need method behavior?
├── Doesn't depend on self records → @api.model
├── Returns specific model → @api.returns
└── Normal record method → no decorator neededOdoo 18 Development Guide
Complete guide for Odoo 18 module development: manifest structure, reports, security, wizards, and advanced patterns.
Table of Contents
1. Module Structure 2. __manifest__.py 3. Security 4. Reports 5. Wizards & Transient Models 6. Data Files 7. Hooks
---
Module Structure
Standard Module Structure
my_module/
├── __init__.py # Package init
├── __manifest__.py # Module manifest (REQUIRED)
├── models/
│ ├── __init__.py
│ ├── my_model.py # Model definitions
│ └── ir_rule.py # Optional: security rules in Python
├── views/
│ ├── my_model_views.xml # View definitions
│ ├── my_model_templates.xml # QWeb templates
│ └── report_templates.xml # Report templates
├── security/
│ ├── ir.model.access.csv # Access rights (REQUIRED)
│ └── my_module_security.xml # Record rules
├── data/
│ ├── my_module_data.xml # Data records
│ └── ir_cron_data.xml # Scheduled actions
├── demo/
│ └── my_module_demo.xml # Demo data
├── report/
│ ├── my_report_views.xml # Report actions
│ └── my_report_templates.xml # Report QWeb templates
├── wizard/
│ ├── __init__.py
│ ├── my_wizard.py # TransientModel
│ └── my_wizard_views.xml # Wizard views
├── static/
│ ├── src/
│ │ ├── js/ # JavaScript files
│ │ ├── css/ # CSS files
│ │ └── scss/ # SCSS files
│ └── description/
│ └── icon.png # Module icon
├── controllers/
│ ├── __init__.py
│ └── my_controller.py # HTTP controllers
├── tests/
│ ├── __init__.py
│ └── test_my_module.py # Test cases
└── lib/
└── controller/
├── __init__.py
└── main.py # Alternative controller location---
__manifest__.py
Basic Manifest
#!/usr/bin/env python
# -*- coding: utf-8 -*-
{
'name': 'My Module',
'version': '18.0.1.0.0',
'summary': 'Short description of module',
'description': """
Long Description
==================
Detailed description of what the module does.
""",
'category': 'My Category',
'author': 'Your Name',
'website': 'https://www.example.com',
'license': 'LGPL-3',
# Dependencies
'depends': [
'base',
'product',
],
# Data files
'data': [
'security/my_module_security.xml',
'security/ir.model.access.csv',
'views/my_module_views.xml',
'data/my_module_data.xml',
'report/my_report_views.xml',
],
# Demo data
'demo': [
'demo/my_module_demo.xml',
],
# Installation
'installable': True,
'application': False, # True = creates app menu
'auto_install': False, # True = auto-install with dependencies
# Hooks
'post_init_hook': 'post_init_hook',
'uninstall_hook': 'uninstall_hook',
}Manifest Fields Reference
| Field | Type | Required | Description |
|---|---|---|---|
name | str | Yes | Module name |
version | str | Yes | Version (e.g., 18.0.1.0.0) |
summary | str | No | Short description (one line) |
description | str | No | Long description (can be multi-line) |
category | str | No | Module category |
author | str | No | Author name(s) |
website | str | No | Module URL |
license | str | No | License (default: LGPL-3) |
depends | list | Yes | Required module dependencies |
data | list | No | Data files to load |
demo | list | No | Demo data files |
assets | dict | No | Web assets (CSS/JS) |
installable | bool | Yes | Whether module can be installed |
application | bool | No | Whether it's an app (shows in Apps menu) |
auto_install | bool | No | Auto-install when dependencies installed |
post_init_hook | str | No | Function to call after install |
pre_init_hook | str | No | Function to call before install |
uninstall_hook | str | No | Function to call after uninstall |
external_dependencies | dict | No | Python/ binary dependencies |
sequence | int | No | Installation order in Apps |
images | list | No | Module screenshot URLs |
html | bool | No | Whether description is HTML |
Assets Declaration (Odoo 18)
'assets': {
# CSS Variables
'web._assets_primary_variables': [
'my_module/static/src/scss/variables.scss',
],
# Backend assets
'web.assets_backend': [
'my_module/static/src/js/my_script.js',
'my_module/static/src/css/my_style.css',
'my_module/static/src/scss/my_style.scss',
'my_module/static/src/xml/*.xml', # QWeb templates
],
# Frontend (website) assets
'web.assets_frontend': [
'my_module/static/src/js/frontend.js',
'my_module/static/src/css/frontend.css',
],
# Report assets
'web.report_assets_common': [
'my_module/static/src/css/report.css',
],
# Test assets
'web.assets_tests': [
'my_module/static/tests/**/*',
],
}External Dependencies
'external_dependencies': {
'python': [
'geopy',
'openpyxl',
'python-dateutil',
],
'bin': [
'pdftk',
'phantomjs',
],
}---
Security
Access Rights (ir.model.access.csv)
Location: security/ir.model.access.csv
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_my_model_user,my.model.user,model_my_model,base.group_user,1,1,1,0
access_my_model_manager,my.model.manager,model_my_model,group_my_module_manager,1,1,1,1Columns:
id: Unique XML ID for the access rightname: Human-readable namemodel_id:id: Model (reference toir.model)group_id:id: Group (reference tores.groups, empty = all users)perm_read: Can read (1 = yes, 0 = no)perm_write: Can writeperm_create: Can createperm_unlink: Can delete
Common Access Patterns
# Full access for managers
access_my_model_manager,my.model.manager,model_my_model,group_my_manager,1,1,1,1
# Read-only for regular users
access_my_model_user,my.model.user,model_my_model,base.group_user,1,0,0,0
# Read and write for regular users
access_my_model_user,my.model.user,model_my_model,base.group_user,1,1,1,0
# No access for portal users
# (Don't declare = no access)
# Portal access (read-only, specific domain)
access_my_model_portal,my.model.portal,model_my_model,base.group_portal,1,0,0,0Record Rules (ir.rule)
Location: security/my_module_security.xml
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!-- Multi-company rule -->
<record id="my_model_comp_rule" model="ir.rule">
<field name="name">My Model multi-company</field>
<field name="model_id" ref="model_my_model"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
<field name="global" eval="True"/>
</record>
<!-- User can only see their own records -->
<record id="my_model_personal_rule" model="ir.rule">
<field name="name">Personal My Records</field>
<field name="model_id" ref="model_my_model"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
</record>
<!-- Managers can see all records -->
<record id="my_model_manager_rule" model="ir.rule">
<field name="name">My Model: All Records</field>
<field name="model_id" ref="model_my_model"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_my_module_manager'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<!-- Portal access -->
<record id="my_model_portal_rule" model="ir.rule">
<field name="name">My Model: Portal Access</field>
<field name="model_id" ref="model_my_model"/>
<field name="domain_force">
[('partner_id', 'in', user.commercial_partner_id.child_ids.ids)]
</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_unlink" eval="False"/>
</record>
</odoo>ir.rule Fields Reference
| Field | Description |
|---|---|
name | Rule description |
model_id | Model (reference to ir.model) |
domain_force | Domain expression for filtering |
groups | Groups rule applies to (empty = all) |
perm_read | Override read permission |
perm_write | Override write permission |
perm_create | Override create permission |
perm_unlink | Override unlink permission |
global | Apply to all users (ignores groups) |
Rule Domain Variables
| Variable | Description |
|---|---|
user | Current user record |
uid | Current user ID |
company_ids | Allowed companies for current user |
company_id | Current company |
context | Current context |
Groups Definition
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Category for module groups -->
<record id="module_category_my_module" model="ir.module.category">
<field name="name">My Module</field>
<field name="description">Helps you manage your records</field>
<field name="sequence">20</field>
</record>
<!-- Manager group -->
<record id="group_my_module_manager" model="res.groups">
<field name="name">Manager</field>
<field name="category_id" ref="module_category_my_module"/>
<field name="implied_ids" eval="[(4, ref('group_my_module_user'))]"/>
<field name="comment">User can manage all records.</field>
</record>
<!-- User group -->
<record id="group_my_module_user" model="res.groups">
<field name="name">User</field>
<field name="category_id" ref="module_category_my_module"/>
<field name="comment">User can access own records.</field>
</record>
</odoo>Group Inheritance
<!-- Manager implies user rights -->
<record id="group_my_module_manager" model="res.groups">
<field name="name">Manager</field>
<field name="implied_ids" eval="[(4, ref('group_my_module_user'))]"/>
</record>
<!-- Manager also has base group_portal -->
<record id="group_my_module_manager" model="res.groups">
<field name="implied_ids" eval="[
(4, ref('group_my_module_user')),
(4, ref('base.group_portal')),
]"/>
</record>---
Reports
Report Action (ir.actions.report)
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_report_my_model" model="ir.actions.report">
<field name="name">My Model Report</field>
<field name="model">my.model</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">my_module.report_my_model</field>
<field name="report_file">my_model_report</field>
<field name="print_report_name">'My Model - %s' % (object.name)</field>
<field name="binding_model_id" ref="model_my_model"/>
<field name="binding_type">report</field>
</record>
</odoo>Report Types
| Type | Description |
|---|---|
qweb-pdf | PDF report (most common) |
qweb-html | HTML report (viewed in browser) |
qweb-text | Text report (e.g., for labels) |
Report with Groups
<record id="action_report_my_model_confidential" model="ir.actions.report">
<field name="name">Confidential Report</field>
<field name="model">my.model</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">my_module.report_confidential</field>
<field name="groups_id" eval="[(4, ref('group_my_module_manager'))]"/>
<field name="binding_model_id" ref="model_my_model"/>
<field name="binding_type">report</field>
</record>QWeb Report Template
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Main report template (called for each record) -->
<template id="report_my_model_document">
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<div class="page">
<h2 t-field="doc.name"/>
<table class="table table-sm">
<tr>
<th>Date</th>
<td><span t-field="doc.date"/></td>
</tr>
<tr>
<th>Customer</th>
<td>
<span t-field="doc.partner_id.name"/>
<br/>
<span t-field="doc.partner_id.street"/>
<span t-field="doc.partner_id.city"/>,
<span t-field="doc.partner_id.country_id.code"/>
</td>
</tr>
</table>
<!-- Lines -->
<t t-if="doc.line_ids">
<table class="table table-bordered">
<thead>
<tr>
<th>Product</th>
<th class="text-right">Quantity</th>
<th class="text-right">Price</th>
<th class="text-right">Total</th>
</tr>
</thead>
<tbody>
<tr t-foreach="doc.line_ids" t-as="line">
<td><span t-field="line.product_id.name"/></td>
<td class="text-right"><span t-field="line.quantity"/></td>
<td class="text-right"><span t-field="line.price_unit"/></td>
<td class="text-right"><span t-field="line.price_total"/></td>
</tr>
</tbody>
</table>
</t>
<!-- Totals -->
<div class="row">
<div class="col-6 offset-6">
<table class="table table-sm">
<tr>
<td class="text-right"><strong>Total</strong></td>
<td class="text-right"><span t-field="doc.amount_total"/></td>
</tr>
</table>
</div>
</div>
</div>
<!-- Footer note -->
<div class="footer_note">
<t t-if="doc.note">
<p t-field="doc.note"/>
</t>
<t t-if="doc.conditions">
<p t-esc="doc.conditions"/>
</t>
</div>
</t>
</template>
<!-- Wrapper template (handles multiple records) -->
<template id="report_my_model_raw">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="my_module.report_my_model_document" t-lang="doc.partner_id.lang"/>
</t>
</t>
</template>
<!-- Main entry point -->
<template id="report_my_model">
<t t-call="my_module.report_my_model_raw"/>
</template>
</odoo>Report Layouts
Odoo provides several built-in layouts:
| Layout | Usage |
|---|---|
web.external_layout | Standard external layout (with header/footer) |
web.external_layout_background | With background styling |
web.external_layout_clean | Minimal layout |
web.html_container | Container without header/footer |
web.internal_layout | Internal layout for backend |
Dynamic Report Name
<field name="print_report_name">
(object.state == 'draft' and 'Draft - %s' % (object.name))
or 'Confirmed - %s' % (object.name)
</field>---
Wizards & Transient Models
TransientModel Structure
from odoo import models, fields, api
from odoo.exceptions import UserError
class MyWizard(models.TransientModel):
"""Wizard for processing selected records"""
_name = 'my.wizard'
_description = 'My Wizard'
# Fields
date = fields.Date(string='Date', default=fields.Date.context_today, required=True)
reason = fields.Text(string='Reason')
user_id = fields.Many2one('res.users', string='User', default=lambda self: self.env.user)
# Related records (from context)
record_ids = fields.Many2many(
'my.model',
'my_wizard_record_rel',
'wizard_id',
'record_id',
string='Records',
)
@api.model
def default_get(self, fields):
"""Set defaults from context (active_ids)"""
res = super(MyWizard, self).default_get(fields)
if 'record_ids' in fields and self.env.context.get('active_model') == 'my.model':
records = self.env['my.model'].browse(self.env.context.get('active_ids', []))
res['record_ids'] = [(6, 0, records.ids)]
return res
def action_process(self):
"""Process selected records"""
self.ensure_one()
# Process records
for record in self.record_ids:
record.action_done(self.date, self.reason)
# Close wizard and show message
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'message': 'Processed {} records'.format(len(self.record_ids)),
'type': 'success',
}
}
def action_open_records(self):
"""Open processed records in list view"""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Processed Records',
'res_model': 'my.model',
'domain': [('id', 'in', self.record_ids.ids)],
'view_mode': 'list,form',
}Wizard View
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Wizard Form View -->
<record id="view_my_wizard_form" model="ir.ui.view">
<field name="name">my.wizard.form</field>
<field name="model">my.wizard</field>
<field name="arch" type="xml">
<form string="My Wizard">
<field name="record_ids" invisible="1"/>
<group>
<group>
<field name="date"/>
</group>
<group>
<field name="user_id"/>
</group>
</group>
<group>
<field name="reason" nolabel="1" placeholder="Enter reason..."/>
</group>
<footer>
<button string="Process" name="action_process" type="object" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- Wizard Action -->
<record id="action_my_wizard" model="ir.actions.act_window">
<field name="name">My Wizard</field>
<field name="res_model">my.wizard</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_my_wizard_form"/>
<field name="target">new</field>
</record>
</odoo>Wizard Action from Record
<!-- Add wizard button to model form -->
<record id="view_my_model_form" model="ir.ui.view">
<field name="name">my.model.form</field>
<field name="model">my.model</field>
<field name="inherit_id" ref="my_module.view_my_model_form"/>
<field name="arch" type="xml">
<header position="inside">
<button string="Open Wizard" name="%(action_my_wizard)d"
type="action" class="btn-primary"/>
</header>
</field>
</record>TransientModel vs Model
| Feature | TransientModel | Model |
|---|---|---|
| Data persistence | Auto-deleted (periodic cleanup) | Persistent |
| Use for | Wizards, temporary data | Regular business data |
| Database table | Yes (temporary) | Yes (permanent) |
| Inheritance | models.TransientModel | models.Model |
| Lifecycle | ~1 day (configurable) | Forever |
active_id | Works | Works |
Multi-Step Wizard
class MultiStepWizard(models.TransientModel):
_name = 'multi.step.wizard'
step = fields.Selection([
('step1', 'Step 1'),
('step2', 'Step 2'),
('step3', 'Step 3'),
], default='step1')
field1 = fields.Char(string='Field 1')
field2 = fields.Char(string='Field 2')
field3 = fields.Char(string='Field 3')
def action_next(self):
if self.step == 'step1':
self.write({'step': 'step2'})
elif self.step == 'step2':
self.write({'step': 'step3'})
return {
'type': 'ir.actions.act_window',
'res_model': 'multi.step.wizard',
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}Wizard with Context
@api.model
def default_get(self, fields):
res = super(MyWizard, self).default_get(fields)
# Get active_ids from context
active_ids = self.env.context.get('active_ids', [])
active_model = self.env.context.get('active_model')
if active_model and active_ids:
res['record_ids'] = [(6, 0, active_ids)]
# Get other context values
res['date'] = self.env.context.get('default_date', fields.Date.today())
return res---
Data Files
Data Records (XML)
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Simple record -->
<record id="my_record_1" model="my.model">
<field name="name">Record 1</field>
<field name="code">R001</field>
</record>
<!-- Record with relation -->
<record id="my_record_2" model="my.model">
<field name="name">Record 2</field>
<field name="category_id" ref="my_category_1"/>
<field name="user_id" ref="base.user_admin"/>
</record>
<!-- noupdate: don't update on module upgrade -->
<record id="my_record_3" model="my.model" noupdate="1">
<field name="name">Record 3 (Customizable)</field>
</record>
</odoo>Cron Jobs
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="cron_my_model_cleanup" model="ir.cron">
<field name="name">Clean up old records</field>
<field name="model_id" ref="model_my_model"/>
<field name="state">code</field>
<field name="code">model.cron_cleanup_old_records()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field name="active" eval="True"/>
</record>
</odoo>Server Actions
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Python code server action -->
<record id="server_action_my_model" model="ir.actions.server">
<field name="name">My Server Action</field>
<field name="model_id" ref="model_my_model"/>
<field name="state">code</field>
<field name="code">
records.action_done()
</field>
</record>
<!-- Create new record server action -->
<record id="server_action_create" model="ir.actions.server">
<field name="name">Create Record</field>
<field name="model_id" ref="model_my_model"/>
<field name="state">object_create</field>
<field name="use_create">new</field>
<field name="fields_lines_ids">
<field eval="[(0, 0, {'field_id': ref('field_my_model_name'), 'value': 'New Record'})]"
name="fields_lines_ids"/>
</field>
</record>
</odoo>---
Hooks
Post-Init Hook
# In __manifest__.py:
# 'post_init_hook': 'post_init_hook',
# In your model file:
def post_init_hook(env):
"""Called after module installation"""
# Create default records
env['my.model'].create({
'name': 'Default Record',
'code': 'DEFAULT',
})
# Run SQL for performance
env.cr.execute("""
ALTER TABLE my_model ADD COLUMN IF NOT EXISTS computed_field VARCHAR;
""")Uninstall Hook
# In __manifest__.py:
# 'uninstall_hook': 'uninstall_hook',
def uninstall_hook(env):
"""Called before module uninstallation"""
# Clean up data
env['my.model'].search([]).unlink()
# Drop custom columns
env.cr.execute("""
ALTER TABLE my_model DROP COLUMN IF EXISTS computed_field;
""")Pre-Init Hook
# In __manifest__.py:
# 'pre_init_hook': 'pre_init_hook',
def pre_init_hook(env):
"""Called before module installation"""
# Prepare database
env.cr.execute("""
ALTER TABLE my_model ADD COLUMN IF NOT EXISTS new_field VARCHAR;
""")---
Complete Module Example
__manifest__.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
{
'name': 'My Module',
'version': '18.0.1.0.0',
'summary': 'My Custom Module',
'description': """
My Module Description
====================
Detailed description.
""",
'author': 'Me',
'category': 'Tools',
'website': 'https://example.com',
'license': 'LGPL-3',
'depends': ['base'],
'data': [
'security/my_module_security.xml',
'security/ir.model.access.csv',
'views/my_model_views.xml',
'views/wizard_views.xml',
'data/my_module_data.xml',
'report/my_report_views.xml',
],
'demo': [
'demo/my_module_demo.xml',
],
'installable': True,
'application': False,
'assets': {
'web.assets_backend': [
'my_module/static/src/js/my_script.js',
],
},
}models/my_model.py
from odoo import models, fields, api
class MyModel(models.Model):
_name = 'my.model'
_description = 'My Model'
_order = 'date desc'
name = fields.Char(string='Name', required=True)
date = fields.Date(string='Date', default=fields.Date.context_today)
state = fields.Selection([
('draft', 'Draft'),
('done', 'Done'),
], default='draft')
@api.model
def action_done(self):
self.write({'state': 'done'})security/ir.model.access.csv
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_my_model_user,my.model.user,model_my_model,base.group_user,1,1,1,0
access_my_model_manager,my.model.manager,model_my_model,group_my_module_manager,1,1,1,1security/my_module_security.xml
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="group_my_module_manager" model="res.groups">
<field name="name">Manager</field>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
</odoo>---
Common Development Tasks
Create Custom Action on Model
<!-- Window action -->
<record id="action_my_model" model="ir.actions.act_window">
<field name="name">My Models</field>
<field name="res_model">my.model</field>
<field name="view_mode">list,form</field>
<field name="domain">[]</field>
<field name="context">{'search_default_draft': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">Create your first record!</p>
</field>
</record>Add Context Action
<!-- Action accessible from smart button -->
<record id="action_my_model_from_partner" model="ir.actions.act_window">
<field name="name">My Models</field>
<field name="res_model">my.model</field>
<field name="view_mode">list,form</field>
<field name="domain">[('partner_id', '=', active_id)]</field>
<field name="context">{'default_partner_id': active_id}</field>
</record>
<!-- Bind to partner model -->
<record id="action_my_model_from_partner_value" model="ir.values">
<field name="name">My Models</field>
<field name="model_id" ref="model_my_model"/>
<field name="binding_model_id" ref="model_res_partner"/>
</record>Add Print Button
<!-- Report action appears in Print menu -->
<record id="action_report_my_model" model="ir.actions.report">
<field name="name">My Model Report</field>
<field name="model">my.model</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">my_module.report_my_model</field>
<field name="binding_model_id" ref="model_my_model"/>
<field name="binding_type">report</field>
</record>Add Server Action Button
<!-- Server action -->
<record id="server_action_my_model_done" model="ir.actions.server">
<field name="name">Mark as Done</field>
<field name="model_id" ref="model_my_model"/>
<field name="state">code</field>
<field name="code">records.action_done()</field>
</record>
<!-- Bind to model -->
<record id="server_action_my_model_done_value" model="ir.values">
<field name="name">Mark as Done</field>
<field name="model_id" ref="model_my_model"/>
<field name="binding_model_id" ref="model_my_model"/>
<field name="action_id" ref="server_action_my_model_done"/>
</record>Add Context Menu Entry
<!-- Add to right-click menu -->
<act_window id="action_my_model_context"
name="My Action"
res_model="my.model"
src_model="my.model"
multi="False"
/>---
Exception Reference
Exception Hierarchy (Odoo 18)
Exception
├── UserError (Base exception for client errors)
│ ├── AccessDenied (Login/password error - no traceback)
│ ├── AccessError (Access rights error)
│ ├── MissingError (Record not found / deleted)
│ ├── RedirectWarning (Warning with redirect option)
│ └── ValidationError (Constraint violation)
└── CacheMiss (Missing value in cache)UserError
Purpose: Generic error managed by the client. When the user tries to do something that doesn't make sense.
from odoo.exceptions import UserError
def action_confirm(self):
for order in self:
if not order.line_ids:
raise UserError("Cannot confirm an order without lines.")AccessDenied
Purpose: Login/password error. No traceback is shown.
from odoo.exceptions import AccessDenied
def check_password(self, password):
if not self.env.user._check_password(password):
raise AccessDenied("Incorrect password")AccessError
Purpose: Access rights error. When user tries to access records they're not allowed to.
from odoo.exceptions import AccessError
def action_delete(self):
if not self.env.user.has_group('base.group_system'):
raise AccessError("Only administrators can delete records.")MissingError
Purpose: Record not found or deleted.
from odoo.exceptions import MissingError
def action_update(self):
record = self.browse(self.id)
if not record.exists():
raise MissingError(_("This record has been deleted"))ValidationError
Purpose: Violation of Python constraints.
from odoo.exceptions import ValidationError
@api.constrains('email')
def _check_email(self):
for record in self:
if record.email and '@' not in record.email:
raise ValidationError("Email must contain '@'")RedirectWarning
Purpose: Warning with option to redirect user to another action.
from odoo.exceptions import RedirectWarning
def action_check_config(self):
if not self.company_id.payment_term_id:
raise RedirectWarning(
_("Please configure a default payment term"),
action=self.env.ref('account.action_payment_term_form').id,
button_text=_("Configure Payment Terms"),
)CacheMiss
Purpose: Missing value in cache. Usually raised internally by ORM.
from odoo.exceptions import CacheMiss
try:
value = self.env.cache.get(record, field)
except CacheMiss:
# Value not in cache, need to fetch
value = record._fetch_field(field)Exception Usage Guidelines
| Exception | When to Use | Behavior |
|---|---|---|
UserError | Generic business logic errors | Shows modal to user |
AccessDenied | Wrong login/password | No traceback, login error |
AccessError | Insufficient permissions | Shows error to user |
MissingError | Record deleted/not found | Shows error to user |
ValidationError | Data validation fails | Shows error to user |
RedirectWarning | Need to redirect user | Shows dialog with button |
CacheMiss | Cache lookup (internal) | Handled by ORM |
Import Statement
from odoo.exceptions import (
UserError,
AccessDenied,
AccessError,
MissingError,
ValidationError,
RedirectWarning,
CacheMiss,
)Related skills
How it compares
Use odoo-18 over generic Python backend skills when you need version-locked Odoo 18 ORM and module conventions inside your agent.
FAQ
What does odoo-18 do?
>
When should I use odoo-18?
>
What are common prerequisites?
--- name: odoo-18 description: > Odoo 18 development reference for Python models and ORM (search, domain, read_group, compute fields), XML/CSV data and views, OWL/JS client code, QWeb reports, security (ACL, record rules
Is Odoo 18 safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.