Skip to content
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',
'author': 'Odoo S.A.',
'license': 'LGPL-3',
"installable": True,
"application": True,
'depends': [
'base',
],
"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/estate_inherited_users_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 estate_inherited_users
from . import estate_property
from . import estate_property_offer
from . import estate_property_tag
from . import estate_property_type
12 changes: 12 additions & 0 deletions estate/models/estate_inherited_users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from odoo import fields, models


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

property_ids = fields.One2many(
comodel_name='estate.property',
inverse_name='salesperson',
string='Estate Properties',
domain=['|', ('state', '=', 'new'), ('state', '=', 'offer_received')],
)
108 changes: 108 additions & 0 deletions estate/models/estate_property.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
from odoo import api, fields, models
from odoo.exceptions import UserError, ValidationError
from odoo.tools.float_utils import float_compare, float_is_zero


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

active = fields.Boolean(string='Active', default=True)
bedrooms = fields.Integer(string='Bedrooms', default=2)
best_price = fields.Float(compute='_compute_best_price')
buyer = fields.Many2one(comodel_name='res.partner', readonly=True, string='Buyer', copy=False)
date_availability = fields.Date(
string='Date availability',
copy=False,
default=lambda self: fields.Date.add(fields.Date.today(), months=3),
)
description = fields.Text(string='Description')
expected_price = fields.Float(string='Expected price', required=True)
facades = fields.Integer(string='Facades')
garage = fields.Boolean(string='Garage')
garden = fields.Boolean(string='Garden')
garden_area = fields.Integer(string='Garden area')
garden_orientation = fields.Selection(
string='Garden orientation',
selection=[('north', 'North'), ('south', 'South'), ('east', 'East'), ('west', 'West')],
)
living_area = fields.Integer(string='Living area')
name = fields.Char(string='Name', required=True, default="Unknown")
offers = fields.One2many(comodel_name='estate.property.offer', inverse_name='property_id', string='Offers')
postcode = fields.Char(string='Postcode')
property_type = fields.Many2one(comodel_name='estate.property.type', string='Property type')
salesperson = fields.Many2one(comodel_name='res.users', string='Salesperson', default=lambda self: self.env.user)
selling_price = fields.Float(string='Selling price', readonly=True, copy=False)
state = fields.Selection(
string='State',
selection=[('new', 'New'), ('offer_received', 'Offer received'), ('offer_accepted', 'Offer accepted'), ('sold', 'Sold'), ('cancelled', 'Cancelled')],
required=True,
copy=True,
default='new',
)
tags = fields.Many2many(comodel_name='estate.property.tag', string='Tags')
total_area = fields.Integer(compute='_compute_total_area')

_strictly_positive_expected_price = models.Constraint(
'CHECK(expected_price > 0)',
'The expected price of a property should be strictly positive.',
)
_positive_selling_price = models.Constraint(
'CHECK(selling_price >= 0)',
'The selling price of a property should 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('offers.price')
def _compute_best_price(self):
for record in self:
record.best_price = max(record.offers.mapped('price')) if record.offers else 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nitpick, both are valid

Suggested change
record.best_price = max(record.offers.mapped('price')) if record.offers else 0
record.best_price = max([0, *record.offers.mapped('price')])


@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 sold_property(self):
for record in self:
if record.state == 'cancelled':
raise UserError(self.env._('A cancelled property cannot be sold.'))
record.state = 'sold'
return True

def cancel_property(self):
for record in self:
if record.state == 'sold':
raise UserError(self.env._('A sold property cannot be cancelled.'))
record.state = 'cancelled'
return True

def action_view_offers(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'estate.property.offer',
'name': self.env._('Offers'),
'views': [[False, 'list'], [False, 'form']],
'domain': [('property_id', '=', self.id)],
}

@api.constrains('expected_price', 'selling_price')
def _check_selling_price_relation_to_expected_price(self):
for record in self:
if float_compare(record.expected_price * 0.9, record.selling_price, 2) == 1 and not float_is_zero(record.selling_price, 2):
raise ValidationError(self.env._(r'The selling price cannot be lower than 90% of the expected price.'))

@api.ondelete(at_uninstall=False)
def _unlink_if_status_is_new_or_cancelled(self):
if any(record.state not in ['new', 'cancelled'] 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.

When using in, It's usually best to use a set rather than a list

Suggested change
if any(record.state not in ['new', 'cancelled'] for record in self):
if any(record.state not in {'new', 'cancelled'} for record in self):

raise UserError(self.env._('Can\'t delete a property that is not new or cancelled.'))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nitpick

Suggested change
raise UserError(self.env._('Can\'t delete a property that is not new or cancelled.'))
raise UserError(self.env._("Can't delete a property that is not new or cancelled."))

90 changes: 90 additions & 0 deletions estate/models/estate_property_offer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
from datetime import datetime, timedelta

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


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

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

_strictly_positive_price = models.Constraint(
'CHECK(price > 0)',
'The price of an offer should be strictly positive',
)

def _compute_display_name(self):
for record in self:
record.display_name = self.env._("Estate Property Offer %s", record.id)

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

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

def accept_offer(self):
for record in self:
if any(offer.status == 'accepted' for offer in record.property_id.offers):
raise UserError(self.env._('Only one offer can be accepted by property.'))
record.status = 'accepted'
record.property_id.buyer = record.partner_id
record.property_id.selling_price = record.price
record.property_id.state = 'offer_accepted'
return True

def refuse_offer(self):
for record in self:
if record.status == 'accepted':
record.property_id.buyer = None
record.property_id.selling_price = None
record.property_id.state = 'offer_received'
record.status = 'refused'
return True

def action_view_property(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'estate.property',
'name': self.env._('Properties'),
'views': [[False, 'list'], [False, 'form']],
'domain': [('id', '=', self.property_id.id)],
}

@api.constrains('price')
def _check_price(self):
for record in self:
if float_compare(record.property_id.expected_price * 0.9, record.price, 2) == 1:
raise ValidationError(self.env._(r'The price of an offer cannot be lower than 90% of the expected price of the 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.

your IDE probably doesn't like it but python accept it fine

Suggested change
raise ValidationError(self.env._(r'The price of an offer cannot be lower than 90% of the expected price of the property.'))
raise ValidationError(self.env._('The price of an offer cannot be lower than 90% of the expected price of the property.'))


@api.model
def create(self, vals_list):
for vals in vals_list:
property = self.env['estate.property'].browse(vals['property_id']).with_prefetch(self.ids)
if any(offer.price > vals['price'] for offer in property.offers):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

price is not a required field so it may be missing (probably blocked by the constraint tho)

Suggested change
if any(offer.price > vals['price'] for offer in property.offers):
if any(offer.price > vals.get('price', 0) for offer in property.offers):

raise UserError(self.env._('An offer cannot have a lower price than an existing offer.'))
if property.state == 'new':
property.state = 'offer_received'
return super().create(vals_list)
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='Name', required=True, default="Unknown")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nitpick, by default Odoo using the technical name of the field with _ replaced with spaces and the first letter of each word capitalized.

Suggested change
name = fields.Char(string='Name', required=True, default="Unknown")
name = fields.Char(required=True, default="Unknown")

color = fields.Integer(string='Color')

_unique_tag_name = models.Constraint(
'unique (name)',
'The name of a property tag should be unique.',
)
23 changes: 23 additions & 0 deletions estate/models/estate_property_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from odoo import api, fields, models


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

name = fields.Char(string='Name', required=True, default="Unknown")
offers = fields.One2many(comodel_name='estate.property.offer', inverse_name='property_type', string="Offers")
offers_count = fields.Integer(string='Offers count', compute='_compute_offers_count')
properties = fields.One2many(comodel_name='estate.property', inverse_name='property_type', string='Properties')
sequence = fields.Integer(string='Sequence', default=1, help='Used to order stages. Lower is better.')

_unique_type_name = models.Constraint(
'unique (name)',
'The name of a property type should be unique.',
)

@api.depends('offers')
def _compute_offers_count(self):
for record in self:
record.offers_count = len(record.offers)
Comment on lines +22 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nitpick, this is probably too much for now, but here's what this would look like instead.
Using _read_group allow to fetch the count directly (not the offer so it's a bit faster) and make only one request to the database.
The write cannot be batched tho.

Suggested change
for record in self:
record.offers_count = len(record.offers)
counts = self.env['estate.property.offer']._read_group(
domain=[('property_type', 'in', self.ids)],
groupby=['property_type'],
aggregates=['__count'],
)
mapped = {property_type.id: count for property_type, count in counts}
for record in self:
record.offers_count = mapped.get(record.id, 0)

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.access.estate.property,access.estate.property,model_estate_property,base.group_user,1,1,1,1
estate.access.estate.property.offer,access.estate.property.offer,model_estate_property_offer,base.group_user,1,1,1,1
estate.access.estate.property.tag,access.estate.property.tag,model_estate_property_tag,base.group_user,1,1,1,1
estate.access.estate.property.type,access.estate.property.type,model_estate_property_type,base.group_user,1,1,1,1
15 changes: 15 additions & 0 deletions estate/views/estate_inherited_users_views.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?xml version="1.0"?>
<odoo>
<record id="estate_view_inherited_users_form" model="ir.ui.view">
<field name="name">estate.inherited.users.form</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="Properties">
<field name="property_ids" />
</page>
</xpath>
</field>
</record>
</odoo>
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"?>
<odoo>
<menuitem id="estate_menu_root" name="Real Estate">
<menuitem id="estate_menu_advertisements" name="Advertisements">
<menuitem id="estate_menu_properties" action="estate_action_to_properties" />
</menuitem>
<menuitem id="estate_menu_settings" name="Settings">
<menuitem id="estate_menu_property_types" action="estate_action_to_property_types" />
<menuitem id="estate_menu_property_tags" action="estate_action_to_property_tags" />
</menuitem>
</menuitem>
</odoo>
53 changes: 53 additions & 0 deletions estate/views/estate_property_offer_views.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?xml version="1.0"?>
<odoo>
<record id="estate_action_to_property_offers" 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', '=', active_id)]</field>
</record>

<record id="estate_view_property_offers_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="Properties" editable="top" decoration-danger="status=='refused'" decoration-success="status=='accepted'">
<field name="price" />
<field name="partner_id" />
<field name="property_id" />
<field name="validity" />
<field name="date_deadline" />
<button name="accept_offer" type="object" string="Accept" icon="fa-check" invisible="status in ['accepted', 'refused']" />
<button name="refuse_offer" type="object" string="Refuse" icon="fa-times" invisible="status in ['accepted', 'refused']" />
</list>
</field>
</record>

<record id="estate_view_property_offers_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="Property Offer">
<header>
<button name="accept_offer" type="object" string="Accept" />
<button name="refuse_offer" type="object" string="Refuse" />
</header>
<sheet>
<div name="button_box" class="oe_button_box">
<button name="action_view_property" type="object" class="oe_stat_button" icon="fa-home">
<field name="property_id" widget="statinfo" string="Property" />
</button>
</div>
<group>
<field name="price" />
<field name="status" />
<field name="partner_id" />
<field name="property_id" />
<field name="validity" />
<field name="date_deadline" />
</group>
</sheet>
</form>
</field>
</record>
</odoo>
Loading