diff --git a/estate/__init__.py b/estate/__init__.py new file mode 100644 index 00000000000..e9917144f69 --- /dev/null +++ b/estate/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models \ No newline at end of file diff --git a/estate/__manifest__.py b/estate/__manifest__.py new file mode 100644 index 00000000000..5d0912f3af7 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Estate', + 'summary': """ + Starting module for "Server framework 101, chapter 2: Build an estate app" + """, + + 'description': """ + Starting module for "Server framework 101, chapter 2: Build an estate app" + """, + + 'author': "Odoo", + 'website': "https://www.odoo.com/", + 'category': 'Tutorials/Estate', + 'version': '0.1', + 'application': True, + 'installable': True, + 'depends': ['base', 'web'], + + 'data': [ + 'security/ir.model.access.csv', + 'views/res_users_views.xml', + 'views/estate_property_views.xml', + 'views/estate_property_type_views.xml', + 'views/estate_property_tag_views.xml', + 'views/estate_property_offer_views.xml', + 'views/estate_menu_views.xml' + ], + + 'license': 'AGPL-3' +} diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..63e14b177a4 --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import estate_property +from . import estate_property_type +from . import estate_property_tag +from . import estate_property_offer +from . import res_users \ No newline at end of file diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..83a79cc73e1 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, fields, api, _ +from datetime import timedelta +from odoo.exceptions import UserError, ValidationError + +class Property(models.Model): + _name = "estate.property" + _description = "Estate property details" + _order = "id desc" + + name = fields.Char(string='Name', required=True) + description = fields.Text(string='Description') + + postcode = fields.Char(string='Postal code') + + date_availability = fields.Date(string='Availability Date', copy=False, + default=fields.Date.today()+timedelta(days=90)) + + expected_price = fields.Float(string='Expected Price', required=True, default=1.0) + selling_price = fields.Float(string='Selling Price', readonly=True, copy=False) + best_price = fields.Float(string='Best Offer', readonly=True, copy=False, + compute="_compute_best_offer") + + bedrooms = fields.Integer(string='Number of bedrooms', default=2) + living_area = fields.Integer() + facades = fields.Integer() + garage = fields.Boolean() + + garden = fields.Boolean() + garden_area = fields.Integer() + garden_orientation = fields.Selection([('north', 'North'), ('south', 'South'), + ('east', 'East'), ('west', 'West')]) + + active = fields.Boolean(default=True) + + state = fields.Selection([('new', 'New'), ('offer received', 'Offer Received'), + ('offer accepted', 'Offer Accepted'), ('cancelled', 'Cancelled'), + ('sold', 'Sold')], + default="new", + required=True, + copy=False) + + total_area = fields.Integer(string='Total Area (sqm)', compute="_compute_total_area", + readonly=True, copy=False) + + _sql_constraints = [ + ('check_epxected_price', 'CHECK(expected_price > 0)', 'The expected price should be greater than 0.'), + ('check_selling_price', 'CHECK(selling_price >= 0)', 'The selling price should be greater than or equal to 0.') + ] + + property_type_id = fields.Many2one( + 'estate.property.type', + string="Type", + help="Select the category for this product" + ) + + property_seller_id = fields.Many2one( + 'res.users', + string='Salesperson', + default=lambda self: self.env.user + ) + + property_buyer_id = fields.Many2one( + 'res.partner', + string='Buyer', + copy=False + ) + + property_tag_ids = fields.Many2many( + 'estate.property.tag', + copy=False + ) + + offer_ids = fields.One2many( + comodel_name='estate.property.offer', + inverse_name='property_id', + string="Offers", + copy=False + ) + + @api.ondelete(at_uninstall=False) + def _unlink_if_new_cancelled(self): + for record in self: + if record.state not in ['new', 'cancelled']: + raise UserError('Only new and cancelled properties can be deleted.') + + + @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_offer(self): + for record in self: + record.best_price = max(record.offer_ids.mapped('price')) if record.offer_ids else 0 + + @api.onchange("garden") + def _onchange_garden(self): + if self.garden: + self.garden_area = 10 + self.garden_orientation = "north" + else: + self.garden_area = 0 + self.garden_orientation = '' + + @api.constrains('expected_price') + def _check_expected_price(self): + for record in self: + if record.expected_price <= 0: + raise ValidationError('The expected price must be greater than zero.') + + @api.constrains('selling_price') + def _check_selling_price(self): + for record in self: + if record.selling_price < 0: + raise ValidationError('The selling price should be positive.') + + @api.constrains('selling_price', 'expected_price') + def _check_selling_price_expected(self): + for record in self: + if record.selling_price < record.expected_price * 0.9 and (not (record.selling_price == 0)) : + raise ValidationError('The selling price should never be lower than 90 percent of the expected price.') + + def action_cancel_the_property(self): + for record in self: + if record.state == 'sold': + raise UserError(_('Sold properties cannot be cancelled.')) + record.state = 'cancelled' + return True + + def action_sell_the_property(self): + for record in self: + if record.state == 'cancelled': + raise UserError(_('Cancelled properties cannot be sold.')) + else: + record.state = 'sold' + return True + + def set_property_state(self): + """ + Set the state of a property to 'offer recieved'. + Will be called when an offer is created for a property. + """ + self.state = 'offer received' diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..86960a11083 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, fields, api, _ +from datetime import timedelta, datetime +from odoo.exceptions import UserError, ValidationError + +class PropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "Manage different offers from buyers for a specific property." + _order = "price desc" + + price = fields.Float(string="Price", required=True, default=1.0) + + validity = fields.Integer(string="Validity (days)", default=7) + date_deadline = fields.Date(string="Deadline", compute="_compute_deadline", + inverse="_inverse_deadline") + + status = fields.Selection([('accepted', 'Accepted'), + ('refused', 'Refused')], + copy=False) + + _sql_constraints = [ + ('check_offer_price', 'CHECK(price > 0)', 'The offer price should be greater than zero.') + ] + + partner_id = fields.Many2one( + 'res.partner', + string='Buyer', + required=True + ) + + property_id = fields.Many2one( + 'estate.property', + string='Property', + required=True + ) + + property_type_id = fields.Many2one( + related='property_id.property_type_id', + store=True + ) + + @api.model_create_multi + def create(self, vals): + for record in vals: + if record.get('property_id'): + curr_prop = self.env['estate.property'].browse(record['property_id']) + + if curr_prop.offer_ids: + max_offer = max(curr_prop.offer_ids, key=lambda offer: offer.price) + + if record['price'] < max_offer.price: + raise UserError(f"The offer must be higher then {max_offer.price}") + + else: + curr_prop.set_property_state() + + return super().create(vals) + + @api.depends("validity", "create_date") + def _compute_deadline(self): + for record in self: + create_date = record.create_date if record.create_date else datetime.now() + record.date_deadline = create_date + timedelta(days=record.validity) + + @api.constrains('price') + def _check_offer_price(self): + for record in self: + if record.price <= 0: + raise ValidationError(_('The offer price should be greater than zero.')) + + def _inverse_deadline(self): + for record in self: + create_date = record.create_date if record.create_date else datetime.now() + record.validity = (record.date_deadline - create_date.date()).days + + def accept_offer(self): + for record in self: + if record.status == 'accepted': + raise UserError(_('This offer has already been accepted.')) + + offer_prop = record.property_id + accepted_off = offer_prop.offer_ids.filtered(lambda x: x.status == 'accepted') + + if accepted_off: + raise UserError(_('This property has already accepted an offer.')) + + offer_prop.property_buyer_id = record.partner_id + offer_prop.selling_price =record.price + offer_prop.state = 'offer accepted' + + record.status = 'accepted' + return True + + def reject_offer(self): + for record in self: + if record.status == 'accepted': + record.property_id.property_buyer_id = False + record.property_id.selling_price = 0 + record.status = 'refused' + return True diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..fbf858da499 --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, fields + +class PropertyTag(models.Model): + _name = "estate.property.tag" + _description = "Add different tags to a property like cozy, spacious, etc." + _order = "name" + + name = fields.Char(string="Name", required=True) + color = fields.Integer() + + _sql_constraints = [ + ('unique_tag_name', 'UNIQUE(name)', 'Tag with this name already exists, try another name.') + ] diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py new file mode 100644 index 00000000000..1df4521e204 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, fields, api + +class PropertyType(models.Model): + _name = "estate.property.type" + _description = "Used to describe the type of the property i.e House, Apartment, etc." + _order = "name" + + name = fields.Char(string="Name", required=True) + sequence = fields.Integer('Sequence', default=1) + offer_count = fields.Integer(compute="_compute_offer_count") + + _sql_constraints = [ + ('unique_type_name', 'UNIQUE(name)', 'Type with this name already exists, try another name.') + ] + + property_ids = fields.One2many( + comodel_name='estate.property', + inverse_name='property_type_id' + ) + + offer_ids = fields.One2many( + comodel_name='estate.property.offer', + inverse_name='property_type_id' + ) + + @api.depends('offer_ids') + def _compute_offer_count(self): + for record in self: + record.offer_count = len(record.offer_ids) diff --git a/estate/models/res_users.py b/estate/models/res_users.py new file mode 100644 index 00000000000..dcaf7af71da --- /dev/null +++ b/estate/models/res_users.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, fields + +class Users(models.Model): + _inherit = 'res.users' + + property_ids = fields.One2many( + comodel_name='estate.property', + inverse_name='property_seller_id', + domain=['|', ('state', '=', 'new'), ('state', '=', 'offer received')] + ) \ No newline at end of file diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..4e660d26253 --- /dev/null +++ b/estate/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink +access_estate_property,estate.property,model_estate_property,base.group_user,1,1,1,1 +access_estate_property_type,estate.property.type,model_estate_property_type,base.group_user,1,1,1,1 +access_estate_property_tag,estate.property.tag,model_estate_property_tag,base.group_user,1,1,1,1 +access_estate_property_offer,estate.property.offer,model_estate_property_offer,base.group_user,1,1,1,1 \ No newline at end of file diff --git a/estate/views/estate_menu_views.xml b/estate/views/estate_menu_views.xml new file mode 100644 index 00000000000..aa67815fbe6 --- /dev/null +++ b/estate/views/estate_menu_views.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..6f0e6e0c0da --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,47 @@ + + + + + + + estate.property.offer.list + estate.property.offer + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml new file mode 100644 index 00000000000..872760c1c41 --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,136 @@ + + + + + + + estate.property.search + estate.property + + + + + + + + + + + + + + + + + + + + estate.property.form + estate.property + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + estate.property.list + estate.property + + + + + + + + + + + + + + + + + + estate.property.kanban + estate.property + + + + + +
+ + +

Expected Price:

+ +

Selling Price:

+
+ +

Best Offer:

+
+
+
+
+
+
+
+ + + + Properties + estate.property + list,form,kanban + {'search_default_available': True} + +

Define a new property

+
+
+
diff --git a/estate/views/res_users_views.xml b/estate/views/res_users_views.xml new file mode 100644 index 00000000000..725d7f951b6 --- /dev/null +++ b/estate/views/res_users_views.xml @@ -0,0 +1,17 @@ + + + + + res.users.view.form.estate.inherit + res.users + + extension + + + + + + + + + \ No newline at end of file diff --git a/estate_account/__init__.py b/estate_account/__init__.py new file mode 100644 index 00000000000..e9917144f69 --- /dev/null +++ b/estate_account/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models \ No newline at end of file diff --git a/estate_account/__manifest__.py b/estate_account/__manifest__.py new file mode 100644 index 00000000000..01edbc1cc28 --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Estate Account', + + 'application': True, + 'installable': True, + + 'depends': ['base', 'web', 'estate', 'account'], + 'category': 'Tutorials/EstateAccount', + + 'license': 'AGPL-3' +} \ No newline at end of file diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py new file mode 100644 index 00000000000..0c4a347c60d --- /dev/null +++ b/estate_account/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import estate_property \ No newline at end of file diff --git a/estate_account/models/estate_property.py b/estate_account/models/estate_property.py new file mode 100644 index 00000000000..ee579bdacd1 --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + +class Property(models.Model): + _inherit = "estate.property" + + def action_sell_the_property(self): + + move_val = { + 'move_type': 'out_invoice', + 'partner_id': self.property_buyer_id.id, + 'invoice_line_ids': [ + (0, 0, { + 'name': self.name, + 'quantity': 1.0, + 'price_unit': self.selling_price + } + ), + (0, 0, { + 'name': 'Commission', + 'quantity': 1.0, + 'price_unit': self.selling_price * 0.06 + } + ), + (0, 0, { + 'name': 'Administrative Fee', + 'quantity': 1.0, + 'price_unit': 100.00 + } + ) + ] + } + + move = self.env['account.move'].create(move_val) + + return super().action_sell_the_property() + \ No newline at end of file