Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
404841d
[ADD] estate: init the module
aski97 Aug 17, 2026
d88a5e6
[ADD] estate: init models package and create estate property with bas…
aski97 Aug 17, 2026
6570e15
[ADD] estate: init security giving access rights to base users group
aski97 Aug 17, 2026
16a92c3
[ADD] estate: create menu and actions under the views folder
aski97 Aug 17, 2026
e87b554
[CLN] estate: rename the strings of the estate property fields for a …
aski97 Aug 17, 2026
470a5a7
[IMP] estate: add active and status field to estate property and enha…
aski97 Aug 17, 2026
1aeaf00
[FIX] estate: make date availability not copyable and expected price …
aski97 Aug 17, 2026
2a695bf
[CLN] estate: clean unnecessary single quote in the view file
aski97 Aug 18, 2026
fe65c6e
[ADD] estate: add basic custom views (list, form, search)
aski97 Aug 18, 2026
f69c7ea
[ADD] estate: real estate property type with views and connections to…
aski97 Aug 18, 2026
6760dd3
[ADD] estate: add salesperson and buyer to real estate property.
aski97 Aug 18, 2026
aa68943
[FIX] estate: move the property type id from description page to titl…
aski97 Aug 18, 2026
e998054
[ADD] estate: add property tags model and include it in the property …
aski97 Aug 18, 2026
f8082f9
[ADD] estate: add property offer model and include it in the property…
aski97 Aug 18, 2026
8db5a91
[LINT] estate: add missing end-of-line to files.
aski97 Aug 18, 2026
b8b5eca
[ADD] estate: add total area field (compute) to estate property model…
aski97 Aug 18, 2026
ffd399c
[ADD] estate: add best price field (compute) to estate property model…
aski97 Aug 18, 2026
4040916
[FIX] estate: create property offers form in order to remove property…
aski97 Aug 18, 2026
d0194c0
[ADD] estate: add validity and date deadline fields in the property o…
aski97 Aug 18, 2026
44f9335
[ADD] estate: set default values on garden area and orientation when …
aski97 Aug 18, 2026
1ee002f
[IMP] estate: add cancel and sold buttons in estate property model fo…
aski97 Aug 19, 2026
6592312
[IMP] estate: add accept and refuse buttons in the property offers li…
aski97 Aug 19, 2026
a245293
[FIX] estate: set buyer after accepting a property offer and ensure n…
aski97 Aug 19, 2026
845d23e
[FIX] estate: set property buyer as read-only (it's set through the a…
aski97 Aug 19, 2026
44d5801
[IMP] estate: add application and database constraint levels to field…
aski97 Aug 19, 2026
966bc4c
[LINT] estate: replace self with record inside the loop
aski97 Aug 19, 2026
cd8cbee
[IMP] estate: add inline view in property type in order to show the a…
aski97 Aug 19, 2026
3c4b816
[IMP] estate: add a status bar for the property status in the form view
aski97 Aug 19, 2026
ec66507
[IMP] estate: add ordering rule in models
aski97 Aug 19, 2026
9a349a2
[IMP] estate: add attribute and options to form views for a better UX…
aski97 Aug 19, 2026
3a20c40
[IMP] estate: set decorations and editability to list views
aski97 Aug 19, 2026
d7bacdc
[IMP] estate: add search default for properties list view
aski97 Aug 19, 2026
7ec6b6d
[IMP] estate: add the action connection to offers
aski97 Aug 19, 2026
000a246
[IMP] estate: add domain filter for offers action
aski97 Aug 19, 2026
89adeae
[IMP] estate: add guards when creating an offer and deleting a property
aski97 Aug 20, 2026
2f47002
[IMP] estate: overwrite res users view form in order to add user's pr…
aski97 Aug 20, 2026
2042225
[ADD] estate account: init the new module
aski97 Aug 20, 2026
c9d7d54
[IMP] estate account: create the invoice once the property get sold
aski97 Aug 20, 2026
285812a
[IMP] estate: add the kanban view for the properties
aski97 Aug 20, 2026
c234eff
[IMP] estate: add the not active filter on property search view
aski97 Aug 20, 2026
3a6f55a
[REF] estate: split the estate_property_views in multiple files
aski97 Aug 20, 2026
9e28f16
[LINT] estate: follow the style roles
aski97 Aug 20, 2026
2e5c516
[CLN] estate: remove stale code and optimize business logic
aski97 Aug 20, 2026
9624419
[LINT] estate: fix style
aski97 Aug 21, 2026
dc83b5c
[IMP] estate: add basic tests for estate property model
aski97 Aug 21, 2026
42b6b97
[LINT] estate: fix test style
aski97 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
23 changes: 23 additions & 0 deletions estate/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
'name': 'Real Estate',
'version': '0.1',
'sequence': 100,
'summary': 'Real Estate Advertisement',
'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,
'assets': {},
'author': 'Odoo S.A.',
'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_offer
from . import estate_property_tag
from . import estate_property_type
from . import res_users
153 changes: 153 additions & 0 deletions estate/models/estate_property.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
from dateutil.relativedelta import relativedelta
from odoo import models, fields, api, exceptions
from odoo.tools import float_is_zero, float_compare


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

name = fields.Char('Title', required=True, translate=True)
description = fields.Text('Description', translate=True)
postcode = fields.Char('Postcode')
date_availability = fields.Date(
string='Available From',
default=lambda _: fields.Date.today() + relativedelta(months=3),
copy=False
)
expected_price = fields.Float('Expected price', required=True)
selling_price = fields.Float('Selling price', readonly=True, copy=False)
bedrooms = fields.Integer('Bedrooms', default=2)
living_area = fields.Integer('Living area (sqm)')
facades = fields.Integer('Facades')
garage = fields.Boolean('Garage', default=False)
garden = fields.Boolean('Garden', default=False)
garden_area = fields.Integer('Garden area (sqm)')
garden_orientation = fields.Selection(
string='Garden orientation',
selection=[('north', 'North'), ('south', 'South'), ('east', 'East'), ('west', 'West')],
help='Garden orientation is important for determining how much sunlight and warmth the outdoor space receives'
)
active = fields.Boolean('Active', default=False)
status = fields.Selection(
string='Status',
selection=[('new', 'New'), ('offer_received', 'Offer Received'), ('offer_accepted', 'Offer Accepted'), ('sold', 'Sold'), ('cancelled', 'Cancelled')],
default='new',
required=True,
)
property_type_id = fields.Many2one("estate.property.type", string='Type')
property_tag_ids = fields.Many2many('estate.property.tag', string='Tags')
property_offer_ids = fields.One2many('estate.property.offer', inverse_name="property_id", string='Offers', copy=False)
# Computed Field
total_area = fields.Integer('Total area (sqm)', compute='_compute_total_area', readonly=True, copy=False)
best_price = fields.Float('Best offer', compute='_compute_best_price', readonly=True, copy=False)
# Other Info
salesperson_id = fields.Many2one(
comodel_name='res.users',
string='Salesman',
default=lambda self: self.env.user)
buyer_id = fields.Many2one(
comodel_name='res.partner',
string='Buyer',
copy=False,
readonly=True,
)

_check_expected_price = models.Constraint(
'check(expected_price > 0)',
'The expected price must be a positive amount and cannot be zero!',
)
_check_selling_price = models.Constraint(
'check(selling_price >= 0)',
'The selling price must be a positive amount!',
)

@api.ondelete(at_uninstall=False)
def _unlink_if_new_or_cancelled(self):
if any(not record.status in ['new', 'cancelled'] for record in self):
raise exceptions.UserError("Can't delete property with status different from NEW or CANCELLED.")

@api.constrains('selling_price', 'expected_price')
def _check_selling_price(self):
for record in self:

if float_is_zero(value=record.selling_price, precision_digits=2):
# no accepted offers yet
return

bottom_bound = .9 * record.expected_price
if float_compare(record.selling_price, bottom_bound, precision_digits=2) < 0:
raise exceptions.ValidationError("The selling price cannot be lower than 90% of the expected price.")

@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("property_offer_ids.price")
def _compute_best_price(self):
for record in self:
if record.property_offer_ids:
record.best_price = max(record.property_offer_ids.mapped('price'))
else:
record.best_price = 0

@api.onchange('garden')
def _onchange_garden_flag(self):
if self.garden:
self.garden_area = 10
self.garden_orientation = 'north'
return

self.garden_area = 0
self.garden_orientation = False

def ensure_status_is_not(self, statuses, error_message=None):
""" Ensures that the status is not equal or included in statuses """
if not statuses:
return

if isinstance(statuses, str):
statuses = [statuses]

if not isinstance(statuses, (list, tuple, set)):
raise TypeError('statuses must be a string, list, tuple, or set')

if self.status in statuses:
msg = error_message or f'Action not allowed for status: {self.status}'
raise exceptions.UserError(msg)

def _check_or_raise(self, condition, error_message=None):
if not condition:
msg = error_message or 'Action not allowed'
raise exceptions.UserError(msg)

def ensure_no_accepted_offers(self, error_message=None):
has_no_accepted = not any(offer.status == "accepted" for offer in self.property_offer_ids)
self._check_or_raise(has_no_accepted, error_message=error_message)

def ensure_any_offers_with_status(self, status, error_message=None):
if not status:
raise ValueError('Status is required')

has_at_least_one = any(offer.status == status for offer in self.property_offer_ids)
self._check_or_raise(has_at_least_one, error_message=error_message)

# ACTIONS

def action_set_status_cancelled(self):
for record in self:
record.ensure_status_is_not("sold", error_message="You cannot cancel a sold property")
record.status = "cancelled"

return True

def action_set_status_sold(self):
for record in self:
record.ensure_status_is_not("cancelled", error_message="You cannot sell a cancelled property")
record.ensure_any_offers_with_status("accepted", error_message="You cannot sell with no accepted offers")

record.status = "sold"

return True
100 changes: 100 additions & 0 deletions estate/models/estate_property_offer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
from datetime import date, timedelta
from typing import Self

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


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

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

_check_price = models.Constraint(
'check(price > 0)',
'The offer price must be a positive amount and cannot be zero!',
)

@api.model
def create(self, vals) -> Self:
for val in vals:
estate_property = self.env['estate.property'].browse(val['property_id'])

estate_property.ensure_status_is_not("sold", error_message="You cannot add an offer to a sold property.")

# set the correct state
estate_property.status = 'offer_received'

# offers should be higher than the ones we already have
if estate_property.property_offer_ids:
min_price = estate_property.property_offer_ids[-1].price
if float_compare(val['price'], min_price, precision_digits=2) < 0:
raise UserError(f"The offer {val['price']} cannot be lower than the other offers.")

return super().create(vals)

@api.depends('create_date', 'validity')
def _compute_date_deadline(self):
for record in self:
record.date_deadline = self._get_date_or_today(record.create_date) + timedelta(days=record.validity)

def _inverse_date_deadline(self):
for record in self:
create_date_or_today = self._get_date_or_today(record.create_date)
record.validity = (record.date_deadline - create_date_or_today).days

# ACTIONS

def action_accept_offer(self):
for record in self:
# If an offer is already accepted we can't accept another one
record.property_id.ensure_no_accepted_offers(error_message="An offer was already accepted")

record.status = "accepted"
record.property_id.selling_price = record.price
record.property_id.buyer_id = record.partner_id

return True

def action_refuse_offer(self):
for record in self:
record.status = "refused"

return True

@staticmethod
def _get_date_or_today(datetime_to_evaluate):
""" Returns the date part of a given datetime if present, otherwise returns today's date """
return datetime_to_evaluate.date() if datetime_to_evaluate else date.today()
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 models, fields


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

name = fields.Char('Name', required=True, translate=True)
color = fields.Integer('Color', required=True, default=0)

_name_uniq = models.Constraint(
'unique (name)',
'There is already a Property Tag with this name!.',
)
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 models, fields, api


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

name = fields.Char('Name', required=True, translate=True)
sequence = fields.Integer('Sequence', default=1, help="Used for ordering purposes")
property_id = fields.One2many('estate.property', 'property_type_id', string='Property')
offer_ids = fields.One2many('estate.property.offer', 'property_type_id', string='Offers')
# Compute Field
offer_count = fields.Integer(string='Offer Count', compute='_compute_offer_count')

_name_uniq = models.Constraint(
'unique (name)',
'There is already a Property Type with this name!.',
)

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


class ResUsers(models.Model):
_name = 'res.users'
_inherit = ["res.users"]

property_ids = fields.One2many('estate.property', 'salesperson_id', string="Real Estate Properties", domain=[('status', 'in', ['new', 'offer_received'])])
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,estate.property access,model_estate_property,base.group_user,1,1,1,1
access_estate_property_type,estate.property.type access,model_estate_property_type,base.group_user,1,1,1,1
access_estate_property_tag,estate.property.tag access,model_estate_property_tag,base.group_user,1,1,1,1
access_estate_property_offer,estate.property.offer access,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_estate_property
Loading