From 25a257b24ed195abfac63cd268c4b68aac7e9047 Mon Sep 17 00:00:00 2001 From: Dani Loewito Date: Tue, 19 May 2026 13:31:38 +0200 Subject: [PATCH 1/3] [ADD] estate, estate_account: add real estate property management modules Problem: No real estate management module in odoo Solution: Make a new module for the 3 people ever who wants a specific real estate module --- estate/__init__.py | 1 + estate/__manifest__.py | 18 ++ estate/models/__init__.py | 5 + estate/models/estate_property.py | 145 ++++++++++++++++ estate/models/estate_property_offer.py | 135 +++++++++++++++ estate/models/estate_property_tag.py | 13 ++ estate/models/estate_property_type.py | 21 +++ estate/models/res_users.py | 12 ++ estate/security/ir.model.access.csv | 5 + estate/tests/__init__.py | 2 + estate/tests/common.py | 13 ++ estate/tests/test_estate_property.py | 74 ++++++++ estate/tests/test_estate_property_offer.py | 57 +++++++ estate/views/estate_menus.xml | 14 ++ estate/views/estate_property_offer_views.xml | 75 ++++++++ estate/views/estate_property_tag_views.xml | 34 ++++ estate/views/estate_property_type_views.xml | 52 ++++++ estate/views/estate_property_views.xml | 171 +++++++++++++++++++ estate/views/res_users_views.xml | 15 ++ estate_account/__init__.py | 1 + estate_account/__manifest__.py | 10 ++ estate_account/models/__init__.py | 1 + estate_account/models/estate_property.py | 28 +++ estate_account/security/ir.model.access.csv | 2 + 24 files changed, 904 insertions(+) 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/common.py create mode 100644 estate/tests/test_estate_property.py create mode 100644 estate/tests/test_estate_property_offer.py create mode 100644 estate/views/estate_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 create mode 100644 estate_account/security/ir.model.access.csv 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..f05f394acf6 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,18 @@ +{ + 'name': "Estate", + 'depends': ['base'], + 'application': True, + 'installable': True, + "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_type_views.xml', + 'views/estate_property_tag_views.xml', + 'views/estate_menus.xml', + 'views/res_users_views.xml', + ], +} diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..fea9f441d6d --- /dev/null +++ b/estate/models/__init__.py @@ -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 diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..29571fcab91 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,145 @@ +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models +from odoo.exceptions import UserError, ValidationError +from odoo.tools import float_compare + + +class EstateProperty(models.Model): + _name = "estate.property" + _description = "Estate Property" + _order = "id desc" + + estate_property_type_id = fields.Many2one(comodel_name="estate.property.type", ondelete="set null") + + estate_property_tag_ids = fields.Many2many(comodel_name="estate.property.tag") + + buyer_id = fields.Many2one(comodel_name="res.partner", ondelete="set null") + + seller_id = fields.Many2one(comodel_name="res.users", ondelete="set null", + default=lambda self: self.env.user.id) + + estate_property_offer_ids = fields.One2many(comodel_name="estate.property.offer", inverse_name="estate_property_id", + copy=False) + + name = fields.Char(string="Estate Property", required=True) + + active = fields.Boolean(default=True) + + description = fields.Text(string="Description") + + postcode = fields.Char(string="Postcode") + + status = fields.Selection( + selection=[ + ("new", "New"), + ("offer_received", "Offer Received"), + ("offer_accepted", "Offer Accepted"), + ("sold", "Sold"), + ("cancelled", "Cancelled"), + ], + default="new", + ) + + date_availability = fields.Date( + string="Available From", + default=fields.Date.today() + relativedelta(months=+3), + copy=False, + ) + + expected_price = fields.Float(string="Expected Price", required=True) + + selling_price = fields.Float(string="Selling Price", copy=False, readonly=True) + + bedrooms = fields.Integer(string="Bedrooms", default=2) + + living_area = fields.Integer(string="Living Area (sqm)") + + facades = fields.Integer(string="Facades") + + garage = fields.Boolean(string="Garage", default=False) + + garden = fields.Boolean(string="Garden", default=False) + garden_area = fields.Integer(string="Garden Area") + garden_orientation = fields.Selection( + selection=[("north", "North"), ("east", "East"), ("south", "South"), ("west", "West")], + ) + + total_area = fields.Integer(compute="_compute_total_area") + + best_price = fields.Float(string="Best Offer", compute="_compute_best_price") + + _check_expected_price_positive = models.Constraint( + definition="CHECK(expected_price > 0)", + message="expected_price <= 0", + ) + + _check_selling_price_positive = models.Constraint( + definition="CHECK(selling_price is null or selling_price > 0)", + message="selling_price <= 0", + ) + + @api.depends("living_area") + def _compute_total_area(self): + for estate in self: + estate.total_area = estate.living_area + estate.garden_area + + @api.depends("estate_property_offer_ids") + def _compute_best_price(self): + for estate in self: + estate.best_price = max(estate.mapped('estate_property_offer_ids.price') or [0]) + + @api.constrains('selling_price') + def _check_ninety_percent_of_expected(self): + for estate in self: + if estate.selling_price > 0: + if float_compare(estate.selling_price, 0.9 * estate.expected_price, 2) < 0: + raise ValidationError(self.env._("Offer is less than 90% of expected")) + + @api.onchange("garden") + def _onchange_garden_orientation(self): + if self.garden: + self.garden_area = 10 + self.garden_orientation = "north" + else: + self.garden_area = 0 + self.garden_orientation = None + + @api.ondelete(at_uninstall=False) + def _unlink_if_new_cancelled(self): + for user in self: + if user.status not in ("new", "cancelled"): + raise UserError(self.env._("Cannot delete active listing")) + + def action_set_sold(self): + self.ensure_one() + if (not self.active) or self.status == "cancelled": + raise UserError(self.env._("Cannot set inactive/cancelled listing to sold")) + + offer_accepted = False + for offer in self.estate_property_offer_ids: + if offer.status == "accepted": + offer_accepted = True + break + + if not offer_accepted: + raise UserError(self.env._("Cannot sell something without accepted offer")) + + self.status = "sold" + return True + + def action_set_cancelled(self): + self.ensure_one() + if self.status == "sold": + raise UserError(self.env._("Cannot set sold listing to cancelled")) + self.status = "cancelled" + return True + + def action_uncancel(self): + self.ensure_one() + self.status = "new" + return True + + def set_offer_received(self): + for estate in self: + estate.status = "offer_received" diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..33712ddda3a --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,135 @@ +from datetime import timedelta + +from odoo import api, fields, models +from odoo.exceptions import UserError + + +class EstatePropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "Estate Property Offer" + _order = "price desc" + + estate_property_id = fields.Many2one( + comodel_name="estate.property", + ondelete="cascade", + required=True, + ) + + property_type_id = fields.Many2one(related="estate_property_id.estate_property_type_id", store=True) + + buyer_id = fields.Many2one( + comodel_name="res.partner", + string="Partner", + ondelete="cascade", + default=lambda self: self.env.user.partner_id.id, + required=True, + ) + + price = fields.Float( + string="Price", + required=True, + aggregator="max", + ) + + status = fields.Selection( + selection=[ + ("new", "New"), + ("refused", "Refused"), + ("accepted", "Accepted"), + ], + default="new", copy=False, + ) + + validity = fields.Integer(default=7) + + date_deadline = fields.Date( + string="Deadline", + compute="_compute_date_deadline", + inverse="_inverse_date_deadline", + ) + + _check_positive_offer = models.Constraint( + definition="CHECK(price > 0)", + message="Offer price must be greater than 0", + ) + + @api.depends("validity") + def _compute_date_deadline(self): + for estate in self: + estate.date_deadline = (estate.create_date or fields.Date.today()) + timedelta(days=estate.validity) + + def _inverse_date_deadline(self): + for estate in self: + new_validity = (estate.date_deadline - fields.Date.today()).days + if new_validity < 0: + raise UserError(self.env._("Validity cannot be negative.")) + estate.validity = (estate.date_deadline - fields.Date.today()).days + + @api.model + def create(self, vals): + for new_offer in vals: + estate_property = self.env["estate.property"].browse(new_offer.get("estate_property_id")) + if estate_property.status == "sold": + raise UserError(self.env._("Cannot bid on a sold property")) + + current_lowest_offer = self.env["estate.property.offer"].search( + domain=[('estate_property_id', '=', new_offer.get("estate_property_id"))], + order="price asc", limit=1, + ) + + if not current_lowest_offer: + if estate_property.status == "new": + estate_property.set_offer_received() + return super().create(vals) + + if new_offer.get("price") < current_lowest_offer.price: + raise UserError(self.env._("Price cannot be lower than current lowest offer")) + + if estate_property.status == "new": + estate_property.set_offer_received() + + return super().create(vals) + + @api.model + def write(self, vals): + updated_price = vals.get("price") + if not updated_price: + return super().write(vals) + + current_property_offers_price_asc = self.estate_property_id.estate_property_offer_ids.sorted("price asc") + + if self == current_property_offers_price_asc[0]: + return super().write(vals) + + if updated_price < current_property_offers_price_asc[0].price: + raise UserError(self.env._("Price cannot be lower than current lowest offer")) + + return super().write(vals) + + def action_accept_offer(self): + self.ensure_one() + for offer in self: + if fields.Date.today() > offer.date_deadline: + raise UserError(self.env._("Offer is expired")) + + estate = offer.estate_property_id + for recur_offer in estate.estate_property_offer_ids: + if recur_offer.status == "accepted": # Guard against multiple accepted offers, independent of property status. + raise UserError(self.env._("Another offer is already accepted")) + + estate.buyer_id = offer.buyer_id + estate.selling_price = offer.price + estate.status = "offer_accepted" + + offer.status = "accepted" + return True + + def action_refuse_offer(self): + self.ensure_one() + self.status = "refused" + + estate = self.estate_property_id + estate.buyer_id = None + estate.selling_price = None + estate.status = "offer_received" + return True diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..fd57e0d1c03 --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,13 @@ +from odoo import fields, models + + +class EstatePropertyTag(models.Model): + _name = 'estate.property.tag' + _description = 'Estate Property Tag' + _order = 'name asc' + + estate_property_ids = fields.Many2many(comodel_name="estate.property") + name = fields.Char(string="Tag", required=True) + color = fields.Integer(string="Color", default=0) + + _check_unique_name = models.UniqueIndex(definition="(UPPER(name))", message="Tag should be unique") diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py new file mode 100644 index 00000000000..a488e9df60b --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,21 @@ +from odoo import api, fields, models + + +class EstatePropertyType(models.Model): + _name = "estate.property.type" + _description = "Estate Property Type" + _order = "name asc" + + estate_property_ids = fields.One2many(comodel_name="estate.property", inverse_name="estate_property_type_id") + estate_property_offer_ids = fields.One2many(comodel_name="estate.property.offer", inverse_name="property_type_id") + + name = fields.Char(string="Type", required=True) + sequence = fields.Integer(string='Sequence', default=1) + offer_count = fields.Integer(compute="_compute_offer_count") + + _check_unique_name = models.UniqueIndex(definition="(UPPER(name))", message="Type should be unique") + + @api.depends('estate_property_offer_ids') + def _compute_offer_count(self): + for prop_type in self: + prop_type.offer_count = len(prop_type.estate_property_offer_ids) diff --git a/estate/models/res_users.py b/estate/models/res_users.py new file mode 100644 index 00000000000..2dba6a3a7c3 --- /dev/null +++ b/estate/models/res_users.py @@ -0,0 +1,12 @@ +from odoo import fields, models + + +class InheritedResUser(models.Model): + _inherit = "res.users" + _description = "Inherited User For Estate Module" + + property_ids = fields.One2many( + comodel_name="estate.property", + inverse_name="seller_id", + domain=[('status', '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..89f97c50842 --- /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_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 diff --git a/estate/tests/__init__.py b/estate/tests/__init__.py new file mode 100644 index 00000000000..d6724ad4c71 --- /dev/null +++ b/estate/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_estate_property +from . import test_estate_property_offer diff --git a/estate/tests/common.py b/estate/tests/common.py new file mode 100644 index 00000000000..9ba33f5c1ef --- /dev/null +++ b/estate/tests/common.py @@ -0,0 +1,13 @@ +from odoo.tests.common import TransactionCase + +class EstateTestCommon(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.properties = cls.env['estate.property'].create([ + {"name": "Test 0", "status": "new", "expected_price": 100000, "garden_area": 0}, + {"name": "Test 1", "status": "new", "expected_price": 100000, "garden_area":10}, + {"name": "Test 2", "status": "new", "expected_price": 100000, "garden_area": 50}, + ]) diff --git a/estate/tests/test_estate_property.py b/estate/tests/test_estate_property.py new file mode 100644 index 00000000000..a282feb137e --- /dev/null +++ b/estate/tests/test_estate_property.py @@ -0,0 +1,74 @@ +from .common import EstateTestCommon + +from odoo.exceptions import UserError +from odoo.tests import Form, tagged + +@tagged('post_install', '-at_install') +class EstatePropertyTestCase(EstateTestCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.property_offer_received = cls.env['estate.property'].create( + {"name": "Test 3", "status": "offer_received", "expected_price": 100000}) + cls.property_offer_received.estate_property_offer_ids = cls.env['estate.property.offer'].create([ + {"estate_property_id": cls.property_offer_received.id, "buyer_id": 1, "price": 100000, "status": "new"}, + {"estate_property_id": cls.property_offer_received.id, "buyer_id": 1, "price": 100000, "status": "refused"}, + ]) + + cls.property_cancelled = cls.env['estate.property'].create( + {"name": "Test 4", "status": "cancelled", "active": True, "expected_price": 100000}, + ) + + cls.property_offer_accepted = cls.env['estate.property'].create( + {"name": "Test 5", "status": "offer_accepted", "expected_price": 100000}) + cls.property_offer_accepted.estate_property_offer_ids = cls.env['estate.property.offer'].create([ + {"estate_property_id": cls.property_offer_accepted.id, "buyer_id": 1, "price": 100000, "status": "accepted"}, + {"estate_property_id": cls.property_offer_accepted.id, "buyer_id": 1, "price": 100000, "status": "refused"}, + ]) + + def test_compute_total_area(self): + """Test that the total_area is computed like it should.""" + self.properties.living_area = 20 + self.assertRecordValues(self.properties, [ + {"name": "Test 0","total_area": 20}, + {"name": "Test 1","total_area": 30}, + {"name": "Test 2","total_area": 70}, + ]) + + def test_action_set_sold(self): + """Test that a property is sold with the correct behavior. + multiple properties: valueError + canceled property: userError (cannot sell a canceled/inactive property) + property with no accepted offer: userError (must accept at least 1 offer to sell) + property with accepted offer: status set to sold + """ + with self.assertRaises(ValueError): + self.properties.action_set_sold() + + with self.assertRaises(UserError): + self.property_cancelled.action_set_sold() + + with self.assertRaises(UserError): + self.property_offer_received.action_set_sold() + + self.property_offer_accepted.action_set_sold() + self.assertEqual(self.property_offer_accepted.status, "sold") + + def test_onchange_garden_orientation(self): + """Test that the garden orientation is computed like it should.""" + property_form = Form(self.env['estate.property']) + self.assertFalse(property_form.garden) + self.assertEqual(property_form.garden_area, 0) + self.assertFalse(property_form.garden_orientation) + + property_form.garden = True + self.assertTrue(property_form.garden) + self.assertEqual(property_form.garden_area, 10) + self.assertEqual(property_form.garden_orientation, "north") + + property_form.garden = False + self.assertFalse(property_form.garden) + self.assertEqual(property_form.garden_area, 0) + self.assertFalse(property_form.garden_orientation) diff --git a/estate/tests/test_estate_property_offer.py b/estate/tests/test_estate_property_offer.py new file mode 100644 index 00000000000..dd96cbea9da --- /dev/null +++ b/estate/tests/test_estate_property_offer.py @@ -0,0 +1,57 @@ +from .common import EstateTestCommon + +from odoo.exceptions import UserError +from odoo.tests import tagged + +@tagged('post_install', '-at_install') +class EstatePropertyOfferTestCase(EstateTestCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.property_offer_received = cls.env['estate.property'].create( + {"name": "Test 3", "status": "offer_received", "expected_price": 100000}) + cls.property_offer_received.estate_property_offer_ids = cls.env['estate.property.offer'].create([ + {"estate_property_id": cls.property_offer_received.id, "buyer_id": 1, "price": 100000, "status": "new"}, + {"estate_property_id": cls.property_offer_received.id, "buyer_id": 1, "price": 100000, "status": "refused"}, + ]) + + cls.property_sold = cls.env['estate.property'].create( + {"name": "Test 4", "status": "sold", "expected_price": 100000}) + + cls.property = cls.env['estate.property'].create( + {"name": "Test 5", "expected_price": 100000}) + + def test_create(self): + """Test that an offer is created with the correct behavior + price lower than current lowest: userError (must be >= current lowest offer) + property already sold: userError (cannot offer on sold property) + no other offer exists: success case + successful offer: create offer, and set property status to "offer_received" IFF status == "new" + """ + with self.assertRaises(UserError): # lower than current lowest + self.env["estate.property.offer"].create({ + "estate_property_id": self.property_offer_received.id, + "buyer_id": 1, + "price": 100}) + + with self.assertRaises(UserError): # already sold + self.env["estate.property.offer"].create({ + "estate_property_id": self.property_sold.id, + "buyer_id": 1, + "price": 100000}) + + self.assertEqual(self.property.status, "new") + self.env["estate.property.offer"].create({ # no other offer exists + "estate_property_id": self.property.id, + "buyer_id": 1, + "price": 100000}) + self.assertEqual(self.property.status, "offer_received") + self.assertEqual(len(self.property.estate_property_offer_ids), 1) + + self.env["estate.property.offer"].create({ # existing offer exists + "estate_property_id": self.property.id, + "buyer_id": 1, + "price": 100000}) + self.assertEqual(self.property.status, "offer_received") + self.assertEqual(len(self.property.estate_property_offer_ids), 2) diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..e62dd2790c0 --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..eb11b19075d --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,75 @@ + + + + + Property Offers + estate.property.offer + list,form + + + + Property Offers + estate.property.offer + list,form + + [('property_type_id', '=', active_id)] + + + + + + estate.property.offer.list + estate.property.offer + + + + + + + + + + + + + + + estate.property.offer.related.list + estate.property.offer + + + + + + + + + + +
+

+
+ + + + + + + + +
+
+ + + estate.property.type.list + estate.property.type + + + + + + + +
\ No newline at end of file diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml new file mode 100644 index 00000000000..59039171f3a --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,171 @@ + + + + + Properties + estate.property + list,form,kanban + + + {'search_default_filter_available_status': 1 } + + + + + + + estate.property.search + estate.property + + + + + + + + + + + + + + + + + + + + + + + estate.property.list + estate.property + + + + + + + + + + + + + + + + + + estate.property.form + estate.property + +
+ + +