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..4d44accd006 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,22 @@ +{ + 'name': 'Estate', + 'category': 'Real Estate', + 'sequence': 1, + 'summary': 'Making the real estate app from tutorials', + 'website': 'www.odoo.com', + 'depends': [ + 'base', + ], + 'data': [ + 'security/ir.model.access.csv', + 'views/estate_properties_view.xml', + 'views/estate_property_offer_view.xml', + 'views/estate_property_type_view.xml', + 'views/estate_property_tag_view.xml', + 'views/estate_menus.xml', + ], + 'installable': True, + 'application': True, + 'author': 'Rini Pillai', + 'license': 'LGPL-3', +} diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..9a474ce9837 --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1,4 @@ +from . import estate_properties +from . import estate_property_type +from . import estate_property_tag +from . import estate_property_offer diff --git a/estate/models/estate_properties.py b/estate/models/estate_properties.py new file mode 100644 index 00000000000..f20a4d52e87 --- /dev/null +++ b/estate/models/estate_properties.py @@ -0,0 +1,211 @@ +import logging +from dateutil import relativedelta as rd + +from odoo import fields, models, api +from odoo.exceptions import UserError, ValidationError + + +_logger = logging.getLogger(__name__) + + +def _get_availability_date(self): + # _logger.info("!!! %s", self) + return fields.Date.today() + rd.relativedelta(months=3) + + +def _get_salesperson(self): + # _logger.info(self.env.user.name) + # _logger.info(self.env.user.id) + # _logger.info(self.env.user) + return self.env.user + + +class EstateProperties(models.Model): + _name = 'estate.properties' + _description = 'Real Estate Properties' + _order = 'sequence' + + active = fields.Boolean(help="Should the property be listed?", default=True) + bedrooms = fields.Integer(default=2) + best_price = fields.Float(compute='_compute_best_price') + buyer_id = fields.Many2one(comodel_name='res.partner', copy=False) + commission = fields.Integer(compute='_compute_commission', store=True) + date_availability = fields.Date(string="Availability Date", copy=False, default=lambda self: fields.Date.add(fields.Date.context_today(self), months=3)) + description = fields.Text() + expected_price = fields.Float(string="Expected Price", required=True) + facades = fields.Integer() + garage = fields.Boolean(string="Has Garage?", help="Does the proeprty have a garage?") + garden = fields.Boolean(string="Has Garden?", help="Does the property have a garden?") + garden_area = fields.Integer() + garden_orientation = fields.Selection( + [ + ('north', "North"), + ('south', "South"), + ('east', "East"), + ('west', "West") + ], + help="Directional orientation of the garden of the property shown" + ) + living_area = fields.Integer() + name = fields.Char(string="Property Name", required=True) + offer_ids = fields.One2many(comodel_name='estate.property.offer', inverse_name='property_id') + postcode = fields.Char() + price_gap = fields.Float(compute='_compute_price_gap') + property_type_colour = fields.Selection(related='property_type_id.colour', readonly=False) + property_type_id = fields.Many2one(comodel_name='estate.property.type') + salesperson_id = fields.Many2one(comodel_name='res.partner', default=lambda self: self.env.user.partner_id) + # salesperson = fields.Char(default=_get_salesperson) + selling_price = fields.Float(readonly=True, copy=False) + sequence = fields.Integer() + state = fields.Selection( + [ + ('new', "New"), + ('offer_received', "Offer Received"), + ('offer_accepted', "Offer Accepted"), + ('sold', "Sold"), + ('cancelled', "Cancelled") + ], + required=True, default='new', copy=False, string="Status" + ) + tag_ids = fields.Many2many(comodel_name='estate.property.tag') + total_area = fields.Integer(compute="_compute_total_area") + + _check_expected_price = models.Constraint( + 'CHECK (expected_price > 0 AND selling_price >= 0)', + "Price should strictly be positive", + ) + + @api.constrains('selling_price') + def _check_selling_price(self): + # breakpoint() + # _logger.error(self.id) + for property in self: + sp_threshold = 0.9 * property.expected_price + # _logger.error(sp_threshold) + # _logger.error(property.selling_price) + # _logger.error(property.expected_price) + # _logger.error(property.id) + if property.selling_price < sp_threshold: + raise ValidationError("Selling price cannot be less than 90% of the expected price") + + @api.depends('living_area', 'garden_area') + def _compute_total_area(self): + # _logger.error(self) + for property in self: + # _logger.error(property._fields) + # _logger.error(property) + property.total_area = property.living_area + property.garden_area + + @api.depends('offer_ids.price') + def _compute_best_price(self): + for property in self: + # _logger.error(self.mapped('offer_ids.price')) + offer_prices = property.mapped('offer_ids.price') + # _logger.error(offer_prices) + # _logger.error(max(offer_prices)) + property.best_price = max(offer_prices) if offer_prices else 0 + + # @api.constrains('expected_price') + # def _check_expected_price(self): + # for property in self: + # if property.expected_price < 0: + # raise UserError("Value cannot be less than zero.") + + @api.onchange('garden') + def _onchange_garden(self): + # _logger.error(self.garden) + if self.garden: + self.garden_area = 10 + self.garden_orientation = 'north' + else: + self.garden_area = 0 + self.garden_orientation = None + + # @api.constrains('date_availability') + # def _check_date_availability(self): + # for property in self: + # if property.date_availability < fields.Date.context_today(property): + # raise ValidationError("Past dates not allowed") + + @api.depends('best_price', 'expected_price') + def _compute_price_gap(self): + for property in self: + if property.best_price and property.expected_price: + property.price_gap = property.best_price - property.expected_price + else: + property.price_gap = 0 + + @api.onchange('state') + def _onchnage_state(self): + if self.state == 'cancelled': + self.active = False + else: + self.active = True + + def property_cancelled(self): + for property in self: + if property.state == 'cancelled': + raise UserError("Property already cancelled") + elif property.state != 'sold': + property.state = 'cancelled' + property.active = False + else: + raise UserError("A sold property cannot be cancelled") + return True + + def property_sold(self): + for property in self: + if property.state == 'sold': + raise UserError("Property already sold") + elif property.state != 'cancelled': + if property.buyer_id: + property.state = 'sold' + else: + raise UserError("No buyer for this property yet") + else: + raise UserError("A cancelled property cannot be sold") + return True + + @api.depends('selling_price') + def _compute_commission(self): + for property in self: + if property.selling_price: + property.commission = property.selling_price * 0.06 + + def property_accept(self): + # breakpoint() + # best_offers = [] + # for property in self.offer_ids: + # best_offers.append(property.price) + # _logger.error(best_offers) + # property_to_accept = max(best_offers) + # _logger.error(property_to_accept) + # if self.offer_ids: + # best = self.offer_ids.search([('price', '=', self.best_price), ('status', 'not in', ['refused'])]) + # if best: + # best.offer_accepted() + # return + # best = self.offer_ids.search([('price', '<', self.best_price), ('status', 'not in', ['refused'])], 0, 1, order='price DESC') + # best.ensure_one() + # best.offer_accepted() + # # _logger.error(best) + # else: + # raise ValidationError("No offers listed for this property") + # for property in self.offer_ids: + # if property.status == 'accepted': + # pass + # else: + # property.status = 'refused' + self.ensure_one() + best_offer = self.offer_ids.filtered(lambda offer: offer.status not in ['accepted', 'refused']) + + if not best_offer: + raise ValidationError("No offers listed for this property") + + best_offer = best_offer.sorted(lambda offer: offer.price, reverse=True)[0] + return best_offer.offer_accepted() + + @api.onchange('offer_ids') + def offer_received_state(self): + if self.offer_ids and self.state == 'new': + self.state = 'offer_received' diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..5501487bbc3 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,104 @@ +import datetime +import logging + +from odoo import api, fields, models +from odoo.exceptions import UserError +from odoo.orm.utils import ValidationError + + +_logger = logging.getLogger(__name__) + + +class EstatePropertyOffer(models.Model): + _name = 'estate.property.offer' + _description = 'Real Estate Property Offers' + _order = 'price desc' + + deadline = fields.Date() + partner_id = fields.Many2one(comodel_name='res.partner', required=True) + price = fields.Float() + property_id = fields.Many2one(comodel_name='estate.properties', readonly=True) + property_type = fields.Char(related='property_id.property_type_id.type') + property_type_id = fields.Many2one(related='property_id.property_type_id') + status = fields.Selection( + [ + ('refused', "Refused"), + ('accepted', "Accepted") + ], + copy=False + ) + validity = fields.Integer(compute="_compute_validity", inverse="_inverse_deadline", default=7) + + _check_offer_price = models.Constraint( + 'CHECK (price > 0)', + "Offer price should be positive" + ) + + @api.depends('deadline') + def _compute_validity(self): + for offer in self: + offer.validity = (offer.deadline - offer.create_date.date()).days if offer.deadline and offer.create_date else 7 + + @api.depends('validity') + def _inverse_deadline(self): + for offer in self: + # _logger.error(fields.Date.context_today(offer) + datetime.timedelta(days=offer.validity)) + # _logger.error(offer._fields) + start_date = offer.create_date.date() if offer.create_date else fields.Date.context_today(offer) + offer.deadline = start_date + datetime.timedelta(days=offer.validity) + + # @api.constrains('price') + # def _check_price(self): + # for property in self: + # if property.price < 0: + # raise ValidationError("Value cannot be less than zero.") + + # @api.constrains('deadline') + # def _check_deadline(self): + # for property in self: + # if property.deadline < fields.Date.context_today(property): + # raise ValidationError("Past dates not allowed") + + def _refuse_remaining_offers(self, offer_id, all_offers): + # _logger.error(all_offers) + for offer in all_offers: + if offer.id != offer_id: + offer.status = 'refused' + + def offer_accepted(self): + # offer_id = 0 + # breakpoint() + # for offer in self: + # if offer.status == 'accepted' and offer.property_id.state == 'offer_accepted': + # raise UserError("Property already accepted!") + # offer.status = 'accepted' + # all_offers = offer.property_id.offer_ids + # # _logger.error(all_offers) + # offer_id = offer.id + # offer._refuse_remaining_offers(offer_id, all_offers) + # offer.property_id.buyer_id = offer.partner_id + # offer.property_id.selling_price = offer.price + # offer.property_id.state = 'offer_accepted' + # return True + self.ensure_one() + property = self.property_id + if property.state == 'offer_accepted' and self.status == 'accepted': + raise ValidationError("Property already accepted!") + remaining_offers = property.offer_ids - self + remaining_offers.write({ + 'status': 'refused' + }) + self.status = 'accepted' + property.write({ + 'buyer_id': self.partner_id, + 'selling_price': self.price, + 'state': 'offer_accepted', + }) + return True + + def offer_refused(self): + for offer in self: + if offer.status == 'refused': + raise UserError("Property already refused!") + offer.status = 'refused' + return True diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..74f6d3da5c7 --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,20 @@ +import random + +from odoo import fields, models + + +class EstatePropertyTag(models.Model): + _name = 'estate.property.tag' + _description = 'Estate property tags' + _order = 'name' + + def _get_default_color(self): + return random.randint(1, 11) + + name = fields.Char(required=True) + color = fields.Integer(default=_get_default_color) + + _check_name = models.Constraint( + 'UNIQUE (name)', + "Property 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..02657d7afc4 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,87 @@ +import logging +from datetime import timedelta + +from odoo import api, fields, models + + +_logger = logging.getLogger(__name__) + + +class EstatePropertyType(models.Model): + _name = 'estate.property.type' + _description = 'Estate Property Types' + _rec_name = 'type' + _order = 'type' + + colour = fields.Selection( + [ + ('red', 'Red'), + ('green', 'Green'), + ('yellow', 'Yellow') + ] + ) + offer_alert = fields.Integer(compute='_compute_offer_alert') + offer_count = fields.Integer(compute='_compute_offer_count') + offer_ids = fields.One2many(comodel_name='estate.property.offer', inverse_name='property_type_id') + pricey_count = fields.Integer(compute='_compute_pricey_count') + property_ids = fields.One2many(comodel_name='estate.properties', inverse_name='property_type_id') + type = fields.Char(required=True) + + _check_name = models.Constraint( + 'UNIQUE (type)', + "Property Type should be unique", + ) + + @api.depends('offer_ids') + def _compute_offer_count(self): + for property_type in self: + # _logger.error(property_type) + # _logger.error(property_type.offer_ids) + # _logger.error(property_type.property_ids.offer_ids) + property_type.offer_count = len(property_type.offer_ids) + + @api.depends('offer_ids.deadline') + def _compute_offer_alert(self): + # breakpoint() + for property in self: + today = fields.Date.context_today(property) + alert = timedelta(days=1) + property.offer_alert = len(property.offer_ids.filtered( + lambda o: o.deadline and abs(o.deadline - today) <= alert + and o.status not in ['refused', 'accepted']) + ) + # _logger.error(property.offer_alert) + + @api.depends('property_ids.expected_price') + def _compute_pricey_count(self): + for property in self: + property.pricey_count = len(property.property_ids.filtered(lambda prop: prop.expected_price > 100000)) + + def action_see_offers(self): + # breakpoint() + self.ensure_one() + action = self.env['ir.actions.actions']._for_xml_id('estate.estate_property_offer_action') + action['domain'] = [('property_type_id', '=', self.id)] + return action + + def action_pricey_property(self): + # breakpoint() + self.ensure_one() + action = self.env['ir.actions.actions']._for_xml_id('estate.estate_properties_action') + action['domain'] = [ + ('expected_price', '>=', 100000), + ('property_type_id', '=', self.id) + ] + action['context'] = {'search_default_available': 0} + return action + + def action_offer_alert(self): + # breakpoint() + self.ensure_one() + action = self.env['ir.actions.actions']._for_xml_id('estate.estate_property_offer_action') + action['domain'] = [ + ('property_type_id', '=', self.id), + ('deadline', '>=', fields.Date.context_today(self)), + ('status', 'not in', ['refused', 'accepted']) + ] + return action diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..d0363679cc3 --- /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_estate_user,access_estate_user,model_estate_properties,base.group_user,1,0,0,0 +access_estate_admin,access_estate_admin,model_estate_properties,base.group_system,1,1,1,1 +access_estate_property_type_user,access_estate_property_type_user,model_estate_property_type,base.group_user,1,0,0,0 +access_estate_property_type_admin,access_estate_property_type_admin,model_estate_property_type,base.group_system,1,1,1,1 +access_estate_property_tag_user,access_estate_property_tag_user,model_estate_property_tag,base.group_system,1,0,0,0 +access_estate_property_tag_admin,access_estate_property_tag_admin,model_estate_property_tag,base.group_system,1,1,1,1 +access_estate_property_offer_user,access_estate_property_offer_user,model_estate_property_offer,base.group_user,1,0,0,0 +access_estate_property_offer_admin,access_estate_property_offer_admin,model_estate_property_offer,base.group_system,1,1,1,1 diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..62d69575f5e --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/estate/views/estate_properties_view.xml b/estate/views/estate_properties_view.xml new file mode 100644 index 00000000000..d96fed89146 --- /dev/null +++ b/estate/views/estate_properties_view.xml @@ -0,0 +1,158 @@ + + + estate.properties.view.list + estate.properties + + + + + + + + + + + + + + + + + + + + + + + + + + + + + estate.properties.view.form + estate.properties + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + estate.properties.view.search + estate.properties + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Properties + estate.properties + list,form + {'search_default_available': 1} + + diff --git a/estate/views/estate_property_offer_view.xml b/estate/views/estate_property_offer_view.xml new file mode 100644 index 00000000000..047d39825ce --- /dev/null +++ b/estate/views/estate_property_offer_view.xml @@ -0,0 +1,45 @@ + + + estate.property.offer.view.list + estate.property.offer + + + + + + + + + + + + + + + + estate.property.offer.view.form + estate.property.offer + + + + + + + + + + + + + + + + + Offer + estate.property.offer + list,form + + diff --git a/estate/views/estate_property_tag_view.xml b/estate/views/estate_property_tag_view.xml new file mode 100644 index 00000000000..98fd83565fe --- /dev/null +++ b/estate/views/estate_property_tag_view.xml @@ -0,0 +1,29 @@ + + + estate.property.tag.view.list + estate.property.tag + + + + + + + + + + estate.property.tag.view.form + estate.property.tag + + + + + + + + + + Property Tag + estate.property.tag + list,form + + diff --git a/estate/views/estate_property_type_view.xml b/estate/views/estate_property_type_view.xml new file mode 100644 index 00000000000..cc9358f516e --- /dev/null +++ b/estate/views/estate_property_type_view.xml @@ -0,0 +1,51 @@ + + + estate.property.type.view.form + estate.property.type + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Property Type + estate.property.type + list,form + +