diff --git a/.gitignore b/.gitignore index b6e47617de1..3855afafd8d 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,7 @@ dmypy.json # Pyre type checker .pyre/ + + +.DS_Store +.vscode/ 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..b3f87498a4a --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,16 @@ +{ + 'name': 'estate', + 'category': 'Tutorials', + 'installable': True, + 'application': True, + 'depends': ['base'], + 'data': [ + 'security/ir.model.access.csv', + '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', + ] +} diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..9a2189b6382 --- /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 00000000000..15075a470da --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,100 @@ +from odoo import api, fields, models +from odoo.exceptions import UserError, ValidationError +import logging + +_logger = logging.getLogger(__name__) + +class EstateProperty(models.Model): + _name = 'estate.property' + _description = "Estate Module" + _order = "id desc" + + name = fields.Char(required=True) + description = fields.Text() + postcode = fields.Char() + date_availability = fields.Date("Available From", copy=False, default=fields.Date.add(fields.Date.today(), months=3)) + 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)") + garden_orientation = fields.Selection([ + ('north', 'North'), + ('south', 'South'), + ('east', 'East'), + ('west', 'West'), + ]) + active = fields.Boolean(default=True) + state = fields.Selection([ + ('new', 'New'), + ('offer_received', 'Offer Received'), + ('offer_accepted', 'Offer Accepted'), + ('sold', 'Sold'), + ('cancelled', 'Cancelled'), + ], + required=True, copy=False, default='new', string="Status") + 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="Salesperson", default = lambda self: self.env.user) + tag_ids = fields.Many2many('estate_property_tag', string="Property Tags") + offer_ids = fields.One2many('estate_property_offer', 'property_id', string="Offers") + total_area = fields.Float("Total Area (sqm)", compute='_compute_total_area') + best_price = fields.Float("Best Offer", compute="_compute_best_offer") + + _check_expected_price_strictly_positive = models.Constraint( + 'CHECK(expected_price > 0)', + 'The expected price should be strictly positive.', + ) + + _check_selling_price_positive = models.Constraint( + 'CHECK(selling_price >= 0)', + 'The selling price should be positive.', + ) + + @api.constrains('expected_price', 'selling_price') + def _check_selling_price_90_percent_expected_price(self): + for record in self: + if (record.selling_price > 0) and ((100 / record.expected_price * record.selling_price) < 90): + raise ValidationError("The selling price cannot be lower than 90 % from expected price") + # all records passed the test, don't return anything + + @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_offer(self): + for record in self: + record.best_price = max(self.offer_ids.mapped('price')) if self.offer_ids else 0.0 + + @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 = '' + + def action_sold(self): + _logger.info("SOLD") + for record in self: + if record.state == "cancelled": + raise UserError("Canceled properties cannot be sold.") + return self.write({'state': 'sold'}) + + def action_cancelled(self): + for record in self: + if record.state == "sold": + raise UserError("Sold properties cannot be canceled.") + return self.write({'state': 'cancelled'}) + + @api.ondelete(at_uninstall=False) + def _unlink_if_property_new_or_cancelled(self): + _logger.warning("ON_DELETE") + if any((record.state not in ('new', 'cancelled')) 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..6c6862a5610 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,84 @@ +from odoo import api, fields, models +from dateutil.relativedelta import relativedelta +from odoo.exceptions import UserError +import logging +from odoo.tools import float_compare + +_logger = logging.getLogger(__name__) + +class EstatePropertyOffer(models.Model): + _name = 'estate_property_offer' + _description = "Estate property offer" + _order = "price desc" + + price=fields.Float(default=0) + state = fields.Selection([ + ('accepted', 'Accepted'), + ('refused', 'Refused'), + ], + copy=False, string="Status") + partner_id=fields.Many2one('res.partner', string="Partner", required=True) + 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") + property_type_id = fields.Many2one(related='property_id.property_type_id', store=True) + + _check_price_strictly_positive = models.Constraint( + 'CHECK(price > 0)', + 'The offer price should be strictly positive.', + ) + + @api.depends("validity") + def _compute_date_deadline(self): + for record in self: + start_date = record.create_date.date() if record.create_date else fields.Date.today() + record.date_deadline = start_date + relativedelta(days =+ record.validity) + + def _inverse_date_deadline(self): + for record in self: + start_date = record.create_date.date() if record.create_date else fields.Date.today() + record.validity = (record.date_deadline - start_date).days + + def action_confirm(self): + for record in self: + if 'accepted' in record.property_id.offer_ids.mapped('state'): + raise UserError("An offer has already been accepted for that property.") + else: + self.write({'state': 'accepted'}) + return record.property_id.write( + { + "state": "offer_accepted", + "selling_price": record.price, + "buyer_id": record.partner_id, + "salesperson_id": self.env.user + } + ) + + def action_refuse(self): + for record in self: + if record.state == 'accepted': + record.property_id.selling_price = 0 + record.property_id.buyer_id = None + record.property_id.salesperson_id = None + return self.write({'state': 'refused'}) + + @api.model + def create(self, vals_list): + _logger.warning("CREATE") + for vals in vals_list: + # Do some business logic, modify vals... + _logger.warning("RECORD PRICE : %s", vals['price']) + + if vals.get("property_id") and vals.get("price"): + offer_property = self.env['estate.property'].browse(vals['property_id']) + + if offer_property.offer_ids: + max_offer = max(offer_property.offer_ids.mapped('price')) + _logger.warning("MAX offers PRICE : %s", max_offer) + if float_compare(vals["price"], max_offer, precision_rounding=0.01) <= 0: + raise UserError("The offer must be higher than %.2f" % max_offer) + + offer_property.state = 'offer_received' + + # Then call super to execute the parent method + 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..1292ae14fec --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,11 @@ +from odoo import fields, models + +class EstatePropertyTag(models.Model): + _name = 'estate_property_tag' + _description = "Estate property tag" + _order = "name" + + name = fields.Char(required=True) + color = fields.Integer() + + _name_unique = models.Constraint('unique(name)', "Property tag name already exists.") diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py new file mode 100644 index 00000000000..8f54b163c67 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,24 @@ +from odoo import api, fields, models + +class EstatePropertyType(models.Model): + _name = 'estate_property_type' + _description = "Estate property type" + _order = "sequence, name" + + name = fields.Char(required=True) + property_ids = fields.One2many('estate.property', 'property_type_id') + sequence = fields.Integer('Sequence', default=1, help="Used to order stages. Lower is better.") + offer_ids = fields.One2many('estate_property_offer', 'property_type_id') + offer_count = fields.Integer("Offers", compute='_compute_offers_count') + + _name_unique = models.Constraint('unique(name)', "Property type name already exists.") + + @api.depends('offer_ids') + def _compute_offers_count(self): + for record in self: + record.offer_count = len(record.offer_ids) + + # def action_view_offers(self): + # res = self.env.ref("estate.estate_property_offer_action").read()[0] + # res["domain"] = [("id", "in", self.offer_ids.ids)] + # return res diff --git a/estate/models/res_users.py b/estate/models/res_users.py new file mode 100644 index 00000000000..148b6fccfe1 --- /dev/null +++ b/estate/models/res_users.py @@ -0,0 +1,6 @@ +from odoo import fields, models + +class ResUsers(models.Model): + _inherit = 'res.users' + + property_ids = fields.One2many('estate.property', 'salesperson_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..89f97c50842 --- /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/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..c2698fae73a --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..c1ee082db66 --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,42 @@ + + + + estate_property_offer_list + estate_property_offer + + + + + + + + + + +
+

+ +

+
+ + + + + + + + + + + + + +
+
+ + + estate_property_type_search + estate_property_type + + + + + + + + + Property Types + estate_property_type + list,form + +
diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml new file mode 100644 index 00000000000..5917a7a97af --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,149 @@ + + + + estate_property_list + estate.property + + + + + + + + + + + + + + + + estate_property_form + estate.property + +
+
+ +
+ +

+ +

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + estate_property_search + estate.property + + + + + + + + + + + + + + + + + + + + estate_property_kanban + estate.property + + + + + +
+
+ + + +
+
+ Expected price : +
+
+ Best Offer: +
+
+ Selling Price: +
+ +
+
+
+
+
+
+ + + Properties + estate.property + list,form,kanban + {'search_default_available': 1} + +

+ Define as estate property +

+ Give any details you can. +

+
+
+
diff --git a/estate/views/res_users_views.xml b/estate/views/res_users_views.xml new file mode 100644 index 00000000000..101fb8f102f --- /dev/null +++ b/estate/views/res_users_views.xml @@ -0,0 +1,17 @@ + + + + + res.users.view.form.inherit.estate + 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..8ec52f40d09 --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,10 @@ +{ + 'name': 'estate_account', + 'category': 'Tutorials', + 'installable': True, + 'application': True, + 'depends': ['estate', 'account'], + 'data': [ + + ] +} diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py new file mode 100644 index 00000000000..c5006b18cf8 --- /dev/null +++ b/estate_account/models/__init__.py @@ -0,0 +1,2 @@ +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..735abc93fdb --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,39 @@ +from odoo import api, fields, models +from odoo import Command +import logging + +_logger = logging.getLogger(__name__) + +class EstateProperty(models.Model): + _inherit = 'estate.property' + + def action_sold(self): + _logger.info("ZZZZZ") + res = super().action_sold() + journal = self.env["account.journal"].search([("type", "=", "sale")], limit=1) + + for record in self: + invoice_values = { + 'move_type': 'out_invoice', + 'partner_id': record.buyer_id.id, + 'journal_id': journal.id, + 'invoice_line_ids': [ + Command.create({ + 'name': 'administrative fees', + 'quantity': 1.0, + 'price_unit': 100.0, + }), + Command.create({ + 'name': 'sold property', + 'quantity': 1.0, + 'price_unit': record.selling_price * 0.06, + }) + ] + } + + + + + self.env['account.move'].create([invoice_values]) + + return res