Odoo

The best entry point into ERP development for a self-taught developer, because you can read the entire source code for free.

What it is

  • Open-source core (LGPLv3) plus a paid Enterprise edition
  • Python + PostgreSQL, with its own ORM
  • Modular: ~40 official apps and thousands of community modules
  • Belgian company, large global partner network, strong in SMB and mid-market
  • Annual major release (Odoo 16, 17, 18, 19…)

Community vs Enterprise

  Community (free) Enterprise (paid)
Licence LGPLv3 Proprietary
Core apps Sales, Purchase, Inventory, Manufacturing, CRM, Accounting (basic), Website All of Community plus full Accounting, Studio, Documents, Sign, Planning, Field Service, Quality, PLM, Barcode, IoT
Support Community Odoo SA
Mobile app No Yes

The Accounting difference matters: Community has invoicing but not the full localised accounting engine (bank reconciliation automation, asset management, localisation packages, follow-up). Many “Community is enough” plans discover this at implementation.

The architecture

┌───────────────────────────────────┐
│  Web client (OWL — JS framework)  │
├───────────────────────────────────┤
│  Controllers (HTTP routes)        │
├───────────────────────────────────┤
│  Models (Python, the ORM layer)   │
├───────────────────────────────────┤
│  PostgreSQL                       │
└───────────────────────────────────┘

Everything is a model, and models are registry entries — which is how inheritance across modules works.

The ORM

from odoo import models, fields, api
from odoo.exceptions import ValidationError

class SaleOrder(models.Model):
    _inherit = 'sale.order'          # extend the existing model

    credit_risk_score = fields.Integer(
        string='Credit Risk Score',
        compute='_compute_credit_risk',
        store=True,
    )
    requires_review = fields.Boolean(string='Requires Credit Review')

    @api.depends('partner_id', 'amount_total')
    def _compute_credit_risk(self):
        for order in self:
            overdue = self.env['account.move.line'].search_count([
                ('partner_id', '=', order.partner_id.id),
                ('date_maturity', '<', fields.Date.today()),
                ('parent_state', '=', 'posted'),
                ('reconciled', '=', False),
            ])
            order.credit_risk_score = min(100, overdue * 10)

    @api.constrains('amount_total')
    def _check_credit_limit(self):
        for order in self:
            if order.amount_total > order.partner_id.credit_limit:
                raise ValidationError(
                    f"Order total {order.amount_total} exceeds the credit limit "
                    f"for {order.partner_id.name}."
                )

    def action_confirm(self):
        res = super().action_confirm()      # call the original
        self.filtered(lambda o: o.credit_risk_score > 70).write({
            'requires_review': True
        })
        return res

Field types

Char, Text, Html, Integer, Float, Monetary, Boolean, Date, Datetime, Binary, Selection, Many2one, One2many, Many2many, Reference, Json.

Field attributes worth knowing

  • compute= + @api.depends — computed fields, cached and invalidated automatically
  • store=True — persist a computed field so you can search and group by it
  • related= — a shortcut through a relation
  • inverse= — makes a computed field writable
  • tracking=True — records changes in the chatter (the message log on the record)
  • company_dependent=True — a different value per company, stored as a property

Inheritance — the three types

Type Syntax Effect
Classical extension _inherit = 'sale.order' Adds fields/methods to the existing model. The one you use 95% of the time
Prototype _inherit = 'x' + _name = 'y' Copies into a new model
Delegation _inherits = {'product.template': 'product_tmpl_id'} Composition — the child exposes the parent’s fields

Module structure

my_module/
├── __init__.py
├── __manifest__.py          ← name, version, depends, data files, licence
├── models/
│   ├── __init__.py
│   └── sale_order.py
├── views/
│   └── sale_order_views.xml ← forms, lists, search, menus, actions
├── security/
│   ├── ir.model.access.csv  ← model-level CRUD per group
│   └── security.xml         ← groups and record rules
├── data/
│   └── data.xml             ← seed/master data
├── wizard/                  ← TransientModel dialogs
├── report/                  ← QWeb PDF templates
├── static/
│   ├── src/js/              ← OWL components
│   └── description/         ← app store icon and description
└── tests/
    └── test_sale_order.py

__manifest__.py:

{
    'name': 'Credit Risk Extension',
    'version': '18.0.1.0.0',
    'category': 'Sales',
    'depends': ['sale_management', 'account'],
    'data': [
        'security/ir.model.access.csv',
        'views/sale_order_views.xml',
    ],
    'license': 'LGPL-3',
    'installable': True,
}

Views (XML)

<record id="view_order_form_credit" model="ir.ui.view">
    <field name="name">sale.order.form.credit</field>
    <field name="model">sale.order</field>
    <field name="inherit_id" ref="sale.view_order_form"/>
    <field name="arch" type="xml">
        <xpath expr="//field[@name='partner_id']" position="after">
            <field name="credit_risk_score"/>
            <field name="requires_review" invisible="credit_risk_score &lt;= 70"/>
        </xpath>
    </field>
</record>

XPath-based view inheritance is how you modify screens without touching the original XML. Positions: after, before, inside, replace, attributes.

Security

# ir.model.access.csv — model-level permissions
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_credit_user,credit.user,model_credit_review,sales_team.group_sale_salesman,1,0,0,0
access_credit_mgr,credit.manager,model_credit_review,sales_team.group_sale_manager,1,1,1,1

Record rules add row-level filtering:

<record id="rule_own_orders" model="ir.rule">
    <field name="name">Salesperson sees own orders</field>
    <field name="model_id" ref="sale.model_sale_order"/>
    <field name="domain_force">[('user_id','=',user.id)]</field>
    <field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
</record>

Multi-company record rules are built in — every model with a company_id gets automatic isolation.

Reports — QWeb

<template id="report_credit_summary">
    <t t-call="web.html_container">
        <t t-foreach="docs" t-as="doc">
            <t t-call="web.external_layout">
                <div class="page">
                    <h2>Credit Review — <span t-field="doc.partner_id.name"/></h2>
                    <table class="table">
                        <tr t-foreach="doc.order_line" t-as="line">
                            <td><span t-field="line.product_id.name"/></td>
                            <td><span t-field="line.price_subtotal"
                                      t-options='{"widget": "monetary",
                                                  "display_currency": doc.currency_id}'/></td>
                        </tr>
                    </table>
                </div>
            </t>
        </t>
    </t>
</template>

QWeb → HTML → PDF via wkhtmltopdf. Templates are inheritable with the same XPath mechanism as views.

Integration

# XML-RPC — the classic external API
import xmlrpc.client
common = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/common')
uid = common.authenticate(db, username, password, {})
models = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/object')

orders = models.execute_kw(db, uid, password,
    'sale.order', 'search_read',
    [[['state', '=', 'sale'], ['date_order', '>=', '2026-01-01']]],
    {'fields': ['name', 'partner_id', 'amount_total'], 'limit': 100})

Also: JSON-RPC, custom HTTP controllers for REST endpoints, and community REST API modules.

Development setup

git clone https://github.com/odoo/odoo --depth 1 --branch 18.0
python3 -m venv venv && source venv/bin/activate
pip install -r odoo/requirements.txt
createdb odoo_dev

./odoo/odoo-bin -d odoo_dev --addons-path=odoo/addons,my_addons \
                -i base --dev=all

--dev=all gives you auto-reload on Python changes and XML reload on view changes. The developer loop is fast.

Testing

from odoo.tests import TransactionCase, tagged

@tagged('post_install', '-at_install')
class TestCreditRisk(TransactionCase):

    def setUp(self):
        super().setUp()
        self.partner = self.env['res.partner'].create({
            'name': 'Test Customer', 'credit_limit': 10000
        })

    def test_credit_limit_blocks_large_order(self):
        with self.assertRaises(ValidationError):
            self.env['sale.order'].create({
                'partner_id': self.partner.id,
                'order_line': [(0, 0, {
                    'product_id': self.env.ref('product.product_product_1').id,
                    'product_uom_qty': 1000, 'price_unit': 50,
                })],
            })

Tests run in a rolled-back transaction, so they’re isolated and fast.

Learning path

1. Install locally, use the apps as a business user     (2 weeks)
2. Python + PostgreSQL fundamentals                      (if needed)
3. ORM: models, fields, decorators, domains             (1 month)
4. Views, inheritance, XPath                            (2 weeks)
5. Security: groups, ACLs, record rules                 (1 week)
6. Reports (QWeb), wizards, scheduled actions           (2 weeks)
7. READ THE SOURCE of sale, purchase, stock, account    (ongoing — the real education)
8. OWL for front-end work                               (1 month)

2–4 months to productive. And uniquely, you can learn entirely for free with no licence, no VM, no partner agreement.

The OCA

The Odoo Community Association maintains thousands of high-quality free modules on GitHub. Reading OCA code is the best way to learn idiomatic Odoo, and contributing is a genuine portfolio credential.

Reality check

Pros: free to learn, readable source, Python, fast development, huge community, strong SMB/mid-market demand globally including South Asia, and low barrier to freelancing.

Cons: lower rates than SAP or Oracle, annual version upgrades that break custom modules, Enterprise/Community feature confusion, and localisation quality that varies a lot by country.

The upgrade treadmill is the main operational pain. Custom modules need work at every annual release. Write clean, minimal extensions and keep them small.

Next