Skip to content
Back to Insights
Odoo Python ERP Module Development

Building Custom Odoo Modules: A Production-Grade Guide

Most Odoo modules work fine in a dev sandbox and fall apart in production. This guide covers the patterns we use to build modules that survive version upgrades, security audits, and real-world traffic — across Community, Enterprise, and Odoo.sh deployments.

Codecanis Admin

11 min read
Data dashboard
Production dashboards from a multi-warehouse Odoo deployment.

Odoo is one of the most underrated platforms in enterprise software. It is open-source, modular, and ships with hundreds of business apps that cover everything from accounting to manufacturing. But the moment you start customising it — and you will customise it, because no two businesses are alike — you discover how thin the line is between a module that thrives in production and one that breaks at the first version upgrade.

We have shipped dozens of Odoo modules across Community 15, 16, 17, and now 18, on Odoo.sh, on bare-metal self-hosted clusters, and on Enterprise. The patterns below are the ones that consistently survive: upgrades, security audits, multi-company tenancy, and the kind of traffic that breaks naive ORM code.

Module Layout and Manifest Hygiene

A well-structured manifest file is your first defence against future pain. We follow a strict layout: models/, views/, security/, controllers/, data/, wizards/, reports/, static/, and tests/. Every module declares its version using the convention <odoo-major>.<module-major>.<module-minor>.<patch> — for example 17.1.2.0 for a module targeting Odoo 17.

# __manifest__.py
{
    "name": "Codecanis Inventory Extensions",
    "version": "17.1.2.0",
    "category": "Inventory",
    "summary": "Multi-warehouse forecasting and replenishment rules",
    "author": "Codecanis",
    "website": "https://codecanis.com",
    "license": "LGPL-3",
    "depends": ["stock", "purchase", "mail"],
    "data": [
        "security/ir.model.access.csv",
        "security/security.xml",
        "views/warehouse_views.xml",
        "data/cron.xml",
    ],
    "assets": {
        "web.assets_backend": [
            "codecanis_inventory/static/src/js/forecast_widget.js",
        ],
    },
    "installable": True,
    "application": False,
    "auto_install": False,
}

One pattern that pays off later: keep application set to False for everything that is not a top-level app. Setting it to True indiscriminately pollutes the Apps menu and makes upgrades harder to reason about.

Model Design: Inherit, Don't Replace

The single most common mistake we see is developers replacing core models when they should be extending them. Odoo gives you three inheritance mechanisms — classical (_inherit with the same _name), prototype (_inherit with a new _name), and delegation (_inherits) — and each has a specific purpose.

from odoo import api, fields, models

class StockWarehouse(models.Model):
    _inherit = "stock.warehouse"

    forecast_horizon_days = fields.Integer(
        string="Forecast Horizon (days)",
        default=30,
        help="Window used by the replenishment algorithm.",
    )
    replenishment_strategy = fields.Selection(
        selection=[
            ("min_max", "Min/Max"),
            ("dynamic", "Dynamic Forecast"),
            ("ml_driven", "ML-Driven"),
        ],
        default="min_max",
        required=True,
    )
    last_forecast_run = fields.Datetime(readonly=True)

Classical inheritance — extending an existing model — is what you want 90% of the time. Avoid declaring a brand-new model that duplicates fields you could have added to the existing one. Every duplicate model is a future data migration you signed up for.

Computed Fields and the @api.depends Contract

Computed fields are powerful and dangerous. The most common bug is forgetting the @api.depends decorator, which tells the ORM when to recompute. Without it, your computed field becomes stale silently.

class SaleOrder(models.Model):
    _inherit = "sale.order"

    margin_total = fields.Monetary(
        compute="_compute_margin_total",
        store=True,
        currency_field="currency_id",
    )

    @api.depends("order_line.price_subtotal", "order_line.product_id.standard_price")
    def _compute_margin_total(self):
        for order in self:
            cost = sum(
                line.product_id.standard_price * line.product_uom_qty
                for line in order.order_line
            )
            order.margin_total = order.amount_untaxed - cost

Three things matter here: declare store=True if you intend to filter or group by the field (otherwise queries become N+1 disasters); always loop using for record in self: even if you expect a single record — Odoo passes recordsets, not records; and the dependency string must drill down through the relationship using dot notation so the ORM can hook the right recomputation triggers.

Security: Access Rules vs Record Rules

Odoo's security model is two layers. The first, ir.model.access.csv, controls model-level CRUD permissions per group. The second, record rules in security.xml, controls row-level visibility — usually based on company, user, or some domain filter.

# security/ir.model.access.csv
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_warehouse_forecast_user,warehouse.forecast.user,model_warehouse_forecast,base.group_user,1,0,0,0
access_warehouse_forecast_manager,warehouse.forecast.manager,model_warehouse_forecast,stock.group_stock_manager,1,1,1,1

For multi-company deployments, every model that holds company-scoped data needs a record rule that filters on company_id in user.company_ids. Skip this and a logistics manager in your German subsidiary will see orders from your US warehouse. We have seen this happen.

# security/security.xml
<record id="warehouse_forecast_company_rule" model="ir.rule">
    <field name="name">Warehouse Forecast: Multi-company</field>
    <field name="model_id" ref="model_warehouse_forecast"/>
    <field name="domain_force">[('company_id', 'in', company_ids)]</field>
    <field name="groups" eval="[(4, ref('base.group_user'))]"/>
</record>

View Inheritance Without Breaking Upstream

Views in Odoo are XML and inheritable via XPath. Two rules: always use position="after" or position="inside" rather than replace, and target specific elements using attributes rather than ordinal positions. //field[@name='partner_id'] survives an upstream view restructure; //tree/field[3] does not.

Controllers: HTTP, JSON, and Portal Patterns

Custom HTTP endpoints live in controllers/. Use @http.route(type='json') for endpoints consumed by Odoo's JS framework, and type='http' for raw HTTP. Always declare auth explicitly — there are three values (public, user, portal) and the difference between them is a security incident waiting to happen.

from odoo import http
from odoo.http import request

class ForecastController(http.Controller):

    @http.route("/api/v1/forecast/<int:warehouse_id>",
                type="json", auth="user", methods=["POST"])
    def warehouse_forecast(self, warehouse_id, **kwargs):
        warehouse = request.env["stock.warehouse"].browse(warehouse_id)
        warehouse.check_access_rights("read")
        warehouse.check_access_rule("read")

        return {
            "warehouse": warehouse.name,
            "horizon": warehouse.forecast_horizon_days,
            "forecast": warehouse._compute_forecast(),
        }

Testing With odoo-bin Shell and TestCase

Odoo provides odoo.tests.TransactionCase and SavepointCase for unit and integration tests. Tests run against a clean database snapshot and rollback after each test, which means you can write tests freely without worrying about side-effects.

from odoo.tests.common import TransactionCase, tagged

@tagged("post_install", "-at_install")
class TestWarehouseForecast(TransactionCase):

    def setUp(self):
        super().setUp()
        self.warehouse = self.env["stock.warehouse"].create({
            "name": "Test WH",
            "code": "TST",
            "forecast_horizon_days": 14,
        })

    def test_forecast_horizon_default(self):
        wh = self.env["stock.warehouse"].create({"name": "Default", "code": "DEF"})
        self.assertEqual(wh.forecast_horizon_days, 30)

    def test_replenishment_strategy_required(self):
        with self.assertRaises(Exception):
            self.env["stock.warehouse"].create({"name": "No Strategy", "code": "NS",
                                                 "replenishment_strategy": False})

For exploratory work, odoo-bin shell drops you into an interactive Python prompt with the Odoo environment pre-loaded. We use it constantly for debugging — query records, walk relationships, test computed field invalidation, all without restarting the server.

CI/CD and Deployment

Our CI pipeline runs four stages on every module change: flake8 + pylint-odoo linting, odoo-bin --test-enable for the module's test suite against a fresh DB, a syntactic check that all XML view IDs resolve, and finally a deployment dry-run that installs the module on a scratch instance.

For deployment, Odoo.sh is the path of least resistance — git push triggers a build, and you get isolated staging branches per pull request. The trade-off is cost (Odoo.sh is roughly 3–5× the cost of self-hosted on equivalent hardware) and limited shell access. For larger deployments we self-host on a Docker-based stack with a separate Postgres tier, Redis for sessions, and an Nginx reverse proxy.

Upgrade Strategy

Every Odoo major release breaks something. We maintain an upgrade matrix per module: which Odoo versions it has been tested against, which fields were renamed, which view IDs moved. For paid Enterprise modules we run the official Odoo Upgrade service; for custom code we use OpenUpgrade scripts where they exist and write our own where they don't.

Key Takeaways

  • Declare module versions as <odoo-major>.<mod-major>.<mod-minor>.<patch> — it makes dependency tracking sane.
  • Prefer classical inheritance (_inherit) over creating duplicate models.
  • Always pair @api.depends with computed fields — and use store=True if you filter on them.
  • Layer security: ir.model.access.csv for CRUD, record rules for row-level filtering, especially multi-company.
  • Inherit views via attribute-based XPath, never ordinal positions.
  • Use odoo-bin shell for exploration and TransactionCase for tests; the database rolls back automatically.
  • Pick Odoo.sh for speed-to-prod, self-host for cost and control. Plan upgrades from day one.
Let's build something

Want to work together?

If this article made you think about your architecture, your roadmap, or a problem you haven't solved yet — let's talk.