diff --git a/estate/__init__.py b/estate/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/estate/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/estate/__manifest__.py b/estate/__manifest__.py new file mode 100644 index 00000000000..c7efe54b623 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,23 @@ +{ + 'name': 'Estate', + 'version': '0.1', + 'depends': ['base'], + 'summary': 'Estate module', + 'category': 'Tutorials/Estate', + 'application': True, + 'license': 'AGPL-3', + 'data': [ + 'security/estate.security.xml', + 'security/ir.model.access.csv', + + 'views/estate_property_views.xml', + 'views/estate_property_offer_views.xml', + 'views/estate_property_type_views.xml', + 'views/estate_property_tag_views.xml', + 'views/res_users_views.xml', + 'data/master_data.xml', + ], + "demo": [ + "demo/demo_data.xml", + ] +} diff --git a/estate/data/master_data.xml b/estate/data/master_data.xml new file mode 100644 index 00000000000..d56e9782583 --- /dev/null +++ b/estate/data/master_data.xml @@ -0,0 +1,16 @@ + + + + Residential + + + Commercial + + + Industrial + + + Land + + + diff --git a/estate/demo/demo_data.xml b/estate/demo/demo_data.xml new file mode 100644 index 00000000000..c6fbf964795 --- /dev/null +++ b/estate/demo/demo_data.xml @@ -0,0 +1,74 @@ + + + + + Big Villa + new + A nice and big villa + 12345 + 2020-02-02 + 1600000 + 6 + 100 + 4 + True + True + 100000 + south + south + + + + + Trailer home + cancelled + Home in a trailer park + 54321 + 1970-01-01 + 100000 + 1 + 10 + 4 + False + + + + + + + + + 10000 + 14 + + + + + + + 1500000 + 14 + + + + + + + 1500001 + 14 + + + + + + + + + + + diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..f49b2d8e5a7 --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1,5 @@ +from . import estate_property +from . import estate_property_offer +from . import estate_property_tag +from . import estate_property_type +from . import res_user diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..7e20b297675 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,128 @@ +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models +from odoo.exceptions import UserError, ValidationError +from odoo.tools import float_compare, float_is_zero + + +class EstateProperty(models.Model): + _name = 'estate.property' + _description = "Estate Property" + _order = "id desc" + _sql_constraints = [ + ('check_expected_price', 'CHECK(expected_price >= 0)', + "The property expected price must be strictly positive."), + ('check_selling_price', 'CHECK(selling_price >= 0)', + "The property selling price must be positive."), + ] + + name = fields.Char(required=True, string="Title") + description = fields.Text() + postcode = fields.Char() + date_availability = fields.Date(copy=False, + default=lambda self: fields.Date.today() + relativedelta(months=3), + string="Available From") + 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 (sqm)") + facades = fields.Integer() + garage = fields.Boolean() + garden = fields.Boolean() + garden_area = fields.Integer(string="Garden area (sqm)") + garden_orientation = fields.Selection( + selection=[ + ('north', "North"), + ('south', "South"), + ('east', "East"), + ('west', "West"), + ] + ) + + active = fields.Boolean(default=True) + state = fields.Selection(copy=False, default='new', required=True, string="Status", + selection=[ + ('new', "New"), + ('offer_received', "Offer Received"), + ('offer_accepted', "Offer Accepted"), + ('sold', "Sold"), + ('cancelled', "Cancelled"), + ] + ) + property_type_id = fields.Many2one('estate.property.type', string="Property Type") + buyer_id = fields.Many2one('res.partner', string="Buyer") + salesman_id = fields.Many2one('res.users', string="Salesman", default=lambda self: self.env.user) + tag_ids = fields.Many2many('estate.property.tag', string="Property Tags") + offer_ids = fields.One2many('estate.property.offer', 'property_id') + total_area = fields.Float(compute='_compute_total_area', string="Total Area (sqm)") + best_price = fields.Float(compute='_compute_best_price', string="Best Offer") + + @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: + record.best_price = max(record.offer_ids.mapped('price'), default=0) + + @api.constrains('selling_price', 'expected_price') + def _check_selling_price(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): + raise ValidationError(self.env._("The selling price must be at least 90% of the expected price.")) + + + @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 = None + + @api.ondelete(at_uninstall=False) + def prevent_delete(self): + for record in self: + if record.state not in ('new', 'cancelled'): + raise UserError(self.env._("Only new and cancelled properties can be deleted.")) + + def action_set_sold(self): + for record in self: + if record.state == 'cancelled': + raise UserError(self.env._("Cancelled properties cannot be sold.")) + + if not any(offer.status == 'accepted' for offer in record.offer_ids): + raise UserError(self.env._("Properties without accepted offer cannot be sold")) + + record.state = 'sold' + return True + + def action_set_cancelled(self): + for record in self: + if record.state == 'sold': + raise UserError(self.env._("Sold properties cannot be cancelled.")) + record.state = 'cancelled' + return True + + def accept_offer(self, accepted_offer): + if self.state == 'offer_accepted': + raise UserError(self.env._("An offer has already been accepted.")) + self.state = 'offer_accepted' + self.buyer_id = accepted_offer.partner_id + self.selling_price = accepted_offer.price + accepted_offer.status = 'accepted' + + # Refuse other offers + for offer in self.offer_ids: + if offer != accepted_offer: + offer.status = 'refused' + + def check_new_offer(self, offer_price): + self.state = 'offer_received' + if offer_price < min(self.offer_ids.mapped('price'), default=0): + raise UserError(self.env._("The offer price is less than the others.")) + return True diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..905b189602f --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,56 @@ +from datetime import timedelta +from dateutil.utils import today + +from odoo import api, fields, models + + +class EstatePropertyOffer(models.Model): + _name = 'estate.property.offer' + _description = "Estate Property Offer" + _order = "price desc" + _sql_constraints = [ + ('check_price', 'CHECK(price >= 0)', "The offer price must be strictly positive.") + ] + + price = fields.Float() + status = fields.Selection( + copy=False, + selection=[ + ('accepted', "Accepted"), + ('refused', "Refused"), + ] + ) + partner_id = fields.Many2one('res.partner', string="Partner", required=True) + property_id = fields.Many2one('estate.property', string="Property", required=True) + property_type_id = fields.Many2one(related='property_id.property_type_id', store=True) + + validity = fields.Integer(default=7, string="Validity (days)") + date_deadline = fields.Date(compute='_compute_date_deadline', inverse='_inverse_date_deadline', string="Deadline") + + @api.depends('create_date', 'validity') + def _compute_date_deadline(self): + for record in self: + create_date = record.create_date or today() + record.date_deadline = create_date + timedelta(days=record.validity) + + def _inverse_date_deadline(self): + for record in self: + create_date = record.create_date or today() + record.validity = (record.date_deadline - create_date.date()).days + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + self.env['estate.property'].browse(vals['property_id']).check_new_offer(vals['price']) + + return super().create(vals_list) + + def action_accept(self): + for record in self: + self.property_id.accept_offer(record) + return True + + def action_refuse(self): + for record in self: + 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..0756c60978d --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,13 @@ +from odoo import fields, models + + +class EstatePropertyTag(models.Model): + _name = 'estate.property.tag' + _description = "Estate Property Tag" + _order = "name" + _sql_constraints = [ + ('name_unique', 'unique(name)', "The tag name must be unique.") + ] + + name = fields.Char(required=True) + color = fields.Integer() diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py new file mode 100644 index 00000000000..20a8d7b9470 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,20 @@ +from odoo import api, fields, models + + +class EstatePropertyType(models.Model): + _name = 'estate.property.type' + _description = "Estate Property Type" + _order = "name" + + name = fields.Char(required=True) + sequence = fields.Integer(default=1) + + property_ids = fields.One2many('estate.property', 'property_type_id', string="Properties") + offer_ids = fields.One2many(comodel_name='estate.property.offer', inverse_name='property_type_id') + + offer_count = fields.Integer(compute='_compute_offer_count', string="Offers") + + @api.depends('property_ids') + def _compute_offer_count(self): + for record in self: + record.offer_count = len(record.property_ids) diff --git a/estate/models/res_user.py b/estate/models/res_user.py new file mode 100644 index 00000000000..0ff939e3202 --- /dev/null +++ b/estate/models/res_user.py @@ -0,0 +1,8 @@ +from odoo import models, fields + + +class ResUsers(models.Model): + _inherit = 'res.users' + + property_ids = fields.One2many('estate.property', inverse_name='salesman_id', + domain=[('state', 'in', ['new', 'offer_received'])]) diff --git a/estate/security/estate.security.xml b/estate/security/estate.security.xml new file mode 100644 index 00000000000..dba8d39be1e --- /dev/null +++ b/estate/security/estate.security.xml @@ -0,0 +1,30 @@ + + + + + + Agent + + + + + Manager + + + + + + Agent rule + + + [('salesman_id', 'in', [False, user.id])] + + + + Manager rule + + + [] + + + diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..bf78b3d046c --- /dev/null +++ b/estate/security/ir.model.access.csv @@ -0,0 +1,9 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +"access_estate_property_manager","access_estate_property_manager","model_estate_property","estate_group_manager",1,1,1,1 +"access_estate_property_type_manager","access_estate_property_type_manager","model_estate_property_type","estate_group_manager",1,1,1,1 +"access_estate_property_tag_manager","access_estate_property_tag_manager","model_estate_property_tag","estate_group_manager",1,1,1,1 +"access_estate_property_offer_manager","access_estate_property_offer_manager","model_estate_property_offer","estate_group_manager",1,1,1,1 +"access_estate_property","access_estate_property","model_estate_property","estate_group_user",1,1,1,0 +"access_estate_property_type","access_estate_property_type","model_estate_property_type","estate_group_user",1,0,0,0 +"access_estate_property_tag","access_estate_property_tag","model_estate_property_tag","estate_group_user",1,0,0,0 +"access_estate_property_offer","access_estate_property_offer","model_estate_property_offer","estate_group_user",1,1,1,0 diff --git a/estate/tests/__init__.py b/estate/tests/__init__.py new file mode 100644 index 00000000000..576617cccff --- /dev/null +++ b/estate/tests/__init__.py @@ -0,0 +1 @@ +from . import test_estate_property diff --git a/estate/tests/test_estate_property.py b/estate/tests/test_estate_property.py new file mode 100644 index 00000000000..16e004f1994 --- /dev/null +++ b/estate/tests/test_estate_property.py @@ -0,0 +1,80 @@ +from odoo.tests.common import TransactionCase +from odoo.exceptions import UserError +from odoo.tests import tagged +from odoo.tests import Form + + +@tagged('post_install', '-at_install') +class EstatePropertyTestCase(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.property = cls.env['estate.property'].create( + {'name': 'Test property', "expected_price": 10}, + ) + + def test_new_offer_cannot_be_lower_than_others(self): + """Test that an offer cannot be created for a sold property""" + self.env['estate.property.offer'].create({ + 'property_id': self.property.id, + 'partner_id': 1, + 'status': 'accepted', + 'price': 10, + }), + + with self.assertRaises(UserError) as cm: + self.env['estate.property.offer'].create({ + 'property_id': self.property.id, + 'partner_id': 1, + 'price': 2, + }) + + def test_sell_property_without_accepted_offers(self): + """Test that a property cannot be sold without any accepted offers""" + self.env['estate.property.offer'].create({ + 'property_id': self.property.id, + 'partner_id': 1, + 'price': 10, + }) + + self.env['estate.property.offer'].create({ + 'property_id': self.property.id, + 'partner_id': 1, + 'price': 20, + }) + + with self.assertRaises(UserError): + self.property.action_set_sold() + + def test_property_state_after_sale(self): + """Test that the property state is 'sold' after the sale""" + self.env['estate.property.offer'].create({ + 'property_id': self.property.id, + 'partner_id': 1, + 'status': 'accepted', + 'price': 10, + }) + + + self.property.action_set_sold() + self.assertTrue(self.property.state == 'sold') + + def test_garden_set_to_true(self): + self.property.garden = False + with Form(self.property) as property_form: + property_form.garden = True + + self.assertRecordValues(self.property, [ + {'garden_area': 10, 'garden_orientation': 'north'} + ]) + + def test_garden_set_to_false(self): + self.property.garden = True + self.property.garden_area = 10 + self.property.garden_orientation = 'north' + with Form(self.property) as property_form: + property_form.garden = False + + self.assertFalse(self.property.garden_area) + self.assertEqual(self.property.garden_area, 0) diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..f44193b667b --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,47 @@ + + + + Property Offer + estate.property.offer + list,form + [('property_type_id', '=', active_id)] + + + + estate.property.offer.list + estate.property.offer + + + + + + + + +
+

+ +

+
+ + + + + + + + + + + + + + +
+
+ +
diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml new file mode 100644 index 00000000000..97bd79c3c1e --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,158 @@ + + + + Properties + estate.property + list,form,kanban + {'search_default_available': True} + + + + + + + + + + + estate.property.list + estate.property + + + + + + + + + + + + + + + + + estate.property.form + estate.property + + +
+
+
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + estate.property.view.search + estate.property + + + + + + + + + + + + + + + + + + estate.property.view.kanban + estate.property + + + + + + +
+ + +
+ Expected Price: + +
+
+ Best Offer: + +
+
+ Selling price: + +
+ +
+
+
+
+ +
+
+ +
diff --git a/estate/views/res_users_views.xml b/estate/views/res_users_views.xml new file mode 100644 index 00000000000..7fccc24fc12 --- /dev/null +++ b/estate/views/res_users_views.xml @@ -0,0 +1,28 @@ + + + + + + res.users.view.form.inherit.estate + res.users + + + + +
Mother fucker
+
+
+ + + + + + + + +
+ +
+ + +
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..f7aa8534fc1 --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,9 @@ +{ + 'name': 'Estate Accounting', + 'version': '0.1', + 'depends': ['base', 'estate', 'account'], + 'summary': 'Estate Accounting module', + 'category': 'Tutorials/Estate', + 'application': True, + 'license': 'AGPL-3', +} diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py new file mode 100644 index 00000000000..5e1963c9d2f --- /dev/null +++ b/estate_account/models/__init__.py @@ -0,0 +1 @@ +from . import estate_property diff --git a/estate_account/models/estate_property.py b/estate_account/models/estate_property.py new file mode 100644 index 00000000000..9d23401162a --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,27 @@ +from odoo import models, Command + + +class EstateProperty(models.Model): + _inherit = 'estate.property' + + def action_set_sold(self): + for record in self: + self.env['account.move'].create({ + 'partner_id': record.buyer_id.id, + 'move_type': 'out_invoice', + 'invoice_line_ids': + [ + Command.create({ + 'name': record.name, + 'price_unit': 0.06 * record.selling_price, + 'quantity': 1 + }), + Command.create({ + 'name': 'administrative fees', + 'price_unit': 100, + 'quantity': 1 + }) + ], + } + ) + return super().action_set_sold()