Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0df3fbd
[ADD] estate: real estate module
JuanICasareski Aug 17, 2026
72686d0
[ADD] estate: models views & search filters
JuanICasareski Aug 17, 2026
0e13e37
[ADD] estate: new state, buyer, & type fields
JuanICasareski Aug 18, 2026
f9b1003
[ADD] estate: tags records, tags on form & list views for estate record
JuanICasareski Aug 18, 2026
db402b1
[ADD] estate: property offers, offer vies, & offers tab on property view
JuanICasareski Aug 18, 2026
c508088
[ADD] estate: computed & onchange fields
JuanICasareski Aug 18, 2026
d89413f
[ADD] estate: sold & cancel property buttons, & accept & refuse offer…
JuanICasareski Aug 18, 2026
eeac1ab
[LINT] estate: add missing newlines & spacing, & remove extra spacing
JuanICasareski Aug 18, 2026
607785f
[FIX] estate: check property_state by using referenced property on of…
JuanICasareski Aug 18, 2026
c44f153
[ADD] estate: models constraints
JuanICasareski Aug 18, 2026
ad6e728
[ADD] estate: type inline view, action buttons, & status bar on prope…
JuanICasareski Aug 18, 2026
ea12161
[ADD] estate: offers & properties decoration on list views, & decorat…
JuanICasareski Aug 19, 2026
278b0b0
[ADD] estate: default filter for properties view
JuanICasareski Aug 19, 2026
0fdba5d
[ADD] estate: stat button on property types to offers
JuanICasareski Aug 19, 2026
6ca1416
[ADD] estate: set state to offer when creating an offer & prevent pro…
JuanICasareski Aug 19, 2026
e25bc58
[ADD] estate: show associated properties on user view
JuanICasareski Aug 19, 2026
8488d14
[FIX] estate: prevent batch creation of offers lower than the best offer
JuanICasareski Aug 19, 2026
35311e6
[FIX] estate: views order so that runbot runs
JuanICasareski Aug 19, 2026
94b7ef5
[ADD] estate: create invoice on property sold
JuanICasareski Aug 20, 2026
48dec55
[ADD] estate: kanban view on real state properties
JuanICasareski Aug 20, 2026
3527b06
[REF] estate: merge `offer` & `received` property states into one
JuanICasareski Aug 20, 2026
38ab99b
[IMP] estate: remove unnecessary check for property state & offer status
JuanICasareski Aug 20, 2026
349e63f
[ADD] estate: test offer creation & property selling
JuanICasareski Aug 20, 2026
f602a6b
[ADD] estate: garden check & uncheck test case
JuanICasareski Aug 21, 2026
1e4cb11
[FIX] estate: float checks on create & sell test cases
JuanICasareski Aug 24, 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
'name': 'Real Estate',
'version': '0.0',
'depends': [
'base',
],
'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/res_users_views.xml',
'views/estate_menus.xml',
],
'installable': True,
'application': True,
'author': 'juica',
'license': 'LGPL-3',
}
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_user
150 changes: 150 additions & 0 deletions estate/models/estate_property.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
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, float_is_zero

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

We normally should always have two blank lines before the definition of the class


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

name = fields.Char('Property Name', required=True, translate=True)
description = fields.Text('Description', translate=True)
postcode = fields.Char('Post Code', required=True)
date_availability = fields.Date(
'Availability Date',
required=True,
copy=False,
default=fields.Date.today() + relativedelta(months=3),
)
type_id = fields.Many2one("estate.property.type", string="Type", required=True)
offer_ids = fields.One2many("estate.property.offer", "property_id")
tag_ids = fields.Many2many("estate.property.tag", string="Tags")
salesperson_id = fields.Many2one(
"res.users",
string="Salesperson",
default=lambda self: self.env.user,
)
buyer_id = fields.Many2one(
"res.partner",
string="Buyer",
copy=False,
)
expected_price = fields.Float('Expected Price')
selling_price = fields.Float(
'Selling Price',
readonly=True,
copy=False,
)
bedrooms = fields.Integer(
'# Bedrooms',
default=2,
)
facades = fields.Integer('# Facades')
garage = fields.Boolean('Garage')
garden = fields.Boolean('Garden')
living_area = fields.Integer('Living Area mt²')
garden_area = fields.Integer('Garden mt²')
garden_orientation = fields.Selection(
string='Garden Orientation',
selection=[
('north', 'North'),
('south', 'South'),
('east', 'East'),
('west', 'West'),
],
)
active = fields.Boolean('Active', default=True)
state = fields.Selection(
string='State',
selection=[
('new', 'New'),
('offer_received', 'Offer Received'),
('offer_accepted', 'Offer Accepted'),
('sold', 'Sold'),
('cancelled', 'Cancelled'),
],
default="new",
copy=False,
required=True,
readonly=True,
# group_expand=True
)
total_area = fields.Integer(
"Total Area m²",
compute="_compute_total_area",
)
best_price = fields.Float(
"Best Price",
compute="_compute_best_price",
)

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

@api.depends("offer_ids.price")
def _compute_best_price(self):
for property in self:
property.best_price = max(property.offer_ids.mapped("price"), default=0)

@api.onchange("garden")
def _onchange_garden(self):
if self.garden:
self.garden_area = 10
# Nasty magic string. I should turn the option into a variable and then reference it
self.garden_orientation = "north"
return

self.garden_area = 0
self.garden_orientation = None

def action_sold(self):
for property in self:
if not property.selling_price:
raise UserError("You can not sell a property without accepted offers")

if property.state == "cancelled":
raise UserError("You can not sell a cancelled property")

property.state = "sold"

return True

def action_cancel(self):
for property in self:
if property.state == "sold":
raise UserError("You can not cancel a sold property")

property.state = "cancelled"

return True

_exp_price_positive = models.Constraint(
'CHECK(expected_price > 0)',
'The property expected price must be strictly positive',
)

_sell_price_positive = models.Constraint(
'CHECK(selling_price >= 0)',
'The property selling price must be positive',
)

@api.constrains("selling_price", "expected_price")
def _check_selling_price(self):
for property in self:
if float_is_zero(property.selling_price, precision_digits=2):
continue

limit = 0.9 * property.expected_price
if float_compare(property.selling_price, limit, precision_digits=2) == -1:
raise ValidationError("The property selling price must be at least 90% of the expected price")

@api.ondelete(at_uninstall=False)
def _unlink_except_not_new_or_cancelled(self):
for property in self:
if property.state not in ('new', 'cancelled'):
raise UserError("You can not delete a property that is not either 'new' or 'cancelled'")
96 changes: 96 additions & 0 deletions estate/models/estate_property_offer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
from dateutil.relativedelta import relativedelta

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


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

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

@api.depends("validity")
def _compute_deadline(self):
for offer in self:
offer.date_deadline = fields.Date.today() + relativedelta(days=offer.validity)

def _inverse_deadline(self):
for offer in self:
delta = offer.date_deadline - fields.Date.today()
offer.validity = delta.days

def action_offer_accept(self):
for offer in self:
if offer.property_id.state in ('offer_accepted', 'sold', 'cancelled'):
raise UserError("You can not accept more offers for this property")

offer.status = "accepted"
offer.property_id.state = "offer_accepted"
offer.property_id.buyer_id = offer.partner_id
offer.property_id.selling_price = offer.price

return True

def action_offer_refuse(self):
for offer in self:
offer.status = "refused"

return True

_price_positive = models.Constraint(
'CHECK(price > 0)',
'The offer price must be strictly positive',
)

@api.model_create_multi
def create(self, values):
# Get the lowest new offer per property
prop_min_offer = {}
for v in values:
pid = v['property_id']
prop_min_offer[pid] = min(prop_min_offer.get(pid, float('inf')), v.get('price', 0))

# Browse the properties referenced by the new offers
properties = self.env['estate.property'].browse(prop_min_offer.keys())

for prop in properties:
if prop.state in ('sold', 'cancelled'):
raise UserError("You can not make an offer on a sold or cancelled property")

# No new offer may be lower than the best existing one
best_existing = prop.offer_ids[0].price if len(prop.offer_ids) else 0.0

if float_is_zero(best_existing, precision_digits=2):
continue

if float_compare(prop_min_offer[prop.id], best_existing, precision_digits=2) == -1:
raise UserError("You can not offer less than the biggest offer")

properties.filtered(lambda p: p.state == 'new').state = 'offer_received'

return super().create(values)
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 = "Real Estate Property Tag"
_order = "name"

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

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


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

name = fields.Char('Type Name', required=True, translate=True)
sequence = fields.Integer('Sequence', default=1)

property_ids = fields.One2many("estate.property", "type_id")
offer_ids = fields.One2many("estate.property.offer", "property_type_id")
offer_count = fields.Integer(compute="_compute_offer_count", default=0)

@api.depends("offer_ids")
def _compute_offer_count(self):
for offer in self:
offer.offer_count = len(offer.offer_ids)

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


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

property_ids = fields.One2many(
"estate.property", "salesperson_id",
domain="[('state', 'in', ['new', 'offer_received'])]",
)
8 changes: 8 additions & 0 deletions estate/security/ir.model.access.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink

access_estate_property,access_estate_property,model_estate_property,base.group_user,1,1,1,1
access_estate_property_type_user,access_estate_property_type_user,model_estate_property_type,base.group_user,1,0,0,0
access_estate_property_type_admin,access_estate_property_type_admin,model_estate_property_type,base.group_system,1,1,1,1
access_estate_property_tag_user,access_estate_property_tag_user,model_estate_property_tag,base.group_user,1,0,0,0
access_estate_property_tag_admin,access_estate_property_tag_admin,model_estate_property_tag,base.group_system,1,1,1,1
access_estate_property_offer,access_estate_property_offer,model_estate_property_offer,base.group_user,1,1,1,1
1 change: 1 addition & 0 deletions estate/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import test_property
Loading