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..c6277d7faa9 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,22 @@ +{ + 'name': 'estate', + 'depends': [ + 'base' + ], + 'installable': True, + 'application': True, + 'author': 'vibad', + 'license': 'LGPL-3', + 'version': '1.0', + 'data': [ + 'security/ir.model.access.csv', + 'view/estate_property_offer_views.xml', + 'view/estate_property_views.xml', + 'view/estate_property_type_views.xml', + 'view/estate_property_tag_views.xml', + 'view/estate_inherit_view.xml', + 'view/estate_action.xml', + ], + + +} diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..c0917a3d550 --- /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 inherited_model diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..98cb8c1302b --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,115 @@ +from odoo import api, fields, models +from odoo.exceptions import UserError, ValidationError + + +class Estate_property(models.Model): + _name = "estate.property" + _description = "APP super mega trop bien" + _order = "id desc" + + name = fields.Char(required=True) + description = fields.Text() + postcode = fields.Char() + date_availability = fields.Date(copy=False, default=lambda self: fields.Date.add(fields.Date.today(), 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() + facades = fields.Integer() + garage = fields.Boolean() + garden = fields.Boolean() + garden_area = fields.Integer() + garden_orientation = fields.Selection( + string="Orientation", + selection=[("north", "North"), ("south", "South"), ("east", "East"), ("west", "West")], + help="The garden orientation", + ) + active = fields.Boolean(default=True) + state = fields.Selection( + string="Status", + selection=[("new", "New"), ("offer_received", "Offer Received"), ("offer_accepted", "Offer Accepted"), ("sold", "Sold"), ("cancelled", "Cancelled")], + required=True, + copy=False, + default="new", + compute="_compute_state", + store=True, + ) + property_type_id = fields.Many2one("estate.property.type", string="Property Type") + salesperson_id = fields.Many2one("res.users", string="Salesperson", default=lambda self: self.env.user) + buyer_id = fields.Many2one("res.partner", string="Buyer", copy=False) + tag_ids = fields.Many2many("estate.property.tag", string="Tags") + offer_ids = fields.One2many("estate.property.offer", "property_id", string="Offers") + total_area = fields.Integer(compute="_compute_area") + best_price = fields.Float(compute="_compute_best_price") + + _check_expected_price = models.Constraint( + "CHECK(expected_price > 0)", + message="The expected price must be strictly positive", + ) + + _check_selling_price = models.Constraint( + "CHECK(selling_price >= 0)", + message="The selling price cannot be negative", + ) + + @api.depends("living_area", "garden_area") + def _compute_area(self): + for record in self: + record.total_area = record.living_area + record.garden_area + + @api.depends("offer_ids.price") + 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 + + @api.onchange("garden") + def _onchange_garden(self): + for record in self: + if record.garden: + record.garden_area = 10 + record.garden_orientation = "north" + else: + record.garden_area = 0 + record.garden_orientation = False + + @api.depends("offer_ids", "offer_ids.state") + def _compute_state(self): + for record in self: + if record.state in ["sold", "cancelled"]: + return + if record.offer_ids: + for offer in record.offer_ids: + if offer.state == "accepted": + record.state = "offer_accepted" + break + else: + record.state = "new" + + def action_sold(self): + for record in self: + if record.state != "cancelled" and record.state != "sold": + record.state = "sold" + else: + raise UserError("A property that is cancelled or already sold cannot be sold.") + + def action_cancel(self): + for record in self: + if record.state != "sold" and record.state != "cancelled": + record.state = "cancelled" + else: + raise UserError("A property that is sold or already cancelled cannot be cancelled.") + + @api.constrains("selling_price", "expected_price") + def _check_enough_selling_price(self): + for record in self: + if record.selling_price and record.selling_price < record.expected_price * 0.9: + raise ValidationError("The selling price cannot be less than 90% of the expected price.") + + @api.ondelete(at_uninstall=False) + def _ondelete_cancel_new(self): + for record in self: + if record.state not in ["new", "cancelled"]: + raise UserError("You can only delete offers that are new or cancelled.") diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..e156550797b --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,78 @@ +from odoo import api, fields, models +from odoo.exceptions import UserError + + +class Estate_property_offer(models.Model): + _name = "estate.property.offer" + _description = "Offer for estate properties" + _order = "price desc" + + price = fields.Float(required=True) + partner_id = fields.Many2one("res.partner", string="Partner", required=True) + property_id = fields.Many2one("estate.property", string="Property", required=True) + state = fields.Selection([ + ("new", "New"), + ("accepted", "Accepted"), + ("refused", "Refused"), + ], default="new", string="State", copy=False) + validaty = fields.Integer(string="Offer Validity (days)", default=7) + date_deadline = fields.Date(string="Offer Deadline", compute="_compute_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)", + message="The price must be strictly positive", + ) + + @api.depends("validaty", "create_date") + def _compute_date_deadline(self): + for record in self: + date = record.create_date + if not date: + date = fields.Date.today() + if record.validaty: + record.date_deadline = fields.Date.add(date, days=record.validaty) + else: + record.date_deadline = False + + def _inverse_date_deadline(self): + for record in self: + if record.date_deadline and record.create_date: + create_date = fields.Date.to_date(record.create_date) + record.validaty = (record.date_deadline - create_date).days + else: + record.validaty = 0 + + def accept_offer(self): + for record in self: + if record.state == "accepted" or record.state == "refused": + raise UserError("This offer has already been accepted or refused.") + for offer in record.property_id.offer_ids: + if offer.state == "accepted": + raise UserError("Another offer has already been accepted for this property.") + if record.property_id.garden_orientation == "south" and record.price <= record.property_id.expected_price: + raise UserError("The price must be more than the expected price for properties with a south-facing garden.") + record.state = "accepted" + record.property_id.selling_price = record.price + record.property_id.buyer_id = record.partner_id + return True + + def refuse_offer(self): + for record in self: + if record.state != "new": + raise UserError("This offer has already been accepted or refused.") + record.state = "refused" + return True + + @api.model_create_multi + def create(self, vals_list): + max_price_list = 0 + for vals in vals_list: + if self.env["estate.property"].browse(vals["property_id"]).state == "new": + self.env["estate.property"].browse(vals["property_id"]).state = "offer_received" + max_price = max(self.env["estate.property.offer"].search([("property_id", "=", vals["property_id"])]).mapped("price") or [0]) + max_price_list = max(max_price_list, max_price) + if vals["price"] <= max_price: + raise UserError("The price must be higher than the current highest offer.") # Error for one offer blocks all offers in the list + max_price_list = max(max_price_list, vals["price"]) + return super().create(vals_list) diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..a1f44dc663d --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,15 @@ +from odoo import fields, models + + +class Estate_property_tag(models.Model): + _name = "estate.property.tag" + _description = "tag super mega trop bien" + _order = "name" + + name = fields.Char(required=True) + color = fields.Integer() + + _check_name = models.Constraint( + "UNIQUE(name)", + message="The name of the tag 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..94c555e6fd6 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,21 @@ +from odoo import fields, models + + +class Estate_property_type(models.Model): + _name = "estate.property.type" + _description = "APP super mega trop bien" + _order = "sequence, name" + _check_name = models.Constraint( + "UNIQUE(name)", + message="The name of the property type must be unique", + ) + + name = fields.Char(required=True) + sequence = fields.Integer() + offer_ids = fields.One2many("estate.property.offer", "property_type_id", string="Offers") + offer_count = fields.Integer(compute="_compute_offer_count") + property_ids = fields.One2many("estate.property", "property_type_id", string="Properties") + + def _compute_offer_count(self): + for record in self: + record.offer_count = len(record.offer_ids) diff --git a/estate/models/inherited_model.py b/estate/models/inherited_model.py new file mode 100644 index 00000000000..01da82ddf7c --- /dev/null +++ b/estate/models/inherited_model.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class Estate_users_model(models.Model): + _inherit = "res.users" + + property_ids = fields.One2many("estate.property", "salesperson_id", string=" Estate properties") diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..ac76fe94279 --- /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 +acces_estate_property,estate_property,model_estate_property,base.group_user,1,1,1,1 +acces_estate_property_type,estate_property_type,model_estate_property_type,base.group_user,1,1,1,0 +acces_estate_property_tag,estate_property_tag,model_estate_property_tag,base.group_user,1,1,1,0 +acces_estate_property_offer,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..18f3a50c3e1 --- /dev/null +++ b/estate/tests/__init__.py @@ -0,0 +1 @@ +from . import test_estate_property \ No newline at end of file diff --git a/estate/tests/test_estate_property.py b/estate/tests/test_estate_property.py new file mode 100644 index 00000000000..7202f8b67a0 --- /dev/null +++ b/estate/tests/test_estate_property.py @@ -0,0 +1,42 @@ +from odoo.exceptions import ValidationError +from odoo.tests import TransactionCase +from odoo import Command + + +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.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.accept_offer() diff --git a/estate/view/estate_action.xml b/estate/view/estate_action.xml new file mode 100644 index 00000000000..cea89a5c703 --- /dev/null +++ b/estate/view/estate_action.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/estate/view/estate_inherit_view.xml b/estate/view/estate_inherit_view.xml new file mode 100644 index 00000000000..0c0b5705924 --- /dev/null +++ b/estate/view/estate_inherit_view.xml @@ -0,0 +1,15 @@ + + + + estate.inherit.view + res.users + + + + + + + + + + diff --git a/estate/view/estate_property_offer_views.xml b/estate/view/estate_property_offer_views.xml new file mode 100644 index 00000000000..31affd5828e --- /dev/null +++ b/estate/view/estate_property_offer_views.xml @@ -0,0 +1,40 @@ + + + + Offers + estate.property.offer + list,form + [('property_type_id', '=', active_id)] + + + + estate.property.offer.form + estate.property.offer + +
+ + + + + + + + + +
+
+
+ + + estate.property.offer.list + estate.property.offer + + + + + +

+ +

+ + + + + + + + + + + + + +
+
+ + + estate.property.type.list + estate.property.type + + + + + + + +
diff --git a/estate/view/estate_property_views.xml b/estate/view/estate_property_views.xml new file mode 100644 index 00000000000..12e4afa65ef --- /dev/null +++ b/estate/view/estate_property_views.xml @@ -0,0 +1,132 @@ + + + + Properties + estate.property + list,form,kanban + {'search_default_available': True} + +s + + estate.properties.list + estate.property + + + + + + + + + + + + + + + + + estate.properties.form + estate.property + +
+
+
+ +

+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + estate.properties.search + estate.property + + + + + + + + + + + + + + + + + + estate.properties.kanban + estate.property + + + + + + + +
+ +
+
+ Expected price: +
+
+ Best Offer: +
+ +
+
+
+
+
+
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..dbcc7393541 --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,13 @@ +{ + 'name': 'estate_account', + 'depends': [ + 'base', + 'estate', + 'account', + ], + 'installable': True, + 'application': True, + 'author': 'vibad', + 'license': 'LGPL-3', + 'version': '1.0', +} 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..f150b6396f5 --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,25 @@ +from odoo import Command, models + + +class Estate_account_model(models.Model): + _inherit = "estate.property" + + def action_sold(self): + invoice = self.env["account.move"].create({ + "move_type": "out_invoice", + "partner_id": self.buyer_id.id, + "invoice_line_ids": [ + Command.create({ + "name": self.name + " - Commission", + "quantity": 1, + "price_unit": self.selling_price * 0.06, # 6% of the selling price as commission + }), + Command.create({ + "name": self.name + " - Admin fee", + "quantity": 1, + "price_unit": 100, + }), + ], + }) + invoice.action_post() + return super().action_sold()