Skip to content

Commit 85518aa

Browse files
committed
[ADD] estate_auction: add auction functionality to real estate module
- Implement automated auction sale mode for properties - Create countdown timer for active auctions - Add automatic auction closing via scheduled cron job - Implement auction-specific offers handling - Send email notifications upon offer acceptance/rejection - Add web interface for placing bids on auction properties - Update property views with auction-specific UI components
1 parent d74ba13 commit 85518aa

18 files changed

+691
-8
lines changed

estate/views/estate_property_web_views.xml

+7-7
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@
7676
<p class="mb-2">
7777
<t t-foreach="property.tag_ids" t-as="tag">
7878
<span
79-
class="badge bg-info-subtle text-info-emphasis me-1 mb-1">
79+
class="badge text-bg-secondary text-info-emphasis me-1 mb-1">
8080
<t t-out="tag.name"/>
8181
</span>
8282
</t>
@@ -91,7 +91,7 @@
9191
t-out="property.bedrooms"/> bed</span>
9292
<span t-if="property.living_area"><i
9393
class="fa fa-arrows-alt me-1"></i> <t
94-
t-out="property.living_area"/> sq.ft.</span>
94+
t-out="property.living_area"/> sq.m.</span>
9595
</div>
9696
<div class="mt-auto">
9797
<div
@@ -134,7 +134,7 @@
134134
<t t-if="property.tag_ids">
135135
<p class="mb-1">
136136
<t t-foreach="property.tag_ids" t-as="tag">
137-
<span class="badge bg-info-subtle text-info-emphasis me-1 mb-1">
137+
<span class="badge text-bg-secondary text-info-emphasis me-1 mb-1">
138138
<t t-out="tag.name"/>
139139
</span>
140140
</t>
@@ -176,7 +176,7 @@
176176
<t t-if="property.tag_ids">
177177
<p class="mb-3">
178178
<t t-foreach="property.tag_ids" t-as="tag">
179-
<span class="badge bg-info-subtle text-info-emphasis me-1">
179+
<span class="badge text-bg-secondary text-info-emphasis me-1">
180180
<t t-out="tag.name"/>
181181
</span>
182182
</t>
@@ -197,8 +197,8 @@
197197
<t
198198
t-foreach="[('fa-building', 'Property Type', property.property_type_id.name),
199199
('fa-bed', 'Bedrooms', property.bedrooms),
200-
('fa-arrows-alt', 'Total Area', '%s sq.ft.' % property.total_area),
201-
('fa-home', 'Living Area', '%s sq.ft.' % property.living_area)]"
200+
('fa-arrows-alt', 'Total Area', '%s sq.m.' % property.total_area),
201+
('fa-home', 'Living Area', '%s sq.m.' % property.living_area)]"
202202
t-as="info">
203203
<t t-if="info[2]">
204204
<div class="col-6">
@@ -222,7 +222,7 @@
222222
</div>
223223
<t t-set="details"
224224
t-value="[('Facades', property.facades), ('Garage', 'Yes' if property.garage else 'No'),
225-
('Garden', 'Yes' if property.garden else 'No'), ('Garden Area', '%s sq.ft.' % property.garden_area if property.garden_area else None),
225+
('Garden', 'Yes' if property.garden else 'No'), ('Garden Area', '%s sq.m.' % property.garden_area if property.garden_area else None),
226226
('Salesperson', property.salesperson_id.name), ('Company', property.company_id.name)]"/>
227227
<div class="card border-0 shadow-sm mb-4"
228228
t-if="any(val for _, val in details)">

estate_account/views/estate_property_views.xml

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<field name="model">estate.property</field>
66
<field name="inherit_id" ref="estate.estate_property_view_form"/>
77
<field name="arch" type="xml">
8-
<xpath expr="//sheet" position="before">
8+
<xpath expr="//sheet" position="inside">
99
<div class="oe_button_box" name="button_box">
1010
<button class="oe_stat_button" name="action_open_invoices" type="object" icon="fa-file-text-o">
1111
<field string="Invoices" name="invoice_ids" widget="statinfo"/>

estate_auction/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from . import models
2+
from . import controllers

estate_auction/__manifest__.py

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
'name': 'Real Estate Auctions',
3+
'version': '1.0',
4+
'depends': ['base', 'estate', 'web'],
5+
'author': 'Aryan Donga (ardo)',
6+
'description': 'Real Estate Auctions addon module',
7+
'application': False,
8+
'installable': True,
9+
'license': 'LGPL-3',
10+
'data': [
11+
'views/estate_property_web_views.xml',
12+
'views/estate_property_offer_views.xml',
13+
'views/estate_property_views.xml',
14+
'views/ir_cron.xml',
15+
'data/auction_email_templates.xml',
16+
],
17+
'assets': {
18+
'web.assets_backend': ['estate_auction/static/src/components/**/*'],
19+
'web.assets_frontend': ['estate_auction/static/src/js/countdown_timer.js'],
20+
},
21+
}
+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from . import estate_auction_offer
2+
from . import estate_auction
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from odoo.addons.estate.controllers.estate import EstateController
2+
from odoo.http import request, route
3+
4+
5+
class EstateAuctionFilter(EstateController):
6+
@route(['/properties', '/properties/page/<int:page>'], auth='public', website=True)
7+
def fetch_properties(self, page=1, **kwargs):
8+
response = super().fetch_properties(page, **kwargs)
9+
properties = response.qcontext.get('properties')
10+
query_params = response.qcontext.get('pager').get('url_args', {})
11+
12+
sale_filter = kwargs.get('sale_mode', False)
13+
if sale_filter:
14+
if sale_filter in ['auction', 'regular']:
15+
properties = properties.filtered(lambda p: p.sale_mode == sale_filter)
16+
query_params.update({'sale_mode': sale_filter})
17+
18+
items_per_page = 10
19+
total_items = len(properties)
20+
start_index = items_per_page * (page - 1)
21+
page_items = properties[start_index : start_index + items_per_page]
22+
23+
pager = request.website.pager(
24+
url='/properties',
25+
total=total_items,
26+
page=page,
27+
step=items_per_page,
28+
url_args=query_params,
29+
)
30+
31+
response.qcontext.update({
32+
'properties': page_items,
33+
'pager': pager,
34+
'sale_mode': sale_filter,
35+
})
36+
37+
return response
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from odoo.exceptions import AccessError, MissingError, UserError, ValidationError
2+
from odoo.http import Controller, request, route
3+
4+
5+
class EstateAuctionOfferController(Controller):
6+
@route('/offer/create', type='http', auth='public', website=True, methods=['POST'])
7+
def create_offer(self, **payload):
8+
request.validate_csrf(payload.get('csrf_token'))
9+
try:
10+
property_id = int(payload.get('property_id'))
11+
offer_price = float(payload.get('offer_price'))
12+
partner_id = int(payload.get('partner_id'))
13+
except ValueError:
14+
return request.redirect(
15+
f'/property/{payload.get("property_id")}?error=Invalid input'
16+
)
17+
18+
estate_property = request.env['estate.property'].sudo().browse(property_id)
19+
partner = request.env['res.partner'].sudo().browse(partner_id)
20+
if not estate_property.exists() or not partner.exists():
21+
return request.redirect(
22+
f'/property/{payload.get("property_id")}?error=Property or Partner not found'
23+
)
24+
25+
try:
26+
request.env['estate.property.offer'].sudo().create({
27+
'price': offer_price,
28+
'partner_id': partner_id,
29+
'property_id': property_id,
30+
})
31+
32+
return request.redirect(f'/offer/success/{property_id}')
33+
except ValidationError as e:
34+
return request.redirect(
35+
f'/property/{property_id}?error=Invalid+data:+{e!s}'
36+
)
37+
except (AccessError, MissingError):
38+
return request.redirect(
39+
f'/property/{property_id}?error=Permission+or+Not+Found'
40+
)
41+
except UserError as e:
42+
return request.redirect(f'/property/{property_id}?error={e!s}')
43+
44+
@route(
45+
'/offer/success/<int:property_id>',
46+
type='http',
47+
auth='public',
48+
website=True,
49+
methods=['GET'],
50+
)
51+
def offer_success(self, property_id):
52+
estate_property = request.env['estate.property'].sudo().browse(property_id)
53+
if not estate_property.exists():
54+
return request.not_found()
55+
56+
return request.render(
57+
'estate_auction.template_property_offer_success',
58+
{'property': estate_property},
59+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<odoo>
2+
<data>
3+
<record id="offer_accepted_email" model="mail.template">
4+
<field name="name">Offer Accepted Email</field>
5+
<field name="model_id" ref="estate.model_estate_property_offer"/>
6+
<field name="subject">Congratulations! Your offer for &apos;${object.property_id.name}&apos; has been
7+
accepted
8+
</field>
9+
<field name="email_from">${(object.company_id.email or '[email protected]') if
10+
object.company_id else '[email protected]'}
11+
</field>
12+
<field name="email_to">${object.partner_id.email}</field>
13+
<field name="body_html">
14+
<![CDATA[
15+
<p>Dear ${object.partner_id.name},</p>
16+
<p>We're pleased to inform you that your offer of <strong>${object.price}</strong> on the property <strong>${object.property_id.name}</strong> has been accepted!</p>
17+
<p>Our team will be in touch shortly to guide you through the next steps in the process.</p>
18+
<p>Warm regards,</p>
19+
<p>The Real Estate Team</p>
20+
]]>
21+
</field>
22+
</record>
23+
24+
<record id="offer_refused_email" model="mail.template">
25+
<field name="name">Offer Refused Email</field>
26+
<field name="model_id" ref="estate.model_estate_property_offer"/>
27+
<field name="subject">Sorry! Your offer for &apos;${object.property_id.name}&apos; has been refused</field>
28+
<field name="email_from">${(object.company_id.email or '[email protected]') if
29+
object.company_id else '[email protected]'}
30+
</field>
31+
<field name="email_to">${object.partner_id.email}</field>
32+
<field name="body_html">
33+
<![CDATA[
34+
<p>Dear ${object.partner_id.name},</p>
35+
<p>Thank you for your interest in the property <strong>${object.property_id.name}</strong> and for submitting your offer of <strong>${object.price}</strong>.</p>
36+
<p>We regret to inform you that your offer was not accepted.</p>
37+
<p>We appreciate your time and encourage you to explore other listings with us.</p>
38+
<p>Kind regards,</p>
39+
<p>The Real Estate Team</p>
40+
]]>
41+
</field>
42+
</record>
43+
</data>
44+
</odoo>

estate_auction/models/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from . import estate_property
2+
from . import estate_property_offer
+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from datetime import datetime
2+
3+
from dateutil.relativedelta import relativedelta
4+
from odoo import api, fields, models
5+
from odoo.exceptions import UserError
6+
7+
8+
class EstateProperty(models.Model):
9+
_inherit = 'estate.property'
10+
11+
auction_state = fields.Selection(
12+
[('01_template', 'Template'), ('02_auction', 'Auction'), ('03_sold', 'Sold')],
13+
string='Auction State',
14+
copy=False,
15+
default='01_template',
16+
required=True,
17+
readonly=True,
18+
)
19+
sale_mode = fields.Selection(
20+
[('auction', 'Auction'), ('regular', 'Regular')],
21+
string='Sale Mode',
22+
default='regular',
23+
required=True,
24+
)
25+
auction_end_time = fields.Datetime(
26+
string='End Time', default=(relativedelta(days=7) + datetime.today())
27+
)
28+
highest_bidder_id = fields.Many2one(
29+
'res.partner',
30+
string='Highest Bidder',
31+
compute='_compute_highest_bid',
32+
store=True,
33+
default=False,
34+
)
35+
highest_offer = fields.Float(
36+
'Highest Offer', compute='_compute_highest_bid', store=True, default=0
37+
)
38+
39+
@api.depends('offer_ids')
40+
def _compute_highest_bid(self):
41+
for record in self:
42+
if record.sale_mode != 'auction':
43+
continue
44+
if not record.offer_ids:
45+
record.highest_bidder_id = False
46+
record.highest_offer = 0.0
47+
continue
48+
max_offer = max(record.offer_ids, key=lambda x: x.price, default=None)
49+
if max_offer is not None:
50+
record.highest_bidder_id = max_offer.partner_id.id
51+
record.highest_offer = max_offer.price
52+
53+
def action_start_estate_auction(self):
54+
if not self.auction_end_time:
55+
raise UserError("You can't start the auction without defining end time.")
56+
57+
if self.auction_end_time <= datetime.now():
58+
raise UserError('Auction end time must be in the future.')
59+
60+
self.auction_state = '02_auction'
61+
62+
def automate_auction_sales(self):
63+
properties = self.search([
64+
('state', 'in', ('new', 'offer_received')),
65+
('sale_mode', '=', 'auction'),
66+
('auction_state', '=', '02_auction'),
67+
])
68+
69+
for estate in properties:
70+
if not estate.auction_end_time or datetime.now() <= estate.auction_end_time:
71+
continue
72+
73+
best_offer = self.env['estate.property.offer'].search(
74+
[('property_id', '=', estate.id)],
75+
order='price desc, create_date asc',
76+
limit=1,
77+
)
78+
if best_offer:
79+
best_offer.action_accept()
80+
estate.auction_state = '03_sold'
81+
estate.action_sold()
82+
else:
83+
estate.auction_state = '03_sold'
84+
estate.state = 'cancelled'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from odoo import api, fields, models
2+
from odoo.exceptions import UserError, ValidationError
3+
4+
5+
class EstatePropertyOffer(models.Model):
6+
_inherit = 'estate.property.offer'
7+
8+
is_parent_auction = fields.Boolean(
9+
string='Is Auction Offer', readonly=True, compute='_compute_is_parent_auction'
10+
)
11+
12+
@api.depends('property_id.sale_mode')
13+
def _compute_is_parent_auction(self):
14+
for record in self:
15+
record.is_parent_auction = record.property_id.sale_mode == 'auction'
16+
17+
@api.model_create_multi
18+
def create(self, offers):
19+
estate = self.env['estate.property'].browse(offers[0].get('property_id'))
20+
if not estate.exists():
21+
raise ValidationError('The specified property does not exist.')
22+
23+
if estate.sale_mode == 'regular':
24+
return super().create(offers)
25+
# if estate.auction_state != '02_auction':
26+
# raise ValidationError('The auction is not active.')
27+
if estate.state in ['sold', 'cancelled']:
28+
raise UserError(
29+
'You cannot create an offer on a sold or cancelled property.'
30+
)
31+
if estate.state == 'offer_accepted':
32+
raise UserError(
33+
'You cannot create an offer on a property with an accepted offer.'
34+
)
35+
36+
for offer in offers:
37+
if offer['price'] < estate.expected_price:
38+
raise UserError(
39+
'The offer price must be higher than the expected price.'
40+
)
41+
42+
estate.state = 'offer_received'
43+
return models.Model.create(self, offers)
44+
45+
def action_accept(self):
46+
super().action_accept()
47+
48+
accept_mail = self.env.ref('estate_auction.offer_accepted_email')
49+
refuse_mail = self.env.ref('estate_auction.offer_refused_email')
50+
51+
offers = self.property_id.offer_ids
52+
for record in self:
53+
offers_to_refuse = offers - record
54+
55+
if accept_mail:
56+
accept_mail.sudo().send_mail(record.id, force_send=True)
57+
if refuse_mail:
58+
for offer in offers_to_refuse:
59+
refuse_mail.sudo().send_mail(offer.id, force_send=True)
60+
61+
return True

0 commit comments

Comments
 (0)