From 56a3a92978210fbb5e647cd297d3ab0f0f9d7718 Mon Sep 17 00:00:00 2001 From: Madhav Pancholi Date: Mon, 15 Sep 2025 19:27:51 +0530 Subject: [PATCH 1/7] [ADD] estate: initial module with property management Introduce a new module `estate` for real estate management as part of the Server Framework 101 onboarding exercise. The module includes: - Models: `estate.property`, `estate.property.type` - Views: form, tree, and search for both models - Menus: "Advertisement > Properties" and "Settings > Property Types" - Access rights for the new models This commit sets the foundation for further training tasks --- estate/__init__.py | 2 + estate/__manifest__.py | 20 +++++ estate/models/__init__.py | 3 + estate/models/estate_property.py | 54 ++++++++++++++ estate/models/estate_property_type.py | 9 +++ estate/security/ir.model.access.csv | 3 + estate/views/estate_menus.xml | 34 +++++++++ estate/views/estate_property_type_view.xml | 41 +++++++++++ estate/views/estate_property_view.xml | 86 ++++++++++++++++++++++ 9 files changed, 252 insertions(+) create mode 100644 estate/__init__.py create mode 100644 estate/__manifest__.py create mode 100644 estate/models/__init__.py create mode 100644 estate/models/estate_property.py create mode 100644 estate/models/estate_property_type.py create mode 100644 estate/security/ir.model.access.csv create mode 100644 estate/views/estate_menus.xml create mode 100644 estate/views/estate_property_type_view.xml create mode 100644 estate/views/estate_property_view.xml diff --git a/estate/__init__.py b/estate/__init__.py new file mode 100644 index 00000000000..a0fdc10fe11 --- /dev/null +++ b/estate/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import models diff --git a/estate/__manifest__.py b/estate/__manifest__.py new file mode 100644 index 00000000000..1527a74fd34 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +{ + "name": "Real Estate", + "version": "0.1", + "depends": ["base"], + "author": "Madhav Pancholi", + "category": "Real Estate", + "description": """ + Manage properties, rentals, and real estate sales. + """, + "installable": True, + "application": True, + "data": [ + "security/ir.model.access.csv", + "views/estate_property_view.xml", + "views/estate_property_type_view.xml", + "views/estate_menus.xml", + ], + "demo": [], +} diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..67b27d82a3b --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +from . import estate_property +from . import estate_property_type diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..ebdf0fd9410 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +from odoo import fields, models +from dateutil.relativedelta import relativedelta + + +class EstateProperty(models.Model): + _name = "estate.property" + _description = "Estate Property" + + name = fields.Char(required=True, string="Title") + description = fields.Text() + postcode = fields.Char() + date_availability = fields.Date( + string="Available From", + copy=False, + default=lambda self: fields.Date.today() + relativedelta(months=3), + ) + expected_price = fields.Float(required=True) + selling_price = fields.Float(copy=False, readonly=True) + bedrooms = fields.Integer(default=2) + living_area = fields.Integer(string="Living Area(sqm)") + facades = fields.Integer() + garage = fields.Boolean() + garden = fields.Boolean() + garden_area = fields.Integer() + garden_orientation = fields.Selection( + selection=[ + ("north", "North"), + ("south", "South"), + ("east", "East"), + ("west", "West"), + ] + ) + active = fields.Boolean(default=True) + state = fields.Selection( + selection=[ + ("new", "New"), + ("offer_received", "Offer Received"), + ("offer_accepted", "Offer Accepted"), + ("sold", "Sold"), + ("cancelled", "Cancelled"), + ], + default="new", + required=True, + ) + salesman_id = fields.Many2one( + "res.users", + string="Salesman", + default=lambda self: self.env.user.id, + ) + buyer_id = fields.Many2one("res.partner", string="Buyer", copy=False) + property_type_id = fields.Many2one( + "estate.property.type", string="Property Type", copy=False + ) diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py new file mode 100644 index 00000000000..9363139f2ab --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +from odoo import fields, models + + +class EstatePropertyType(models.Model): + _name = "estate.property.type" + _description = "Property Type" + + name = fields.Char(required=True, string="Type") diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..ac680bd78a6 --- /dev/null +++ b/estate/security/ir.model.access.csv @@ -0,0 +1,3 @@ +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 \ 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..d746946ea9e --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,34 @@ + + + + + + + + \ No newline at end of file diff --git a/estate/views/estate_property_type_view.xml b/estate/views/estate_property_type_view.xml new file mode 100644 index 00000000000..06e26e9876d --- /dev/null +++ b/estate/views/estate_property_type_view.xml @@ -0,0 +1,41 @@ + + + + estate.property.type.view.tree + estate.property.type + + + + + + + + estate.property.type.view.form + estate.property.type + +
+ + + + + + + +
+
+
+ + estate.property.type.search + estate.property.type + + + + + + + + Property Type + estate.property.type + list,form + +
\ No newline at end of file diff --git a/estate/views/estate_property_view.xml b/estate/views/estate_property_view.xml new file mode 100644 index 00000000000..e5d5469cce3 --- /dev/null +++ b/estate/views/estate_property_view.xml @@ -0,0 +1,86 @@ + + + + estate.property.view.tree + estate.property + + + + + + + + + + + + + estate.property.view.form + estate.property + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + estate.property.search + estate.property + + + + + + + + + + + + + + + + + + Properties + estate.property + list,form + +
\ No newline at end of file From 787a31c774477560e5b43154a28a775d26e0a8ae Mon Sep 17 00:00:00 2001 From: Madhav Pancholi Date: Wed, 17 Sep 2025 10:38:17 +0530 Subject: [PATCH 2/7] [IMP] estate: add views, menus and access rights This update enhances the Estate module with several new functional features: - Introduced Property Tags with dedicated menu and view, including color customization for better organization. - Added access rights for the new models and restricted property records so users only see their own. - Improved property list view with visual decorations to highlight status (success, warning, danger, muted). - Added buttons to mark properties as Sold or Cancelled, visible only under the right conditions. - Enforced data integrity with new computed values, onchange behavior, and validation rules on property records. --- estate/__init__.py | 1 - estate/__manifest__.py | 5 +- estate/data/auto_reject_offer_cron.xml | 11 +++ estate/models/__init__.py | 3 +- estate/models/estate_property.py | 82 ++++++++++++++++++- estate/models/estate_property_offer.py | 72 ++++++++++++++++ estate/models/estate_property_tag.py | 18 ++++ estate/models/estate_property_type.py | 36 +++++++- estate/security/demo_group.xml | 7 ++ estate/security/ir.model.access.csv | 4 +- .../security/salesperson_own_records_rule.xml | 13 +++ estate/views/estate_menus.xml | 9 +- estate/views/estate_property_tag_view.xml | 44 ++++++++++ estate/views/estate_property_type_view.xml | 25 +++++- estate/views/estate_property_view.xml | 43 ++++++++-- 15 files changed, 353 insertions(+), 20 deletions(-) create mode 100644 estate/data/auto_reject_offer_cron.xml create mode 100644 estate/models/estate_property_offer.py create mode 100644 estate/models/estate_property_tag.py create mode 100644 estate/security/demo_group.xml create mode 100644 estate/security/salesperson_own_records_rule.xml create mode 100644 estate/views/estate_property_tag_view.xml diff --git a/estate/__init__.py b/estate/__init__.py index a0fdc10fe11..0650744f6bc 100644 --- a/estate/__init__.py +++ b/estate/__init__.py @@ -1,2 +1 @@ -# -*- coding: utf-8 -*- from . import models diff --git a/estate/__manifest__.py b/estate/__manifest__.py index 1527a74fd34..a33faa11837 100644 --- a/estate/__manifest__.py +++ b/estate/__manifest__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- { "name": "Real Estate", "version": "0.1", @@ -11,9 +10,13 @@ "installable": True, "application": True, "data": [ + "data/auto_reject_offer_cron.xml", "security/ir.model.access.csv", + "security/demo_group.xml", + "security/salesperson_own_records_rule.xml", "views/estate_property_view.xml", "views/estate_property_type_view.xml", + "views/estate_property_tag_view.xml", "views/estate_menus.xml", ], "demo": [], diff --git a/estate/data/auto_reject_offer_cron.xml b/estate/data/auto_reject_offer_cron.xml new file mode 100644 index 00000000000..76fbd60ed6e --- /dev/null +++ b/estate/data/auto_reject_offer_cron.xml @@ -0,0 +1,11 @@ + + + + Offers: Reject Offer Once validity expires + 1 + days + + model._auto_reject_offer() + code + + \ No newline at end of file diff --git a/estate/models/__init__.py b/estate/models/__init__.py index 67b27d82a3b..2f1821a39c1 100644 --- a/estate/models/__init__.py +++ b/estate/models/__init__.py @@ -1,3 +1,4 @@ -# -*- coding: utf-8 -*- from . import estate_property from . import estate_property_type +from . import estate_property_tag +from . import estate_property_offer diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py index ebdf0fd9410..87d05829181 100644 --- a/estate/models/estate_property.py +++ b/estate/models/estate_property.py @@ -1,5 +1,6 @@ -# -*- coding: utf-8 -*- -from odoo import fields, models +from odoo import fields, models, api, _ +from odoo.exceptions import UserError, ValidationError +from odoo.tools import float_compare from dateutil.relativedelta import relativedelta @@ -9,7 +10,7 @@ class EstateProperty(models.Model): name = fields.Char(required=True, string="Title") description = fields.Text() - postcode = fields.Char() + postcode = fields.Char(groups="base.demo_user0") date_availability = fields.Date( string="Available From", copy=False, @@ -17,8 +18,10 @@ class EstateProperty(models.Model): ) expected_price = fields.Float(required=True) selling_price = fields.Float(copy=False, readonly=True) + best_price = fields.Float(string="Best Offer", compute="_compute_best_price") bedrooms = fields.Integer(default=2) living_area = fields.Integer(string="Living Area(sqm)") + total_area = fields.Integer(string="Total Area(sqm)", compute="_compute_total_area") facades = fields.Integer() garage = fields.Boolean() garden = fields.Boolean() @@ -52,3 +55,76 @@ class EstateProperty(models.Model): property_type_id = fields.Many2one( "estate.property.type", string="Property Type", copy=False ) + tag_ids = fields.Many2many("estate.property.tag", string="Tags") + offer_ids = fields.One2many("estate.property.offer", "property_id", string="Offers") + + _sql_constraints = [ + ( + "check_positive_expected", + "CHECK(expected_price >= 0)", + "Expected Price must be positive value.", + ), + ( + "check_positive_selling", + "CHECK(selling_price >= 0)", + "Selling Price must be positive value.", + ), + ( + "check_positive_offer", + "CHECK(best_price >= 0)", + "Offer Price must be positive value.", + ), + ] + + @api.depends("offer_ids.price") + def _compute_best_price(self): + for rec in self: + rec.best_price = 0 + if rec.offer_ids: + rec.best_price = max(rec.offer_ids.mapped("price")) + + @api.depends("living_area", "garden_area") + def _compute_total_area(self): + for rec in self: + rec.total_area = rec.living_area + rec.garden_area + + @api.onchange("garden") + def _onchange_partner_id(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 rec in self: + expected_price = (rec.expected_price * 90) / 100 + if ( + float_compare(rec.selling_price, expected_price, precision_digits=2) < 0 + and rec.selling_price + ): + raise ValidationError( + _("Selling price must be atlease 90% of Expected Price.") + ) + + @api.ondelete(at_uninstall=False) + def _unlink_except_contains_new_cancelled_state(self): + for rec in self: + if rec.state in ("new", "cancelled"): + raise UserError( + _("You cannot delete records in new or cancelled state.") + ) + + def action_sell_property(self): + for rec in self: + if rec.state == "cancelled": + raise UserError(_("A Cancelled Property Can not be Sold")) + rec.state = "sold" + + def action_cancel_property(self): + for rec in self: + if rec.state == "sold": + raise UserError(_("A Sold Property Can not be Cancelled")) + rec.state = "cancelled" diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..d7a15ffc879 --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,72 @@ +from odoo import fields, models, api, _ +from odoo.exceptions import UserError +from dateutil.relativedelta import relativedelta + + +class EstatePropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "Property Offer" + _order = "price desc" + + price = fields.Float() + status = fields.Selection( + selection=[ + ("accepted", "Accepted"), + ("refused", "Refused"), + ], + copy=False, + ) + partner_id = fields.Many2one("res.partner", required=True, string="Partner") + property_id = fields.Many2one("estate.property", required=True) + validity = fields.Integer(default=7) + date_deadline = fields.Date( + string="Deadline", + compute="_compute_date_deadline", + inverse="_inverse_date_deadline", + store=True, + ) + property_type_id = fields.Many2one( + "estate.property.type", related="property_id.property_type_id", store=True + ) + + @api.model_create_multi + def create(self, vals_list): + offers = super().create(vals_list) + offers.mapped("property_id").write({"state": "offer_received"}) + return offers + + @api.depends("validity") + def _compute_date_deadline(self): + for rec in self: + if rec.create_date: + rec.date_deadline = rec.create_date.date() + relativedelta( + days=rec.validity + ) + else: + rec.date_deadline = fields.Date.today() + relativedelta( + days=rec.validity + ) + + def _inverse_date_deadline(self): + for rec in self: + if rec.date_deadline and rec.create_date: + rec.validity = (rec.date_deadline - rec.create_date.date()).days + + def _auto_reject_offer(self): + rejected_offers = self.search( + [(("date_deadline", "<", fields.Date.context_today(self)))] + ) + for offer in rejected_offers: + offer.status = "refused" + + def action_accept_offer(self): + estate = self.env["estate.property"].search([("id", "=", self.property_id.id)]) + if any(offer.status == "accepted" for offer in estate.offer_ids): + raise UserError(_("Only one offer can be accepted at a time.")) + else: + estate.selling_price = self.price + estate.buyer_id = self.partner_id.id + self.status = "accepted" + + def action_refuse_offer(self): + self.status = "refused" diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..6a9239bbc1e --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,18 @@ +from odoo import fields, models + + +class EstatePropertyTag(models.Model): + _name = "estate.property.tag" + _description = "Property Tag" + _order = "name asc" + + name = fields.Char(required=True) + color = fields.Integer("Color Index") + + _sql_constraints = [ + ( + "name_uniq", + "unique(name)", + "A tag with the same name exists.", + ) + ] diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py index 9363139f2ab..82ceee8c485 100644 --- a/estate/models/estate_property_type.py +++ b/estate/models/estate_property_type.py @@ -1,9 +1,41 @@ -# -*- coding: utf-8 -*- -from odoo import fields, models +from odoo import fields, models, api, _ class EstatePropertyType(models.Model): _name = "estate.property.type" _description = "Property Type" + _order = "sequence, name asc" name = fields.Char(required=True, string="Type") + property_ids = fields.One2many( + "estate.property", "property_type_id", string="Properties" + ) + sequence = fields.Integer("Sequence", default=1) + offer_ids = fields.One2many( + "estate.property.offer", "property_type_id", string="Offers" + ) + offer_count = fields.Integer( + string="Offers", compute="_compute_offer_count", store=True + ) + + _sql_constraints = [ + ( + "name_uniq", + "unique(name)", + "A Property Type with the same name exists.", + ) + ] + + @api.depends("offer_ids") + def _compute_offer_count(self): + for rec in self: + rec.offer_count = len(rec.offer_ids) + + def action_open_offers(self): + return { + "type": "ir.actions.act_window", + "name": _("Offers"), + "res_model": "estate.property.offer", + "view_mode": "list,form", + "domain": [("id", "in", self.offer_ids.ids)], + } diff --git a/estate/security/demo_group.xml b/estate/security/demo_group.xml new file mode 100644 index 00000000000..19dc6c2c81f --- /dev/null +++ b/estate/security/demo_group.xml @@ -0,0 +1,7 @@ + + + + Demo + + + \ No newline at end of file diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv index ac680bd78a6..05bd9eefba4 100644 --- a/estate/security/ir.model.access.csv +++ b/estate/security/ir.model.access.csv @@ -1,3 +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 \ No newline at end of file +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/security/salesperson_own_records_rule.xml b/estate/security/salesperson_own_records_rule.xml new file mode 100644 index 00000000000..7f8e5c6802e --- /dev/null +++ b/estate/security/salesperson_own_records_rule.xml @@ -0,0 +1,13 @@ + + + + estate.property: user: read only own records + + [('salesman_id', '=', user.id)] + + + + + + + \ No newline at end of file diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml index d746946ea9e..bcd3f87b8e3 100644 --- a/estate/views/estate_menus.xml +++ b/estate/views/estate_menus.xml @@ -31,4 +31,11 @@ action="estate_property_type_action" sequence="1" /> - \ No newline at end of file + + diff --git a/estate/views/estate_property_tag_view.xml b/estate/views/estate_property_tag_view.xml new file mode 100644 index 00000000000..11108c57db4 --- /dev/null +++ b/estate/views/estate_property_tag_view.xml @@ -0,0 +1,44 @@ + + + + estate.property.tag.view.tree + estate.property.tag + + + + + + + + + estate.property.tag.view.form + estate.property.tag + +
+ + + + + + + +
+
+
+ + + estate.property.tag.search + estate.property.tag + + + + + + + + + Property Tags + estate.property.tag + list,form + +
diff --git a/estate/views/estate_property_type_view.xml b/estate/views/estate_property_type_view.xml index 06e26e9876d..5a2cfb865d2 100644 --- a/estate/views/estate_property_type_view.xml +++ b/estate/views/estate_property_type_view.xml @@ -5,25 +5,45 @@ estate.property.type + - + + estate.property.type.view.form estate.property.type
+
+ +
+ + + + + + + + + + +
+ estate.property.type.search estate.property.type @@ -33,9 +53,10 @@ + Property Type estate.property.type list,form - \ No newline at end of file + diff --git a/estate/views/estate_property_view.xml b/estate/views/estate_property_view.xml index e5d5469cce3..b88e5fd78e8 100644 --- a/estate/views/estate_property_view.xml +++ b/estate/views/estate_property_view.xml @@ -4,34 +4,44 @@ estate.property.view.tree estate.property - + - + - + + estate.property.view.form estate.property
+
+
- + + @@ -44,10 +54,24 @@ - - + + + + + + + + + + + + + + + + + diff --git a/awesome_owl/static/src/counter/counter.js b/awesome_owl/static/src/counter/counter.js new file mode 100644 index 00000000000..be4713b957d --- /dev/null +++ b/awesome_owl/static/src/counter/counter.js @@ -0,0 +1,19 @@ +import { Component, useState } from "@odoo/owl"; + +export class Counter extends Component { + static template = "awesome_owl.Counter"; + static props = { + onChange: { type: Function, optional: true } + } + + setup() { + this.counter = useState({ value : 0 }) + } + + increment() { + this.counter.value++; + if (this.props.onChange) { + this.props.onChange(); + } + } +} diff --git a/awesome_owl/static/src/counter/counter.xml b/awesome_owl/static/src/counter/counter.xml new file mode 100644 index 00000000000..62ebf42b721 --- /dev/null +++ b/awesome_owl/static/src/counter/counter.xml @@ -0,0 +1,11 @@ + + + + +
+ Counter: + +
+
+ +
diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js index 657fb8b07bb..19aa9e7df54 100644 --- a/awesome_owl/static/src/playground.js +++ b/awesome_owl/static/src/playground.js @@ -1,7 +1,20 @@ -/** @odoo-module **/ - -import { Component } from "@odoo/owl"; +import { Component, useState, markup } from "@odoo/owl"; +import { Counter } from "./counter/counter"; +import { Card } from "./card/card"; +import { TodoList } from "./todo/todo_list"; export class Playground extends Component { static template = "awesome_owl.playground"; + static components = { Counter, Card, TodoList }; + + setup() { + this.state = useState( { value:0 } ); + this.content1 = "hello content1"; + this.content2 = markup("hello content2"); + } + + incrementSum() { + this.state.value++; + } + } diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml index 4fb905d59f9..99e1b0ed4f2 100644 --- a/awesome_owl/static/src/playground.xml +++ b/awesome_owl/static/src/playground.xml @@ -4,7 +4,21 @@
hello world + +
+
+ Sum of the two counter: +
+
+ + + + + + +
+
diff --git a/awesome_owl/static/src/todo/todo_item.js b/awesome_owl/static/src/todo/todo_item.js new file mode 100644 index 00000000000..3dc4d94225c --- /dev/null +++ b/awesome_owl/static/src/todo/todo_item.js @@ -0,0 +1,18 @@ +import { Component, useState } from "@odoo/owl"; + +export class TodoItem extends Component { + static template = "awesome_owl.TodoItem"; + static props = { + todo : { type : Object }, + toggleState : { type : Function}, + removeTodo : { type : Function} + } + + onChange(todoId) { + this.props.toggleState(todoId); + } + + onRemove(todoId) { + this.props.removeTodo(todoId); + } +} \ No newline at end of file diff --git a/awesome_owl/static/src/todo/todo_item.xml b/awesome_owl/static/src/todo/todo_item.xml new file mode 100644 index 00000000000..6ffbda5ed63 --- /dev/null +++ b/awesome_owl/static/src/todo/todo_item.xml @@ -0,0 +1,12 @@ + + + + +
+ + . + +
+
+ +
diff --git a/awesome_owl/static/src/todo/todo_list.js b/awesome_owl/static/src/todo/todo_list.js new file mode 100644 index 00000000000..e1472757f81 --- /dev/null +++ b/awesome_owl/static/src/todo/todo_list.js @@ -0,0 +1,33 @@ +import { Component, useState, useRef } from "@odoo/owl"; +import { TodoItem } from "./todo_item"; +import { useAutofocus } from "../utils" + +export class TodoList extends Component { + static template = "awesome_owl.TodoList"; + static components = { TodoItem }; + + setup(){ + this.todos = useState([]); + useAutofocus('input'); + } + + addTodo(ev){ + if (ev.keyCode === 13 && ev.target.value) { + this.todos.push({ + id: this.todos.length + 1, + description: ev.target.value, + isCompleted: false + }); + } + } + + inputStatus(todoId) { + const todo = this.todos.find(item => item.id === todoId); + todo.isCompleted = !todo.isCompleted; + } + + removeTodo(todoId){ + const index = this.todos.findIndex(item => item.id === todoId); + this.todos.splice(index, 1); + } +} \ No newline at end of file diff --git a/awesome_owl/static/src/todo/todo_list.xml b/awesome_owl/static/src/todo/todo_list.xml new file mode 100644 index 00000000000..49f099b3054 --- /dev/null +++ b/awesome_owl/static/src/todo/todo_list.xml @@ -0,0 +1,13 @@ + + + + +
+ + + + +
+
+ +
diff --git a/awesome_owl/static/src/utils.js b/awesome_owl/static/src/utils.js new file mode 100644 index 00000000000..6a5475b357e --- /dev/null +++ b/awesome_owl/static/src/utils.js @@ -0,0 +1,8 @@ +import { useRef, onMounted } from "@odoo/owl"; + +export function useAutofocus(refName) { + const ref = useRef(refName); + onMounted(() => { + ref.el.focus(); + }); +} \ No newline at end of file From 40ee634de24608b0a219863d6edae9473898cc9f Mon Sep 17 00:00:00 2001 From: Madhav Pancholi Date: Sun, 21 Sep 2025 21:32:32 +0530 Subject: [PATCH 6/7] [ADD] awesome_dashboard: training dashboard module for sales data This module is part of the training exercises. It introduces a customizable dashboard for sales data, including: - Pie chart visualization of shirt orders by size - Numeric cards for aggregated KPIs (average quantity, order counts, amounts) --- awesome_dashboard/__manifest__.py | 40 ++++----- awesome_dashboard/static/src/dashboard.js | 10 --- awesome_dashboard/static/src/dashboard.xml | 8 -- .../static/src/dashboard/dashboard.js | 90 +++++++++++++++++++ .../static/src/dashboard/dashboard.xml | 37 ++++++++ .../dashboard_item/dashboard_item.js | 9 ++ .../dashboard_item/dashboard_item.xml | 10 +++ .../static/src/dashboard/dashboard_items.js | 64 +++++++++++++ .../src/dashboard/number_card/number_card.js | 13 +++ .../src/dashboard/number_card/number_card.xml | 9 ++ .../src/dashboard/pie_chart/pie_chart.js | 41 +++++++++ .../src/dashboard/pie_chart/pie_chart.xml | 10 +++ .../pie_chart_card/pie_chart_card.js | 15 ++++ .../pie_chart_card/pie_chart_card.xml | 7 ++ .../statistics/statistics_services.js | 22 +++++ .../static/src/dashboard_action.js | 12 +++ awesome_dashboard/views/views.xml | 2 +- awesome_owl/static/src/card/card.js | 2 +- awesome_owl/static/src/counter/counter.js | 20 +++-- awesome_owl/static/src/counter/counter.xml | 5 +- awesome_owl/static/src/playground.js | 9 +- awesome_owl/static/src/playground.xml | 8 +- awesome_owl/static/src/todo/todo_item.js | 2 +- awesome_owl/static/src/todo/todo_list.js | 2 +- awesome_owl/static/src/utils.js | 2 +- estate/data/auto_reject_offer_cron.xml | 2 +- 26 files changed, 395 insertions(+), 56 deletions(-) delete mode 100644 awesome_dashboard/static/src/dashboard.js delete mode 100644 awesome_dashboard/static/src/dashboard.xml create mode 100644 awesome_dashboard/static/src/dashboard/dashboard.js create mode 100644 awesome_dashboard/static/src/dashboard/dashboard.xml create mode 100644 awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.js create mode 100644 awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.xml create mode 100644 awesome_dashboard/static/src/dashboard/dashboard_items.js create mode 100644 awesome_dashboard/static/src/dashboard/number_card/number_card.js create mode 100644 awesome_dashboard/static/src/dashboard/number_card/number_card.xml create mode 100644 awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.js create mode 100644 awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml create mode 100644 awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.js create mode 100644 awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.xml create mode 100644 awesome_dashboard/static/src/dashboard/statistics/statistics_services.js create mode 100644 awesome_dashboard/static/src/dashboard_action.js diff --git a/awesome_dashboard/__manifest__.py b/awesome_dashboard/__manifest__.py index 31406e8addb..e126682df2b 100644 --- a/awesome_dashboard/__manifest__.py +++ b/awesome_dashboard/__manifest__.py @@ -1,30 +1,30 @@ # -*- coding: utf-8 -*- { - 'name': "Awesome Dashboard", - - 'summary': """ + "name": "Awesome Dashboard", + "summary": """ Starting module for "Discover the JS framework, chapter 2: Build a dashboard" """, - - 'description': """ + "description": """ Starting module for "Discover the JS framework, chapter 2: Build a dashboard" """, - - 'author': "Odoo", - 'website': "https://www.odoo.com/", - 'category': 'Tutorials/AwesomeDashboard', - 'version': '0.1', - 'application': True, - 'installable': True, - 'depends': ['base', 'web', 'mail', 'crm'], - - 'data': [ - 'views/views.xml', + "author": "Odoo", + "website": "https://www.odoo.com/", + "category": "Tutorials/AwesomeDashboard", + "version": "0.1", + "application": True, + "installable": True, + "depends": ["base", "web", "mail", "crm"], + "data": [ + "views/views.xml", ], - 'assets': { - 'web.assets_backend': [ - 'awesome_dashboard/static/src/**/*', + "assets": { + "web.assets_backend": [ + "awesome_dashboard/static/src/**/*", + ("remove", "awesome_dashboard/static/src/dashboard/**/*"), + ], + "awesome_dashboard.dashboard_assetsbundle": [ + "awesome_dashboard/static/src/dashboard/**/*", ], }, - 'license': 'AGPL-3' + "license": "AGPL-3", } diff --git a/awesome_dashboard/static/src/dashboard.js b/awesome_dashboard/static/src/dashboard.js deleted file mode 100644 index 637fa4bb972..00000000000 --- a/awesome_dashboard/static/src/dashboard.js +++ /dev/null @@ -1,10 +0,0 @@ -/** @odoo-module **/ - -import { Component } from "@odoo/owl"; -import { registry } from "@web/core/registry"; - -class AwesomeDashboard extends Component { - static template = "awesome_dashboard.AwesomeDashboard"; -} - -registry.category("actions").add("awesome_dashboard.dashboard", AwesomeDashboard); diff --git a/awesome_dashboard/static/src/dashboard.xml b/awesome_dashboard/static/src/dashboard.xml deleted file mode 100644 index 1a2ac9a2fed..00000000000 --- a/awesome_dashboard/static/src/dashboard.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - hello dashboard - - - diff --git a/awesome_dashboard/static/src/dashboard/dashboard.js b/awesome_dashboard/static/src/dashboard/dashboard.js new file mode 100644 index 00000000000..d2a918bdead --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.js @@ -0,0 +1,90 @@ +import { Component, onWillStart, useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { Layout } from "@web/search/layout"; +import { _t } from "@web/core/l10n/translation"; +import { DashboardItem } from "./dashboard_item/dashboard_item"; +import { Dialog } from "@web/core/dialog/dialog"; +import { CheckBox } from "@web/core/checkbox/checkbox"; +import { browser } from "@web/core/browser/browser"; + +export class AwesomeDashboard extends Component { + + static template = "awesome_dashboard.AwesomeDashboard"; + static components = { Layout, DashboardItem }; + + setup() { + this.action = useService("action"); + this.statistics = useState(useService("awesome_dashboard.statistics")); + this.display = { + controlPanel: {}, + }; + this.items = registry.category("awesome_dashboard").getAll(); + this.dialog = useService("dialog"); + this.state = useState({ + disabledItems: browser.localStorage.getItem("disabledDashboardItems")?.split(",") || [] + }); + } + + openConfiguration() { + this.dialog.add(ConfigurationDialog, { + items: this.items, + disabledItems: this.state.disabledItems, + onUpdateConfiguration: this.updateConfiguration.bind(this), + }) + } + + updateConfiguration(newDisabledItems) { + this.state.disabledItems = newDisabledItems; + } + + openCustomers() { + this.action.doAction("base.action_partner_form"); + } + + async openLeads() { + this.action.doAction({ + type: 'ir.actions.act_window', + name: _t('Leads'), + target: 'current', + res_model: 'crm.lead', + views: [[false, 'list'], [false, 'form']], + }); + } +} + +class ConfigurationDialog extends Component { + static template = "awesome_dashboard.ConfigurationDialog"; + static components = { Dialog, CheckBox }; + static props = ["close", "items", "disabledItems", "onUpdateConfiguration"]; + + setup() { + this.items = useState(this.props.items.map((item) => { + return { + ...item, + enabled: !this.props.disabledItems.includes(item.id), + } + })); + } + + done() { + this.props.close(); + } + + onChange(checked, changedItem) { + changedItem.enabled = checked; + const newDisabledItems = Object.values(this.items).filter( + (item) => !item.enabled + ).map((item) => item.id) + + browser.localStorage.setItem( + "disabledDashboardItems", + newDisabledItems, + ); + + this.props.onUpdateConfiguration(newDisabledItems); + } + +} + +registry.category("lazy_components").add("AwesomeDashboard", AwesomeDashboard); diff --git a/awesome_dashboard/static/src/dashboard/dashboard.xml b/awesome_dashboard/static/src/dashboard/dashboard.xml new file mode 100644 index 00000000000..eeb9f74a6d5 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + Which cards do you whish to see ? + + + + + + + + + + + + + \ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.js b/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.js new file mode 100644 index 00000000000..18dcb975cde --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.js @@ -0,0 +1,9 @@ +import { Component } from "@odoo/owl"; + +export class DashboardItem extends Component { + static template = "awesome_dashboard.DashboardItem"; + static props = { + slots: { type: Object, optional: true }, + size: { type: Number, optional: true, default: 1}, + } +} diff --git a/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.xml b/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.xml new file mode 100644 index 00000000000..dc8fae623db --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_item/dashboard_item.xml @@ -0,0 +1,10 @@ + + + +
+
+ +
+
+
+
diff --git a/awesome_dashboard/static/src/dashboard/dashboard_items.js b/awesome_dashboard/static/src/dashboard/dashboard_items.js new file mode 100644 index 00000000000..d1b0be342ed --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_items.js @@ -0,0 +1,64 @@ +import { NumberCard } from "./number_card/number_card"; +import { PieChartCard } from "./pie_chart_card/pie_chart_card"; +import { registry } from "@web/core/registry"; + +const items = [ + { + id: "average_quantity", + description: "Average amount of t-shirt", + Component: NumberCard, + props: (data) => ({ + title: "Average amount of t-shirt by order this month", + value: data.average_quantity, + }) + }, + { + id: "average_time", + description: "Average time for an order", + Component: NumberCard, + props: (data) => ({ + title: "Average time for an order to go from 'new' to 'sent' or 'cancelled'", + value: data.average_time, + }) + }, + { + id: "number_new_orders", + description: "New orders this month", + Component: NumberCard, + props: (data) => ({ + title: "Number of new orders this month", + value: data.nb_new_orders, + }) + }, + { + id: "cancelled_orders", + description: "Cancelled orders this month", + Component: NumberCard, + props: (data) => ({ + title: "Number of cancelled orders this month", + value: data.nb_cancelled_orders, + }) + }, + { + id: "amount_new_orders", + description: "amount orders this month", + Component: NumberCard, + props: (data) => ({ + title: "Total amount of new orders this month", + value: data.total_amount, + }) + }, + { + id: "pie_chart", + description: "Shirt orders by size", + Component: PieChartCard, + props: (data) => ({ + title: "Shirt orders by size", + values: { ...data.orders_by_size }, + }) + } +] + +items.forEach(item => { + registry.category("awesome_dashboard").add(item.id, item); +}); diff --git a/awesome_dashboard/static/src/dashboard/number_card/number_card.js b/awesome_dashboard/static/src/dashboard/number_card/number_card.js new file mode 100644 index 00000000000..8c8322a2074 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/number_card/number_card.js @@ -0,0 +1,13 @@ +import { Component } from "@odoo/owl"; + +export class NumberCard extends Component { + static template = "awesome_dashboard.NumberCard"; + static props = { + title: { + type: String, + }, + value: { + type: Number, optional: true, + } + } +} diff --git a/awesome_dashboard/static/src/dashboard/number_card/number_card.xml b/awesome_dashboard/static/src/dashboard/number_card/number_card.xml new file mode 100644 index 00000000000..3a0713623fa --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/number_card/number_card.xml @@ -0,0 +1,9 @@ + + + + +
+ +
+
+
diff --git a/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.js b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.js new file mode 100644 index 00000000000..12c29272a06 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.js @@ -0,0 +1,41 @@ +import { loadJS } from "@web/core/assets"; +import { getColor } from "@web/core/colors/colors"; +import { Component, onWillStart, useRef, onMounted, onWillUnmount } from "@odoo/owl"; + +export class PieChart extends Component { + static template = "awesome_dashboard.PieChart"; + static props = { + label: String, + data: Object, + }; + + setup() { + this.canvasRef = useRef("canvas"); + onWillStart(() => loadJS(["/web/static/lib/Chart/Chart.js"])); + onMounted(() => { + this.renderChart(); + }); + onWillUnmount(() => { + this.chart.destroy(); + }); + } + + renderChart() { + const labels = Object.keys(this.props.data); + const data = Object.values(this.props.data); + const color = labels.map((_, index) => getColor(index)); + this.chart = new Chart(this.canvasRef.el, { + type: "pie", + data: { + labels: labels, + datasets: [ + { + label: this.props.label, + data: data, + backgroundColor: color, + }, + ], + }, + }); + } +} diff --git a/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml new file mode 100644 index 00000000000..4f3c54a6c15 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml @@ -0,0 +1,10 @@ + + + +
+
+ +
+
+
+
diff --git a/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.js b/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.js new file mode 100644 index 00000000000..7520dc8630e --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.js @@ -0,0 +1,15 @@ +import { Component } from "@odoo/owl"; +import { PieChart } from "../pie_chart/pie_chart"; + +export class PieChartCard extends Component { + static template = "awesome_dashboard.PieChartCard"; + static components = { PieChart } + static props = { + title: { + type: String, + }, + values: { + type: Object, optional: true, + }, + } +} diff --git a/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.xml b/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.xml new file mode 100644 index 00000000000..58a6811c83a --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/awesome_dashboard/static/src/dashboard/statistics/statistics_services.js b/awesome_dashboard/static/src/dashboard/statistics/statistics_services.js new file mode 100644 index 00000000000..5f7c5e566d2 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/statistics/statistics_services.js @@ -0,0 +1,22 @@ +import { registry } from "@web/core/registry"; +import { memoize } from "@web/core/utils/functions"; +import { rpc } from "@web/core/network/rpc"; +import { reactive } from "@odoo/owl"; + +const statisticsService = { + start(env) { + const statistics = reactive({ isReady: false }); + + async function loadData() { + const updates = await rpc("/awesome_dashboard/statistics"); + Object.assign(statistics, updates, { isReady: true }); + } + + setInterval(loadData, 10000); + loadData(); + + return statistics; + }, +}; + +registry.category("services").add("awesome_dashboard.statistics", statisticsService); diff --git a/awesome_dashboard/static/src/dashboard_action.js b/awesome_dashboard/static/src/dashboard_action.js new file mode 100644 index 00000000000..26958490203 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_action.js @@ -0,0 +1,12 @@ +import { Component, xml } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { LazyComponent } from "@web/core/assets"; + +class DashboardLoader extends Component { + static components = { LazyComponent }; + static template = xml` + + `; +} + +registry.category("actions").add("awesome_dashboard.dashboard_loader", DashboardLoader); diff --git a/awesome_dashboard/views/views.xml b/awesome_dashboard/views/views.xml index 47fb2b6f258..e23d36bf52e 100644 --- a/awesome_dashboard/views/views.xml +++ b/awesome_dashboard/views/views.xml @@ -2,7 +2,7 @@ Dashboard - awesome_dashboard.dashboard + awesome_dashboard.dashboard_loader diff --git a/awesome_owl/static/src/card/card.js b/awesome_owl/static/src/card/card.js index edd7d79292c..986d8165f16 100644 --- a/awesome_owl/static/src/card/card.js +++ b/awesome_owl/static/src/card/card.js @@ -14,4 +14,4 @@ export class Card extends Component { onToggle() { this.toggle.value = !this.toggle.value; } -} \ No newline at end of file +} diff --git a/awesome_owl/static/src/counter/counter.js b/awesome_owl/static/src/counter/counter.js index be4713b957d..2f21bf01e17 100644 --- a/awesome_owl/static/src/counter/counter.js +++ b/awesome_owl/static/src/counter/counter.js @@ -3,17 +3,27 @@ import { Component, useState } from "@odoo/owl"; export class Counter extends Component { static template = "awesome_owl.Counter"; static props = { - onChange: { type: Function, optional: true } + onChange: { type: Function, optional: true }, + increment:{ type: Boolean, optional: true} } setup() { this.counter = useState({ value : 0 }) } - increment() { - this.counter.value++; - if (this.props.onChange) { - this.props.onChange(); + onCalculate(operator) { + if (operator === '+') { + this.counter.value++; + if (this.props.onChange) { + this.props.onChange(operator); + } } + else { + this.counter.value--; + if (this.props.onChange) { + this.props.onChange(operator); + } + } + } } diff --git a/awesome_owl/static/src/counter/counter.xml b/awesome_owl/static/src/counter/counter.xml index 62ebf42b721..b1cd45d4ea1 100644 --- a/awesome_owl/static/src/counter/counter.xml +++ b/awesome_owl/static/src/counter/counter.xml @@ -4,7 +4,10 @@
Counter: - + + + +
diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js index 19aa9e7df54..bc083f84a81 100644 --- a/awesome_owl/static/src/playground.js +++ b/awesome_owl/static/src/playground.js @@ -13,8 +13,13 @@ export class Playground extends Component { this.content2 = markup("hello content2"); } - incrementSum() { - this.state.value++; + onChange(operator) { + if (operator === '+') { + this.state.value++; + } + else { + this.state.value--; + } } } diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml index 99e1b0ed4f2..cdcd4e885fa 100644 --- a/awesome_owl/static/src/playground.xml +++ b/awesome_owl/static/src/playground.xml @@ -4,18 +4,18 @@
hello world - - + +
Sum of the two counter:
- + - +
diff --git a/awesome_owl/static/src/todo/todo_item.js b/awesome_owl/static/src/todo/todo_item.js index 3dc4d94225c..4b7ed88214d 100644 --- a/awesome_owl/static/src/todo/todo_item.js +++ b/awesome_owl/static/src/todo/todo_item.js @@ -15,4 +15,4 @@ export class TodoItem extends Component { onRemove(todoId) { this.props.removeTodo(todoId); } -} \ No newline at end of file +} diff --git a/awesome_owl/static/src/todo/todo_list.js b/awesome_owl/static/src/todo/todo_list.js index e1472757f81..0d5a1ab5114 100644 --- a/awesome_owl/static/src/todo/todo_list.js +++ b/awesome_owl/static/src/todo/todo_list.js @@ -30,4 +30,4 @@ export class TodoList extends Component { const index = this.todos.findIndex(item => item.id === todoId); this.todos.splice(index, 1); } -} \ No newline at end of file +} diff --git a/awesome_owl/static/src/utils.js b/awesome_owl/static/src/utils.js index 6a5475b357e..f452f103aa0 100644 --- a/awesome_owl/static/src/utils.js +++ b/awesome_owl/static/src/utils.js @@ -5,4 +5,4 @@ export function useAutofocus(refName) { onMounted(() => { ref.el.focus(); }); -} \ No newline at end of file +} diff --git a/estate/data/auto_reject_offer_cron.xml b/estate/data/auto_reject_offer_cron.xml index 76fbd60ed6e..a4330e79592 100644 --- a/estate/data/auto_reject_offer_cron.xml +++ b/estate/data/auto_reject_offer_cron.xml @@ -8,4 +8,4 @@ model._auto_reject_offer() code
- \ No newline at end of file + From d17d142b87bef42bd7bf8b302f1bdc6d83d2a0eb Mon Sep 17 00:00:00 2001 From: Madhav Pancholi Date: Mon, 22 Sep 2025 10:26:07 +0530 Subject: [PATCH 7/7] [FIX] estate : Fixed Style error in estate module Removed Translation from product name. --- estate/security/demo_group.xml | 2 +- estate/security/salesperson_own_records_rule.xml | 2 +- estate/views/res_users_view.xml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/estate/security/demo_group.xml b/estate/security/demo_group.xml index 19dc6c2c81f..24d3bd38385 100644 --- a/estate/security/demo_group.xml +++ b/estate/security/demo_group.xml @@ -4,4 +4,4 @@ Demo - \ No newline at end of file + diff --git a/estate/security/salesperson_own_records_rule.xml b/estate/security/salesperson_own_records_rule.xml index 7f8e5c6802e..4ca37164cb4 100644 --- a/estate/security/salesperson_own_records_rule.xml +++ b/estate/security/salesperson_own_records_rule.xml @@ -10,4 +10,4 @@ - \ No newline at end of file + diff --git a/estate/views/res_users_view.xml b/estate/views/res_users_view.xml index f784de03090..4edcb647a03 100644 --- a/estate/views/res_users_view.xml +++ b/estate/views/res_users_view.xml @@ -21,4 +21,4 @@ - \ No newline at end of file +