Almost every Odoo project needs something the standard apps don't do out of the box. The right way to add it is a custom module: your own code, kept separate from Odoo's, that survives upgrades. In this tutorial we build a small but complete module, Equipment Rental, and cover the Odoo 19 changes that trip up developers coming from older versions.
You need a working Odoo 19 development setup (see our guide on installing Odoo 19 on Ubuntu) and a custom addons folder listed in addons_path.
1. Module structure
equipment_rental/
├── __init__.py
├── __manifest__.py
├── models/
│ ├── __init__.py
│ └── rental.py
├── security/
│ └── ir.model.access.csv
└── views/
└── rental_views.xml
You can generate a skeleton with odoo-bin scaffold equipment_rental /path/to/custom-addons, but writing it by hand once helps you understand every file.
2. The manifest
# __manifest__.py
{
"name": "Equipment Rental",
"version": "19.0.1.0.0",
"summary": "Track equipment rented to customers",
"category": "Sales",
"author": "Your Company",
"license": "LGPL-3",
"depends": ["base", "mail"],
"data": [
"security/ir.model.access.csv",
"views/rental_views.xml",
],
"application": True,
}
Order in data matters: access rights first, then views, and menus after the actions they open. Use the 19.0.x.y.z version format so upgrades are tracked correctly.
3. The model
# __init__.py
from . import models
# models/__init__.py
from . import rental
# models/rental.py
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class EquipmentRental(models.Model):
_name = "equipment.rental"
_description = "Equipment Rental"
_inherit = ["mail.thread"]
_order = "date_start desc"
name = fields.Char(required=True, tracking=True)
partner_id = fields.Many2one("res.partner", string="Customer", required=True)
date_start = fields.Date(required=True, default=fields.Date.today)
date_end = fields.Date(required=True)
daily_rate = fields.Monetary(currency_field="currency_id")
currency_id = fields.Many2one(
"res.currency", default=lambda self: self.env.company.currency_id)
days = fields.Integer(compute="_compute_total", store=True)
total = fields.Monetary(compute="_compute_total", store=True,
currency_field="currency_id")
state = fields.Selection([
("draft", "Draft"),
("rented", "Rented"),
("returned", "Returned"),
], default="draft", tracking=True)
_name_partner_unique = models.Constraint(
"UNIQUE(name, partner_id)",
"This customer already has a rental with this name.",
)
@api.depends("date_start", "date_end", "daily_rate")
def _compute_total(self):
for rec in self:
if rec.date_start and rec.date_end:
rec.days = (rec.date_end - rec.date_start).days + 1
else:
rec.days = 0
rec.total = rec.days * rec.daily_rate
@api.constrains("date_start", "date_end")
def _check_dates(self):
for rec in self:
if rec.date_end and rec.date_start and rec.date_end < rec.date_start:
raise ValidationError("End date cannot be before start date.")
def action_rent(self):
self.write({"state": "rented"})
def action_return(self):
self.write({"state": "returned"})
Odoo 19 note: SQL constraints are now declared as class attributes with models.Constraint instead of the old _sql_constraints list. Python constraints still use @api.constrains.
4. Access rights
Without an access rule, the model exists but nobody (except the superuser) can see it, a very common beginner trap.
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_equipment_rental_user,equipment.rental user,model_equipment_rental,base.group_user,1,1,1,1
The external id model_equipment_rental is generated automatically from the model name, with dots replaced by underscores.
5. Views, action and menu
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="equipment_rental_list" model="ir.ui.view">
<field name="name">equipment.rental.list</field>
<field name="model">equipment.rental</field>
<field name="arch" type="xml">
<list decoration-muted="state == 'returned'">
<field name="name"/>
<field name="partner_id"/>
<field name="date_start"/>
<field name="date_end"/>
<field name="total" sum="Total"/>
<field name="state" widget="badge"/>
</list>
</field>
</record>
<record id="equipment_rental_form" model="ir.ui.view">
<field name="name">equipment.rental.form</field>
<field name="model">equipment.rental</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_rent" type="object" string="Mark as Rented"
class="btn-primary" invisible="state != 'draft'"/>
<button name="action_return" type="object" string="Mark as Returned"
invisible="state != 'rented'"/>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<group>
<group>
<field name="name"/>
<field name="partner_id"/>
</group>
<group>
<field name="date_start"/>
<field name="date_end"/>
<field name="daily_rate"/>
<field name="days"/>
<field name="total"/>
<field name="currency_id" invisible="1"/>
</group>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_equipment_rental" model="ir.actions.act_window">
<field name="name">Rentals</field>
<field name="res_model">equipment.rental</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_rental_root" name="Rentals" sequence="20"/>
<menuitem id="menu_rental" name="Rentals" parent="menu_rental_root"
action="action_equipment_rental"/>
</odoo>
Odoo 17+ and 19 notes:
- List views use
<list>, andview_modeuseslist. The old<tree>tag is gone. attrsandstateswere removed. Write conditions directly:invisible="state != 'draft'",readonly="...",required="...".- The chatter is a single
<chatter/>tag instead of the oldoe_chatterdiv.
6. Install and test
# restart Odoo, then install from the command line
./odoo-bin -c /etc/odoo.conf -d mydb -i equipment_rental --stop-after-init
# after code changes
./odoo-bin -c /etc/odoo.conf -d mydb -u equipment_rental --stop-after-init
Or enable developer mode, open Apps, click Update Apps List, search "Equipment Rental" and install. Python changes need a server restart; XML and data changes need a module upgrade (-u).
Common errors and fixes
- Module not in the Apps list: the folder is not in
addons_path, or you didn't click Update Apps List. - "You are not allowed to access 'Equipment Rental'": missing or wrong line in
ir.model.access.csv. - "Invalid view type: tree": replace
<tree>with<list>andtreewithlistinview_mode. - "Since 17.0, the attrs and states attributes are no longer used": rewrite them as direct
invisible/readonlyexpressions. - Computed field not updating: check that every field it reads is listed in
@api.depends.
Where to go next
From here you can inherit existing models (add fields to sale.order with _inherit), add QWeb PDF reports, scheduled actions, or REST endpoints with http.Controller. When the requirement gets bigger, such as integrations, migrations or complex workflows, our Odoo development team can help. Bundo Tech is an Official Odoo Partner with 200+ Odoo projects delivered. Talk to us.