Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4520620
[ADD] estate: add manifest and init for new module
aryep-odoo Aug 17, 2026
55fd4a4
[ADD] estate: add new model for properties
aryep-odoo Aug 17, 2026
6636921
[ADD] estate: add security rules to the estate base model
aryep-odoo Aug 17, 2026
8bdc650
[IMP] estate: update fields and create basic views for creation, mani…
aryep-odoo Aug 17, 2026
5ede1f3
[ADD] estate: add views for list, form and filters/groups
aryep-odoo Aug 18, 2026
7bf0a5b
[ADD] estate: add new relational fields with proper views, actions an…
aryep-odoo Aug 18, 2026
27e083d
[ADD] estate: introduce business logic
aryep-odoo Aug 18, 2026
f8c5a59
[CLN] estate: apply coding guidelines
aryep-odoo Aug 18, 2026
9da69bc
[IMP] estate: enhance the domain for the filter in property view
aryep-odoo Aug 18, 2026
c6d95e3
[ADD] estate: add new actions for handling property offers
aryep-odoo Aug 19, 2026
0f0d78a
[ADD] estate: new constraints
aryep-odoo Aug 19, 2026
f25c9d0
[IMP] estate: update display of information and relation between models
aryep-odoo Aug 19, 2026
d76cac2
[IMP] estate: added inheritance features
aryep-odoo Aug 19, 2026
fe74bd8
[FIX] estate: remove conflicting menuitem declaration
aryep-odoo Aug 19, 2026
1080e68
[ADD] estate_account: create new module to interconnect estate and ac…
aryep-odoo Aug 20, 2026
b9c0c74
[LINT] estate: apply rules of linitng based on Ruff config
aryep-odoo Aug 20, 2026
17b56ef
[LINT] estate_accouunt: apply rules of linitng based on Ruff config
aryep-odoo Aug 20, 2026
e0c408a
[ADD] estate: new kanban view
aryep-odoo Aug 20, 2026
a55c5a6
[FIX] estate: remove the build error
aryep-odoo Aug 20, 2026
699843f
[IMP] estate: handle edge cases
aryep-odoo Aug 21, 2026
3420437
[ADD] estate: new unit tests
aryep-odoo Aug 21, 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
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
20 changes: 20 additions & 0 deletions estate/__manifest__.py
Comment thread
aryep-odoo marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "Estate",
"version": "1.0",
"summary": "Estate Management",
"depends": ["base"],
"application": True,
"installable": True,
"data": [
'security/ir.model.access.csv',

'views/estate_property_views.xml',
'views/estate_property_tag_views.xml',
'views/estate_property_offer_views.xml',
'views/estate_property_type_views.xml',
'views/res_users_views.xml',
'views/estate_menus.xml',
],
"author": "Arturo Yepez",
"license": 'LGPL-3',
}
5 changes: 5 additions & 0 deletions estate/models/__init__.py
Comment thread
aryep-odoo marked this conversation as resolved.
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
149 changes: 149 additions & 0 deletions estate/models/estate_property.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
from dateutil.relativedelta import relativedelta

from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
from odoo.tools.float_utils import float_compare


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

name = fields.Char(string="Title", required=True)
description = fields.Text()
postcode = fields.Char()
date_availability = fields.Date(
string="Available From",
default=lambda _: fields.Date.today() + relativedelta(months=3),
)
expected_price = fields.Float(required=True)
selling_price = fields.Float(readonly=True, copy=False)
bedrooms = fields.Integer(default=2)
living_area = fields.Integer(string="Living Area (sqm)")
facades = fields.Integer()
garage = fields.Boolean()
garden = fields.Boolean()
garden_area = fields.Integer()
garden_orientation = fields.Selection(
string="Garden Orientation",
selection=[
("north", "North"),
("south", "South"),
("east", "East"),
("west", "West"),
],
)
state = fields.Selection(
string="Status",
selection=[
("new", "New"),
("offer_received", "Offer Received"),
("offer_accepted", "Offer Accepted"),
("sold", "Sold"),
("cancelled", "Cancelled"),
],
default="new",
copy=False,
)
property_type_id = fields.Many2one(
string="Property Type",
comodel_name="estate.property.type",
)
partner_id = fields.Many2one(
string="Buyer",
comodel_name="res.partner",
copy=False,
readonly=True,
)
user_id = fields.Many2one(
string="Salesman",
comodel_name="res.users",
default=lambda self: self.env.user,
)
tag_ids = fields.Many2many(
string="Property Tags",
comodel_name="estate.property.tag",
)
offer_ids = fields.One2many(
string="Offers",
comodel_name="estate.property.offer",
inverse_name="property_id",
)
total_area = fields.Integer(
string="Total Area (sqm)",
compute="_compute_total_area",
readonly=True,
)
best_price = fields.Float(
string="Best Offer",
compute="_compute_best_price",
readonly=True,
)
active = fields.Boolean(default=True)

_check_positive_expected_price = models.Constraint(
'CHECK(expected_price > 0)',
'Expected price must be a positive amount.',
)
_check_positive_selling_price = models.Constraint(
'CHECK(selling_price >= 0)',
'Selling price must be a positive amount.',
)

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

@api.depends("offer_ids")
def _compute_best_price(self):
for record in self:
if record.offer_ids:
record.best_price = max(record.offer_ids.mapped("price"))
else:
record.best_price = 0.0

@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 = False

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Good job finding the translation function! It is a better practice to use it as self.env._(...) than simply _, as explained in its docstring, for the reasons explained in this commit.


@api.ondelete(at_uninstall=False)
def _unlink_if_not_new_or_cancelled(self):
for record in self:
if record.state not in ("new", "cancelled"):
raise ValidationError(_("Only new or cancelled properties can be deleted."))

def action_sold(self):
self.ensure_one()

if not self.offer_ids:
raise ValidationError(_("Properties without offers can't be sold"))

if self.state == "cancelled":
raise UserError(_("Cancelled properties cannot be sold."))

if self.state != "offer_accepted":
raise UserError(_("Only accepted offers can be sold."))

self.state = "sold"

def action_cancel(self):
self.ensure_one()

if self.state == "sold":
raise UserError(_("Sold properties cannot be cancelled."))

self.state = "cancelled"
86 changes: 86 additions & 0 deletions estate/models/estate_property_offer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
from dateutil.relativedelta import relativedelta

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


class EstatePropertyOffer(models.Model):
_name = "estate.property.offer"
_description = "Estate Property Offer"
_order = "price desc"

price = fields.Float(string="Offer Price", required=True)
validity = fields.Integer(string="Validity (days)", default=7)
date_deadline = fields.Date(
string="Deadline",
compute="_compute_date_deadline",
inverse="_inverse_date_deadline",
)
status = fields.Selection(
string="Status",
selection=[
("accepted", "Accepted"),
("refused", "Refused"),
],
copy=False,
)
partner_id = fields.Many2one(
string="Buyer",
comodel_name="res.partner",
required=True,
)
property_id = fields.Many2one(
string="Property",
comodel_name="estate.property",
required=True,
)
property_type_id = fields.Many2one(
string="Property Type",
related="property_id.property_type_id",
store=True,
)

_check_positive_price = models.Constraint(
'CHECK(price > 0)',
'Offer prices must be a positive amount.',
)

# Methods
@api.depends("validity")
def _compute_date_deadline(self):
for record in self:
record.date_deadline = fields.Date.today() + relativedelta(days=record.validity)

def _inverse_date_deadline(self):
for record in self:
record.validity = (record.date_deadline - fields.Date.today()).days

@api.model
def create(self, vals_list):
for vals in vals_list:
property = self.env["estate.property"].browse(vals.get("property_id"))

if property and property.state in ("sold", "offer_accepted"):
raise ValidationError(_("Can't create an offer for properties that are sold or with an accepted offer"))

property.state = "offer_received"

return super().create(vals_list)

def action_accept(self):
for record in self:
record.status = "accepted"
record.property_id.state = "offer_accepted"
record.property_id.partner_id = record.partner_id
record.property_id.selling_price = record.price

# Refuse other offers for the same property
other_offers = self.search([
("property_id", "=", record.property_id.id),
("id", "!=", record.id),
])
other_offers.write({"status": "refused"})
Comment on lines +70 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Few things to say here:

  1. An action should (usually) only operate on one record, so you don't need to loop over self, and instead you should call self.ensure_one() at the very start of the action.
  2. When updating multiple fields of a same record, you can batch the writes using the \write method (as you did further down the method actually)
  3. You should avoid using search in a loop. In this case it ends up being fine because we're removing the loop, but otherwise it would have been a bad practice. An alternative is to use _read_group and then to loop over the result (example)
Suggested change
def action_accept(self):
for record in self:
record.status = "accepted"
record.property_id.state = "offer_accepted"
record.property_id.partner_id = record.partner_id
record.property_id.selling_price = record.price
# Refuse other offers for the same property
other_offers = self.search([
("property_id", "=", record.property_id.id),
("id", "!=", record.id),
])
other_offers.write({"status": "refused"})
def action_accept(self):
self.ensure_one()
self.status = "accepted"
self.property_id.write({
'state': 'offer_accepted',
'partner_id': self.partner_id.id,
'selling_price': self.price,
})
# Refuse other offers for the same property
self.search([
("property_id", "=", self.property_id.id),
("id", "!=", record.id),
]).write({"status": "refused"})


def action_refuse(self):
for record in self:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
for record in self:
self.ensure_one()

record.status = "refused"
15 changes: 15 additions & 0 deletions estate/models/estate_property_tag.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from odoo import fields, models


class EstatePropertyTag(models.Model):
_name = "estate.property.tag"
_description = "Estate Property Tag"
_order = "name"

name = fields.Char(string="Tag Name", required=True)
color = fields.Integer(string="Color Index")

_unique_name = models.Constraint(
'UNIQUE(name)',
'Tag name must be unique.',
)
29 changes: 29 additions & 0 deletions estate/models/estate_property_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from odoo import fields, models


class EstatePropertyType(models.Model):
_name = "estate.property.type"
_description = "Estate Property Type"
_order = "sequence, name"

name = fields.Char(string="Property Type", required=True)
sequence = fields.Integer('Sequence', default=1, help="Used to order types. Lower is better.")
property_ids = fields.One2many(
string="Properties",
comodel_name="estate.property",
inverse_name="property_type_id",
)
offer_ids = fields.One2many(
string="Offers",
comodel_name="estate.property.offer",
inverse_name="property_type_id",
)
offer_count = fields.Integer(
string="Number of Offers",
compute="_compute_offer_count",
)

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


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

property_ids = fields.One2many(
string="Real Estate Properties",
comodel_name="estate.property",
inverse_name="user_id",
)
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
access_estate_property,access_estate_property,estate.model_estate_property,base.group_user,1,1,1,1
access_estate_property_type,access_estate_property_type,estate.model_estate_property_type,base.group_user,1,1,1,1
access_estate_property_tag,access_estate_property_tag,estate.model_estate_property_tag,base.group_user,1,1,1,1
access_estate_property_offer,access_estate_property_offer,estate.model_estate_property_offer,base.group_user,1,1,1,1
4 changes: 4 additions & 0 deletions estate/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@

from . import common
from . import test_estate_property
from . import test_estate_property_offer
27 changes: 27 additions & 0 deletions estate/tests/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from odoo.addons.base.tests.common import BaseCommon


class EstatePropertyCommon(BaseCommon):

@classmethod
def setUpClass(cls):
super().setUpClass()

cls.estate_property_types = cls.env["estate.property.type"].create([
{"name": "House"},
{"name": "Apartment"},
])
cls.estate_property_tags = cls.env["estate.property.tag"].create([
{"name": "tag1"},
{"name": "tag2"},
])
Comment on lines +10 to +17

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 you don't use them, do not create them 👌

cls.estate_properties = cls.env["estate.property"].create([

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Creating in batch is a great practice, but do you really need two properties? Their only difference is the price, which I don't think really changes anything in your tests. I would either:

  • Create only one property
  • Create two properties that have significant differences (for example, one with a garden and one without)

As a side note, keeping the two properties together doesn't sound like the best idea here, since it forces you to call `search' at the start of each test, which is unnecessarily costly. Instead, you could do the following:

Suggested change
cls.estate_properties = cls.env["estate.property"].create([
cls.property_1, cls.property_2 = cls.env["estate.property"].create([

with more appropriate names if your properties have significant differences (e.g., property_with_garden, ...).

{
"name": "Property 1",
"expected_price": 100.0,
},
{
"name": "Property 2",
"expected_price": 150.0,
},
])
31 changes: 31 additions & 0 deletions estate/tests/test_estate_property.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from odoo.tests import Form, tagged

from odoo.addons.estate.tests.common import EstatePropertyCommon


@tagged("post_install", "-at_install")
class EstatePropertyGardenCase(EstatePropertyCommon):

@classmethod
def setUpClass(cls):
super().setUpClass()

main_property = cls.estate_properties.search([("name", "=", "Property 1")])
main_property.living_area = 90
main_property.garden = True
main_property.garden_area = 100
main_property.garden_orientation = "south"
Comment on lines +13 to +17

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 could just create a property with those values from the start, instead of searching and editing an existing one


def test_garden_deactivation(self):
"""Test that when the Garden property is deactivated we get to see the change and deletion of previous values"""
main_property = self.estate_properties.search([("name", "=", "Property 1")])
with Form(main_property) as property:
self.assertEqual(100, property.garden_area)
self.assertEqual("south", property.garden_orientation)
self.assertEqual(190, property.total_area)

property.garden = False

self.assertEqual(0, property.garden_area)
self.assertEqual(False, property.garden_orientation)
self.assertEqual(90, property.total_area)
Loading