Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c95998a
[ADD] Chapter 2 - Add app
aurb9 Dec 15, 2025
1fd3b26
[REV] Fix build
aurb9 Dec 15, 2025
48f899f
[FIX] Fix build
aurb9 Dec 15, 2025
fee6b56
[ADD] Chapter 3
aurb9 Dec 15, 2025
057d77f
[ADD] Chapter 4 - Add security
aurb9 Dec 15, 2025
0e1ffa3
[IMP] Chapter 5 - Add UI
aurb9 Dec 15, 2025
80c6911
[IMP] Chapter 6 - Add custom views
aurb9 Dec 16, 2025
c551f66
[FIX] Fix parsing error in group by view
aurb9 Dec 16, 2025
793b0cc
[FIX] Fix parsing error in group by view
aurb9 Dec 16, 2025
f578c54
[MERGE] Merge remote-tracking branch 'dev/19.0-tutorials-auber' into …
aurb9 Dec 16, 2025
fbf913f
[IMP] estate: remember last sold price for statistics
barracudapps Dec 16, 2025
8da414a
[FIX] estate: unfreeze field date
barracudapps Dec 16, 2025
1de0fde
[IMP] Chapter 7 - Add relational models
aurb9 Dec 16, 2025
8115ce2
[IMP] estate: add compute, inverse and onchange methods
aurb9 Dec 16, 2025
648390f
[IMP] estate: add action buttons on property and property_offer
aurb9 Dec 17, 2025
e98208e
[IMP] estate: add SQL and Python constraints to the models
aurb9 Dec 17, 2025
c0bed00
[IMP] estate: add SQL and Python constraints to the models
aurb9 Dec 17, 2025
724803e
[MERGE] Merge remote-tracking branch 'dev/19.0-tutorials-auber' into …
aurb9 Dec 17, 2025
d8ce950
[IMP] estate: allow buttons to disappear conditionally and add offer …
aurb9 Dec 18, 2025
f153bb7
[FIX] estate: single quote for technical field
aurb9 Dec 19, 2025
783a0a0
[FIX] estate: change order of data imports to resolve runbot error
aurb9 Dec 19, 2025
65fe366
[IMP] estate: change property state upon creation of offer and add pr…
aurb9 Dec 19, 2025
dc2f8de
[IMP] estate: remove buttons from view if property is cancelled or sold
aurb9 Dec 19, 2025
06d9f41
[FIX] estate: change super call
aurb9 Dec 19, 2025
0e9783d
[FIX] estate: remove double specification of properties
aurb9 Dec 19, 2025
6b3d3a2
[FIX] estate: split import on separate lines
aurb9 Dec 19, 2025
8ff4802
[ADD] estate_account: extend `estate` to create an invoice when prope…
aurb9 Dec 19, 2025
8d26a6b
[IMP] estate: add customized kanban view to property
aurb9 Dec 19, 2025
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
18 changes: 18 additions & 0 deletions estate/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
'name': 'Real Estate',
'depends': ['base'],
'data': [
'security/ir.model.access.csv',

'views/estate_property_tag_views.xml',
'views/estate_property_offer_views.xml',
'views/estate_property_type_views.xml',
'views/estate_property_views.xml',
'views/estate_menus.xml',
'views/res_users_views.xml',
],
'installable': True,
'application': True,
'author': "Odoo",
'license': 'AGPL-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 property
from . import property_offer
from . import property_tag
from . import property_type
from . import res_users
110 changes: 110 additions & 0 deletions estate/models/property.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
from dateutil.relativedelta import relativedelta

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


class Property(models.Model):
_name = 'estate.property'
_description = "Estate property"
_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 self: fields.Date.today() + relativedelta(months=3),
copy=False,
)
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(string="Garden Area (sqm)")
garden_orientation = fields.Selection(
selection=[('north', 'North'), ('south', 'South'), ('east', 'East'), ('west', 'West')]
)
state = fields.Selection(
default="new",
string="Status",
selection=[
('new', 'New'),
('offer_received', 'Offer Received'),
('offer_accepted', 'Offer Accepted'),
('sold', 'Sold'),
('cancelled', 'Cancelled'),
],
required=True,
copy=False,
)
active = fields.Boolean(default=True)
property_type_id = fields.Many2one('estate.property.type', string="Property Type")
partner_id = fields.Many2one('res.partner', string="Buyer", readonly=True)
user_id = fields.Many2one('res.users', string="Salesman", default=lambda self: self.env.user)
tag_ids = fields.Many2many('estate.property.tag', string="Property Tags")
offer_ids = fields.One2many('estate.property.offer', 'property_id', string="Offers")
total_area = fields.Integer(string="Total Area (sqm)", compute='_compute_total_area')
best_offer = fields.Float(default=0.0, compute="_compute_best_offer")

_expected_price_strictly_pos = models.Constraint(
"CHECK(expected_price > 0)", "The expected price must be strictly positive."
)
_selling_price_pos = models.Constraint(
"CHECK(selling_price >= 0)", "The selling price 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')
def _compute_best_offer(self):
for record in self:
record.best_offer = max(record.offer_ids.mapped('price')) if record.offer_ids else 0.0

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

@api.constrains('selling_price', 'expected_price')
def _check_selling_price(self):
for record in self:
if (
not float_is_zero(record.selling_price, precision_digits=2) and
float_compare(record.selling_price, record.expected_price * 0.9, precision_digits=2) == -1
):
Comment on lines +79 to +82

Choose a reason for hiding this comment

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

Could have been only one condition as expected_price is strictly positive (via your SQL constraint), this leads selling_price to be strictly positive as well (but when expected_price` is very small, which is a functional stupid case).

BTW, you could also have added a SQL constraint like

(
    "check_selling_price",
    "CHECK(selling_price >= 0.9 * expected_price)",
    "Selling price must be at least 90% of expected price.",
),

raise ValidationError(
self.env._(
"The selling price must be at least 90% of the expected price! "
"You must reduce the expected price if you want to accept this offer."
)
)

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

self.state = 'sold'
return True

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

self.state = 'cancelled'
return True

@api.ondelete(at_uninstall=False)
def _unlink_except_new_or_cancelled(self):
for record in self:
if record.state not in ['new', 'cancelled']:
raise UserError(
self.env._("Only new and cancelled properties can be deleted.")
)
61 changes: 61 additions & 0 deletions estate/models/property_offer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from dateutil.relativedelta import relativedelta

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


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

price = fields.Float()
status = fields.Selection(selection=[('accepted', 'Accepted'), ('refused', 'Refused')], copy=False)
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(default=7, string="Validity (days)")
deadline = fields.Date(compute='_compute_deadline', inverse='_inverse_deadline')

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

@api.depends('validity')
def _compute_deadline(self):
for record in self:
create_date = record.create_date or fields.Date.today()
record.deadline = create_date + relativedelta(days=record.validity)

def _inverse_deadline(self):
for record in self:
record.validity = (record.deadline - fields.Date.to_date(record.create_date)).days

def action_confirm(self):
self.status = 'accepted'
for offer in self.property_id.offer_ids:
if offer.id == self.id:
continue

offer.status = 'refused'

self.property_id.state = 'offer_accepted'
self.property_id.partner_id = self.partner_id
self.property_id.selling_price = self.price
return True

def action_refuse(self):
self.status = 'refused'
return True

@api.model_create_multi
def create(self, vals):
for record in vals:
property = self.env['estate.property'].browse(record['property_id'])
min_price = min(property.offer_ids.mapped('price')) if property.offer_ids else 0.0
if record['price'] < min_price:
raise UserError(self.env._("The offer must be higher than %d.", min_price))

property.state = 'offer_received'

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

name = fields.Char(required=True)
color = fields.Integer()

_name_uniq = models.Constraint("UNIQUE(name)", "The name must be unique.")
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 = "Property Type"
_order = "sequence desc, name"

name = fields.Char(required=True)
property_ids = fields.One2many('estate.property', 'property_type_id')
sequence = fields.Integer(help="Used to order property types. Higher is better.")
offer_ids = fields.One2many('estate.property.offer', 'property_type_id')
offer_count = fields.Integer(compute='_compute_offer_count')

_name_uniq = models.Constraint("UNIQUE(name)", "The name must be unique.")

@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
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', 'user_id', domain="[('active', '=', 'True')]")
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,model_estate_property,base.group_user,1,1,1,1
access_estate_property_type,access_estate_property_type,model_estate_property_type,base.group_user,1,1,1,1
access_estate_property_tag,access_estate_property_tag,model_estate_property_tag,base.group_user,1,1,1,1
access_estate_property_offer,access_estate_property_offer,model_estate_property_offer,base.group_user,1,1,1,1
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_property_menu_root" name="Real Estate">
<menuitem id="estate_property_advertisements_menu" name="Advertisements">
<menuitem id="estate_property_model_menu_action" action="estate_property_model_action"/>
</menuitem>
<menuitem id="estate_property_settings_menu" name="Settings">
<menuitem id="estate_property_type_model_menu_action" action="estate_property_type_model_action"/>
<menuitem id="estate_property_tag_model_menu_action" action="estate_property_tag_model_action"/>
</menuitem>
</menuitem>
</odoo>
44 changes: 44 additions & 0 deletions estate/views/estate_property_offer_views.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?xml version="1.0"?>
<odoo>
<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="Property Offer">
<sheet>
<group>
<group>
<field name="price"/>
<field name="partner_id"/>
<field name="validity"/>
<field name="deadline"/>
</group>
</group>
</sheet>
</form>
</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 Offer" editable="bottom" decoration-success="status == 'accepted'"
decoration-danger="status == 'refused'">
<field name="price"/>
<field name="partner_id"/>
<field name="validity"/>
<field name="deadline"/>
<button name="action_confirm" type="object" title="Accept" icon="fa-check" invisible="status"/>
<button name="action_refuse" type="object" title="Refuse" icon="fa-times" invisible="status"/>
</list>
</field>
</record>

<record id="estate_property_offer_model_action" model="ir.actions.act_window">
<field name="name">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>
</odoo>
32 changes: 32 additions & 0 deletions estate/views/estate_property_tag_views.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?xml version="1.0"?>
<odoo>
<record id="estate_property_tag_model_view_form" model="ir.ui.view">
<field name="name">estate.property.tag.form</field>
<field name="model">estate.property.tag</field>
<field name="arch" type="xml">
<form string="Property Tag">
<sheet>
<group>
<field name="name"/>
</group>
</sheet>
</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 Tag" editable="bottom">
<field name="name"/>
</list>
</field>
</record>

<record id="estate_property_tag_model_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>
</odoo>
57 changes: 57 additions & 0 deletions estate/views/estate_property_type_views.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?xml version="1.0"?>
<odoo>
<record id="estate_property_type_view_form" model="ir.ui.view">
<field name="name">estate.property.type.form</field>
<field name="model">estate.property.type</field>
<field name="arch" type="xml">
<form string="Property Type">
<sheet>
<div name="button_box">
<button name="%(estate.estate_property_offer_model_action)d" type="action"
class="oe_stat_button" icon="fa-id-card-o">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">
<field name="offer_count"/>
</span>
<span class="o_stat_text">Offers</span>
</div>
</button>
</div>
<div class="oe_title">
<h1 class="mb32">
<field name="name" class="mb16"/>
</h1>
</div>
<notebook>
<page name="Properties">
<field name="property_ids" readonly="True">
<list string="Properties" delete="False">
<field name="name"/>
<field name="expected_price"/>
<field name="state"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>

<record id="estate_property_type_view_list" model="ir.ui.view">
<field name="name">estate.property.type.list</field>
<field name="model">estate.property.type</field>
<field name="arch" type="xml">
<list string="Property Type">
<field name="sequence" widget="handle"/>
<field name="name"/>
</list>
</field>
</record>

<record id="estate_property_type_model_action" model="ir.actions.act_window">
<field name="name">Property Types</field>
<field name="res_model">estate.property.type</field>
<field name="view_mode">list,form</field>
</record>
</odoo>
Loading