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..ea570d71dab
--- /dev/null
+++ b/estate/__manifest__.py
@@ -0,0 +1,18 @@
+{
+ 'name': "Estate",
+ 'depends': ['base'],
+ 'application': True,
+ 'installable': True,
+ "author": "daloe",
+ "license": "LGPL-3",
+ "data": [
+ 'security/ir.model.access.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/estate_menus.xml',
+ 'views/res_users_views.xml'
+ ],
+}
diff --git a/estate/models/__init__.py b/estate/models/__init__.py
new file mode 100644
index 00000000000..c2df8d540dd
--- /dev/null
+++ b/estate/models/__init__.py
@@ -0,0 +1,7 @@
+# -*- coding: utf-8 -*-
+
+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..b65957249b2
--- /dev/null
+++ b/estate/models/estate_property.py
@@ -0,0 +1,195 @@
+from dateutil.relativedelta import relativedelta
+
+from odoo import api, fields, models
+from odoo.exceptions import UserError, ValidationError
+from odoo.tools import float_compare
+
+class EstateProperty(models.Model):
+ _name = "estate.property"
+ _description = "technical training estate property model"
+ _order = "id desc"
+
+ # RELATIONAL FIELDS
+ estate_property_type_id = fields.Many2one(
+ "estate.property.type",
+ ondelete="set null",
+ )
+
+ estate_property_tag_ids = fields.Many2many(
+ "estate.property.tag",
+ )
+
+ buyer_id = fields.Many2one(
+ "res.partner",
+ ondelete="set null",
+ )
+
+ seller_id = fields.Many2one(
+ "res.users",
+ ondelete="set null",
+ default=lambda self: self.env.user.id,
+ )
+
+ estate_property_offer_ids = fields.One2many(
+ "estate.property.offer",
+ "estate_property_id",
+ copy=False,
+ )
+
+ # BASIC FIELDS
+ name = fields.Char(
+ "Estate Property",
+ required = True,
+ )
+
+ active = fields.Boolean(
+ default = True,
+ )
+
+ status = fields.Selection(
+ [["new", "New"], ["offer_received", "Offer Received"], ["offer_accepted", "Offer Accepted"], ["sold", "Sold"], ["cancelled", "Cancelled"]],
+ default="new",
+ )
+
+ description = fields.Text(
+ "Description"
+ )
+
+ postcode = fields.Char(
+ "Postcode"
+ )
+
+ date_availability = fields.Date(
+ "Available From",
+ copy = False,
+ default = fields.Date.today() + relativedelta(months=+3),
+ )
+
+ expected_price = fields.Float(
+ "Expected Price",
+ required = True,
+ )
+
+ selling_price = fields.Float(
+ "Selling Price",
+ copy = False,
+ readonly = True,
+ )
+
+ bedrooms = fields.Integer(
+ "Bedrooms",
+ default = 2,
+ )
+
+ living_area = fields.Integer(
+ "Living Area (sqm)"
+ )
+
+ facades = fields.Integer(
+ "Facades"
+ )
+
+ garage = fields.Boolean(
+ "Garage",
+ default=False,
+ )
+
+ garden = fields.Boolean(
+ "Garden",
+ default=False,
+ )
+
+ garden_area = fields.Integer(
+ "Garden Area"
+ )
+
+ garden_orientation = fields.Selection(
+ [["north", "North"],["east", "East"], ["south", "South"], ["west", "West"]],
+ )
+
+ # COMPUTED FIELDS
+ total_area = fields.Integer(compute="_compute_total_area")
+
+ best_price = fields.Float("Best Offer", compute="_compute_best_price")
+
+ # SQL CONSTRAINTS & INDEXES
+ _check_expected_price_positive = models.Constraint(
+ "CHECK(expected_price > 0)",
+ "expected_price <= 0"
+ )
+
+ _check_selling_price_positive = models.Constraint(
+ "CHECK(selling_price is null or selling_price > 0)",
+ "selling_price <= 0"
+ )
+
+ # COMPUTE & INVERSE & SEARCH
+ @api.depends("living_area")
+ def _compute_total_area(self):
+ for estate in self:
+ null_safe_garden_area = 0
+ if estate.garden_area:
+ null_safe_garden_area = estate.garden_area
+
+ estate.total_area = estate.living_area + null_safe_garden_area
+
+ @api.depends("estate_property_offer_ids")
+ def _compute_best_price(self):
+ for estate in self:
+ if len(estate.estate_property_offer_ids) > 0:
+ estate.best_price = max(self.estate_property_offer_ids.mapped("price"))
+ else:
+ estate.best_price = 0
+
+ # PYTHON CONSTRAINS & ONCHANGE
+ @api.constrains('selling_price')
+ def _check_ninety_percent_of_expected(self):
+ for estate in self:
+ if estate.selling_price > 0: # restrict checks to only when offer acceptance happens
+ if float_compare(estate.selling_price, 0.9 * estate.expected_price, 2) < 0:
+ raise ValidationError("Offer is <90% of expected")
+
+ @api.onchange("garden")
+ def _onchange_garden_orientation(self):
+ if self.garden:
+ self.garden_area = 10
+ self.garden_orientation = "north"
+ else:
+ self.garden_area = None
+ self.garden_orientation = None
+
+ # ORM OVERRIDES
+ @api.ondelete(at_uninstall=False)
+ def _unlink_if_new_cancelled(self):
+ for user in self:
+ if user.status not in ("new", "cancelled"):
+ error = self.env._("Cannot delete active listing")
+ raise UserError(error)
+
+ # ACTIONS
+ def action_set_sold(self):
+ self.ensure_one() # this button should only exist on a form
+ if not self.active:
+ error = self.env._("Cannot set inactive/cancelled listing to sold")
+ raise UserError(error)
+
+ self.status = "sold"
+ return True
+
+ def action_set_cancelled(self):
+ self.ensure_one() # this button should only exist on a form
+ if self.status == "sold":
+ error = self.env._("Cannot set sold listing to cancelled")
+ raise UserError(error)
+ self.status = "cancelled"
+ return True
+
+ def action_uncancel(self):
+ self.ensure_one()
+ self.status = "new"
+ return True
+
+ # BUSINESS LOGIC
+ def set_offer_received(self):
+ for estate in self:
+ estate.status = "offer_received"
\ No newline at end of file
diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py
new file mode 100644
index 00000000000..aefff74536d
--- /dev/null
+++ b/estate/models/estate_property_offer.py
@@ -0,0 +1,136 @@
+from datetime import timedelta
+
+from odoo.exceptions import UserError
+from odoo import models, fields, api
+
+class EstatePropertyOffer(models.Model):
+ _name = "estate.property.offer"
+ _description = "tech training estate property tag"
+ _order = "price desc"
+
+ # RELATIONAL FIELDS
+ estate_property_id = fields.Many2one(
+ "estate.property",
+ ondelete="cascade",
+ required=True,
+ )
+
+ property_type_id = fields.Many2one(
+ related="estate_property_id.estate_property_type_id",
+ store=True,
+ )
+
+ buyer_id = fields.Many2one(
+ "res.partner",
+ string="Partner",
+ ondelete="cascade",
+ default=lambda self: self.env.user.partner_id.id,
+ required=True,
+ )
+
+ # BASIC FIELDS
+ price = fields.Float(
+ string="Price",
+ required=True,
+ aggregator="max",
+ )
+
+ status = fields.Selection(
+ [["new", "New"], ["refused", "Refused"], ["accepted", "Accepted"]],
+ default="new",
+ copy=False,
+ )
+
+ # COMPUTED FIELDS
+ validity = fields.Integer(default=7)
+
+ date_deadline = fields.Date(string="Deadline", compute="_compute_date_deadline", inverse="_inverse_date_deadline")
+
+ # SQL CONSTRAINTS & INDEXES
+ _check_positive_offer = models.Constraint(
+ "CHECK(price > 0)",
+ "Offer price must be greater than 0"
+ )
+
+ # COMPUTE & INVERSE & SEARCH
+ @api.depends("validity")
+ def _compute_date_deadline(self):
+ for estate in self:
+ if estate.create_date:
+ estate.date_deadline = estate.create_date + timedelta(days=estate.validity)
+ else:
+ estate.date_deadline = fields.Date.today() + timedelta(days=estate.validity)
+
+ def _inverse_date_deadline(self):
+ for estate in self:
+ new_validity = (estate.date_deadline - fields.Date.today()).days
+ if new_validity < 0:
+ raise UserError("validity < 0")
+ estate.validity = (estate.date_deadline - fields.Date.today()).days
+
+ # ORM OVERRIDES
+ @api.model
+ def create(self, vals):
+ for new_offer in vals:
+ current_property_offers_price_asc = self.env["estate.property.offer"].search(
+ [('estate_property_id', '=', new_offer.get("estate_property_id"))]
+ ).sorted("price asc")
+
+ estate_property = self.env["estate.property"].browse(new_offer.get("estate_property_id"))
+ if not current_property_offers_price_asc or len(current_property_offers_price_asc) == 0:
+ if estate_property.status == "new":
+ estate_property.set_offer_received()
+ return super().create(vals)
+
+ if new_offer.get("price") < current_property_offers_price_asc[0].price:
+ raise UserError("submitted offer < lowest current offer")
+
+ if estate_property.status == "new":
+ estate_property.set_offer_received()
+ return super().create(vals)
+
+ @api.model
+ def write(self, vals):
+ updated_price = vals.get("price")
+ if not updated_price:
+ return super().write(vals) # just skip, since logic only applies if there's updated price
+
+ current_property_offers_price_asc = self.estate_property_id.estate_property_offer_ids.sorted("price asc")
+
+ if self == current_property_offers_price_asc[0]:
+ return super().write(vals)
+
+ if updated_price < current_property_offers_price_asc[0].price:
+ raise UserError("submitted offer < lowest current offer")
+
+ return super().write(vals)
+
+ # ACTIONS
+ def action_accept_offer(self):
+ self.ensure_one()
+ for offer in self:
+ if fields.Date.today() > offer.date_deadline:
+ raise UserError("offer expired")
+
+ estate = offer.estate_property_id
+ for recur_offer in estate.estate_property_offer_ids:
+ if recur_offer.status == "accepted":
+ # more foolproof than just checking estate_property.status
+ raise UserError("another offer already accepted")
+
+ estate.buyer_id = offer.buyer_id
+ estate.selling_price = offer.price
+ estate.status = "offer_accepted"
+
+ offer.status = "accepted"
+ return True
+
+ def action_refuse_offer(self):
+ self.ensure_one()
+ self.status = "refused"
+
+ estate = self.estate_property_id
+ estate.buyer_id = None
+ estate.selling_price = None
+ estate.status = "offer_received"
+ return True
\ No newline at end of file
diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py
new file mode 100644
index 00000000000..9dd61f57671
--- /dev/null
+++ b/estate/models/estate_property_tag.py
@@ -0,0 +1,28 @@
+from odoo import models, fields
+
+class EstatePropertyTag(models.Model):
+ _name = 'estate.property.tag'
+ _description = 'tech training estate property tag'
+ _order = 'name asc'
+
+ # RELATIONAL FIELDS
+ estate_property_ids = fields.Many2many(
+ "estate.property",
+ )
+
+ # BASIC FIELDS
+ name = fields.Char(
+ "Tag",
+ required=True,
+ )
+
+ color = fields.Integer(
+ string="Color",
+ default=0,
+ )
+
+ # SQL CONSTRAINTS & INDEXES
+ _check_unique_name = models.UniqueIndex(
+ "(UPPER(name))",
+ "Tag should be unique"
+ )
diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py
new file mode 100644
index 00000000000..b5111d971f3
--- /dev/null
+++ b/estate/models/estate_property_type.py
@@ -0,0 +1,48 @@
+from odoo import models, fields, api
+
+class EstatePropertyType(models.Model):
+ _name = "estate.property.type"
+ _description = "technical training estate property type model"
+ _order = "name asc"
+
+ # RELATIONAL FIELDS
+ estate_property_ids = fields.One2many(
+ "estate.property",
+ "estate_property_type_id"
+ )
+
+ estate_property_offer_ids = fields.One2many(
+ "estate.property.offer",
+ "property_type_id"
+ )
+
+ # BASIC FIELDS
+ name = fields.Char(
+ "Type",
+ required=True,
+ )
+
+ sequence = fields.Integer(
+ 'Sequence',
+ default=1,
+ )
+
+ # COMPUTED FIELDS
+ offer_count = fields.Integer(
+ compute="_compute_offer_count",
+ )
+
+ # SQL CONSTRAINTS & INDEXES
+ _check_unique_name = models.UniqueIndex(
+ "(UPPER(name))",
+ "Type should be unique"
+ )
+
+ # COMPUTE & INVERSE & SEARCH
+ @api.depends('estate_property_offer_ids')
+ def _compute_offer_count(self):
+ for prop_type in self:
+ if prop_type.estate_property_ids:
+ prop_type.offer_count = len(prop_type.estate_property_offer_ids)
+ else:
+ prop_type.offer_count = 0
diff --git a/estate/models/res_users.py b/estate/models/res_users.py
new file mode 100644
index 00000000000..8be97a511c9
--- /dev/null
+++ b/estate/models/res_users.py
@@ -0,0 +1,10 @@
+from odoo import fields, models
+
+class InheritedResUser(models.Model):
+ _inherit = "res.users"
+
+ property_ids = fields.One2many(
+ "estate.property",
+ "seller_id",
+ domain=[('status','in',['new', 'offer_received'])],
+ )
\ No newline at end of file
diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv
new file mode 100644
index 00000000000..05bd9eefba4
--- /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,access_estate_property,model_estate_property,base.group_user,1,1,1,1
+access_estate_property_type,access_estate_property_type,model_estate_property_type,base.group_user,1,1,1,1
+access_estate_property_tag,access_estate_property_tag,model_estate_property_tag,base.group_user,1,1,1,1
+access_estate_property_offer,access_estate_property_offer,model_estate_property_offer,base.group_user,1,1,1,1
\ No newline at end of file
diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml
new file mode 100644
index 00000000000..e62dd2790c0
--- /dev/null
+++ b/estate/views/estate_menus.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml
new file mode 100644
index 00000000000..eb11b19075d
--- /dev/null
+++ b/estate/views/estate_property_offer_views.xml
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+ Property Offers
+ estate.property.offer
+ list,form
+
+ [('property_type_id', '=', active_id)]
+
+
+
+
+
+ estate.property.offer.list
+ estate.property.offer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ estate.property.offer.related.list
+ estate.property.offer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ estate.property.offer.related.form
+ estate.property.offer
+
+
+
+
+
\ No newline at end of file
diff --git a/estate/views/estate_property_tag_views.xml b/estate/views/estate_property_tag_views.xml
new file mode 100644
index 00000000000..b6e2fc5eb60
--- /dev/null
+++ b/estate/views/estate_property_tag_views.xml
@@ -0,0 +1,34 @@
+
+
+
+
+ Property Tags
+ estate.property.tag
+ list,form
+
+
+
+
+
+ estate.property.tag.form
+ estate.property.tag
+
+
+
+
+
+
+
+ estate.property.tag.list
+ estate.property.tag
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/estate/views/estate_property_type_views.xml b/estate/views/estate_property_type_views.xml
new file mode 100644
index 00000000000..c7a6c3523a8
--- /dev/null
+++ b/estate/views/estate_property_type_views.xml
@@ -0,0 +1,52 @@
+
+
+
+
+ Property Types
+ estate.property.type
+ list,form
+
+
+
+
+
+ estate.property.type.form
+ estate.property.type
+
+
+
+
+
+
+ estate.property.type.list
+ estate.property.type
+
+
+
+
+
+
+
+
\ 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 00000000000..59039171f3a
--- /dev/null
+++ b/estate/views/estate_property_views.xml
@@ -0,0 +1,171 @@
+
+
+
+
+ Properties
+ estate.property
+ list,form,kanban
+
+
+ {'search_default_filter_available_status': 1 }
+
+
+
+
+
+
+ estate.property.search
+ estate.property
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ estate.property.list
+ estate.property
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ estate.property.form
+ estate.property
+
+
+
+
+
+
+
+ estate.property.kanban
+ estate.property
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/estate/views/res_users_views.xml b/estate/views/res_users_views.xml
new file mode 100644
index 00000000000..a25611b2b08
--- /dev/null
+++ b/estate/views/res_users_views.xml
@@ -0,0 +1,15 @@
+
+
+
+ res.users.view.form.inherit.estate.property
+ res.users
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/estate_account/__init__.py b/estate_account/__init__.py
new file mode 100644
index 00000000000..9a7e03eded3
--- /dev/null
+++ b/estate_account/__init__.py
@@ -0,0 +1 @@
+from . import models
\ No newline at end of file
diff --git a/estate_account/__manifest__.py b/estate_account/__manifest__.py
new file mode 100644
index 00000000000..b53d3fdd800
--- /dev/null
+++ b/estate_account/__manifest__.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+{
+ 'name': 'Estate Accounting',
+ 'depends': ['estate', 'account'],
+ 'installable': True,
+ "author": "daloe",
+ "license": "LGPL-3",
+ "data": [
+ 'security/ir.model.access.csv',
+ ],
+}
\ No newline at end of file
diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py
new file mode 100644
index 00000000000..9c1cb4a3ffa
--- /dev/null
+++ b/estate_account/models/__init__.py
@@ -0,0 +1,2 @@
+from . import estate_property
+from . import estate_property_offer
\ No newline at end of file
diff --git a/estate_account/models/estate_property.py b/estate_account/models/estate_property.py
new file mode 100644
index 00000000000..364859012cb
--- /dev/null
+++ b/estate_account/models/estate_property.py
@@ -0,0 +1,34 @@
+from odoo import models
+from odoo.exceptions import AccessError
+from odoo.orm.commands import Command
+
+
+class EstateProperty(models.Model):
+ _inherit = ["estate.property"]
+
+ def action_set_sold(self):
+ for property in self:
+ if not self.env['account.move'].has_access('create'):
+ try:
+ self.check_access('write')
+ except AccessError:
+ return self.env['account.move']
+
+ self.env['account.move'].create({
+ "partner_id": property.buyer_id.id,
+ "move_type": "out_invoice",
+ "invoice_line_ids": [
+ Command.create({
+ "name": "6% selling price",
+ "quantity": 1,
+ "price_unit": property.selling_price * 0.06
+ }),
+ Command.create({
+ "name": "admin fees",
+ "quantity": 1,
+ "price_unit": 100000
+ })
+ ]
+ })
+
+ return super().action_set_sold()
\ No newline at end of file
diff --git a/estate_account/models/estate_property_offer.py b/estate_account/models/estate_property_offer.py
new file mode 100644
index 00000000000..4d6d123d005
--- /dev/null
+++ b/estate_account/models/estate_property_offer.py
@@ -0,0 +1,37 @@
+from odoo import models
+from odoo.exceptions import AccessError
+from odoo.orm.commands import Command
+
+class EstatePropertyOffer(models.Model):
+ _inherit = ["estate.property.offer"]
+
+ def action_accept_offer(self):
+ for offer in self:
+ if not self.env['account.move'].has_access('create'):
+ try:
+ self.check_access('write')
+ except AccessError:
+ return self.env['account.move']
+
+ self.env['account.move'].create({
+ "partner_id": offer.buyer_id.id,
+ "move_type": "out_invoice",
+ "invoice_line_ids": [
+ Command.create({
+ "name": offer.estate_property_id.name,
+ "quantity": 1,
+ "price_unit": offer.price
+ }),
+ Command.create({
+ "name": "6%",
+ "quantity": 1,
+ "price_unit": offer.price * 0.06
+ }),
+ Command.create({
+ "name": "admin fees",
+ "quantity": 1,
+ "price_unit": 100000
+ })
+ ]
+ })
+ return super().action_accept_offer()
\ No newline at end of file
diff --git a/estate_account/security/ir.model.access.csv b/estate_account/security/ir.model.access.csv
new file mode 100644
index 00000000000..0e11f47e58d
--- /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
+access_estate_property,access_estate_property,model_estate_property,base.group_user,1,1,1,1
\ No newline at end of file