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..5f5f859fc4
--- /dev/null
+++ b/estate/__manifest__.py
@@ -0,0 +1,23 @@
+# A custom real estate module, created for learning purposes
+
+{
+ 'name': '[TUTO] Estate',
+ 'summary': 'Track your real estate properties',
+ 'depends': [
+ 'base_setup'
+ ],
+ 'data': [
+ 'security/ir.model.access.csv',
+
+ 'views/estate_property_views.xml',
+ 'views/estate_settings_views.xml',
+ 'views/estate_property_offer_views.xml',
+ 'views/estate_menu_views.xml'
+ ],
+ 'installable': True,
+ 'application': True,
+ 'demo': [
+ "demo/estate_demo.xml",
+ ],
+ 'auto_install': True
+}
diff --git a/estate/demo/estate_demo.xml b/estate/demo/estate_demo.xml
new file mode 100644
index 0000000000..389e05122f
--- /dev/null
+++ b/estate/demo/estate_demo.xml
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+ Appartment
+
+
+ House
+
+
+
+
+ cosy
+
+
+ renovated
+
+
+ renovation needed
+
+
+
+
+ 2000000
+
+ 12
+
+
+ 200000
+
+ 10
+
+
+ 220000
+
+ 25
+
+
+ 260000
+
+
+
+
+
+
+ Beach House
+ canceled
+ A brand new house by the beach
+ 8300
+
+ 2500000
+ 0
+ 6
+ 1050
+ 4
+ True
+ True
+ 420
+ south
+
+
+
+
+
+
+ City Apt
+ offer_accepted
+ A shitty appartment in Brussels
+ 1090
+
+ 250000
+ 0
+ 2
+ 95
+ 1
+ False
+ False
+ 0
+
+
+
+
+
+
+
+
+ Mass cancel
+
+
+ list
+ code
+ action = records.action_cancel()
+
+
+
diff --git a/estate/models/__init__.py b/estate/models/__init__.py
new file mode 100644
index 0000000000..a94838fc6d
--- /dev/null
+++ b/estate/models/__init__.py
@@ -0,0 +1 @@
+from . import estate_property_infos, estate_property, estate_property_offer
diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py
new file mode 100644
index 0000000000..0b6d60b866
--- /dev/null
+++ b/estate/models/estate_property.py
@@ -0,0 +1,139 @@
+from odoo import api, exceptions, fields, models
+from datetime import date
+from dateutil.relativedelta import relativedelta
+
+
+class Estate_Property(models.Model):
+ _name = "estate_property"
+ _description = "Estate properties"
+ _order = "id desc"
+ active = False
+
+ name = fields.Char(required=True, string="Title")
+
+ status = fields.Selection(
+ [
+ ("new", "New"),
+ ("offer_received", "Offer received"),
+ ("offer_accepted", "Offer Accepted"),
+ ("sold", "Sold"),
+ ("canceled", "Canceled"),
+ ],
+ default="new",
+ readonly=True,
+ copy=False,
+ string="Sale State",
+ )
+
+ description = fields.Text(copy=False, string="Description")
+
+ postcode = fields.Char(
+ help="Une adresse serait mieux mais bon...", string="Postcode"
+ )
+
+ date_availability = fields.Date(
+ default=(date.today() + relativedelta(months=+3)),
+ copy=False,
+ string="Available From",
+ )
+
+ expected_price = fields.Float(
+ default=0.0, required=True, copy=False, string="Expected Price"
+ )
+
+ selling_price = fields.Float(readonly=True, string="Selling Price")
+
+ bedrooms = fields.Integer(default=2, string="Bedrooms")
+
+ living_area = fields.Integer(string="Living Area (sqm)")
+
+ facades = fields.Integer(default=2, string="Facades")
+
+ garage = fields.Boolean(default=False, string="Garage")
+
+ garden = fields.Boolean(default=False, string="Garden")
+
+ garden_area = fields.Integer(default=0, string="Garden Area (sqm)")
+
+ garden_orientation = fields.Selection(
+ [("north", "North"), ("south", "South"), ("west", "West"), ("east", "East")],
+ string="Garden Orientation",
+ )
+
+ type_id = fields.Many2one(
+ "estate_property_type", required=True, string="Property Type"
+ )
+
+ buyer = fields.Many2one("res.partner", copy=False, string="Buyer")
+
+ salesperson = fields.Many2one(
+ "res.users", default=(lambda self: self.env.user), string="Salesman"
+ )
+
+ tag_ids = fields.Many2many("estate_property_tag", string="Property Tags")
+
+ offer_ids = fields.One2many("estate_property_offer", "property_id", string="Offers")
+
+ total_area = fields.Integer(compute="_compute_total_area", string="Total Area")
+
+ best_offer = fields.Float(
+ compute="_compute_best_offer", default=0.0, string="Best Offer"
+ )
+
+ _sql_constraints = [
+ (
+ "check_positive_expected_price",
+ "CHECK(expected_price >= 0.0)",
+ "Expected Price should be a positive number.",
+ ),
+ (
+ "check_positive_selling_price",
+ "CHECK(selling_price >= 0.0)",
+ "Selling Price should be a positive number.",
+ ),
+ ]
+
+ @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")
+ def _compute_best_offer(self):
+ for record in self:
+ record.best_offer = max(record.offer_ids.mapped("price"))
+
+ @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 = ""
+
+ @api.constrains("selling_price", "expected_price")
+ def _check_expected_vs_selling_price(self):
+ for record in self:
+ if (record.selling_price > 0.0) and (
+ record.selling_price < 0.9 * record.expected_price
+ ):
+ raise exceptions.ValidationError(
+ r"Cannot sell for less than 90% of expected price."
+ )
+
+ def action_sold(self):
+ for record in self:
+ if record.status != "canceled":
+ record.status = "sold"
+ else:
+ raise exceptions.UserError("Canceled properties cannot be sold.")
+ return True
+
+ def action_cancel(self):
+ for record in self:
+ if record.status != "sold":
+ record.status = "canceled"
+ else:
+ raise exceptions.UserError("Sold properties cannot be canceled.")
+ return True
diff --git a/estate/models/estate_property_infos.py b/estate/models/estate_property_infos.py
new file mode 100644
index 0000000000..f4521f1534
--- /dev/null
+++ b/estate/models/estate_property_infos.py
@@ -0,0 +1,42 @@
+from odoo import fields, models
+
+
+class Estate_Property_Type(models.Model):
+ _name = "estate_property_type"
+ _description = "Estate property Types"
+ _order = "name"
+
+ name = fields.Char(
+ required=True,
+ string="Type"
+ )
+
+ property_ids = fields.One2many(
+ "estate_property",
+ "type_id",
+ string="Estate Properties"
+ )
+
+ _sql_constraints = [
+ ("check_unique_type", "UNIQUE(name)", "Property types must be unique.")
+ ]
+
+
+class Estate_Property_Tag(models.Model):
+ _name = "estate_property_tag"
+ _description = "Estate property Tags"
+ _order = "name"
+
+ name = fields.Char(
+ required=True,
+ string="Type"
+ )
+
+ property_estate_ids = fields.Many2many(
+ "estate_property",
+ string="Estate Properties"
+ )
+
+ _sql_constraints = [
+ ("check_unique_tag", "UNIQUE(name)", "Property tags must be unique.")
+ ]
diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py
new file mode 100644
index 0000000000..9c44323807
--- /dev/null
+++ b/estate/models/estate_property_offer.py
@@ -0,0 +1,76 @@
+from odoo import api, exceptions, fields, models
+from datetime import date
+from dateutil.relativedelta import relativedelta
+
+
+class Estate_Property_Offer(models.Model):
+ _name = "estate_property_offer"
+ _description = "Estate Property Offers"
+ _order = "price desc"
+
+ price = fields.Float(
+ string="Price"
+ )
+
+ status = fields.Selection(
+ [("accepted", "Accepted"), ("refused", "Refused")],
+ readonly=True,
+ copy=False,
+ string="Status"
+ )
+
+ partner_id = fields.Many2one(
+ 'res.partner',
+ required=True,
+ string="Partner"
+ )
+
+ property_id = fields.Many2one(
+ 'estate_property',
+ string="Property"
+ )
+
+ validity = fields.Integer(
+ default=7,
+ string="Validity (days)"
+ )
+
+ deadline = fields.Date(
+ compute="_compute_deadline",
+ copy=False,
+ string="Deadline"
+ )
+
+ _sql_constraints = [
+ ("check_positive_price", "CHECK(price > 0.0)", "Offer Price should be a positive number (higher than 0).")
+ ]
+
+ @api.depends("validity")
+ def _compute_deadline(self):
+ for record in self:
+ record.deadline = date.today() + relativedelta(days=+record.validity)
+
+ def _inverse_deadline(self):
+ for record in self:
+ record.validity = relativedelta(date.today(), record.deadline)
+
+ def action_accept(self):
+ for record in self:
+ if not any(offer_status == "accepted" for offer_status in record.property_id.offer_ids.mapped("status")):
+ # Set values in the Property itself
+ record.property_id.selling_price = record.price
+ record.property_id.buyer = record.partner_id
+
+ record.status = "accepted"
+ else:
+ raise exceptions.UserError("An offer has already been accepted.")
+ return True
+
+ def action_refuse(self):
+ for record in self:
+ if record.status == "accepted":
+ # Set values in the Property itself
+ record.property_id.selling_price = 0.0
+ record.property_id.buyer = None
+ record.status = "refused"
+ return True
diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv
new file mode 100644
index 0000000000..0db13e578e
--- /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_tag,access_estate_property_tag,estate.model_estate_property_tag,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
\ No newline at end of file
diff --git a/estate/static/description/icon.webp b/estate/static/description/icon.webp
new file mode 100644
index 0000000000..9758ad2b58
Binary files /dev/null and b/estate/static/description/icon.webp differ
diff --git a/estate/views/estate_menu_views.xml b/estate/views/estate_menu_views.xml
new file mode 100644
index 0000000000..a4cdc3455e
--- /dev/null
+++ b/estate/views/estate_menu_views.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml
new file mode 100644
index 0000000000..5599dbeae3
--- /dev/null
+++ b/estate/views/estate_property_offer_views.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+ estate_property_offer_list
+ estate_property_offer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Offers
+ estate_property_offer
+ list
+
+
+
\ 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 0000000000..5c117cd390
--- /dev/null
+++ b/estate/views/estate_property_views.xml
@@ -0,0 +1,123 @@
+
+
+
+
+
+ estate_property_search
+ estate_property
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ estate_property_form
+ estate_property
+
+
+
+
+
+
+
+
+
+ estate_property_list
+ estate_property
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Estate Properties
+ estate_property
+ list,form
+
+
+
\ No newline at end of file
diff --git a/estate/views/estate_settings_views.xml b/estate/views/estate_settings_views.xml
new file mode 100644
index 0000000000..71ec6ef34f
--- /dev/null
+++ b/estate/views/estate_settings_views.xml
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+ estate_property_type_form
+ estate_property_type
+
+
+
+
+
+
+
+
+ estate_property_type_list
+ estate_property_type
+
+
+
+
+
+
+
+
+
+
+
+ estate_property_tag_list
+ estate_property_tag
+
+
+
+
+
+
+
+
+
+
+ Property Types
+ estate_property_type
+ list,form
+
+
+
+ Property Tags
+ estate_property_tag
+ list
+
+
+
\ No newline at end of file