diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000000..ff5300ef481 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python.languageServer": "None" +} \ No newline at end of file diff --git a/awesome_dashboard/controllers/controllers.py b/awesome_dashboard/controllers/controllers.py index 56d4a051287..05977d3bd7f 100644 --- a/awesome_dashboard/controllers/controllers.py +++ b/awesome_dashboard/controllers/controllers.py @@ -9,7 +9,7 @@ logger = logging.getLogger(__name__) class AwesomeDashboard(http.Controller): - @http.route('/awesome_dashboard/statistics', type='json', auth='user') + @http.route('/awesome_dashboard/statistics', type='jsonrpc', auth='user') def get_statistics(self): """ Returns a dict of statistics about the orders: 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..154dba84edd --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,21 @@ +{ + 'name': "Real Estate", + 'version': "0.1.0", + 'author': "keman-odoo", + 'license': "LGPL-3", + 'category': "Tutorials", + 'depends': ['base'], + 'summary': "manage properties, track buyers offers and handle property sales efficiently", + 'sequence': "3", + 'data': [ + 'security/ir.model.access.csv', + 'views/estate_property_offer_views.xml', + 'views/res_user_views.xml', + 'views/estate_property_issue_views.xml', + 'views/estate_property_type_views.xml', + 'views/estate_property_tag_views.xml', + 'views/estate_property_visit_views.xml', + 'views/estate_property_views.xml', + 'views/estate_menus.xml' + ], +} diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..ced09a98801 --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1,7 @@ +from . import estate_property +from . import estate_property_type +from . import estate_property_tag +from . import estate_property_offer +from . import estate_property_visit +from . import estate_property_issue +from . import res_user diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..26ee800e562 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,152 @@ +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError, ValidationError +from odoo.tools.float_utils import float_compare, float_is_zero + + +class EstateProperty(models.Model): + _name = "estate.property" + _description = " Real estate Property" + _order = "id desc" + + name = fields.Char(required=True, default="UNKNOWN") + description = fields.Text() + postcode = fields.Char() + date_availability = fields.Date( + default=lambda self: fields.Date.today() + relativedelta(months=3), + copy=False + ) + expected_price = fields.Float(required=True) + 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() + active = fields.Boolean(default=True) + garden_orientation = fields.Selection( + [ + ('north', 'North'), + ('south', 'South'), + ('east', 'East'), + ('west', 'West') + ], + string="Garden Orientation" + ) + state = fields.Selection( + [ + ('new', 'New'), + ('offer_received', 'Offer Received'), + ('offer_accepted', 'Offer Accepted'), + ('sold', 'Sold'), + ('cancelled', 'Cancelled'), + ], + default='new', + copy=False, + required=True, + store=True + ) + property_type_id = fields.Many2one( + "estate.property.type", + string="property type", + ) + buyer_id = fields.Many2one( + "res.partner", + string="Buyer", + ) + user_id = fields.Many2one( + "res.users", + string="Salesperson", + default=lambda self: self.env.user, + ) + tags_ids = fields.Many2many( + "estate.property.tag", + string="tags", + ) + offer_ids = fields.One2many( + "estate.property.offer", + "property_id", + ) + issue_ids = fields.One2many( + "estate.property.issue", + "property_id" + ) + visit_ids = fields.One2many("estate.property.visit", "property_id") + visit_count = fields.Integer(compute="_compute_visit_count") + total_area = fields.Float(compute="_compute_total_area") + best_price = fields.Float(compute="_compute_best_price") + + _check_expected_price = models.Constraint( + 'CHECK(expected_price >= 0)', + 'A property expected price must be strictly positive' + ) + + _check_selling_price = models.Constraint( + 'CHECK(selling_price > 0)', + 'A property selling price must be positive' + ) + + @api.depends("living_area", "garden_area") + def _compute_total_area(self): + for record in self: + record.total_area = record.garden_area + record.living_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 + + def _compute_visit_count(self): + for record in self: + record.visit_count = len(record.visit_ids) + + @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 + + @api.constrains('expected_price', 'selling_price') + def _check_selling_price(self): + for record in self: + + if float_is_zero(record.selling_price, precision_digits=2): + continue + + min_price = record.expected_price * 0.9 + + if float_compare(record.selling_price, min_price, precision_digits=2) < 0: + raise ValidationError( + "Selling price cannot be lower than 90% of expected price.") + + @api.ondelete(at_uninstall=False) + def _check_poperty_delete(self): + for record in self: + if record.state not in ('new', 'cancelled'): + raise UserError('you are not in new or cancel') + + def action_sold(self): + for record in self: + if record.state == 'cancelled': + raise UserError(_("Cancelled property can not be sold")) + + if record.issue_ids.priority == 'high' and record.issue_ids.state != 'resolved': + raise UserError(_("you can not sold overdue property")) + + if not any(o.status == 'accepted' for o in record.offer_ids): + raise UserError(_("Accept an offer first")) + else: + record.state = 'sold' + + def action_cancle(self): + for record in self: + if record.state == 'sold': + raise UserError(_("Sold property can not be Cancelled")) + + record.state = 'cancelled' diff --git a/estate/models/estate_property_issue.py b/estate/models/estate_property_issue.py new file mode 100644 index 00000000000..b23f879cd07 --- /dev/null +++ b/estate/models/estate_property_issue.py @@ -0,0 +1,107 @@ +from datetime import timedelta + +from odoo import models, fields, api + + +class EstatePropertyIssue(models.Model): + _name = "estate.property.issue" + _description = "Property Issue" + + name = fields.Char(string="Issue Title", required=True) + property_id = fields.Many2one( + "estate.property", + string="Property", + required=True + ) + partner_id = fields.Many2one( + "res.partner", + string="Reported By" + ) + salesman_id = fields.Many2one( + "res.users", + string="Assign To" + ) + issue_type = fields.Selection( + [ + ('plumbing', 'Plumbing'), + ('electrical', 'Electrical'), + ('structural', 'Structural'), + ('other', 'Other') + ], + string="Issue Type", + required=True + ) + state = fields.Selection( + [ + ('new', 'New'), + ('in_progress', 'In Progress'), + ('resolved', 'Resolved'), + ('cancel', 'Cancelled') + ], + default='new', + string="Status" + ) + priority = fields.Selection( + [ + ('low', 'Low'), + ('medium', 'Medium'), + ('high', 'High') + ], + string="Priority", + compute="_compute_priority", + store=True + ) + is_overdue = fields.Boolean( + compute="_compute_is_overdue" + ) + reported_date = fields.Date( + string="Reported Date", + default=fields.Date.today + ) + resolved_date = fields.Date(string="Resolved Date", readonly=True) + description = fields.Text(string="Description") + end_date = fields.Date(compute="_compute_end_date") + + @api.depends("issue_type") + def _compute_priority(self): + for record in self: + if record.issue_type == 'structural': + record.priority = 'high' + elif record.issue_type == 'electrical': + record.priority = 'medium' + else: + record.priority = 'low' + + @api.onchange("salesman_id") + def _onchange_salesman(self): + for record in self: + if record.salesman_id: + record.state = 'in_progress' + + @api.depends("priority", "reported_date") + def _compute_end_date(self): + for record in self: + if record.reported_date: + if record.priority == 'high': + record.end_date = record.reported_date + timedelta(days=2) + elif record.priority == 'medium': + record.end_date = record.reported_date + timedelta(days=5) + else: + record.end_date = record.reported_date + timedelta(days=10) + + @api.depends("state", "end_date") + def _compute_is_overdue(self): + for record in self: + if record.state != 'resolved' and fields.Date.today() > record.end_date: + record.is_overdue = True + else: + record.is_overdue = False + + def action_resolved(self): + for record in self: + record.state = 'resolved' + record.resolved_date = fields.Date.today() + + def action_cancel(self): + for record in self: + record.state = 'cancel' diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..71d247b8698 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,91 @@ +from datetime import timedelta + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + + +class EstatePropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "estate property offers" + _order = "price desc" + + price = fields.Float() + status = fields.Selection( + [ + ('accepted', 'Accepted'), + ('refused', 'Refused'), + ], + string="Status", + copy=False + ) + partner_id = fields.Many2one( + "res.partner", + required=True + ) + property_id = fields.Many2one( + "estate.property", + ) + validity = fields.Integer(default=7) + date_deadline = fields.Date( + compute="_compute_date_deadline", + inverse="_inverse_date_deadline" + ) + property_type_id = fields.Many2one( + "estate.property.type", + related="property_id.property_type_id", + store=True + ) + + _check_price = models.Constraint( + 'CHECK(price > 0)', + 'An offer price must be strictly positive' + ) + + @api.depends("create_date", "validity") + def _compute_date_deadline(self): + for record in self: + if record.create_date: + record.date_deadline = record.create_date + \ + timedelta(days=record.validity) + else: + record.date_deadline = fields.Date.today() + timedelta(days=record.validity) + + def _inverse_date_deadline(self): + for record in self: + if record.create_date and record.date_deadline: + record.validity = (record.date_deadline - + record.create_date.date()).days + + @api.model + def create(self, vals_list): + for vals in vals_list: + current_price = vals.get('price') + property = self.env['estate.property'].browse(vals['property_id']) + for offer in property.offer_ids: + if current_price < offer.price: + raise UserError( + "Offer price must greater than minimum price") + + offers = super().create(vals_list) + for record in offers: + if record.property_id.state == 'new': + record.property_id.state = 'offer_received' + + return offers + + def action_confirm(self): + for record in self: + for offer in record.property_id.offer_ids: + if offer.status == "accepted": + raise UserError(_("Only one offer can be accepted.")) + record.status = "accepted" + record.property_id.selling_price = record.price + record.property_id.buyer_id = record.partner_id + record.property_id.state = "offer_accepted" + + def action_cancel(self): + for record in self: + if record.status == 'accepted': + record.property_id.selling_price = False + record.property_id.buyer_id = False + record.status = "refused" diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..eae1173aced --- /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 = "property tag" + _order = "name" + + name = fields.Char(required=True) + color = fields.Integer() + + _unique_name = models.Constraint( + 'UNIQUE(name)', + 'property name 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..ecbda8f8805 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,24 @@ +from odoo import models, fields + + +class EstatePropertyType(models.Model): + _name = "estate.property.type" + _description = "estate property" + _order = "sequence, name" + + name = fields.Char(required=True) + line_ids = fields.One2many( + "estate.property", + "property_type_id" + ) + sequence = fields.Integer(default=1) + offer_ids = fields.One2many( + "estate.property.offer", + "property_type_id", + string="Offers" + ) + offer_count = fields.Integer(compute="_compute_offer_count") + + def _compute_offer_count(self): + for record in self: + record.offer_count = len(record.offer_ids) diff --git a/estate/models/estate_property_visit.py b/estate/models/estate_property_visit.py new file mode 100644 index 00000000000..30cbc9c0d3c --- /dev/null +++ b/estate/models/estate_property_visit.py @@ -0,0 +1,26 @@ +from datetime import timedelta + +from odoo import models, fields, api +from odoo.exceptions import UserError + + +class EstatePropertyVisit(models.Model): + _name = "estate.property.visit" + _description = "Property Visit" + + property_id = fields.Many2one("estate.property") + partner_id = fields.Many2one( + "res.partner", string="Customer" + ) + visit_time = fields.Datetime(string="Visit Time", required=True) + + @api.constrains('visit_date', 'property_id') + def _check_visit_time(self): + for record in self: + for visit in record.property_id.visit_ids: + if record.id == visit.id or record.visit_time.date() != visit.visit_time.date(): + continue + if self.partner_id == self.partner_id and record.visit_time == visit.visit_time: + raise UserError("CAN'T VISIT 2 PROPERTIES AT SAME TIME") + if record.visit_time - visit.visit_time < timedelta(hours=1): + raise UserError("THIS SLOT IS BOOKED") diff --git a/estate/models/res_user.py b/estate/models/res_user.py new file mode 100644 index 00000000000..a30ba23bd51 --- /dev/null +++ b/estate/models/res_user.py @@ -0,0 +1,11 @@ +from odoo import models, fields + + +class ResUsers(models.Model): + _inherit = "res.users" + + property_ids = fields.One2many( + "estate.property", + "user_id", + domain=[('state', '=', 'available')] + ) diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..c2b55b3e1fc --- /dev/null +++ b/estate/security/ir.model.access.csv @@ -0,0 +1,7 @@ +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 +access_estate_property_visit,access_estate_property_visit,model_estate_property_visit,base.group_user,1,1,1,1 +access_estate_property_issue,access_estate_property_issue,model_estate_property_issue,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..c533a731be9 --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/estate/views/estate_property_issue_views.xml b/estate/views/estate_property_issue_views.xml new file mode 100644 index 00000000000..6fb8f72c1fd --- /dev/null +++ b/estate/views/estate_property_issue_views.xml @@ -0,0 +1,65 @@ + + + Property Issue + estate.property.issue + list,form + + + + estate.property.issue.list + estate.property.issue + + + + + + + + + + + + + + + + + + + estate.property.issue.form + estate.property.issue + +
+ +
+
+

+ +

+ + + + + + + + + + + + + + + + + + + +
+
+
+
+
diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..0c6d3421f6c --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,40 @@ + + + + + Property offer + estate.property.offer + list,form + + + + estate.property.offer.list + estate.property.offer + + + + + + + + + +

+ +

+
+ + + + + + + + + + + + + +
+
+ + + estate.property.type.list + 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..714e9dc5468 --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,151 @@ + + + Properties + estate.property + list,kanban,form + {'search_default_available': True} + + + + estate.property.kanban + estate.property + + + + + + + + + + + + + + + + + + + + + estate.property.list + estate.property + + + + + + + + + + + + + + + + + + estate.property.form + estate.property + +
+
+
+ + +
+ +
+

+ +

+ +

+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + estate.property.search + estate.property + + + + + + + + + + + + + + + +
diff --git a/estate/views/estate_property_visit_views.xml b/estate/views/estate_property_visit_views.xml new file mode 100644 index 00000000000..62ddbe8bdcf --- /dev/null +++ b/estate/views/estate_property_visit_views.xml @@ -0,0 +1,46 @@ + + + + Property visit + estate.property.visit + list,form,calendar + [('property_id', '=', active_id)] + {'default_property_id': active_id} + + + + estate.property.visit.calendar + estate.property.visit + + + + + + + + + estate.property.visit.list + estate.property.visit + + + + + + + + + + estate.property.visit.form + estate.property.visit + +
+ + + + + + +
+
+
+
diff --git a/estate/views/res_user_views.xml b/estate/views/res_user_views.xml new file mode 100644 index 00000000000..e6f6b093497 --- /dev/null +++ b/estate/views/res_user_views.xml @@ -0,0 +1,15 @@ + + + + res.users.form.inherit.estate + res.users + + + + + + + + + +