Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
39c9e64
[ADD] Tutorial - Ch 1, 2, 3 - Add module, models, and basic fields
andha-odoo Mar 16, 2026
9448e18
[IMP] Tutorial - Chapter 4 - Security
andha-odoo Mar 16, 2026
c8a3e91
[IMP] Tutorial - Chapter 5 - Actions and Menus
andha-odoo Mar 16, 2026
077ffd7
[IMP] Tutorial - Chapter 5 - Fields, Attributes, View, Default Values
andha-odoo Mar 17, 2026
b266f26
[IMP] Tutorial - Chapter 6 - List, Form, and Search Views
andha-odoo Mar 17, 2026
1d80309
[FIX] Datetime calculation as lambda and renaming values
andha-odoo Mar 17, 2026
e9e2d4c
[IMP] Tutorial - Chapter 7 - Relations Between Models
andha-odoo Mar 17, 2026
cde4aaf
[FIX] styling fixes
andha-odoo Mar 17, 2026
5bc4790
[IMP] Tutorial - Chapter 8 - Computed Fields and Onchanges
andha-odoo Mar 17, 2026
f7a41c9
[IMP] Tutorial - Chapter 9 - Ready For Some Action?
andha-odoo Mar 18, 2026
a4e18a2
[IMP] Tutorial - Chapter 10 - Constraints
andha-odoo Mar 18, 2026
6d77cf4
[IMP] Tutorial - Chapter 11 - Sprinkles
andha-odoo Mar 18, 2026
0b90b2f
[FIX] Rename access record entries
andha-odoo Mar 18, 2026
f7402bc
[FIX] Rename access record entries (again)
andha-odoo Mar 18, 2026
d2011ff
[FIX] Rename access record entries (again x2)
andha-odoo Mar 19, 2026
286105a
[FIX] Rename access record entries (again x3)
andha-odoo Mar 19, 2026
80e0a16
[LINT] Cleaning up various .py and .xml files
andha-odoo Mar 19, 2026
c267b65
[IMP] Tutorial - Chapter 12 - Inheritance
andha-odoo Mar 19, 2026
aa77f29
[IMP] Tutorial - Chapter 13 - Invoicing
andha-odoo Mar 19, 2026
9ce82fb
[IMP] Tutorial - Chapter 14 - Kanban View
andha-odoo Mar 19, 2026
dcdfb4c
[LINT] Remove unused variable declaration
andha-odoo Mar 19, 2026
55b2bc5
[ADD] Web Framework Tutorial - Chapter 1
andha-odoo Mar 23, 2026
0edbed8
[ADD] Web Framework Tutorial - Chapter 2 - Ch 1...8
andha-odoo Mar 23, 2026
b1bc98e
[IMP] Web Framework Tutorial - Chapter 2 - Ch 9, 10, 11
andha-odoo Mar 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,6 @@ dmypy.json

# Pyre type checker
.pyre/

# Linting
pyproject.toml
Comment on lines 128 to +132
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should not push this

1 change: 1 addition & 0 deletions estate/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
17 changes: 17 additions & 0 deletions estate/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
'name': 'Real Estate',
'version': '1.0',
'depends': ['base'],
'author': 'Anmol Dhaliwal',
'category': 'Category',
'data': [
'security/ir.model.access.csv',
'views/estate_property_views.xml',
'views/estate_property_offer_views.xml',
'views/estate_property_type_views.xml',
'views/estate_property_tag_views.xml',
'views/estate_menus.xml',
'views/res_users_views.xml',
],
'license': 'OEEL-1',
}
5 changes: 5 additions & 0 deletions estate/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from . import estate_property
from . import estate_property_type
from . import estate_property_tag
from . import estate_property_offer
from . import res_users
131 changes: 131 additions & 0 deletions estate/models/estate_property.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
from odoo import api, fields, models, exceptions
from dateutil.relativedelta import relativedelta
from odoo.tools.float_utils import float_compare, float_is_zero


class EstateProperty(models.Model):
_name = 'estate.property'
_description = 'A specific property'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double quotes here as it is a string that will be shown to the user

_order = 'id desc'

name = fields.Char('Title', required=True)
description = fields.Text()
postcode = fields.Char()
date_availability = fields.Date(
'Available From',
copy=False,
default=lambda _: fields.Date.today() + relativedelta(months=3),
)
expected_price = fields.Float(required=True)
_expected_price = models.Constraint(
'CHECK(expected_price > 0)',
'The expected price must be strictly positive.',
)
selling_price = fields.Float(readonly=True, copy=False)
_selling_price = models.Constraint(
'CHECK(expected_price >= 0)',
'The selling price must be positive.',
)
bedrooms = fields.Integer(default=2)
living_area = fields.Integer('Living Area (sqm)')
facades = fields.Integer()
garage = fields.Boolean()
garden = fields.Boolean()
garden_area = fields.Integer(string='Garden Area (sqm)')
garden_orientation = fields.Selection(
string='Garden Orientation',
selection=[
('north', 'North'),
('east', 'East'),
('south', 'South'),
('west', 'West'),
],
)
active = fields.Boolean(default=True)
state = fields.Selection(
string='State',
selection=[
('new', 'New'),
('offer_received', 'Offer Received'),
('offer_accepted', 'Offer Accepted'),
('sold', 'Sold'),
('cancelled', 'Cancelled'),
],
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.uid
)
buyer_id = fields.Many2one('res.partner', string='Buyer', copy=False)
tag_ids = fields.Many2many('estate.property.tag', string='Tags')
offer_ids = fields.One2many(
'estate.property.offer', 'property_id', string='Offers'
)
total_area = fields.Integer(
compute='_compute_total_area', string='Total Area (sqm)'
)
best_price = fields.Float(string='Best Price', compute='_compute_best_price')

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

@api.depends('offer_ids')
def _compute_best_price(self):
for record in self:
record.best_price = (
max(record.offer_ids.mapped('price')) if record.offer_ids else 0
)
Comment on lines +82 to +84
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be on one line ^^


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

def action_sold(self):
for record in self:
if record.state == 'cancelled':
raise exceptions.UserError('A cancelled listing cannot be sold')
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should import UserError directly instead of using exceptions.UserError

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also it needs to be translated using self.env._("A cancelled listing cannot be sold.")

Do not forget about double quotes also 😄

elif record.state == 'sold':
raise exceptions.UserError('This listing has already been sold')
else:
record.state = 'sold'
return True

def action_cancel(self):
for record in self:
if record.state == 'sold':
raise exceptions.UserError('Sold listings cannot be cancelled')
elif record.state == 'cancelled':
raise exceptions.UserError('This listing is already cancelled')
else:
record.state = 'cancelled'
return True

@api.constrains('selling_price', 'expected_price')
def _validate_selling_price(self):
for record in self:
if float_compare(
record.selling_price, record.expected_price * 0.9, 2
) == -1 and not float_is_zero(record.selling_price, 2):
Comment on lines +118 to +120
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if float_compare(record.selling_price, record.expected_price * 0.9, 2) == -1 
    and not float_is_zero(record.selling_price, 2):

This is easier to read IMO 😄

raise exceptions.ValidationError(
'The selling price must be at least 90%% of the expected price'
)

Comment on lines +121 to +124
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here and for every other exceptions ^^

@api.ondelete(at_uninstall=False)
def _check_before_delete(self):
for record in self:
if record.state not in ('new', 'cancelled'):
raise exceptions.UserError(
'A property cannot be deleted unless its state is New or Cancelled'
)
82 changes: 82 additions & 0 deletions estate/models/estate_property_offer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from odoo import api, exceptions, fields, models
from dateutil.relativedelta import relativedelta
from odoo.tools import float_compare


class EstatePropertyOffer(models.Model):
_name = 'estate.property.offer'
_description = 'An offer made on a property'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here it should be double quotes. It happens at other places also.

_order = 'price desc'

price = fields.Float(string='Price')
_price = models.Constraint(
'CHECK(price > 0)',
'The offer price must be strictly positive.',
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be double quotes ^^

)
status = fields.Selection(
copy=False,
selection=[
('accepted', 'Accepted'),
('refused', 'Refused'),
],
)
partner_id = fields.Many2one('res.partner', string='Partner', required=True)
property_id = fields.Many2one(
'estate.property', string='Property', required=True, ondelete='cascade'
)
validity = fields.Integer(string='Validity (days)', default=7)
date_deadline = fields.Date(
string='Deadline',
compute='_compute_date_deadline',
inverse='_inverse_date_deadline',
)
property_type_id = fields.Many2one(
'estate.property.type', related='property_id.property_type_id', store=True
)

@api.depends('validity')
def _compute_date_deadline(self):
for record in self:
record.date_deadline = (
(record.create_date + relativedelta(days=record.validity))
if record.create_date
else (fields.Date.today() + relativedelta(days=record.validity))
)
Comment on lines +41 to +44
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly, it might be easier to read using a normal condition.


def _inverse_date_deadline(self):
for record in self:
record.validity = relativedelta(
record.date_deadline,
record.create_date if record.create_date else fields.Date.today(),
).days

def action_accept(self):
for record in self:
for offer in record.property_id.offer_ids:
if offer.status == 'accepted':
raise exceptions.UserError(
'An offer has already been accepted for this property'
)
else:
record.status = 'accepted'
record.property_id.state = 'offer_accepted'
record.property_id.buyer_id = record.partner_id
record.property_id.selling_price = record.price
return True

def action_refuse(self):
for record in self:
record.status = 'refused'
return True

@api.model_create_multi
def create(self, vals_list):
for val in vals_list:
property_for_offer = self.env['estate.property'].browse(val['property_id'])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid doing a browse in a loop because that way you make a lot of sql queries. You can browse all of them beforehand then use the records in the loop.

if float_compare(val['price'], property_for_offer.best_price, 2) == -1:
raise exceptions.UserError(
'New offers cannot be lower than existing offers'
)
property_for_offer.state = 'offer_received'

return super().create(vals_list)
11 changes: 11 additions & 0 deletions estate/models/estate_property_tag.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from odoo import fields, models


class EstatePropertyType(models.Model):
_name = 'estate.property.tag'
_description = 'A property tag'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Description is not really a description I know it is confusing haha. It should be the Display String of the Model.
So here for instance : "Property Tag" and for estate.property.offer: "Property Offer".

Don't forget to change it for all models ^^

_order = 'name'

name = fields.Char(string='Tag Name', required=True)
_unique_name = models.Constraint('UNIQUE(name)', 'The name must be unique.')
color = fields.Integer()
21 changes: 21 additions & 0 deletions estate/models/estate_property_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from odoo import api, fields, models


class EstatePropertyType(models.Model):
_name = 'estate.property.type'
_description = 'A type of property'
_order = 'sequence asc'

name = fields.Char(string='Title', required=True)
_unique_name = models.Constraint('UNIQUE(name)', 'The name must be unique.')
property_ids = fields.One2many(
'estate.property', 'property_type_id', string='Properties'
)
sequence = fields.Integer('Sequence', default=1)
offer_ids = fields.One2many('estate.property.offer', 'property_type_id')
offer_count = fields.Integer(compute='_compute_offer_count')

@api.depends('offer_ids')
def _compute_offer_count(self):
for record in self:
record.offer_count = len(record.offer_ids)
12 changes: 12 additions & 0 deletions estate/models/res_users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from odoo import fields, models


class ResUsers(models.Model):
_inherit = 'res.users'

property_ids = fields.One2many(
'estate.property',
'salesperson_id',
string='Property',
domain="['|', ('state', '=', 'New'), ('state', '=', 'Offer Received')]",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The domain should not be in a string. You can directly pass the list otherwise it should not work.

)
5 changes: 5 additions & 0 deletions estate/security/ir.model.access.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
estate_property_user,estate_property_user,model_estate_property,base.group_user,1,1,1,1
estate_property_type_user,estate_property_type_user,model_estate_property_type,base.group_user,1,1,1,1
estate_property_tag_user,estate_property_tag_user,model_estate_property_tag,base.group_user,1,1,1,1
estate_property_offer_user,estate_property_offer_user,model_estate_property_offer,base.group_user,1,1,1,1
3 changes: 3 additions & 0 deletions estate/views/estate_list_views.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
</odoo>
Comment on lines +2 to +3
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty file here 👀

12 changes: 12 additions & 0 deletions estate/views/estate_menus.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="estate_property_menu_root" name="Estate Properties">
<menuitem id="estate_properties_menu" name="Advertisements">
<menuitem id="estate_property_menu_action" action="estate_property_action" />
</menuitem>
<menuitem id="estate_properties_settings_menu" name="Settings">
<menuitem id="estate_property_type_menu_action" action="estate_property_type_action" />
<menuitem id="estate_property_tag_menu_action" action="estate_property_tag_action" />
</menuitem>
</menuitem>
</odoo>
48 changes: 48 additions & 0 deletions estate/views/estate_property_offer_views.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="estate_property_offer_action" model="ir.actions.act_window">
<field name="name">View Property Offers</field>
<field name="res_model">estate.property.offer</field>
<field name="view_mode">list,form</field>
<field name="domain">[('property_type_id', '=', active_id)]</field>
</record>

<record id="estate_property_offer_list_view" model="ir.ui.view">
<field name="name">estate.property.offer.list</field>
<field name="model">estate.property.offer</field>
<field name="arch" type="xml">
<list string="Offers" editable="bottom" decoration-success="status == 'accepted'"
decoration-danger="status == 'refused'">
<field name="price" />
<field name="partner_id" />
<field name="validity" />
<field name='date_deadline' />
<button name="action_accept" string="Accept" type="object" icon="fa-check"
invisible="status == 'accepted' or status == 'refused'" />
<button name="action_refuse" string="Refuse" type="object" icon="fa-times"
invisible="status == 'accepted' or status == 'refused'" />
</list>
</field>
</record>

<record id="estate_property_offer_form_view" model="ir.ui.view">
<field name="name">estate.property.offer.form</field>
<field name="model">estate.property.offer</field>
<field name="arch" type="xml">
<form string="Properties">
<sheet>
<h1 class="mb32">
<field name="property_id" class="mb16" />
</h1>
<group>
<field name="price" />
<field name="partner_id" />
<field name="validity" />
<field name='date_deadline' />
<field name="status" />
</group>
</sheet>
</form>
</field>
</record>
</odoo>
19 changes: 19 additions & 0 deletions estate/views/estate_property_tag_views.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="estate_property_tag_action" model="ir.actions.act_window">
<field name="name">View Property Tags</field>
<field name="res_model">estate.property.tag</field>
<field name="view_mode">list,form</field>
</record>

<record id="estate_property_tag_list_view" model="ir.ui.view">
<field name="name">Property Tags</field>
<field name="model">estate.property.tag</field>
<field name="arch" type="xml">
<list string="Tags" editable="bottom">
<field name="name" />
<field name="color" widget="color_picker" />
</list>
</field>
</record>
</odoo>
Loading