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..59930d3e30e --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,19 @@ +{ + "name": "Real Estate", + "version": "1.0", + "depends": ["base"], + "author": "Odoo S.A.", + "category": "Productivity", + "description": """ + Our brand new real estate app! + """, + "application": True, + "data": [ + "security/ir.model.access.csv", + "views/estate_property_views.xml", + "views/estate_property_type_views.xml", + "views/estate_property_tag_views.xml", + "views/estate_menus.xml", + ], + "license": "OPL-1", +} diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..82c319c6537 --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1,4 @@ +from . import estate_property +from . import estate_property_offer +from . import estate_property_type +from . import estate_property_tag diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..b1e35d3de70 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,113 @@ +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 Property(models.Model): + _name = "estate.property" + _description = "Real Estate Property" + + name = fields.Char("Property Name", required=True) + description = fields.Text("Property Description") + postcode = fields.Char("Postcode") + + date_availability = fields.Date("Available Date", copy=False, default=lambda _x: datetime.now() + relativedelta(months=+3)) + + expected_price = fields.Float("Expected Price") + selling_price = fields.Float("Selling Price", copy=False, readonly=True) + best_price = fields.Float("Best offer", compute="_compute_best_price") + + bedrooms = fields.Integer("Number of Bedrooms", default=2) + living_area = fields.Integer("Living Area") + facades = fields.Integer("Number of Facades") + + has_garage = fields.Boolean("Has a Garage") + has_garden = fields.Boolean("Has a Garden") + garden_area = fields.Integer("Garden Area") + garden_orientation = fields.Selection( + string="Garden Orientation", + selection=[ + ("north", "North"), + ("south", "South"), + ("east", "East"), + ("west", "West"), + ], + ) + + state = fields.Selection( + string="Property State", + selection=[ + ("new", "New"), + ("offer_received", "Offer Received"), + ("offer_accepted", "Offer Accepted"), + ("sold", "Sold"), + ("cancelled", "Cancelled"), + ], + default="new", + ) + + total_area = fields.Integer("Total Area", compute="_compute_total_area") + + property_type_id = fields.Many2one("estate.property.type", string="Property Type") + + buyer_id = fields.Many2one("res.partner", string="Buyer", copy=False) + seller_id = fields.Many2one("res.users", string="Seller", default=lambda self: self.env.user) + + tags_ids = fields.Many2many("estate.property.tag", string="Property Tags") + + offer_ids = fields.One2many("estate.property.offer", "property_id", string="Offers") + + active = fields.Boolean(default=True) + + _check_expected_price = models.Constraint("CHECK(expected_price > 0)", "Expected price must be greater than zero") + _check_selling_price = models.Constraint("CHECK(selling_price >= 0)", "Selling price must be greater or equal to zero") + + @api.constrains("expected_price", "selling_price") + def _check_selling_price_proportion(self): + for record in self: + if record.selling_price == 0: + continue # 0 means no offer accepted + + if float_compare(record.selling_price, 0.9 * record.expected_price, 2) < 0: + raise ValidationError(message="Selling price must be at least 90 percent of expected price") + + def action_mark_as_sold(self): + for record in self: + if record.state == "cancelled": + raise UserError(message="Can't sell a cancelled auction.") + + record.state = "sold" + + return True + + def action_cancel_selling(self): + for record in self: + if record.state == "sold": + raise UserError(message="Can't cancel a sold auction.") + + record.state = "cancelled" + + return True + + @api.depends("offer_ids") + def _compute_best_price(self): + for record in self: + record.best_price = max(record.offer_ids.mapped("price"), default=0.0) + + @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.onchange("has_garden") + def _onchange_has_garden(self): + if self.has_garden: + self.garden_area = 10 + self.garden_orientation = "north" + return + + self.garden_area = 0 + self.garden_orientation = "" diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..f81afe164d1 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,68 @@ +from datetime import datetime, timedelta + +from odoo import api, fields, models +from odoo.exceptions import UserError + + +class PropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "Offer on a Real Estate Property" + + price = fields.Float("Price") + status = fields.Selection( + string="Status", + copy=False, + selection=[ + ("accepted", "Accepted"), + ("refused", "Refused"), + ], + ) + + validity = fields.Integer("Validity (in days)", default=7) + + date_deadline = fields.Date("Deadline", compute="_compute_date_deadline", inverse="_inverse_date_deadline") + + partner_id = fields.Many2one("res.partner", string="Made by", required=True) + property_id = fields.Many2one("estate.property", string="Property", required=True) + + _check_price = models.Constraint("CHECK(price > 0)", "Offered price must be greater than zero") + + def action_accept_offer(self): + for record in self: + linked_property = record.property_id + states = linked_property.offer_ids.mapped("status") + + if "accepted" in states: + raise UserError(message="An offer was already accepted") + + if linked_property.state == "cancelled": + raise UserError(message="Can't accept offers on cancelled auctions") + + record.status = "accepted" + linked_property.selling_price = record.price + linked_property.buyer_id = record.partner_id + linked_property.state = "sold" + + return True + + def action_reject_offer(self): + for record in self: + record.status = "refused" + + return True + + @api.depends("validity") + def _compute_date_deadline(self): + for record in self: + offer_date = record.create_date + if not offer_date: + offer_date = datetime.now() + + record.date_deadline = offer_date.date() + timedelta(days=record.validity) + + def _inverse_date_deadline(self): + for record in self: + offer_date = record.create_date + if not offer_date: + offer_date = datetime.now() + record.validity = (record.date_deadline - offer_date.date()).days diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..67cca8ee90d --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,10 @@ +from odoo import fields, models + + +class PropertyTag(models.Model): + _name = "estate.property.tag" + _description = "Real Estate Property Tag" + + name = fields.Char("Tag", required=True) + + _check_name = models.Constraint("UNIQUE(name)", "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..ab993856220 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,8 @@ +from odoo import fields, models + + +class PropertyType(models.Model): + _name = "estate.property.type" + _description = "Real Estate Property Type" + + name = fields.Char(string="Type", required=True) diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..a42bd95f417 --- /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 +access_estate_property_user,access_estate_property_user,model_estate_property,base.group_user,1,1,1,1 +access_estate_property_type_user,access_estate_property_type_user,model_estate_property_type,base.group_user,1,1,1,1 +access_estate_property_tag_user,access_estate_property_tag_user,model_estate_property_tag,base.group_user,1,1,1,1 +access_estate_property_offer_user,access_estate_property_offer_user,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..c7fb9b1c48b --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/estate/views/estate_property_tag_views.xml b/estate/views/estate_property_tag_views.xml new file mode 100644 index 00000000000..4dcd3674e34 --- /dev/null +++ b/estate/views/estate_property_tag_views.xml @@ -0,0 +1,8 @@ + + + + Property Tags + estate.property.tag + list,form + + diff --git a/estate/views/estate_property_type_views.xml b/estate/views/estate_property_type_views.xml new file mode 100644 index 00000000000..e50051fee30 --- /dev/null +++ b/estate/views/estate_property_type_views.xml @@ -0,0 +1,8 @@ + + + + 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..b1e1c88cc35 --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,131 @@ + + + + + Property Lookup + estate.property + + + + + + + + + + + + + + + + + + + + Properties + estate.property + + + + + + + + + + + + + + + Properties + estate.property + +
+
+
+ +

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