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
+
+
+
+
+
+
+
+
+
+
+
+
+ estate.property.offer.view.form
+ estate.property.offer
+
+
+
+
+
diff --git a/estate/views/estate_property_tag_views.xml b/estate/views/estate_property_tag_views.xml
new file mode 100644
index 0000000000..04d96a7483
--- /dev/null
+++ b/estate/views/estate_property_tag_views.xml
@@ -0,0 +1,17 @@
+
+
+
+
+ Property Tags
+ estate.property.tag
+
+
+ estate.property.tag.view.list
+ estate.property.tag
+
+
+
+
+
+
+
diff --git a/estate/views/estate_property_type_views.xml b/estate/views/estate_property_type_views.xml
new file mode 100644
index 0000000000..97660a4165
--- /dev/null
+++ b/estate/views/estate_property_type_views.xml
@@ -0,0 +1,40 @@
+
+
+
+ Property Types
+ estate.property.type
+
+
+ estate.property.type.view.list
+ estate.property.type
+
+
+
+
+
+
+
+
+
+
+ estate.property.type.view.form
+ estate.property.type
+
+
+
+
+
+
+
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 @@
+
+
+
+