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..8c7af4fb8d8 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,21 @@ +{ + 'name': "Sevan Estate", + 'license': 'LGPL-3', + 'depends': ['base'], + 'summary': "Just a simple estate app for Sevan.", + 'application': True, + 'installable': True, + + 'data': [ + 'security/ir.model.access.csv', + + # Be careful with the order! + 'views/estate_property_views.xml', + 'views/estate_property_offer_views.xml', + 'views/estate_property_type_views.xml', + 'views/estate_property_tag_views.xml', + 'views/res_users_views.xml', + 'views/estate_menus.xml', + ], + 'author': "Sevan Corp.", +} 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..66ef822b20b --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,118 @@ +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 Property(models.Model): + # Model definition + _name = "estate.property" + _description = "Estate Property" + _order = "id desc" + _check_expected_price = models.Constraint( + "CHECK(expected_price > 0)", + "The expected price should be strictly positive.", + ) + + # Fields + name = fields.Char("Title", required=True) + description = fields.Text() + postcode = fields.Char() + date_availability = fields.Date( + "Available From", + default=datetime.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( + string="Orientation", + selection=[ + ("north", "North"), + ("south", "South"), + ("east", "East"), + ("west", "West"), + ], + ) + total_area = fields.Integer("Total Area (sqm)", compute="_compute_total_area") + property_type_id = fields.Many2one("estate.property.type", string="Property Type") + buyer_id = fields.Many2one("res.partner", copy=False) + salesman_id = fields.Many2one("res.users", default=lambda self: self.env.user) + tag_ids = fields.Many2many("estate.property.tag", string="Tags") + offer_ids = fields.One2many("estate.property.offer", "property_id") + best_price = fields.Float(compute="_compute_best_price") + active = fields.Boolean(default=True) + state = fields.Selection( + selection=[ + ("new", "New"), + ("offer_received", "Offer Received"), + ("offer_accepted", "Offer Accepted"), + ("sold", "Sold"), + ("cancelled", "Cancelled"), + ], + required=True, + copy=False, + default="new", + store=True, + compute="_compute_state", + ) + + @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: + record.best_price = max(record.offer_ids.mapped("price")) if record.offer_ids else 0.0 + + @api.depends("offer_ids") + def _compute_state(self): + for record in self: + if (record.state == "new" and len(record.offer_ids) > 0): + record.state = "offer_received" + + @api.constrains("expected_price", "selling_price") + def _check_selling_price_to_expected_price_ratio(self): + for record in self: + if (not float_is_zero(record.selling_price, precision_digits=0) and + float_compare(record.selling_price, record.expected_price * 0.9, precision_digits=0) == -1): + raise ValidationError("The selling price should be at least 90% of the expected price.") + + @api.onchange("garden") + def _onchange_garden_values(self): + self.garden_area = 10 if self.garden else 0 + self.garden_orientation = "north" if self.garden else None + + @api.ondelete(at_uninstall=False) + def _unlink(self): + for record in self: + if record.state in ("new", "cancelled"): + raise UserError("New or cancelled properties cannot be deleted.") + return super().unlink() + + def action_cancel_property(self): + if self.state == "sold": + raise UserError("Sold properties cannot be cancelled.") + return self.write({ + "state": "cancelled", + }) + + def action_sold_property(self): + if self.state == "cancelled": + raise UserError("Cancelled properties cannot be sold.") + return self.write({ + "state": "sold", + }) diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..362b12a33bb --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,78 @@ +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 + + +class PropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "Estate Property Offer" + _order = "price desc" + _check_price = models.Constraint( + 'CHECK(price > 0.00)', + "The offer's amount should be strictly positive.", + ) + + price = fields.Float() + status = fields.Selection( + selection=[ + ("accepted", "Accepted"), + ("refused", "Refused"), + ], + copy=False, + ) + partner_id = fields.Many2one( + "res.partner", + string="Partner", + required=True, + ) + property_id = fields.Many2one( + "estate.property", + string="Property", + required=True, + ) + validity = fields.Integer("Validity (days)", default=7) + date_deadline = fields.Date("Deadline", compute="_compute_deadline", inverse="_inverse_deadline") + property_type_id = fields.Many2one("estate.property.type", store=True, related="property_id.property_type_id") + + @api.depends("validity") + def _compute_deadline(self): + for record in self: + date = datetime.today() if not record.create_date else record.create_date.date() + record.date_deadline = date + relativedelta(days=record.validity) + + def _inverse_deadline(self): + for record in self: + date = datetime.today() if not record.create_date else record.create_date.date() + record.validity = (record.date_deadline - date).days + + @api.model + def create(self, vals_list): + for vals_dict in vals_list: + property_id = self.env["estate.property"].browse(vals_dict["property_id"]) + offers = property_id.offer_ids + if (len(offers) > 0 and vals_dict["price"] < min(offers.mapped("price"))): + raise UserError("Offer with a lower value than an existing offer cannot be created.") + return super().create(vals_list) + + def action_accept_offer(self): + for record in self: + if "accepted" in record.mapped("property_id.offer_ids.status"): + raise UserError("Only one offer can be accepted") + if (record.property_id.garden_orientation == "south" and + float_compare(record.property_id.expected_price, record.price, 2) == 1): + raise ValidationError("For properties with South oriented gardens, only offers having a price higher than the expected value of the property can be accepted") + return self.status.write({ + "status": "accepted", + }) and self.property_id.write({ + "state": "offer_accepted", + "selling_price": record.price, + "buyer_id": record.partner_id, + }) + + def action_refuse_offer(self): + return self.write({ + "status": "refused", + }) diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..56908e57a26 --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,15 @@ +from odoo import fields, models + + +class PropertyTag(models.Model): + _name = "estate.property.tag" + _description = "Estate Property Tag" + _order = "name" + + name = fields.Char(required=True) + color = fields.Integer() + + _check_tag_name_unique = models.Constraint( + 'UNIQUE(name)', + 'This tag already exists.', + ) diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py new file mode 100644 index 00000000000..2ad9665851e --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,18 @@ +from odoo import api, fields, models + + +class PropertyType(models.Model): + _name = "estate.property.type" + _description = "Estate Property Type" + _order = "sequence, name" + + name = fields.Char(required=True) + property_ids = fields.One2many("estate.property", "property_type_id") + sequence = fields.Integer("Sequence", default=1) + offer_ids = fields.One2many("estate.property.offer", "property_type_id") + offer_count = fields.Integer(compute="_compute_offer_count") + + @api.depends("offer_ids") + 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..265d5b5729a --- /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( + "estate.property", + "salesman_id", + domain=[("state", "not in", ["sold", "cancelled"])] + ) diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..ff68a56c044 --- /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 +estate.access_estate_property,access_estate_property,estate.model_estate_property,base.group_user,1,1,1,0 +estate.access_estate_property_type,access_estate_property_type,estate.model_estate_property_type,base.group_user,1,1,1,0 +estate.access_estate_property_tag,access_estate_property_tag,estate.model_estate_property_tag,base.group_user,1,1,1,0 +estate.access_estate_property_offer,access_estate_property_offer,estate.model_estate_property_offer,base.group_user,1,1,1,0 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..efb1b93f5c8 --- /dev/null +++ b/estate/tests/test_estate_property.py @@ -0,0 +1,44 @@ +from odoo import Command +from odoo.exceptions import ValidationError +from odoo.tests import TransactionCase + + +class TestEstateProperty(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.estate = cls.env['estate.property'].create({ + 'name': 'Super test estate', + 'expected_price': 100000.0, + 'state': 'new', + }) + cls.test_partner = cls.env['res.partner'].create({ + 'name': 'Maman ours', + }) + + def test_estate_best_price(self): + ''' + Ensure best price is correctly updated when an offer is received. + ''' + self.assertEqual(self.estate.best_price, 0.0) + self.estate.offer_ids = [Command.create({ + 'price': 125000.0, + 'partner_id': self.test_partner.id, + })] + self.assertEqual(self.estate.best_price, 125000.0) + + def test_accept_offer_south_facing_garden(self): + ''' + Ensure offers for estates with south-facing gardens can only be accepted if above expected + price. + ''' + self.estate.garden = True + self.estate.garden_orientation = 'south' + self.estate.expected_price = 500000 + self.estate.offer_ids = [Command.create({ + 'price': 475000.0, + 'partner_id': self.test_partner.id, + })] + with self.assertRaises(ValidationError): + self.estate.offer_ids.action_accept_offer() diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..8b1d9acbdeb --- /dev/null +++ b/estate/views/estate_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..efc6a63e006 --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,46 @@ + + + + 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..f18b2da7cc3 --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,146 @@ + + + + estate.property.view.list + estate.property + + + + + + + + + + + + + + + + + estate.property.view.form + estate.property + +
+
+
+ + +

+ +

+
+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + estate.property.view.search + estate.property + + + + + + + + + + + + + + + + + estate.property.view.kanban + estate.property + + + + +
+ + + + +
+ Expected price: + +
+
+ Best Offer: + +
+
+ Selling Price: + +
+
+
+
+
+
+
+ + + Properties + estate.property + list,form,kanban + {'search_default_state': 1} + +
diff --git a/estate/views/res_users_views.xml b/estate/views/res_users_views.xml new file mode 100644 index 00000000000..84b69f7a03b --- /dev/null +++ b/estate/views/res_users_views.xml @@ -0,0 +1,15 @@ + + + + res.users.form.estate.extention + 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..7b2b1f69d61 --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,9 @@ +{ + 'name': "Estate Account", + 'license': 'LGPL-3', + 'depends': ['estate', 'account'], + 'summary': "Link module to be able to create invoices for sold estate properties.", + 'application': True, + 'installable': True, + 'author': "Sevan Corp.", +} diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py new file mode 100644 index 00000000000..02b688798a3 --- /dev/null +++ b/estate_account/models/__init__.py @@ -0,0 +1 @@ +from . import estate_account diff --git a/estate_account/models/estate_account.py b/estate_account/models/estate_account.py new file mode 100644 index 00000000000..eebb6c9186e --- /dev/null +++ b/estate_account/models/estate_account.py @@ -0,0 +1,29 @@ +from odoo import Command, models +from odoo.tools.float_utils import float_round + + +class EstateAccount(models.Model): + _inherit = "estate.property" + + def action_sold_property(self): + invoice_vals_list = [] + for sold_property in self: + invoice_vals = { + 'move_type': 'out_invoice', + 'partner_id': sold_property.buyer_id.id, + 'line_ids': [ + Command.create({ + 'name': "6% of the selling price", + 'quantity': 1, + 'price_unit': float_round(sold_property.selling_price * 0.06, precision_digits=2) + }), + Command.create({ + 'name': "Administrative fees", + 'quantity': 1, + 'price_unit': 100.00 + }), + ] + } + invoice_vals_list.append(invoice_vals) + self.env['account.move'].create(invoice_vals_list) + return super().action_sold_property()