diff --git a/estate/__init__.py b/estate/__init__.py new file mode 100644 index 00000000000..d6210b1285d --- /dev/null +++ b/estate/__init__.py @@ -0,0 +1,3 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/estate/__manifest__.py b/estate/__manifest__.py new file mode 100644 index 00000000000..2b293b680f6 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,25 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Estate', + 'depends': [ + 'base', + ], + 'version': '19.0.0.0', + 'author': "Odoo S.A.", + 'license': "LGPL-3", + 'installable': True, + 'application': True, + 'data': [ + 'views/res_users_views.xml', + 'views/estate_property_offer_views.xml', + 'views/estate_property_tag_views.xml', + 'views/estate_property_type_views.xml', + 'views/estate_property_views.xml', + 'views/estate_menus.xml', + 'security/ir.model.access.csv', + 'data/estate.property.type.csv', + 'data/estate_property_data.xml', + 'data/estate_property_offer_data.xml', + ], +} diff --git a/estate/data/estate.property.type.csv b/estate/data/estate.property.type.csv new file mode 100644 index 00000000000..87a1e2f7023 --- /dev/null +++ b/estate/data/estate.property.type.csv @@ -0,0 +1,5 @@ +"id","name","sequence","property_ids","offer_ids","offer_count" +property_type_1,"Residential",,,, +property_type_2,"Commercial",,,, +property_type_3,"Industrial",,,, +property_type_4,"Land",,,, diff --git a/estate/data/estate_property_data.xml b/estate/data/estate_property_data.xml new file mode 100644 index 00000000000..613c857708b --- /dev/null +++ b/estate/data/estate_property_data.xml @@ -0,0 +1,60 @@ + + + + + Big Villa + + new + A nice and big villa + 12345 + + 1600000 + 6 + 100 + 4 + True + True + 100000 + south + + + + Trailer Home + + cancelled + Home in a trailer park + 54321 + + 100000 + 120000 + 1 + 10 + 4 + False + + + + Cozy Cottage + + new + A cozy cottage in the forest + 98765 + + 50000 + 1 + 30 + 4 + False + + + diff --git a/estate/data/estate_property_offer_data.xml b/estate/data/estate_property_offer_data.xml new file mode 100644 index 00000000000..9ce964e30cc --- /dev/null +++ b/estate/data/estate_property_offer_data.xml @@ -0,0 +1,31 @@ + + + + + 1500000 + + + + + + + 1600000 + + + + + + + 1600001 + + + + + + + + + + + + diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..3ced267895e --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1,9 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import ( + estate_property, + estate_property_offer, + estate_property_tag, + estate_property_type, + res_users, +) diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..091b47fc811 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,90 @@ +from datetime import date, timedelta + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class Property(models.Model): + _name = 'estate.property' + _description = "Test description for estate.property model" + _order = 'id DESC' + + name = fields.Char(required=True) + expected_price = fields.Float(required=True) + property_type_id = fields.Many2one('estate.property.type', string="Property Type") + state = fields.Selection( + selection=[('new', "New"), ('offer_received', "Offer Received"), ('offer_accepted', "Offer accepted"), ('sold', "Sold"), ('cancelled', "Cancelled")], + default='new', + ) + description = fields.Text() + postcode = fields.Char() + selling_price = fields.Float(copy=False, readonly=True) + date_availability = fields.Date(copy=False, default=lambda self: date.today() + timedelta(days=90)) + bedrooms = fields.Integer(default=2) + living_area = fields.Integer() + facades = fields.Integer() + garage = fields.Boolean() + garden = fields.Boolean() + garden_area = fields.Integer() + garden_orientation = fields.Selection( + string='Orientation', + selection=[('north', "North"), ('south', "South"), ('east', "East"), ('west', "West")]) + active = fields.Boolean(default=True) + buyer_id = fields.Many2one('res.partner') + salesperson_id = fields.Many2one('res.users', copy=False, default=lambda self: self.env.user) + tag_ids = fields.Many2many('estate.property.tag') + offer_ids = fields.One2many('estate.property.offer', 'property_id') + total_area = fields.Float(compute='_compute_total_area') + best_price = fields.Float(compute='_compute_best_price') + + _check_expected_price = models.Constraint( + 'CHECK(expected_price > 0)', + "The expected price must be strictly positive", + ) + _check_selling_price = models.Constraint( + 'CHECK (selling_price >= 0)', + "The selling price must be positive", + ) + + @api.depends('garden_area', 'living_area') + def _compute_total_area(self): + for record in self: + record.total_area = record.living_area + record.garden_area + + @api.depends('offer_ids') + def _compute_best_price(self): + for record in self: + record.best_price = max(o.price for o in record.offer_ids) 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 = None + self.garden_orientation = None + + @api.ondelete(at_uninstall=False) + def _unlink_check_status(self): + for record in self: + if record.state not in ['new', 'cancelled']: + raise UserError(_("A property can only be deleted if its state is 'New' or 'Cancelled'")) + + def action_property_cancel(self): + for record in self: + if record.state == 'sold': + raise UserError(_("A sold property cannot be cancelled.")) + record.state = 'cancelled' + return True + + def action_property_sold(self): + for record in self: + if record.state == 'cancelled': + raise UserError(_("A cancelled property cannot be sold.")) + if not record.offer_ids: + raise UserError(_("A property with no offers cannot be sold.")) + if not record.buyer_id or not record.best_price > 0 or not any(o for o in record.offer_ids if o.status == "accepted"): + raise UserError(_("A property with no accepted offer cannot be sold")) + record.state = 'sold' + return True diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..cfb854ac724 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,67 @@ +from datetime import datetime, timedelta + +from odoo import _, api, exceptions, fields, models +from odoo.exceptions import UserError +from odoo.tools.float_utils import float_compare + + +class PropertyOffer(models.Model): + _name = 'estate.property.offer' + _description = "Test description for estate.property.offer model" + _order = 'price DESC' + + price = fields.Float() + status = fields.Selection( + string="Offer Status", + copy=False, + selection=[('accepted', "Accepted"), ('refused', "Refused")]) + partner_id = fields.Many2one('res.partner', required=True) + property_id = fields.Many2one('estate.property', required=True) + validity = fields.Integer(default=7) + date_deadline = fields.Date(compute='_compute_date_deadline', inverse='_inverse_date_deadline') + property_type_id = fields.Many2one(related='property_id.property_type_id', store=True) + + _check_price = models.Constraint( + 'CHECK (price > 0)', + "The price must be strictly positive", + ) + + @api.depends('validity') + def _compute_date_deadline(self): + for record in self: + # record.create_date is "falsy" so if checking with `record.create_date if hasattr(record.create_date) else datetime.today()` then it's true because it hasattr but it's None so it's converted to false + record.date_deadline = ((record.create_date or datetime.today()) + timedelta(days=record.validity)).date() + + def _inverse_date_deadline(self): + for record in self: + record.validity = (record.date_deadline - record.create_date.date()).days + + @api.constrains('price') + def _check_selling_price_90_percent(self): + for record in self: + if float_compare(record.price, 0.9 * record.property_id.expected_price, precision_digits=2) == -1: + raise exceptions.UserError(_("The selling price (%s) cannot be lower than 90%% of the expected price (%s)", record.price, record.property_id.expected_price)) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + property = self.env['estate.property'].browse(vals['property_id']) + if property.state == 'sold': + raise UserError(_("Cannot create an offer for a sold property")) + property.state = 'offer_received' + return super().create(vals_list) + + @api.depends('property_id', 'property_id.offer_ids') + def action_offer_accept(self): + for record in self: + if any(o.status == 'accepted' for o in record.property_id.offer_ids): + raise exceptions.UserError(_("Cannot accept more than one offer")) + if float_compare(record.price, 0.9 * record.property_id.expected_price, precision_digits=2) == -1: + raise exceptions.UserError(_("The selling price cannot be lower than 90% of the expected price")) + record.status = 'accepted' + record.property_id.buyer_id = record.partner_id + record.property_id.state = 'offer_accepted' + record.property_id.selling_price = record.price + + def action_offer_refuse(self): + self.status = 'refused' # assigns the same value to all the records diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..e706437bf16 --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,15 @@ +from odoo import fields, models + + +class PropertyTag(models.Model): + _name = 'estate.property.tag' + _description = "Test description for estate.property.tag model" + _order = 'name' + + name = fields.Char(required=True) + color = fields.Integer() + + _check_name = models.Constraint( + 'UNIQUE (name)', + "Property tag 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..14d557560a0 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,23 @@ +from odoo import api, fields, models + + +class PropertyType(models.Model): + _name = 'estate.property.type' + _description = "Test description for estate.property.type model" + _order = 'name' + + name = fields.Char(required=True) + sequence = fields.Integer('Sequence', default=1, help="Used to order types.") + 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', default=0) + + _check_name = models.Constraint( + 'UNIQUE (name)', + "Property type name must be unique", + ) + + @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..e62e2688803 --- /dev/null +++ b/estate/models/res_users.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class ResUsers(models.Model): + _inherit = 'res.users' + + property_ids = fields.One2many('estate.property', 'salesperson_id') diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..89f97c50842 --- /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 diff --git a/estate/tests/__init__.py b/estate/tests/__init__.py new file mode 100644 index 00000000000..e64c8d20e3c --- /dev/null +++ b/estate/tests/__init__.py @@ -0,0 +1,5 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import common +from . import test_estate_property +from . import test_estate_property_offer diff --git a/estate/tests/common.py b/estate/tests/common.py new file mode 100644 index 00000000000..e7c39f6a4c8 --- /dev/null +++ b/estate/tests/common.py @@ -0,0 +1,81 @@ +from odoo.tests.common import TransactionCase + + +class TestEstateCommon(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.partner = cls.env['res.partner'].create({ + 'name': 'Test Buyer', + 'email': 'buyer@example.com', + }) + + cls.property_type = cls.env['estate.property.type'].create({'name': 'test_property_type'}) + + cls.tag1 = cls.env['estate.property.tag'].create({'name': 'Luxury'}) + cls.tag2 = cls.env['estate.property.tag'].create({'name': 'Garden'}) + + cls.cozy_cottage = cls.env['estate.property'].create({ + 'name': 'Cozy Cottage', + 'expected_price': 250000, + 'property_type_id': cls.property_type.id, + 'description': 'A beautiful cottage in the countryside.', + 'postcode': '12345', + 'living_area': 120, + 'facades': 2, + 'garage': True, + 'garden': True, + 'garden_area': 20, + 'garden_orientation': "north", + 'bedrooms': 3, + 'buyer_id': cls.partner.id, + 'tag_ids': [(6, 0, [cls.tag1.id, cls.tag2.id])], + }) + cls.modern_apartment = cls.env['estate.property'].create({ + 'name': 'Modern Apartment', + 'expected_price': 180000, + 'property_type_id': cls.property_type.id, + 'description': 'City center apartment with modern design.', + 'postcode': '54321', + 'living_area': 85, + 'facades': 1, + 'garage': False, + 'garden': False, + 'bedrooms': 2, + 'tag_ids': [(6, 0, [cls.tag1.id])], + }) + cls.beachfront_villa = cls.env['estate.property'].create({ + 'name': 'Beachfront Villa', + 'expected_price': 750000, + 'property_type_id': cls.property_type.id, + 'description': 'Villa with private beach access.', + 'postcode': '67890', + 'living_area': 200, + 'facades': 4, + 'garage': True, + 'garden': True, + 'garden_area': 100, + 'bedrooms': 5, + 'tag_ids': [(6, 0, [cls.tag2.id])], + }) + + # Create offers for only 2 properties (leave one without offers) + cls.offers = cls.env['estate.property.offer'].create([ + { + 'price': 260000, + 'partner_id': cls.partner.id, + 'property_id': cls.cozy_cottage.id, + }, + { + 'price': 185000, + 'partner_id': cls.partner.id, + 'property_id': cls.modern_apartment.id, + }, + # No offer for Beachfront Villa -> will trigger UserError when trying to sell + ]) + + def _sell_cozy_cottage(self): + self.offers[0].action_offer_accept() + self.cozy_cottage.action_property_sold() diff --git a/estate/tests/test_estate_property.py b/estate/tests/test_estate_property.py new file mode 100644 index 00000000000..079ee9d29ab --- /dev/null +++ b/estate/tests/test_estate_property.py @@ -0,0 +1,38 @@ +from odoo.exceptions import UserError +from odoo.tests import Form, tagged + +from odoo.addons.estate.tests.common import TestEstateCommon + + +# The CI will run these tests after all the modules are installed, +# not right after installing the one defining it. +@tagged('post_install', '-at_install') +class TestEstateProperty(TestEstateCommon): + def test_action_property_sold(self): + """Test that everything behaves like it should when selling a property.""" + + # Accept one offer to be able to sell the property + self._sell_cozy_cottage() + + self.assertRecordValues(self.cozy_cottage, + [{'name': "Cozy Cottage", 'state': 'sold'}]) + + with self.assertRaises(UserError, msg="A property with no accepted offer cannot be sold"): + self.modern_apartment.action_property_sold() + with self.assertRaises(UserError, msg="A property with no offers cannot be sold."): + self.beachfront_villa.action_property_sold() + + def test_onchange_garden(self): + """Test that everything behaves as it should when unchecking garden""" + + self.assertRecordValues(self.cozy_cottage, [ + {'name': "Cozy Cottage", 'garden': True, 'garden_area': 20, 'garden_orientation': 'north'}, + ]) + + # Use Form to trigger onchanges + with Form(self.cozy_cottage) as f: + f.garden = False + + self.assertRecordValues(self.cozy_cottage, [ + {'name': "Cozy Cottage", 'garden': False, 'garden_area': 0, 'garden_orientation': None}, + ]) diff --git a/estate/tests/test_estate_property_offer.py b/estate/tests/test_estate_property_offer.py new file mode 100644 index 00000000000..9461852b9f1 --- /dev/null +++ b/estate/tests/test_estate_property_offer.py @@ -0,0 +1,26 @@ +from odoo.exceptions import UserError +from odoo.tests import tagged + +from odoo.addons.estate.tests.common import TestEstateCommon + + +# The CI will run these tests after all the modules are installed, +# not right after installing the one defining it. +@tagged('post_install', '-at_install') +class TestEstatePropertyOffer(TestEstateCommon): + def test_create(self): + """Test that an offer cannot be created for a sold property.""" + + # Sell the property, so we have a sold property to create a new offer on + self._sell_cozy_cottage() + + self.assertRecordValues(self.cozy_cottage, + [{'name': "Cozy Cottage", 'state': 'sold'}]) + + with self.assertRaises(UserError, msg="Cannot create an offer for a sold property"): + self.env['estate.property.offer'].create([ + { + 'price': 270000, + 'partner_id': self.partner.id, + 'property_id': self.cozy_cottage.id, + }]) diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..d876331c341 --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..43b75a08428 --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,36 @@ + + + + estate.property.offer.view.list + estate.property.offer + + + + + +