diff --git a/estate.zip b/estate.zip
new file mode 100644
index 00000000000..54462371381
Binary files /dev/null and b/estate.zip differ
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..bddb9a613a9
--- /dev/null
+++ b/estate/__manifest__.py
@@ -0,0 +1,24 @@
+{
+ 'name': 'ESTATE',
+ 'description': "aras estate tutorial module",
+ 'website': 'https://www.odoo.com/page/estate',
+ 'depends': [
+ 'base',
+ ],
+ 'data': [
+ 'security/ir.model.access.csv',
+ 'views/estate_property_views.xml',
+ 'views/estate_property_tag_views.xml',
+ 'views/estate_property_offer_views.xml',
+ 'views/estate_property_type_views.xml',
+ 'views/estate_menus.xml',
+ 'views/res_users_views.xml',
+ ],
+ 'demo': [
+ 'demo/estate.property.type.csv',
+ 'demo/estate_property.xml',
+ 'demo/estate_property_offer.xml'
+ ],
+ 'installable': True,
+ 'application': True,
+}
diff --git a/estate/demo/estate.property.type.csv b/estate/demo/estate.property.type.csv
new file mode 100644
index 00000000000..17f4695bb64
--- /dev/null
+++ b/estate/demo/estate.property.type.csv
@@ -0,0 +1,5 @@
+"id","name"
+"data_property_type_residential","Residential"
+"data_property_type_commerial","Commerial"
+"data_property_type_industrial","Industrial"
+"data_property_type_land","Land"
diff --git a/estate/demo/estate_property.xml b/estate/demo/estate_property.xml
new file mode 100644
index 00000000000..71d03835dee
--- /dev/null
+++ b/estate/demo/estate_property.xml
@@ -0,0 +1,57 @@
+
+
+ Big Villa
+ A nice and big villa
+ 12345
+
+ 1600000
+ 6
+ 100
+ 4
+ True
+ True
+ 100000
+ south
+
+
+
+
+ Trailer home
+ cancelled
+ Home in a trailer park
+ 54321
+
+ 100000
+ 1
+ 10
+ 4
+
+
+
+
+ Tutorial House
+ offer_received
+ X2many tutorial
+ 13579
+
+ 123456
+ 2
+ 30
+ 4
+
+
+
+
+
\ No newline at end of file
diff --git a/estate/demo/estate_property_offer.xml b/estate/demo/estate_property_offer.xml
new file mode 100644
index 00000000000..87c01d263e1
--- /dev/null
+++ b/estate/demo/estate_property_offer.xml
@@ -0,0 +1,29 @@
+
+
+
+
+ 10000
+ 14
+
+
+
+
+
+
+ 1500000
+ 14
+
+
+
+
+
+
+ 1500001
+ 14
+
+
+
+
+
+
+
diff --git a/estate/models/__init__.py b/estate/models/__init__.py
new file mode 100644
index 00000000000..8f914bbb526
--- /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_user
diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py
new file mode 100644
index 00000000000..6971e731d1c
--- /dev/null
+++ b/estate/models/estate_property.py
@@ -0,0 +1,99 @@
+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 = "ch3 exercise tutorial"
+ _order = "id desc"
+
+ name = fields.Char(required=True)
+ # active = fields.Boolean(default=False)
+ state = fields.Selection(
+ string='Status',
+ selection=[('new', 'New'), ('offer_received', 'Offer Received'), ('offer_accepted', 'Offer Accepted'), ('sold', 'Sold'), ('cancelled', 'Cancelled')],
+ default='new'
+ )
+ description = fields.Text()
+ postcode = fields.Char()
+ date_availability = fields.Date(default=lambda _: fields.Date.add(fields.Date.today(), months=3), copy=False)
+ expected_price = fields.Float()
+ selling_price = fields.Float(readonly=True, copy=False)
+ bedrooms = fields.Integer(default="2")
+ living_area = fields.Integer()
+ facades = fields.Integer()
+ garage = fields.Boolean()
+ garden = fields.Boolean()
+ garden_area = fields.Integer()
+ garden_orientation = fields.Selection(
+ string='Garden Orientation',
+ selection=[('north', 'North'), ('south', 'South'), ('east', 'East'), ('west', 'West')]
+ )
+ property_type_id = fields.Many2one("estate.property.type", string="Property Type")
+ buyer = fields.Many2one("res.partner", string="Buyer", copy=False)
+ sales_person = fields.Many2one("res.users", string="Sales Person", default=lambda self: self.env.user)
+ property_tag_ids = fields.Many2many("estate.property.tag")
+ offer_ids = fields.One2many("estate.property.offer", "property_id")
+ total_area = fields.Integer(compute="_compute_total_area")
+ best_price = fields.Float(compute="_compute_best_price", string="Best Offer")
+
+ _sql_constraints = [
+ ('check_expected_price', 'CHECK(expected_price > 0)',
+ 'The Expected price of an estate should be positive.')
+ ]
+
+ @api.depends("living_area", "garden_area")
+ def _compute_total_area(self):
+ for record in self:
+ record.total_area = record.living_area + record.garden_area
+
+ @api.depends("offer_ids.price")
+ def _compute_best_price(self):
+ for record in self:
+ prices = record.offer_ids.mapped('price')
+ record.best_price = max(prices) if prices else 0
+ if prices and record.state in [None, 'new']:
+ record.state = 'offer_received'
+
+ @api.onchange("garden")
+ def _onchange_garden(self):
+ for record in self:
+ if record.garden:
+ record.garden_area = 10
+ record.garden_orientation = 'north'
+ else:
+ record.garden_area = 0
+ record.garden_orientation = False
+
+ def action_property_sold(self):
+ for record in self:
+ if record.state == 'cancelled':
+ raise UserError("Cancelled properties cannot be sold.")
+ elif record.state != 'offer_accepted':
+ raise UserError("You cannot sell a property without an accepted offer.")
+ else:
+ record.state = 'sold'
+ return True
+
+ def action_property_cancelled(self):
+ for record in self:
+ if record.state == 'sold':
+ raise UserError("Sold properties cannot be cancelled.")
+ else:
+ record.state = 'cancelled'
+ return True
+
+ @api.constrains('selling_price')
+ def _check_selling_price(self):
+ for record in self:
+ if record.selling_price is not None and float_compare(record.selling_price, 0.9 * record.expected_price, precision_digits=9) == -1:
+ raise ValidationError("The selling price cannot be less than 90% of the expected price")
+
+ @api.ondelete(at_uninstall=False)
+ def unlink_if_new_or_cancelled(self):
+ for record in self:
+ if record.state not in ('new', 'cancelled'):
+ raise UserError("Only new and cancelled properties can be deleted.")
+ if record.offer_ids:
+ record.offer_ids.unlink()
diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py
new file mode 100644
index 00000000000..3334e5a0ce0
--- /dev/null
+++ b/estate/models/estate_property_offer.py
@@ -0,0 +1,66 @@
+from odoo import api, fields, models
+from odoo.exceptions import UserError
+
+
+class EstatePropertyOffer(models.Model):
+ _name = "estate.property.offer"
+ _description = "ch7 exercise tutorial"
+ _order = "price desc"
+
+ price = fields.Float()
+ status = fields.Selection(
+ copy=False,
+ string='Offer Status',
+ selection=[('accepted', 'Accepted'), ('refused', 'Refused')]
+ )
+ partner_id = fields.Many2one("res.partner", string="Partner", required=True)
+ property_id = fields.Many2one("estate.property", string="Property", required=True)
+ validity = fields.Integer(default=7, string="Validity")
+ date_deadline = fields.Date(compute="_compute_date_deadline", inverse="_inverse_date_deadline", string="Deadline")
+ create_date = fields.Date(default=fields.Date.today)
+ property_type_id = fields.Many2one(related="property_id.property_type_id")
+
+ _sql_constraints = [
+ ('check_price', 'CHECK(price > 0)',
+ 'The Price offered for an estate should be positive.'),
+ ]
+
+ @api.depends("create_date", "validity")
+ def _compute_date_deadline(self):
+ for record in self:
+ record.date_deadline = fields.Date.add(record.create_date, days=record.validity)
+
+ def _inverse_date_deadline(self):
+ for record in self:
+ record.validity = (record.date_deadline - record.create_date).days
+
+ def action_offer_accept(self):
+ for record in self:
+ if record.status == 'accepted':
+ raise UserError("A offer has already been accepted.")
+ record.status = 'accepted'
+ for id in record.property_id:
+ id.selling_price = record.price
+ id.buyer = record.partner_id
+ id.state = 'offer_accepted'
+ return True
+
+ def action_offer_refuse(self):
+ for record in self:
+ if record.status == 'accepted':
+ raise UserError("You cannot refuse an accepted offer.")
+ record.status = 'refused'
+ return True
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ property = self.env['estate.property'].browse(vals.get('property_id'))
+ if property.state in ('sold', 'cancelled'):
+ raise UserError(f"Cannot create an offer in a {property.state} property.")
+ price = vals.get('price')
+ estate_property = self.env['estate.property'].browse(vals.get('property_id'))
+ if estate_property.best_price and price <= estate_property.best_price:
+ raise UserError(f"The selling must be higher than {estate_property.best_price:.2f}")
+ estate_property.best_price = price
+ return super().create(vals_list)
diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py
new file mode 100644
index 00000000000..0b973fa9e56
--- /dev/null
+++ b/estate/models/estate_property_tag.py
@@ -0,0 +1,15 @@
+from odoo import fields, models
+
+
+class EstatePropertyTag(models.Model):
+ _name = "estate.property.tag"
+ _description = "ch7 exercise tutorial"
+ _order = "name"
+
+ name = fields.Char(required=True)
+ color = fields.Integer()
+
+ _sql_constraints = [
+ ('unique_property_tag', 'UNIQUE(name)',
+ 'Each property tag should have a unique name.')
+ ]
diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py
new file mode 100644
index 00000000000..bb9a7d75b99
--- /dev/null
+++ b/estate/models/estate_property_type.py
@@ -0,0 +1,22 @@
+from odoo import fields, models
+
+
+class EstatePropertyType(models.Model):
+ _name = "estate.property.type"
+ _description = "ch7 exercise tutorial"
+ _order = "sequence, name"
+
+ name = fields.Char(required=True)
+ property_ids = fields.One2many("estate.property", "property_type_id")
+ sequence = fields.Integer()
+ offer_ids = fields.One2many("estate.property.offer", "property_type_id")
+ offer_count = fields.Integer(compute="_compute_offer_count")
+
+ _sql_constraints = [
+ ('unique_property_type', 'UNIQUE(name)',
+ 'Each property type should have a unique name.')
+ ]
+
+ def _compute_offer_count(self):
+ for record in self:
+ record.offer_count = len(record.offer_ids)
diff --git a/estate/models/res_user.py b/estate/models/res_user.py
new file mode 100644
index 00000000000..80c83227d1f
--- /dev/null
+++ b/estate/models/res_user.py
@@ -0,0 +1,13 @@
+from odoo import fields, models
+
+
+class ResUsers(models.Model):
+ _inherit = "res.users"
+
+ property_ids = fields.One2many(
+ comodel_name="estate.property",
+ inverse_name="sales_person",
+ 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..72c60bbff73
--- /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
diff --git a/estate/tests/__init__.py b/estate/tests/__init__.py
new file mode 100644
index 00000000000..576617cccff
--- /dev/null
+++ b/estate/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_estate_property
diff --git a/estate/tests/test_estate_property.py b/estate/tests/test_estate_property.py
new file mode 100644
index 00000000000..75692d12952
--- /dev/null
+++ b/estate/tests/test_estate_property.py
@@ -0,0 +1,90 @@
+from odoo.tests.common import TransactionCase
+from odoo.exceptions import UserError
+from odoo.tests import Form, tagged
+from odoo import Command
+
+
+@tagged('post_install', '-at_install')
+class EstateTestCase(TransactionCase):
+
+ @classmethod
+ def setUpClass(cls):
+ # add env on cls and many other things
+ super().setUpClass()
+
+ cls.property = cls.env['estate.property'].create([{'name': 'test_house'}])
+ cls.partner = cls.env['res.partner'].create([{
+ 'name': 'test_person',
+ }])
+
+ def test_creation_area(self):
+ """Test that the total_area is computed like it should."""
+ self.property.living_area = 20
+ self.property.garden = True
+ self.property.garden_area = 15
+
+ self.assertEqual(self.property.total_area, 35)
+
+ def test_action_sell_without_accepted_offer(self):
+ """Test that everything behaves like it should when selling an invalid property."""
+
+ self.assertEqual(self.property.state, 'new')
+
+ with self.assertRaises(UserError):
+ self.property.action_property_sold()
+
+ def test_action_sell_with_accepted_offer(self):
+ """Test that everything behaves like it should when selling a valid property."""
+
+ self.property.offer_ids.create({
+ 'property_id': self.property.id,
+ 'partner_id': self.partner.id,
+ 'price': 124,
+ 'validity': 14,
+ })
+ self.property.offer_ids.action_offer_accept()
+ self.property.action_property_sold()
+
+ self.assertRecordValues(self.property, [
+ {'state': 'sold'},
+ ])
+
+ def test_creation_offer_for_sold_property(self):
+ """Test that everything behaves like it should when property is sold."""
+
+ self.property.write({'offer_ids': [Command.create({
+ 'partner_id': self.partner.id,
+ 'price': 124,
+ 'validity': 14,
+ })]})
+ self.property.offer_ids.action_offer_accept()
+ self.property.action_property_sold()
+
+ with self.assertRaises(UserError):
+ self.property.offer_ids.create({
+ 'property_id': self.property.id,
+ 'partner_id': self.partner.id,
+ 'price': 130,
+ 'validity': 14,
+ })
+
+ def test_enable_garden(self):
+ """Test that default values are assigned to garden area and orientation when garden is enabled"""
+
+ form = Form(self.env['estate.property'])
+ form.garden = True
+
+ self.assertEqual(form.garden_area, 10)
+ self.assertEqual(form.garden_orientation, 'north')
+
+ def test_disable_garden(self):
+ """Test that values are removed from garden area and orientation when garden is disabled"""
+
+ form = Form(self.env['estate.property'])
+ form.garden = True
+ form.garden_area = 15
+ form.garden_orientation = 'south'
+ form.garden = False
+
+ self.assertEqual(form.garden_area, 0)
+ self.assertEqual(form.garden_orientation, False)
diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml
new file mode 100644
index 00000000000..5908b95ce1b
--- /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 00000000000..d97ef3ae650
--- /dev/null
+++ b/estate/views/estate_property_offer_views.xml
@@ -0,0 +1,47 @@
+
+
+
+
+ Property Offers
+ estate.property.offer
+ [('property_type_id', '=', active_id)]
+ list,form
+
+
+
+ estate.property.offer.list
+ estate.property.offer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ estate.property.off string="Offer"er.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 00000000000..e863683c616
--- /dev/null
+++ b/estate/views/estate_property_tag_views.xml
@@ -0,0 +1,19 @@
+
+
+
+
+ Property Tags
+ estate.property.tag
+ list
+
+
+
+ estate.property.tag.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 00000000000..c84f2dd7de9
--- /dev/null
+++ b/estate/views/estate_property_type_views.xml
@@ -0,0 +1,60 @@
+
+
+
+
+ Property Types
+ estate.property.type
+ list,form,search
+
+
+
+ estate.property.type.list
+ estate.property.type
+
+
+
+
+
+
+
+
+
+ estate.property.type.form
+ estate.property.type
+
+
+
+
+
+
+ estate.property.type.search
+ estate.property.type
+
+
+
+
+
+
+
diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml
new file mode 100644
index 00000000000..5563fb9319e
--- /dev/null
+++ b/estate/views/estate_property_views.xml
@@ -0,0 +1,139 @@
+
+
+
+
+ Estate Property
+ estate.property
+ {'search_default_state': True}
+ list,form,kanban,search
+
+
+
+ estate.property.list
+ estate.property
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ estate.property.form
+ estate.property
+
+
+
+
+
+
+ estate.property.kanban
+ estate.property
+
+
+
+
+
+
+
+
+ Expected Price:
+
+
+ Best Price:
+
+
+
+ Selling Price:
+
+
+
+
+
+
+
+
+
+
+
+ estate.property.search
+ estate.property
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/estate/views/res_users_views.xml b/estate/views/res_users_views.xml
new file mode 100644
index 00000000000..771b96840ac
--- /dev/null
+++ b/estate/views/res_users_views.xml
@@ -0,0 +1,18 @@
+
+
+
+
+ res.users.form.properties
+ res.users
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/estate_account/__init__.py b/estate_account/__init__.py
new file mode 100644
index 00000000000..0650744f6bc
--- /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 00000000000..afdc575584b
--- /dev/null
+++ b/estate_account/__manifest__.py
@@ -0,0 +1,13 @@
+{
+ 'name': 'ESTATE_ACCOUNT',
+ 'description': "aras estate tutorial module",
+ 'website': 'https://www.odoo.com/page/estate',
+ 'depends': [
+ 'estate',
+ 'account'
+ ],
+ 'data': [
+ ],
+ 'installable': True,
+ 'application': True,
+}
diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py
new file mode 100644
index 00000000000..5e1963c9d2f
--- /dev/null
+++ b/estate_account/models/__init__.py
@@ -0,0 +1 @@
+from . import estate_property
diff --git a/estate_account/models/estate_property.py b/estate_account/models/estate_property.py
new file mode 100644
index 00000000000..f31c56ab369
--- /dev/null
+++ b/estate_account/models/estate_property.py
@@ -0,0 +1,27 @@
+from odoo import Command, models
+
+
+class EstateProperty(models.Model):
+ _inherit = "estate.property"
+
+ def action_property_sold(self):
+ for record in self:
+ self.env["account.move"].create(
+ {
+ "partner_id": record.buyer.id,
+ "move_type": 'out_invoice',
+ "line_ids": [
+ Command.create({
+ "name": "Selling price 6 percent",
+ "price_unit": record.selling_price * 0.06,
+ "quantity": 1,
+ }),
+ Command.create({
+ "name": "Administrative fees",
+ "price_unit": 100,
+ "quantity": 1,
+ })
+ ],
+ }
+ )
+ return super().action_property_sold()