
Odoo 17
- 255 installs
- 118 repo stars
- Updated July 14, 2026
- unclecatvn/agent-skills
Odoo 17 is an agent skill that surfaces version-17 Odoo development references so assistants write correct modules, data XML, and migrations.
About
Odoo 17 is a documentation skill that packages UncleCat’s Odoo 17.0 knowledge base for AI coding assistants. Solo builders and small teams shipping custom Odoo modules—inventory, CRM extensions, industry verticals—get a single installable skill instead of guessing framework APIs from stale blog posts. The package centers on SKILL.md as a master index and a references folder of focused guides for actions, controllers, decorators, fields, manifests, mixins, models, migrations, and OWL front-end components. You use it during active module development whenever the agent must generate compliant Python, XML records, or upgrade scripts. Installation aligns with skills.sh (`npx skills add unclecatvn/agent-skills`) or Cursor remote rules from the 17.0 branch. It does not run Odoo or replace official docs, but it steers agents toward version-17-correct patterns so you ship modules faster with fewer ORM and security footguns.
- Master SKILL.md index plus 18 reference guides (actions, controllers, data, decorators, fields, manifest, mixins, models
- Install via npx skills add or Cursor remote rule pinned to branch 17.0 and skills/odoo-17.0/
- Covers ir.actions, HTTP routing, @api decorators, mail.thread mixins, and migration hooks for Odoo 17
- Works with Cursor, Claude Code, Windsurf, Aider, and other agents that read project skills
- Structured paths for XML/CSV data files, wizards, and development manifest conventions
Odoo 17 by the numbers
- 255 all-time installs (skills.sh)
- Ranked #1,473 of 4,348 Backend & APIs skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
npx skills add https://github.com/unclecatvn/agent-skills --skill odoo-17Add your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 255 |
|---|---|
| repo stars | ★ 118 |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 14, 2026 |
| Repository | unclecatvn/agent-skills ↗ |
What it does
Give your agent accurate Odoo 17 ORM, OWL, manifest, and migration patterns while you build or extend ERP modules.
Who is it for?
Best when you're building Odoo 17 custom modules with Claude Code, Cursor, or Windsurf in an existing Odoo codebase.
Skip if: Skip if you're on Odoo 16 or earlier without retargeting docs, or developers and only need hosted Odoo SaaS configuration with no custom code.
When should I use this skill?
Building or extending Odoo 17 modules and the agent needs framework-accurate patterns for models, views, actions, or migrations.
What you get
Your agent pulls indexed Odoo 17 guides while coding so models, views, cron actions, and migration scripts match framework conventions.
- Agent-grounded Odoo 17 module code
- XML/CSV data and manifest snippets aligned to v17 guides
By the numbers
- 18 reference development guides
- skills/odoo-17.0 references tree
- branch 17.0 subfolder install path
Files
Odoo 17 Skill - Master Index
Master index for all Odoo 17 development guides. Read the appropriate guide from references/ based on your task.
Quick Reference
| Topic | File | When to Use |
|---|---|---|
| Actions | references/odoo-17-actions-guide.md | Creating actions, menus, scheduled jobs, server actions |
| API Decorators | references/odoo-17-decorator-guide.md | Using @api decorators, compute fields, validation |
| Controllers | references/odoo-17-controller-guide.md | Writing HTTP endpoints, routes, web controllers |
| Data Files | references/odoo-17-data-guide.md | XML/CSV data files, records, shortcuts |
| Development | references/odoo-17-development-guide.md | Creating modules, manifest, reports, security, wizards |
| Field Types | references/odoo-17-field-guide.md | Defining model fields, choosing field types |
| Manifest | references/odoo-17-manifest-guide.md | __manifest__.py configuration, dependencies, hooks |
| Migration | references/odoo-17-migration-guide.md | Upgrading modules, data migration, version changes |
| Mixins | references/odoo-17-mixins-guide.md | mail.thread, activities, email aliases, tracking |
| Model Methods | references/odoo-17-model-guide.md | Writing ORM queries, CRUD operations, domain filters |
| OWL Components | references/odoo-17-owl-guide.md | Building OWL UI components, hooks, services |
| Performance | references/odoo-17-performance-guide.md | Optimizing queries, fixing slow code, preventing N+1 |
| Reports | references/odoo-17-reports-guide.md | QWeb reports, PDF/HTML, templates, paper formats |
| Security | references/odoo-17-security-guide.md | Access rights, record rules, field permissions |
| Testing | references/odoo-17-testing-guide.md | Writing tests, mocking, assertions, browser testing |
| Transactions | references/odoo-17-transaction-guide.md | Handling database errors, savepoints, UniqueViolation |
| Translation | references/odoo-17-translation-guide.md | Adding translations, localization, i18n |
| Views & XML | references/odoo-17-view-guide.md | Writing XML views, actions, menus, QWeb templates |
File Structure
skills/odoo-17.0/
├── SKILL.md # This file - master index
└── references/ # Development guides
├── odoo-17-actions-guide.md
├── odoo-17-controller-guide.md
├── odoo-17-data-guide.md
├── odoo-17-decorator-guide.md
├── odoo-17-development-guide.md
├── odoo-17-field-guide.md
├── odoo-17-manifest-guide.md
├── odoo-17-migration-guide.md
├── odoo-17-mixins-guide.md
├── odoo-17-model-guide.md
├── odoo-17-owl-guide.md
├── odoo-17-performance-guide.md
├── odoo-17-reports-guide.md
├── odoo-17-security-guide.md
├── odoo-17-testing-guide.md
├── odoo-17-transaction-guide.md
├── odoo-17-translation-guide.md
└── odoo-17-view-guide.mdBase Code Reference (Odoo 17)
All guides are based on analysis of Odoo 17 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 17 Documentation - AI Agents Setup
Setup guide for using Odoo 17 documentation with AI coding assistants (Cursor, Claude Code, Windsurf, Aider, etc.).
Quick Start
Install via skills.sh (Recommended)
# Add Odoo 17 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:
17.0 - Subfolder:
skills/odoo-17.0/
---
Documentation Structure
skills/odoo-17.0/
├── SKILL.md # Master index (all agents)
├── references/ # Development guides (18 files)
│ ├── odoo-17-actions-guide.md # ir.actions.*, cron, bindings
│ ├── odoo-17-controller-guide.md # HTTP, routing, controllers
│ ├── odoo-17-data-guide.md # XML/CSV data files, records
│ ├── odoo-17-decorator-guide.md # @api decorators
│ ├── odoo-17-development-guide.md # Manifest, wizards (overview)
│ ├── odoo-17-field-guide.md # Field types, parameters
│ ├── odoo-17-manifest-guide.md # __manifest__.py reference
│ ├── odoo-17-mixins-guide.md # mail.thread, activities, etc.
│ ├── odoo-17-model-guide.md # ORM, CRUD, search, domain
│ ├── odoo-17-migration-guide.md # Migration scripts, hooks
│ ├── odoo-17-owl-guide.md # OWL components, services
│ ├── odoo-17-performance-guide.md # N+1 prevention, optimization
│ ├── odoo-17-reports-guide.md # QWeb reports, PDF/HTML
│ ├── odoo-17-security-guide.md # ACL, record rules, security
│ ├── odoo-17-testing-guide.md # Test classes, decorators
│ ├── odoo-17-transaction-guide.md # Savepoints, errors
│ ├── odoo-17-translation-guide.md # Translations, i18n
│ └── odoo-17-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-17-actions-guide.md | Actions (window, URL, server, cron) | Creating actions, menus, scheduled jobs |
references/odoo-17-controller-guide.md | HTTP controllers, routing | Writing endpoints |
references/odoo-17-data-guide.md | XML/CSV data files, records | Creating data files |
references/odoo-17-decorator-guide.md | @api decorators usage | Using @api decorators |
references/odoo-17-development-guide.md | Module structure, wizards | Creating new modules |
references/odoo-17-field-guide.md | Field types, parameters | Defining model fields |
references/odoo-17-manifest-guide.md | __manifest__.py reference | Configuring module manifest |
references/odoo-17-mixins-guide.md | mail.thread, activities, mixins | Adding messaging, activities |
references/odoo-17-model-guide.md | ORM methods, CRUD, domains | Writing model methods |
references/odoo-17-migration-guide.md | Migration scripts, hooks | Upgrading modules |
references/odoo-17-owl-guide.md | OWL components, hooks, services | Building OWL UI |
references/odoo-17-performance-guide.md | Performance optimization | Fixing slow code |
references/odoo-17-reports-guide.md | QWeb reports, templates | Creating reports |
references/odoo-17-security-guide.md | ACL, record rules, security | Configuring security |
references/odoo-17-testing-guide.md | Test classes, decorators, mocking | Writing tests |
references/odoo-17-transaction-guide.md | Database transactions, error handling | Savepoints, UniqueViolation |
references/odoo-17-translation-guide.md | Translations, localization, i18n | Adding translations |
references/odoo-17-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 | 17.0 |
| Subfolder | skills/odoo-17.0/ |
Globs patterns used by Cursor:
| File | globs Pattern |
|---|---|
SKILL.md | **/*.{py,xml} |
references/odoo-17-actions-guide.md | **/*.{py,xml} |
references/odoo-17-controller-guide.md | **/controllers/**/*.py |
references/odoo-17-data-guide.md | **/*.{xml,csv} |
references/odoo-17-decorator-guide.md | **/models/**/*.py |
references/odoo-17-development-guide.md | **/*.{py,xml,csv} |
references/odoo-17-field-guide.md | **/models/**/*.py |
references/odoo-17-manifest-guide.md | **/__manifest__.py |
references/odoo-17-mixins-guide.md | **/models/**/*.py |
references/odoo-17-model-guide.md | **/models/**/*.py |
references/odoo-17-migration-guide.md | **/migrations/**/*.py |
references/odoo-17-owl-guide.md | static/src/**/*.{js,xml} |
references/odoo-17-performance-guide.md | **/*.{py,xml} |
references/odoo-17-reports-guide.md | **/report/**/*.xml |
references/odoo-17-security-guide.md | **/security/**/*.{csv,xml} |
references/odoo-17-testing-guide.md | **/tests/**/*.py |
references/odoo-17-transaction-guide.md | **/models/**/*.py |
references/odoo-17-translation-guide.md | **/*.{py,js,xml} |
references/odoo-17-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-17/
└── SKILL.md
.claude/skills/
└── odoo-17/
└── SKILL.md---
Key Odoo 17 Conventions
Odoo 17 is the last version before the v18 API modernization. Write code using the v17 conventions:
| Concern | Odoo 17 | v18+ (not valid in v17) |
|---|---|---|
| List view tag | <tree> | <list> |
| Dynamic attributes | Direct invisible="...", readonly="...", required="..." (v17 REMOVED the legacy attrs=/states= forms — validator rejects them) | (same, attrs=/states= removed starting 17.0) |
| Field aggregation | group_operator= | aggregator= |
| SQL queries | cr.execute() | SQL class + execute_query_dict() |
| SQL constraints | _sql_constraints | models.Constraint(...) |
| Kanban card | t-name="kanban-box" | t-name="card" |
---
Repository
URL: git@github.com:unclecatvn/agent-skills.git Branch: 17.0 License: MIT
Odoo 17 Development Guide
This file provides guidance to AI agents when working with Odoo 17 code in this repository.
For setup instructions with different AI IDEs, see [AGENTS.md](./AGENTS.md)
Documentation Structure
The skills/odoo-17.0/references/ directory contains modular guides for Odoo 17 development:
skills/odoo-17.0/
├── SKILL.md # Master index
├── references/ # Development guides (18 files)
│ ├── odoo-17-actions-guide.md # ir.actions.*, cron, bindings
│ ├── odoo-17-controller-guide.md # HTTP, routing, controllers
│ ├── odoo-17-data-guide.md # XML/CSV data files, records
│ ├── odoo-17-decorator-guide.md # @api decorators
│ ├── odoo-17-development-guide.md # Manifest, wizards (overview)
│ ├── odoo-17-field-guide.md # Field types, parameters
│ ├── odoo-17-manifest-guide.md # __manifest__.py reference
│ ├── odoo-17-mixins-guide.md # mail.thread, activities, etc.
│ ├── odoo-17-model-guide.md # ORM, CRUD, search, domain
│ ├── odoo-17-migration-guide.md # Migration scripts, hooks
│ ├── odoo-17-owl-guide.md # OWL components, services
│ ├── odoo-17-performance-guide.md # N+1 prevention, optimization
│ ├── odoo-17-reports-guide.md # QWeb reports, PDF/HTML
│ ├── odoo-17-security-guide.md # ACL, record rules, security
│ ├── odoo-17-testing-guide.md # Test classes, decorators
│ ├── odoo-17-transaction-guide.md # Savepoints, errors
│ ├── odoo-17-translation-guide.md # Translations, i18n
│ └── odoo-17-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-17-actions-guide.md |
| Creating a new module | references/odoo-17-development-guide.md |
| Configuring __manifest__.py | references/odoo-17-manifest-guide.md |
| Creating XML/CSV data files | references/odoo-17-data-guide.md |
| Writing ORM queries/search | references/odoo-17-model-guide.md |
| Defining model fields | references/odoo-17-field-guide.md |
| Using @api decorators | references/odoo-17-decorator-guide.md |
| Writing XML views | references/odoo-17-view-guide.md |
| Fixing slow code/N+1 queries | references/odoo-17-performance-guide.md |
| Handling database errors | references/odoo-17-transaction-guide.md |
| Creating HTTP endpoints | references/odoo-17-controller-guide.md |
| Building OWL components | references/odoo-17-owl-guide.md |
| Upgrading modules/migrating data | references/odoo-17-migration-guide.md |
| Using mail.thread, activities, mixins | references/odoo-17-mixins-guide.md |
| Creating QWeb reports | references/odoo-17-reports-guide.md |
| Configuring security (ACL, rules) | references/odoo-17-security-guide.md |
| Writing tests | references/odoo-17-testing-guide.md |
| Adding translations/localization | references/odoo-17-translation-guide.md |
Key Odoo 17 Conventions (vs Odoo 18+)
Odoo 17 is the version before the big v18 API modernization. Code must use the v17 patterns:
| Concern | Odoo 17 convention | Odoo 18+ change (do NOT use in v17) |
|---|---|---|
| List view tag | <tree> | <list> |
| Dynamic attributes | Direct Python expressions: invisible="state == 'done'", readonly="locked", required="type == 'post'" (v17 REMOVED attrs= and states= — the view validator raises ValidationError if they appear) | (same direct-expression form) |
| Delete validation | @api.ondelete(at_uninstall=False) (available since v15) or override unlink() | (same, @api.ondelete still preferred) |
| Field aggregation | group_operator='sum' | aggregator='sum' |
| SQL queries | self.env.cr.execute(query, params) | SQL class + execute_query_dict() |
| Batch create | create([{...}, {...}]) also supported | (same) |
| SQL constraints | _sql_constraints = [(...)] | models.Constraint(...) |
| QWeb output | t-esc and t-out both valid (t-out is newer, preferred for HTML-safe output) | Only t-out |
| Kanban template | t-name="kanban-box" | t-name="card" |
| Chatter in form view | <div class="oe_chatter"> <field name="message_follower_ids"/> <field name="message_ids"/> </div> | <chatter/> shortcut tag |
Critical Anti-Patterns
| Anti-Pattern | Why Bad | Correct Approach |
|---|---|---|
@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() |
create() in loop | N INSERT statements | Batch: create([{...}, {...}]) |
Plain override of unlink() for validation | Breaks module uninstall | Use @api.ondelete(at_uninstall=False) |
Using <list> in Odoo 17 | Not the v17 convention | Use <tree> |
Using attrs="{'invisible': [...]}" or states="..." | v17 view validator rejects these since 17.0 | Use direct invisible="...", readonly="...", required="..." Python expressions |
Using aggregator= on fields | v18+ only | Use group_operator= |
Using models.Constraint(...) | v19+ only | Use _sql_constraints |
Using privilege_id on res.groups | v19+ only | Use category_id |
@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 (available since v15)
└── Update form UI → @api.onchange
└── NO CRUD operations allowed
Need to define method behavior?
├── Method-level, doesn't depend on self → @api.model
├── Create multiple records in one call → @api.model_create_multi (required on create)
├── Block RPC access to a public method → @api.private (or rename with leading _)
└── 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)])Tree View (Odoo 17)
<tree string="Records" editable="bottom" multi_edit="1">
<field name="state" decoration-success="state == 'done'"/>
<field name="phone" optional="show"/>
</tree>Conditional Attributes (Odoo 17)
v17 uses direct Python expressions (NOT the legacy attrs=/states= dict, which the validator rejects since 17.0):
<field name="date_done"
invisible="state != 'done'"
readonly="state in ('done', 'cancel')"/>Boolean fields can be used directly:
<field name="note" invisible="not has_note"/>
<button name="action_confirm" invisible="state != 'draft'" string="Confirm"/>Delete Validation (Odoo 17)
@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")Batch Create (Odoo 17)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
# per-vals preprocessing
pass
return super().create(vals_list)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/
│ └── 17.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
├── i18n/
│ ├── my_module.pot
│ └── vi.po
└── static/
└── src/
├── js/
│ └── my_component.js
├── xml/
│ └── my_component.xml
└── scss/
└── my_component.scssBase Code Reference
The guides are based on Odoo 17 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 17 API Highlights
Use this file as the version-specific ruleset when the resolved Odoo version is 17.0. It supplements — not replaces — the general review checklist.
Views
- List view tag: `<tree>` — Odoo 17 still uses
<tree>. The rename to<list>happens in 18. - Applies everywhere: view records,
xpathexpressions, actionview_mode="tree,form". - Legacy `attrs=` / `states=` are rejected by the view validator in 17. Use direct-expression attributes:
attrs="{'invisible': [('state','=','done')]}"→invisible="state == 'done'"attrs="{'readonly': [('locked','=',True)]}"→readonly="locked"states="draft,confirmed"→invisible="state not in ('draft','confirmed')"- Reference:
references/odoo-17-view-guide.md.
Fields
- Aggregation parameter: `group_operator=` (numeric fields default to
'sum'). The parameter is renamed toaggregator=in later versions — in v17 source,aggregator=will fail or be silently ignored. - Reference:
references/odoo-17-field-guide.md.
Decorators
- `@api.model_create_multi` is required when overriding
create(). Do not rely on the@api.modelfallback. - `@api.ondelete(at_uninstall=False)` is available (since 15) and preferred over overriding
unlink()for validation. - Reference:
references/odoo-17-decorator-guide.md.
Frontend
- OWL 2.8 is the frontend framework version shipped with Odoo 17.
Quick review checks (v17-specific)
- ❌
<list>tag (belongs in 18+) — flag as wrong version. - ❌
attrs="..."/states="..."— must be rewritten to direct expressions. - ❌
aggregator=— usegroup_operator=in 17. - ✅
@api.model_create_multioncreate()overrides. - ✅
@api.ondeletefor delete validation.
Odoo 17 Actions Guide
Reference for Odoo 17 ir.actions.*, ir.ui.menu, and ir.cron.
Table of Contents
1. Action Basics 2. Window Actions (ir.actions.act_window) 3. URL Actions (ir.actions.act_url) 4. Server Actions (ir.actions.server) 5. Report Actions (ir.actions.report) 6. Client Actions (ir.actions.client) 7. Scheduled Actions (ir.cron) 8. Menus (ir.ui.menu) 9. Action Bindings 10. Returning Actions From Python 11. Quick Reference
---
Action Basics
An action tells the client what to do in response to a user interaction: opening a view, calling server code, printing a report, opening a URL, refreshing the UI, etc.
Common Shape
Every action record exposes:
| Field | Description |
|---|---|
type | Model name (ir.actions.act_window, ir.actions.server, ...) — implicit via the model attribute of <record> |
name | Human-readable label |
From Python, actions can be returned as:
| Form | Meaning |
|---|---|
False | Close any open dialog |
| string (tag) | Client action tag |
| integer / XML-id | Reference to an existing ir.actions.* record |
| dict | Inline action descriptor, executed as-is |
---
Window Actions
ir.actions.act_window drives the standard "view a model through one or more views" behaviour.
Key Fields
| Field | Type | Notes |
|---|---|---|
name | Char | Action label (shown in breadcrumbs if target != 'new') |
res_model | Char (required) | Target model |
view_mode | Char | Comma-separated view types (no spaces). v17 default: `tree,form` |
view_id | Many2one (ir.ui.view) | Specific view to use for the first matching view_mode type |
view_ids | One2many (ir.actions.act_window.view) | Fine-grained (sequence, view_mode, view_id) list |
search_view_id | Many2one (ir.ui.view) | Specific search view |
res_id | Integer | Opens a specific record in form mode |
domain | Char (Python list) | Default domain filter |
context | Char (Python dict) | Default context (supports search_default_*, default_*) |
target | Selection | current (default), new (dialog), inline, fullscreen, main |
limit | Integer | List pagination (default 80) |
mobile_view_mode | Char | First view mode on small screens (default kanban) |
help | Html | Nocontent help shown when the list is empty |
binding_model_id | Many2one (ir.model) | Attaches the action to the Action menu of another model |
binding_view_types | Char | list,form (default); restricts which views display the binding |
groups_id | Many2many (res.groups) | Restrict visibility |
v17 vs v18: the list view tag and the value you put inview_modeistreein v17 (it was renamed tolistin v18). Defaultview_modein v17 istree,form.
View Types (VIEW_TYPES)
Defined in odoo/addons/base/models/ir_actions.py:
tree, form, graph, pivot, calendar, gantt, kanban — plus search and qweb recognised by ir.ui.view.
Basic Example
<record id="action_my_model" model="ir.actions.act_window">
<field name="name">My Records</field>
<field name="res_model">my.model</field>
<field name="view_mode">tree,kanban,form</field>
<field name="domain">[('active','=',True)]</field>
<field name="context">{'search_default_my_records': 1, 'default_user_id': uid}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">Create your first record!</p>
<p>Start by giving it a name.</p>
</field>
</record>Binding a Specific View
<record id="action_my_model" model="ir.actions.act_window">
<field name="name">Customers</field>
<field name="res_model">res.partner</field>
<field name="view_mode">tree,form</field>
<field name="view_id" ref="view_partner_tree_custom"/>
<field name="search_view_id" ref="view_partner_search_custom"/>
</record>Open a Single Record in a Dialog
<record id="action_open_wizard" model="ir.actions.act_window">
<field name="name">Configure</field>
<field name="res_model">my.config.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>Per-Mode View Ordering with ir.actions.act_window.view
When several views of the same type exist, or you want strict ordering, prefer ir.actions.act_window.view:
<record id="action_sale" model="ir.actions.act_window">
<field name="name">Sales</field>
<field name="res_model">sale.order</field>
<field name="view_mode">tree,kanban,form</field>
</record>
<record id="action_sale_tree" model="ir.actions.act_window.view">
<field name="sequence">1</field>
<field name="view_mode">tree</field>
<field name="view_id" ref="view_sale_tree_custom"/>
<field name="act_window_id" ref="action_sale"/>
</record>
<record id="action_sale_form" model="ir.actions.act_window.view">
<field name="sequence">2</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_sale_form_custom"/>
<field name="act_window_id" ref="action_sale"/>
</record>Target Values
| Value | Effect |
|---|---|
current (default) | Replace the main content area; breadcrumb added |
new | Open in a modal dialog |
inline | Edit the form inline without a modal (rare) |
fullscreen | Full-screen takeover |
main | Replace the main content and reset breadcrumbs |
---
URL Actions
ir.actions.act_url opens a URL — either in the same tab, a new tab, or triggers a download.
| Field | Values |
|---|---|
url | Any absolute/relative URL |
target | new (default), self, download |
<record id="action_open_docs" model="ir.actions.act_url">
<field name="name">Documentation</field>
<field name="url">https://www.odoo.com/documentation/17.0/</field>
<field name="target">new</field>
</record>Returned from Python:
return {
'type': 'ir.actions.act_url',
'url': '/web/binary/download?attachment_id=%s' % attachment.id,
'target': 'self',
}---
Server Actions
ir.actions.server runs server-side logic. The state field selects the flavour:
state | Purpose |
|---|---|
code | Execute a Python snippet |
object_create | Create a record of crud_model_id from the current context |
object_write | Update the current record (or records) |
multi | Run a list of other server actions sequentially (child_ids) |
webhook | Send an HTTP POST to an external URL |
(See odoo/addons/base/models/ir_actions.py::ServerActions.)
code — Run Python
<record id="action_server_notify" model="ir.actions.server">
<field name="name">Notify Owner</field>
<field name="model_id" ref="model_my_model"/>
<field name="state">code</field>
<field name="code">
if records:
for record in records:
record.message_post(body="Processed via server action")
action = {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Done',
'message': '%d record(s) processed' % len(records),
'type': 'success',
},
}
</field>
</record>code Evaluation Context
| Name | Description |
|---|---|
env | Current environment |
model | env[model_id.model] |
record | Current record, if any (may be empty) |
records | Recordset the action is run on |
action | Assign here to return a follow-up action |
log(msg, level='info') | Writes into ir.logging |
Warning | UserError constructor (aliased for legacy compat) |
datetime, dateutil, time, timezone, UserError, float_compare | Python helpers |
object_write — Update Record
<record id="action_mark_done" model="ir.actions.server">
<field name="name">Mark Done</field>
<field name="model_id" ref="model_my_model"/>
<field name="state">object_write</field>
<field name="update_path">state</field>
<field name="value">done</field>
</record>object_create — Create a Record
<record id="action_spawn_task" model="ir.actions.server">
<field name="name">Create Task</field>
<field name="model_id" ref="model_res_partner"/>
<field name="state">object_create</field>
<field name="crud_model_id" ref="project.model_project_task"/>
<field name="link_field_id" ref="project.field_project_task__partner_id"/>
</record>multi — Chain Actions
<record id="action_multi" model="ir.actions.server">
<field name="name">Process & Notify</field>
<field name="model_id" ref="model_my_model"/>
<field name="state">multi</field>
<field name="child_ids" eval="[(6, 0, [
ref('action_mark_done'),
ref('action_server_notify'),
])]"/>
</record>webhook — Outgoing HTTP Call
<record id="action_webhook" model="ir.actions.server">
<field name="name">Notify external CRM</field>
<field name="model_id" ref="model_my_model"/>
<field name="state">webhook</field>
<field name="webhook_url">https://hooks.example.com/odoo</field>
<field name="webhook_field_ids" eval="[(6, 0, [
ref('field_my_model__name'),
ref('field_my_model__state'),
])]"/>
</record>Common Server-Action Fields
| Field | Purpose |
|---|---|
model_id | Model the action is declared on |
crud_model_id | (object_create) target model to create |
link_field_id | (object_create) auto-link the new record to record via this field |
child_ids | (multi) ordered list of sub-actions |
update_path | (object_write) dot-path of the field to update |
value | (object_write) new value (string; coerced per field) |
code | (code) Python source |
webhook_url / webhook_field_ids | (webhook) endpoint + exported fields |
---
Report Actions
ir.actions.report prints a QWeb template.
Key Fields
| Field | Notes |
|---|---|
name | Default file name if print_report_name is empty |
model | Required — the model the report is about |
report_type | qweb-pdf, qweb-html, qweb-text (default qweb-pdf) |
report_name | External id of the QWeb template (required) |
report_file | Base name for generated files (without extension) |
print_report_name | Python expression using object (one record) for the file name |
paperformat_id | Defaults to the company's paperformat |
attachment_use | If True, reuse saved attachment instead of regenerating |
attachment | Python expression producing the attachment filename |
binding_model_id | Bind to the model's Print menu |
binding_type | 'report' (default for ir.actions.report) |
groups_id | Restrict by group |
XML Record
<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_template</field>
<field name="report_file">my_module.report_my_model</field>
<field name="print_report_name">'Report - %s' % (object.name or '')</field>
<field name="binding_model_id" ref="model_my_model"/>
<field name="paperformat_id" ref="base.paperformat_euro"/>
</record><report> Shortcut
Equivalent to the record above but shorter:
<report
id="action_report_my_model"
string="My Model Report"
model="my.model"
report_type="qweb-pdf"
name="my_module.report_my_model_template"
file="my_module.report_my_model"
print_report_name="'Report - %s' % (object.name or '')"
attachment_use="False"/>The shortcut automatically sets binding_model_id to the given model and binding_type='report', so the action appears under the model's Print menu.
---
Client Actions
ir.actions.client triggers a JS-side action registered via registry.category('actions').
| Field | Description |
|---|---|
tag | Registered client action identifier |
params | Arbitrary dict passed to the JS handler |
target | current, new, fullscreen, main |
<record id="action_open_dashboard" model="ir.actions.client">
<field name="name">Dashboard</field>
<field name="tag">my_module.dashboard</field>
<field name="params" eval="{'filter': 'my_open'}"/>
</record>Commonly used built-in tags:
| Tag | Effect |
|---|---|
reload | Reload the entire web client |
reload_context | Refresh the current action with latest context |
soft_reload | Soft reload without losing breadcrumbs |
display_notification | Show a toast (pass params: title, message, type, sticky) |
home | Go back to the home menu |
pos.ui | Launch the Point of Sale UI |
Example from Python:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Success',
'message': 'Data imported',
'type': 'success', # 'info', 'warning', 'danger', 'success'
'sticky': False,
'next': {'type': 'ir.actions.act_window_close'},
},
}---
Scheduled Actions
ir.cron schedules the execution of an ir.actions.server on an interval. Internally ir.cron delegates to a related ir.actions.server (ir_actions_server_id), so every scheduled job is also a server action.
Fields
| Field | Description |
|---|---|
name (via ir_actions_server_id) | Job name |
user_id | User whose permissions drive execution (default: current user) |
active | True to enable |
interval_number | Integer, combined with interval_type |
interval_type | minutes, hours, days, weeks, months |
numbercall | Remaining number of executions; -1 = unlimited (v17 still uses this) |
doall | If True, catch up on missed occurrences after downtime |
nextcall | Next planned execution datetime |
priority | Integer, lower runs first when several jobs are due (default 5) |
model_id | Model the underlying server action runs on |
code | Python snippet (same context as a code server action) |
state | Mirror of the server action state — typically code for crons |
Declaration
<record id="ir_cron_my_job" model="ir.cron">
<field name="name">My Module: Daily Sync</field>
<field name="model_id" ref="model_my_model"/>
<field name="state">code</field>
<field name="code">model._cron_daily_sync()</field>
<field name="user_id" ref="base.user_root"/>
<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"/>
<field name="priority">10</field>
</record>Wrap in <data noupdate="1"> if users are allowed to tweak the schedule:
<odoo>
<data noupdate="1">
<record id="ir_cron_my_job" model="ir.cron">
...
</record>
</data>
</odoo>interval_type Values
Defined in odoo/addons/base/models/ir_cron.py:
minutes, hours, days, weeks, months.
(weeks and months are computed with dateutil.relativedelta, so a "1 month" cron fired on Jan 31 will land on Feb 28/29.)
Writing Cron Code
class MyModel(models.Model):
_name = 'my.model'
@api.model
def _cron_daily_sync(self):
# Process records in batches to keep the worker responsive
limit = 500
to_do = self.search([('sync_required', '=', True)], limit=limit)
to_do.action_sync()
# Let the scheduler continue if work remains
if self.search_count([('sync_required', '=', True)]) > limit:
self.env['ir.cron']._trigger() # re-run ASAPGood practices:
- Always use
@api.modeland operate on explicit recordsets (neverself). - Batch work and signal remaining effort (
self.env['ir.cron']._trigger()can re-arm a job immediately). - Catch exceptions locally if one bad record should not poison the rest; otherwise let the scheduler mark the job failed.
- Cron users are
OdooBot(base.user_root) by default; make sure the code can read the data.
Triggering a Cron From Code
cron = self.env.ref('my_module.ir_cron_my_job')
cron._trigger() # runs as soon as possible
cron._trigger(at=datetime(2026, 1, 1, 3, 0)) # schedule a one-off extra runFailure Handling (v17)
- A scheduled action that raises is retried the next tick.
- Persistent failures are visible in the job log; administrators can deactivate the cron from the UI.
- Setting
numbercallto a positive integer runs the job N times and then auto-deactivates;-1runs indefinitely.
---
Menus
Menus live in ir.ui.menu. The usual way to create them is the <menuitem> shortcut (see the data-files guide for details).
Minimal Tree
<!-- Top-level app menu -->
<menuitem id="menu_my_module_root"
name="My Module"
sequence="50"
web_icon="my_module,static/description/icon.png"/>
<!-- First-level -->
<menuitem id="menu_my_records"
name="Records"
parent="menu_my_module_root"
action="action_my_model"
sequence="10"/>
<!-- Second-level -->
<menuitem id="menu_reporting"
name="Reporting"
parent="menu_my_module_root"
sequence="90"/>
<menuitem id="menu_report_analysis"
name="Analysis"
parent="menu_reporting"
action="action_report_analysis"
sequence="10"/>Attributes Recap
| Attribute | Purpose |
|---|---|
id | External ID |
name | Visible label |
parent | Parent menu external ID |
action | External ID of any action (act_window, server, client, url, ...) |
sequence | Integer, lower first (default 10) |
groups | Comma-separated group ids; prefix - to hide from a group |
web_icon | module,path/to/icon.png — only meaningful on top-level app menus |
active | True/False |
The <menuitem> shortcut automatically copies the action's name onto the menu if you don't set one (see convert.py::_tag_menuitem).
Security
Menus are filtered using the groups_id of both the menu and the referenced action. To hide a menu from a specific group, use -group.xml_id:
<menuitem id="menu_admin_tools"
name="Admin Tools"
parent="menu_my_module_root"
action="action_admin_tools"
groups="base.group_system,-base.group_portal"/>web_icon Asset
For top-level application menus, web_icon points to a PNG inside the module. The standard location is static/description/icon.png. Sub-menus do not need a web_icon.
---
Action Bindings
Any ir.actions.* can be "bound" to a model, making it appear in the Action or Print menu of that model's list/form views.
| Field | Description |
|---|---|
binding_model_id | ir.model ref — where the action is displayed |
binding_type | action (default) or report |
binding_view_types | Where in the UI: list / form / list,form (default) |
Server Action Binding (Action Menu)
<record id="action_bulk_archive" model="ir.actions.server">
<field name="name">Archive Selected</field>
<field name="model_id" ref="model_my_model"/>
<field name="state">code</field>
<field name="code">records.action_archive()</field>
<field name="binding_model_id" ref="model_my_model"/>
<field name="binding_type">action</field>
<field name="binding_view_types">list</field>
</record>Report Binding (Print Menu)
<record id="action_report_invoice_custom" model="ir.actions.report">
<field name="name">Custom Invoice</field>
<field name="model">account.move</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">my_module.report_invoice_custom</field>
<field name="binding_model_id" ref="account.model_account_move"/>
<!-- binding_type = 'report' is set automatically for ir.actions.report -->
</record>binding_view_types Values
| Value | Where shown |
|---|---|
list | Only in the list view (after selecting records) |
form | Only in the form view (for the current record) |
list,form | Default — both views |
---
Returning Actions From Python
Open a Form View
def action_open_partner(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Partner',
'res_model': 'res.partner',
'view_mode': 'form',
'res_id': self.partner_id.id,
'target': 'current',
}Open a Filtered List
def action_view_orders(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Orders',
'res_model': 'sale.order',
'view_mode': 'tree,form',
'domain': [('partner_id', '=', self.id)],
'context': {'default_partner_id': self.id, 'search_default_my_orders': 1},
}Open via External ID
def action_wizard(self):
action = self.env['ir.actions.actions']._for_xml_id('my_module.action_configure')
action['context'] = {'default_partner_id': self.id}
return actionClose a Dialog
return {'type': 'ir.actions.act_window_close'}Reload / Notify
return {'type': 'ir.actions.client', 'tag': 'reload'}
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {'title': 'Saved', 'type': 'success', 'next': {'type': 'ir.actions.act_window_close'}},
}---
Quick Reference
Action Types
| Type | Model | Use |
|---|---|---|
| Window | ir.actions.act_window | Open views for a model |
| URL | ir.actions.act_url | Open / download a URL |
| Server | ir.actions.server | Run server Python / create / update / multi / webhook |
| Report | ir.actions.report | QWeb PDF / HTML / Text |
| Client | ir.actions.client | Registered JS action |
| Cron | ir.cron | Schedule a server action |
Targets
| Value | Effect |
|---|---|
current | Replace main content |
new | Open in dialog |
inline | Edit inline (form only) |
fullscreen | Hide the chrome |
main | Replace main content, reset breadcrumbs |
Minimum XML for Each Type
<!-- Window -->
<record id="a1" model="ir.actions.act_window">
<field name="name">Customers</field>
<field name="res_model">res.partner</field>
<field name="view_mode">tree,form</field>
</record>
<!-- URL -->
<record id="a2" model="ir.actions.act_url">
<field name="name">Docs</field>
<field name="url">https://odoo.com</field>
<field name="target">new</field>
</record>
<!-- Server -->
<record id="a3" model="ir.actions.server">
<field name="name">Archive</field>
<field name="model_id" ref="model_my_model"/>
<field name="state">code</field>
<field name="code">records.action_archive()</field>
</record>
<!-- Report -->
<report id="a4" model="my.model" string="My Report"
report_type="qweb-pdf" name="my_module.tpl"/>
<!-- Client -->
<record id="a5" model="ir.actions.client">
<field name="name">Dashboard</field>
<field name="tag">my_module.dashboard</field>
</record>
<!-- Cron -->
<record id="a6" model="ir.cron">
<field name="name">Daily Sync</field>
<field name="model_id" ref="model_my_model"/>
<field name="state">code</field>
<field name="code">model._cron_daily_sync()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
</record>Menu With Action
<menuitem id="menu_root" name="My Module" sequence="50"/>
<menuitem id="menu_records"
parent="menu_root"
action="a1"
sequence="10"/>---
Base Code Reference
odoo/addons/base/models/ir_actions.py—IrActions,act_window,act_url,server,client,act_window_view,VIEW_TYPES, defaultview_mode='tree,form'(v17).odoo/addons/base/models/ir_actions_report.py—ir.actions.report,report_typeselection (qweb-pdf,qweb-html,qweb-text), paperformat handling.odoo/addons/base/models/ir_cron.py—ir.cron,interval_type,numbercall,doall,nextcall,priority,_trigger(), batching, the underlyingir_actions_server_idlink.odoo/addons/base/models/ir_ui_menu.py—ir.ui.menumodel and access filtering.odoo/tools/convert.py— data-loader shortcuts (<menuitem>,<report>,<template>,<asset>).
Odoo 17 Controller Guide
Complete reference for Odoo 17 HTTP controllers, routing, and request handling.
Table of Contents
1. Controller Basics 2. @route Decorator 3. Authentication Types 4. Request/Response Types 5. Request Object API 6. CSRF Handling 7. CORS Handling 8. File Uploads 9. File Downloads 10. Common Patterns 11. Best Practices
---
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 on every public method. - Access
request(the module-level proxy inodoo.http) for env, session, HTTP data. - Return the right response type for the route
type.
Controller Inheritance / Override
In Odoo 17 controllers are extended by Python inheritance (not registry-based). Any method you override MUST be re-decorated with @http.route(); arguments you omit are inherited from the parent.
from odoo import http
from odoo.addons.web.controllers.home import Home
class MyHome(Home):
@http.route() # keep path, type, auth from parent
def index(self, *args, **kw):
# custom logic
return super().index(*args, **kw)Module Layout
my_module/
├── __init__.py
├── __manifest__.py
├── controllers/
│ ├── __init__.py # from . import main, api
│ ├── main.py
│ └── api.py
└── ...Remember to from . import controllers in the root __init__.py.
---
@route Decorator
Basic Route
from odoo import http
class HelloController(http.Controller):
@http.route('/hello', type='http', auth='user')
def hello(self):
return "Hello World!"URL Parameters (Werkzeug Converters)
from odoo.http import request
# Integer 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})
# Model converter - resolves an ID to a recordset automatically
@http.route('/order/<model("sale.order"):order>', type='http', auth='user')
def order_by_model(self, order):
return request.render('sale.order_view', {'order': order})
# Wildcard / path parameter
@http.route('/download/<path:file_path>', type='http', auth='user')
def download_any(self, file_path):
...
# Query string parameters arrive as kwargs
@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 (or list of paths)
type='http', # 'http' or 'json'
auth='user', # 'user' (default), 'public', 'none'
methods=['GET', 'POST'], # Allowed HTTP methods (None = all)
csrf=True, # CSRF protection (default True for http, False for json)
cors='*', # Access-Control-Allow-Origin value
website=True, # Defined by website module; enables website layout/context
sitemap=False, # Website-only: include in sitemap
save_session=True, # Persist session changes after the request
)
def my_handler(self):
...Notes on Odoo 17 specifics:
authaccepts exactly'user','public','none'. There is NOauth='bearer'in v17 (added in later versions). Implement token auth manually withauth='none'orauth='public'+ header check (see API Endpoint pattern below).methods=Noneallows every HTTP verb.csrfdefault isTruefortype='http', effectively ignored fortype='json'.website=Truerequires thewebsitemodule installed.
Multiple Paths on One Handler
@http.route(['/path1', '/path2'], type='http', auth='public')
def dual(self):
return "Same handler for both paths"
# Or stack decorators
@http.route('/alpha', type='http', auth='public')
@http.route('/beta', type='http', auth='public')
def stacked(self):
return "Hello"---
Authentication Types
auth='user' (Default)
Requires an authenticated user. Unauthenticated requests are redirected to the login page.
@http.route('/my/orders', type='http', auth='user')
def my_orders(self):
orders = request.env['sale.order'].search([
('user_id', '=', request.env.user.id),
])
return request.render('my_module.orders', {'orders': orders})Behavior:
request.env.useris the logged-in user.request.env.uidis that user's id.- Normal ACL / record rules apply.
auth='public'
Allows unauthenticated access, using the shared "Public user" record.
@http.route('/shop/products', type='http', auth='public')
def shop_products(self):
products = request.env['product.product'].search([
('website_published', '=', True),
])
return request.render('my_module.shop', {'products': products})Behavior:
- If the visitor is logged in, runs as that user.
- If not, runs as the public user (usually limited access).
- ACL / record rules still apply; use
.sudo()only when truly needed.
auth='none'
No environment, no database authentication at all.
@http.route('/healthz', type='http', auth='none', csrf=False)
def healthcheck(self):
return "OK"Behavior:
request.envis NOT available.- Route works even when no database is selected.
- Use for static endpoints, health checks, some login screens, or manual token verification against external services.
Odoo 17 does NOT have auth='bearer'
auth='bearer' was introduced in later Odoo versions. In 17, implement your own bearer-token logic:
from odoo import http
from odoo.http import request
from werkzeug.exceptions import Unauthorized
class ApiAuth(http.Controller):
@http.route('/api/v1/me', type='json', auth='none', csrf=False)
def api_me(self, **params):
token = request.httprequest.headers.get('Authorization', '')
if not token.startswith('Bearer '):
raise Unauthorized()
token = token[7:]
# Resolve token -> user (custom model), then manually set up env
user = request.env(su=True)['api.token']._authenticate(token)
if not user:
raise Unauthorized()
request.update_env(user=user.id)
return {'login': user.login, 'name': user.name}---
Request/Response Types
type='http' - HTML / Text / Binary
The handler's return value can be:
- A
strorbytes(taken as the body). - An
odoo.http.Responsecreated withrequest.make_response/request.render/request.redirect/request.not_found. - A Werkzeug response object.
from odoo.http import request
# Render a 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([]),
})
# Plain text
@http.route('/ping', type='http', auth='none')
def ping(self):
return "PONG"
# Response with headers
@http.route('/inline_pdf/<int:doc_id>', type='http', auth='user')
def inline_pdf(self, doc_id):
report = request.env.ref('my_module.action_report')
pdf, _ = report._render_qweb_pdf(report.report_name, [doc_id])
return request.make_response(pdf, headers=[
('Content-Type', 'application/pdf'),
('Content-Length', len(pdf)),
])
# Redirect (local or absolute)
@http.route('/go', type='http', auth='user')
def go(self):
return request.redirect('/web')
# 404
@http.route('/maybe/<int:rec_id>', type='http', auth='user')
def maybe(self, rec_id):
rec = request.env['my.model'].browse(rec_id).exists()
if not rec:
return request.not_found()
return request.render('my_module.detail', {'rec': rec})type='json' - JSON-RPC 2.0
In Odoo 17, type='json' expects a JSON-RPC 2.0 envelope in the POST body:
{
"jsonrpc": "2.0",
"method": "call",
"params": {"id": 42, "limit": 10},
"id": 1
}The endpoint receives the dict under "params" as keyword arguments; the return value is serialized as the "result" field of the response.
@http.route('/api/records', type='json', auth='user')
def get_records(self, domain=None, limit=80, offset=0):
domain = domain or []
records = request.env['my.model'].search_read(
domain, ['id', 'name', 'state'],
limit=limit, offset=offset,
)
return {'records': records, 'count': len(records)}Calling from OWL:
// Using the rpc service (Odoo 17 pattern)
this.rpc = useService("rpc");
const res = await this.rpc("/api/records", { domain: [["state", "=", "open"]], limit: 20 });type='json' behavior:
- CSRF is disabled by default (the JSON-RPC client handles the exchange).
- Exceptions raised in the handler are serialized as JSON-RPC errors.
- Python
UserError/AccessErrorsurface to the browser as user-friendly dialogs.
---
Request Object API
from odoo.http import request
Environment
request.env # odoo.api.Environment bound to the authenticated user
request.env.user # res.users recordset (the current user)
request.env.company # Current company (res.company)
request.env.companies # Allowed companies (res.company recordset)
request.env.lang # Language code of the user's context
request.env.uid # Integer user id
request.env.context # Context dict (lang, tz, allowed_company_ids, ...)You can .sudo() the env like in any ORM context:
partner = request.env['res.partner'].sudo().browse(partner_id)Session
request.session # dict-like Session object
request.session.uid # Logged-in user id (or None)
request.session.db # Current database name
request.session['cart_id'] # Custom session entries (must be JSON-serialisable)
request.session.get('foo') # Safe readHTTP Data
request.httprequest # Underlying werkzeug.wrappers.Request
request.httprequest.method # 'GET', 'POST', ...
request.httprequest.headers # werkzeug Headers
request.httprequest.files # FileStorage dict (multipart uploads)
request.httprequest.remote_addr
request.params # Merged query string + form body + JSON params
request.db # Current db name or None
request.csrf_token() # Fresh CSRF token (use in forms)Response Helpers
request.render(template, values=None, **kw) # Lazy QWeb rendering
request.make_response(body, headers=None, cookies=None, status=200)
request.make_json_response(data, headers=None, cookies=None, status=200)
request.not_found(description=None)
request.redirect(location, code=303, local=True)
request.redirect_query(location, query=None, code=303, local=True)---
CSRF Handling
Default Behavior
type='http'POST/PUT/DELETE require a CSRF token (csrf_tokenform field).- GET/HEAD/OPTIONS never require CSRF.
type='json'doesn't check CSRF (JSON-RPC clients handle the exchange themselves).
Form with CSRF Token
<form action="/my/form/submit" method="POST">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="text" name="email"/>
<button type="submit">Send</button>
</form>@http.route('/my/form/submit', type='http', auth='public', methods=['POST'])
def form_submit(self, email=None, **kw):
# csrf_token already validated by the framework
request.env['newsletter.signup'].sudo().create({'email': email})
return request.redirect('/thanks')Disabling CSRF (Webhooks / Server-to-Server)
Only disable CSRF for endpoints you protect another way (HMAC signature, IP whitelist, bearer token):
@http.route('/webhook/payment', type='http', auth='none', methods=['POST'], csrf=False)
def payment_webhook(self, **kwargs):
signature = request.httprequest.headers.get('X-Hub-Signature')
# Verify HMAC here
...
return "OK"---
CORS Handling
Set the cors option on the route. Odoo 17 emits Access-Control-Allow-Origin and automatically serves preflight OPTIONS with compatible headers.
@http.route('/api/v1/status', type='json', auth='none', cors='*', csrf=False)
def api_status(self):
return {'status': 'ok'}
# Restrict to a specific origin
@http.route('/api/v1/orders', type='json', auth='user', cors='https://partner.example.com')
def api_orders(self, **kw):
...For public APIs, combine cors='*' with csrf=False (since browsers can't send CSRF tokens cross-origin). Prefer type='json' so the framework handles preflight correctly.
---
File Uploads
Multipart file uploads arrive in request.httprequest.files. Each entry is a werkzeug.datastructures.FileStorage.
import base64
from odoo import http
from odoo.http import request
class UploadController(http.Controller):
@http.route('/my/upload', type='http', auth='user', methods=['POST'])
def upload(self, **post):
upload = request.httprequest.files.get('file')
if not upload:
return request.make_response("No file", status=400)
data = upload.read() # bytes
attachment = request.env['ir.attachment'].create({
'name': upload.filename,
'datas': base64.b64encode(data),
'mimetype': upload.mimetype,
'res_model': 'my.model',
'res_id': int(post.get('res_id', 0)) or False,
})
return request.redirect(f'/web#id={attachment.res_id}&model=my.model')HTML form:
<form action="/my/upload" method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="res_id" t-att-value="rec.id"/>
<input type="file" name="file"/>
<button type="submit">Upload</button>
</form>For large uploads, stream with upload.stream instead of upload.read().
---
File Downloads
Option 1: request.make_response (small payloads)
@http.route('/download/report/<int:report_id>', type='http', auth='user')
def download_report(self, report_id):
report = request.env['ir.actions.report'].browse(report_id)
pdf, _ = report._render_qweb_pdf(report.report_name, [report_id])
return request.make_response(pdf, headers=[
('Content-Type', 'application/pdf'),
('Content-Disposition', f'attachment; filename="{report.name}.pdf"'),
('Content-Length', len(pdf)),
])Option 2: http.Stream (Odoo 17 preferred helper)
odoo.http.Stream is the recommended way to serve files or binary fields with proper caching, ETag, and conditional support.
from odoo import http
from odoo.http import Stream, request
class DownloadController(http.Controller):
@http.route('/download/attachment/<int:attachment_id>', type='http', auth='user')
def download_attachment(self, attachment_id):
attachment = request.env['ir.attachment'].browse(attachment_id).exists()
if not attachment:
return request.not_found()
attachment.check('read') # ACL
return Stream.from_attachment(attachment).get_response(as_attachment=True)
@http.route('/download/field/<int:rec_id>', type='http', auth='user')
def download_binary_field(self, rec_id):
rec = request.env['my.model'].browse(rec_id).exists()
if not rec:
return request.not_found()
return Stream.from_binary_field(rec, 'binary_field').get_response(
as_attachment=True,
)Stream constructors in v17:
Stream.from_path(path, filter_ext=('',), public=False)— serve a file on disk.Stream.from_attachment(attachment)— from anir.attachmentrecord.Stream.from_binary_field(record, field_name)— from a binary/image field.
---
Common Patterns
JSON Endpoint for Frontend
from odoo import http
from odoo.http import request
class MyWebController(http.Controller):
@http.route('/my/data', type='json', auth='user')
def get_data(self, domain=None, fields=None):
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):
record = request.env['my.model'].browse(record_id).exists()
if not record:
return {'error': 'Record not found'}
if action_type == 'validate':
record.action_validate()
elif action_type == 'cancel':
record.action_cancel()
return {'success': True, 'state': record.state}OWL side:
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
setup() {
this.rpc = useService("rpc");
}
async loadData() {
const res = await this.rpc("/my/data", { domain: [["state", "=", "open"]] });
this.state.records = res.records;
}Website Page
from odoo import http
from odoo.http import request
class WebsiteCatalog(http.Controller):
@http.route('/shop', type='http', auth='public', website=True, sitemap=True)
def shop(self, **kw):
products = request.env['product.template'].search([
('website_published', '=', True),
('sale_ok', '=', True),
])
return request.render('my_module.shop', {
'products': products,
})
@http.route('/shop/<model("product.template"):product>',
type='http', auth='public', website=True)
def product(self, product, **kw):
return request.render('my_module.product', {
'product': product,
})External API Endpoint (Token-Protected)
from odoo import http
from odoo.http import request
from werkzeug.exceptions import Forbidden
class SalesApi(http.Controller):
def _check_api_key(self):
key = request.httprequest.headers.get('X-Api-Key')
expected = request.env['ir.config_parameter'].sudo().get_param('my_module.api_key')
if not key or key != expected:
raise Forbidden()
@http.route('/api/v1/orders', type='json', auth='user', csrf=False, cors='*')
def list_orders(self, domain=None, limit=80):
self._check_api_key()
orders = request.env['sale.order'].sudo().search_read(
domain or [],
['id', 'name', 'state', 'amount_total', 'partner_id'],
limit=limit,
)
return {'orders': orders}
@http.route('/api/v1/orders/<int:order_id>', type='json', auth='user', csrf=False, cors='*')
def get_order(self, order_id):
self._check_api_key()
order = request.env['sale.order'].sudo().browse(order_id).exists()
if not order:
return {'error': 'not_found'}
return order.read(['name', 'state', 'amount_total'])[0]Webhook (No CSRF, HMAC-Verified)
import hmac
import hashlib
from odoo import http
from odoo.http import request
class StripeWebhook(http.Controller):
@http.route('/webhook/stripe', type='http', auth='none',
methods=['POST'], csrf=False)
def stripe_webhook(self, **kwargs):
secret = request.env(su=True)['ir.config_parameter'].get_param('stripe.webhook_secret')
signature = request.httprequest.headers.get('Stripe-Signature', '')
payload = request.httprequest.get_data()
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
return request.make_response("bad signature", status=400)
# Process event
...
return "OK"Error Handling
from odoo import http
from odoo.http import request
from odoo.exceptions import UserError, AccessError
class ActionController(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 as e:
return {'error': 'access_denied', 'message': str(e)}
except UserError as e:
return {'error': 'user_error', 'message': str(e)}
@http.route('/page', type='http', auth='user')
def my_page(self):
try:
return request.render('my_module.template', {
'data': self._get_data(),
})
except UserError as e:
return request.render('my_module.error', {'message': str(e)})
except Exception:
return request.not_found()Response Methods Quick Reference
request.render('module.template', values) # Lazy QWeb render
request.make_response(body, headers=[...], status=200) # Raw body + headers
request.make_json_response({'key': 'value'}, status=200) # JSON body
request.redirect('/somewhere') # 303 redirect (local)
request.redirect('https://other.example', local=False) # Absolute redirect
request.not_found() # 404---
Best Practices
1. Keep controllers thin. Move business logic into models. Controllers handle HTTP concerns only. 2. Pick the right `type`. 'http' for pages, forms, downloads; 'json' for frontend RPC calls and machine APIs. 3. Default to `auth='user'`. Use 'public' only for unauthenticated features; 'none' only when you must bypass the ORM entirely. 4. Don't `sudo()` by reflex. Prefer proper ACLs / record rules; sudo() is for deliberate privilege elevation. 5. Validate inputs. int(kwargs.get('id', 0)), enum whitelists, length checks — the browser is untrusted. 6. Check existence. rec.exists() before operating on browsed ids. 7. Respect CSRF. Only disable it when you authenticate the request another way (HMAC, API key, bearer token). 8. Set `cors` explicitly for JSON APIs used cross-origin, and pair with csrf=False. 9. Use `Stream` for downloads. Proper caching, ETag, conditional GET come for free. 10. Raise werkzeug exceptions for HTTP errors (NotFound, Forbidden, BadRequest) rather than returning ad-hoc strings.
---
Base Code Reference
The APIs documented here are defined in the Odoo 17 source:
odoo/http.py—Controller,route,Request,Response,Stream, session, dispatching.odoo/exceptions.py—UserError,AccessError,ValidationError.addons/web/controllers/report.py— reference controllers, e.g./report/barcode,/report/download.addons/web/controllers/home.py— base web client routes (useful for inheritance examples).
Odoo 17 Data Files Guide
Reference for Odoo 17 data files: XML structure, record/field/delete/function tags, shortcuts, CSV files, and value resolution.
Table of Contents
1. Data File Structure 2. External IDs 3. `<record>` Tag 4. `<field>` Tag Values (eval / ref / search / obj / type / file) 5. Relational Field Commands 6. `<delete>` Tag 7. `<function>` Tag 8. Shortcuts (`<menuitem>`, `<template>`, `<asset>`) 9. CSV Data Files 10. `noupdate` Attribute 11. Loading Order & Modes 12. Quick Reference
---
Data File Structure
Every XML data file is a well-formed XML document whose root is <odoo>:
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- (Re-)loaded at install and at every module upgrade -->
<record id="always_updatable" model="my.model">
<field name="name">Value</field>
</record>
<!-- Loaded on install; skipped on upgrade -->
<data noupdate="1">
<record id="user_editable" model="my.model">
<field name="name">Default</field>
</record>
</data>
</odoo>Accepted Top-Level Tags
The loader (odoo/tools/convert.py) recognises these children of <odoo> / <data>:
| Tag | Purpose |
|---|---|
<record> | Create or update a record of an arbitrary model |
<delete> | Delete records by id or by search domain |
<function> | Call an arbitrary model method |
<menuitem> | Shortcut to create an ir.ui.menu |
<template> | Shortcut to create a QWeb ir.ui.view |
<report> | Shortcut to create an ir.actions.report |
<act_window> | Shortcut to create an ir.actions.act_window (legacy) |
<asset> | Shortcut to create an ir.asset |
Any other tag at the top level is silently ignored by the loader.
File Locations
Convention-based, declared via the data: / demo: keys of __manifest__.py:
| Directory | When Loaded |
|---|---|
data/ | Always, at install and upgrade |
demo/ | Only when --without-demo is not set |
security/ | Typically contains ir.model.access.csv and record-rule XML (loaded from data: in the manifest) |
views/ | Views/menus/actions (loaded from data:) |
---
External IDs
Every data record is addressed by an external ID (aka XML-id), stored in ir.model.data.
Format: module.name
- Inside the same module,
module.can be omitted:<field name="user_id" ref="admin"/>is equivalent toref="<current_module>.admin". - Across modules always use the full form:
ref="base.user_admin". - Only one dot is allowed:
module.identifier(the identifier itself must not contain a dot).
<record id="my_partner" model="res.partner">
<field name="name">Corp</field>
</record>
<record id="my_contact" model="res.partner">
<field name="name">Jane</field>
<field name="parent_id" ref="my_partner"/> <!-- same module -->
<field name="country_id" ref="base.us"/> <!-- cross module -->
</record>---
<record> Tag
<record id="partner_acme" model="res.partner">
<field name="name">ACME Corp</field>
<field name="is_company" eval="True"/>
<field name="country_id" ref="base.us"/>
</record><record> Attributes
| Attribute | Purpose |
|---|---|
id | External ID (recommended, required to make the record updatable) |
model | Target model (required) |
context | Extra context dict passed to create/write (rarely needed) |
forcecreate | If "0"/"false" and the record does not exist during an update run inside a noupdate="1" block, skip creation instead of raising |
Create vs. Update
The loader decides based on the existence of the external ID:
- First time the file is loaded →
create. - Subsequent upgrades →
write(unless the data is protected bynoupdate="1").
If you re-declare only a subset of the fields, only those fields are written:
<!-- Later XML (same id) only updates `email` -->
<record id="partner_acme" model="res.partner">
<field name="email">info@acme.test</field>
</record>An empty <record> (no <field> children) produces no write.
---
<field> Tag Values
A <field> sets one attribute of the record. Its value is resolved in the following priority:
1. search="..." — evaluate a domain, use the first match (or all matches for many2many). 2. ref="..." — resolve an external ID. 3. eval="..." — evaluate a Python expression. 4. Inline text (interpreted based on type="...", default char).
Empty Field = False
<field name="partner_id"/> <!-- Sets to False -->Direct Value
<field name="name">ACME Corp</field>
<field name="ref">ACME-001</field>Leading / trailing whitespace is preserved. Numeric/boolean coercion is driven by the destination field's ORM type (so <field name="active">1</field> writes True on a Boolean).
type="..." — Interpretation Hints
| Type | Semantics |
|---|---|
char (default) | Plain string |
int | Integer (None maps to Python None) |
float | Float |
xml / html | Serialise child XML/HTML into the field (wraps multiple roots in a <data> container) |
file | Stored as "module,/path" - validated against addons_path |
base64 | Base64-encode the bytes read from file= |
list / tuple | Build a list/tuple from nested <value> children |
<!-- HTML body -->
<field name="description" type="html">
<p>Read the <a href="https://odoo.com">docs</a>.</p>
</field>
<!-- Binary file loaded from a module path -->
<field name="data" type="base64" file="my_module/static/src/img/logo.png"/>
<!-- File path reference -->
<field name="image_path" type="file" name="my_module/static/src/img/photo.jpg"/>
<!-- Integer -->
<field name="priority" type="int">10</field>
<!-- List -->
<field name="tags" type="list">
<value>alpha</value>
<value>beta</value>
<value eval="'gamma'"/>
</field>eval="..." — Python Expression
Evaluated with safe_eval. The evaluation context (see convert.py::_get_idref) includes:
| Name | Description |
|---|---|
True, False, None | Python literals |
ref | ref('module.xmlid') → database integer id |
obj | obj('res.partner') → browse by model name (resolves to env['res.partner'].browse) |
Command | odoo.fields.Command helpers (Command.link, Command.set, ...) |
time, datetime, DateTime, timedelta, relativedelta | Date/time helpers |
pytz | Timezone helper |
version | Odoo major version string (e.g. "17.0") |
<field name="active" eval="True"/>
<field name="amount" eval="19.95"/>
<field name="date" eval="(datetime.date.today() + relativedelta(days=30)).strftime('%Y-%m-%d')"/>
<field name="groups_id" eval="[Command.link(ref('base.group_user'))]"/>
<field name="tag_ids" eval="[Command.set([ref('tag_a'), ref('tag_b')])]"/>ref="..." — External ID Reference
Resolves an external ID to its database integer id. Works on Many2one and on any field expecting an id.
<field name="user_id" ref="base.user_admin"/>
<field name="country_id" ref="base.us"/>On a reference (polymorphic) field, ref sets "model,id" automatically:
<field name="resource_ref" ref="base.user_admin"/>
<!-- Stored as "res.users,<id>" -->search="..." — Domain Search
Evaluate an ORM domain, take the result.
<record id="demo_partner" model="res.partner">
<field name="name">Demo</field>
<field name="country_id" search="[('code','=','US')]"/> <!-- first match -->
<field name="category_id" search="[('name','in',['VIP','Gold'])]"/>
<!-- For Many2many: all matched ids become the linked set -->
</record>Rules:
- Many2one: uses the first matching record.
- Many2many: uses the full set, written via
Command.set. - Use
use="..."to pick a field other thanid.
obj — Browse Records in eval
Inside eval, obj('model.name') returns a recordset proxy. Useful for computed defaults:
<record id="seq_my_model" model="ir.sequence">
<field name="name">My sequence</field>
<field name="prefix">MM-</field>
<field name="padding" eval="obj('res.company').search([], limit=1).id or 4"/>
</record>Inline File Content
<!-- File path stored as "module,path" (for fields that expect a relative path) -->
<field name="image_path" type="file" name="my_module/static/src/img/photo.png"/>
<!-- File content base64-encoded (for Binary fields) -->
<field name="icon" type="base64" file="my_module/static/description/icon.png"/>---
Relational Field Commands
Odoo 17 uses odoo.Command (exposed in eval as Command) or the legacy tuple form.
| Command | Tuple form | Effect |
|---|---|---|
Command.create(values) | (0, 0, {values}) | Create a related record |
Command.update(id, values) | (1, id, {values}) | Update the linked record |
Command.delete(id) | (2, id, 0) | Delete the record and unlink |
Command.unlink(id) | (3, id, 0) | Unlink only |
Command.link(id) | (4, id, 0) | Link (for M2M/O2M) |
Command.clear() | (5, 0, 0) | Clear all links |
Command.set([ids]) | (6, 0, [ids]) | Replace the link set |
Examples:
<!-- Replace tags (M2M) -->
<field name="category_id" eval="[Command.set([ref('cat_customer'), ref('cat_supplier')])]"/>
<!-- Add a tag without removing existing -->
<field name="category_id" eval="[Command.link(ref('cat_vip'))]"/>
<!-- Clear -->
<field name="line_ids" eval="[Command.clear()]"/>
<!-- Create inline child (O2M) -->
<field name="line_ids" eval="[
Command.create({'name': 'Line A', 'price': 10.0}),
Command.create({'name': 'Line B', 'price': 20.0}),
]"/>Legacy tuples still work everywhere:
<field name="groups_id" eval="[(6, 0, [ref('base.group_user')])]"/>Inline O2M via Nested <record>
A nicer way to create children with their own external IDs:
<record id="order_demo" model="sale.order">
<field name="partner_id" ref="base.res_partner_2"/>
<record id="order_demo_line_1" model="sale.order.line">
<field name="product_id" ref="product.product_product_1"/>
<field name="product_uom_qty">1</field>
</record>
<record id="order_demo_line_2" model="sale.order.line">
<field name="product_id" ref="product.product_product_2"/>
<field name="product_uom_qty">2</field>
</record>
</record>The loader writes the parent first, then inserts each nested record with the right inverse_name set to the parent's id.
---
<delete> Tag
Remove records at load time.
<!-- By external ID -->
<delete model="res.partner" id="legacy.partner_old"/>
<!-- By domain (removes all matching) -->
<delete model="ir.ui.menu" search="[('name','=','Obsolete')]"/>id and search are mutually exclusive. If id does not exist the loader logs a warning and continues.
---
<function> Tag
Invoke a method on a model.
<!-- With eval -> positional args list -->
<function model="res.partner" name="create"
eval="[{'name': 'From XML', 'email': 'x@example.com'}]"/>
<!-- With explicit <value> children -->
<function model="my.module" name="setup_defaults">
<value>arg1</value>
<value eval="ref('base.user_admin')"/>
<value name="company_id" eval="ref('base.main_company')"/> <!-- becomes kwarg -->
</function>
<!-- Nested function: the inner call's result is passed as args -->
<function model="res.partner" name="write">
<function model="res.partner" name="search"
eval="[[('vip','=',True)]]"/>
<value eval="{'category_id': [Command.link(ref('cat_vip'))]}"/>
</function>Note: inside a <data noupdate="1"> block, <function> is skipped except during an init install (convert.py::_tag_function). Use it for post-install hooks, cache clearing, or one-shot migrations.
---
Shortcuts
<menuitem> — ir.ui.menu Shortcut
<menuitem id="menu_root" name="My Module" sequence="10" web_icon="my_module,static/description/icon.png"/>
<menuitem id="menu_records"
name="Records"
parent="menu_root"
action="action_my_records"
sequence="1"/>
<!-- Nested (auto-creates intermediate from id of another menuitem) -->
<menuitem id="menu_reporting" parent="menu_root" name="Reporting"/>
<menuitem id="menu_report_analysis"
parent="menu_reporting"
action="action_report_analysis"/>
<!-- Security groups (prefix "-" to remove) -->
<menuitem id="menu_admin_only"
name="Administration"
parent="menu_root"
groups="base.group_system"/>
<menuitem id="menu_regular_users"
name="Users"
parent="menu_root"
groups="base.group_user,-base.group_portal"/>
<!-- Nested via XML nesting (rarely used; equivalent to parent=) -->
<menuitem id="menu_outer" name="Outer">
<menuitem id="menu_inner" name="Inner" action="action_x"/>
</menuitem>| Attribute | Description |
|---|---|
id | External ID (required) |
name | Label (defaults to id if omitted) |
parent | Parent menu external ID |
action | External ID of any ir.actions.* record |
sequence | Integer; lower sorts first |
groups | Comma-separated groups; prefix - to exclude |
web_icon | module,path/to/icon.png — used for top-level app menus |
active | "True" / "False" |
<template> — QWeb ir.ui.view Shortcut
<template id="landing_page" name="Landing Page">
<div class="container">
<h1 t-out="title"/>
</div>
</template>
<!-- Inheritance -->
<template id="landing_page_extra" inherit_id="my_module.landing_page">
<xpath expr="//h1" position="after">
<p>Extended</p>
</xpath>
</template>
<!-- Primary (a clone rather than a modifier) -->
<template id="landing_alternative"
inherit_id="my_module.landing_page"
primary="True"/>
<!-- Groups, active, priority -->
<template id="admin_only" groups="base.group_system" priority="20" active="True">
...
</template>The shortcut expands to an ir.ui.view record with type="qweb" and arch set to the inner XML.
<asset> — ir.asset Shortcut
<asset id="my_module_backend" name="My Module Backend Assets">
<bundle>web.assets_backend</bundle>
<path>my_module/static/src/js/my_component.js</path>
<path>my_module/static/src/xml/my_component.xml</path>
<path>my_module/static/src/scss/my_component.scss</path>
</asset>
<!-- With a directive (include/replace/before/after) -->
<asset id="website_style" name="Website Styles">
<bundle directive="append">web.assets_frontend</bundle>
<path>my_module/static/src/scss/overrides.scss</path>
</asset>The same result can be declared via the 'assets' key in __manifest__.py. Use <asset> records only for conditional or module-level assets; prefer the manifest key for the common case.
<report> Shortcut (Actions)
<report
id="action_report_my_model"
string="My Report"
model="my.model"
report_type="qweb-pdf"
name="my_module.my_report_template"
file="my_module.my_report"
attachment_use="True"
attachment="'Report-' + (object.name or '').replace('/', '_') + '.pdf'"
print_report_name="'Report-%s' % (object.name)"/>This expands to an ir.actions.report record.
---
CSV Data Files
CSV files are the go-to format for flat, bulk data (access rights, translations, country lookups, etc.).
Naming
The file name is <model_with_dots_as_underscores>.csv:
| Model | File |
|---|---|
ir.model.access | ir.model.access.csv |
res.country.state | res.country.state.csv |
Structure
- Header row: field names.
idreferences the external id. - Each subsequent row is one record.
- Use
:idsuffix on a column to look up related records by their external id.
id,country_id:id,name,code
state_us_ca,base.us,California,CA
state_us_ny,base.us,New York,NYTypical Use Cases
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,my_module.model_my_model,base.group_user,1,0,0,0
access_my_model_manager,my.model.manager,my_module.model_my_model,my_module.group_manager,1,1,1,1Simple Master Data
id,name,code,active
tag_vip,VIP,VIP,True
tag_new,New Customer,NEW,TrueCSV vs XML
| CSV | XML |
|---|---|
| Simpler for bulk seed data | Supports all features (eval, search, type, relational commands) |
| No Python evaluation | Required for HTML blocks, complex relationships |
Only :id reference shorthand | Full external-id + domain search |
| Single model per file | Multiple models per file |
---
noupdate Attribute
noupdate controls whether records in a block are refreshed on -u (module update).
<odoo>
<!-- Always refreshed on upgrade -->
<record id="view_x_form" model="ir.ui.view">
<field name="model">x</field>
<field name="arch" type="xml">...</field>
</record>
<!-- Only inserted once (install), never overwritten -->
<data noupdate="1">
<record id="default_company_website" model="res.company">
<field name="website">https://example.com</field>
</record>
</data>
</odoo>noupdate On the Record Itself
You can also set noupdate on a single record's ir.model.data:
<record id="my_partner" model="res.partner">
<field name="name">Partner</field>
</record>
<!-- Then protect it in a second pass -->
<function model="ir.model.data" name="write" eval="[
[ref('__ir_model_data_id_of_my_partner__')],
{'noupdate': True},
]"/>In practice you wrap records in <data noupdate="1">. The convenience is important for demo data and user-editable defaults.
Recommended Defaults
| Record Kind | noupdate |
|---|---|
| Views, menus, actions, reports | 0 (default) |
| Scheduled jobs you want users to reconfigure | 1 |
| Default records (sequences, warehouses, journals) | 1 |
| Demo data | 1 (and inside <data noupdate="1">) |
Access-control records (ir.model.access) | 0 — keep in sync |
| Record rules | 0 — keep in sync |
forcecreate
Inside a noupdate="1" block, if a record referenced by id does not exist during upgrade, the loader will still create it. To opt out, add forcecreate="0" on the <record>:
<data noupdate="1">
<record id="optional_cron" model="ir.cron" forcecreate="0">
<field name="name">Optional Job</field>
...
</record>
</data>---
Loading Order & Modes
- Files are loaded in the order declared in
__manifest__.py(data:thendemo:). - Within a file, operations execute top-to-bottom. A later record can reference an earlier external id, but not the other way around.
- Update mode (
-u my_module) re-runs every file but skipsnoupdate="1"blocks and<function>calls. - Init mode (first install) runs everything, including
<function>innoupdate="1"blocks.
Multiple <data> Blocks
You can freely mix updatable and non-updatable blocks in one file:
<odoo>
<record id="view_x_form" model="ir.ui.view">...</record>
<data noupdate="1">
<record id="default_config" model="res.config.settings">...</record>
</data>
<record id="action_x" model="ir.actions.act_window">...</record>
<menuitem id="menu_x" name="X" action="action_x"/>
</odoo>---
Quick Reference
<record> and <field>
<record id="external_id" model="model.name" context="{}">
<field name="char_field">plain string</field>
<field name="boolean" eval="True"/>
<field name="m2o_field" ref="module.other_xmlid"/>
<field name="m2o_field" search="[('code','=','X')]"/>
<field name="m2m_field" eval="[Command.set([ref('a'), ref('b')])]"/>
<field name="html_field" type="html"><p>HTML content</p></field>
<field name="binary" type="base64" file="module/static/file.bin"/>
<field name="file_ref" type="file" name="module/static/x.png"/>
<field name="count" type="int">42</field>
</record>Shortcuts
<menuitem id="m1" name="Label" parent="m_root" action="act_x" sequence="10"
groups="base.group_user"/>
<template id="tpl" name="My Template" inherit_id="parent.tpl" priority="16">
<xpath expr="//div" position="inside"><p>Extra</p></xpath>
</template>
<asset id="bundle_x" name="Bundle">
<bundle>web.assets_backend</bundle>
<path>my_module/static/src/js/a.js</path>
</asset>
<report id="act_rep_x" string="My Report"
model="my.model" report_type="qweb-pdf"
name="my_module.tpl_report_x" file="my_module.report_x"/><delete> / <function>
<delete model="res.partner" id="obsolete.legacy_partner"/>
<delete model="ir.ui.menu" search="[('name','=','Old')]"/>
<function model="res.partner" name="unlink_inactive"/>
<function model="my.model" name="post_install_hook"
eval="[ref('base.main_company')]"/>Relational Commands
| Command | Tuple | Meaning |
|---|---|---|
Command.create({...}) | (0, 0, {...}) | Create |
Command.update(id, {...}) | (1, id, {...}) | Update |
Command.delete(id) | (2, id, 0) | Delete |
Command.unlink(id) | (3, id, 0) | Unlink |
Command.link(id) | (4, id, 0) | Link |
Command.clear() | (5, 0, 0) | Clear |
Command.set([ids]) | (6, 0, [ids]) | Replace |
---
Base Code Reference
odoo/tools/convert.py— XML data loader, all tag handlers (_tag_record,_tag_delete,_tag_function,_tag_menuitem,_tag_template,_tag_asset),evalcontext (_get_idref).odoo/addons/base/models/ir_model.py—ir.model.dataand the external-id resolver (_xmlid_to_res_model_res_id).odoo/addons/base/models/ir_ui_menu.py— menu model backing<menuitem>.odoo/addons/base/models/ir_ui_view.py—ir.ui.viewbacking<template>.odoo/fields.py—Commandhelpers exposed ineval.
Odoo 17 Decorator Guide
Complete reference for Odoo 17 @api decorators and how to combine them.
Table of Contents
1. @api.model 2. @api.model_create_multi 3. @api.depends 4. @api.depends_context 5. @api.constrains 6. @api.onchange 7. @api.ondelete 8. @api.private 9. @api.returns 10. @api.autovacuum 11. Combinations 12. Decision Tree
---
@api.model
Marks a method where self is a recordset but its contents are irrelevant — only the model (class) matters. Use it for factories, class-level helpers, or methods callable as self.env['my.model'].method(...).
from odoo import api, fields, models
class SaleOrder(models.Model):
_name = 'sale.order'
@api.model
def default_get(self, fields_list):
vals = super().default_get(fields_list)
vals['team_id'] = self.env.user.team_id.id
return vals
@api.model
def _default_pricelist(self):
return self.env['product.pricelist'].search([], limit=1)
pricelist_id = fields.Many2one(
'product.pricelist',
default=_default_pricelist,
)Notes:
- Over RPC,
@api.modelmethods receiveselfas an empty recordset of the model. - For a method that takes a dict and is named
create,@api.modelautomatically falls back to@api.model_create_single(seeodoo/api.py:378). Do not rely on that fallback — in Odoo 17 you should explicitly use@api.model_create_multiwhen overridingcreate().
---
@api.model_create_multi
Required decorator whenever you override create() in Odoo 17.
from odoo import api, fields, models
class SaleOrder(models.Model):
_name = 'sale.order'
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
vals.setdefault('state', 'draft')
if not vals.get('name'):
vals['name'] = self.env['ir.sequence'].next_by_code('sale.order')
orders = super().create(vals_list)
orders._post_create_hook()
return ordersThe decorator adapts the callsite: callers may still pass a single dict, which the wrapper silently converts to [dict] before invoking the method (see odoo/api.py:427). That means create() always receives a list inside the method body.
Failing to use @api.model_create_multi logs:
The model <module> is not overriding the create method in batchand falls back to a slow per-record wrapper (@api.model_create_single) — every batched create call then loops one record at a time.
Never decorate create() with @api.model in Odoo 17 — it silently degrades to single-record mode with a runtime warning.
---
@api.depends
Declares the fields whose changes should trigger recomputation of a computed field.
class SaleOrder(models.Model):
_name = 'sale.order'
amount_untaxed = fields.Monetary()
tax_total = fields.Monetary()
amount_total = fields.Monetary(compute='_compute_amount_total', store=True)
@api.depends('amount_untaxed', 'tax_total')
def _compute_amount_total(self):
for order in self:
order.amount_total = order.amount_untaxed + order.tax_totalDotted paths (relational dependencies)
@api.depends('partner_id.name', 'partner_id.email')
def _compute_partner_display(self):
for order in self:
order.partner_display = f"{order.partner_id.name} <{order.partner_id.email}>"One2many traversal
@api.depends('order_line.price_subtotal')
def _compute_amount_untaxed(self):
for order in self:
order.amount_untaxed = sum(order.order_line.mapped('price_subtotal'))Dynamic dependencies
Pass a callable for dependencies that vary by model configuration:
@api.depends(lambda self: (self._rec_name,) if self._rec_name else ())
def _compute_display_name(self):
...Rules and caveats
1. Cannot depend on `id` — Odoo raises NotImplementedError("Compute method cannot depend on field 'id'.") (see odoo/api.py:267). 2. List every field you actually read — missing dependencies cause stale cached values. 3. Dotted paths OK in @api.depends (but not in @api.constrains or @api.onchange). 4. Assign to every record in self — even when you only care about a subset — so the ORM does not re-call the method for the missed ones. 5. Cycles — add recursive=True on the field if the dependency path loops back (e.g. parent_id.total).
---
@api.depends_context
Tells the ORM that a non-stored computed field's value depends on some context keys. When any of those keys change, Odoo invalidates the cached value and re-computes.
class ProductProduct(models.Model):
_inherit = 'product.product'
price = fields.Float(compute='_compute_price')
@api.depends_context('pricelist')
def _compute_price(self):
pricelist_id = self.env.context.get('pricelist')
pricelist = self.env['product.pricelist'].browse(pricelist_id) if pricelist_id else None
for product in self:
product.price = (
pricelist._get_product_price(product, 1.0) if pricelist else product.list_price
)Built-in context keys with special support (see odoo/api.py:287):
| Key | Meaning |
|---|---|
'company' | Current env.company.id (derived from allowed_company_ids or the user's company) |
'uid' | (env.uid, env.su) tuple — recomputed when the user or sudo flag changes |
'lang' | env.context['lang'] |
'active_test' | Either from context, falling back to the field's configured default |
'bin_size', 'bin_size_<field>' | Treated as booleans — any truthy value collapses to True |
Other keys are used as-is and must be hashable (lists are converted to tuples automatically).
@api.depends_context('company')
def _compute_balance(self):
for partner in self:
partner.balance = partner._compute_balance_for_company(self.env.company)
@api.depends_context('show_cost')
def _compute_display_price(self):
show_cost = self.env.context.get('show_cost', False)
for product in self:
product.display_price = product.standard_price if show_cost else product.list_priceCombine with @api.depends when the field depends on both regular fields and context:
price = fields.Float(compute='_compute_price')
@api.depends('list_price')
@api.depends_context('pricelist')
def _compute_price(self):
...---
@api.constrains
Validates data integrity on the listed fields. Raise ValidationError on failure.
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class SaleOrder(models.Model):
_name = 'sale.order'
@api.constrains('date_order', 'validity_date')
def _check_dates(self):
for order in self:
if order.validity_date and order.validity_date < order.date_order.date():
raise ValidationError(_("Validity date cannot precede the order date."))
@api.constrains('order_line')
def _check_has_lines(self):
for order in self:
if not order.order_line:
raise ValidationError(_("A confirmed order must have at least one line."))Rules (from odoo/api.py:100)
1. Only simple field names. Dotted paths ('partner_id.name') are silently ignored. 2. Triggered only when listed fields are present in `create()` / `write()` — if the field is not in the view or not in the vals dict, the constraint is not evaluated. 3. Raise ValidationError (from odoo.exceptions). Using UserError for constraint failures is technically possible but semantically wrong. 4. A constraint can list several fields; it fires whenever any of them is in the write.
Ensuring a constraint always runs
If you need an absolute invariant (e.g. "a confirmed order must have at least one line") that must be checked even when the user does not touch order_line, override create() / write() and call the constraint method explicitly, or re-write the field to trigger the constraint:
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
records._check_has_lines()
return records---
@api.onchange
Runs on the form view when the user edits one of the listed fields. The method is invoked on a pseudo-record: a single record holding the unsaved form values. Field assignments are sent back to the client.
class SaleOrderLine(models.Model):
_name = 'sale.order.line'
@api.onchange('product_id')
def _onchange_product_id(self):
if self.product_id:
self.price_unit = self.product_id.list_price
self.name = self.product_id.display_name
self.product_uom_id = self.product_id.uom_id
else:
self.price_unit = 0.0
self.name = ''Warnings and notifications
Return a dict with a warning key:
@api.onchange('discount')
def _onchange_discount(self):
if self.discount and self.discount > 50:
return {
'warning': {
'title': _("High Discount"),
'message': _("Discount above 50%% requires manager approval."),
'type': 'notification', # 'dialog' (default) or 'notification'
}
}Dynamic domains
Onchange can return a domain dict (field name -> domain) to filter Many2one dropdowns — but the Odoo 17 recommended pattern is to use the domain= attribute on the field itself or a Char _domain field referenced from the view. Returning a domain from onchange is still supported but has been demoted in the code base.
Rules (from odoo/api.py:197)
1. Only simple field names. Dotted paths are ignored. 2. No CRUD on the pseudo-record. Calling create(), write(), unlink() from inside an onchange is undefined behaviour — the record may not exist yet in the DB. 3. A one2many / many2many field cannot modify itself via onchange (webclient limitation, issue #2693). 4. Assign values directly (self.field = value) or use self.update({'field': value}).
Onchange is UI only
Onchange does not run on server-side create() or write(). Any invariant that must hold in all cases must also be expressed as @api.depends / @api.constrains.
---
@api.ondelete
Preferred way to reject a deletion. Runs during unlink(); by default skipped during module uninstall so the uninstaller can still drop data.
from odoo import api, models
from odoo.exceptions import UserError
class SaleOrder(models.Model):
_name = 'sale.order'
@api.ondelete(at_uninstall=False)
def _unlink_except_draft(self):
if any(order.state not in ('draft', 'cancel') for order in self):
raise UserError(_("Only draft or cancelled orders can be deleted."))Naming convention
By convention the method is named:
_unlink_if_<condition>— raise when<condition>is true_unlink_except_<allowed_state>— raise unless records are in the allowed state
at_uninstall
| Value | Behaviour |
|---|---|
False (recommended) | Runs during normal use; skipped when the module is being uninstalled. |
True | Always runs — even during uninstall. Reserve for system-critical invariants (e.g. "the default language must not disappear"). |
@api.ondelete(at_uninstall=True)
def _unlink_if_default_language(self):
if self.env.ref('base.lang_en') in self:
raise UserError(_("Cannot delete the default language."))Why not override unlink()?
Overriding unlink() for validation breaks module uninstallation: during uninstall, your check raises, the transaction rolls back, and leftover data stays in the DB. @api.ondelete(at_uninstall=False) is the supported way to add delete rules (available since Odoo 15).
Multiple ondelete methods
You can decorate several methods on the same model; all of them run in declaration order.
---
@api.private
Marks a public method as non-RPC-callable. The RPC layer raises AccessError if a client tries to call it. See odoo/api.py:384.
from odoo import api, models
class SaleOrder(models.Model):
_inherit = 'sale.order'
@api.private
def refund_total(self, reason):
# callable from Python code only — RPC clients cannot reach it
self.ensure_one()
self._post_refund(reason)
return self.amount_totalThe leading-underscore naming convention remains the default way to mark internal helpers (_compute_*, _prepare_*, _action_*): the RPC dispatcher does not call underscore-prefixed methods from the frontend. Use @api.private when an existing public method needs to stop being RPC-exposed and renaming it to _ would break internal callers.
Sets the attribute method._api_private = True, which the RPC layer reads in odoo/service/model.py.
---
@api.returns
Declares the model of the recordset returned by a method. Affects how the method's result is adapted when called via XML-RPC.
class Partner(models.Model):
_name = 'res.partner'
@api.returns('self')
def copy(self, default=None):
return super().copy(default)
@api.returns('res.partner')
def get_main_contact(self):
return self.mapped('contact_ids')[:1]When the method is invoked from the Python record-style API, the result is returned as a recordset. When invoked over XML-RPC, Odoo downgrades the recordset to ids (list[int]) automatically.
Advanced form with upgrade / downgrade converters:
@api.returns('mail.message', lambda value: value.id)
def message_post(self, **kwargs):
...Here, when a caller uses the traditional RPC style, the message record is downgraded to its id. Inheritance is automatic: a subclass overriding message_post inherits the @api.returns declaration.
---
@api.autovacuum
Registers a method to be run by the daily ir.autovacuum cron job. The method name must start with _ (the decorator asserts this).
from datetime import timedelta
from odoo import api, fields, models
class IrAttachment(models.Model):
_inherit = 'ir.attachment'
@api.autovacuum
def _gc_orphan_attachments(self):
threshold = fields.Datetime.now() - timedelta(days=30)
self.search([
('res_model', '=', False),
('create_date', '<', threshold),
]).unlink()Use this for lightweight GC tasks that would otherwise need their own ir.cron record. Autovacuum methods are called with self as an empty recordset of the model.
---
Combinations
Computed + stored + searchable + writable
full_name = fields.Char(
compute='_compute_full_name',
inverse='_inverse_full_name',
search='_search_full_name',
store=True,
)
@api.depends('first_name', 'last_name')
def _compute_full_name(self):
for r in self:
r.full_name = f"{r.first_name or ''} {r.last_name or ''}".strip()
def _inverse_full_name(self):
for r in self:
parts = (r.full_name or '').split(' ', 1)
r.first_name = parts[0]
r.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)]@api.depends + @api.depends_context
price = fields.Float(compute='_compute_price')
@api.depends('list_price')
@api.depends_context('pricelist', 'uid')
def _compute_price(self):
...Both decorators stack: the compute is re-run when either a listed field or one of the listed context keys changes.
@api.model with @api.constrains
@api.constrains is normally a record-level decorator, but you can combine it with @api.model when the check itself does not iterate (rarely useful):
@api.model
@api.constrains('code')
def _check_code_format(self):
for rec in self:
if rec.code and not rec.code.isalnum():
raise ValidationError(_("Code must be alphanumeric."))Batch create + constrains
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
records._check_business_rules() # force invariants missing from vals
return records---
Decision Tree
Defining / overriding a FIELD
├── Computed from other fields? ............ @api.depends
│ └── Also needs context? ............... + @api.depends_context
│ └── Should be searchable? .............. store=True OR search=...
│ └── Should be writable? ................ inverse=...
│
Defining / overriding a METHOD
├── Overriding create()? ................... @api.model_create_multi (REQUIRED)
├── Model-level (self content irrelevant)? . @api.model
├── Validates integrity? ................... @api.constrains (simple field names only)
├── Form UI reaction? ...................... @api.onchange (no CRUD, no dotted paths)
├── Deletion guard? ........................ @api.ondelete(at_uninstall=False)
├── Daily cleanup job? ..................... @api.autovacuum (method must be private _)
├── Returns a recordset via RPC? ........... @api.returns('model' or 'self')
└── Regular record-level method ............ no decorator---
Quick Reference
| Decorator | Purpose | Dotted paths? | Notes |
|---|---|---|---|
@api.model | Method where self content is irrelevant | — | Also fallback-handles create(single_dict) but use model_create_multi instead |
@api.model_create_multi | Batch-capable create() override | — | Required in v17 when overriding create() |
@api.depends(*fields) | Compute dependencies | Yes | Cannot depend on 'id' |
@api.depends_context(*keys) | Context dependencies | — | Values must be hashable |
@api.constrains(*fields) | Data validation | No | Only runs when listed fields are in vals |
@api.onchange(*fields) | Form UI reaction | No | No CRUD on pseudo-record |
@api.ondelete(at_uninstall=False) | Deletion guard | — | Preferred over overriding unlink() |
@api.returns(model, ...) | Declare return-model for RPC | — | Inherited by overrides automatically |
@api.autovacuum | Daily cleanup | — | Method name must start with _ |
---
Base Code Reference
Verify decorator semantics in:
/Users/unclecat/odoo/17.0/odoo/api.py— all@apidecorators (modelL369,model_create_multiL434,dependsL246,depends_contextL271,constrainsL100,onchangeL197,ondeleteL138,returnsL297,autovacuumL358)./Users/unclecat/odoo/17.0/odoo/models.py—@api.model_create_multiusage oncreate()(line 4561), recomputation engine interacting with@api.depends, and their.autovacuumcron target./Users/unclecat/odoo/17.0/odoo/exceptions.py—ValidationError,UserError,MissingErrorto raise from decorated methods.
Related skills
How it compares
Use as a version-pinned ERP reference skill instead of asking the model to recall Odoo APIs from training data alone.
FAQ
Who is odoo-17 for?
Developers and small shops customizing Odoo 17 ERP modules who want their coding agent to follow official-style patterns for ORM, XML, and OWL.
When should I use odoo-17?
During Build when authoring models, controllers, manifests, cron jobs, or upgrade scripts; also when debugging Odoo-specific errors in an agent-assisted session.
Is odoo-17 safe to install?
It is documentation-only in your repo; review the Security Audits panel on this Prism page and inspect the skill files before enabling remote rules from third-party Git URLs.