diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000000..f9201e34206 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.analysis.extrapaths": [ + "/home/odoo/Documents/odoo" + ], + + "files.insertFinalNewline": true +} 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..690e0a1421e --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,24 @@ +{ + 'name': "Estate", + 'category': "Real Estate/Brokerage", + 'depends': [ + 'base' + ], + 'data': [ + 'security/security.xml', + 'security/ir.model.access.csv', + 'data/estate.property.type.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_user_view.xml', + 'views/estate_menus.xml', + ], + "demo": [ + 'demo/demo_data.xml' + ], + 'application': True, + 'author': "Odoo S.A.", + 'license': "LGPL-3", +} diff --git a/estate/data/estate.property.type.csv b/estate/data/estate.property.type.csv new file mode 100644 index 00000000000..619ef009cfb --- /dev/null +++ b/estate/data/estate.property.type.csv @@ -0,0 +1,5 @@ +id,name +estate_property_type1,"Residential" +estate_property_type2,"Commercial" +estate_property_type3,"Industrial" +estate_property_type4,"Land" diff --git a/estate/demo/demo_data.xml b/estate/demo/demo_data.xml new file mode 100644 index 00000000000..37ede026d2e --- /dev/null +++ b/estate/demo/demo_data.xml @@ -0,0 +1,102 @@ + + + + + + Big Villa + new + A nice and big villa + 12345 + 2026-02-02 + 1600000 + 6 + 100 + 4 + True + True + 100000 + south + + + + + Trailer home + canceled + Home in a trailer park + 54321 + 1970-01-01 + 100000 + 120000 + 1 + 10 + 4 + False + + + + + One2many Test + new + Adding offers directly in the field test + 7965 + 2025-12-01 + 215000 + 1 + 65 + 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..9a2189b6382 --- /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 res_users diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..58e3324dd53 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,132 @@ +from odoo import models, fields, api, exceptions +from odoo.tools.float_utils import float_compare + + +class EstateProperty(models.Model): + _name = "estate.property" + _description = "Estate Properties" + _order = "id desc" + + _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.', + ) + + #################################################### + # FIELDS DECLARATION + #################################################### + + name = fields.Char( + string='Title', + required=True, + default="My new house" + ) + description = fields.Text() + notes = fields.Html() + postcode = fields.Char() + date_availability = fields.Date( + string='Available From', + default=fields.Date.add(fields.Date.today(), months=3), + copy=False + ) + expected_price = fields.Float(string='Expected price', 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( + selection=[('new', 'New'), ('offer_received', 'Offer Received'), ('offer_accepted', 'Offer Accepted'), ('sold', 'Sold'), ('canceled', 'Canceled')], + default='new' + ) + property_type_id = fields.Many2one("estate.property.type") + partner_id = fields.Many2one("res.partner", string="Buyer") + user_id = fields.Many2one( + "res.users", + string="Salesperson", + 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_offer = fields.Float(compute="_compute_best_price") + company_id = fields.Many2one( + "res.company", + required=True, + default=lambda self: self.env.user.company_id + ) + + #################################################### + # FUNCTIONS DECLARATION + #################################################### + + def cancel_property_button(self): + self.ensure_one() + if self.state != "sold": + self.state = "canceled" + else: + raise exceptions.UserError("Sold properties cannot be canceled") + return True + + def sold_property_button(self): + self.ensure_one() + if self.state != "canceled": + self.state = "sold" + else: + raise exceptions.UserError("Canceled properties cannot be sold") + return True + + @api.depends("garden_area", "living_area") + def _compute_total_area(self): + for record in self: + record.total_area = record.garden_area + record.living_area + + @api.depends("offer_ids.price") + def _compute_best_price(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 not self.garden: + self.garden_orientation = "north" + self.garden_area = 10 + + @api.constrains("state") + def _check_offers_state(self): + self.ensure_one() + if self.state == 'sold' and not [offer for offer in self.offer_ids if offer.status == 'accepted']: + raise exceptions.UserError("You cannot sold a property without accepted offer") + + @api.constrains('selling_price', 'expected_price') + def _check_date_end(self): + for record in self: + if float_compare(record.selling_price, 0.9 * record.expected_price, precision_digits=2) < 0 and record.selling_price > 0: + raise exceptions.ValidationError( + r"The selling price must be at least 90% of the expected price! You must reduce the expected price if you want to accept this offer." + ) + + #################################################### + # CRUD + #################################################### + + @api.ondelete(at_uninstall=False) + def _unlink_if_offer_unavailable(self): + if self.state not in ['new', 'canceled']: + raise exceptions.UserError("Can't delete an active property!") diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..f6a785c1122 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,93 @@ +from odoo import models, fields, api, exceptions + + +class EstatePropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "Estate Property Offer" + _order = "price desc" + + _check_offer_price = models.Constraint( + 'CHECK(price > 0)', + 'The offer price must be strictly positive.', + ) + + #################################################### + # FIELDS DECLARATION + #################################################### + + price = fields.Float(required=True) + status = fields.Selection( + 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, + string="Validity (days)" + ) + date_deadline = fields.Date( + compute="_compute_date_deadline", + inverse="_inverse_date_deadline", + string="Deadline" + ) + property_type_id = fields.Many2one(related="property_id.property_type_id", store=True) + + #################################################### + # FUNCTIONS DECLARATION + #################################################### + + def accept_offer_button(self): + for record in self: + for offer in record.property_id.offer_ids: + if offer.status == "accepted" and offer.partner_id != record.partner_id: + raise exceptions.UserError("You can only accept one offer !") + break + record.status = "accepted" + record.property_id.partner_id = record.partner_id + record.property_id.selling_price = record.price + record.property_id.state = "offer_accepted" + return True + + def refuse_offer_button(self): + for record in self: + if record.status == "accepted": + record.property_id.partner_id = "" + record.property_id.selling_price = 0 + record.status = "refused" + return True + + @api.depends("create_date", "validity") + def _compute_date_deadline(self): + for record in self: + if not record.create_date: + record.create_date = fields.Date.today() + record.date_deadline = fields.Date.add(fields.Date.to_date(record.create_date), days=record.validity) + + def _inverse_date_deadline(self): + for record in self: + record.validity = (fields.Date.to_date(record.date_deadline) - fields.Date.to_date(record.create_date)).days + + #################################################### + # CRUD + #################################################### + + @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 exceptions.UserError("Can't add an offer to an already sold property!") + for offer in property_id.offer_ids: + if offer.price > val['price']: + raise exceptions.UserError("Can't add an offer with smaller price than a previous one!") + offers = super().create(vals) + offers.property_id.state = "offer_received" + + return offers diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..e609d4a7918 --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,15 @@ +from odoo import models, fields + + +class EstatePropertyTag(models.Model): + _name = "estate.property.tag" + _description = "Estate Property Tag" + _order = "name asc" + + _check_unique_name = models.Constraint( + 'UNIQUE (name)', + 'Type names 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..d82a6ee57bc --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,29 @@ +from odoo import models, fields, api + + +class EstatePropertyType(models.Model): + _name = "estate.property.type" + _description = "Estate Property Type" + _order = "sequence asc" + + _check_unique_name = models.Constraint( + 'UNIQUE (name)', + 'Tag names must be unique.', + ) + + name = fields.Char(required=True) + property_ids = fields.One2many( + "estate.property", + "property_type_id" + ) + sequence = fields.Integer(default=1, help="Used to order stages. Lower is better.") + 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) diff --git a/estate/models/res_users.py b/estate/models/res_users.py new file mode 100644 index 00000000000..d94a3d01a28 --- /dev/null +++ b/estate/models/res_users.py @@ -0,0 +1,7 @@ +from odoo import models, fields + + +class UsersExtension(models.Model): + _inherit = 'res.users' + + property_ids = fields.One2many("estate.property", "user_id", domain=[('state', 'in', ['new', 'offer_received'])]) diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..294fbcc1d1c --- /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_admin,access_estate_property_admin,model_estate_property,estate.estate_group_manager,1,1,1,0 +access_estate_property_type_admin,access_estate_property_type,model_estate_property_type,estate.estate_group_manager,1,1,1,1 +access_estate_property_tag_admin,access_estate_property_tag,model_estate_property_tag,estate.estate_group_manager,1,1,1,1 +access_estate_property_offer_admin,access_estate_property_offer,model_estate_property_offer,estate.estate_group_manager,1,1,1,1 +access_estate_property_user,access_estate_property_user,model_estate_property,estate.estate_group_user,1,1,1,0 +access_estate_property_type_user,access_estate_property_type_user,model_estate_property_type,estate.estate_group_user,1,0,0,0 +access_estate_property_tag_user,access_estate_property_tag_user,model_estate_property_tag,estate.estate_group_user,1,0,0,0 +access_estate_property_offer_user,access_estate_property_offer_user,model_estate_property_offer,estate.estate_group_user,1,1,1,1 diff --git a/estate/security/security.xml b/estate/security/security.xml new file mode 100644 index 00000000000..8bd76c3a197 --- /dev/null +++ b/estate/security/security.xml @@ -0,0 +1,40 @@ + + + + + Agent + The user will be able to manage properties and offers. + + + + Manager + + The user will have access to the real estate configuration. + + + + Real estate agents can only see and update properties affected to them or without agent affected. + + + [ + '|', ('user_id', '=', user.id), + ('user_id', '=', False) + ] + + + + + + [(1, '=', 1)] + + + + Estate company multi-company + + [ + '|', ('company_id', '=', False), + ('company_id', 'in', company_ids) + ] + + + diff --git a/estate/static/description/icon.jpg b/estate/static/description/icon.jpg new file mode 100644 index 00000000000..c75544662b4 Binary files /dev/null and b/estate/static/description/icon.jpg differ 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..3c17fd05f2e --- /dev/null +++ b/estate/tests/test_estate_property.py @@ -0,0 +1,48 @@ +from odoo.tests.common import TransactionCase +from odoo.exceptions import UserError +from odoo.tests import tagged, Form + + +@tagged('post_install', '-at_install') +class EstateTestCase(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.properties = cls.env['estate.property'].create({"name": "Test house", "expected_price": 240000}) + cls.partner = cls.env['res.partner'].create({"name": "Partner"}) + + def test_offer_creation(self): + """Test that the offer cannot be created if the property is sold.""" + self.env['estate.property.offer'].create({"price": 240000, "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": 240000, "partner_id": self.partner.id, "property_id": self.properties.id}) + + def test_property_selling(self): + """Test that the property cannot be sold if no accepted offer.""" + with self.assertRaises(UserError): + self.properties.state = 'sold' + + self.env['estate.property.offer'].create({"price": 240000, "partner_id": self.partner.id, "property_id": self.properties.id, 'status': 'accepted'}) + self.properties.state = 'sold' + + def test_garden_fields_reset(self): + estate_property_form = Form(self.env['estate.property'].with_context({"name": "Test garden", "expected_price": 320000})) + + estate_property_form.garden = True + estate_property_form.garden_orientation = 'east' + estate_property_form.garden_area = 120 + + self.assertEqual(estate_property_form.garden_orientation, "east") + self.assertEqual(estate_property_form.garden_area, 120) + + estate_property_form.garden = False + self.assertEqual(estate_property_form.garden_orientation, "north") + self.assertEqual(estate_property_form.garden_area, 10) + + estate_property_form.garden = True + self.assertEqual(estate_property_form.garden_orientation, "north") + self.assertEqual(estate_property_form.garden_area, 10) diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..2ab5410125d --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..01762dde77d --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,56 @@ + + + + + Property Offers + estate.property.offer + list,form + [('property_type_id', '=', active_id)] + +

Can you see this section ?

+
+
+ + + estate.property.offer.list + estate.property.offer + + + + + + + + +

+ +

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

Can you see this section ?

+
+
+ + + estate.property.list + estate.property + + + + + + + + + + + + + + + + estate.property.form + estate.property + +
+
+
+ +

+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + estate.property.search + estate.property + + + + + + + + + + + + + + + + + + + estate.property.kanban + estate.property + + + + + +
+

+ +

+ Expected Price: +
+ Best Offer: +
+
+ Selling Price: +
+ +
+
+
+
+
+
+ +
diff --git a/estate/views/res_user_view.xml b/estate/views/res_user_view.xml new file mode 100644 index 00000000000..f4fd4d98d9e --- /dev/null +++ b/estate/views/res_user_view.xml @@ -0,0 +1,19 @@ + + + + + + res.users.view.form.inherit.gamification + res.users + + + + + + + + + + + + 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..7383494bccf --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,13 @@ +{ + 'name': "Estate Account", + 'depends': [ + 'base', + 'estate', + 'account' + ], + 'data': [ + ], + 'application': False, + '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..b62b9c186ff --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,33 @@ +from odoo import models, Command + + +class EstatePropertyExtension(models.Model): + _inherit = 'estate.property' + + def sold_property_button(self): + self.env['account.move'].check_access('write') + + # Set the create function parameters + values = {"partner_id": self.partner_id.id, + "move_type": "out_invoice", + "line_ids": [ + Command.create({ + "name": self.name, + "quantity": 1, + "price_unit": self.selling_price + }), + Command.create({ + "name": r"6% malus for being ugly", + "quantity": 1, + "price_unit": 0.06 * self.selling_price + }), + Command.create({ + "name": "Administrative fees", + "quantity": 1, + "price_unit": 100 + }) + ], + } + # Create the invoice + self.env['account.move'].sudo().create(values) + return super().sold_property_button()