diff --git a/estate/Pipfile b/estate/Pipfile new file mode 100644 index 0000000000..621139aec7 --- /dev/null +++ b/estate/Pipfile @@ -0,0 +1,12 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +black = "*" + +[dev-packages] + +[requires] +python_version = "3.12" diff --git a/estate/Pipfile.lock b/estate/Pipfile.lock new file mode 100644 index 0000000000..01a1f81007 --- /dev/null +++ b/estate/Pipfile.lock @@ -0,0 +1,90 @@ +{ + "_meta": { + "hash": { + "sha256": "77e9b8783573c4507534daed344ef7fe1082f3c2cd1ca44f60a2b07523cac541" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.12" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "black": { + "hashes": [ + "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", + "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7", + "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da", + "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", + "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", + "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", + "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", + "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", + "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32", + "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", + "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", + "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", + "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0", + "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", + "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", + "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", + "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355", + "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", + "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e", + "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9", + "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", + "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==25.1.0" + }, + "click": { + "hashes": [ + "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", + "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b" + ], + "markers": "python_version >= '3.10'", + "version": "==8.2.1" + }, + "mypy-extensions": { + "hashes": [ + "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", + "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558" + ], + "markers": "python_version >= '3.8'", + "version": "==1.1.0" + }, + "packaging": { + "hashes": [ + "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", + "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" + ], + "markers": "python_version >= '3.8'", + "version": "==25.0" + }, + "pathspec": { + "hashes": [ + "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", + "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" + ], + "markers": "python_version >= '3.8'", + "version": "==0.12.1" + }, + "platformdirs": { + "hashes": [ + "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", + "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4" + ], + "markers": "python_version >= '3.9'", + "version": "==4.3.8" + } + }, + "develop": {} +} diff --git a/estate/__init__.py b/estate/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /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 0000000000..e1bfa81e81 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,18 @@ +{ + "name": "Real Estate", + "version": "1.0", + "category": "Tutorials/RealEstate", + "summary": "This is a test", + "depends": ["base"], + "installable": True, + "application": True, + "data": [ + "views/estate_property_offer_views.xml", + "views/estate_property_tag_views.xml", + "views/estate_property_type_views.xml", + "views/estate_property_views.xml", + "views/res_users_views.xml", + "views/estate_menus.xml", + "security/ir.model.access.csv", + ], +} diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 0000000000..9a2189b638 --- /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 0000000000..85a8829de3 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 + +from typing import final +from odoo import models, fields, api, exceptions, tools + +@final +class EstateProperty(models.Model): + _name = "estate.property" + _description = "Test Model Description here" + _order = "id desc" + + name = fields.Char(required=True) + description = fields.Text() + postcode = fields.Char() + date_availability = fields.Date( + "Available From", + copy=False, + default=lambda _: fields.Date.add(fields.Datetime.now(), months=3), + ) + + @api.ondelete(at_uninstall=False) + def _unlink_except_wrong_state(self) -> None: + record: EstateProperty + for record in self: + if record.state in ["new", "cancelled"]: + raise exceptions.UserError(f"Cannot delete a property of state {record.state}") + + + @api.model_create_multi + def create(self: "EstateProperty", vals_list: list[api.ValuesType]): + for val in vals_list: + val["state"] = "received" + return super().create(vals_list) + + expected_price = fields.Float(required=True) + selling_price = fields.Float(readonly=True, copy=False) + bedrooms = fields.Integer(default=2) + living_area = fields.Integer("Living Area (sqm)") + facades = fields.Integer() + garage = fields.Boolean() + garden = fields.Boolean() + garden_area = fields.Integer("Garden Area (sqm)") + res_users_id = fields.Many2one("res.users") + + garden_orientation = fields.Selection( + [ + ("north", "North"), + ("south", "South"), + ("east", "East"), + ("west", "West"), + ] + ) + active = fields.Boolean(default=True) + state = fields.Selection( + [ + ("new", "New"), + ("offer", "Offer"), + ("received", "Offer Received"), + ("offer_accepted", "Offer Accepted"), + ("sold", "Sold"), + ("cancelled", "Cancelled"), + ], + copy=False, + default="new", + string="State", + ) + property_type_id = fields.Many2one("estate.property.type", string="Property Type") + buyer_id = fields.Many2one("res.partner", string="Buyer", copy=False) + salesperson_id = fields.Many2one( + "res.users", string="Sales Person", default=lambda self: self.env.uid + ) + tag_ids = fields.Many2many("estate.property.tag", string="Tags") + estate_property_offer_ids = fields.One2many( + "estate.property.offer", "estate_property_id" + ) + + total_area = fields.Integer(compute="_compute_total_area") + + @api.depends("living_area", "garden_area") + def _compute_total_area(self) -> None: + for record in self: + record.total_area = record.living_area + record.garden_area + + best_price = fields.Float(compute="_compute_best_price", string="Best Offer") + + @api.depends("estate_property_offer_ids.price", "estate_property_offer_ids.status") + def _compute_best_price(self) -> None: + record: EstatePropert + for record in self: + offers = record.estate_property_offer_ids + offers = offers.filtered(lambda o: o.status != "refused") + record.best_price = max(offers.mapped("price") or [0]) + + @api.onchange("garden") + def _onchange_garden(self) -> None: + self.garden_area = 10 if self.garden else 0 + self.garden_orientation = "north" if self.garden else "" + + def action_cancel_property(self) -> bool: + record: EstateProperty + for record in self: + if record.state == "sold": + raise exceptions.UserError("You can't cancel a sold property") + record.state = "cancelled" + return True + + def action_sell_property(self) -> bool: + record: EstateProperty + for record in self: + if record.state == "cancelled": + raise exceptions.UserError("You can't sell a cancelled property") + record.state = "sold" + return True + + _sql_constraints = [ + ( + "positive_expected_price", + "CHECK(expected_price > 0)", + "Expected price should be stictly positive", + ), + ( + "positive_selling_price", + "CHECK(selling_price >= 0)", + "Selling price should be positive", + ), + ] + + @api.constrains("selling_price", "expected_price", "state") + def _check_selling_price(self) -> None: + record: EstateProperty + for record in self: + if record.state != "offer_accepted": + continue + is_lower = ( + tools.float_compare( + record.selling_price, + record.expected_price * 0.9, + precision_digits=2, + ) + == -1 + ) + if is_lower: + raise exceptions.ValidationError( + "Selling price cannot be lower than 90% of the expected price." + ) diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 0000000000..79f9945ad6 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,77 @@ +from odoo import models, fields, api, exceptions + + +class EstatePropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "" + _order = "price desc" + + price = fields.Float() + status = fields.Selection( + [ + ("accepted", "Accepted"), + ("refused", "Refused"), + ], + copy=False, + ) + partner_id = fields.Many2one("res.partner", required=True) + estate_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" + ) + + @api.depends("create_date", "validity") + def _compute_date_deadline(self) -> None: + record: EstatePropertyOffer + for record in self: + create_date: fields.Datetime = record.create_date or fields.Datetime.now() + record.date_deadline = fields.Date.add(create_date, days=record.validity) + + def _inverse_date_deadline(self) -> None: + # FIXME wasted too much time on this. + record: EstatePropertyOffer + for record in self: + pass + + @api.model_create_multi + def create(self: 'EstatePropertyOffer', vals_list: list[api.ValuesType]): + for val in vals_list: + property_id: int = val['estate_property_id'] + property = self.env['estate.property'].browse(property_id) + prices = property.estate_property_offer_ids.mapped('price') + min_price = min(prices or [0]) + if val['price'] < min_price: + raise exceptions.UserError(f'Price cannot be lower than ${min_price}') + + + return super().create(vals_list) + + def action_accept_offer(self) -> bool: + record: EstatePropertyOffer + for record in self: + all_offers = record.estate_property_id.estate_property_offer_ids + if all_offers.filtered(lambda o: o.status == "accepted"): + raise exceptions.UserError("You can't accept two offers") + record.estate_property_id.buyer_id = record.partner_id + record.estate_property_id.selling_price = record.price + record.estate_property_id.state = "offer_accepted" + record.status = "accepted" + return True + + def action_refuse_offer(self) -> bool: + record: EstatePropertyOffer + for record in self: + record.status = "refused" + return True + + _sql_constraints: list[tuple[str, str, str]] = [ + ( + "offer_price_strictly_positive", + "CHECK(offer > 0)", + "Offer price must be stricly positive.", + ), + ] + + property_type_id = fields.Many2one(related="estate_property_id.property_type_id", store=True) diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 0000000000..e6e16317e1 --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 + +from odoo import models, fields +from typing import final + + +@final +class EstatePropertyTag(models.Model): + _name = "estate.property.tag" + _description = "Tag for a property" + _order = "name asc" + + name = fields.Char(required=True) + + _sql_constraints: list[tuple[str, str, str]] = [ + ( + "unique_name", + "UNIQUE (name)", + "Tag name should be unique", + ), + ] diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py new file mode 100644 index 0000000000..c6ab0f6dff --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,34 @@ +from odoo import models, fields, api + + +class EstatePropertyType(models.Model): + _name = "estate.property.type" + _description = "Type of property" + _order = "sequence, name, id" + + name = fields.Char(required=True) + + sequence = fields.Integer( + "Sequence", default=1, help="Used to order stages. Lower is better." + ) + + _sql_constraints: list[tuple[str, str, str]] = [ + ( + "unique_name", + "UNIQUE (name)", + "Type name should be unique", + ), + ] + + 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") + + + @api.depends("offer_ids") + def _compute_offer_count(self) -> None: + record: EstatePropertyType + 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 0000000000..16221b96e1 --- /dev/null +++ b/estate/models/res_users.py @@ -0,0 +1,9 @@ +from odoo import models, fields + +from odoo import models, fields + + +class ResUsers(models.Model): + _inherit = "res.users" + + property_ids = fields.One2many("estate.property", "res_users_id") diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 0000000000..ce69c72c94 --- /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 +estate.access_estate_property,access_estate_property,estate.model_estate_property,base.group_user,1,1,1,1 +estate.access_estate_property_type,access_estate_property_type,estate.model_estate_property_type,base.group_user,1,1,1,1 +estate.access_estate_property_offer,access_estate_property_offer,estate.model_estate_property_offer,base.group_user,1,1,1,1 +estate.access_estate_property_tag,access_estate_property_tag,estate.model_estate_property_tag,base.group_user,1,1,1,1 diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 0000000000..499c5e7047 --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 0000000000..90fc488254 --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,40 @@ + + + + Offers + estate.property.offer + {'search_default_available': 1, 'create': False} + [('property_type_id', '=', active_id)] + + + estate.property.offer.view.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 0000000000..215e1c7cef --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,130 @@ + + + + Properties + estate.property + {'search_default_available': 1} + kanban,form + + + estate.property.view.kanban + estate.property + + + + + +
+ + + + + + + +
+
+
+
+
+
+ + estate.property.view.list + estate.property + + + + + + + + + + + + + + + + estate.property.view.form + estate.property + +
+
+
+ + +

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + estate.property.view.search + estate.property + + + + + + + + + + + + + + + +
diff --git a/estate/views/res_users_views.xml b/estate/views/res_users_views.xml new file mode 100644 index 0000000000..63a20edd66 --- /dev/null +++ b/estate/views/res_users_views.xml @@ -0,0 +1,12 @@ + + + + res.users.view.form + res.users + + + + + + + diff --git a/estate_account/__init__.py b/estate_account/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /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 0000000000..e5b7cd9888 --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,13 @@ +{ + "name": "Estate Account", + "version": "1.0", + "category": "Tutorials/EstateAccount", + "summary": "Lorem Ipsum", + "depends": ["base", 'estate', 'account'], + "installable": True, + "application": True, + "data": [ + "views/estate_account_menus.xml", + "security/ir.model.access.csv", + ], +} diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py new file mode 100644 index 0000000000..89fe494883 --- /dev/null +++ b/estate_account/models/__init__.py @@ -0,0 +1,2 @@ +from . import estate_account +from . import estate_property diff --git a/estate_account/models/estate_account.py b/estate_account/models/estate_account.py new file mode 100644 index 0000000000..b57a6ee26c --- /dev/null +++ b/estate_account/models/estate_account.py @@ -0,0 +1,9 @@ +from odoo import models, fields + + +class EstateAccount(models.Model): + _name = "estate.account" + _description = "Account of real estate" + _order = "name asc" + + name = fields.Char(required=True) diff --git a/estate_account/models/estate_property.py b/estate_account/models/estate_property.py new file mode 100644 index 0000000000..e29bd60cee --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,42 @@ +from odoo import models, fields + +""" +NOTE +An account move is just a group of operations (account move lines) +E.g.: a vendor bill is an account move + a customer invoice is an account move too +""" + + +class EstateProperty(models.Model): + _inherit = "estate.property" + + def action_sell_property(self) -> bool: + if not super().action_sell_property(): + return False + record: EstateProperty + for record in self: + partner_id: int = record.buyer_id.id + invoice_lines = [ + fields.Command.create( + { + "name": "Partial pay", + "quantity": 1, + "price_unit": record.selling_price / 100 * 6 + } + ), + fields.Command.create( + { + "name": "Administrative fees", + "quantity": 1, + "price_unit": 100 + } + ), + ] + account_move = self.env["account.move"].create( + { + "move_type": "out_invoice", + "partner_id": partner_id, + "invoice_line_ids": invoice_lines + } + ) diff --git a/estate_account/security/ir.model.access.csv b/estate_account/security/ir.model.access.csv new file mode 100644 index 0000000000..5fa6dd6e81 --- /dev/null +++ b/estate_account/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +estate_account.access_estate_account,access_estate_account,estate_account.model_estate_account,base.group_user,1,1,1,1 diff --git a/estate_account/views/estate_account_menus.xml b/estate_account/views/estate_account_menus.xml new file mode 100644 index 0000000000..38f0b06fb9 --- /dev/null +++ b/estate_account/views/estate_account_menus.xml @@ -0,0 +1,4 @@ + + + +