diff --git a/estate/__init__.py b/estate/__init__.py
new file mode 100644
index 00000000000..a9e3372262c
--- /dev/null
+++ b/estate/__init__.py
@@ -0,0 +1,2 @@
+
+from . import models
diff --git a/estate/__manifest__.py b/estate/__manifest__.py
new file mode 100644
index 00000000000..e5838f68794
--- /dev/null
+++ b/estate/__manifest__.py
@@ -0,0 +1,17 @@
+{
+ 'name': 'Estate',
+ 'category': 'Sales',
+ 'sequence': 1,
+ 'summary': 'Sell and bid on the hottest real estate properties.',
+ 'website': 'https://www.odoo.com/app/estate',
+ 'depends': [
+ 'base_setup',
+ 'web',
+ ],
+ 'data': [
+ 'security/ir.model.access.csv',
+ 'views/estate_property_views.xml',
+ 'views/estate_menus.xml',
+ ],
+ 'application': True
+}
diff --git a/estate/models/__init__.py b/estate/models/__init__.py
new file mode 100644
index 00000000000..a2dc03361d9
--- /dev/null
+++ b/estate/models/__init__.py
@@ -0,0 +1,6 @@
+
+from . import estate_property
+from . import estate_property_type
+from . import estate_property_tag
+from . import estate_property_offer
+from . import inherited_model
diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py
new file mode 100644
index 00000000000..3ca8ba51001
--- /dev/null
+++ b/estate/models/estate_property.py
@@ -0,0 +1,123 @@
+from odoo import fields, models, api
+from dateutil.relativedelta import relativedelta
+from odoo.exceptions import UserError
+
+
+class EstateProperty(models.Model):
+ _name = 'estate.property'
+ _description = "Real Estate Property"
+ _order = 'id desc'
+
+ name = fields.Char("Title", required=True, translate=True)
+ property_type_id = fields.Many2one(
+ 'estate.property.type',
+ string="Property Type",
+ )
+ postcode = fields.Char("Postcode", required=True)
+ availability = fields.Date(
+ "Available From",
+ required=True,
+ copy=False,
+ default=lambda self: fields.Date.today() + relativedelta(months=3),
+ )
+ description = fields.Text("Description")
+ bedrooms = fields.Integer("Bedrooms", required=True, default=2)
+ living_area = fields.Integer("Living Area (sqm)", required=True)
+ currency_id = fields.Many2one('res.currency', string="Currency", default=lambda self: self.env.company.currency_id.id)
+ expected_price = fields.Monetary("Expected Price", required=True)
+ selling_price = fields.Monetary("Selling Price", readonly=True, copy=False)
+ # best_offer_id = fields.Many2one('estate.property.offer', string='Best Offer', readonly=True)
+ facades = fields.Integer("Facades", default=False)
+ garage = fields.Boolean("Garage", default=False)
+ garden = fields.Boolean("Garden", default=False)
+ garden_area = fields.Integer("Garden Area (sqm)", required=False)
+ garden_orientation = fields.Selection(selection=[('north', "North"), ('south', "South"), ('east', "East"), ('west', "West")], string="Garden Orientation")
+
+ @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 = False
+
+ total_area = fields.Integer("Total Area (sqm)", compute='_compute_area')
+
+ @api.depends('garden_area', 'living_area')
+ def _compute_area(self):
+ for record in self:
+ record.total_area = record.living_area + record.garden_area
+
+ state = fields.Selection(
+ string="Status",
+ selection=[('new', "New"), ('offer_received', "Offer Received"), ('offer_accepted', "Offer Accepted"), ('sold', "Sold"), ('canceled', "Canceled")],
+ required=True,
+ copy=False,
+ default='new',
+ )
+
+ def sold_action(self):
+ for record in self:
+ if record.state != 'canceled':
+ record.state = 'sold'
+ else:
+ raise UserError(record.env._("You can not sell a canceled property."))
+ return True
+
+ def cancel_action(self):
+ for record in self:
+ if record.state != 'sold':
+ record.state = 'canceled'
+ else:
+ raise UserError(record.env._("You can not cancel a sold property."))
+ return True
+ active = fields.Boolean("Active", default=True)
+ buyer_id = fields.Many2one('res.partner', string="Buyer", copy=False)
+ seller_id = fields.Many2one('res.users', string="Salesperson", default=lambda self: self.env.user)
+ tag_ids = fields.Many2many(
+ 'estate.property.tag',
+ string="Tags",
+ )
+ offer_ids = fields.One2many(
+ 'estate.property.offer',
+ 'property_id',
+ string="Offers",
+ )
+ best_offer = fields.Monetary(
+ string="Best Offer",
+ compute='_compute_best_offer',
+ )
+
+ @api.depends('offer_ids.price')
+ def _compute_best_offer(self):
+ for record in self:
+ if record.offer_ids:
+ record.best_offer = max(record.offer_ids.mapped('price'))
+ else:
+ record.best_offer = 0.0
+
+ def write(self, vals):
+ result = super().write(vals)
+ if 'offer_ids' in vals:
+ for record in self:
+ if record.offer_ids and record.state == 'new':
+ record.state = 'offer_received'
+ elif not record.offer_ids and record.state == 'offer_received':
+ record.state = 'new'
+ return result
+
+ _check_expected_price = models.Constraint(
+ 'CHECK(expected_price > 0)',
+ "The expected price should be higher than zero.",
+ )
+ _check_selling_price = models.Constraint(
+ 'CHECK(selling_price >= 0)',
+ "The selling price can't be negative",
+ )
+
+ @api.ondelete(at_uninstall=False)
+ def _unlink_if_new_or_canceled(self):
+ if any((not (record.state == 'new') and not (record.state == 'canceled'))
+ for record in self):
+ raise UserError("Only new and canceled properties can be deleted!")
diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py
new file mode 100644
index 00000000000..6416f2b7beb
--- /dev/null
+++ b/estate/models/estate_property_offer.py
@@ -0,0 +1,77 @@
+from odoo import models, fields, api
+from dateutil.relativedelta import relativedelta
+from odoo.exceptions import UserError, ValidationError
+
+
+class EstatePropertyOffer(models.Model):
+ _name = 'estate.property.offer'
+ _description = "Offer to buy real estate property"
+ _order = 'price desc'
+
+ property_id = fields.Many2one(
+ 'estate.property',
+ string="Property Name",
+ required=True,
+ ondelete='cascade',
+ )
+ property_type_id = fields.Many2one(related="property_id.property_type_id", store=True)
+ partner_id = fields.Many2one('res.partner', string="Partner", required=True)
+ create_date = fields.Date(default=lambda self: fields.Date.today())
+ validity = fields.Integer("Validity (days)", default=7)
+ date_deadline = fields.Date(
+ "Deadline",
+ compute='_compute_date',
+ inverse='_inverse_date',
+ )
+
+ @api.depends('validity')
+ def _compute_date(self):
+ for record in self:
+ record.date_deadline = record.create_date + relativedelta(days=record.validity)
+
+ def _inverse_date(self):
+ for record in self:
+ record.validity = (record.date_deadline - record.create_date).days
+
+ currency_id = fields.Many2one('res.currency', string="Currency", default=lambda self: self.env.company.currency_id.id)
+ price = fields.Monetary("Price")
+ status = fields.Selection(
+ string="Status",
+ selection=[('accepted', "Accepted"), ('refused', "Refused")],
+ copy=False,
+ )
+
+ def accept_offer(self):
+ for record in self:
+ if record.property_id.offer_ids.filtered(lambda o: o.status == 'accepted'):
+ raise UserError(record.env._("There is already an accepted offer for this property."))
+ record.status = 'accepted'
+ record.property_id.selling_price = record.price
+ record.property_id.state = 'offer_accepted'
+ record.property_id.buyer_id = record.partner_id
+ return True
+
+ def refuse_offer(self):
+ for record in self:
+ record.status = 'refused'
+ return True
+
+ _check_price = models.Constraint(
+ 'CHECK(price > 0)',
+ "The offer price must be positive",
+ )
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ if 'property_id' in vals and 'price' in vals:
+ linked_property = self.env['estate.property'].browse(vals['property_id'])
+ if linked_property.best_offer and vals['price'] < linked_property.best_offer:
+ raise UserError("The offer price must be higher than the current best offer of %.2f" % property.best_offer)
+ return super().create(vals_list)
+
+ @api.constrains('status')
+ def _check_fair_price(self):
+ for record in self:
+ if record.status == 'accepted' and record.price < record.property_id.expected_price * 0.9:
+ raise ValidationError(record.env._(f"The selling price must be at least {90}% of the expected price. \n If you want to accept this offer, lower the expected price."))
diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py
new file mode 100644
index 00000000000..da22dc639cc
--- /dev/null
+++ b/estate/models/estate_property_tag.py
@@ -0,0 +1,19 @@
+from odoo import fields, models
+
+
+class EstatePropertyTag(models.Model):
+ _name = 'estate.property.tag'
+ _description = "Real Estate Property Tag"
+ _order = 'name'
+
+ name = fields.Char(
+ "Name",
+ required=True,
+ )
+ _name_uniq = models.Constraint(
+ 'unique(name)',
+ 'This property tag already exists.',
+ )
+ color = fields.Integer(
+ "Color"
+ )
diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py
new file mode 100644
index 00000000000..0146aff7eb6
--- /dev/null
+++ b/estate/models/estate_property_type.py
@@ -0,0 +1,31 @@
+from odoo import fields, models, api
+
+
+class EstatePropertyType(models.Model):
+ _name = 'estate.property.type'
+ _description = "Real Estate Property Type"
+ _order = 'sequence, name'
+
+ name = fields.Char(
+ "Name",
+ required=True,
+ )
+ _name_uniq = models.Constraint(
+ 'unique(name)',
+ 'This property type already exists.',
+ )
+ property_ids = fields.One2many(
+ 'estate.property',
+ 'property_type_id',
+ )
+ sequence = fields.Integer("Sequence", default=1)
+ 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/inherited_model.py b/estate/models/inherited_model.py
new file mode 100644
index 00000000000..2d873f42463
--- /dev/null
+++ b/estate/models/inherited_model.py
@@ -0,0 +1,7 @@
+from odoo import models, fields
+
+
+class Users(models.Model):
+ _inherit = 'res.users'
+
+ property_ids = fields.One2many('estate.property', 'seller_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..404f43c06fe
--- /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,estate.property,model_estate_property,base.group_user,1,1,1,1
+access_estate_property_type,estate.property.type,model_estate_property_type,base.group_user,1,1,1,1
+access_estate_property_tag,estate.property.tag,model_estate_property_tag,base.group_user,1,1,1,1
+access_estate_property_offer,estate.property.offer,model_estate_property_offer,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 00000000000..e65356d0bd3
--- /dev/null
+++ b/estate/views/estate_menus.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml
new file mode 100644
index 00000000000..4a8c456a7e7
--- /dev/null
+++ b/estate/views/estate_property_views.xml
@@ -0,0 +1,244 @@
+
+
+
+
+ estate.property.list.view
+ estate.property
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ estate.property.form.view
+ estate.property
+
+
+
+
+
+
+ Properties
+ estate.property
+ list,form
+
+ {'search_default_state': True}
+
+
+
+ estate.property.search
+ estate.property
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Property Offers
+ estate.property.offer
+ list,form
+ [('property_type_id', '=', active_id)]
+
+
+
+ estate.property.offer.list.view
+ estate.property.offer
+
+
+
+
+
+
+
+
+
+
+
+
+
+ estate.property.offer.form.view
+ estate.property.offer
+
+
+
+
+
+
+ Property Types
+ estate.property.type
+ list,form
+
+
+
+ estate.property.type.form.view
+ estate.property.type
+
+
+
+
+
+
+ estate.property.type.list.view
+ estate.property.type
+
+
+
+
+
+
+
+
+
+ Property Tags
+ estate.property.tag
+ list
+
+
+
+ estate.property.tag.form.view
+ estate.property.tag
+
+
+
+
+
+
+ inherited.model.form.view
+ res.users
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ruff.toml b/ruff.toml
new file mode 100644
index 00000000000..e84deba95cb
--- /dev/null
+++ b/ruff.toml
@@ -0,0 +1,44 @@
+# Exclude a variety of commonly ignored directories.
+exclude = [
+ ".bzr",
+ ".direnv",
+ ".eggs",
+ ".git",
+ ".git-rewrite",
+ ".hg",
+ ".ipynb_checkpoints",
+ ".mypy_cache",
+ ".nox",
+ ".pants.d",
+ ".pyenv",
+ ".pytest_cache",
+ ".pytype",
+ ".ruff_cache",
+ ".svn",
+ ".tox",
+ ".venv",
+ ".vscode",
+ "__pypackages__",
+ "_build",
+ "buck-out",
+ "build",
+ "dist",
+ "node_modules",
+ "site-packages",
+ "venv",
+]
+
+# Assume Python 3.12
+target-version = "py312"
+
+line-length = 255
+[lint]
+# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
+select = ["ALL"]
+ignore = ["A","ARG","ANN","B","C901","D","DTZ","DOC","E501","ERA001","FBT","N","PD","PERF","PIE790","PLR","PT","Q","RSE102","RUF001","RUF012","S","SIM102","SIM108","SLF001","TID252","UP031","TRY003","TRY300","E713","SIM117","PGH003","RUF005","FIX","TD","TRY400","C408","PLW2901","PTH","EM102","INP001","CPY001","E266","PIE808","PLC2701","RUF100","FA100","FURB","C420","COM812","TRY002","B904","EM101","I001","UP006","UP007","RET","RUF021","E741","FAST","ASYNC","AIR","DJ","NPY","FA102","F401"]
+
+# Allow fix for all enabled rules (when `--fix`) is provided.
+fixable = ["ALL"]
+unfixable = ["A","ARG","ANN","B","C901","D","DTZ","DOC","E501","ERA001","FBT","N","PD","PERF","PIE790","PLR","PT","Q","RSE102","RUF001","RUF012","S","SIM102","SIM108","SLF001","TID252","UP031","TRY003","TRY300","E713","SIM117","PGH003","RUF005","FIX","TD","TRY400","C408","PLW2901","PTH","EM102","INP001","CPY001","E266","PIE808","PLC2701","RUF100","FA100","FURB","C420","COM812","TRY002","B904","EM101","I001","UP006","UP007","RET","RUF021","E741","FAST","ASYNC","AIR","DJ","NPY","FA102","F401"]
+[format]
+quote-style = "preserve"