diff --git a/estate/__init__.py b/estate/__init__.py new file mode 100644 index 00000000000..9a7e03eded3 --- /dev/null +++ b/estate/__init__.py @@ -0,0 +1 @@ +from . import models \ No newline at end of file diff --git a/estate/__manifest__.py b/estate/__manifest__.py new file mode 100644 index 00000000000..4566199eee8 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,22 @@ +{ + 'name': "Real Estate", + 'application': True, + 'installable': True, + 'category': "Real Estate/Brokerage", + 'version': '1.0', + 'summary': "The Real Estate Advertisement", + "depends": [ + "base", + ], + '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/res_users_views.xml', + 'views/estate_menus.xml', + ], + 'author': "rencelotm", + 'license': "AGPL-3" +} \ No newline at end of file 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..ae67c47e1c4 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,218 @@ +import datetime +from odoo import api, fields, models, exceptions +from odoo.tools.float_utils import float_compare, float_is_zero + +class EstateProperty(models.Model): + _name = "estate.estate.property" + _description = "Real Estate Property Module Tutorial" + _order = "id desc" + + ## SQL Constraints Section ## + + _check_expected_price = models.Constraint( + 'CHECK(expected_price > 0)', + 'The expected price should be a positive number' + ) + _check_selling_price = models.Constraint( + 'CHECK(selling_price >= 0)', + 'The selling price should be a positive number' + ) + + name = fields.Char( + "Property Name", + required=True, + help="Enter the name of the property" + ) + active = fields.Boolean(default=True) + description = fields.Text( + "Property Description", + help="Enter a quick description of the characteristic of the property" + ) + postcode = fields.Char( + "Postcode", + help="Enter the postcode of the property" + ) + date_availability = fields.Date( + "Date Availability", + copy=False, + help="Enter the date at which the property is available. By default set to 3 months", + default=lambda _: fields.Date.today() + datetime.timedelta(weeks=12) # Equivalent to 3 months + ) + expected_price = fields.Float( + "Expected Price", + required=True, + help="The expected price for the property." + ) + selling_price = fields.Float( + "Selling Price", + readonly=True, + copy=False, + default=0.0, + ) + bedrooms = fields.Integer( + "Nb Bedrooms", + default=2, + help="The number of bedrooms that the property has. By default set to 2." + ) + living_area = fields.Integer( + "Living Area", + help="The number of square meters the living area has." + ) + facades = fields.Integer( + "Nb Facades", + help="The number of facades the property has. Cannot be more that four." + ) + garage = fields.Boolean( + "Garage", + help="Is the property has a garage?" + ) + garden = fields.Boolean( + "Garden", + help="Is the property has a garden?" + ) + garden_area = fields.Integer( + "Nb Garden Area", + help="Enter the number of square meters the garden has. Only if the property has a garden" + ) + garden_orientation = fields.Selection( + string="Orientation", + selection = [ + ("north", "North"), + ("south", "South"), + ("east", "East"), + ("west", "West"), + ], + help="Choose the orientation of the garden" + ) + state = fields.Selection( + string="State", + selection = [ + ("new", "New"), + ("offer_received", "Offer Received"), + ("offer_accepted", "Offer Accepted"), + ("sold", "Sold"), + ("cancelled", "Cancelled"), + ], + required = True, + copy = False, + default="new", + ) + # Property Type ID + property_type_id = fields.Many2one( + "estate.property.type", + string="Property Type", + help="The type of the property (House, Loft, Apartment, etc.)" + ) + # Buyer and Salesperson + salesperson = fields.Many2one( + "res.users", + string="Salesperson", + index=True, + default=lambda self: self.env.user, + help="Name of the salesperson" + ) # Internal entity + buyer = fields.Many2one( + "res.partner", + string="Buyer", + index=True, + copy=False, + help="Name of the potential buyer for the property" + ) # External entity + + # Tags as Many2many + tags_ids = fields.Many2many( + "estate.property.tag", + string="Tags" + ) + + # Offers as One2many + offer_ids = fields.One2many( + "estate.property.offer", + "property_id", + string="Offers", + ) + total_area = fields.Float( + compute="_compute_total_area", + string="Total Area (sqm)", + help="Total area of the property" + ) + best_offer = fields.Float( + compute="_compute_best_offer", + string="Best Offer", + help="The best offer proposed so far" + ) + + ## API Constraints Section ## + + @api.constrains("selling_price", "expected_price") + def _check_selling_price(self): + for record in self: + if not float_is_zero(record.selling_price, 2) and float_compare(record.selling_price, record.expected_price * 0.9, 4) < 0 : + raise exceptions.ValidationError("The selling price cannot be less than 90% of the expected price!") + + ## Method Section ## + + def sold_property_action(self): + + if not self.env.user.has_group('estate.group_system'): + exceptions.UserError("You do not have permission to perform this action!") + + for record in self: + if record.state != "cancelled": + record.state = "sold" + else: + raise exceptions.UserError("A cancelled property cannot be sold!") + return True + + def cancel_property_action(self): + for record in self: + if record.state != "sold": + record.state = "cancelled" + else: + raise exceptions.UserError("A sold property cannot be cancelled!") + return True + + ## CRUD Methods ## + @api.ondelete(at_uninstall=True) + def property_delete_checker(self): + for record in self: + if record.state not in ['new', 'cancelled']: + raise exceptions.UserError("Cannot delete the property except if it's 'new' or 'cancelled' one!") + return True + + + @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_offer(self): + for record in self: + if record.offer_ids: + record.best_offer = max(record.offer_ids.mapped('price')) + else: + record.best_offer = 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 = "" + + @api.onchange("offer_ids") + def _onchange_offers(self): + if self.offer_ids and self.state == "new": + self.state = "offer_received" + if len(self.offer_ids) == 0: + self.state = "new" + + @api.onchange("state") + def _onchange_state(self): + if self.state == "cancelled" or self.state == "sold": + self.active = False + else: + self.active = True diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..995e58230a5 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,83 @@ +import datetime +from odoo import api, fields, models, exceptions + + +class EstatePropertyOffer(models.Model): + _name = 'estate.property.offer' + _description = "Define an offer on an property" + _order = "price desc" + price = fields.Float() + status = fields.Selection( + [ + ('accepted', 'Accepted'), + ('refused', 'Refused'), + ], + copy=False, + readonly=True + ) + validity = fields.Integer(default=7, string="Validity (days)") + date_deadline = fields.Date(compute="_compute_deadline", inverse="_inverse_deadline", string="Deadline") + partner_id = fields.Many2one("res.partner", required=True) + property_id = fields.Many2one("estate.estate.property", required=True) + property_state = fields.Selection(related="property_id.state", readonly=True) + property_type_ids = fields.Many2one(related="property_id.property_type_id", readonly=True, store=True) + + ## SQL Constraints Section ## + + _check_price = models.Constraint( + 'CHECK(price > 0)', + 'The offer price cannot be less than 0' + ) + + ## Methods Section ## + + def accept_offer_action(self): + for record in self: + if record.property_id.state == "cancelled" or record.property_id.state == "sold": + raise exceptions.UserError("An offer on a sold or cancelled property cannot be accepted!") + if record.status: + raise exceptions.UserError("Cannot change the status of an already statued offer!") + for offer in record.property_id.offer_ids: + if offer.status == "accepted": + raise exceptions.UserError("Cannot have more than 1 accepted offer!") + + record.status = "accepted" + record.property_id.buyer = record.partner_id + record.property_id.selling_price = record.price + record.property_id.state = "offer_accepted" + return True + + def refuse_offer_action(self): + for record in self: + if record.status: + raise exceptions.UserError("Cannot change the status of an already statued offer!") + else: + record.status = "refused" + return True + + @api.depends("create_date", "validity") + def _compute_deadline(self): + for record in self: + if not record.create_date: + record.date_deadline = fields.Date.today() + datetime.timedelta(days=record.validity) + else: + record.date_deadline = record.create_date + datetime.timedelta(days=record.validity) + + @api.depends("create_date", "date_deadline") + def _inverse_deadline(self): + for record in self: + if record.create_date and record.date_deadline: + record.validity = (record.date_deadline - record.create_date.date()).days + else: + record.validity = (record.date_deadline - fields.Date.today()).days + + ## CRUD Method ## + @api.model + def create(self, vals): + existing_offers = self.search([("property_id", "=", vals[0]["property_id"])]) + for offer in existing_offers: + if vals[0]["price"] <= offer.price: + raise exceptions.UserError("The offer price cannot be lower than price of another offer!") + property = self.env["estate.estate.property"].browse(vals[0]["property_id"]) + property.state = "offer_received" + return super().create(vals) diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..96121145a7d --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,16 @@ +from odoo import fields, models + +class EstatePropertyTag(models.Model): + _name = "estate.property.tag" + _description = "Tag for Estate Property" + _order = "name" + + ## SQL Constraints Section ## + _check_name = models.Constraint( + 'UNIQUE(name)', + 'A tag should be unique' + ) + + name = fields.Char(required=True) + property_ids = fields.One2many("estate.estate.property", "tags_ids") + color = fields.Integer() diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py new file mode 100644 index 00000000000..5382cb4ca5b --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,26 @@ +from odoo import api, fields, models + +class PropertyType(models.Model): + _name = "estate.property.type" + _description = "Types of Property" + _order = "name" + name = fields.Char(required=True) + sequence = fields.Integer('Sequence', default=1) + property_ids = fields.One2many("estate.estate.property", "property_type_id") + + # Stat button fields + offer_ids = fields.One2many("estate.property.offer", "property_type_ids") + offer_count = fields.Integer(compute="_compute_offer_count") + + ## Constraints Section ## + _check_name = models.Constraint( + 'UNIQUE(name)', + 'The type of a property should be unique' + ) + + ## Computed fields ## + @api.depends("offer_ids") + def _compute_offer_count(self): + for record in self: + record.offer_count = len(record.offer_ids) + return True diff --git a/estate/models/res_users.py b/estate/models/res_users.py new file mode 100644 index 00000000000..21ecd7fc698 --- /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.estate.property", + "salesperson", + string="Salesperson", + domain=[("state", "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..178b82d0fe0 --- /dev/null +++ b/estate/security/ir.model.access.csv @@ -0,0 +1,9 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_real_estate_property_user,access_real_estate_property_user,model_estate_estate_property,base.group_user,1,0,0,0 +access_real_estate_property_admin,access_real_estate_property_admin,model_estate_estate_property,base.group_system,1,1,1,1 +access_real_estate_property_type_user,access_real_estate_property_type_user,model_estate_property_type,base.group_user,1,0,0,0 +access_real_estate_property_type_admin,access_real_estate_property_type_admin,model_estate_property_type,base.group_system,1,1,1,1 +access_real_estate_property_tag_user,access_real_estate_property_tag_user,model_estate_property_tag,base.group_user,1,0,0,0 +access_real_estate_property_tag_admin,access_real_estate_property_tag_user,model_estate_property_tag,base.group_system,1,1,1,1 +access_real_estate_property_offer_user,access_real_estate_property_offer_user,model_estate_property_offer,base.group_user,1,0,0,0 +access_real_estate_property_offer_admin,access_real_estate_property_offer_admin,model_estate_property_offer,base.group_system,1,1,1,1 \ No newline at end of file diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..ad2f0a90864 --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..7956a9f86fd --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,44 @@ + + + + + Property Offers + estate.property.offer + list,form + [('property_type_ids', '=', active_id)] + + + + estate.property.offer.view.list + estate.property.offer + + + + + + + + + + +

+ +

+
+ + + + + + + + + + + + + +
+
+ + + estate.property.type.search + 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..0e056b58ddb --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,167 @@ + + + + Properties + estate.estate.property + list,form,kanban + {'search_default_availability': True} + + + + + + + estate.estate.property.view.list + estate.estate.property + + + + + + + + + + + + + + + + + + + + estate.estate.property.view.form + estate.estate.property + +
+
+
+ +

+ + + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + + estate.estate.property.view.search + estate.estate.property + + + + + + + + + + + + + + + + + + + + + estate.estate.property.view.kanban + estate.estate.property + + + + +
+ +
+
+ Expected Price - +
+ Best Offer - +
+
+ Selling Price - +
+ +
+
+
+ +
+
+
+ +
\ No newline at end of file diff --git a/estate/views/res_users_views.xml b/estate/views/res_users_views.xml new file mode 100644 index 00000000000..1636e6faa28 --- /dev/null +++ b/estate/views/res_users_views.xml @@ -0,0 +1,24 @@ + + + + + res.users.view.form.inherit.estate.estate.property + res.users + + + + + + + + + + + + \ No newline at end of file diff --git a/estate_account/__init__.py b/estate_account/__init__.py new file mode 100644 index 00000000000..9a7e03eded3 --- /dev/null +++ b/estate_account/__init__.py @@ -0,0 +1 @@ +from . import models \ No newline at end of file diff --git a/estate_account/__manifest__.py b/estate_account/__manifest__.py new file mode 100644 index 00000000000..a3a95e71a71 --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,13 @@ +{ + 'name': 'Estate Account Linker', + 'application': True, + 'installable': True, + 'category': 'Linker', + 'version': '0.1', + 'depends': [ + 'account', + 'estate' + ], + 'author': 'rencelotm', + 'license': 'AGPL-3' +} \ No newline at end of file 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..62e4978cfb5 --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,55 @@ +from odoo import models, Command + + +class EstateProperty(models.Model): + _inherit = "estate.estate.property" + + + def sold_property_action(self): + res = super().sold_property_action() + account_model = self.env['account.move'] + for record in self: + account_obj = account_model.create( + { + 'partner_id': record.buyer.id, + 'move_type': 'out_invoice', + 'journal_id': self.env['account.journal'].search([('type', '=', 'sale')], limit=1).id, + } + ) + account_obj['line_ids'] = [ + Command.create({ + 'name': "Selling Commission", + 'quantity': 1, + 'price_unit': self.selling_price * .06, + 'account_id': account_obj.id, + }), + Command.create({ + 'name': "Administrative fees", + 'quantity': 1, + 'price_unit': 100.00, + 'account_id': account_obj.id, + }) + ] + return res + + #result = super(EstateProperty, self).sold_property_action() + #invoices = self.env['account.move'].create( + # { + # 'partner_id': self.buyer.id, + # 'move_type': 'out_invoice', + # 'journal_id': self.env['account.journal'].search([('type', '=', 'sale')], limit=1).id, + # 'line_ids': [ + # Command.create({ + # 'name': 'Selling Commission', + # 'quantity': 1, + # 'price_unit': self.selling_price * .06 + # }), + # Command.create({ + # 'name': 'Administrative fees', + # 'quantity': 1, + # 'price_unit': 100.00 + # }) + # ] + # } + #) + #return result diff --git a/estate_account/security/ir.model.access.csv b/estate_account/security/ir.model.access.csv new file mode 100644 index 00000000000..8da6d106fb3 --- /dev/null +++ b/estate_account/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +estate_account.access_estate_property,access_estate_property,estate_account.model_estate_property,base.group_user,1,1,1,1 \ No newline at end of file