From 992fde1001fa361e5220fce04e1d95458f5f8ae5 Mon Sep 17 00:00:00 2001 From: "Sudarshan Maity (sumai)" Date: Tue, 26 May 2026 13:17:33 +0530 Subject: [PATCH 1/2] [ADD] estate_auction: added estate auction bridge module * Added Auction Selling Mode for estate properties. * Implemented auction states (Draft, In Progress, Sold, Ended). * Implemented automatic auction closing using cron jobs with highest bid acceptance. * Added email notifications for accepted and rejected auction bidders. * Added filters for Regular and Auction properties. * Added UI restrictions and validations specific to auction workflow (hide accept/refuse buttons, draft restrictions, auction validations). --- estate/__init__.py | 2 + estate/__manifest__.py | 35 +++ estate/controllers/__init__.py | 1 + estate/controllers/main.py | 53 +++++ estate/data/estate_website_templates.xml | 124 +++++++++++ estate/data/mail_template.xml | 27 +++ estate/data/offer_cron.xml | 14 ++ estate/models/__init__.py | 9 + estate/models/estate_property.py | 200 ++++++++++++++++++ estate/models/estate_property_issue.py | 124 +++++++++++ estate/models/estate_property_offer.py | 97 +++++++++ estate/models/estate_property_tag.py | 17 ++ estate/models/estate_property_type.py | 22 ++ estate/models/estate_property_visit.py | 22 ++ estate/models/mail_compose_message.py | 14 ++ estate/models/res_config_settings.py | 7 + estate/models/res_users.py | 7 + estate/security/ir.model.access.csv | 7 + estate/static/description/icon.png | Bin 0 -> 39463 bytes estate/views/estate_menus.xml | 72 +++++++ estate/views/estate_property_issue_views.xml | 52 +++++ estate/views/estate_property_offer_views.xml | 48 +++++ estate/views/estate_property_tag_views.xml | 35 +++ estate/views/estate_property_type_views.xml | 50 +++++ estate/views/estate_property_views.xml | 177 ++++++++++++++++ estate/views/estate_property_visit_views.xml | 40 ++++ estate/views/res_config_settings_views.xml | 36 ++++ estate/views/res_users_views.xml | 16 ++ estate_auction/__init__.py | 2 + estate_auction/__manifest__.py | 28 +++ estate_auction/controller/__init__.py | 1 + estate_auction/controller/main.py | 81 +++++++ estate_auction/data/auction_cron.xml | 14 ++ .../data/auction_email_templates.xml | 53 +++++ .../data/estate_auction_website_template.xml | 129 +++++++++++ estate_auction/models/__init__.py | 2 + estate_auction/models/estate_property.py | 90 ++++++++ .../models/estate_property_offer.py | 21 ++ .../static/src/js/auction_countdown.js | 65 ++++++ .../views/estate_property_offer_views.xml | 20 ++ .../views/estate_property_views.xml | 76 +++++++ 41 files changed, 1890 insertions(+) create mode 100644 estate/__init__.py create mode 100644 estate/__manifest__.py create mode 100644 estate/controllers/__init__.py create mode 100644 estate/controllers/main.py create mode 100644 estate/data/estate_website_templates.xml create mode 100644 estate/data/mail_template.xml create mode 100644 estate/data/offer_cron.xml create mode 100644 estate/models/__init__.py create mode 100644 estate/models/estate_property.py create mode 100644 estate/models/estate_property_issue.py create mode 100644 estate/models/estate_property_offer.py create mode 100644 estate/models/estate_property_tag.py create mode 100644 estate/models/estate_property_type.py create mode 100644 estate/models/estate_property_visit.py create mode 100644 estate/models/mail_compose_message.py create mode 100644 estate/models/res_config_settings.py create mode 100644 estate/models/res_users.py create mode 100644 estate/security/ir.model.access.csv create mode 100644 estate/static/description/icon.png create mode 100644 estate/views/estate_menus.xml create mode 100644 estate/views/estate_property_issue_views.xml create mode 100644 estate/views/estate_property_offer_views.xml create mode 100644 estate/views/estate_property_tag_views.xml create mode 100644 estate/views/estate_property_type_views.xml create mode 100644 estate/views/estate_property_views.xml create mode 100644 estate/views/estate_property_visit_views.xml create mode 100644 estate/views/res_config_settings_views.xml create mode 100644 estate/views/res_users_views.xml create mode 100644 estate_auction/__init__.py create mode 100644 estate_auction/__manifest__.py create mode 100644 estate_auction/controller/__init__.py create mode 100644 estate_auction/controller/main.py create mode 100644 estate_auction/data/auction_cron.xml create mode 100644 estate_auction/data/auction_email_templates.xml create mode 100644 estate_auction/data/estate_auction_website_template.xml create mode 100644 estate_auction/models/__init__.py create mode 100644 estate_auction/models/estate_property.py create mode 100644 estate_auction/models/estate_property_offer.py create mode 100644 estate_auction/static/src/js/auction_countdown.js create mode 100644 estate_auction/views/estate_property_offer_views.xml create mode 100644 estate_auction/views/estate_property_views.xml diff --git a/estate/__init__.py b/estate/__init__.py new file mode 100644 index 00000000000..f7209b17100 --- /dev/null +++ b/estate/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/estate/__manifest__.py b/estate/__manifest__.py new file mode 100644 index 00000000000..fd2e1a5200c --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,35 @@ +{ + 'name': 'Real Estate', + 'summary': 'Manage real estate properties and offers', + 'description': "This module allows managing property advertisements,including property details, offers, and related data.", + 'author': 'Sudarshan Maity (sumai)', + 'website': '', + 'category': 'Real Estate', + 'version': '1.0', + 'license': 'LGPL-3', + 'depends': [ + 'base', + 'mail', + 'crm', + 'website' + ], + + 'data': [ + 'security/ir.model.access.csv', + 'data/mail_template.xml', + 'data/offer_cron.xml', + 'data/estate_website_templates.xml', + 'views/res_config_settings_views.xml', + '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_property_visit_views.xml', + 'views/estate_property_issue_views.xml', + 'views/res_users_views.xml', + 'views/estate_menus.xml', + ], + + 'installable': True, + 'application': True, +} diff --git a/estate/controllers/__init__.py b/estate/controllers/__init__.py new file mode 100644 index 00000000000..12a7e529b67 --- /dev/null +++ b/estate/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/estate/controllers/main.py b/estate/controllers/main.py new file mode 100644 index 00000000000..6635ed561ed --- /dev/null +++ b/estate/controllers/main.py @@ -0,0 +1,53 @@ +from odoo.http import Controller, route, request + + +class EstateWebsite(Controller): + + _property_per_page = 6 + + @route( + ["/properties", "/properties/page/"], + type="http", + auth="public", + website=True) + def properties(self, page=1, **kwargs): + website = request.website + found_properties = request.env["estate.property"].sudo().search([]) + + pager = website.pager( + url="/properties", + total=len(found_properties), + page=page, + step=self._property_per_page, + url_args=kwargs, + ) + + offset = pager["offset"] + properties_list = found_properties[offset: offset + self._property_per_page] + + return request.render( + "estate.estate_properties_template", + { + "properties": properties_list, + "pager": pager, + } + ) + + @route( + ['/properties/'], + type='http', + auth='public', + website=True + ) + def property_detail(self, property_id, **kwargs): + + property_record = request.env[ + 'estate.property' + ].sudo().browse(property_id) + + return request.render( + 'estate.estate_property_detail_template', + { + 'property': property_record + } + ) diff --git a/estate/data/estate_website_templates.xml b/estate/data/estate_website_templates.xml new file mode 100644 index 00000000000..de90b4b4a21 --- /dev/null +++ b/estate/data/estate_website_templates.xml @@ -0,0 +1,124 @@ + + + + + + Properties + /properties + + 20 + + + + + + + + \ No newline at end of file diff --git a/estate/data/mail_template.xml b/estate/data/mail_template.xml new file mode 100644 index 00000000000..f85e71646fb --- /dev/null +++ b/estate/data/mail_template.xml @@ -0,0 +1,27 @@ + + + + + Estate: Sold property + + Property {{ object.name }} is Sold + {{ object.salesperson_id.email or '' }} + + +
+

+ + Hello

+ The property has been successfully sold.
+ Buyer:
+ Salesperson:

+ + Thank you. +

+
+
+
+
+
diff --git a/estate/data/offer_cron.xml b/estate/data/offer_cron.xml new file mode 100644 index 00000000000..a4aba014aae --- /dev/null +++ b/estate/data/offer_cron.xml @@ -0,0 +1,14 @@ + + + + + vaccum expired offers automatically + + code + model._cron_offers_expire(job_count=20) + + 1 + days + + + diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..63f4f8cb16c --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1,9 @@ +from . import estate_property +from . import estate_property_type +from . import estate_property_tag +from . import estate_property_offer +from . import estate_property_visit +from . import estate_property_issue +from . import res_users +from . import mail_compose_message +from . import res_config_settings diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..73fba89ecd1 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,200 @@ +from datetime import timedelta + +from odoo import models, fields, api + +from odoo.exceptions import UserError, ValidationError + + +class EstateProperty(models.Model): + _name = 'estate.property' + _inherit = ['mail.thread', 'mail.activity.mixin'] + _description = 'Real Estate Property' + + _order = "id desc" + + name = fields.Char(string="Title", required=True) + image_1920 = fields.Image() + description = fields.Text() + postcode = fields.Char() + date_availability = fields.Date(string="Available From", copy=False, default=lambda self: fields.Date.today() + timedelta(days=90)) + 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)", copy=False) + total_area = fields.Integer(string="Total Area (sqm)", compute="_compute_total_area", store=True) + facades = fields.Integer() + garage = fields.Boolean() + garden = fields.Boolean() + garden_orientation = fields.Selection( + [ + ('north', "North"), + ('south', "South"), + ('east', "East"), + ('west', "West"), + ], + string="Garden Orientation" + ) + garden_area = fields.Integer(string="Garden Area (sqm)") + active = fields.Boolean(string="is Active", default=True) + state = fields.Selection( + [ + ('new', "New"), + ('offer_received', "Offer Received"), + ('offer_accepted', "Offer Accepted"), + ('sold', "Sold"), + ('cancelled', "Cancelled") + ], + string="Status", required=True, copy=False, default='new', readonly=False, tracking=True) + swimming_pool = fields.Boolean(string="Swimming Pool") + property_age = fields.Integer(string="Property Age") + property_type_id = fields.Many2one("estate.property.type", string="Property Type") + buyer_id = fields.Many2one("res.partner", string="Buyer", copy=False) + salesperson_id = fields.Many2one("res.users", string="Salesperson", default=lambda self: self.env.user) + tags_ids = fields.Many2many("estate.property.tag", string="Property Tags", compute="_compute_tags", readonly=False) + offer_ids = fields.One2many("estate.property.offer", "property_id", string="Offers") + issue_ids = fields.One2many("estate.property.issue", "property_id", string="Issue") + visit_ids = fields.One2many("estate.property.visit", "propert_id", string="visit") + best_price = fields.Float(string="Best Offer", compute="_compute_best_price") + spam = fields.Boolean(string="is suspicious", compute="_compute_offers") + overdue = fields.Boolean(string="Issue Overdue") + + _check_expected_price = models.Constraint( + 'CHECK(expected_price > 0)', + 'Expected price must be strictly positive.' + ) + + _check_selling_price = models.Constraint( + 'CHECK(selling_price >= 0)', + 'Selling price must be positive.' + ) + + @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.depends("offer_ids.price") + def _compute_best_price(self): + for record in self: + prices = record.offer_ids.mapped("price") + record.best_price = max(prices) if prices else 0 + + @api.onchange("garden") + def _onchange_garden(self): + for record in self: + if record.garden: + record.garden_area = 10 + record.garden_orientation = 'north' + else: + record.garden_area = 0 + record.garden_orientation = False + + @api.depends("expected_price", "offer_ids", "state", "create_date") + def _compute_tags(self): + Tag = self.env['estate.property.tag'] + tag_names = ['high value', 'low interest', 'quick sale'] + tags = Tag.search([('name', 'in', tag_names)]) + tag_map = {} + for tag in tags: + tag_map[tag.name] = tag + for name in tag_names: + if name not in tag_map: + tag_map[name] = Tag.create({'name': name}) + high = tag_map['high value'] + low = tag_map['low interest'] + quick = tag_map['quick sale'] + + today = fields.Date.today() + for record in self: + new_tags = self.env['estate.property.tag'] + if record.expected_price > 200: + new_tags |= high + if len(record.offer_ids) <= 2: + new_tags |= low + if record.state == 'sold': + calc = (today - record.create_date.date()).days + if calc <= 10: + new_tags |= quick + record.tags_ids = new_tags + + @api.depends("offer_ids.partner_id", "offer_ids.create_date") + def _compute_offers(self): + for record in self: + record.spam = False + for offers in record.offer_ids: + count = 0 + for other_offer in record.offer_ids: + if other_offer.partner_id == offers.partner_id and other_offer.create_date and offers.create_date: + if abs(other_offer.create_date - offers.create_date) <= timedelta(minutes=5): + count += 1 + if count >= 3: + record.spam = True + break + + @api.constrains("selling_price", "expected_price") + def _set_price(self): + for record in self: + if record.selling_price > 0 and record.selling_price < 0.9 * record.expected_price: + raise ValidationError("Selling price cannot be lower than 90 percent of expected price.") + + @api.constrains("visit_ids") + def _single_partner(self): + for record in self: + for other_date in record.visit_ids: + for current_date in record.visit_ids: + if current_date.id != other_date.id: + if other_date.visit_date < current_date.end_date and current_date.visit_date < other_date.end_date: + raise ValidationError("only 1 partner in 1 day") + + @api.ondelete(at_uninstall=False) + def _property_deletion(self): + for record in self: + if record.state not in ('new', 'cancelled'): + raise UserError("You can only delete properties in New or Cancelled state.") + + def _mark_as_sold(self): + for record in self: + for issue in record.issue_ids: + if issue.priority == 'high' and issue.state == 'overdue': + raise UserError("property cannot sold due to overdue") + if record.state == 'cancelled': + raise UserError("Cancelled property cannot be sold.") + if record.state != 'offer_accepted': + raise UserError("Property cannot be sold without an accepted offer.") + + record.state = 'sold' + + def action_sold(self): + # self._mark_as_sold() + template = self.env.ref('estate.email_template_edi_estate', raise_if_not_found=False) + partner_ids = [] + for record in self: + if record.buyer_id: + partner_ids.append(record.buyer_id.id) + if record.salesperson_id: + partner_ids.append(record.salesperson_id.partner_id.id) + + ctx = { + 'default_model': 'estate.property', + 'default_res_ids': self.ids, + 'default_composition_mode': 'comment', + 'default_template_id': template.id, + 'default_use_template': True, + 'default_partner_ids': partner_ids, + 'mark_property_sold': True, + } + return { + 'name': 'Send Email', + 'type': 'ir.actions.act_window', + 'res_model': 'mail.compose.message', + 'view_mode': 'form', + 'target': 'new', + 'context': ctx, + } + + def action_cancel(self): + for record in self: + if record.state == 'sold': + raise UserError("Sold property cannot be cancelled.") + record.state = 'cancelled' + return True diff --git a/estate/models/estate_property_issue.py b/estate/models/estate_property_issue.py new file mode 100644 index 00000000000..eb83a58f3af --- /dev/null +++ b/estate/models/estate_property_issue.py @@ -0,0 +1,124 @@ +from datetime import timedelta + +from odoo import models, fields, api + +from odoo.exceptions import UserError + + +class EstatePropertyIssue(models.Model): + _name = 'estate.property.issue' + _description = 'Issues related to estate property' + + name = fields.Char(string="Issue Name", required=True) + description = fields.Text() + property_id = fields.Many2one("estate.property", string="Property", required=True) + reported_by_id = fields.Many2one("res.partner", string="Reported By", copy=False, default=lambda self: self.env.user) + assigned_to_id = fields.Many2one("res.users", string="Assigned To") + issue_type = fields.Selection([ + ('plumbing', "Plumbing Issue"), + ('electrical', "Electrical Issue"), + ('structural', "Structural Issue"), + ('other', "Other Issue") + ], string="Issue Type", required=True) + state = fields.Selection([ + ('new', "New"), + ('in_progress', "In Progress"), + ('resolved', "Resolved"), + ('overdue', "Overdue"), + ('cancelled', "Cancelled") + ], default='new') + priority = fields.Selection([ + ('low', "Low"), + ('medium', "Medium"), + ('high', "High") + ]) + reported_date = fields.Date(string="Reported Date", readonly=True, default=lambda self: fields.Date.today()) + # reported_date = fields.Date(string="Reported Date") + resolved_date = fields.Date(string="Resolved Date", readonly=True) + assigned_date = fields.Date(string="Assigned Date") + overdue = fields.Boolean(string="Issue Overdue", compute="_compute_overdue") + + @api.onchange("issue_type") + def _onchange_priority(self): + for record in self: + if record.issue_type == 'structural': + record.priority = 'high' + elif record.issue_type == 'electrical': + record.priority = 'medium' + else: + record.priority = 'low' + + # @api.onchange("assigned_to_id") + # def _onchange_assign(self): + # for record in self: + # if record.assigned_to_id : + # print(record.assigned_to_id) + # record.state = 'in_progress' + + @api.depends("priority", "state", "assigned_date") + def _compute_overdue(self): + for record in self: + record.overdue = False + resolve_date = record.resolved_date if record.resolved_date else fields.Date.today() + start_date = record.assigned_date + if record.priority == 'high': + if record.state == 'in_progress' or record.state == 'overdue': + if resolve_date - start_date > timedelta(days=2): + record.overdue = True + record.state = 'overdue' + elif record.priority == 'medium': + if record.state == 'in_progress' or record.state == 'overdue': + if resolve_date - start_date > timedelta(days=5): + record.overdue = True + record.state = 'overdue' + elif record.priority == 'low': + if record.state == 'in_progress' or record.state == 'overdue': + if resolve_date - start_date > timedelta(days=10): + record.overdue = True + record.state = 'overdue' + + # @api.depends("priority", "state", "create_date") + # def _compute_overdue(self): + # for record in self: + # record.overdue = False + # resolve_date = record.resolved_date if record.resolved_date else fields.Date.today() + # start_date = record.create_date.date() + # if record.priority == 'high': + # if record.state == 'in_progress' or record.state == 'overdue': + # if resolve_date - start_date > timedelta(days=2): + # record.overdue = True + # record.state = 'overdue' + # elif record.priority == 'medium': + # if record.state == 'in_progress' or record.state == 'overdue': + # if resolve_date - start_date > timedelta(days=5): + # record.overdue = True + # record.state = 'overdue' + # elif record.priority == 'low': + # if record.state == 'in_progress' or record.state == 'overdue': + # if resolve_date - start_date > timedelta(days=10): + # record.overdue = True + # record.state = 'overdue' + + def action_resolved(self): + for record in self: + if record.state == 'cancelled': + raise UserError("Cancelled property issue cannot be resolved.") + if record.state != 'in_progress' and record.state != 'overdue': + raise UserError("Property cannot be resolved until the work has starte.") + record.state = 'resolved' + record.resolved_date = fields.Date.today() + return True + + def action_cancelled(self): + for record in self: + if record.state == 'resolved': + raise UserError("Resolved property issues cannot be cancelled.") + record.state = 'cancelled' + return True + + def action_accept(self): + for record in self: + record.state = 'in_progress' + record.assigned_to_id = record.property_id.salesperson_id + record.assigned_date = fields.Date.today() + return True diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..8e6d377c1c1 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,97 @@ +from datetime import timedelta + +from odoo import api, fields, models + +from odoo.exceptions import UserError + + +class EstatePropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "Property Offer" + _order = "price desc" + + price = fields.Float() + status = fields.Selection( + [ + ('accepted', "Accepted"), + ('refused', "Refused"), + ], + string="Current Status", copy=False) + partner_id = fields.Many2one("res.partner", string="Partner", required=True) + property_id = fields.Many2one("estate.property", string="Property", required=True) + property_type_id = fields.Many2one("estate.property.type", related="property_id.property_type_id", store=True) + validity = fields.Integer(string="Validity (days)", default=7) + date_deadline = fields.Date(string="Deadline", compute="_compute_date_deadline", inverse="_inverse_date_deadline", store=True) + + _check_price = models.Constraint( + 'CHECK(price > 0)', + 'Offer price must be strictly positive.' + ) + + @api.depends("create_date", "validity") + def _compute_date_deadline(self): + today = fields.Date.today() + for record in self: + base_date = record.create_date.date() if record.create_date else today + record.date_deadline = base_date + timedelta(days=record.validity or 0) + + def _inverse_date_deadline(self): + for record in self: + if record.create_date and record.date_deadline: + record.validity = (record.date_deadline - record.create_date.date()).days + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + for record in records: + property_rec = record.property_id + existing_offers = property_rec.offer_ids - record + if existing_offers: + max_price = 0 + for offer in existing_offers: + if offer.price > max_price: + max_price = offer.price + if record.price <= max_price: + raise UserError("Offer must be higher than existing offers.") + property_rec.state = 'offer_received' + return records + + @api.model + def _cron_offers_expire(self, job_count=20): + domain = [ + ('date_deadline', '<', fields.Date.context_today(self)), + ('status', 'not in', ['accepted', 'refused']), + ] + expired_offers = self.search( + domain, order='date_deadline asc', limit=job_count + ) + if expired_offers: + expired_offers.write({ + 'status': 'refused' + }) + return True + + def action_accept(self): + for record in self: + for offer in record.property_id.offer_ids: + if offer.status == 'accepted': + raise UserError("Only one offer can be accepted for a property.") + record.status = 'accepted' + record.property_id.write({ + 'selling_price': record.price, + 'buyer_id': record.partner_id.id, + 'state': 'offer_accepted'}) + (record.property_id.offer_ids - record).filtered(lambda offer: offer.status != 'refused').write({'status': 'refused'}) + return True + + def action_refuse(self): + for record in self: + record.status = 'refused' + # if record.property_id.buyer_id == record.partner_id: + # record.property_id.write({ + # 'selling_price': 0, + # 'buyer_id': False, + # 'state': 'offer_received' + # }) + record.property_id.selling_price = False + return True diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..1b1c7b503d6 --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,17 @@ +from odoo import models, fields + + +class EstatePropertyTag(models.Model): + _name = 'estate.property.tag' + _description = "Estate Property Tag" + + _order = "name" + + name = fields.Char(string="Tags Name", required=True) + + color = fields.Integer() + + _check_unique_name = models.Constraint( + 'UNIQUE(name)', + '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..fa02f4be553 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,22 @@ +from odoo import models, fields + + +class EstatePropertyType(models.Model): + _name = 'estate.property.type' + _description = 'Real Estate Property Types' + + _order = "sequence, name" + + name = fields.Char(string="Type Name", required=True) + sequence = fields.Integer(default=10) + property_ids = fields.One2many("estate.property", "property_type_id", string="Properties") + offer_ids = fields.One2many("estate.property.offer", "property_type_id", string="Offers") + offer_count = fields.Integer(compute="_compute_offer_count") + + _check_unique_name = models.Constraint( + 'UNIQUE(name)', + 'Property type name must be unique.') + + def _compute_offer_count(self): + for record in self: + record.offer_count = len(record.offer_ids) diff --git a/estate/models/estate_property_visit.py b/estate/models/estate_property_visit.py new file mode 100644 index 00000000000..d0d9530705e --- /dev/null +++ b/estate/models/estate_property_visit.py @@ -0,0 +1,22 @@ +from odoo import api, fields, models + + +class EstatePropertyVisit(models.Model): + _name = "estate.property.visit" + _description = "Property Description" + + partner_id = fields.Many2one("res.partner", string="Partner", required=True) + propert_id = fields.Many2one("estate.property", string="Property", required=True) + visit_date = fields.Datetime(string="Visiting Date") + end_date = fields.Datetime(string="End date") + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + for record in records: + self.env['calendar.event'].create({ + 'name': "property visit", + 'start': record.visit_date, + 'stop': record.end_date, + }) + return records diff --git a/estate/models/mail_compose_message.py b/estate/models/mail_compose_message.py new file mode 100644 index 00000000000..10e33d752c4 --- /dev/null +++ b/estate/models/mail_compose_message.py @@ -0,0 +1,14 @@ +from odoo import models + + +class MailComposeMessage(models.TransientModel): + _inherit = 'mail.compose.message' + + def action_send_mail(self): + mail = super().action_send_mail() + if self.env.context.get('mark_property_sold'): + properties = self.env['estate.property'].browse( + self.env.context.get('default_res_ids', [])) + properties._mark_as_sold() + + return mail diff --git a/estate/models/res_config_settings.py b/estate/models/res_config_settings.py new file mode 100644 index 00000000000..97740200f8b --- /dev/null +++ b/estate/models/res_config_settings.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + module_estate_auction = fields.Boolean(string="Automated Auction") diff --git a/estate/models/res_users.py b/estate/models/res_users.py new file mode 100644 index 00000000000..de2e3f9d76a --- /dev/null +++ b/estate/models/res_users.py @@ -0,0 +1,7 @@ +from odoo import models, fields + + +class ResUsers(models.Model): + _inherit = "res.users" + + property_ids = fields.One2many("estate.property", "salesperson_id", string="Property", domain=[('state', 'in', ['new', 'offer_received'])]) diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..c2b55b3e1fc --- /dev/null +++ b/estate/security/ir.model.access.csv @@ -0,0 +1,7 @@ +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 +access_estate_property_visit,access_estate_property_visit,model_estate_property_visit,base.group_user,1,1,1,1 +access_estate_property_issue,access_estate_property_issue,model_estate_property_issue,base.group_user,1,1,1,1 diff --git a/estate/static/description/icon.png b/estate/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..729b9d68897817264a9b7dd50edc327248746863 GIT binary patch literal 39463 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 g!W30 z86dL=0-(TAdaTLt=*jJ#iNo`a-~GWIRj4K=A*DuKl48!p+o%fVeza93T+Ap7%mB1Y z!~vKi<_$k_1KpS3f1{qwEANcf9CRN}`Ka53g5y~` MzI@`laiW%gN^MK7jA~j} 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/estate/views/estate_property_issue_views.xml b/estate/views/estate_property_issue_views.xml new file mode 100644 index 00000000000..ea0d492bdea --- /dev/null +++ b/estate/views/estate_property_issue_views.xml @@ -0,0 +1,52 @@ + + + + Property Issue + estate.property.issue + list,form + + + + estate.property.issue.list + estate.property.issue + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml new file mode 100644 index 00000000000..54be4746612 --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,177 @@ + + + + Properties + estate.property + list,form,kanban + {'search_default_available': 1} + + + + estate.property.list + estate.property + + + + + + + + + + + + + + + + + + estate.property.form + estate.property + +
+
+
+ + + + +

+ +

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + estate.property.search + estate.property + + + + + + + + + + + + + + + + + + + + estate.property.kanban + estate.property + + + + + + + +
+ + + +
+ Bedrooms: + +
+
+ Expected Price: + +
+
+ Best Price: + +
+
+ Selling Price: + +
+
+ +
+ +
+
+
+
+
+
+ +
diff --git a/estate/views/estate_property_visit_views.xml b/estate/views/estate_property_visit_views.xml new file mode 100644 index 00000000000..06fd3d4e72a --- /dev/null +++ b/estate/views/estate_property_visit_views.xml @@ -0,0 +1,40 @@ + + + + Property visit + estate.property.visit + list,form + + + + estate.property.visit.list + estate.property.visit + + + + + + + + + + + + + estate.property.visit.form + estate.property.visit + +
+ + + + + + + + +
+
+
+ +
\ No newline at end of file diff --git a/estate/views/res_config_settings_views.xml b/estate/views/res_config_settings_views.xml new file mode 100644 index 00000000000..86a0a012d96 --- /dev/null +++ b/estate/views/res_config_settings_views.xml @@ -0,0 +1,36 @@ + + + + res.config.settings.view.form.auction + res.config.settings + + + + + + + +
+ Enable automated Real Estate Auction to sell properties in auction format. +
+
+ +
+ +
+ +
+ +
+ +
+ + + Settings + res.config.settings + + form + {'module': 'estate'} + + +
\ 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..9e90ebe2fd4 --- /dev/null +++ b/estate/views/res_users_views.xml @@ -0,0 +1,16 @@ + + + + res.users.form.inherit.property + res.users + + + + + + + + + + + \ No newline at end of file diff --git a/estate_auction/__init__.py b/estate_auction/__init__.py new file mode 100644 index 00000000000..5607426d8a1 --- /dev/null +++ b/estate_auction/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controller diff --git a/estate_auction/__manifest__.py b/estate_auction/__manifest__.py new file mode 100644 index 00000000000..ecdba19bef2 --- /dev/null +++ b/estate_auction/__manifest__.py @@ -0,0 +1,28 @@ +{ + 'name': 'Real Estate Auction', + 'description': "This module allows creating property auction.", + 'author': 'Sudarshan Maity (sumai)', + 'website': '', + 'category': 'Real Estate', + 'version': '1.0', + 'license': 'LGPL-3', + 'depends': [ + 'estate', + ], + + 'data': [ + 'views/estate_property_views.xml', + 'views/estate_property_offer_views.xml', + 'data/auction_cron.xml', + 'data/estate_auction_website_template.xml', + 'data/auction_email_templates.xml', + ], + + 'assets': { + 'web.assets_frontend': [ + 'estate_auction/static/src/js/auction_countdown.js', + ], + }, + + 'installable': True, +} diff --git a/estate_auction/controller/__init__.py b/estate_auction/controller/__init__.py new file mode 100644 index 00000000000..12a7e529b67 --- /dev/null +++ b/estate_auction/controller/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/estate_auction/controller/main.py b/estate_auction/controller/main.py new file mode 100644 index 00000000000..57e9eb070c7 --- /dev/null +++ b/estate_auction/controller/main.py @@ -0,0 +1,81 @@ +from odoo import fields +from odoo.http import request, route +from odoo.addons.estate.controllers.main import EstateWebsite + + +class EstateAuctionWebsite(EstateWebsite): + + @route( + ["/properties", "/properties/page/"], + type="http", + auth="public", + website=True + ) + def properties(self, page=1, **kwargs): + website = request.website + domain = [] + selling_mode = kwargs.get("selling_mode") + if selling_mode: + domain.append(('selling_mode', '=', selling_mode)) + found_properties = request.env["estate.property"].sudo().search(domain) + + pager = website.pager( + url="/properties", + total=len(found_properties), + page=page, + step=self._property_per_page, + url_args=kwargs, + ) + + offset = pager["offset"] + properties_list = found_properties[ + offset: + offset + self._property_per_page + ] + + return request.render( + "estate.estate_properties_template", + { + "properties": properties_list, + "pager": pager, + "selling_mode": selling_mode, + } + ) + + @route( + "/properties//bid", + type="http", + auth="user", + methods=["POST"], + website=True, + ) + def submit_auction_bid(self, property_id, amount, **kwargs): + property_url = f"/properties/{property_id}" + property_obj = request.env["estate.property"].sudo().browse(property_id) + if (property_obj.selling_mode != "auction" or property_obj.auction_state != "in_progress"): + return request.redirect(property_url) + if (property_obj.auction_end and property_obj.auction_end <= fields.Datetime.now()): + return request.redirect(property_url) + + request.env["estate.property.offer"].sudo().create({ + "property_id": property_obj.id, + "partner_id": request.env.user.partner_id.id, + "price": float(amount), + }) + + return request.redirect(f"{property_url}/bid/success") + + @route( + "/properties//bid/success", + type="http", + auth="user", + website=True, + ) + def auction_bid_success(self, property_id, **kwargs): + property_obj = request.env["estate.property"].sudo().browse(property_id) + return request.render( + "estate_auction.website_auction_bid_success", + { + "property": property_obj + } + ) diff --git a/estate_auction/data/auction_cron.xml b/estate_auction/data/auction_cron.xml new file mode 100644 index 00000000000..6f87a04cd02 --- /dev/null +++ b/estate_auction/data/auction_cron.xml @@ -0,0 +1,14 @@ + + + + + close auctions automatically + + code + model._cron_close_auction(job_count=20) + + 1 + minutes + + + diff --git a/estate_auction/data/auction_email_templates.xml b/estate_auction/data/auction_email_templates.xml new file mode 100644 index 00000000000..c70faec7fa5 --- /dev/null +++ b/estate_auction/data/auction_email_templates.xml @@ -0,0 +1,53 @@ + + + + Auction Offer Accepted + + Your Auction Bid Has Been Accepted + {{ user.email_formatted }} + {{ object.partner_id.id }} + + +

Hello ,

+

Congratulations!

+

+ Your bid for property + + + + has been accepted. +

+

+ Final Bid Amount: + + + +

+
+
+ + + Auction Offer Rejected + + Auction Result + {{ user.email_formatted }} + {{ object.partner_id.id }} + + +

Hello ,

+

+ Thank you for participating + in the auction for + + + . +

+

+ Unfortunately your offer + was not selected. +

+ +
+
+ +
\ No newline at end of file diff --git a/estate_auction/data/estate_auction_website_template.xml b/estate_auction/data/estate_auction_website_template.xml new file mode 100644 index 00000000000..d5565a9ccbb --- /dev/null +++ b/estate_auction/data/estate_auction_website_template.xml @@ -0,0 +1,129 @@ + + + + + + + + \ No newline at end of file diff --git a/estate_auction/models/__init__.py b/estate_auction/models/__init__.py new file mode 100644 index 00000000000..49e424d7342 --- /dev/null +++ b/estate_auction/models/__init__.py @@ -0,0 +1,2 @@ +from . import estate_property +from . import estate_property_offer diff --git a/estate_auction/models/estate_property.py b/estate_auction/models/estate_property.py new file mode 100644 index 00000000000..afcc5f99fa6 --- /dev/null +++ b/estate_auction/models/estate_property.py @@ -0,0 +1,90 @@ +from odoo import models, fields, api + +from odoo.exceptions import UserError, ValidationError + + +class EstateProperty(models.Model): + _inherit = "estate.property" + + selling_mode = fields.Selection([ + ('regular', 'Regular'), + ('auction', 'Auction') + ], + string="Selling Mode", default='regular', required=True) + + auction_state = fields.Selection([ + ('draft', 'Draft'), + ('in_progress', 'In Progress'), + ('ended', 'Ended'), + ('sold', 'Sold') + ], + string="Auction State", default='draft', readonly=True) + auction_end = fields.Datetime(string="Auction End Time") + # highest_offer = fields.Float(string="Highest Offer", compute="_compute_best_price", readonly=True) + highest_bidder_id = fields.Many2one("res.partner", string="Highest Bidder", compute="_compute_best_price", readonly=True) + + @api.onchange('selling_mode') + def _onchange_selling_mode(self): + for record in self: + if record.selling_mode == 'auction': + record.auction_state = 'draft' + else: + record.auction_state = False + + @api.depends("offer_ids.price") + def _compute_best_price(self): + super()._compute_best_price() + for record in self: + highest_offer = record.offer_ids[:1] + record.highest_bidder_id = highest_offer.partner_id + + def write(self, vals): + for record in self: + if (vals.get('selling_mode') == 'regular' and record.auction_state in ['in_progress', 'sold', 'ended']): + raise ValidationError("You cannot change selling mode after auction starts.") + return super().write(vals) + + @api.model + def _cron_close_auction(self, job_count=20): + domain = [ + ('selling_mode', '=', 'auction'), + ('auction_state', '=', 'in_progress'), + ('auction_end', '<=', fields.Datetime.now()) + ] + expired_auction_offer = self.search(domain, order='auction_end asc', limit=job_count) + + accepted_template = self.env.ref('estate_auction.email_template_auction_offer_accepted') + rejected_template = self.env.ref('estate_auction.email_template_auction_offer_rejected') + + for property_record in expired_auction_offer: + high_offer = property_record.offer_ids[:1] + if high_offer: + high_offer.action_accept() + high_offer.property_id._mark_as_sold() + property_record.auction_state = 'sold' + + accepted_template.send_mail(high_offer.id, force_send=True) + rejected_offers = property_record.offer_ids.filtered(lambda offer: offer.id != high_offer.id) + for offer in rejected_offers: + rejected_template.send_mail(offer.id, force_send=True) + else: + property_record.auction_state = 'ended' + + def action_start_auction(self): + for record in self: + if record.selling_mode != 'auction': + raise UserError("This property is not auction type.") + if not record.auction_end: + raise UserError("Please set auction end time.") + record.auction_state = 'in_progress' + + def action_end_auction(self): + for record in self: + # if not record.offer_ids: + # raise UserError("Atleast add one offer") + high_offer = record.offer_ids[:1] + if high_offer: + high_offer.action_accept() + record.auction_state = 'sold' + else: + record.auction_state = 'ended' diff --git a/estate_auction/models/estate_property_offer.py b/estate_auction/models/estate_property_offer.py new file mode 100644 index 00000000000..a6712165844 --- /dev/null +++ b/estate_auction/models/estate_property_offer.py @@ -0,0 +1,21 @@ +from odoo import models, api, fields +from odoo.exceptions import UserError + + +class EstatePropertyOffer(models.Model): + _inherit = "estate.property.offer" + + is_auction_property = fields.Boolean(compute="_compute_is_auction_property") + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + property_record = self.env['estate.property'].search([('id', '=', vals.get('property_id'))], limit=1) + if (property_record.selling_mode == 'auction' and property_record.auction_state != 'in_progress'): + raise UserError("Auction is not active.") + return super().create(vals_list) + + @api.depends('property_id.selling_mode') + def _compute_is_auction_property(self): + for record in self: + record.is_auction_property = record.property_id.selling_mode == 'auction' diff --git a/estate_auction/static/src/js/auction_countdown.js b/estate_auction/static/src/js/auction_countdown.js new file mode 100644 index 00000000000..99056aa8024 --- /dev/null +++ b/estate_auction/static/src/js/auction_countdown.js @@ -0,0 +1,65 @@ +const countdown = document.getElementById("auction_countdown"); +if (countdown) { + const endTime = new Date( countdown.dataset.endTime).getTime(); + + function updateCountdown() { + const now = new Date().getTime(); + const distance = endTime - now; + + if (distance <= 0) { + countdown.innerHTML = "00d 00h 00m 00s"; + return; + } + + const days = + Math.floor( + distance / (1000 * 60 * 60 * 24) + ); + + const hours = + Math.floor( + ( + distance % + (1000 * 60 * 60 * 24) + ) + / + (1000 * 60 * 60) + ); + + const minutes = + Math.floor( + ( + distance % + (1000 * 60 * 60) + ) + / + (1000 * 60) + ); + + const seconds = + Math.floor( + ( + distance % + (1000 * 60) + ) + / + 1000 + ); + + countdown.innerHTML = + days + "d " + + + hours + "h " + + + minutes + "m " + + + seconds + "s"; + } + + updateCountdown(); + + setInterval( + updateCountdown, + 1000 + ); +} diff --git a/estate_auction/views/estate_property_offer_views.xml b/estate_auction/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..81864c47176 --- /dev/null +++ b/estate_auction/views/estate_property_offer_views.xml @@ -0,0 +1,20 @@ + + + + estate.property.offer.list.auction + estate.property.offer + + + + status in ('accepted', 'refused') or is_auction_property + + + status in ('accepted', 'refused') or is_auction_property + + + + + + + + \ No newline at end of file diff --git a/estate_auction/views/estate_property_views.xml b/estate_auction/views/estate_property_views.xml new file mode 100644 index 00000000000..9f88f40ef2e --- /dev/null +++ b/estate_auction/views/estate_property_views.xml @@ -0,0 +1,76 @@ + + + + estate.property.form.auction + estate.property + + + + + + + + + + diff --git a/estate_auction/__manifest__.py b/estate_auction/__manifest__.py index ecdba19bef2..705ace67af6 100644 --- a/estate_auction/__manifest__.py +++ b/estate_auction/__manifest__.py @@ -8,6 +8,9 @@ 'license': 'LGPL-3', 'depends': [ 'estate', + 'estate_event', + 'estate_account', + 'sign' ], 'data': [ diff --git a/estate_auction/controller/main.py b/estate_auction/controller/main.py index 57e9eb070c7..44a4f64cb74 100644 --- a/estate_auction/controller/main.py +++ b/estate_auction/controller/main.py @@ -69,8 +69,7 @@ def submit_auction_bid(self, property_id, amount, **kwargs): "/properties//bid/success", type="http", auth="user", - website=True, - ) + website=True) def auction_bid_success(self, property_id, **kwargs): property_obj = request.env["estate.property"].sudo().browse(property_id) return request.render( diff --git a/estate_auction/data/auction_cron.xml b/estate_auction/data/auction_cron.xml index 6f87a04cd02..b588d347a31 100644 --- a/estate_auction/data/auction_cron.xml +++ b/estate_auction/data/auction_cron.xml @@ -11,4 +11,24 @@ minutes + + Check Signed Agreements + + code + model.search([]).action_check_signed_agreement() + 1 + minutes + + + + + Check Auction Invoice Payment + + code + model.action_check_invoice_payment() + 1 + minutes + + + diff --git a/estate_auction/data/auction_email_templates.xml b/estate_auction/data/auction_email_templates.xml index c70faec7fa5..af75a70211d 100644 --- a/estate_auction/data/auction_email_templates.xml +++ b/estate_auction/data/auction_email_templates.xml @@ -23,6 +23,32 @@

+

+ Please sign the auction agreement before the deadline. +

+

+ Signature Deadline: + + + +

+

+ Click below to review and sign the agreement document. +

+

+ + Sign Agreement + +

+

+ Thank you for participating in the auction. +

diff --git a/estate_auction/models/estate_property.py b/estate_auction/models/estate_property.py index afcc5f99fa6..7d4b63ca13f 100644 --- a/estate_auction/models/estate_property.py +++ b/estate_auction/models/estate_property.py @@ -1,3 +1,5 @@ +from datetime import timedelta + from odoo import models, fields, api from odoo.exceptions import UserError, ValidationError @@ -15,6 +17,8 @@ class EstateProperty(models.Model): auction_state = fields.Selection([ ('draft', 'Draft'), ('in_progress', 'In Progress'), + ('agreement_sent', 'Agreement Sent'), + ('in_payment', 'In Payment'), ('ended', 'Ended'), ('sold', 'Sold') ], @@ -22,6 +26,10 @@ class EstateProperty(models.Model): auction_end = fields.Datetime(string="Auction End Time") # highest_offer = fields.Float(string="Highest Offer", compute="_compute_best_price", readonly=True) highest_bidder_id = fields.Many2one("res.partner", string="Highest Bidder", compute="_compute_best_price", readonly=True) + sign_request_id = fields.Many2one("sign.request", string="Sign Request") + sign_url = fields.Char(string="Sign URL") + agreement_signed = fields.Boolean(string="Agreement Signed") + signature_deadline = fields.Datetime(string="Signature Deadline") @api.onchange('selling_mode') def _onchange_selling_mode(self): @@ -60,8 +68,11 @@ def _cron_close_auction(self, job_count=20): high_offer = property_record.offer_ids[:1] if high_offer: high_offer.action_accept() - high_offer.property_id._mark_as_sold() - property_record.auction_state = 'sold' + property_record.action_create_sign_request() + accepted_template.send_mail(high_offer.id, force_send=True) + # high_offer.property_id._mark_as_sold() + # property_record.auction_state = 'sold' + property_record.auction_state = 'agreement_sent' accepted_template.send_mail(high_offer.id, force_send=True) rejected_offers = property_record.offer_ids.filtered(lambda offer: offer.id != high_offer.id) @@ -79,12 +90,81 @@ def action_start_auction(self): record.auction_state = 'in_progress' def action_end_auction(self): + accepted_template = self.env.ref('estate_auction.email_template_auction_offer_accepted') + rejected_template = self.env.ref('estate_auction.email_template_auction_offer_rejected') for record in self: # if not record.offer_ids: # raise UserError("Atleast add one offer") high_offer = record.offer_ids[:1] if high_offer: high_offer.action_accept() - record.auction_state = 'sold' + # high_offer.property_id._mark_as_sold() + # record.auction_state = 'sold' + record.action_create_sign_request() + accepted_template.send_mail(high_offer.id, force_send=True) + record.auction_state = 'agreement_sent' + + rejected_offers = record.offer_ids.filtered(lambda offer: offer.id != high_offer.id) + for offer in rejected_offers: + rejected_template.send_mail(offer.id, force_send=True) else: record.auction_state = 'ended' + + def action_view_website_property(self): + for record in self: + return { + 'type': 'ir.actions.act_url', + 'url': f'/properties/{record.id}', + 'target': 'self', + } + + def action_create_sign_request(self): + sign_template = self.env['sign.template'].search([('name', '=', 'auction_agreement.pdf')], limit=1) + if not sign_template: + raise UserError("Auction Agreement template not found.") + role = sign_template.sign_item_ids[:1].responsible_id + base_url = self.get_base_url() + for record in self: + if not record.buyer_id: + raise UserError("Buyer not found.") + deadline = (fields.Datetime.now() + timedelta(days=2)) + sign_request = self.env['sign.request'].create({ + 'template_id': sign_template.id, + 'reference': f'Auction Agreement - {record.name}', + 'subject': 'Property Auction Agreement', + 'message': ('Please sign the auction agreement.'), + 'validity': deadline.date(), + 'request_item_ids': [(0, 0, {'partner_id': record.buyer_id.id, 'role_id': role.id})] + }) + sign_request_item = sign_request.request_item_ids[:1] + # sign_url = sign_request_item.access_url + sign_url = ( + f"{base_url}" + f"/sign/document/" + f"{sign_request.id}/" + f"{sign_request_item.access_token}" + ) + record.write({ + 'sign_request_id': sign_request.id, + 'signature_deadline': deadline, + 'sign_url': sign_url, + }) + + def action_check_signed_agreement(self): + for record in self: + if not record.sign_request_id: + continue + sign_request_item = record.sign_request_id.request_item_ids[:1] + if sign_request_item.signing_date: + if record.state != 'offer_accepted': + continue + record.agreement_signed = True + record._mark_as_sold() + record.auction_state = 'in_payment' + + def action_check_invoice_payment(self): + properties = self.search([('auction_state', '=', 'in_payment')]) + for record in properties: + invoice = record.invoice_ids[:1] + if invoice and invoice.payment_state == 'paid': + record.auction_state = 'sold' diff --git a/estate_auction/models/estate_property_offer.py b/estate_auction/models/estate_property_offer.py index a6712165844..bb2e65af6dd 100644 --- a/estate_auction/models/estate_property_offer.py +++ b/estate_auction/models/estate_property_offer.py @@ -11,8 +11,16 @@ class EstatePropertyOffer(models.Model): def create(self, vals_list): for vals in vals_list: property_record = self.env['estate.property'].search([('id', '=', vals.get('property_id'))], limit=1) - if (property_record.selling_mode == 'auction' and property_record.auction_state != 'in_progress'): - raise UserError("Auction is not active.") + if property_record.selling_mode == 'auction': + if property_record.auction_state != 'in_progress': + raise UserError("Auction is not active.") + existing_offers = property_record.offer_ids + property_record.offer_ids = [(5, 0, 0)] + records = super().create(vals_list) + property_record.offer_ids = [(4, offer.id) for offer in existing_offers] + for record in records: + record.property_id.state = 'offer_received' + return records return super().create(vals_list) @api.depends('property_id.selling_mode') diff --git a/estate_auction/views/estate_property_offer_views.xml b/estate_auction/views/estate_property_offer_views.xml index 81864c47176..5a5ab78c5c6 100644 --- a/estate_auction/views/estate_property_offer_views.xml +++ b/estate_auction/views/estate_property_offer_views.xml @@ -12,7 +12,7 @@ status in ('accepted', 'refused') or is_auction_property - + diff --git a/estate_auction/views/estate_property_views.xml b/estate_auction/views/estate_property_views.xml index 9f88f40ef2e..1b28bbc4627 100644 --- a/estate_auction/views/estate_property_views.xml +++ b/estate_auction/views/estate_property_views.xml @@ -7,7 +7,7 @@ + + + diff --git a/estate_crm/__init__.py b/estate_crm/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/estate_crm/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/estate_crm/__manifest__.py b/estate_crm/__manifest__.py new file mode 100644 index 00000000000..30e4f3ff913 --- /dev/null +++ b/estate_crm/__manifest__.py @@ -0,0 +1,19 @@ +{ + 'name': 'Real Estate Crm', + 'description': "This module allows creating property offer lead in Crm.", + 'author': 'Sudarshan Maity (sumai)', + 'website': '', + 'category': 'Real Estate', + 'version': '1.0', + 'license': 'LGPL-3', + 'depends': [ + 'estate', + 'crm', + ], + + 'data': [ + + ], + + 'installable': True, +} diff --git a/estate_crm/models/__init__.py b/estate_crm/models/__init__.py new file mode 100644 index 00000000000..6b65f241225 --- /dev/null +++ b/estate_crm/models/__init__.py @@ -0,0 +1 @@ +from . import estate_property_offer diff --git a/estate_crm/models/crm_lead.py b/estate_crm/models/crm_lead.py new file mode 100644 index 00000000000..49af297b26d --- /dev/null +++ b/estate_crm/models/crm_lead.py @@ -0,0 +1,7 @@ +from odoo import models, fields + + +class CrmLead(models.Model): + _inherit = "crm.lead" + + offer_ids = fields.Integer() diff --git a/estate_crm/models/estate_property_offer.py b/estate_crm/models/estate_property_offer.py new file mode 100644 index 00000000000..a4f7f07c8b0 --- /dev/null +++ b/estate_crm/models/estate_property_offer.py @@ -0,0 +1,50 @@ +from odoo import api, models, fields + + +class EstatePropertyOffer(models.Model): + _inherit = "estate.property.offer" + + lead_id = fields.Many2one("crm.lead") + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + for record in records: + tags = [] + for tag in record.property_id.tags_ids: + crm_tag = self.env['crm.tag'].search([('name', '=', tag.name)]) + if not crm_tag: + crm_tag = self.env['crm.tag'].create({'name': tag.name}) + tags.append(crm_tag.id) + + new_lead = self.env['crm.lead'].create({ + 'name': f'Offer By {record.partner_id.name}', + 'expected_revenue': record.price, + 'partner_id': record.partner_id.id, + 'user_id': record.property_id.salesperson_id.id, + 'email_from': record.partner_id.email, + 'phone': record.partner_id.phone, + 'date_deadline': record.date_deadline, + 'tag_ids': [(6, 0, tags)], + # 'offer_ids': record.lead_id.id + }) + record.lead_id = new_lead.id + return records + + def action_accept(self): + xyz = super().action_accept() + for record in self: + if record.lead_id: + record.lead_id.action_set_won() + remaining = (record.property_id.offer_ids - record) + for other in remaining: + if other.lead_id: + other.lead_id.action_set_lost() + return xyz + + def action_refuse(self): + abc = super().action_refuse() + for record in self: + if record.lead_id: + record.lead_id.action_set_lost() + return abc diff --git a/estate_event/__init__.py b/estate_event/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/estate_event/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/estate_event/__manifest__.py b/estate_event/__manifest__.py new file mode 100644 index 00000000000..f5291ecfdfe --- /dev/null +++ b/estate_event/__manifest__.py @@ -0,0 +1,20 @@ +{ + 'name': 'Real Estate Event', + 'description': "This module allows creating Event.", + 'author': 'Sudarshan Maity (sumai)', + 'website': '', + 'category': 'Real Estate', + 'version': '1.0', + 'license': 'LGPL-3', + 'depends': [ + 'estate', + 'event', + ], + + 'data': [ + 'views/estate_property_views.xml', + # 'views/estate_property_offer_views.xml' + ], + + 'installable': True, +} diff --git a/estate_event/models/__init__.py b/estate_event/models/__init__.py new file mode 100644 index 00000000000..2ea39768711 --- /dev/null +++ b/estate_event/models/__init__.py @@ -0,0 +1,4 @@ +from . import estate_property_offer +from . import estate_property_event +from . import estate_property_event_registration +from . import event_event diff --git a/estate_event/models/estate_property_event.py b/estate_event/models/estate_property_event.py new file mode 100644 index 00000000000..ed53492fd6d --- /dev/null +++ b/estate_event/models/estate_property_event.py @@ -0,0 +1,35 @@ +from odoo import fields, models, api + + +class EstateProperty(models.Model): + _inherit = "estate.property" + + event_ids = fields.One2many("event.event", "property_id", string="Events") + event_count = fields.Integer(compute="_compute_event_count") + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + for record in records: + self.env['event.event'].create({ + 'name': f'Open house - {record.name}', + 'organizer_id': record.salesperson_id.id, + 'property_id': record.id, + }) + return records + + @api.depends("event_ids") + def _compute_event_count(self): + for record in self: + record.event_count = len(record.event_ids) + + def action_view_events(self): + for record in self: + event = self.env['event.event'].search([('property_id', '=', record.id)]) + return { + 'type': 'ir.actions.act_window', + 'name': 'Event', + 'res_model': 'event.event', + 'view_mode': 'form', + 'res_id': event.id, + } diff --git a/estate_event/models/estate_property_event_registration.py b/estate_event/models/estate_property_event_registration.py new file mode 100644 index 00000000000..2bab82e69cf --- /dev/null +++ b/estate_event/models/estate_property_event_registration.py @@ -0,0 +1,18 @@ +from odoo import models, api + + +class EventRegistration(models.Model): + _inherit = "event.registration" + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + for record in records: + partner = self.env['res.partner'].search([('email', '=', record.email)], limit=1) + if not partner: + partner = self.env['res.partner'].create({ + 'name': record.name, + 'email': record.email, + }) + record.partner_id = partner.id + return records diff --git a/estate_event/models/estate_property_offer.py b/estate_event/models/estate_property_offer.py new file mode 100644 index 00000000000..642cf0f1b57 --- /dev/null +++ b/estate_event/models/estate_property_offer.py @@ -0,0 +1,45 @@ +from odoo import models, fields, api + +from odoo.fields import Datetime + + +class EstatePropertyOffer(models.Model): + _inherit = "estate.property.offer" + + available_partner_ids = fields.Many2many("res.partner", compute="_compute_available_partner_ids") + partner_id = fields.Many2one("res.partner", string="Partner", required=True, domain="[('id', 'in', available_partner_ids)]") + + @api.depends('property_id') + def _compute_available_partner_ids(self): + for record in self: + partners = self.env['res.partner'] + if record.property_id: + event = self.env['event.event'].search([('property_id', '=', record.property_id._origin.id)], limit=1) + registrations = self.env['event.registration'].search([ + ('event_id', '=', event.id), + ('state', '=', 'done') + ]) + emails = registrations.mapped('email') + event_partners = self.env['res.partner'].search([('email', 'in', emails)]) + visits = self.env['estate.property.visit'].search([ + ('propert_id', '=', record.property_id._origin.id), + ('end_date', '<=', Datetime.now()) + ]) + visit_partners = visits.mapped('partner_id') + partners = visit_partners | event_partners + record.available_partner_ids = partners + + # @api.model_create_multi + # def create(self, vals_list): + # records = super().create(vals_list) + # for record in records: + # event = self.env['event.event'].search([('property_id', '=', record.property_id.id)], limit=1) + + # registration = self.env['event.registration'].search([ + # ('event_id', '=', event.id), + # ('partner_id', '=', record.partner_id.id), + # ('state', '=', 'done') + # ], limit=1) + # if not registration: + # raise UserError("This partner has not attended the event.") + # return records diff --git a/estate_event/models/event_event.py b/estate_event/models/event_event.py new file mode 100644 index 00000000000..f2ab34b635a --- /dev/null +++ b/estate_event/models/event_event.py @@ -0,0 +1,7 @@ +from odoo import models, fields + + +class EventEvent(models.Model): + _inherit = "event.event" + + property_id = fields.Many2one("estate.property", string="Property") diff --git a/estate_event/views/estate_property_offer_views.xml b/estate_event/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..80833afbdb5 --- /dev/null +++ b/estate_event/views/estate_property_offer_views.xml @@ -0,0 +1,15 @@ + \ No newline at end of file diff --git a/estate_event/views/estate_property_views.xml b/estate_event/views/estate_property_views.xml new file mode 100644 index 00000000000..38ae3829594 --- /dev/null +++ b/estate_event/views/estate_property_views.xml @@ -0,0 +1,19 @@ + + + + res.property.form.inherit.property + estate.property + + + +
+ +
+
+
+
+ +
+ \ No newline at end of file diff --git a/estate_sales/__init__.py b/estate_sales/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/estate_sales/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/estate_sales/__manifest__.py b/estate_sales/__manifest__.py new file mode 100644 index 00000000000..7cf9f5c10b8 --- /dev/null +++ b/estate_sales/__manifest__.py @@ -0,0 +1,19 @@ +{ + 'name': 'Real Estate Sales Quotation', + 'description': "This module allows creating property offer quotation in Sale.", + 'author': 'Sudarshan Maity (sumai)', + 'website': '', + 'category': 'Real Estate', + 'version': '1.0', + 'license': 'LGPL-3', + 'depends': [ + 'estate', + 'sale', + ], + + 'data': [ + + ], + + 'installable': True, +} diff --git a/estate_sales/models/__init__.py b/estate_sales/models/__init__.py new file mode 100644 index 00000000000..6b65f241225 --- /dev/null +++ b/estate_sales/models/__init__.py @@ -0,0 +1 @@ +from . import estate_property_offer diff --git a/estate_sales/models/estate_property_offer.py b/estate_sales/models/estate_property_offer.py new file mode 100644 index 00000000000..4b8ad49ae68 --- /dev/null +++ b/estate_sales/models/estate_property_offer.py @@ -0,0 +1,20 @@ +from odoo import Command, models + + +class EstatePropertyOffer(models.Model): + _inherit = "estate.property.offer" + + def action_accept(self): + sale = super().action_accept() + for record in self: + self.env['sale.order'].create({ + 'partner_id': record.partner_id.id, + 'order_line': [ + Command.create({ + 'name': f' Offer by {record.partner_id.name} on{record.property_id.name}', + 'product_uom_qty': 1, + 'price_unit': record.price + }) + ] + }) + return sale