-
Notifications
You must be signed in to change notification settings - Fork 1.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Test commit & push MEPL #166
base: 18.0
Are you sure you want to change the base?
Changes from all commits
efc5ed9
4b8aad3
3daab46
73a964c
8a7dcc6
c444774
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
from . import models |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
{ | ||
'name': 'Real Estate', | ||
'version': '1.0', | ||
'category': 'Tutorials', | ||
'depends': [ | ||
'base' | ||
], | ||
'data': [ | ||
'security/ir.model.access.csv', | ||
|
||
'views/estate_property_offer_views.xml', | ||
'views/estate_property_type_views.xml', | ||
'views/estate_property_tag_views.xml', | ||
'views/estate_property_views.xml', | ||
'views/estate_user_views.xml', | ||
|
||
'views/estate_menu_views.xml' | ||
], | ||
'installable': True, | ||
'application': True, | ||
'auto_install': False, | ||
'license': 'AGPL-3' | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
from . import property | ||
from . import property_offer | ||
from . import property_tag | ||
from . import property_type | ||
from . import res_user |
Original file line number | Diff line number | Diff line change | ||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,130 @@ | ||||||||||||||||
from dateutil.relativedelta import relativedelta | ||||||||||||||||
|
||||||||||||||||
from odoo import models, fields, api | ||||||||||||||||
from odoo.exceptions import UserError, ValidationError | ||||||||||||||||
from odoo.tools import float_is_zero, float_compare | ||||||||||||||||
|
||||||||||||||||
|
||||||||||||||||
class Property(models.Model): | ||||||||||||||||
_name = 'estate.property' | ||||||||||||||||
_description = 'Property' | ||||||||||||||||
_sql_constraints = [ | ||||||||||||||||
('check_expected_price', 'CHECK(expected_price > 0)', 'The Expected Price must be positive.'), | ||||||||||||||||
('check_selling_price', 'CHECK(selling_price >= 0)', 'The Selling Price must be positive.'), | ||||||||||||||||
('check_bedrooms', 'CHECK(bedrooms >= 0)', 'The number of bedrooms must be positive.'), | ||||||||||||||||
('check_living_area', 'CHECK(living_area > 0)', 'The living area must be positive.'), | ||||||||||||||||
('check_facades', 'CHECK(facades > 0)', 'The number of facades must be positive.'), | ||||||||||||||||
('check_name_unique', 'UNIQUE(name)', 'The Property name must be unique.') | ||||||||||||||||
] | ||||||||||||||||
_order = 'id desc' | ||||||||||||||||
|
||||||||||||||||
name = fields.Char(string='Title', required=True) | ||||||||||||||||
description = fields.Text() | ||||||||||||||||
postcode = fields.Char() | ||||||||||||||||
date_availability = fields.Date(string='Available From', copy=False, | ||||||||||||||||
default=lambda self: fields.Datetime.today() + relativedelta(months=3)) | ||||||||||||||||
expected_price = fields.Float(required=True) | ||||||||||||||||
selling_price = fields.Float(readonly=True, copy=False) | ||||||||||||||||
bedrooms = fields.Integer(default=2) | ||||||||||||||||
living_area = fields.Integer(string='Living Area (m²)') | ||||||||||||||||
facades = fields.Integer() | ||||||||||||||||
garage = fields.Boolean() | ||||||||||||||||
garden = fields.Boolean() | ||||||||||||||||
garden_area = fields.Integer(string='Garden Area (m²)') | ||||||||||||||||
garden_orientation = fields.Selection([ | ||||||||||||||||
('north', 'North'), | ||||||||||||||||
('east', 'East'), | ||||||||||||||||
('south', 'South'), | ||||||||||||||||
('west', 'West') | ||||||||||||||||
]) | ||||||||||||||||
active = fields.Boolean(default=True) | ||||||||||||||||
state = fields.Selection([ | ||||||||||||||||
('new', 'New'), | ||||||||||||||||
('offer_received', 'Offer Received'), | ||||||||||||||||
('offer_accepted', 'Offer Accepted'), | ||||||||||||||||
('sold', 'Sold'), | ||||||||||||||||
('canceled', 'Canceled') | ||||||||||||||||
], default='new', required=True) | ||||||||||||||||
property_type_id = fields.Many2one('estate.property.type', string='Property Type', required=True) | ||||||||||||||||
buyer_id = fields.Many2one('res.partner', string='Buyer', copy=False) | ||||||||||||||||
salesperson_id = fields.Many2one('res.users', string='Sales Person', default=lambda self: self.env.user) | ||||||||||||||||
tag_ids = fields.Many2many('estate.property.tag', string='Tags') | ||||||||||||||||
offer_ids = fields.One2many('estate.property.offer', 'property_id', string='Offers') | ||||||||||||||||
total_area = fields.Integer(string='Total Area (m²)', compute='_compute_total_area') | ||||||||||||||||
best_price = fields.Float(compute='_compute_best_price') | ||||||||||||||||
|
||||||||||||||||
@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: | ||||||||||||||||
if len(record.offer_ids) == 0: | ||||||||||||||||
record.best_price = 0 | ||||||||||||||||
else: | ||||||||||||||||
record.best_price = max(record.offer_ids.mapped('price')) | ||||||||||||||||
|
||||||||||||||||
@api.onchange('garden') | ||||||||||||||||
def _onchange_garden(self): | ||||||||||||||||
if self.garden: | ||||||||||||||||
self.garden_orientation = 'north' | ||||||||||||||||
self.garden_area = 10 | ||||||||||||||||
else: | ||||||||||||||||
self.garden_orientation = None | ||||||||||||||||
self.garden_area = 0 | ||||||||||||||||
|
||||||||||||||||
@api.constrains('selling_price', 'expected_price') | ||||||||||||||||
def _check_date_end(self): | ||||||||||||||||
for record in self: | ||||||||||||||||
if not float_is_zero(record.selling_price, precision_digits=2) and float_compare(record.selling_price, | ||||||||||||||||
0.9 * record.expected_price, | ||||||||||||||||
precision_digits=2) < 0: | ||||||||||||||||
Comment on lines
+81
to
+83
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
This is overall preferable and more clear (nitpicky, I admit) :D |
||||||||||||||||
raise ValidationError("The selling price must not be below 90% of the expected price.") | ||||||||||||||||
|
||||||||||||||||
def action_sold(self): | ||||||||||||||||
for record in self: | ||||||||||||||||
if record.state in ['sold', 'canceled']: | ||||||||||||||||
raise UserError('Cannot mark as sold.') | ||||||||||||||||
record.state = 'sold' | ||||||||||||||||
return True | ||||||||||||||||
|
||||||||||||||||
def action_canceled(self): | ||||||||||||||||
for record in self: | ||||||||||||||||
if record.state in ['sold', 'canceled']: | ||||||||||||||||
raise UserError('Cannot mark as canceled.') | ||||||||||||||||
record.state = 'canceled' | ||||||||||||||||
return True | ||||||||||||||||
|
||||||||||||||||
def action_reset(self): | ||||||||||||||||
for record in self: | ||||||||||||||||
if record.state not in ['sold', 'canceled']: | ||||||||||||||||
raise UserError('Cannot reset.') | ||||||||||||||||
record.state = 'new' | ||||||||||||||||
return True | ||||||||||||||||
|
||||||||||||||||
# Make sure that only one offer is accepted | ||||||||||||||||
def set_accepted_offer(self, offer): | ||||||||||||||||
for record in self: | ||||||||||||||||
if offer is not None: | ||||||||||||||||
record.state = 'offer_accepted' | ||||||||||||||||
record.selling_price = offer.price | ||||||||||||||||
record.buyer_id = offer.partner_id | ||||||||||||||||
else: | ||||||||||||||||
record.state = 'offer_received' | ||||||||||||||||
record.selling_price = 0 | ||||||||||||||||
record.buyer_id = None | ||||||||||||||||
for o in record.offer_ids: | ||||||||||||||||
if o.state == 'accepted' and (offer is None or o.id != offer.id): | ||||||||||||||||
o.action_reset(propagate=False) | ||||||||||||||||
|
||||||||||||||||
def offer_created(self): | ||||||||||||||||
for record in self: | ||||||||||||||||
if record.state == 'new': | ||||||||||||||||
record.state = 'offer_received' | ||||||||||||||||
|
||||||||||||||||
@api.ondelete(at_uninstall=False) | ||||||||||||||||
def _unlink_except_new_or_canceled(self): | ||||||||||||||||
if any((record.state not in ['canceled', 'new']) for record in self): | ||||||||||||||||
raise UserError("Can't delete a property with offers!") |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
from dateutil.relativedelta import relativedelta | ||
|
||
from odoo import models, fields, api | ||
from odoo.exceptions import UserError | ||
|
||
|
||
class PropertyOffer(models.Model): | ||
_name = 'estate.property.offer' | ||
_description = 'Property Offer' | ||
_sql_constraints = [ | ||
('check_offer_price', 'CHECK(price > 0)', 'The Offer Price must be positive.'), | ||
('check_validity', 'CHECK(validity >= 0)', 'The Offer Validity must be positive.'), | ||
] | ||
_order = 'price desc' | ||
|
||
state = fields.Selection([ | ||
('received', 'Received'), | ||
('accepted', 'Accepted'), | ||
('refused', 'Refused') | ||
], default='received', required=True, readonly=True) | ||
partner_id = fields.Many2one('res.partner', string='Partner', required=True) | ||
price = fields.Float() | ||
property_id = fields.Many2one('estate.property', string='Property', required=True) | ||
create_date = fields.Date(string='Create Date', default=lambda self: fields.Datetime.now()) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. create_date is an automatic field and should not be added here (see https://www.odoo.com/documentation/18.0/developer/tutorials/server_framework_101/03_basicmodel.html#:~:text=of%20the%20model.-,create_date,-(). I see you're already using |
||
validity = fields.Integer(string='Validity (days)', default=7) | ||
deadline_date = fields.Date(string='Deadline Date', compute='_compute_deadline_date', | ||
inverse='_inverse_deadline_date') | ||
property_type_id = fields.Many2one('estate.property.type', related='property_id.property_type_id') | ||
|
||
@api.depends('validity', 'deadline_date', 'create_date') | ||
def _compute_deadline_date(self): | ||
for record in self: | ||
record.deadline_date = record.create_date + relativedelta(days=record.validity) | ||
|
||
@api.depends('validity', 'deadline_date', 'create_date') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is not needed for an inverse method (I guess it was just copy-pasted from above) |
||
def _inverse_deadline_date(self): | ||
for record in self: | ||
record.validity = (record.deadline_date - record.create_date).days | ||
|
||
def action_accept(self, propagate=True): | ||
for record in self: | ||
if propagate: | ||
record.property_id.set_accepted_offer(record) | ||
record.state = 'accepted' | ||
return True | ||
|
||
def action_refuse(self, propagate=True): | ||
for record in self: | ||
if record.state == 'accepted' and propagate: | ||
record.property_id.set_accepted_offer(None) | ||
record.state = 'refused' | ||
return True | ||
|
||
def action_reset(self, propagate=True): | ||
for record in self: | ||
if record.state == 'accepted' and propagate: | ||
record.property_id.set_accepted_offer(None) | ||
record.state = 'received' | ||
return True | ||
Comment on lines
+40
to
+59
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's better to bring all code about There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was doing this because of ownership. Isn't it better to let each class manage its own variables ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. While it can be true in "classic" frameworks, in Odoo, a lot of modules depend on each other, and might want to adapt the behavior of the inherited model, or add features. If we add everything in the inherited class, it would become monstrously huge and hard to work in. In that sense, we can apply the same thinking with files within a same module: as the "offer acceptance feature" comes from this file, it can handle the property variables by itself. We don't usually use setters and getters unless there are strong conditions to respect. At the end of the day, it is just a (heavy) preference and your way of doing might be accepted, I was particularly nitpicky because your implem was very well done 😄 |
||
|
||
@api.model_create_multi | ||
def create(self, vals): | ||
res = super(models.Model, self).create(vals) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is usually best to put the You'll have to work with the list of dicts in vals (a bit more annoying to work with but not a lot) |
||
for record in res: | ||
if (record.property_id.state in ['canceled', 'sold']): | ||
raise UserError('Cannot create new offers in Sold or Canceled property.') | ||
|
||
best_offer = max(record.property_id.offer_ids.mapped('price')) if len( | ||
record.property_id.offer_ids) > 0 else 0 | ||
if (record.price < best_offer): | ||
raise UserError(f'The offer must be higher than {best_offer}') | ||
|
||
record.property_id.offer_created() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Like for |
||
return res |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
from odoo import models, fields | ||
|
||
|
||
class PropertyTag(models.Model): | ||
_name = 'estate.property.tag' | ||
_description = 'Property Tag' | ||
_sql_constraints = [ | ||
('check_name_unique', 'UNIQUE(name)', 'The Tag name must be unique.') | ||
] | ||
_order = 'name asc' | ||
|
||
name = fields.Char(string='Tag', required=True) | ||
color = fields.Integer(string='Color') |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
from odoo import models, fields, api | ||
|
||
|
||
class PropertyType(models.Model): | ||
_name = 'estate.property.type' | ||
_description = 'Property Type' | ||
_sql_constraints = [ | ||
('check_name_unique', 'UNIQUE(name)', 'The type name must be unique.') | ||
] | ||
_order = 'sequence asc, name asc' | ||
|
||
name = fields.Char(string='Title', required=True) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The constraint prevent duplication, try to find how to allow duplication for the record while still respecting the constraint 😁 |
||
sequence = fields.Integer('Sequence', default=1) | ||
property_ids = fields.One2many('estate.property', 'property_type_id') | ||
offer_ids = fields.One2many('estate.property.offer', 'property_type_id') | ||
offer_count = fields.Integer(compute='_compute_offer_count') | ||
|
||
@api.depends('offer_ids') | ||
def _compute_offer_count(self): | ||
for record in self: | ||
record.offer_count = len(record.offer_ids) |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,8 @@ | ||||||
from odoo import models, fields | ||||||
|
||||||
|
||||||
class Users(models.Model): | ||||||
_inherit = 'res.users' | ||||||
|
||||||
property_ids = fields.One2many('estate.property', 'salesperson_id', string='Properties', | ||||||
domain="[('date_availability', '<=', context_today().strftime('%Y-%m-%d'))]") | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
It doesn't work the way you did: you can try yourself by going to the Setting App -> Users & Companies and looking into the user you set as salesperson (with unavailable properties set up for him): you'll see that even unavailable properties are still shown by default |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink | ||
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,access_estate_property,model_estate_property,base.group_user,1,1,1,1 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
<?xml version="1.0" encoding="utf-8"?> | ||
<odoo> | ||
<menuitem id="estate_menu_root" name="Real Estate" web_icon="estate,static/description/icon.png"> | ||
<menuitem id="estate_advertisements_menu" name="Advertisements"> | ||
<menuitem id="estate_properties_menu" action="estate_property_action"/> | ||
</menuitem> | ||
<menuitem id="estate_settings_menu" name="Settings"> | ||
<menuitem id="estate_property_type_menu" action="estate_property_type_action"/> | ||
<menuitem id="estate_property_tag_menu" action="estate_property_tag_action"/> | ||
</menuitem> | ||
</menuitem> | ||
</odoo> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
<?xml version="1.0" encoding="utf-8"?> | ||
<odoo> | ||
<record id="estate_property_offer_view_list" model="ir.ui.view"> | ||
<field name="name">estate.property.offer.list</field> | ||
<field name="model">estate.property.offer</field> | ||
<field name="arch" type="xml"> | ||
<list string="Property offers" editable="bottom"> | ||
<field name="price" optional="show"/> | ||
<field name="partner_id"/> | ||
<field name="deadline_date" optional="show"/> | ||
<field name="validity" optional="hide"/> | ||
<button name="action_accept" string="Accept" invisible="state in ['accepted', 'refused']" type="object" | ||
icon="fa-check"/> | ||
<button name="action_refuse" string="Refuse" invisible="state in ['accepted', 'refused']" type="object" | ||
icon="fa-times"/> | ||
<field name="state" widget="badge" | ||
decoration-success="state == 'accepted'" | ||
decoration-info="state == 'received'" | ||
decoration-danger="state == 'refused'" | ||
optional="show"/> | ||
</list> | ||
</field> | ||
</record> | ||
|
||
<record id="estate_property_offer_action" model="ir.actions.act_window"> | ||
<field name="name">Property Types</field> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Forgot to change the name here ? :D |
||
<field name="res_model">estate.property.offer</field> | ||
<field name="domain">[('property_type_id', '=', active_id)]</field> | ||
<field name="view_mode">list</field> | ||
</record> | ||
</odoo> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
<?xml version="1.0" encoding="utf-8"?> | ||
<odoo> | ||
<record id="estate_property_tag_view_list" model="ir.ui.view"> | ||
<field name="name">estate.property.tag.list</field> | ||
<field name="model">estate.property.tag</field> | ||
<field name="arch" type="xml"> | ||
<list string="Property tags" editable="bottom"> | ||
<field name="name"/> | ||
</list> | ||
</field> | ||
</record> | ||
|
||
<record id="estate_property_tag_view_filter" model="ir.ui.view"> | ||
<field name="name">estate.property.tag.list.select</field> | ||
<field name="model">estate.property.tag</field> | ||
<field name="arch" type="xml"> | ||
<search string="Search Property tag"> | ||
<field name="name"/> | ||
</search> | ||
</field> | ||
</record> | ||
|
||
<record id="estate_property_tag_action" model="ir.actions.act_window"> | ||
<field name="name">Property tags</field> | ||
<field name="res_model">estate.property.tag</field> | ||
<field name="view_mode">list</field> | ||
</record> | ||
</odoo> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This constraint was not asked by the tutorial. You can add it if you feel it is better, as it is a tutorial, but you might want to adapt the corresponding field... As of now, duplicating the property raises an error.