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..c8ebdfa3ca8 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,19 @@ +{ + 'name': 'estate', + 'depends': [ + 'base', + ], + 'application': 'True', + 'author': 'Odoo S.A.', + 'license': 'LGPL-3', + 'data': [ + 'security/ir.model.access.csv', + + 'views/estate_property_offer_views.xml', + 'views/estate_property_tag_views.xml', + 'views/estate_property_type_views.xml', + 'views/estate_property_views.xml', + 'views/inherited_user_views.xml', + 'views/estate_menus.xml', + ], +} diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..b8a9f7e0dfd --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1,5 @@ +from . import estate_property +from . import estate_property_type +from . import estate_property_tag +from . import estate_property_offer +from . import inherited_user diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..8a3653b1885 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,103 @@ +from odoo import api, models, fields +from odoo.exceptions import UserError +import datetime +from dateutil.relativedelta import relativedelta +from odoo.tools.float_utils import float_compare + + +class EstateProperty(models.Model): + _name = "estate.property" + _description = "test description" + _order = "id desc" + + name = fields.Char('Name', required=True) + description = fields.Text('Description') + postcode = fields.Char('Postcode') + date_availability = fields.Date('Date availability', copy=False, default=datetime.date.today() + relativedelta(months=+3)) + expected_price = fields.Float('Expected price', required=True) + selling_price = fields.Float('Selling price', readonly=True, copy=False) + bedrooms = fields.Integer('Bedrooms', default=2) + living_area = fields.Integer('Living area (sqm)') + facades = fields.Integer('Facades') + garage = fields.Boolean('Garage') + garden = fields.Boolean('Garden') + garden_area = fields.Integer('Garden area (sqm)') + garden_orientation = fields.Selection( + string='Orientation', + selection=[('north', 'North'), ('east', 'East'), ('south', 'South'), ('west', 'West')], + ) + active = fields.Boolean('Active', default=True) + state = fields.Selection( + string='state', + selection=[('new', 'New'), ('offerreceived', 'Offer Received'), ('offeraccepted', 'Offer Accepted'), ('sold', 'Sold'), ('cancelled', 'Cancelled')], + default='new', + ) + total_area = fields.Integer('Total area (sqm)', compute='_compute_total_area') + best_offer = fields.Float('Best price', compute='_compute_best_offer') + property_type_id = fields.Many2one('estate.property.type', string="Property Type") + salesperson_id = fields.Many2one('res.users', string='Salesman', default=lambda self: self.env.user) + buyer_id = fields.Many2one('res.partner', string='Buyer', copy=False) + tag_ids = fields.Many2many('estate.property.tag', string="Property Tags") + offer_ids = fields.One2many('estate.property.offer', 'property_id', string="Offers") + + @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_offer = 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 = None + + def action_set_cancelled(self): + for record in self: + if record.state == 'sold': + raise UserError('A sold property cannot be cancelled') + else: + record.state = 'cancelled' + return True + + def action_set_sold(self): + for record in self: + if record.state == 'cancelled': + raise UserError('A cancelled property cannot be sold') + elif record.state != 'offeraccepted': + raise UserError('There is no accepted offer on this property') + else: + record.state = 'sold' + return True + + _check_expected_price = models.Constraint( + 'CHECK(expected_price > 0)', + 'The expected price must be striclty positive', + ) + + _check_selling_price = models.Constraint( + 'CHECK(selling_price >= 0)', + 'The selling price must be positive', + ) + + @api.constrains('selling_price') + def check_selling_price(self): + for record in self: + expected_minimum = record.expected_price * 0.9 + if float_compare(record.selling_price, expected_minimum, precision_digits=2) < 0: + raise UserError(r'The selling price should be at least 90% of the expexted price') + else: + record.state = "offeraccepted" + + @api.ondelete(at_uninstall=False) + def unlink_property(self): + for record in self: + if record.state != 'new' and record.state != 'canceled': + raise UserError("Only new and canceled properties can be deleted") diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..40bf90d072d --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,67 @@ +from odoo import api, models, fields +from odoo.exceptions import UserError + + +class EstatePropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "test description" + _order = "price desc" + + price = fields.Float('Price') + status = fields.Selection( + string='Status', + selection=[('accepted', 'Accepted'), ('refused', 'Refused')], + copy=False, + ) + partner_id = fields.Many2one('res.partner', string='Partner', required=True) + property_id = fields.Many2one('estate.property', string='Property', required=True) + validity = fields.Integer('Validity', default=7) + date_deadline = fields.Date('Deadline', compute='_compute_deadline', inverse='inverse_deadline') + property_type_id = fields.Many2one(related='property_id.property_type_id', store=True) + + @api.depends('validity') + def _compute_deadline(self): + for records in self: + records.date_deadline = fields.Datetime.add(fields.Date.today(), days=records.validity) + + def inverse_deadline(self): + for records in self: + records.validity = (records.date_deadline - fields.Date.today()).days + + def action_accept(self): + for record in self: + record.status = 'accepted' + if not record.property_id.buyer_id: + record.property_id.buyer_id = record.partner_id + record.property_id.selling_price = record.price + else: + raise UserError('An offer has already been accepted') + return True + + def action_decline(self): + for record in self: + if record.status == 'accepted': + raise UserError('This offer has already been accepted') + else: + record.status = 'refused' + return True + + _check_offer_price = models.Constraint( + 'CHECK(price > 0)', + 'The offer price must be strictly positive' + ) + + @api.model + def create(self, vals): + for val in vals: + property_id = self.env['estate.property'].browse(val['property_id']) + if property_id.state == "sold": + raise UserError("You can't create an offer for a sold property") + + for offer in property_id.offer_ids: + if offer.price > val['price']: + raise UserError("You can't create a lower offer than the highest one") + offers = super().create(vals) + offers.property_id.state = 'offerreceived' + + return offers diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..4d9204d89c7 --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,15 @@ +from odoo import fields, models + + +class EstatePropertyTag(models.Model): + _name = "estate.property.tag" + _description = "test description" + _order = "name" + + name = fields.Char('Name', required=True) + color = fields.Integer('Color') + + _check_name = models.Constraint( + 'UNIQUE (name)', + 'The 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..7a2ae4b90a1 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,23 @@ +from odoo import api, fields, models + + +class EstatePropertyType(models.Model): + _name = "estate.property.type" + _description = "test description" + _order = "sequence, name" + + name = fields.Char('Name', required=True) + sequence = fields.Integer('Sequence', default=1, help="Used to order stages.") + 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('Number of offers', compute='_compute_offers') + + @api.depends('offer_ids') + def _compute_offers(self): + for record in self: + record.offer_count = len(record.offer_ids) + + _check_name = models.Constraint( + 'UNIQUE (name)', + 'The name must be unique', + ) diff --git a/estate/models/inherited_user.py b/estate/models/inherited_user.py new file mode 100644 index 00000000000..b2afd66d504 --- /dev/null +++ b/estate/models/inherited_user.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class InheritedUser(models.Model): + _inherit = "res.users" + + property_ids = fields.One2many('estate.property', 'salesperson_id', string="Related Properties", domain=[('state', 'in', ['new', 'offerreceived'])]) diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..4c593ed42e4 --- /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,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 \ No newline at end of file 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..1279e2b5020 --- /dev/null +++ b/estate/tests/test_estate_property.py @@ -0,0 +1,39 @@ +from odoo.tests.common import TransactionCase +from odoo.exceptions import UserError +from odoo.tests import Form, tagged + + +@tagged('post_install', '-at_install') +class EstateTestCase(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.properties = cls.env['estate.property'].create({'name': 'testAdd', 'expected_price': '190000'}) + cls.partner = cls.env['res.partner'].create({'name': 'eric'}) + + def test_creation_estate(self): + self.env['estate.property.offer'].create({'price': 180000, 'partner_id': self.partner.id, 'property_id': self.properties.id, 'status': 'accepted'}) + self.properties.state = 'sold' + + with self.assertRaises(UserError): + self.env['estate.property.offer'].create({'price': 180000, 'partner_id': self.partner.id, 'property_id': self.properties.id}) + + def test_sell_property(self): + self.properties.state = 'new' + + with self.assertRaises(UserError): + self.properties.action_set_sold() + + def test_garden_onchange(self): + property_form = Form(self.env['estate.property']) + property_form.garden = True + property_form.garden_area = 20 + property_form.garden_orientation = 'east' + + property_form.garden = False + property_form.garden = True + + self.assertEqual(property_form.garden_area, 10) + self.assertEqual(property_form.garden_orientation, 'north') diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..c4f8af9c34c --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ 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..61031db6607 --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,24 @@ + + + + Offers + estate.property.offer + list,form + [('property_type_id', '=', active_id)] + + + + property type offer + estate.property.offer + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + estate.property.list + estate.property.type + + + + + + + + \ 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..e8e6b274c1f --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,138 @@ + + + + property action + estate.property + list,form,kanban + {'search_default_inactive': True} + +

Hello

+
+
+ + + 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: +
+ +
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/estate/views/inherited_user_views.xml b/estate/views/inherited_user_views.xml new file mode 100644 index 00000000000..17ee28bc5e8 --- /dev/null +++ b/estate/views/inherited_user_views.xml @@ -0,0 +1,15 @@ + + + + res.users.view.form.inherit.estate + 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..501ae613d6f --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,10 @@ +{ + 'name': 'estate_account', + 'depends': [ + 'base', + 'estate', + 'account' + ], + 'author': 'Odoo S.A.', + 'license': 'LGPL-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..b983415ee1a --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,32 @@ +from odoo import models, Command + + +class InheritedEstateProperty(models.Model): + _inherit = "estate.property" + + def action_set_sold(self): + self.env['account.move'].create( + { + 'partner_id': self.buyer_id.id, + 'move_type': 'out_invoice', + 'invoice_line_ids': [ + Command.create({ + 'name': self.name, + 'quantity': 1, + 'price_unit': self.selling_price + }), + Command.create({ + 'name': r'extra 6% because i feel like it', + 'quantity': 1, + 'price_unit': self.selling_price * 0.06 + }), + Command.create({ + 'name': 'administrative fees', + 'quantity': 1, + 'price_unit': 100.00 + }) + ] + } + ) + res = super().action_set_sold() + return res