Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
f5b4ede
[ADD] estate: 1. A New Application
bepro-odoo Jul 20, 2026
5b8acd1
[IMP] estate: 3. Models and fields
bepro-odoo Jul 20, 2026
999d421
[IMP] estate: 4. Security
bepro-odoo Jul 20, 2026
69c5e66
[IMP] estate: 5. Some UI
bepro-odoo Jul 20, 2026
3cbf95a
[IMP] estate: 6. Basic views
bepro-odoo Jul 20, 2026
00df62a
[IMP] estate: 7. Many2One
bepro-odoo Jul 20, 2026
2beb69a
[IMP] estate: 7. Many2Many
bepro-odoo Jul 20, 2026
a7e7ef5
[IMP] estate: 7. One2Many
bepro-odoo Jul 20, 2026
47cb955
[IMP] estate: 8. Computed fields
bepro-odoo Jul 20, 2026
7f6bc8e
[IMP] estate: 8. Onchange fields
bepro-odoo Jul 20, 2026
3351716
[REF] estate: Add workspace
bepro-odoo Jul 22, 2026
5a2cd9f
[IMP] estate: 9. Actions
bepro-odoo Jul 22, 2026
3857fad
[IMP] estate: 10. Constraints
bepro-odoo Jul 22, 2026
a1f01a6
[IMP] estate: 11. Sprinkles
bepro-odoo Jul 22, 2026
ed07c6e
[IMP] estate: 12. Inheritance
bepro-odoo Jul 22, 2026
dab808c
[IMP] estate: 13. Interact with other modules
bepro-odoo Jul 22, 2026
64cd738
[IMP] estate: 14. QWeb
bepro-odoo Jul 22, 2026
f978886
[LINT] estate:
bepro-odoo Jul 27, 2026
e0e121b
[IMP] estate: Split constraints and error messages
bepro-odoo Jul 27, 2026
e0adc89
[IMP] estate: Remove default value for property name
bepro-odoo Jul 27, 2026
8dbc9bf
[FIX] estate: Unset ORM field with False instead of None
bepro-odoo Jul 27, 2026
5067f4a
[IMP] estate: rework auto-update between validity and date_deadline
bepro-odoo Jul 27, 2026
e21d2c5
[IMP] estate: simplify estate.property.offer creation
bepro-odoo Jul 27, 2026
f78dfb1
[IMP] estate: optimize constraint of single accepted offer per property
bepro-odoo Jul 27, 2026
fb392c4
[MOV] estate: res_user -> res_users
bepro-odoo Jul 27, 2026
2ed2cb7
[IMP] estate: remove unused group elements in user view
bepro-odoo Jul 27, 2026
6ec1592
[IMP] estate: Add fields validity and date_deadline to offer tree view
bepro-odoo Jul 27, 2026
bbb358e
[FIX] estate: Compare state.raw_value in property kanban view
bepro-odoo Jul 27, 2026
aa77f80
[MOV] estate: split view files by model
bepro-odoo Jul 27, 2026
7d298fc
[MOV] estate: estate.property.type
bepro-odoo Jul 27, 2026
50b73e4
[MOV] estate: views estate.property tag
bepro-odoo Jul 27, 2026
c6f70fa
[IMP] estate: views estate.property.offer
bepro-odoo Jul 27, 2026
fac26de
[LINT] estate: modify external ids according to coding guidelines
bepro-odoo Jul 27, 2026
6d89bae
[REM] estate: remove vscode workspace file
bepro-odoo Jul 27, 2026
f21a3e7
[LINT] estate: remove empty comment line
bepro-odoo Jul 27, 2026
3768c17
[FIX] estate: method today doesn't exist on datetime
bepro-odoo Jul 27, 2026
64d049a
[LINT] estate: format estate_menus.xml
bepro-odoo Jul 28, 2026
bccf94a
[MOV] estate: rename view resource files
bepro-odoo Jul 29, 2026
0de649d
[LINT] estate: rename access rules according to guidelines
bepro-odoo Jul 29, 2026
118285d
[LINT] estate: order class properties according to guidelines
bepro-odoo Jul 29, 2026
f7f80be
[LINT] estate: run ruff
bepro-odoo Jul 29, 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
15 changes: 15 additions & 0 deletions estate/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
'name': 'Real Estate',
'depends': ['base'],
'author': 'Odoo S.A.',
'license': 'LGPL-3',
'data': [
'security/ir.model.access.csv',
'views/estate_property_views.xml',
'views/estate_property_offer_views.xml',
'views/estate_property_tag_views.xml',
'views/estate_property_type_views.xml',
'views/estate_menus.xml',
'views/res_user_views.xml',
],
}
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 property
from . import property_offer
from . import property_tag
from . import property_type
from . import res_users
118 changes: 118 additions & 0 deletions estate/models/property.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
from odoo import _, api, exceptions, fields, models
from odoo.tools.float_utils import float_compare, float_is_zero


class Property(models.Model):
_name = "estate.property"
_description = "Properties of our managed estates"
_order = "id desc"

name = fields.Char(string='Title', required=True)
active = fields.Boolean(default=True)
state = fields.Selection(required=True, default='new', copy=False, selection=[
('new', 'New'),
('offer_received', 'Offer Received'),
('offer_accepted', 'Offer Accepted'),
('sold', 'Sold'),
('cancelled', 'Cancelled'),
])
Comment thread
bepro-odoo marked this conversation as resolved.
property_type_id = fields.Many2one("estate.property.type", string="Property Type")
salesman_id = fields.Many2one('res.users', string="Salesman", default=lambda self: self.env.user)
buyer_id = fields.Many2one('res.partner', string="Buyer", copy=False)
tag_ids = fields.Many2many("estate.property.tag")
offer_ids = fields.One2many("estate.property.offer", "property_id")
description = fields.Text(string='description')
postcode = fields.Char(string='postcode')
date_availability = fields.Date(string='Available from', default=lambda _: fields.Date.add(fields.Date.today(), months=3), copy=False)

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
date_availability = fields.Date(string='Available from', default=lambda _: fields.Date.add(fields.Date.today(), months=3), copy=False)
date_availability = fields.Date(string='Available from', default=fields.Date.add(fields.Date.today(), months=3), copy=False)

We don't need the lambda here. We could just put the value as it is.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I tested the approach without lambda and fieldtype datetime. The form value is stuck at a datetime around startup of odoo server, meaning that the default value is generated once and reused as default. The purpose of the field is to have a date set 3 months into the future at point of record creation, implying the date must be calculated at that point in time, hence the lambda is required.

Info @SaddemAmine

expected_price = fields.Float(string='Expected price', required=True)
selling_price = fields.Float(string='selling price', readonly=True, copy=False)
bedrooms = fields.Integer(string='# bedrooms', default=2)
living_area = fields.Integer(string='living area size')
facades = fields.Integer(string='# facades')
garage = fields.Boolean(string='Has garage')
garden = fields.Boolean(string='Has garden')
garden_area = fields.Integer(string='garden area size')
garden_orientation = fields.Selection(string='garden orientation',
selection=[
('north', 'North'),
('south', 'South'),
('east', 'East'),
('west', 'West'),
])
total_area = fields.Float(string="Total area", compute="_compute_total_area")
best_price = fields.Float(string="Best offer", compute="_compute_best_price")

_check_positive_expected_price = models.Constraint(
'CHECK (expected_price >= 0)', 'Expected price must be positive!')
_check_positive_selling_price = models.Constraint(
'CHECK (selling_price >= 0)', 'Selling price must be positive!')
_check_positive_living_area = models.Constraint(
'CHECK (living_area >= 0)', 'Living area must be positive!')
_check_positive_amounts = models.Constraint(
'CHECK (facades >= 0)', 'Number of facades must be positive!')
_check_positive_garden_area = models.Constraint(
'CHECK (garden_area >= 0)', 'Garden area must be positive!')

@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.price")
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
Comment on lines +64 to +67

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
if record.offer_ids:
record.best_price = max(record.offer_ids.mapped("price"))
else:
record.best_price = 0
record.best_price = max(record.offer_ids.mapped("price")) if record.offer_ids else 0

Just a suggestion, feel free to ignore it if you want 👌

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

ignored


@api.constrains('selling_price', 'expected_price')
def _check_selling_price(self):
for property in self:
if float_is_zero(property.selling_price, 2):
return
if float_compare(property.selling_price, 0.9 * property.expected_price, 2) == -1:
raise exceptions.ValidationError(_("The accepted price is less than 90% of the expected price!"))

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

@api.ondelete(at_uninstall=False)
def _unlink_if_draft(self):
for record in self:
if record.state not in ['new', 'cancelled']:
raise exceptions.ValidationError(_("Only properties in state New or Cancelled can be deleted!"))

def action_sold(self):
for record in self:
if record.state == 'cancelled':
raise exceptions.UserError(_("Cancelled properties cannot be sold!"))

record.state = "sold"
return True

def action_cancelled(self):
for record in self:
if record.state == "sold":
raise exceptions.UserError(_("Sold properties cannot be cancelled!"))

record.state = "cancelled"
return True

def confirm_offer(self):
for property in self:
accepted_offer = property.offer_ids.filtered(lambda r: r.status == 'accepted')
accepted_offer.ensure_one()
property.selling_price = accepted_offer.price
property.buyer_id = accepted_offer.partner_id
property.state = 'offer_accepted'
# Refuse all other offers
(property.offer_ids - accepted_offer).action_cancel()

def _set_offer_received(self):
self.ensure_one()
if self.state == 'new':
self.state = 'offer_received'
76 changes: 76 additions & 0 deletions estate/models/property_offer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import datetime as dt
from math import floor

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


class PropertyOffer(models.Model):
_name = "estate.property.offer"
_description = "Bids for a property"
_order = "price desc"

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

_check_positive_price = models.Constraint('CHECK(price >= 0)', 'Price has to be positive')

@api.depends("create_date", "validity")
def _compute_date_deadline(self):
for record in self:
record.date_deadline = fields.Date.add(record.create_date or fields.Datetime.now(), days=record.validity)

@api.onchange('date_deadline')
def _inverse_date_deadline(self):
for record in self:
if record.date_deadline:
raw_difference = record.date_deadline - (record.create_date or fields.Datetime.now()).date()
difference = raw_difference.total_seconds()
if difference > 0:
record.validity = floor(difference / dt.timedelta(days=1).total_seconds())

@api.constrains('status')
def _check_maximum_one_offer_accepted(self):
accepted_offers = self.filtered(lambda r: r.status == "accepted")
properties = accepted_offers.mapped('property_id')
for property in properties:
peer_offers = property.offer_ids
accepted_peers = peer_offers.filtered(lambda r: r.status == 'accepted')
if len(accepted_peers) > 1:
raise ValidationError(_("A single offer can be accepted at a time!"))

@api.model_create_multi
def create(self, vals_list):
property_ids = self.env["estate.property"].browse(vals["property_id"] for vals in vals_list)
# Precondition: price and property_id are required fields
for vals in vals_list:
property = property_ids.filtered(lambda p: p.id == vals["property_id"])
property.ensure_one()

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
property.ensure_one()

Since the id is unique, there will only be at most one property matching vals["property_id"], so I think the ensure_one is not needed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

kept for reason that filter is constructed with untrusted user input and property could contain empty recordset

# Hook-approach (for composability)
property._set_offer_received()
if property.best_price and property.best_price > vals['price']:
raise UserError(_("New offer price must be higher than those of pre-existing offers!"))

return super().create(vals_list)

def action_confirm(self):
for record in self:
if record.status == "refused":
raise UserError(_("Offer is already refused!"))
Comment thread
YassinWalid marked this conversation as resolved.

record.status = 'accepted'
record.property_id.confirm_offer()
return True

def action_cancel(self):
for record in self:
if record.status == "accepted":
raise UserError(_("Offer is already accepted!"))
Comment thread
YassinWalid marked this conversation as resolved.

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


class PropertyTag(models.Model):
_name = "estate.property.tag"
_description = "Tag assigned to property"
_order = "name"

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

_name_idx = models.UniqueIndex('(name)', 'Another record already exists with the same name!')
20 changes: 20 additions & 0 deletions estate/models/property_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from odoo import api, fields, models


class PropertyType(models.Model):
_name = "estate.property.type"
_description = "Type of property"
_order = "name"

name = fields.Char(string='Name', required=True)
sequence = fields.Integer('Sequence', default=1)
property_ids = fields.One2many("estate.property", "property_type_id")
offer_ids = fields.One2many("estate.property.offer", "property_type_id")
offer_count = fields.Integer(compute="_compute_offer_count")

_name_idx = models.UniqueIndex('(name)', 'Another record already exists with the same name!')

@api.depends("offer_ids")
def _compute_offer_count(self):
for record in self:
record.offer_count = len(record.offer_ids)
7 changes: 7 additions & 0 deletions estate/models/res_users.py
Comment thread
bepro-odoo marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from odoo import fields, models


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

property_ids = fields.One2many("estate.property", "salesman_id", domain="[('state', 'in', ['new', 'offer_received'])]")
5 changes: 5 additions & 0 deletions estate/security/ir.model.access.csv
Comment thread
bepro-odoo marked this conversation as resolved.
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_model,access.estate.property.model,estate.model_estate_property,base.group_user,1,1,1,1
access_estate_property_type_model,access.estate.property.type.model,estate.model_estate_property_type,base.group_user,1,1,1,1
access_estate_property_tag_model,access.estate.property.tag.model,estate.model_estate_property_tag,base.group_user,1,1,1,1
access_estate_property_offer_model,access.estate.property.offer.model,estate.model_estate_property_offer,base.group_user,1,1,1,1
14 changes: 14 additions & 0 deletions estate/views/estate_menus.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0"?>
<odoo>
<menuitem id="estate_menu_root" name="Estate">
<menuitem id="estate_menu_advertisements" name="Advertisements">
<menuitem id="estate_property_menu" action="estate_property_action"/>
</menuitem>

<menuitem id="estate_menu_settings" name="Settings">
<menuitem id="estate_property_type_menu" action="estate_property_type_action"/>
<menuitem id="estate_property_tag_menu" action="estate_property_tag_action"/>
</menuitem>
</menuitem>

</odoo>
46 changes: 46 additions & 0 deletions estate/views/estate_property_offer_views.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?xml version="1.0"?>
<odoo>
<record id="estate_property_offer_action" model="ir.actions.act_window">
<field name="name">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_view_list" 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="Property Offers" editable="top"
decoration-danger="status in ['refused']"
decoration-success="status in ['accepted']">
<field name="price" />
<field name="partner_id" />
<field name="validity" />
<field name="date_deadline" />
<button name="action_confirm" string="Confirm" type="object" icon="fa-check" invisible="status"/>
<button name="action_cancel" string="Cancel" type="object" icon="fa-times" invisible="status"/>
</list>
</field>
</record>

<record id="estate_property_offer_view_form" 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="Estate Property">
<sheet>
<group>
<group name="general">
<field name="price" />
<field name="partner_id" />
<field name="validity" />
<field name="date_deadline" />
<field name="status" />
</group>
</group>
</sheet>
</form>
</field>
</record>
</odoo>
18 changes: 18 additions & 0 deletions estate/views/estate_property_tag_views.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?xml version="1.0"?>
<odoo>
<record id="estate_property_tag_action" model="ir.actions.act_window">
<field name="name">Property Tags</field>
<field name="res_model">estate.property.tag</field>
<field name="view_mode">list,form</field>
</record>

<record id="estate_property_tag_view_list" model="ir.ui.view">
<field name="name">estate.property.tag.list</field>
<field name="model">estate.property.tag</field>
<field name="arch" type="xml">
<list string="Property Tags" editable="top">
<field name="name" />
</list>
</field>
</record>
</odoo>
Loading