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..ea570d71dab --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,18 @@ +{ + 'name': "Estate", + 'depends': ['base'], + 'application': True, + 'installable': True, + "author": "daloe", + "license": "LGPL-3", + "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/estate_menus.xml', + 'views/res_users_views.xml' + ], +} diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..c2df8d540dd --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- + +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..b65957249b2 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,195 @@ +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models +from odoo.exceptions import UserError, ValidationError +from odoo.tools import float_compare + +class EstateProperty(models.Model): + _name = "estate.property" + _description = "technical training estate property model" + _order = "id desc" + + # RELATIONAL FIELDS + estate_property_type_id = fields.Many2one( + "estate.property.type", + ondelete="set null", + ) + + estate_property_tag_ids = fields.Many2many( + "estate.property.tag", + ) + + buyer_id = fields.Many2one( + "res.partner", + ondelete="set null", + ) + + seller_id = fields.Many2one( + "res.users", + ondelete="set null", + default=lambda self: self.env.user.id, + ) + + estate_property_offer_ids = fields.One2many( + "estate.property.offer", + "estate_property_id", + copy=False, + ) + + # BASIC FIELDS + name = fields.Char( + "Estate Property", + required = True, + ) + + active = fields.Boolean( + default = True, + ) + + status = fields.Selection( + [["new", "New"], ["offer_received", "Offer Received"], ["offer_accepted", "Offer Accepted"], ["sold", "Sold"], ["cancelled", "Cancelled"]], + default="new", + ) + + description = fields.Text( + "Description" + ) + + postcode = fields.Char( + "Postcode" + ) + + date_availability = fields.Date( + "Available From", + copy = False, + default = fields.Date.today() + relativedelta(months=+3), + ) + + expected_price = fields.Float( + "Expected Price", + required = True, + ) + + selling_price = fields.Float( + "Selling Price", + copy = False, + readonly = True, + ) + + bedrooms = fields.Integer( + "Bedrooms", + default = 2, + ) + + living_area = fields.Integer( + "Living Area (sqm)" + ) + + facades = fields.Integer( + "Facades" + ) + + garage = fields.Boolean( + "Garage", + default=False, + ) + + garden = fields.Boolean( + "Garden", + default=False, + ) + + garden_area = fields.Integer( + "Garden Area" + ) + + garden_orientation = fields.Selection( + [["north", "North"],["east", "East"], ["south", "South"], ["west", "West"]], + ) + + # COMPUTED FIELDS + total_area = fields.Integer(compute="_compute_total_area") + + best_price = fields.Float("Best Offer", compute="_compute_best_price") + + # SQL CONSTRAINTS & INDEXES + _check_expected_price_positive = models.Constraint( + "CHECK(expected_price > 0)", + "expected_price <= 0" + ) + + _check_selling_price_positive = models.Constraint( + "CHECK(selling_price is null or selling_price > 0)", + "selling_price <= 0" + ) + + # COMPUTE & INVERSE & SEARCH + @api.depends("living_area") + def _compute_total_area(self): + for estate in self: + null_safe_garden_area = 0 + if estate.garden_area: + null_safe_garden_area = estate.garden_area + + estate.total_area = estate.living_area + null_safe_garden_area + + @api.depends("estate_property_offer_ids") + def _compute_best_price(self): + for estate in self: + if len(estate.estate_property_offer_ids) > 0: + estate.best_price = max(self.estate_property_offer_ids.mapped("price")) + else: + estate.best_price = 0 + + # PYTHON CONSTRAINS & ONCHANGE + @api.constrains('selling_price') + def _check_ninety_percent_of_expected(self): + for estate in self: + if estate.selling_price > 0: # restrict checks to only when offer acceptance happens + if float_compare(estate.selling_price, 0.9 * estate.expected_price, 2) < 0: + raise ValidationError("Offer is <90% of expected") + + @api.onchange("garden") + def _onchange_garden_orientation(self): + if self.garden: + self.garden_area = 10 + self.garden_orientation = "north" + else: + self.garden_area = None + self.garden_orientation = None + + # ORM OVERRIDES + @api.ondelete(at_uninstall=False) + def _unlink_if_new_cancelled(self): + for user in self: + if user.status not in ("new", "cancelled"): + error = self.env._("Cannot delete active listing") + raise UserError(error) + + # ACTIONS + def action_set_sold(self): + self.ensure_one() # this button should only exist on a form + if not self.active: + error = self.env._("Cannot set inactive/cancelled listing to sold") + raise UserError(error) + + self.status = "sold" + return True + + def action_set_cancelled(self): + self.ensure_one() # this button should only exist on a form + if self.status == "sold": + error = self.env._("Cannot set sold listing to cancelled") + raise UserError(error) + self.status = "cancelled" + return True + + def action_uncancel(self): + self.ensure_one() + self.status = "new" + return True + + # BUSINESS LOGIC + def set_offer_received(self): + for estate in self: + estate.status = "offer_received" \ No newline at end of file diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..aefff74536d --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,136 @@ +from datetime import timedelta + +from odoo.exceptions import UserError +from odoo import models, fields, api + +class EstatePropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "tech training estate property tag" + _order = "price desc" + + # RELATIONAL FIELDS + estate_property_id = fields.Many2one( + "estate.property", + ondelete="cascade", + required=True, + ) + + property_type_id = fields.Many2one( + related="estate_property_id.estate_property_type_id", + store=True, + ) + + buyer_id = fields.Many2one( + "res.partner", + string="Partner", + ondelete="cascade", + default=lambda self: self.env.user.partner_id.id, + required=True, + ) + + # BASIC FIELDS + price = fields.Float( + string="Price", + required=True, + aggregator="max", + ) + + status = fields.Selection( + [["new", "New"], ["refused", "Refused"], ["accepted", "Accepted"]], + default="new", + copy=False, + ) + + # COMPUTED FIELDS + validity = fields.Integer(default=7) + + date_deadline = fields.Date(string="Deadline", compute="_compute_date_deadline", inverse="_inverse_date_deadline") + + # SQL CONSTRAINTS & INDEXES + _check_positive_offer = models.Constraint( + "CHECK(price > 0)", + "Offer price must be greater than 0" + ) + + # COMPUTE & INVERSE & SEARCH + @api.depends("validity") + def _compute_date_deadline(self): + for estate in self: + if estate.create_date: + estate.date_deadline = estate.create_date + timedelta(days=estate.validity) + else: + estate.date_deadline = fields.Date.today() + timedelta(days=estate.validity) + + def _inverse_date_deadline(self): + for estate in self: + new_validity = (estate.date_deadline - fields.Date.today()).days + if new_validity < 0: + raise UserError("validity < 0") + estate.validity = (estate.date_deadline - fields.Date.today()).days + + # ORM OVERRIDES + @api.model + def create(self, vals): + for new_offer in vals: + current_property_offers_price_asc = self.env["estate.property.offer"].search( + [('estate_property_id', '=', new_offer.get("estate_property_id"))] + ).sorted("price asc") + + estate_property = self.env["estate.property"].browse(new_offer.get("estate_property_id")) + if not current_property_offers_price_asc or len(current_property_offers_price_asc) == 0: + if estate_property.status == "new": + estate_property.set_offer_received() + return super().create(vals) + + if new_offer.get("price") < current_property_offers_price_asc[0].price: + raise UserError("submitted offer < lowest current offer") + + if estate_property.status == "new": + estate_property.set_offer_received() + return super().create(vals) + + @api.model + def write(self, vals): + updated_price = vals.get("price") + if not updated_price: + return super().write(vals) # just skip, since logic only applies if there's updated price + + current_property_offers_price_asc = self.estate_property_id.estate_property_offer_ids.sorted("price asc") + + if self == current_property_offers_price_asc[0]: + return super().write(vals) + + if updated_price < current_property_offers_price_asc[0].price: + raise UserError("submitted offer < lowest current offer") + + return super().write(vals) + + # ACTIONS + def action_accept_offer(self): + self.ensure_one() + for offer in self: + if fields.Date.today() > offer.date_deadline: + raise UserError("offer expired") + + estate = offer.estate_property_id + for recur_offer in estate.estate_property_offer_ids: + if recur_offer.status == "accepted": + # more foolproof than just checking estate_property.status + raise UserError("another offer already accepted") + + estate.buyer_id = offer.buyer_id + estate.selling_price = offer.price + estate.status = "offer_accepted" + + offer.status = "accepted" + return True + + def action_refuse_offer(self): + self.ensure_one() + self.status = "refused" + + estate = self.estate_property_id + estate.buyer_id = None + estate.selling_price = None + estate.status = "offer_received" + return True \ 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..9dd61f57671 --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,28 @@ +from odoo import models, fields + +class EstatePropertyTag(models.Model): + _name = 'estate.property.tag' + _description = 'tech training estate property tag' + _order = 'name asc' + + # RELATIONAL FIELDS + estate_property_ids = fields.Many2many( + "estate.property", + ) + + # BASIC FIELDS + name = fields.Char( + "Tag", + required=True, + ) + + color = fields.Integer( + string="Color", + default=0, + ) + + # SQL CONSTRAINTS & INDEXES + _check_unique_name = models.UniqueIndex( + "(UPPER(name))", + "Tag should be unique" + ) diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py new file mode 100644 index 00000000000..b5111d971f3 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,48 @@ +from odoo import models, fields, api + +class EstatePropertyType(models.Model): + _name = "estate.property.type" + _description = "technical training estate property type model" + _order = "name asc" + + # RELATIONAL FIELDS + estate_property_ids = fields.One2many( + "estate.property", + "estate_property_type_id" + ) + + estate_property_offer_ids = fields.One2many( + "estate.property.offer", + "property_type_id" + ) + + # BASIC FIELDS + name = fields.Char( + "Type", + required=True, + ) + + sequence = fields.Integer( + 'Sequence', + default=1, + ) + + # COMPUTED FIELDS + offer_count = fields.Integer( + compute="_compute_offer_count", + ) + + # SQL CONSTRAINTS & INDEXES + _check_unique_name = models.UniqueIndex( + "(UPPER(name))", + "Type should be unique" + ) + + # COMPUTE & INVERSE & SEARCH + @api.depends('estate_property_offer_ids') + def _compute_offer_count(self): + for prop_type in self: + if prop_type.estate_property_ids: + prop_type.offer_count = len(prop_type.estate_property_offer_ids) + else: + prop_type.offer_count = 0 diff --git a/estate/models/res_users.py b/estate/models/res_users.py new file mode 100644 index 00000000000..8be97a511c9 --- /dev/null +++ b/estate/models/res_users.py @@ -0,0 +1,10 @@ +from odoo import fields, models + +class InheritedResUser(models.Model): + _inherit = "res.users" + + property_ids = fields.One2many( + "estate.property", + "seller_id", + domain=[('status','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..05bd9eefba4 --- /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,access_estate_property,model_estate_property,base.group_user,1,1,1,1 +access_estate_property_type,access_estate_property_type,model_estate_property_type,base.group_user,1,1,1,1 +access_estate_property_tag,access_estate_property_tag,model_estate_property_tag,base.group_user,1,1,1,1 +access_estate_property_offer,access_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..e62dd2790c0 --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ 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..eb11b19075d --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,75 @@ + + + + + Property Offers + estate.property.offer + list,form + + + + Property Offers + estate.property.offer + list,form + + [('property_type_id', '=', active_id)] + + + + + + estate.property.offer.list + estate.property.offer + + + + + + + + + + + + + + + estate.property.offer.related.list + estate.property.offer + + + + + + + + + + +
+

+
+ + + + + + + + +
+
+ + + estate.property.type.list + 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..59039171f3a --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,171 @@ + + + + + Properties + estate.property + list,form,kanban + + + {'search_default_filter_available_status': 1 } + + + + + + + estate.property.search + estate.property + + + + + + + + + + + + + + + + + + + + + + + estate.property.list + estate.property + + + + + + + + + + + + + + + + + + estate.property.form + estate.property + +
+ + +