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..fe0832dcd7c --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,20 @@ +{ + "name": "Estate", + "version": "1.0", + "summary": "Estate Management", + "depends": ["base"], + "application": True, + "installable": True, + "data": [ + 'security/ir.model.access.csv', + + 'views/estate_property_views.xml', + 'views/estate_property_tag_views.xml', + 'views/estate_property_offer_views.xml', + 'views/estate_property_type_views.xml', + 'views/res_users_views.xml', + 'views/estate_menus.xml', + ], + "author": "Arturo Yepez", + "license": 'LGPL-3', +} diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..9a2189b6382 --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1,5 @@ +from . import estate_property +from . import estate_property_type +from . import estate_property_tag +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..66e0a362c32 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,149 @@ +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 + + +class EstateProperty(models.Model): + _name = "estate.property" + _description = "Estate Property Management" + _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 _: fields.Date.today() + relativedelta(months=3), + ) + 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() + garden_orientation = fields.Selection( + string="Garden Orientation", + selection=[ + ("north", "North"), + ("south", "South"), + ("east", "East"), + ("west", "West"), + ], + ) + state = fields.Selection( + string="Status", + selection=[ + ("new", "New"), + ("offer_received", "Offer Received"), + ("offer_accepted", "Offer Accepted"), + ("sold", "Sold"), + ("cancelled", "Cancelled"), + ], + default="new", + copy=False, + ) + property_type_id = fields.Many2one( + string="Property Type", + comodel_name="estate.property.type", + ) + partner_id = fields.Many2one( + string="Buyer", + comodel_name="res.partner", + copy=False, + readonly=True, + ) + user_id = fields.Many2one( + string="Salesman", + comodel_name="res.users", + default=lambda self: self.env.user, + ) + tag_ids = fields.Many2many( + string="Property Tags", + comodel_name="estate.property.tag", + ) + offer_ids = fields.One2many( + string="Offers", + comodel_name="estate.property.offer", + inverse_name="property_id", + ) + total_area = fields.Integer( + string="Total Area (sqm)", + compute="_compute_total_area", + readonly=True, + ) + best_price = fields.Float( + string="Best Offer", + compute="_compute_best_price", + readonly=True, + ) + active = fields.Boolean(default=True) + + _check_positive_expected_price = models.Constraint( + 'CHECK(expected_price > 0)', + 'Expected price must be a positive amount.', + ) + _check_positive_selling_price = models.Constraint( + 'CHECK(selling_price >= 0)', + 'Selling price must be a positive amount.', + ) + + # Methods + @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_price(self): + for record in self: + if record.offer_ids: + record.best_price = max(record.offer_ids.mapped("price")) + else: + record.best_price = 0.0 + + @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 = False + + @api.constrains("selling_price") + def _check_selling_price(self): + for record in self: + if record.selling_price and float_compare(record.selling_price, 0.9 * record.expected_price, precision_digits=2) < 0: + raise ValidationError(_("Selling price cannot be lower than 90% of the expected price.")) + + @api.ondelete(at_uninstall=False) + def _unlink_if_not_new_or_cancelled(self): + for record in self: + if record.state not in ("new", "cancelled"): + raise ValidationError(_("Only new or cancelled properties can be deleted.")) + + def action_sold(self): + self.ensure_one() + + if not self.offer_ids: + raise ValidationError(_("Properties without offers can't be sold")) + + if self.state == "cancelled": + raise UserError(_("Cancelled properties cannot be sold.")) + + if self.state != "offer_accepted": + raise UserError(_("Only accepted offers can be sold.")) + + self.state = "sold" + + def action_cancel(self): + self.ensure_one() + + if self.state == "sold": + raise UserError(_("Sold properties cannot be cancelled.")) + + self.state = "cancelled" diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..a85168269fb --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,86 @@ +from dateutil.relativedelta import relativedelta + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class EstatePropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "Estate Property Offer" + _order = "price desc" + + price = fields.Float(string="Offer Price", required=True) + validity = fields.Integer(string="Validity (days)", default=7) + date_deadline = fields.Date( + string="Deadline", + compute="_compute_date_deadline", + inverse="_inverse_date_deadline", + ) + status = fields.Selection( + string="Status", + selection=[ + ("accepted", "Accepted"), + ("refused", "Refused"), + ], + copy=False, + ) + partner_id = fields.Many2one( + string="Buyer", + comodel_name="res.partner", + required=True, + ) + property_id = fields.Many2one( + string="Property", + comodel_name="estate.property", + required=True, + ) + property_type_id = fields.Many2one( + string="Property Type", + related="property_id.property_type_id", + store=True, + ) + + _check_positive_price = models.Constraint( + 'CHECK(price > 0)', + 'Offer prices must be a positive amount.', + ) + + # Methods + @api.depends("validity") + def _compute_date_deadline(self): + for record in self: + record.date_deadline = fields.Date.today() + relativedelta(days=record.validity) + + def _inverse_date_deadline(self): + for record in self: + record.validity = (record.date_deadline - fields.Date.today()).days + + @api.model + def create(self, vals_list): + for vals in vals_list: + property = self.env["estate.property"].browse(vals.get("property_id")) + + if property and property.state in ("sold", "offer_accepted"): + raise ValidationError(_("Can't create an offer for properties that are sold or with an accepted offer")) + + property.state = "offer_received" + + return super().create(vals_list) + + def action_accept(self): + for record in self: + record.status = "accepted" + record.property_id.state = "offer_accepted" + record.property_id.partner_id = record.partner_id + record.property_id.selling_price = record.price + + # Refuse other offers for the same property + other_offers = self.search([ + ("property_id", "=", record.property_id.id), + ("id", "!=", record.id), + ]) + other_offers.write({"status": "refused"}) + + def action_refuse(self): + for record in self: + record.status = "refused" diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..654e30df9f3 --- /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(string="Tag Name", required=True) + color = fields.Integer(string="Color Index") + + _unique_name = models.Constraint( + 'UNIQUE(name)', + 'Tag name 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..ae5d7c20e4e --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,29 @@ +from odoo import fields, models + + +class EstatePropertyType(models.Model): + _name = "estate.property.type" + _description = "Estate Property Type" + _order = "sequence, name" + + name = fields.Char(string="Property Type", required=True) + sequence = fields.Integer('Sequence', default=1, help="Used to order types. Lower is better.") + property_ids = fields.One2many( + string="Properties", + comodel_name="estate.property", + inverse_name="property_type_id", + ) + offer_ids = fields.One2many( + string="Offers", + comodel_name="estate.property.offer", + inverse_name="property_type_id", + ) + offer_count = fields.Integer( + string="Number of Offers", + compute="_compute_offer_count", + ) + + # Methods + def _compute_offer_count(self): + for record in self: + record.offer_count = len(record.offer_ids) diff --git a/estate/models/res_users.py b/estate/models/res_users.py new file mode 100644 index 00000000000..1569524fdaa --- /dev/null +++ b/estate/models/res_users.py @@ -0,0 +1,11 @@ +from odoo import fields, models + + +class ResUsers(models.Model): + _inherit = "res.users" + + property_ids = fields.One2many( + string="Real Estate Properties", + comodel_name="estate.property", + inverse_name="user_id", + ) diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..64542da710d --- /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,estate.model_estate_property,base.group_user,1,1,1,1 +access_estate_property_type,access_estate_property_type,estate.model_estate_property_type,base.group_user,1,1,1,1 +access_estate_property_tag,access_estate_property_tag,estate.model_estate_property_tag,base.group_user,1,1,1,1 +access_estate_property_offer,access_estate_property_offer,estate.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..c09a70d5011 --- /dev/null +++ b/estate/tests/__init__.py @@ -0,0 +1,4 @@ + +from . import common +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..55145fe82cd --- /dev/null +++ b/estate/tests/common.py @@ -0,0 +1,27 @@ +from odoo.addons.base.tests.common import BaseCommon + + +class EstatePropertyCommon(BaseCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.estate_property_types = cls.env["estate.property.type"].create([ + {"name": "House"}, + {"name": "Apartment"}, + ]) + cls.estate_property_tags = cls.env["estate.property.tag"].create([ + {"name": "tag1"}, + {"name": "tag2"}, + ]) + cls.estate_properties = cls.env["estate.property"].create([ + { + "name": "Property 1", + "expected_price": 100.0, + }, + { + "name": "Property 2", + "expected_price": 150.0, + }, + ]) diff --git a/estate/tests/test_estate_property.py b/estate/tests/test_estate_property.py new file mode 100644 index 00000000000..87d845d0e52 --- /dev/null +++ b/estate/tests/test_estate_property.py @@ -0,0 +1,31 @@ +from odoo.tests import Form, tagged + +from odoo.addons.estate.tests.common import EstatePropertyCommon + + +@tagged("post_install", "-at_install") +class EstatePropertyGardenCase(EstatePropertyCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + main_property = cls.estate_properties.search([("name", "=", "Property 1")]) + main_property.living_area = 90 + main_property.garden = True + main_property.garden_area = 100 + main_property.garden_orientation = "south" + + def test_garden_deactivation(self): + """Test that when the Garden property is deactivated we get to see the change and deletion of previous values""" + main_property = self.estate_properties.search([("name", "=", "Property 1")]) + with Form(main_property) as property: + self.assertEqual(100, property.garden_area) + self.assertEqual("south", property.garden_orientation) + self.assertEqual(190, property.total_area) + + property.garden = False + + self.assertEqual(0, property.garden_area) + self.assertEqual(False, property.garden_orientation) + self.assertEqual(90, property.total_area) diff --git a/estate/tests/test_estate_property_offer.py b/estate/tests/test_estate_property_offer.py new file mode 100644 index 00000000000..902b06cc335 --- /dev/null +++ b/estate/tests/test_estate_property_offer.py @@ -0,0 +1,55 @@ +from odoo.exceptions import ValidationError +from odoo.tests import tagged + +from odoo.addons.estate.tests.common import EstatePropertyCommon + + +@tagged("post_install", "-at_install") +class EstatePropertyOfferCase(EstatePropertyCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + main_property = cls.estate_properties.search([("name", "=", "Property 1")]) + cls.estate_property_offers = cls.env["estate.property.offer"].create([ + { + "price": 95.0, + "partner_id": cls.partner.id, + "property_id": main_property.id, + }, + ]) + main_property.state = "offer_received" + + def _sell_main_property(cls): + """Sells the main property with a current offer""" + main_property = cls.estate_properties.search([("name", "=", "Property 1")]) + + if main_property and main_property.offer_ids: + main_property.offer_ids[0].action_accept() + + main_property.action_sold() + + def test_create_offer_sold_property(self): + """Test that you can't create an offer for a sold property""" + self._sell_main_property() + + with self.assertRaises(ValidationError): + self.env["estate.property.offer"].create([ + { + "price": 97.0, + "partner_id": self.partner.id, + "property_id": self.estate_properties.search([("name", "=", "Property 1")]).id, + }, + ]) + + def test_sell_property_without_offer(self): + """Test that you can't sell properties that do not have any offer""" + main_property = self.estate_properties.search([("name", "=", "Property 1")]) + + for offer in self.estate_property_offers: + if offer.property_id.id == main_property.id: + offer.unlink() + + with self.assertRaises(ValidationError): + self._sell_main_property() diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..a68098a85db --- /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..3fec5474b90 --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,51 @@ + + + + Estate Offers + estate.property.offer + list,form + + [('property_type_id', '=', active_id)] + + + + + estate.property.offer.view.list + estate.property.offer + + + + + + + + + + + + +
+

+
+ + + + + + + + + + + + + + +
+
+
diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml new file mode 100644 index 00000000000..59ce50dce3a --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,156 @@ + + + + Estate Properties + estate.property + list,kanban,form + + {'search_default_available_properties': True} + + + + + estate.property.view.list + estate.property + + + + + + + + + + + + + + + + + + estate.property.view.form + estate.property + +
+
+
+ +
+

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