diff --git a/estate/__init__.py b/estate/__init__.py new file mode 100644 index 00000000000..d6210b1285d --- /dev/null +++ b/estate/__init__.py @@ -0,0 +1,3 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/estate/__manifest__.py b/estate/__manifest__.py new file mode 100644 index 00000000000..6e4d64ac3be --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,25 @@ +{ + "name": "Real Estate", + "category": "Real Estate/Brokerage", + "summary": "Manage real estate properties, offers, and sales with ease", + "description": "A demo application for managing real estate listings, tracking offers, and streamlining property sales workflows. Designed to showcase essential features for real estate and brokerage operations within Odoo", + "installable": True, + "depends": ["base"], + "application": True, + "license": "AGPL-3", + "data": [ + "security/estate_security.xml", + "security/ir.model.access.csv", + "views/estate_property_offer_views.xml", + "views/estate_property_views.xml", + "views/estate_settings_views.xml", + "views/estate_property_tags_views.xml", + "views/res_users_views.xml", + "views/estate_menus.xml", + "data/estate.property.type.csv", + ], + "demo": [ + "demo/estate_property_demo.xml", + "demo/estate_offer_demo.xml", + ], +} diff --git a/estate/data/estate.property.type.csv b/estate/data/estate.property.type.csv new file mode 100644 index 00000000000..6069981e33c --- /dev/null +++ b/estate/data/estate.property.type.csv @@ -0,0 +1,5 @@ +id,name +property_type_residential,Residential +property_type_commercial,Commercial +property_type_industrial,Industrial +property_type_land,Land diff --git a/estate/demo/estate_offer_demo.xml b/estate/demo/estate_offer_demo.xml new file mode 100644 index 00000000000..48772b2a25d --- /dev/null +++ b/estate/demo/estate_offer_demo.xml @@ -0,0 +1,43 @@ + + + + + 1500000 + 14 + + + + + + + + 1500001 + 14 + + + + + + + + 1500002 + 14 + + + + + + + + + + + + + + + + + + + diff --git a/estate/demo/estate_property_demo.xml b/estate/demo/estate_property_demo.xml new file mode 100644 index 00000000000..cd241795d51 --- /dev/null +++ b/estate/demo/estate_property_demo.xml @@ -0,0 +1,114 @@ + + + + Big Villa + new + A nice and big villa + 12345 + 2020-02-02 + 1600000 + 0 + 6 + 100 + 4 + true + true + 100000 + south + + + + + Trailer home + cancelled + Home in a trailer park + 54321 + 1970-01-01 + 100000 + 120000 + 1 + 10 + 4 + false + false + 0 + + + + + Small House + new + A compact house perfect for small families. + 98765 + + 250000 + 0 + 3 + 80 + 2 + true + true + 50 + east + + + + + + 250000 + 30 + + + + + 280000 + 15 + + + + + + + Very Small House + new + A nice and small villa + 12345 + + 1600000 + 0 + 6 + 100 + 4 + true + true + 100000 + south + + + + + + + + + + diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..35928a71f7e --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1,7 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +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..afc841fa355 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,129 @@ +from dateutil.relativedelta import relativedelta +from odoo import fields, models, api, _ +from odoo.exceptions import UserError, ValidationError +from odoo.tools.float_utils import float_compare, float_is_zero + + +class EstateProperty(models.Model): + _name = "estate.property" + _description = "estate property revenue plans" + _order = "id desc" + + name = fields.Char(required=True) + description = fields.Text(string="Description") + postcode = fields.Char(string="Postcode") + date_availability = fields.Date( + string="Available From", + copy=False, + default=lambda self: fields.Date.today() + relativedelta(months=3), + ) + expected_price = fields.Float(string="Expected Price", 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(string="Number of Facades") + garage = fields.Boolean(string="Garage") + garden = fields.Boolean(string="Garden") + garden_area = fields.Integer(string="Garden Area (sqm)") + garden_orientation = fields.Selection( + selection=[ + ("north", "North"), + ("south", "South"), + ("east", "East"), + ("west", "West"), + ], + string="Garden Orientation", + help="Orientation of the garden relative to the property", + ) + active = fields.Boolean(default=True) + state = fields.Selection( + selection=[ + ("new", "NEW"), + ("offer_received", "Offer Received"), + ("offer_accepted", "Offer Accepted"), + ("sold", "Sold"), + ("cancelled", "Cancelled"), + ], + string="Status", + required=True, + default="new", + copy=False, + ) + + total_area = fields.Integer(string="Total Area", compute="_compute_total_area") + best_price = fields.Float(string="Best Offer", compute="_compute_best_price") + property_type_id = fields.Many2one("estate.property.type", string="Property Type") + buyer_id = fields.Many2one("res.partner", string="Buyer", copy=False) + user_id = fields.Many2one( + "res.users", string="Salesman", copy=False, default=lambda self: self.env.user + ) + tag_id = fields.Many2many("estate.property.tag", string="Tags", copy=False) + offer_id = fields.One2many("estate.property.offer", "property_id", string="Offer") + company_id = fields.Many2one("res.company", string="Company", required=True, default=lambda self: self.env.company) + + _sql_constraints = [ + ( + "check_expected_price", + "CHECK(expected_price > 0)", + "The Expected price of a property should be strictly positive.", + ), + ( + "check_selling_price", + "CHECK(selling_price IS NULL OR selling_price >= 0)", + "Selling price must be positive when set.", + ), + ] + + @api.depends("living_area", "garden_area") + def _compute_total_area(self): + for area in self: + area.total_area = area.living_area + area.garden_area + + @api.depends("offer_id.price") + def _compute_best_price(self): + for record in self: + # Get all prices from offer_ids + offer_prices = record.offer_id.mapped("price") + record.best_price = max(offer_prices) if offer_prices else 0.0 + + @api.onchange("garden") + def _onchange_garden(self): + if self.garden: + self.garden_orientation = "north" + self.garden_area = 10 + else: + self.garden_orientation = False + self.garden_area = 0 + + def action_property_sold(self): + for prop in self: + if prop.state == "cancelled": + raise UserError(_("Cancelled properties cannot be sold.")) + prop.state = "sold" + return True + + def action_property_cancel(self): + for prop in self: + if prop.state == "sold": + raise UserError(_("Sold properties cannot be cancelled.")) + prop.state = "cancelled" + return True + + @api.constrains("selling_price", "expected_price") + def _check_selling_price(self): + for record in self: + if float_is_zero(record.selling_price, precision_digits=2): + continue + min_allowed_price = record.expected_price * 0.9 + + if (float_compare(record.selling_price, min_allowed_price, precision_digits=2) < 0): + raise ValidationError( + "Selling price cannot be lower than 90% of the expected price. " + f"(Minimum allowed: {min_allowed_price:.2f})" + ) + + @api.ondelete(at_uninstall=False) + def _unlink_except_state_not_new(self): + for rec in self: + if rec.state not in ["new", "cancelled"]: + raise UserError(_("You cannot delete a property unless its state is '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..26bfacd298b --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,98 @@ +from odoo import fields, models, api, exceptions, _ +from dateutil.relativedelta import relativedelta +from odoo.exceptions import UserError + + +class EstatePropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "Offers of Estate property " + _order = "price desc" + + price = fields.Float(string="Price") + status = fields.Selection( + selection=[("accepted", "Accepted"), ("refused", "Refused")], + copy=False, + string="Status", + ) + + validity = fields.Integer(string="Validity", default=7) + date_deadline = fields.Date( + string="Deadline", + compute="_compute_deadline_date", + inverse="_inverse_deadline_date", + ) + + partner_id = fields.Many2one( + "res.partner", string="Partner", copy=False, required=True, store=True + ) + property_id = fields.Many2one("estate.property", copy=False, required=True) + property_type_id = fields.Many2one( + "estate.property.type", + related="property_id.property_type_id", + store=True, + readonly=False, + ) + _sql_constraints = [ + ( + "check_offer_price", + "CHECK(price > 0)", + "The Offer price must be strictly positive", + ) + ] + + @api.depends("create_date", "validity") + def _compute_deadline_date(self): + for offer in self: + create_dt = offer.create_date or fields.Date.today() + offer.date_deadline = create_dt + relativedelta(days=offer.validity) + + def _inverse_deadline_date(self): + for offer in self: + if offer.date_deadline: + create_dt = (offer.create_date or fields.Datetime.now()).date() + delta = (offer.date_deadline - create_dt).days + offer.validity = delta + else: + offer.validity = 0 + + def action_accept(self): + for offer in self: + # Allow only if property has no accepted offer + if offer.property_id.buyer_id: + raise UserError( + _("An offer has already been accepted for this property.") + ) + # Set buyer and selling price + offer.property_id.buyer_id = offer.partner_id + offer.property_id.selling_price = offer.price + offer.status = "accepted" + offer.property_id.state = "offer_accepted" + # Setting remaining Offer as refused + other_offers = offer.property_id.offer_id - offer + other_offers.write({"status": "refused"}) + + return True + + def action_refused(self): + for offer in self: + offer.status = "refused" + return True + + @api.model_create_multi + def create(self, vals_list): + new_records = [] + for vals in vals_list: + property_id = vals.get("property_id") + + property_rec = self.env["estate.property"].browse(property_id) + if property_rec.state == "new": + property_rec.state = "offer_received" + + if "price" in vals and vals["price"] < property_rec.best_price: + raise exceptions.ValidationError( + "Offer price must be higher than existing offers." + ) + + new_records.append(vals) + + return super().create(new_records) diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..09db9eec7f7 --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,18 @@ +from odoo import fields, models + + +class EstatePropertyTag(models.Model): + _name = "estate.property.tag" + _description = "Tags of Estate property " + _order = "name asc" + + name = fields.Char(required=True, string="Name") + color = fields.Integer(default=3) + + _sql_constraints = [ + ( + "unique_property_tag_name", + "UNIQUE(name)", + "A property 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..b1a0d6e4e24 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,33 @@ +from odoo import fields, models, api + + +class EstatePropertyType(models.Model): + _name = "estate.property.type" + _description = "Types of Estate property " + _order = "sequence, name asc" # Order by sequence first + + name = fields.Char(required=True) + sequence = fields.Integer( + "Sequence", default=10, help="Used to order property types." + ) + + property_ids = fields.One2many("estate.property", "property_type_id") + offer_ids = fields.One2many( + "estate.property.offer", "property_type_id", string="Offers" + ) + offer_count = fields.Integer( + string="Offer Count", compute="_compute_offer_count", store=True + ) + + _sql_constraints = [ + ( + "unique_property_type_name", + "UNIQUE(name)", + "A property type name must be unique.", + ), + ] + + @api.depends("offer_ids") + def _compute_offer_count(self): + for rec in self: + rec.offer_count = len(rec.offer_ids) diff --git a/estate/models/inherited_model.py b/estate/models/inherited_model.py new file mode 100644 index 00000000000..e7460290d2c --- /dev/null +++ b/estate/models/inherited_model.py @@ -0,0 +1,12 @@ +from odoo import models, fields + + +class ResUsers(models.Model): + _inherit = "res.users" + + property_ids = fields.One2many( + comodel_name="estate.property", + inverse_name="user_id", + string="Properties", + domain=[("state", "in", ["new", "offer_received"])], + ) diff --git a/estate/security/estate_security.xml b/estate/security/estate_security.xml new file mode 100644 index 00000000000..88955319c3b --- /dev/null +++ b/estate/security/estate_security.xml @@ -0,0 +1,38 @@ + + + + Agent + + + + + Manager + + + + + + + Estate: Agent see own or unassigned properties + + ['|', ('user_id', '=', False), ('user_id', '=', user.id)] + + + + + Estate: Manager see all properties + + + + + + Estate Property: Multi-company + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + + + + + diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..beaea9ae523 --- /dev/null +++ b/estate/security/ir.model.access.csv @@ -0,0 +1,8 @@ +id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink +estate_access_property_manager,Estate Manager: property,model_estate_property,estate.estate_group_manager,1,1,1,1 +estate_access_property_agent,Estate Agent: property,model_estate_property,estate.estate_group_user,1,1,1,0 +estate_access_type_manager,Estate Manager: type,model_estate_property_type,estate.estate_group_manager,1,1,1,1 +estate_access_type_agent,Estate Agent: type,model_estate_property_type,estate.estate_group_user,1,0,0,0 +estate_access_tag_manager,Estate Manager: tag,model_estate_property_tag,estate.estate_group_manager,1,1,1,1 +estate_access_tag_agent,Estate Agent: tag,model_estate_property_tag,estate.estate_group_user,1,0,0,0 +access_estate_model_offer,estate.property.offer,model_estate_property_offer,base.group_user,1,1,1,1 diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..285e43cb8d0 --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,25 @@ + + + + diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..9da71bcc15d --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,24 @@ + + + Offers + estate.property.offer + list + [('property_type_id', '=', active_id)] + + + + + estate.property.offer.list + estate.property.offer + + + + + + + + + + + + diff --git a/estate/views/estate_property_tags_views.xml b/estate/views/estate_property_tags_views.xml new file mode 100644 index 00000000000..da6830b2fc2 --- /dev/null +++ b/estate/views/estate_property_tags_views.xml @@ -0,0 +1,24 @@ + + + Property Tag + estate.property.tag + list,form + + + + estate.property.tag.form + estate.property.tag + +
+ +
+

+ +

+
+
+
+
+
+ +
diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml new file mode 100644 index 00000000000..7b48d35f19f --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,135 @@ + + + Properties + estate.property + list,form + {'search_default_filter_available': 1} + + + + + estate.property.list + estate.property + + + + + + + + + + + + + + + + estate.property.form + estate.property + +
+
+
+ +
+

+ +

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

+ +

+
+ + + + + + + + + + + +
+ +
+
+
diff --git a/estate/views/res_users_views.xml b/estate/views/res_users_views.xml new file mode 100644 index 00000000000..d62fd6a1315 --- /dev/null +++ b/estate/views/res_users_views.xml @@ -0,0 +1,15 @@ + + + res.users.form.inherit.estate + res.users + + + + + + + + + + + diff --git a/estate_account/__init__.py b/estate_account/__init__.py new file mode 100644 index 00000000000..d6210b1285d --- /dev/null +++ b/estate_account/__init__.py @@ -0,0 +1,3 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/estate_account/__manifest__.py b/estate_account/__manifest__.py new file mode 100644 index 00000000000..a5edaebf1fc --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,9 @@ +{ + "name": "Estate Account", + "summary": "Integrate real estate operations with accounting", + "description": "Extends the Real Estate app to handle financial operations. Automates invoicing, tracks commissions, and links property sales with accounting workflows for seamless integration", + "depends": ["base", "estate", "account"], + "category": "Tutorials", + "installable": True, + "license": "AGPL-3", +} diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py new file mode 100644 index 00000000000..09b94f90f8d --- /dev/null +++ b/estate_account/models/__init__.py @@ -0,0 +1,3 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +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..3a1c611cdf2 --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,51 @@ +from odoo import models, Command +from odoo.exceptions import UserError, AccessError + + +class EstateProperty(models.Model): + _inherit = "estate.property" + + def action_property_sold(self): + res = super().action_property_sold() + journal = self.env["account.journal"].sudo().search( + [("type", "=", "sale")], limit=1 + ) + + if not journal: + raise UserError("No sales journal found!") + + for property in self: + if not property.buyer_id: + continue + + commission = property.selling_price * 0.06 + admin_fee = 100.00 + try: + property.check_access('write') + except AccessError as e: + raise UserError(f"You don't have permission to modify {property.name}") from e + + self.env["account.move"].sudo().create({ + "partner_id": property.buyer_id.id, + "move_type": "out_invoice", + "company_id": property.company_id.id, + "journal_id": journal.id, + "invoice_line_ids": [ + Command.create( + { + "name": "Commission (6%)", + "quantity": 1, + "price_unit": commission, + } + ), + Command.create( + { + "name": "Administrative fees", + "quantity": 1, + "price_unit": admin_fee, + } + ), + ], + }) + + return res