Odoo Online (SaaS) / Importable Modules Skill
Use this skill when developing importable (data) modules — custom Odoo modules that contain no custom Python business logic and can be deployed on managed/SaaS instances like Odoo.com.
Key Constraint
SaaS instances do not allow arbitrary Python code. Everything that would normally be a Python class, method, or field definition must instead be expressed as XML data records. JavaScript files are allowed.
Before Building: The Logic Budget
A data-only module is worth building only while the database stays simple. Logic expressed as records — server
actions, automation rules, sandboxed computes and constraints, crons, ir.model / ir.model.fields records
standing in for a Python class — is budgeted at 10 hours of development, unless the analysis or the prompt
states another figure.
What a developer would have written identically on Odoo.sh does not count: views and QWeb, reports, SCSS, JavaScript and OWL. A rich interface on a light database is what this hosting is for.
Over the budget, or where a requirement needs a workaround rather than an implementation — a chain of automation
rules standing in for one override, a cron polling for what an override would catch, a standard button rebound
to a server action, standard behaviour reimplemented because the method cannot be reached — stop and ask
whether to move the development to Odoo.sh before writing any of it. Show the logic total, the workarounds and
what Odoo.sh would allow instead; the hosting is the developer's call. The odoo_task_analysis skill carries the
same rule for the analysis that precedes the build.
Module Structure
my_module
├── actions/ *.xml — server actions, window actions
├── models/ *.xml — ir.model + ir.model.fields records (named after the model, e.g. `product_template.xml`)
├── security/ ir.model.access.csv, *_security.xml
├── views/ *.xml — UI views
├── static/src/js *.js — JavaScript (OWL components, tours, etc.)
├── __init__.py — empty, required for classic addons-path installs
└── __manifest__.py — standard manifest; models listed in `data`
__init__.py is empty. data files must be listed in dependency order (models before views).
Deploying
Via UI: zip the module → Apps → Import Module (requires developer mode + base_import_module installed).
Upload options:
- Force init: re-runs
noupdate="1"records - Import demo data: imports demo records
Via CLI:
odoo-bin deploy <path_to_module> https://<instance> --login <user> --password <pass> [--force]
The deploying user needs Administration/Settings rights.
Changing a field's type after first deploy is not supported — easiest fix is a fresh DB or uninstall first.
Naming Rules
- All custom models must be prefixed with
x_and use underscores only (no dots):x_my_model - All custom fields must be prefixed:
x_my_field
Defining Models
Models are ir.model records:
<record id="model_estate_property" model="ir.model">
<field name="name">Real Estate Property</field>
<field name="model">x_estate_property</field>
</record>
Odoo auto-adds: id, create_date, create_uid, write_date, write_uid.
Defining Fields
Fields are ir.model.fields records:
<record id="field_estate_property_name" model="ir.model.fields">
<field name="model_id" ref="my_module.model_estate_property"/>
<field name="name">x_name</field>
<field name="field_description">Name</field>
<field name="ttype">char</field>
<field name="required">True</field>
</record>
Common ttype values: char, integer, float, boolean, date, datetime, html, text,
many2one, many2many, one2many, selection, binary.
Key optional attributes: required, readonly, index, copied, translate, help.
Default Values
Use ir.default records (static only — no dynamic defaults like "today"):
<record id="default_estate_selling_price" model="ir.default">
<field name="field_id" ref="my_module.field_estate_property_selling_price"/>
<field name="json_value">100000</field>
</record>
Optionally scoped with user_id or company_id.
Relational Fields
Many2one
<record id="field_estate_property_partner_id" model="ir.model.fields">
<field name="model_id" ref="my_module.model_estate_property"/>
<field name="name">x_partner_id</field>
<field name="field_description">Customer</field>
<field name="ttype">many2one</field>
<field name="relation">res.partner</field>
<!-- optional: ondelete, domain -->
</record>
Many2many
Same as many2one but ttype = many2many. Optional: relation_table, column1, column2
(only needed when multiple m2m exist between the same two models).
One2many
Same as many2one but ttype = one2many. Requires relation_field (the m2o field on the child model).
Computed Fields
Stored by default. Use depends + compute with a CDATA block. Sandbox provides datetime, dateutil, time
plus Python builtins. No imports, no OS access, no print. Use dict assignment, not dot assignment:
<record id="field_estate_property_total_area" model="ir.model.fields">
<field name="model_id" ref="my_module.model_estate_property"/>
<field name="name">x_total_area</field>
<field name="field_description">Total Area</field>
<field name="ttype">float</field>
<field name="depends">x_living_area,x_garden_area</field>
<field name="compute"><![CDATA[
for property in self:
property['x_total_area'] = property.x_living_area + property.x_garden_area
]]></field>
</record>
Set store="False" to disable storage; readonly="True" to prevent editing.
Related Fields
Mirror a value through a many2one chain using the related attribute:
<record id="field_estate_property_country_id" model="ir.model.fields">
<field name="model_id" ref="my_module.model_estate_property"/>
<field name="name">x_country_id</field>
<field name="field_description">Buyer's Country</field>
<field name="ttype">many2one</field>
<field name="relation">res.country</field>
<field name="related">x_partner_id.country_id</field>
</record>
Server Actions (Business Logic)
Code-type server actions run in a sandbox. Available globals: env, model, user, uid, record,
records, datetime, dateutil, timezone, time, float_compare, b64encode, b64decode, Command.
To return a client action assign to action; for website assign to response.
<record id="action_estate_refuse_offers" model="ir.actions.server">
<field name="name">Refuse all offers</field>
<field name="model_id" ref="my_module.model_estate_property"/>
<field name="state">code</field>
<field name="code"><![CDATA[
for property in records:
property.x_offer_ids.write({'x_status': 'refused'})
]]></field>
</record>
Binding to a button in a view
<button name="my_module.action_estate_refuse_offers" type="action" string="Refuse All Offers"/>
Binding to the gear icon (⚙)
Set binding_model_id and binding_view_types (tree, form, or both) on the server action record.
Overriding Standard Modules
Via UI replacement
Replace a button's type="object" call with a type="action" that calls your server action, which then
calls the original method before/after your custom logic:
<record id="view_sale_order_form_estate" model="ir.ui.view">
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="model">sale.order</field>
<field name="arch" type="xml">
<xpath expr="//button[@name='action_confirm'][@type='object']" position="attributes">
<attribute name="type">action</attribute>
<attribute name="name">my_module.action_create_property_from_sale</attribute>
</xpath>
</field>
</record>
Via Automation Rules (preferred for lifecycle events)
More robust than UI replacement — fires even when the event is triggered by portal, API, etc.
Requires base_automation in module dependencies.
<record id="automation_create_property_on_sale_confirm" model="base.automation">
<field name="name">Create property on sale confirm</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="trigger">on_state_set</field>
<field name="trg_selection_field_id" ref="sale.selection__sale_order__state__sale"/>
<field name="trigger_field_ids" eval="[(4, ref('sale.field_sale_order__state'))]"/>
<field name="action_server_ids" eval="[(4, ref('my_module.action_create_property_from_sale'))]"/>
</record>
Website Controllers
When website is installed, server actions can act as HTTP endpoints:
<record id="server_action_estate_list" model="ir.actions.server">
<field name="name">Estate List Controller</field>
<field name="model_id" ref="my_module.model_estate_property"/>
<field name="website_published">True</field>
<field name="website_path">estate</field> <!-- full path: /website/action/estate -->
<field name="state">code</field>
<field name="code"><![CDATA[
properties = request.env['x_estate_property'].search([])
response = request.make_json_response([{'name': p.x_name} for p in properties])
]]></field>
</record>
Useful request methods: get_http_params(), get_json_data(), render(xmlid, qcontext),
redirect(url), not_found(), make_response(data), make_json_response(data).
The server action's
model_idmust be readable by the public user, or the action returns 403. Link toir.filtersas a workaround if needed.
JavaScript / OWL Components
JS files are fully supported. No glob patterns — list every file explicitly in the manifest:
"assets": {
"web.assets_backend": [
"my_module/static/src/js/my_component.js",
],
},
Onboarding Tours
static/src/js/tour.js:
import { registry } from "@web/core/registry";
registry.category("web_tour.tours").add('my_tour', {
url: "/web",
steps: () => [{
trigger: '.o_app[data-menu-xmlid="my_module.menu_root"]',
content: 'Welcome to the app!',
}],
});
Register the tour as an XML record:
<record id="my_tour" model="web_tour.tour">
<field name="name">my_tour</field>
<field name="sequence">2</field>
<field name="rainbow_man_message">You made it!</field>
</record>
Known Limitations
| Feature | Status |
|---|---|
| Custom Python classes/methods | Not allowed |
| Importing Python libraries | Not allowed |
| Dynamic default values (e.g., "today") | Not allowed |
| Changing a field type after deploy | Not supported |
| Glob patterns in assets | Not supported |
| Arbitrary OS/file access in sandbox | Not allowed |
print() in sandbox |
Not allowed |
Dot assignment in compute (self.x_field = v) |
Not allowed — use self['x_field'] = v |