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..9fc7a8b9117 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,16 @@ +{ + "name": "Real estate", + "author": "Odoo", + "license": "LGPL-3", + "depends": ["base"], + "application": True, + "data": [ + "security/ir.model.access.csv", + "views/estate_property_offer_views.xml", + "views/estate_property_type_views.xml", + "views/estate_property_tag_views.xml", + "views/estate_property_views.xml", + "views/res_user_views.xml", + "views/estate_menus.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..f0f55dcce8d --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,113 @@ +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 EstateProperty(models.Model): + _name = "estate.property" + _description = "Estate Property" + _order = "sequence, id desc" + + name = fields.Char("Title", required=True) + sequence = fields.Integer(default=1) + description = fields.Text() + postcode = fields.Char() + date_availability = fields.Date( + "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("Living Area (sqm)") + facades = fields.Integer() + garage = fields.Boolean() + garden = fields.Boolean() + garden_area = fields.Integer("Garden Area (sqm)") + garden_orientation = fields.Selection([("north", "North"), ("south", "South"), ("east", "East"), ("west", "West")]) + active = fields.Boolean(default=True) + state = fields.Selection( + [ + ("new", "New"), + ("offer", "Offer Received"), + ("accepted", "Offer Accepted"), + ("sold", "Sold"), + ("canceled", "Cancelled"), + ], + default="new", + readonly=True, + ) + property_type_id = fields.Many2one("estate.property.type") + salesman_id = fields.Many2one("res.users", copy=False, default=lambda self: self.env.user) + buyer_id = fields.Many2one("res.partner", copy=False, readonly=True) + tag_ids = fields.Many2many("estate.property.tag") + offer_ids = fields.One2many("estate.property.offer", "property_id") + total_area = fields.Integer("Total Area (sqm)", compute="_compute_total_area") + best_offer = fields.Float(compute="_compute_best_offer") + + _check_expected_price = models.Constraint("CHECK(expected_price>0)", "The expected price needs to be bigger then 0") + _check_selling_price = models.Constraint("CHECK(selling_price>=0)", "The selling price needs to be positive") + + @api.depends("garden_area", "living_area") + def _compute_total_area(self): + for estate_property in self: + estate_property.total_area = estate_property.living_area + estate_property.garden_area + + @api.depends("offer_ids.price") + def _compute_best_offer(self): + for estate_property in self: + estate_property.best_offer = max(estate_property.offer_ids.mapped("price"), default=None) + + @api.onchange("garden") + def _onchange_garden(self): + self.garden_area = 10 if self.garden else 0 + self.garden_orientation = "north" if self.garden else None + + @api.constrains("selling_price", "expected_price") + def _check_selling_price(self): + for estate_property in self: + if ( + not float_is_zero(estate_property.selling_price, 2) + and float_compare(estate_property.selling_price, estate_property.expected_price * 0.9, 2) < 0 + ): + raise ValidationError("The property can not be sold for a price lower than 90% of the expected price") + + @api.ondelete(at_uninstall=False) + def _unlink_property(self): + if any(estate_property.state not in ["new", "canceled"] for estate_property in self): + raise UserError("Only properties with state new or canceled can be removed!") + + def action_state_to_sold(self): + self.ensure_one() + if self.state == "canceled": + raise UserError("Canceled properties can not be sold") + if not [offer for offer in self.offer_ids if offer.status == 'accepted']: + raise UserError("Properties can not be marked as sold if there is no accepted offer") + self.state = "sold" + + def action_state_to_canceled(self): + self.ensure_one() + if self.state == "sold": + raise UserError("Sold properties can not be canceled") + self.state = "canceled" + + def accept_offer(self, price, buyer): + self.ensure_one() + if self.state not in ["new", "offer"]: + raise UserError( + "An offer can can only be accepted when the building is still for sale and no other offer is already accepted" + ) + self.selling_price = price + self.buyer_id = buyer + self.state = "accepted" + + def offer_made(self, price): + self.ensure_one() + if self.state not in ["new", "offer"]: + raise UserError( + "An offer can can only be accepted when the building is still for sale and no other offer is already accepted" + ) + if float_compare(price, self.best_offer, 2) <= 0: + raise UserError("Only offers that are over the current best offer can be accepted") + self.state = "offer" diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..7386c36a902 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,48 @@ +from dateutil.relativedelta import relativedelta + +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" + + price = fields.Float() + status = fields.Selection([("accepted", "Accepted"), ("refused", "Refused")], copy=False) + partner_id = fields.Many2one("res.partner", required=True) + property_id = fields.Many2one("estate.property", required=True) + validity = fields.Integer("Validity (days)", default=7) + date_deadline = fields.Date("Deadline", compute="_calculate_date_deadline", inverse="_inverse_date_deadline") + property_type_id = fields.Many2one(related="property_id.property_type_id", store=True) + + _check_price = models.Constraint("CHECK(price>0)", "The selling price needs to be bigger then 0") + + @api.depends("validity", "create_date") + def _calculate_date_deadline(self): + for offer in self: + offer.date_deadline = (offer.create_date or fields.Date.today()) + relativedelta(days=offer.validity) + + def _inverse_date_deadline(self): + for offer in self: + offer.validity = (offer.date_deadline - fields.Date.today()).days + + @api.model + def create(self, vals_list): + for vals in vals_list: + self.env["estate.property"].browse(vals["property_id"]).offer_made(vals["price"]) + return super().create(vals_list) + + def action_accept_offer(self): + self.ensure_one() + if self.status: + raise UserError("The offer was already revised") + self.property_id.accept_offer(self.price, self.partner_id) + self.status = "accepted" + + def action_refuse_offer(self): + self.ensure_one() + if self.status: + raise UserError("The offer was already revised") + 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..cb82933c8c1 --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -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(required=True) + color = fields.Integer() + + _unique_name = models.UniqueIndex( + "(name)", + "Another entry with the same name already exists.", + ) diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py new file mode 100644 index 00000000000..8f675ddaa63 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,22 @@ +from odoo import api, fields, models + + +class EstatePropertyType(models.Model): + _name = "estate.property.type" + _description = "Estate property type" + _order = "name" + + name = fields.Char(required=True) + 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") + + _unique_name = models.UniqueIndex( + "(name)", + "Another entry with the same name already exists.", + ) + + @api.depends("offer_ids") + def _compute_offer_count(self): + for property_type in self: + property_type.offer_count = len(property_type.offer_ids) diff --git a/estate/models/res_users.py b/estate/models/res_users.py new file mode 100644 index 00000000000..320617c0ce6 --- /dev/null +++ b/estate/models/res_users.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class ResUsers(models.Model): + _inherit = "res.users" + + property_ids = fields.One2many("estate.property", "salesman_id") diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..3338f9c89b7 --- /dev/null +++ b/estate/security/ir.model.access.csv @@ -0,0 +1,6 @@ +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,0 +access_estate_property_offer_admin,access_estate_property_offer_admin,model_estate_property_offer,base.group_system,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/test_estate_property.py b/estate/tests/test_estate_property.py new file mode 100644 index 00000000000..f66a43c9f05 --- /dev/null +++ b/estate/tests/test_estate_property.py @@ -0,0 +1,33 @@ +from odoo.exceptions import UserError +from odoo.tests import TransactionCase, tagged, Form + + +@tagged('post_install', '-at_install') +class TestEstateProperty(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + def test_mark_as_sold_without_offer(self): + self.property = self.env['estate.property'].create({ + 'name': 'property1', + 'expected_price': 100, + 'state': 'offer' + }) + with self.assertRaises(UserError): + self.property.action_state_to_sold() + + def test_reset_garden(self): + self.property = self.env['estate.property'].create({ + 'name': 'property', + 'expected_price': 100, + 'garden': True, + 'garden_area': 20, + 'garden_orientation': 'north' + }) + with Form(self.property) as f1: + f1.garden = False + self.assertFalse(self.property.garden, 'the garden should not be there anymore') + self.assertEqual(self.property.garden_area, 0, "garden area should be reset") + self.assertFalse(self.property.garden_orientation, "garden orientation is expected to be unset") diff --git a/estate/tests/test_estate_property_offer.py b/estate/tests/test_estate_property_offer.py new file mode 100644 index 00000000000..57c3fef776e --- /dev/null +++ b/estate/tests/test_estate_property_offer.py @@ -0,0 +1,42 @@ +from odoo.exceptions import UserError +from odoo.tests import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestEstatePropertyOffer(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.buyer = cls.env['res.partner'].create({'name': 'buyer'}) + + def test_good_offer(self): + self.property = self.env['estate.property'].create({ + 'name': 'property1', + 'expected_price': 100, + }) + self.env['estate.property.offer'].create({ + 'price': 90, + 'partner_id': self.buyer.id, + 'property_id': self.property.id, + }) + self.assertRecordValues(self.property.offer_ids, [ + {'price': 90, 'partner_id': self.buyer.id} + ]) + self.assertEqual(self.property.state, "offer") + + def test_offer_for_sold(self): + self.property = self.env['estate.property'].create({ + 'name': 'property1', + 'expected_price': 100, + 'state': 'sold' + }) + with self.assertRaises(UserError): + self.env['estate.property.offer'].create({ + 'price': 90, + 'partner_id': self.buyer.id, + 'property_id': self.property.id, + }) + self.assertFalse(self.property.offer_ids, "after failure there should be no offer added") + self.assertEqual(self.property.state, "sold", "after failure the state should not change") diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..9a1d5e3fd43 --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..9d0a2598f67 --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,25 @@ + + + estate.property.offer.view.list + estate.property.offer + + + + + + + +

+ +

+ + + + + + + + + +
+
+ + + + Property Types + estate.property.type + list,form + +
diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml new file mode 100644 index 00000000000..a00fb804631 --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,135 @@ + + + + estate.property.view.search + estate.property + + + + + + + + + + + + + + + + + estate.property.view.list + estate.property + + + + + + + + + + + + + + + + + + estate.property.view.kanban + estate.property + + + + +
+ +
Expected price: + +
+
Best Price: + +
+
Selling Price: + +
+ +
+
+
+
+
+
+ + + estate.property.view.form + estate.property + +
+
+
+ +

+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + Property + estate.property + list,form,kanban + { + 'search_default_available': 1, + } + + +
diff --git a/estate/views/res_user_views.xml b/estate/views/res_user_views.xml new file mode 100644 index 00000000000..d1f5c3c2f23 --- /dev/null +++ b/estate/views/res_user_views.xml @@ -0,0 +1,15 @@ + + + + res.users.form.inherit.estate + res.users + + + + + + + + + + diff --git a/estate_account/__init__.py b/estate_account/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/estate_account/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/estate_account/__manifest__.py b/estate_account/__manifest__.py new file mode 100644 index 00000000000..6fcca4fd25f --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,6 @@ +{ + "name": "Real estate accounting", + "author": "Odoo", + "license": "LGPL-3", + "depends": ["estate", "account"], +} diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py new file mode 100644 index 00000000000..5e1963c9d2f --- /dev/null +++ b/estate_account/models/__init__.py @@ -0,0 +1 @@ +from . import estate_property diff --git a/estate_account/models/estate_property.py b/estate_account/models/estate_property.py new file mode 100644 index 00000000000..22aa332f08b --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,21 @@ +from odoo import Command, models +from odoo.exceptions import UserError + + +class EstateProperty(models.Model): + _inherit = "estate.property" + + def action_state_to_sold(self): + if not self.buyer_id: + raise UserError("Properties can only be sold if the buyer is filled in") + self.env["account.move"].create( + { + "move_type": "out_invoice", + "partner_id": self.buyer_id.id, + "invoice_line_ids": [ + Command.create({"name": "commission", "quantity": 1, "price_unit": self.selling_price * 0.06}), + Command.create({"name": "Administrative fee", "quantity": 1, "price_unit": 100}), + ], + } + ) + return super().action_state_to_sold()