From 0ebe3e4edaed27017c3d3bfe397da257ca36f89f Mon Sep 17 00:00:00 2001 From: "Julien Piron (jupir)" Date: Tue, 19 May 2026 11:53:26 +0200 Subject: [PATCH 1/2] [ADD] estate,estate_accountant: technical onboarding commit Problem: There is currently no modules to manage real estate businesses in odoo. Solution: Implement estate properties, offer, tags, and types models with the associated views as well as unit tests. task-6229399 --- awesome_owl/static/src/playground.js | 10 +- awesome_owl/static/src/playground.xml | 5 +- estate/__init__.py | 1 + estate/__manifest__.py | 18 +++ estate/models/__init__.py | 5 + estate/models/estate_property.py | 131 ++++++++++++++++++ estate/models/estate_property_offer.py | 77 +++++++++++ estate/models/estate_property_tag.py | 12 ++ estate/models/estate_property_type.py | 29 ++++ estate/models/res_users.py | 11 ++ estate/security/ir.model.access.csv | 5 + estate/tests/__init__.py | 1 + estate/tests/test_estate_property.py | 84 ++++++++++++ estate/views/estate_property_menus.xml | 12 ++ estate/views/estate_property_offer_views.xml | 17 +++ estate/views/estate_property_tag_views.xml | 19 +++ estate/views/estate_property_type_views.xml | 49 +++++++ estate/views/estate_property_views.xml | 135 +++++++++++++++++++ estate/views/res_users_views.xml | 29 ++++ estate_account/__init__.py | 1 + estate_account/__manifest__.py | 9 ++ estate_account/models/__init__.py | 1 + estate_account/models/estate_property.py | 37 +++++ 23 files changed, 694 insertions(+), 4 deletions(-) create mode 100644 estate/__init__.py create mode 100644 estate/__manifest__.py create mode 100644 estate/models/__init__.py create mode 100644 estate/models/estate_property.py create mode 100644 estate/models/estate_property_offer.py create mode 100644 estate/models/estate_property_tag.py create mode 100644 estate/models/estate_property_type.py create mode 100644 estate/models/res_users.py create mode 100644 estate/security/ir.model.access.csv create mode 100644 estate/tests/__init__.py create mode 100644 estate/tests/test_estate_property.py create mode 100644 estate/views/estate_property_menus.xml create mode 100644 estate/views/estate_property_offer_views.xml create mode 100644 estate/views/estate_property_tag_views.xml create mode 100644 estate/views/estate_property_type_views.xml create mode 100644 estate/views/estate_property_views.xml create mode 100644 estate/views/res_users_views.xml create mode 100644 estate_account/__init__.py create mode 100644 estate_account/__manifest__.py create mode 100644 estate_account/models/__init__.py create mode 100644 estate_account/models/estate_property.py diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js index 4ac769b0aa5..d6a478f5b94 100644 --- a/awesome_owl/static/src/playground.js +++ b/awesome_owl/static/src/playground.js @@ -1,5 +1,13 @@ -import { Component } from "@odoo/owl"; +import { Component, useState } from "@odoo/owl"; export class Playground extends Component { static template = "awesome_owl.playground"; + + setup() { + this.state = useState({ value: 0 }) + } + + increment() { + this.state.value++ + } } diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml index 4fb905d59f9..cf6e76f2a50 100644 --- a/awesome_owl/static/src/playground.xml +++ b/awesome_owl/static/src/playground.xml @@ -2,9 +2,8 @@ -
- hello world -
+

Counter:

+
diff --git a/estate/__init__.py b/estate/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/estate/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/estate/__manifest__.py b/estate/__manifest__.py new file mode 100644 index 00000000000..660a4a07a44 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,18 @@ +{ + "name": "Estate", + "summary": "Track your real estate business", + "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/res_users_views.xml", + "views/estate_property_menus.xml", + ], + "installable": True, + "application": True, + "author": "Odoo S.A.", + "license": "LGPL-3", +} diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..9c7b5068827 --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1,5 @@ +from . import estate_property +from . import estate_property_tag +from . import estate_property_type +from . import estate_property_offer +from . import res_users diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..937656cd8d7 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,131 @@ +from datetime import datetime + +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 + + +class EstatePropery(models.Model): + _name = 'estate.property' + _description = "Estate property" + _order = 'id desc' + + name = fields.Char(required=True) + description = fields.Text() + postcode = fields.Char() + date_availability = fields.Date( + copy=False, + default=lambda self: self.__default_date_availability(), + ) + expected_price = fields.Float(required=True) + selling_price = fields.Float(readonly=True, copy=False) + bedrooms = fields.Integer(default=2) + living_area = fields.Integer() + facades = fields.Integer() + garage = fields.Boolean() + garden = fields.Boolean() + garden_area = fields.Integer() + garden_orientation = fields.Selection( + string="Orientation", + selection=[ + ('north', "North"), + ('east', "East"), + ('soutch', "South"), + ('west', "West"), + ], + ) + total_area = fields.Integer(compute='_compute_total_area') + best_price = fields.Float(compute='_compute_best_price') + property_type_id = fields.Many2one('estate.property.type') + tag_ids = fields.Many2many('estate.property.tag') + salesperson_id = fields.Many2one('res.users', default=lambda self: self.env.user) + buyer_id = fields.Many2one('res.partner', copy=False) + offer_ids = fields.One2many('estate.property.offer', 'property_id') + active = fields.Boolean(default=True) + state = fields.Selection( + selection=[ + ('new', "New"), + ('offer_received', "Offer Received"), + ('offer_accepted', "Offer Accepted"), + ('sold', "Sold"), + ('cancelled', "Cancelled"), + ], + copy=False, + default='new', + ) + + _expected_price_constraint = models.Constraint( + 'CHECK(expected_price >= 0)', + "Expected price must be positive.", + ) + _selling_price_constraint = models.Constraint( + 'CHECK(selling_price >= 0)', + "Selling price must be positive.", + ) + + @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.constrains('selling_price', 'expected_price') + def _check_selling_price_and_expected_price(self): + for property in self: + if ( + not float_is_zero(property.selling_price, 0) + and float_compare( + property.expected_price * 0.9, + property.selling_price, + 0, + ) + > 0 + ): + raise ValidationError( + _( + "You cannot accept an offer lower than 90% of the expected price. Lower the expected price if you want to accept it.", + ), + ) + + @api.onchange('garden') + def _onchange_garden(self): + if self.garden: + self.garden_area = 10 + self.garden_orientation = 'north' + else: + self.garden_area = None + self.garden_orientation = None + + @api.ondelete(at_uninstall=False) + def _unlink_except_valid_state(self): + if any(property.state not in ['new', 'cancelled'] for property in self): + raise UserError( + _("Can't delete a property if status is not New or Cancelled"), + ) + + def action_cancel_property(self): + for property in self: + if property.state == 'sold': + raise UserError(_("Sold properties cannot be cancelled.")) + property.state = 'cancelled' + + def action_mark_sold_property(self): + for property in self: + if property.state == 'cancelled': + raise UserError(_("Cancelled properties cannot be sold.")) + + if not any(offer.status == 'accepted' for offer in property.offer_ids): + raise UserError( + _("Properties without an accepted offer cannot be marked sold."), + ) + + property.state = 'sold' + + def __default_date_availability(self): + return datetime.today() + relativedelta(months=3) diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..7079e85df97 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,77 @@ +from datetime import timedelta + +from dateutil.utils import today + +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools.float_utils import float_compare + + +class EstatePropertyOffer(models.Model): + _name = 'estate.property.offer' + _description = "Offer for a property" + _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', + ) + validity = fields.Integer(default=7) + date_deadline = fields.Date( + compute='_compute_deadline', + inverse='_inverse_deadline', + ) + + _check_price = models.Constraint('CHECK(price >= 0)', 'Price must be positive') + + @api.depends('create_date', 'validity') + def _compute_deadline(self): + for offer in self: + offer.date_deadline = (offer.create_date or today()).date() + timedelta( + days=offer.validity + ) + + def _inverse_deadline(self): + for offer in self: + offer.validity = ( + (offer.date_deadline - offer.create_date.date()).days + if offer.create_date + else (offer.date_line - today()).days + ) + + @api.model_create_multi + def create(self, vals_list): + for record in vals_list: + property = self.env['estate.property'].browse(record['property_id']) + + if float_compare(property.best_price, record['price'], 2) == 1: + raise UserError(_("You already have a higher offer")) + + if property.state == 'sold': + raise UserError( + _("This property is already sold. You cannot add a new offer."), + ) + + property.state = 'offer_received' + + return super().create(vals_list) + + def action_accept(self): + if self.property_id.state == 'cancelled': + raise UserError(_("This property is cancelled.")) + if self.property_id.state == 'sold': + raise UserError(_("This property is already sold.")) + + self.status = 'accepted' + self.property_id.state = 'offer_accepted' + self.property_id.buyer_id = self.partner_id + self.property_id.selling_price = self.price + + def action_refuse(self): + self.status = 'refused' diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..83bf9abf4e9 --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,12 @@ +from odoo import fields, models + + +class EstatePropertyTag(models.Model): + _name = 'estate.property.tag' + _description = "Property tag" + _order = 'name' + + name = fields.Char(required=True) + color = fields.Integer() + + _name_constraint = models.UniqueIndex('(name)', "Tag names must be unique.") diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py new file mode 100644 index 00000000000..1ac9fec56e5 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,29 @@ +from odoo import _, api, fields, models + + +class EstatePropertyType(models.Model): + _name = 'estate.property.type' + _description = "Property type" + _order = 'name' + + sequence = fields.Integer(default=1) + name = fields.Char(required=True) + property_ids = fields.One2many('estate.property', 'property_type_id') + offer_ids = fields.One2many('estate.property.offer', 'property_type_id', store=True) + offer_count = fields.Integer(compute='_compute_offer_count') + + @api.depends('property_ids', 'offer_ids') + def _compute_offer_count(self): + for property_type in self: + property_type.offer_count = len(property_type.offer_ids) + + @api.readonly + def action_view_offers(self): + return { + 'name': _("Offer(s)"), + 'type': 'ir.actions.act_window', + 'res_model': 'estate.property.offer', + 'target': 'current', + 'view_mode': 'list,form', + 'domain': [('id', 'in', self.property_ids.ids)], + } diff --git a/estate/models/res_users.py b/estate/models/res_users.py new file mode 100644 index 00000000000..f7e3cf04456 --- /dev/null +++ b/estate/models/res_users.py @@ -0,0 +1,11 @@ +from odoo import fields, models + + +class EstateUser(models.Model): + _inherit = 'res.users' + + property_ids = fields.One2many( + 'estate.property', + 'salesperson_id', + domain=[('state', 'in', ['new', 'offer_received'])], + ) diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..9a113d0bcb2 --- /dev/null +++ b/estate/security/ir.model.access.csv @@ -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_offer,access_estate_property_offer,model_estate_property_offer,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_type,access_estate_property_type,model_estate_property_type,base.group_user,1,1,1,1 diff --git a/estate/tests/__init__.py b/estate/tests/__init__.py new file mode 100644 index 00000000000..576617cccff --- /dev/null +++ b/estate/tests/__init__.py @@ -0,0 +1 @@ +from . import test_estate_property diff --git a/estate/tests/test_estate_property.py b/estate/tests/test_estate_property.py new file mode 100644 index 00000000000..f2491d61956 --- /dev/null +++ b/estate/tests/test_estate_property.py @@ -0,0 +1,84 @@ +from odoo.exceptions import UserError +from odoo.tests import Form, new_test_user, tagged +from odoo.tests.common import TransactionCase + + +@tagged("post_install", "-at_install", "estate") +class EstateTestCase(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.properties = cls.env["estate.property"].create( + [{"name": "Farm 1", "expected_price": 1_000_000}] + ) + + def test_creation_area(self): + """Test that the total_area is properly computed.""" + self.properties.living_area = 200 + self.properties.garden_area = 100 + self.assertRecordValues( + self.properties, [{"name": "Farm 1", "total_area": 300}] + ) + + def test_garden_area_and_orientation(self): + # when garden is True + with Form(self.env["estate.property"]) as property: + property.name = "Farm 2" + property.expected_price = 2_000_000 + property.garden = True + + # then garden_area and garden_orientation are set + self.assertRecordValues( + property.record, [{"garden_area": 10, "garden_orientation": "north"}] + ) + + # when garden is False + property.garden = False + property.save() + + # garden_area and garden_orientation are disabled + self.assertRecordValues( + property.record, [{"garden_area": 0, "garden_orientation": False}] + ) + + def test_no_offer_allowed_for_sold_properties(self): + # when property is sold + self.properties.state = "sold" + buyer = new_test_user(self.env, login="mark_baier@example.com") + + # then new offer cannot be made + with self.assertRaises(UserError): + self.properties.offer_ids = self.env["estate.property.offer"].create( + [ + { + "property_id": self.properties.id, + "price": 1_000_000, + "partner_id": buyer.partner_id.id, + } + ] + ) + + def test_properties_cannot_be_sold_without_accepted_offer(self): + # when property has no offer it cannot be marked sold + with self.assertRaises(UserError): + self.properties.action_mark_sold_property() + + # when property has no accepted offer it cannot be marked sold + buyer = new_test_user(self.env, login="mark_baier@example.com") + offer = self.env["estate.property.offer"].create( + [ + { + "property_id": self.properties.id, + "price": 1_000_000, + "partner_id": buyer.partner_id.id, + } + ] + ) + with self.assertRaises(UserError): + self.properties.action_mark_sold_property() + + # when property has an accepted offer it can be marked sold + offer.action_accept() + self.properties.action_mark_sold_property() + + self.assertRecordValues(self.properties, [{"state": "sold"}]) diff --git a/estate/views/estate_property_menus.xml b/estate/views/estate_property_menus.xml new file mode 100644 index 00000000000..33ca260d0de --- /dev/null +++ b/estate/views/estate_property_menus.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..7926a79f9d4 --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,17 @@ + + + + + estate.property.offer.list + estate.property.offer + + + + + + + + + + + diff --git a/estate/views/estate_property_tag_views.xml b/estate/views/estate_property_tag_views.xml new file mode 100644 index 00000000000..e7a93a37aad --- /dev/null +++ b/estate/views/estate_property_tag_views.xml @@ -0,0 +1,19 @@ + + + + + Property Tags + estate.property.tag + list,form + + + estate.property.tag.list + estate.property.tag + + + + + + + + diff --git a/estate/views/estate_property_type_views.xml b/estate/views/estate_property_type_views.xml new file mode 100644 index 00000000000..4a0fba9632d --- /dev/null +++ b/estate/views/estate_property_type_views.xml @@ -0,0 +1,49 @@ + + + + + Property Types + estate.property.type + list,form + + + estate.property.type.list + estate.property.type + + + + + + + + + estate.property.type.form + estate.property.type + +
+ +
+ +
+

+ +

+ + + + + + + + + + + +
+
+
+
+
+
diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml new file mode 100644 index 00000000000..a33804a40e4 --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,135 @@ + + + + + Properties + estate.property + list,kanban,form + {'search_default_available': True} + + + estate.property.list + estate.property + + + + + + + + + + + + + + estate.property.form + estate.property + +
+
+
+ +

+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +