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..637de0e498a --- /dev/null +++ b/estate/controllers/main.py @@ -0,0 +1,49 @@ +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..7487f41adc5 --- /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, ondelete='cascade') + 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 00000000000..729b9d68897 Binary files /dev/null and b/estate/static/description/icon.png differ diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..f9841b70585 --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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_account/__init__.py b/estate_account/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/estate_account/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/estate_account/__manifest__.py b/estate_account/__manifest__.py new file mode 100644 index 00000000000..c85aba160b7 --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,20 @@ +{ + 'name': 'Real Estate Account', + 'description': "This module allows managing property invoice.", + 'author': 'Sudarshan Maity (sumai)', + 'website': '', + 'category': 'Real Estate', + 'version': '1.0', + 'license': 'LGPL-3', + 'depends': [ + 'estate', + 'estate_event', + 'account', + ], + + 'data': [ + 'views/estate_property_views.xml', + ], + + 'installable': True, +} diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py new file mode 100644 index 00000000000..e6773ef3d05 --- /dev/null +++ b/estate_account/models/__init__.py @@ -0,0 +1,2 @@ +from . import estate_property +from . import account_move diff --git a/estate_account/models/account_move.py b/estate_account/models/account_move.py new file mode 100644 index 00000000000..a75910d8a3d --- /dev/null +++ b/estate_account/models/account_move.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class AccountMove(models.Model): + _inherit = "account.move" + + property_id = fields.Many2one("estate.property", string="Property", copy=False, readonly=True) diff --git a/estate_account/models/estate_property.py b/estate_account/models/estate_property.py new file mode 100644 index 00000000000..e318f5c14c9 --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,52 @@ +from odoo import models, fields, api +from odoo import Command + + +class EstateProperty(models.Model): + _inherit = "estate.property" + + invoice_ids = fields.One2many("account.move", "property_id") + invoice_count = fields.Integer(compute="_compute_invoice_count", string="Total Invoices") + + @api.depends("invoice_ids") + def _compute_invoice_count(self): + for record in self: + record.invoice_count = len(record.invoice_ids) + + def _mark_as_sold(self): + sold = super()._mark_as_sold() + for record in self: + self.env['account.move'].create({ + 'partner_id': record.buyer_id.id, + 'move_type': 'out_invoice', + 'property_id': record.id, + 'invoice_line_ids': [ + Command.create({ + 'name': record.name, + 'quantity': 1, + 'price_unit': record.selling_price, + }), + Command.create({ + 'name': f'Commission (6%) on {record.name}', + 'quantity': 1, + 'price_unit': record.selling_price * 0.06, + }), + Command.create({ + 'name': 'Administrative Fees', + 'quantity': 1, + 'price_unit': 100.0, + }) + ], + }) + return sold + + def action_view_invoice(self): + for record in self: + invoice = self.env['account.move'].search([('property_id', '=', record.id)], limit=1) + return { + 'type': 'ir.actions.act_window', + 'name': 'Invoices', + 'res_model': 'account.move', + 'view_mode': 'form', + 'res_id': invoice.id, + } diff --git a/estate_account/views/estate_property_views.xml b/estate_account/views/estate_property_views.xml new file mode 100644 index 00000000000..ca0a25410cc --- /dev/null +++ b/estate_account/views/estate_property_views.xml @@ -0,0 +1,17 @@ + + + estate.property.form.invoice.button + estate.property + + + +
+ +
+
+ +
+
+
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..705ace67af6 --- /dev/null +++ b/estate_auction/__manifest__.py @@ -0,0 +1,31 @@ +{ + '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', + 'estate_event', + 'estate_account', + 'sign' + ], + + '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..44a4f64cb74 --- /dev/null +++ b/estate_auction/controller/main.py @@ -0,0 +1,80 @@ +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..b588d347a31 --- /dev/null +++ b/estate_auction/data/auction_cron.xml @@ -0,0 +1,34 @@ + + + + + close auctions automatically + + code + model._cron_close_auction(job_count=20) + + 1 + 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 new file mode 100644 index 00000000000..af75a70211d --- /dev/null +++ b/estate_auction/data/auction_email_templates.xml @@ -0,0 +1,79 @@ + + + + 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: + + + +

+

+ 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. +

+
+
+ + + 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..7d4b63ca13f --- /dev/null +++ b/estate_auction/models/estate_property.py @@ -0,0 +1,170 @@ +from datetime import timedelta + +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'), + ('agreement_sent', 'Agreement Sent'), + ('in_payment', 'In Payment'), + ('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) + 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): + 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() + 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) + 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): + 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() + # 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 new file mode 100644 index 00000000000..bb2e65af6dd --- /dev/null +++ b/estate_auction/models/estate_property_offer.py @@ -0,0 +1,29 @@ +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': + 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') + 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..5a5ab78c5c6 --- /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..1b28bbc4627 --- /dev/null +++ b/estate_auction/views/estate_property_views.xml @@ -0,0 +1,105 @@ + + + + estate.property.form.auction + estate.property + + + + + + + + + + + + estate.property.kanban.auction + estate.property + + + + + + + +
+ + In Progress + + + Ended + + + Sold + +
+
+
+
+ +
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