diff --git a/estate/__init__.py b/estate/__init__.py new file mode 100644 index 00000000000..2dceb4d920c --- /dev/null +++ b/estate/__init__.py @@ -0,0 +1,3 @@ + + +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..f215c631005 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,16 @@ +{ + 'name': "Estate", + 'version': '1.0', + 'author': "Eadi", + 'depends': ['base'], + 'description': "", + 'data': [ + 'security/ir.model.access.csv', + 'views/estate_property_views.xml', + 'views/estate_menus.xml', + 'views/estate_property_type_views.xml', + 'views/estate_property_offer_views.xml', + 'views/res_users_views.xml' + ], + 'application': True +} \ No newline at end of file diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..8f49796968a --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1,6 @@ + +from . import estate_property +from . import estate_property_type +from . import estate_property_tag +from . import estate_property_offer +from . import res_users \ No newline at end of file diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..8e49218e33b --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,100 @@ + +from odoo import api, fields, models +from odoo.exceptions import UserError, ValidationError +from datetime import timedelta +from odoo.tools import float_compare, float_is_zero + +class EstateProperty(models.Model): + _name = "estate.property" + _description = "Estate Property Model" + _order = "id desc" + _sql_constraints = [ + ("check_expected_price", "CHECK(expected_price > 0)", "The expected price must be strictly positive"), + ("check_selling_price", "CHECK(selling_price >= 0)", "The selling price must be positive") + ] + + name = fields.Char(required=True) + salesperson_id = fields.Many2one("res.users", string="Salesperson", default=lambda self: self.env.user) + buyer_id = fields.Many2one("res.partner", string="Buyer") + property_type_id = fields.Many2one("estate.property.type", string="Property Type", required=True) + tag_ids = fields.Many2many("estate.property.tag", string="Tags") + offer_ids = fields.One2many("estate.property.offer", "property_id", string="Offers", help="All offers received for this property") + description = fields.Text() + postcode = fields.Char() + date_availability = fields.Date(string="Available From", copy=False , default= lambda self: fields.Date.today() + timedelta(90)) + expected_price = fields.Float(required=True) + selling_price = fields.Float(string="Selling Price", readonly=True, copy=False) + bedrooms = fields.Integer(string="Bedrooms", default=2) + living_area = fields.Integer(string="Living Area(sqm)") + facades = fields.Integer() + garage = fields.Boolean() + garden = fields.Boolean() + garden_area = fields.Integer(string="Garden Area(sqm)") + garden_orientation = fields.Selection(selection=[("north", "North"), ("south", "South"), ("east", "East"), ("west", "West")]) + active = fields.Boolean(string="Active", default=True) + state = fields.Selection(selection=[("new", "New"), ("offer received", "Offer Received"), ("offer accepted", "Offer Accepted"), ("sold", "Sold"), ("cancelled", "Cancelled")], required=True, default="new", copy=False, string="Status" ) + total_area = fields.Integer(string="Total Area(sqm)", compute="_compute_total") + best_price = fields.Float(string="Best Price", compute="_compute_best_price") + + + @api.depends("living_area", "garden_area") + def _compute_total(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: + #mapped method takes the values of that specific column + record.best_price = max(record.offer_ids.mapped("price")) + else: + record.best_price = 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("expected_price", "selling_price") + def _check_selling_price(self): + for record in self: + # skipping all the records with selling price = 0, check only when an offer is accepted, so when selling price is updated + if float_is_zero(record.selling_price, precision_digits=2): + continue + + min_selling_price = record.expected_price * 0.9 + # float compare returns -1 if float 1 is less than float 2 + if float_compare(record.selling_price, min_selling_price, precision_digits=2) == -1: + raise ValidationError("Selling price cannot be lower than 90% of the expected price") + + @api.ondelete(at_uninstall=False) + def _prevent_deletion_if_not_new_or_cancelled(self): + # take all the records with state different than new or cancelled + # raise user error if these records are present + invalid_records = self.filtered(lambda r: r.state not in ["new", "cancelled"]) + + if invalid_records: + raise UserError("Cannot delete properties that are not ''new' or 'cancelled'") + + def action_sold(self): + if "canceled" in self.mapped("state"): + raise UserError("Canceled properties can't be sold") + return self.write({"state": "sold"}) + + def action_cancel(self): + if "sold" in self.mapped("state"): + raise UserError("Sold properties can't be canceled") + return self.write({"state": "canceled"}) + + + + + + + + diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..a6dfc36cd14 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,103 @@ +from odoo import fields, models, api +from odoo.exceptions import UserError, ValidationError +from datetime import datetime, timedelta +from odoo.tools import float_compare + +class EstatePropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "Offer for a property" + _order = "price desc" + _sql_constraints = [ + ("check_price", "CHECK(price > 0)", "The expected price must be strictly positive") + ] + + price = fields.Float(string="Price", required=True) + property_id = fields.Many2one("estate.property", string="Property", required=True, help="The property this offer is for") + partner_id = fields.Many2one("res.partner", string="Partner", required=True, help="The partner making the offer") + property_type_id = fields.Many2one("estate.property.type", string="Property Type", related="property_id.property_type_id", store=True) + status = fields.Selection([("accepted", "Accepted"), ("refused", "Refused")], string="Status", copy=False) + validity = fields.Integer(string="Validity(days)", default=7, help="Number of days this offer is valid") + date_deadline = fields.Date(string="Deadline", compute="_compute_date_deadline", inverse="_inverse_date_deadline", help="Date when offer expires") + + @api.depends('create_date', 'validity') + def _compute_date_deadline(self): + for record in self: + if record.create_date: + # if the record existis and it has a create_date + record.date_deadline = record.create_date.date() + timedelta(days=record.validity) + else: + # using today's date if record isn't created yet + record.date_deadline = fields.Date.today() + timedelta(days=record.validity) + + + def _inverse_date_deadline(self): + for record in self: + if record.date_deadline: + if record.create_date: + difference = record.date_deadline - record.create_date.date() + record.validity = difference.days + else: + # using today's date again + difference = record.date_deadline - fields.Date.today() + record.validity = difference.days + + @api.model + def create(self, vals): + property_id = vals.get("property_id") + if not property_id: + raise ValidationError("Property is required for offer creation") + + # Browsing in order to get the property record + property_record = self.env['estate.property'].browse(property_id) + + # checking if there are any existing offer for the record + existing_offers = self.search([("property_id", "=", property_id)]) + + # getting max offer and comparing with the current price + if existing_offers: + max_existing_offer = max(existing_offers.mapped("price")) + new_price = vals.get("price", 0) + + if float_compare(new_price, max_existing_offer, precision_digits=2) <= 0: + raise UserError( + f"Offer amount ({new_price:,.2f}) must be higher than " + f"existing offers (highest: {max_existing_offer:,.2f})" + ) + + # creating the offer + offer = super().create(vals) + + # updating the state when an offer is added + + if property_record.state == "new": + property_record.write({"state": "offer received"}) + + return offer + + def action_accept(self): + # check if record with status accepted already exists + # if it exists raise error saying an offer was already accepted + # self.write status accepted + # self.write property with status, selling price and buyer id + if "accepted" in self.mapped("property_id.offer_ids.status"): + raise UserError("An offer was already accepted") + self.write(({"status":"accepted"})) + return self.mapped("property_id").write( + { + "state":"offer accepted", + "selling_price":self.price, + "buyer_id":self.partner_id + } + ) + + def action_refuse(self): + return self.write({"status":"refused"}) + + + + + + + + + \ No newline at end of file diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..e0593054824 --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,10 @@ +from odoo import fields, models + +class EstatePropertyTag(models.Model): + _name = "estate.property.tag" + _description = "Tags for Properties" + _order = "name" + _sql_constraints = [("name_uniq", "UNIQUE(name)", "Tag name must be unique")] + + name = fields.Char(required=True) + color = fields.Integer("Color Index") \ No newline at end of file diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py new file mode 100644 index 00000000000..2e76c1bedf2 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,18 @@ +from odoo import fields, models, api + +class EstatePropertyType(models.Model): + _name = "estate.property.type" + _description = "Type of a Property" + _order = "sequence, name" + _sql_constraints = [("name_uniq", "UNIQUE(name)", "Property type name must be unique")] + + name = fields.Char(required=True) + property_ids = fields.One2many("estate.property", "property_type_id", string="Properties") + offer_ids = fields.One2many("estate.property.offer", "property_type_id", string="Offers") + sequence = fields.Integer(string="Sequence", default=10) + offer_count = fields.Integer(string="Number of Offers", compute="_compute_offer_count") + + @api.depends("offer_ids") + def _compute_offer_count(self): + for record in self: + record.offer_count = len(record.offer_ids) \ No newline at end of file diff --git a/estate/models/res_users.py b/estate/models/res_users.py new file mode 100644 index 00000000000..f146492bb1d --- /dev/null +++ b/estate/models/res_users.py @@ -0,0 +1,8 @@ +from odoo import fields, models + + +class ResUsers(models.Model): + + _inherit = "res.users" + + property_ids = fields.One2many("estate.property", "salesperson_id", string="Properties", domain=[("state", "in", ["new", "offer received"])]) \ No newline at end of file diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..08fb85e3719 --- /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,1 +access_estate_property_type,estate.property.type,model_estate_property_type,base.group_user,1,1,1,1 +access_estate_property_tag,estate.property.tag,model_estate_property_tag,base.group_user,1,1,1,1 +access_estate_property_offer,estate.property.offer,model_estate_property_offer,base.group_user,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..ac1daf263a9 --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..c1b2288f054 --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,44 @@ + + + + + estate.property.offer.form + estate.property.offer + + + + + + + + + + + + estate.property.offer.list + estate.property.offer + + + + + + + + + + + + + + + Property Offers + estate.property.offer + + [('property_type_id','=', active_id)] + list,form + + + \ No newline at end of file diff --git a/estate/views/estate_property_type_views.xml b/estate/views/estate_property_type_views.xml new file mode 100644 index 00000000000..a491d81d65a --- /dev/null +++ b/estate/views/estate_property_type_views.xml @@ -0,0 +1,63 @@ + + + + + + estate.property.type.form + estate.property.type + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + estate.property.type.list + estate.property.type + + + + + + + + + + + + Property Type + estate.property.type + list,form + + + Create a new 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..6e3c77d2954 --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,180 @@ + + + + + + estate.property.form + estate.property + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + estate.property.list + estate.property + + + + + + + + + + + + + + + + + estate.property.kanban + estate.property + + + + + + + + + + + + Expected Price: + + Best Offer: + + Selling Price: + + + + + + + + + + + + estate.property.search + estate.property + + + + + + + + + + + + + + + + + + + + + + + + Property + estate.property + list,kanban,form + {'search_default_filter_available': True} + + + Create a new property + + + + + + + Property Tag + estate.property.tag + list,form + + + Create a new property tag + + + + + \ 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..b82182c508b --- /dev/null +++ b/estate/views/res_users_views.xml @@ -0,0 +1,15 @@ + + + + res.users.form.inherit.estate + 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..58b15f56269 --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,9 @@ +{ + "name": "Estate Accounting", + "version": "1.0", + "author": "Eadi", + "depends": ["estate", "account"], + "description": "", + "data": [], + "application": True +} \ 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..f4c8fd6db6d --- /dev/null +++ b/estate_account/models/__init__.py @@ -0,0 +1 @@ +from . import estate_property \ No newline at end of file diff --git a/estate_account/models/estate_property.py b/estate_account/models/estate_property.py new file mode 100644 index 00000000000..efa187bc1c4 --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,31 @@ +from odoo import fields, models, Command + +class EstateProperty(models.Model): + _inherit = "estate.property" + + def action_sold(self): + res = super().action_sold() + journal = self.env["account.journal"].search([("type", "=","sale")], limit = 1) + invoice_vals = { + "partner_id": self.buyer_id.id, + "move_type": "out_invoice", + "journal_id": journal.id, + "invoice_date": fields.Date.today(), + "invoice_line_ids": [ + Command.create({ + "name": self.name, + "quantity": 1, + "price_unit": self.selling_price * 6.0 / 100.0 + }), + Command.create({ + "name": "Administrative Fees", + "quantity": 1, + "price_unit": 100.0 + }) + ] + } + + + invoice = self.env["account.move"].create(invoice_vals) + + return res \ No newline at end of file
+ Create a new property type +
+ Create a new property +
+ Create a new property tag +