Odoo Custom Module Development Part 5 - Relations, Computed Fields, and Business Logic

This is the fifth article in the Odoo custom module development series.

In Part 4, we added the user interface. In this article, we will make the module smarter by adding relations, computed fields, onchange logic, constraints, and object methods.

This is where a simple CRUD module starts becoming a real business application.

What we will add

We will add:

  • property type relation
  • salesperson relation
  • buyer relation
  • computed total area
  • a computed price helper field
  • onchange behavior for garden fields
  • validation constraints
  • action buttons to sell or cancel a property

1. Add relational fields to estate.property

Update models/property.py:

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


class EstateProperty(models.Model):
_name = 'estate.property'
_description = 'Real Estate Property'
_order = 'id desc'

name = fields.Char(required=True)
description = fields.Text()
postcode = fields.Char()
date_availability = fields.Date(copy=False)
expected_price = fields.Float(required=True)
selling_price = fields.Float(readonly=True, copy=False)
bedrooms = fields.Integer(default=2)
living_area = fields.Integer()
facades = fields.Integer()
garage = fields.Boolean()
garden = fields.Boolean()
garden_area = fields.Integer(default=10)
garden_orientation = fields.Selection(
[('north', 'North'), ('south', 'South'), ('east', 'East'), ('west', 'West')],
default='north',
)
active = fields.Boolean(default=True)
state = fields.Selection(
[
('new', 'New'),
('offer_received', 'Offer Received'),
('offer_accepted', 'Offer Accepted'),
('sold', 'Sold'),
('canceled', 'Canceled'),
],
required=True,
copy=False,
default='new',
)
property_type_id = fields.Many2one('estate.property.type', string='Property Type')
salesperson_id = fields.Many2one('res.users', string='Salesperson', default=lambda self: self.env.user)
buyer_id = fields.Many2one('res.partner', string='Buyer', copy=False)
total_area = fields.Integer(compute='_compute_total_area', store=True)
best_price = fields.Float(compute='_compute_best_price', store=False)

@api.depends('living_area', 'garden_area')
def _compute_total_area(self):
for record in self:
record.total_area = record.living_area + record.garden_area

def _compute_best_price(self):
for record in self:
record.best_price = record.selling_price or 0.0

@api.onchange('garden')
def _onchange_garden(self):
for record in self:
if record.garden:
record.garden_area = 10
record.garden_orientation = 'north'
else:
record.garden_area = 0
record.garden_orientation = False

@api.constrains('expected_price')
def _check_expected_price(self):
for record in self:
if record.expected_price <= 0:
raise ValidationError('Expected price must be greater than zero.')

@api.constrains('selling_price', 'expected_price')
def _check_selling_price(self):
for record in self:
if record.selling_price and record.selling_price < (record.expected_price * 0.9):
raise ValidationError('Selling price cannot be lower than 90% of the expected price.')

def action_mark_sold(self):
for record in self:
if record.state == 'canceled':
raise UserError('Canceled properties cannot be sold.')
record.state = 'sold'

def action_mark_canceled(self):
for record in self:
if record.state == 'sold':
raise UserError('Sold properties cannot be canceled.')
record.state = 'canceled'

In a more advanced version of this module, best_price would usually be computed from related offer records. Here we keep it simple so you can learn computed fields without introducing another model yet.

2. Why these field types matter

  • Many2one: one property belongs to one type, one buyer, one salesperson
  • computed fields: values are derived automatically from other fields
  • @api.onchange: improves the form experience before saving
  • constraints: stop invalid business data from entering the database
  • object methods: let buttons execute business transitions

3. Show the new fields in the form view

Update the property form view in estate_property_views.xml:

<form string="Property">
<header>
<button name="action_mark_sold" type="object" string="Mark as Sold" class="oe_highlight"/>
<button name="action_mark_canceled" type="object" string="Cancel"/>
<field name="state" widget="statusbar" statusbar_visible="new,offer_received,offer_accepted,sold"/>
</header>
<sheet>
<group>
<group>
<field name="name"/>
<field name="property_type_id"/>
<field name="salesperson_id"/>
<field name="buyer_id"/>
<field name="postcode"/>
<field name="date_availability"/>
</group>
<group>
<field name="expected_price"/>
<field name="selling_price"/>
<field name="best_price" readonly="1"/>
<field name="bedrooms"/>
<field name="living_area"/>
<field name="garden"/>
<field name="garden_area"/>
<field name="garden_orientation"/>
<field name="total_area" readonly="1"/>
<field name="active"/>
</group>
</group>
<notebook>
<page string="Description">
<field name="description"/>
</page>
</notebook>
</sheet>
</form>

This adds visible business behavior to the UI.

4. Add fields to the list view

Update the list view so users can quickly scan important values:

<list string="Properties">
<field name="name"/>
<field name="property_type_id"/>
<field name="salesperson_id"/>
<field name="expected_price"/>
<field name="selling_price"/>
<field name="total_area"/>
<field name="state"/>
</list>

5. Add SQL-level protection if needed

Python constraints are good for business rules, but some cases also benefit from SQL constraints.

Example:

_sql_constraints = [
('check_expected_price', 'CHECK(expected_price > 0)', 'Expected price must be greater than zero.'),
]

Use SQL constraints for simple database-safe guarantees. Use Python constraints when the rule is more complex.

6. Upgrade the module and test behavior

Run:

cd ~/odoo-dev/odoo
source .venv/bin/activate
python3 odoo-bin -c ~/odoo-dev/odoo.conf -d odoo19 -u estate --stop-after-init

Then test these scenarios from the UI:

  • create a property with expected_price = 0 and confirm validation fails
  • toggle garden on and off and confirm the onchange logic updates the fields
  • click Mark as Sold
  • try to cancel a sold property and confirm the error is raised

7. Best practices for business logic in Odoo

  • keep business rules in Python methods, not only in the UI
  • use constraints for validation, not manual reminders in help text
  • use object methods for state transitions
  • make state changes explicit and readable
  • avoid putting serious business logic inside onchange methods because onchange only runs in the UI

8. Common mistakes

Relying only on onchange for validation

@api.onchange does not protect imports, server-side operations, or API calls. Use constraints for real validation.

Forgetting store=True for a computed field you want to search or group by

Non-stored computed fields are recalculated dynamically and are not always suitable for filtering or grouping.

Putting too much logic in button methods without state checks

Always validate the current state before allowing a transition.

Final words

Your module now has meaningful business logic and workflow behavior.

In the next article, we will add sample data, PDF reporting, and automated tests so the module becomes easier to demonstrate and maintain.

Previous article: Part 4 - Menus, Actions, and Views

Next article: Part 6 - Reports, Demo Data, and Tests

Related posts

Md. Monirul Alom

Md. Monirul Alom

I am a Full Stack Web developer. I love to code, travel, do some volunteer work. Whenever I get time I write for this blog