Install
npx skillscat add hellbellies/agentic-coding/skills-odoo18 Install via the SkillsCat registry.
SKILL.md
Odoo 18 Development Skills
This document provides conventions and patterns for writing code indistinguishable from the Odoo 18 codebase, based on analysis of the addons/project module and core framework.
Project Structure
addon_name/
├── __init__.py # Module imports
├── __manifest__.py # Module metadata
├── controllers/ # HTTP controllers
│ ├── __init__.py
│ └── portal.py
├── models/ # Business logic
│ ├── __init__.py
│ └── model_name.py
├── views/ # XML views
│ └── model_views.xml
├── security/ # Access rights
│ ├── ir.model.access.csv
│ └── model_security.xml
├── tests/ # Unit tests
│ ├── __init__.py
│ └── test_feature.py
├── wizard/ # Transient models
├── report/ # Report definitions
├── data/ # Demo/default data
├── static/ # Frontend assets
│ └── src/
│ ├── components/
│ ├── views/
│ └── js/
└── i18n/ # TranslationsPython Code Style
File Header
Every Python file must start with the copyright header (NO coding: utf-8 in Odoo 18):
# (c) Sozialinfo.ch See LICENSE file for full copyright and licensing details.Import Ordering
Imports are grouped in this order, separated by blank lines:
- Standard library imports (alphabetically)
- Third-party imports (alphabetically)
- Odoo imports (
from odoo import ...) - Odoo addon imports (
from odoo.addons...) - Local imports (
from . import ...)
import ast
import json
from collections import defaultdict
from datetime import timedelta
from odoo import api, Command, fields, models, _
from odoo.addons.mail.tools.discuss import Store
from odoo.exceptions import UserError, ValidationError
from odoo.osv.expression import AND
from odoo.tools import get_lang, SQL
from .project_task import CLOSED_STATESModel Class Structure
class Project(models.Model):
_name = "project.project"
_description = "Project"
_inherit = [
'portal.mixin',
'mail.alias.mixin',
'mail.thread',
]
_order = "sequence, name, id"
# Class attributes (no blank lines between these)
_rating_satisfaction_days = 30
_systray_view = 'activity'
# Blank line, then helper methods used by field definitions
def _default_stage_id(self):
return self.env['project.project.stage'].search([], limit=1)
# Blank line, then field definitions
name = fields.Char("Name", required=True, tracking=True, translate=True)
active = fields.Boolean(default=True, copy=False)
# Blank line, then SQL constraints
_sql_constraints = [
('project_date_greater', 'check(date >= date_start)',
"The project's start date must be before its end date.")
]
# Blank line, then compute/inverse/search methods
@api.depends('milestone_ids', 'milestone_ids.is_reached')
def _compute_milestone_count(self):
...
# Blank line, then constraint methods
@api.constrains('company_id', 'partner_id')
def _ensure_company_consistency(self):
...
# Blank line, then CRUD overrides
def create(self, vals):
...
# Blank line, then business methods
def action_view_tasks(self):
...Field Definitions
- String parameter first, then other parameters
- Use
string=explicitly only when different from field name - Group related parameters, keep line length reasonable
# Simple field
name = fields.Char("Name", required=True, tracking=True, translate=True)
# Field with lambda default
label_tasks = fields.Char(
string='Use Tasks as',
default=lambda s: s.env._('Tasks'),
translate=True,
help="Name used to refer to the tasks"
)
# Selection field
privacy_visibility = fields.Selection([
('followers', 'Invited internal users (private)'),
('employees', 'All internal users'),
('portal', 'Invited portal users and all internal users (public)'),
], string='Visibility', required=True, default='portal', tracking=True)
# Many2one with domain
partner_id = fields.Many2one(
'res.partner', string='Customer',
domain="['|', ('company_id', '=?', company_id), ('company_id', '=', False)]"
)
# Computed field with search
is_favorite = fields.Boolean(
compute='_compute_is_favorite',
readonly=False,
search='_search_is_favorite',
compute_sudo=True,
string='Show Project on Dashboard',
export_string_translation=False,
)
# Computed field with inverse (read/write computed field)
is_pinned = fields.Boolean(
"Pinned",
compute='_compute_is_pinned',
inverse='_inverse_is_pinned',
readonly=False,
)SQL Constraints
SQL constraint messages are plain strings without translation:
_sql_constraints = [
('name_project_unique', 'unique(name, project_id)',
'A note with this title already exists in this project.'),
('recurring_task_has_no_parent',
'CHECK (NOT (recurring_task IS TRUE AND parent_id IS NOT NULL))',
"A subtask cannot be recurrent."),
]Method Decorators
# Compute method
@api.depends('milestone_ids', 'milestone_ids.is_reached')
def _compute_milestone_count(self):
...
# Compute with context
@api.depends_context('company')
@api.depends('company_id')
def _compute_currency_id(self):
...
# Model method (no self recordset)
@api.model
def _search_is_favorite(self, operator, value):
...
# Onchange
@api.onchange('company_id')
def _onchange_company_id(self):
...
# Create multi
@api.model_create_multi
def create(self, vals_list):
...
# Ondelete constraint
@api.ondelete(at_uninstall=False)
def _unlink_if_no_tasks(self):
...Error Handling
from odoo.exceptions import UserError, ValidationError
# User-facing errors with _()
raise UserError(_('The project and the associated partner must be linked to the same company.'))
# ValidationError for constraint violations
raise ValidationError(_('This task has sub-tasks, so it cannot be private.'))
# NotImplementedError for unsupported operations
raise NotImplementedError(_('Operation not supported'))
# Formatted error messages
raise UserError(_(
"This project is associated with %(project_company)s, whereas the selected stage belongs to %(stage_company)s.",
project_company=project.company_id.name,
stage_company=project.stage_id.company_id.name
))CRUD Patterns
@api.model_create_multi
def create(self, vals_list):
# Modify context for create
self = self.with_context(mail_create_nosubscribe=True)
# Pre-process vals_list
for vals in vals_list:
if vals.pop('is_favorite', False):
vals['favorite_user_ids'] = [self.env.uid]
return super().create(vals_list)
def write(self, vals):
# Handle special fields before super
if 'is_favorite' in vals:
self._set_favorite_user_ids(vals.pop('is_favorite'))
res = super().write(vals)
# Post-processing after super
if 'active' in vals:
self.mapped('tasks').write({'active': vals['active']})
return res
def unlink(self):
# Cleanup before unlink
self.tasks.unlink()
return super().unlink()
def copy(self, default=None):
default = dict(default or {})
default['milestone_ids'] = False
new_records = super().copy(default)
return new_records
def copy_data(self, default=None):
vals_list = super().copy_data(default=default)
return [dict(vals, name=self.env._("%s (copy)", record.name))
for record, vals in zip(self, vals_list)]Command Usage for Relational Fields
from odoo import Command
# Link (add to many2many)
favorite_user_ids = [Command.link(self.env.uid)]
# Unlink (remove from many2many)
favorite_user_ids = [Command.unlink(self.env.uid)]
# Clear (remove all)
tag_ids = [Command.clear()]
# Set (replace all)
tag_ids = [Command.set(existing_tags.ids)]
# Create (create and link)
child_ids = [Command.create({'name': 'Subtask'})]
# Update
collaborator_ids = [Command.update(record.id, {'limited_access': True})]
# Delete
collaborator_ids = [Command.delete(record_id)]Read Group Pattern
def _compute_task_count(self):
tasks_count_by_project = dict(
self.env['project.task'].with_context(
active_test=any(project.active for project in self)
)._read_group(
[('project_id', 'in', self.ids), ('display_in_project', '=', True)],
['project_id'],
['__count']
)
)
for project in self:
project.task_count = tasks_count_by_project.get(project, 0)XML Views
View Record Structure
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_model_form" model="ir.ui.view">
<field name="name">model.form</field>
<field name="model">model.name</field>
<field name="arch" type="xml">
<form string="Model">
<header>
<button name="action_method" string="Action" type="object" class="oe_highlight"/>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" type="object" name="action_view_items" icon="fa-check">
<field name="item_count" widget="statinfo"/>
</button>
</div>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
<group>
<group>
<field name="name"/>
</group>
<group>
<field name="user_id" widget="many2one_avatar_user"/>
</group>
</group>
<notebook>
<page name="description" string="Description">
<field name="description"/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
</odoo>Inherit View
<record id="view_model_form_inherit" model="ir.ui.view">
<field name="name">model.form.inherit</field>
<field name="model">model.name</field>
<field name="inherit_id" ref="module.view_model_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='name']" position="after">
<field name="new_field"/>
</xpath>
</field>
</record>Security
Access Rights CSV
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_project_user,project.user,model_project,group_project_user,1,1,1,0
access_project_manager,project.manager,model_project,group_project_manager,1,1,1,1Record Rules XML
<record model="ir.rule" id="project_comp_rule">
<field name="name">Project: multi-company</field>
<field name="model_id" ref="model_project_project"/>
<field name="domain_force">[('company_id', 'in', company_ids + [False])]</field>
</record>Testing
Test Class Structure
from odoo import Command, fields
from odoo.tests import Form, users
from odoo.tests.common import TransactionCase
from odoo.exceptions import UserError
class TestProjectCommon(TransactionCase):
@classmethod
def setUpClass(cls):
super(TestProjectCommon, cls).setUpClass()
cls.project = cls.env['project.project'].create({
'name': 'Test Project',
})
def test_feature_description(self):
"""Test description explaining what is being tested."""
# Arrange
task = self.env['project.task'].create({
'name': 'Test Task',
'project_id': self.project.id,
})
# Act
result = task.action_complete()
# Assert
self.assertEqual(task.state, '1_done')
self.assertTrue(result)
@users('project_user')
def test_as_user(self):
"""Test running as specific user."""
self.assertEqual(self.env.user.login, 'project_user')Test Patterns
# Testing exceptions
with self.assertRaises(UserError):
project.with_user(user).write({'name': 'New Name'})
# Using Form for UI testing
with Form(project) as form:
form.name = 'Updated Name'
form.is_favorite = True
self.assertTrue(project.is_favorite)
# Flushing and invalidating for access rights tests
project.flush_model()
project.invalidate_model()JavaScript/OWL
Component Registration
/** @odoo-module */
import { registry } from "@web/core/registry"
import { kanbanView } from "@web/views/kanban/kanban_view"
import { ProjectTaskKanbanModel } from "./project_task_kanban_model"
export const projectTaskKanbanView = {
...kanbanView,
Model: ProjectTaskKanbanModel,
}
registry.category("views").add("project_task_kanban", projectTaskKanbanView)Field Widget
import { registry } from "@web/core/registry"
import { booleanFavoriteField } from "@web/views/fields/boolean_favorite/boolean_favorite_field"
export const projectIsFavoriteField = {
...booleanFavoriteField,
extractProps: (fieldsInfo, dynamicInfo) => {
return {
...booleanFavoriteField.extractProps(fieldsInfo, dynamicInfo),
readonly: Boolean(fieldsInfo.attrs.readonly),
}
},
}
registry.category("fields").add("project_is_favorite", projectIsFavoriteField)Common Anti-Patterns to Avoid
- Do NOT use type hints - Odoo 18 codebase does not use Python type annotations
- Do NOT use f-strings in translations - Use
_("%s value", var)or_("text %(key)s", key=val) - Do NOT add
# -*- coding: utf-8 -*-- Not needed in Odoo 18 (note: some legacy files still have it) - Do NOT use
self.env.cr.execute()for simple queries - Use_read_group()instead - Do NOT hardcode user IDs - Use
self.env.uidorself.env.user - Do NOT bypass
super()in CRUD methods - Always call parent implementation - Do NOT use
sudo()without necessity - Document why sudo is needed - Do NOT skip
export_string_translation=False- Add to non-translatable computed fields - Do NOT translate SQL constraint messages - Use plain strings, not
_() - Do NOT orphan compute/inverse methods - Always declare
compute=andinverse=on the field definition - Do NOT "fix" typos or improve existing code - Match the original exactly unless style-related
Running Tests
# Run all tests for a module
./odoo-bin -c odoo.conf -i project --test-enable
# Run specific test file
./odoo-bin -c odoo.conf -i project --test-enable --test-file=addons/project/tests/test_project_base.py
# Run specific test class/method (use pytest-style path)
./odoo-bin -c odoo.conf -i project --test-enable --test-file=addons/project/tests/test_project_base.py::TestProjectBase::test_feature