From bbeeb660a89c2189c51575d0e38e4f6cbba14fcf Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Mon, 19 Jan 2026 14:32:24 +0100 Subject: [PATCH 01/39] [ADD] estate: create new estate module manifest Initial commit of my technical onboarding exercises, corresponding to chapters 1 and 2 of server framework 101. Initialisation of a new module named 'estate'. --- estate/__init__.py | 0 estate/__manifest__.py | 7 +++++++ 2 files changed, 7 insertions(+) create mode 100644 estate/__init__.py create mode 100644 estate/__manifest__.py diff --git a/estate/__init__.py b/estate/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/estate/__manifest__.py b/estate/__manifest__.py new file mode 100644 index 00000000000..d49a772a844 --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,7 @@ +{ + 'name': 'estate', + 'depends': [ + 'base', + ], + 'application': True, +} \ No newline at end of file From 0e3670ea1c10620dfe2ffd3cecfc26e20f22c708 Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Mon, 19 Jan 2026 15:18:07 +0100 Subject: [PATCH 02/39] [IMP] estate: defined estate property model Creation of the estate module's basic property data model. This corresponds to chapter 3 of the functional onboarding exercise. --- estate/__init__.py | 1 + estate/__manifest__.py | 2 +- estate/models/__init__.py | 1 + estate/models/estate_property.py | 26 ++++++++++++++++++++++++++ 4 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 estate/models/__init__.py create mode 100644 estate/models/estate_property.py diff --git a/estate/__init__.py b/estate/__init__.py index e69de29bb2d..0650744f6bc 100644 --- a/estate/__init__.py +++ b/estate/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/estate/__manifest__.py b/estate/__manifest__.py index d49a772a844..68ec479e4c6 100644 --- a/estate/__manifest__.py +++ b/estate/__manifest__.py @@ -4,4 +4,4 @@ 'base', ], 'application': True, -} \ No newline at end of file +} diff --git a/estate/models/__init__.py b/estate/models/__init__.py new file mode 100644 index 00000000000..5e1963c9d2f --- /dev/null +++ b/estate/models/__init__.py @@ -0,0 +1 @@ +from . import estate_property diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py new file mode 100644 index 00000000000..b6d41f55c14 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,26 @@ +from odoo import fields, models + +class EstateProperty(models.Model): + _name = "estate_property" + _description = "Real estate property model" + + name = fields.Char(required=True) + description= fields.Text() + postcode = fields.Char() + date_availability = fields.Date() + expected_price = fields.Float(required=True) + selling_price = fields.Float() + bedrooms = fields.Integer() + living_area = fields.Integer() + facades = fields.Integer() + garage = fields.Boolean() + garden = fields.Boolean() + garden_area = fields.Integer() + garden_orientation = fields.Selection(string='Type', + selection=[ + ('north', 'North'), + ('south', 'South'), + ('east', 'East'), + ('west', 'West'), + ] + ) From 8f57e107a27e91d7b74494d3b562c391fd3f65e4 Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Mon, 19 Jan 2026 15:32:48 +0100 Subject: [PATCH 03/39] [IMP] estate: added security permissions Added all permission for group_user to view the estate property data. This corresponds to chapter 4 of the technical onboarding exercise. --- estate/__manifest__.py | 3 +++ estate/security/ir.model.access.csv | 2 ++ 2 files changed, 5 insertions(+) create mode 100644 estate/security/ir.model.access.csv diff --git a/estate/__manifest__.py b/estate/__manifest__.py index 68ec479e4c6..3ed4df7f937 100644 --- a/estate/__manifest__.py +++ b/estate/__manifest__.py @@ -4,4 +4,7 @@ 'base', ], 'application': True, + 'data': [ + 'security/ir.model.access.csv' + ] } diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..0e11f47e58d --- /dev/null +++ b/estate/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_estate_property,access_estate_property,model_estate_property,base.group_user,1,1,1,1 \ No newline at end of file From 53017adb053ef181aee7c5876b774bb7cd8026e4 Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Mon, 19 Jan 2026 16:18:35 +0100 Subject: [PATCH 04/39] [IMP] estate: add GUI to view and edit properties Added proper menus to edit estate properties, and the appropraite restriction on field edition. This corresponds to chapter 5 of the functional onboarding. --- estate/__manifest__.py | 4 +++- estate/models/estate_property.py | 22 +++++++++++++++++----- estate/security/ir.model.access.csv | 2 +- estate/views/estate_menus.xml | 8 ++++++++ estate/views/estate_property_views.xml | 8 ++++++++ 5 files changed, 37 insertions(+), 7 deletions(-) create mode 100644 estate/views/estate_menus.xml create mode 100644 estate/views/estate_property_views.xml diff --git a/estate/__manifest__.py b/estate/__manifest__.py index 3ed4df7f937..b37a6bddd42 100644 --- a/estate/__manifest__.py +++ b/estate/__manifest__.py @@ -5,6 +5,8 @@ ], 'application': True, 'data': [ - 'security/ir.model.access.csv' + 'views/estate_property_views.xml', + 'views/estate_menus.xml', + 'security/ir.model.access.csv', ] } diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py index b6d41f55c14..227f70d2768 100644 --- a/estate/models/estate_property.py +++ b/estate/models/estate_property.py @@ -1,22 +1,25 @@ +import datetime +from dateutil.relativedelta import relativedelta + from odoo import fields, models class EstateProperty(models.Model): - _name = "estate_property" + _name = "estate.property" _description = "Real estate property model" name = fields.Char(required=True) description= fields.Text() postcode = fields.Char() - date_availability = fields.Date() + date_availability = fields.Date(copy=False, default=datetime.date.today() + relativedelta(months=3)) expected_price = fields.Float(required=True) - selling_price = fields.Float() - bedrooms = fields.Integer() + 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='Type', + garden_orientation = fields.Selection( selection=[ ('north', 'North'), ('south', 'South'), @@ -24,3 +27,12 @@ class EstateProperty(models.Model): ('west', 'West'), ] ) + active = fields.Boolean(default=True) + state = fields.Selection(required=True, default='new', copy=False, + selection=[ + ("new", "New"), + ("offer_received", "Offer Received"), + ("offer_accepted", "Offer Accepted"), + ("sold", "Sold"), + ("cancelled", "Cancelled")], + ) diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv index 0e11f47e58d..32389642d4f 100644 --- a/estate/security/ir.model.access.csv +++ b/estate/security/ir.model.access.csv @@ -1,2 +1,2 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_estate_property,access_estate_property,model_estate_property,base.group_user,1,1,1,1 \ No newline at end of file +access_estate_property,access_estate_property,model_estate_property,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..2b6076f7181 --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml new file mode 100644 index 00000000000..1d2a3aaa4cd --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,8 @@ + + + + Properties + estate.property + list,form + + From 52cf939b2439531c6a388e196c6c05af488ae596 Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Mon, 19 Jan 2026 17:14:08 +0100 Subject: [PATCH 05/39] [IMP] estate: create views for estate models Added list and form views, as well as search filters for estate property model. This corresponds to chapter 6 of the technical onboarding exercise. --- estate/models/estate_property.py | 11 ++-- estate/views/estate_property_views.xml | 76 ++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py index 227f70d2768..86528384609 100644 --- a/estate/models/estate_property.py +++ b/estate/models/estate_property.py @@ -7,18 +7,18 @@ class EstateProperty(models.Model): _name = "estate.property" _description = "Real estate property model" - name = fields.Char(required=True) + name = fields.Char(required=True, string="Title") description= fields.Text() postcode = fields.Char() - date_availability = fields.Date(copy=False, default=datetime.date.today() + relativedelta(months=3)) + date_availability = fields.Date(copy=False, default=datetime.date.today() + relativedelta(months=3), string="Available From") 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 = fields.Integer(string="Living Area (sqm)") facades = fields.Integer() garage = fields.Boolean() garden = fields.Boolean() - garden_area = fields.Integer() + garden_area = fields.Integer(string="Garden Area (sqm)") garden_orientation = fields.Selection( selection=[ ('north', 'North'), @@ -34,5 +34,6 @@ class EstateProperty(models.Model): ("offer_received", "Offer Received"), ("offer_accepted", "Offer Accepted"), ("sold", "Sold"), - ("cancelled", "Cancelled")], + ("cancelled", "Cancelled") + ], ) diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml index 1d2a3aaa4cd..be1253522df 100644 --- a/estate/views/estate_property_views.xml +++ b/estate/views/estate_property_views.xml @@ -5,4 +5,80 @@ estate.property list,form + + + estate.property.list + estate.property + + + + + + + + + + + + + + + estate.property.form + estate.property + +
+ + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + estate.property.search + estate.property + + + + + + + + + + + + + + From 7e9b857bb9c0bf7a96b4a797fad3586c5590c029 Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Tue, 20 Jan 2026 10:00:41 +0100 Subject: [PATCH 06/39] [ADD] estate: create relational models describing properties Added property offer, type and tag models to flesh out the property descriptions and track potential buyers. This corresponds to chapter 7 of the functional onboarding exercise. --- estate/__manifest__.py | 3 ++ estate/models/__init__.py | 3 ++ estate/models/estate_property.py | 5 ++++ estate/models/estate_property_offer.py | 15 ++++++++++ estate/models/estate_property_tag.py | 7 +++++ estate/models/estate_property_type.py | 7 +++++ estate/security/ir.model.access.csv | 3 ++ estate/views/estate_menus.xml | 5 ++++ estate/views/estate_property_offer_views.xml | 30 ++++++++++++++++++++ estate/views/estate_property_tag_views.xml | 14 +++++++++ estate/views/estate_property_type_views.xml | 20 +++++++++++++ estate/views/estate_property_views.xml | 13 +++++++++ 12 files changed, 125 insertions(+) create mode 100644 estate/models/estate_property_offer.py create mode 100644 estate/models/estate_property_tag.py create mode 100644 estate/models/estate_property_type.py create mode 100644 estate/views/estate_property_offer_views.xml create mode 100644 estate/views/estate_property_tag_views.xml create mode 100644 estate/views/estate_property_type_views.xml diff --git a/estate/__manifest__.py b/estate/__manifest__.py index b37a6bddd42..5de28c51cf3 100644 --- a/estate/__manifest__.py +++ b/estate/__manifest__.py @@ -5,6 +5,9 @@ ], 'application': True, 'data': [ + 'views/estate_property_type_views.xml', + 'views/estate_property_tag_views.xml', + 'views/estate_property_offer_views.xml', 'views/estate_property_views.xml', 'views/estate_menus.xml', 'security/ir.model.access.csv', diff --git a/estate/models/__init__.py b/estate/models/__init__.py index 5e1963c9d2f..2f1821a39c1 100644 --- a/estate/models/__init__.py +++ b/estate/models/__init__.py @@ -1 +1,4 @@ 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 86528384609..3b0c36eeb28 100644 --- a/estate/models/estate_property.py +++ b/estate/models/estate_property.py @@ -37,3 +37,8 @@ class EstateProperty(models.Model): ("cancelled", "Cancelled") ], ) + property_type_id = fields.Many2one("estate.property.type", string="Property type") + buyer_id = fields.Many2one("res.partner", string="Buyer", copy=False) + salesman_id = fields.Many2one("res.users", string="Salesman", default=lambda self: self.env.user) + property_tag_ids = fields.Many2many("estate.property.tag", string="Tags") + offer_ids = fields.One2many("estate.property.offer", "property_id", string="Offers") diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..d0eda80f94f --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,15 @@ +from odoo import fields, models + +class EstatePropertyOffer(models.Model): + _name = "estate.property.offer" + _description = "A property offer is an amount a potential buyer offers to the seller" + + price = fields.Float() + status = fields.Selection(copy=False, + selection=[ + ("accepted", "Accepted"), + ("refused", "Refused"), + ] + ) + partner_id = fields.Many2one("res.partner", required=True) + property_id = fields.Many2one("estate.property", required=True) diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..1572e5d0ddb --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,7 @@ +from odoo import fields, models + +class EstatePropertyTag(models.Model): + _name = "estate.property.tag" + _description = "Tags describing the property such as 'cozy' and 'renovated'" + + name = fields.Char(required=True) diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py new file mode 100644 index 00000000000..11eb71d76eb --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,7 @@ +from odoo import fields, models + +class EstatePropertyType(models.Model): + _name = "estate.property.type" + _description = "Estate property type such as 'house'" + + name = fields.Char(required=True) diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv index 32389642d4f..89f97c50842 100644 --- a/estate/security/ir.model.access.csv +++ b/estate/security/ir.model.access.csv @@ -1,2 +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 index 2b6076f7181..f3973e31ac3 100644 --- a/estate/views/estate_menus.xml +++ b/estate/views/estate_menus.xml @@ -4,5 +4,10 @@ + + + + + diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..8082d2d84af --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,30 @@ + + + + estate.property.offer.list + estate.property.offer + + + + + + + + + + + estate.property.offer.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..9bb2ee17836 --- /dev/null +++ b/estate/views/estate_property_tag_views.xml @@ -0,0 +1,14 @@ + + + + Property Tags + estate.property.tag + list,form + + + + Property Tags + estate.property.tag + list,form + + diff --git a/estate/views/estate_property_type_views.xml b/estate/views/estate_property_type_views.xml new file mode 100644 index 00000000000..8bb9d7c417c --- /dev/null +++ b/estate/views/estate_property_type_views.xml @@ -0,0 +1,20 @@ + + + + Property Types + estate.property.type + list,form + + + + estate.property.type.form + estate.property.type + +
+ +

+
+
+
+
+
diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml index be1253522df..0e3e6cb311b 100644 --- a/estate/views/estate_property_views.xml +++ b/estate/views/estate_property_views.xml @@ -18,6 +18,7 @@ + @@ -30,10 +31,12 @@

+ + @@ -57,6 +60,15 @@ + + + + + + + + +
@@ -75,6 +87,7 @@ + From 4979e4a4a488af1ddc6f0b743e35c6b6a6e9fe89 Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Tue, 20 Jan 2026 11:08:54 +0100 Subject: [PATCH 07/39] [IMP] estate: add dynamic related field update Added update logic to set related fields when editing estate property record to reduce busywork. This corresponds to chapter 8 of the functional onboarding exercise. --- estate/models/estate_property.py | 34 +++++++++++++++----- estate/models/estate_property_offer.py | 23 +++++++++++-- estate/models/estate_property_tag.py | 1 + estate/models/estate_property_type.py | 1 + estate/views/estate_property_offer_views.xml | 3 ++ estate/views/estate_property_views.xml | 7 ++-- 6 files changed, 53 insertions(+), 16 deletions(-) diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py index 3b0c36eeb28..88adac28aea 100644 --- a/estate/models/estate_property.py +++ b/estate/models/estate_property.py @@ -1,14 +1,15 @@ import datetime from dateutil.relativedelta import relativedelta -from odoo import fields, models +from odoo import api, fields, models + class EstateProperty(models.Model): _name = "estate.property" _description = "Real estate property model" name = fields.Char(required=True, string="Title") - description= fields.Text() + description = fields.Text() postcode = fields.Char() date_availability = fields.Date(copy=False, default=datetime.date.today() + relativedelta(months=3), string="Available From") expected_price = fields.Float(required=True) @@ -28,13 +29,13 @@ class EstateProperty(models.Model): ] ) active = fields.Boolean(default=True) - state = fields.Selection(required=True, default='new', copy=False, + state = fields.Selection(required=True, default="new", copy=False, selection=[ - ("new", "New"), - ("offer_received", "Offer Received"), - ("offer_accepted", "Offer Accepted"), - ("sold", "Sold"), - ("cancelled", "Cancelled") + ('new', 'New'), + ('offer_received', 'Offer Received'), + ('offer_accepted', 'Offer Accepted'), + ('sold', 'Sold'), + ('cancelled', 'Cancelled') ], ) property_type_id = fields.Many2one("estate.property.type", string="Property type") @@ -42,3 +43,20 @@ class EstateProperty(models.Model): salesman_id = fields.Many2one("res.users", string="Salesman", default=lambda self: self.env.user) property_tag_ids = fields.Many2many("estate.property.tag", string="Tags") offer_ids = fields.One2many("estate.property.offer", "property_id", string="Offers") + total_area = fields.Float(compute="_compute_total_area") + best_price = fields.Float(compute="_compute_best_price", string="Best Offer") + + @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') + def _compute_best_price(self): + for record in self: + record.best_price = max(record.offer_ids.mapped("price")) if record.offer_ids else 0 + + @api.onchange('garden') + def _onchange_garden(self): + self.garden_area = 10 if self.garden else 0 + self.garden_orientation = 'north' if self.garden else None diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py index d0eda80f94f..f7a01b3923b 100644 --- a/estate/models/estate_property_offer.py +++ b/estate/models/estate_property_offer.py @@ -1,4 +1,8 @@ -from odoo import fields, models +import datetime +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models + class EstatePropertyOffer(models.Model): _name = "estate.property.offer" @@ -7,9 +11,22 @@ class EstatePropertyOffer(models.Model): price = fields.Float() status = fields.Selection(copy=False, selection=[ - ("accepted", "Accepted"), - ("refused", "Refused"), + ('accepted', 'Accepted'), + ('refused', 'Refused'), ] ) partner_id = fields.Many2one("res.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", string="Deadline") + + @api.depends('create_date', 'validity') + def _compute_date_deadline(self): + for record in self: + creation_date = record.create_date or datetime.date.today() + record.date_deadline = creation_date + relativedelta(days=record.validity) + + def _inverse_date_deadline(self): + for record in self: + record.validity = record.date_deadline.day - record.create_date.day + diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py index 1572e5d0ddb..5511f94e186 100644 --- a/estate/models/estate_property_tag.py +++ b/estate/models/estate_property_tag.py @@ -1,5 +1,6 @@ from odoo import fields, models + class EstatePropertyTag(models.Model): _name = "estate.property.tag" _description = "Tags describing the property such as 'cozy' and 'renovated'" diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py index 11eb71d76eb..834fb6f46c1 100644 --- a/estate/models/estate_property_type.py +++ b/estate/models/estate_property_type.py @@ -1,5 +1,6 @@ from odoo import fields, models + class EstatePropertyType(models.Model): _name = "estate.property.type" _description = "Estate property type such as 'house'" diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml index 8082d2d84af..69c2cc0786e 100644 --- a/estate/views/estate_property_offer_views.xml +++ b/estate/views/estate_property_offer_views.xml @@ -8,6 +8,7 @@ + @@ -21,6 +22,8 @@ + + diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml index 0e3e6cb311b..6b4a0b49b88 100644 --- a/estate/views/estate_property_views.xml +++ b/estate/views/estate_property_views.xml @@ -29,11 +29,9 @@
-

- @@ -42,11 +40,11 @@ + - @@ -58,6 +56,7 @@ + @@ -70,7 +69,6 @@ -
@@ -88,7 +86,6 @@ - From 7dc437e8aa3ff578e619089a4c756a6f1e031289 Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Tue, 20 Jan 2026 13:21:38 +0100 Subject: [PATCH 08/39] [IMP] estate: add button handlers for offer status operations Added buttons to manage offer status seamlessly. This corresponds to chapter 9 of the functional onboarding exercise. --- estate/models/estate_property.py | 17 +++++++++++++++++ estate/models/estate_property_offer.py | 12 ++++++++++++ estate/views/estate_property_offer_views.xml | 3 +++ estate/views/estate_property_views.xml | 4 ++++ 4 files changed, 36 insertions(+) diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py index 88adac28aea..1fec6989313 100644 --- a/estate/models/estate_property.py +++ b/estate/models/estate_property.py @@ -2,6 +2,7 @@ from dateutil.relativedelta import relativedelta from odoo import api, fields, models +from odoo.exceptions import UserError class EstateProperty(models.Model): @@ -60,3 +61,19 @@ def _compute_best_price(self): def _onchange_garden(self): self.garden_area = 10 if self.garden else 0 self.garden_orientation = 'north' if self.garden else None + + def action_mark_as_sold(self): + for record in self: + if record.state == 'cancelled': + raise UserError("A Cancelled property cannot be sold") + else: + record.state = 'sold' + return True + + def action_mark_as_cancelled(self): + for record in self: + if record.state == 'sold': + raise UserError("A sold property cannot be cancelled") + else: + record.state = 'cancelled' + return True diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py index f7a01b3923b..a7a20a6e01f 100644 --- a/estate/models/estate_property_offer.py +++ b/estate/models/estate_property_offer.py @@ -2,6 +2,7 @@ from dateutil.relativedelta import relativedelta from odoo import api, fields, models +from odoo.exceptions import UserError class EstatePropertyOffer(models.Model): @@ -30,3 +31,14 @@ def _inverse_date_deadline(self): for record in self: record.validity = record.date_deadline.day - record.create_date.day + def action_accept_offer(self): + for record in self: + if 'accepted' in record.property_id.offer_ids.mapped("status"): + raise UserError("Another offer has already been accepted for this property") + record.status = 'accepted' + record.property_id.buyer_id = record.partner_id + record.property_id.selling_price = record.price + + def action_refuse_offer(self): + for record in self: + record.status = 'refused' diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml index 69c2cc0786e..b74834e0150 100644 --- a/estate/views/estate_property_offer_views.xml +++ b/estate/views/estate_property_offer_views.xml @@ -9,6 +9,9 @@ + +

+ + + + + + + + + + +
diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml index eb8eff1455a..b919166eee6 100644 --- a/estate/views/estate_property_views.xml +++ b/estate/views/estate_property_views.xml @@ -4,20 +4,25 @@ Properties estate.property list,form + {'search_default_available': True} estate.property.list estate.property - + - + @@ -29,16 +34,17 @@
-

- + - + @@ -58,13 +64,13 @@ - - + + - + @@ -87,10 +93,10 @@ - + - + From 4d65f98d3bdad08d22f7b3d3325850cfac661d3a Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Wed, 21 Jan 2026 10:05:46 +0100 Subject: [PATCH 11/39] [IMP] estate: add user model connectivity Linked the properties to the salesman user and updated the user view to contain the list of relevant properties. This corresponds to chapter 12 of the functional onboarding exercise. --- estate/__manifest__.py | 1 + estate/models/__init__.py | 1 + estate/models/estate_property.py | 6 ++++++ estate/models/estate_property_offer.py | 10 ++++++++++ estate/models/res_users.py | 7 +++++++ estate/views/estate_property_views.xml | 2 +- estate/views/res_users_views.xml | 15 +++++++++++++++ 7 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 estate/models/res_users.py create mode 100644 estate/views/res_users_views.xml diff --git a/estate/__manifest__.py b/estate/__manifest__.py index 5de28c51cf3..c2eeed87740 100644 --- a/estate/__manifest__.py +++ b/estate/__manifest__.py @@ -10,6 +10,7 @@ 'views/estate_property_offer_views.xml', 'views/estate_property_views.xml', 'views/estate_menus.xml', + 'views/res_users_views.xml', 'security/ir.model.access.csv', ] } diff --git a/estate/models/__init__.py b/estate/models/__init__.py index 2f1821a39c1..9a2189b6382 100644 --- a/estate/models/__init__.py +++ b/estate/models/__init__.py @@ -2,3 +2,4 @@ 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 index 7ecc69990db..5a5ddebe7c9 100644 --- a/estate/models/estate_property.py +++ b/estate/models/estate_property.py @@ -95,3 +95,9 @@ def action_mark_as_cancelled(self): else: record.state = 'cancelled' return True + + @api.ondelete(at_uninstall=False) + def prevent_deletion_if_not_new_or_cancelled(self): + for record in self: + if record.state not in ['new', 'cancelled']: + raise UserError("Only New or Cancelled properties can be deleted") diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py index bdcc468030d..ea76c708d98 100644 --- a/estate/models/estate_property_offer.py +++ b/estate/models/estate_property_offer.py @@ -50,3 +50,13 @@ def action_accept_offer(self): def action_refuse_offer(self): for record in self: record.status = 'refused' + + def create(self, vals_list): + for vals in vals_list: + property_record = self.env["estate.property"].browse(vals["property_id"]) + if property_record.offer_ids and vals["price"] < max(property_record.offer_ids.mapped("price")): + raise UserError("Newly created offer price must not be lower than the current best offer") + else: + property_record.state = 'offer_received' + return super().create(vals_list) + diff --git a/estate/models/res_users.py b/estate/models/res_users.py new file mode 100644 index 00000000000..ad02bfe39a4 --- /dev/null +++ b/estate/models/res_users.py @@ -0,0 +1,7 @@ +from odoo import fields, models + +class ResUsers(models.Model): + _inherit = ["res.users"] + + property_ids = fields.One2many("estate.property", "salesman_id", string="Properties", + domain=['|', ('state', '=', 'new'), ('state', '=', 'offer_received')]) diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml index b919166eee6..4a1a26cd032 100644 --- a/estate/views/estate_property_views.xml +++ b/estate/views/estate_property_views.xml @@ -70,7 +70,7 @@ - + diff --git a/estate/views/res_users_views.xml b/estate/views/res_users_views.xml new file mode 100644 index 00000000000..a95c6746a54 --- /dev/null +++ b/estate/views/res_users_views.xml @@ -0,0 +1,15 @@ + + + + res.users.view.form.inherit.estate + res.users + + + + + + + + + + From 345da8021b9a8cd88f3b674c3e0c323cc382e990 Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Wed, 21 Jan 2026 11:08:06 +0100 Subject: [PATCH 12/39] [ADD] estate_account: generate invoices from sold properties Added a link module between estate and account that allows automatic generation of invoices when a property is sold through estate. This corresponds to chapter 13 of the functional onboarding exercise. --- estate/models/estate_property_offer.py | 1 + estate/models/res_users.py | 2 +- estate_account/__init__.py | 1 + estate_account/__manifest__.py | 7 ++++++ estate_account/models/__init__.py | 1 + estate_account/models/estate_property.py | 29 ++++++++++++++++++++++++ 6 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 estate_account/__init__.py create mode 100644 estate_account/__manifest__.py create mode 100644 estate_account/models/__init__.py create mode 100644 estate_account/models/estate_property.py diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py index ea76c708d98..26a6f4d0f04 100644 --- a/estate/models/estate_property_offer.py +++ b/estate/models/estate_property_offer.py @@ -44,6 +44,7 @@ def action_accept_offer(self): if 'accepted' in record.property_id.offer_ids.mapped("status"): raise UserError("Another offer has already been accepted for this property") record.status = 'accepted' + record.property_id.state = 'offer_accepted' record.property_id.buyer_id = record.partner_id record.property_id.selling_price = record.price diff --git a/estate/models/res_users.py b/estate/models/res_users.py index ad02bfe39a4..0c68d7e79ef 100644 --- a/estate/models/res_users.py +++ b/estate/models/res_users.py @@ -3,5 +3,5 @@ class ResUsers(models.Model): _inherit = ["res.users"] - property_ids = fields.One2many("estate.property", "salesman_id", string="Properties", + property_ids = fields.One2many("estate.property", "salesman_id", string="Real Estate Properties", domain=['|', ('state', '=', 'new'), ('state', '=', 'offer_received')]) 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..7bdc01a863c --- /dev/null +++ b/estate_account/__manifest__.py @@ -0,0 +1,7 @@ +{ + 'name': 'estate_account', + 'depends': [ + 'account', + 'estate', + ], +} 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..ad8bdba341c --- /dev/null +++ b/estate_account/models/estate_property.py @@ -0,0 +1,29 @@ +from odoo import Command, models + + +class EstateProperty(models.Model): + _inherit = ["estate.property"] + + def action_mark_as_sold(self): + super().action_mark_as_sold() + for record in self: + if record.state == 'cancelled': + continue + + self.env['account.move'].create({ + "partner_id": record.buyer_id.id, + "move_type": "out_invoice", + "line_ids": [ + Command.create({ + "name": "6% of selling price", + "quantity": 1, + "price_unit": 0.06 * record.selling_price + }), + Command.create({ + "name": "Administrative fees", + "quantity": 1, + "price_unit": 100 + }) + ] + }) + return True From 1c38baf62c6af166e78edcf5be593d650061b010 Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Wed, 21 Jan 2026 11:50:35 +0100 Subject: [PATCH 13/39] [IMP] estate: add kanban view to properties Added a kanban view to organize property management by type and display the relevant pricing information. This corresponds to chapter 14 of the functional onboarding exercise. --- .gitignore | 3 +++ estate/views/estate_property_views.xml | 25 ++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b6e47617de1..ca7a1f2cc5b 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ dmypy.json # Pyre type checker .pyre/ + +# VSCode config files +.vscode/ diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml index 4a1a26cd032..ace8d2abf37 100644 --- a/estate/views/estate_property_views.xml +++ b/estate/views/estate_property_views.xml @@ -3,7 +3,7 @@ Properties estate.property - list,form + list,form,kanban {'search_default_available': True} @@ -101,4 +101,27 @@ + + + estate.property.kanban + estate.property + + + + + + +
Expected Price:
+
+ Best Price: +
+
+ Selling Price: +
+ +
+
+
+
+
From 2d76645ea3d8e96a4e3ab3b14a489b3bf4b64c21 Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Wed, 21 Jan 2026 14:03:12 +0100 Subject: [PATCH 14/39] [ADD] awesome_owl: add counter and card components to playground Created new OWL components Counter and Card and connected them via props to the parent playground. This corresponds to parts 1-6 of chapter 1 of the web tutorial. --- awesome_owl/static/src/card/card.js | 11 +++++++++++ awesome_owl/static/src/card/card.xml | 11 +++++++++++ awesome_owl/static/src/counter/counter.js | 18 ++++++++++++++++++ awesome_owl/static/src/counter/counter.xml | 9 +++++++++ awesome_owl/static/src/playground.js | 20 ++++++++++++++++++-- awesome_owl/static/src/playground.xml | 19 ++++++++++++------- 6 files changed, 79 insertions(+), 9 deletions(-) create mode 100644 awesome_owl/static/src/card/card.js create mode 100644 awesome_owl/static/src/card/card.xml create mode 100644 awesome_owl/static/src/counter/counter.js create mode 100644 awesome_owl/static/src/counter/counter.xml diff --git a/awesome_owl/static/src/card/card.js b/awesome_owl/static/src/card/card.js new file mode 100644 index 00000000000..39cd854bfeb --- /dev/null +++ b/awesome_owl/static/src/card/card.js @@ -0,0 +1,11 @@ +import { Component } from "@odoo/owl"; + + +export class Card extends Component { + static template = "my_module.Card"; + + static props = { + title: {type: String}, + content: {type: String}, + } +} diff --git a/awesome_owl/static/src/card/card.xml b/awesome_owl/static/src/card/card.xml new file mode 100644 index 00000000000..9a6d789f87e --- /dev/null +++ b/awesome_owl/static/src/card/card.xml @@ -0,0 +1,11 @@ + + + +
+
+
+

+
+
+
+
diff --git a/awesome_owl/static/src/counter/counter.js b/awesome_owl/static/src/counter/counter.js new file mode 100644 index 00000000000..859aba35c68 --- /dev/null +++ b/awesome_owl/static/src/counter/counter.js @@ -0,0 +1,18 @@ +import { Component, useState } from "@odoo/owl"; + + +export class Counter extends Component { + static template = "my_module.Counter"; + static props = { + onChange: {type: Function, optional: true} + } + + setup() { + this.state = useState({ value: 0 }); + } + + increment() { + this.state.value++; + 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..5178de316d5 --- /dev/null +++ b/awesome_owl/static/src/counter/counter.xml @@ -0,0 +1,9 @@ + + + +
+

Counter:

+ +
+
+
diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js index 4ac769b0aa5..9b6e9c50f68 100644 --- a/awesome_owl/static/src/playground.js +++ b/awesome_owl/static/src/playground.js @@ -1,5 +1,21 @@ -import { Component } from "@odoo/owl"; +import { Component, markup, useState } from "@odoo/owl"; + +import { Card } from "./card/card" +import { Counter } from "./counter/counter" + export class Playground extends Component { - static template = "awesome_owl.playground"; + static template = "my_module.Playground"; + static components = { Card, Counter }; + + value1 = "
some text 1
"; + value2 = markup("
some text 2
"); + + setup() { + this.state = useState({ sum: 0 }); + } + + incrementSum() { + this.state.sum++; + } } diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml index 4fb905d59f9..399c330e20f 100644 --- a/awesome_owl/static/src/playground.xml +++ b/awesome_owl/static/src/playground.xml @@ -1,10 +1,15 @@ - - -
- hello world -
-
- + +
+

+ Hello world: + + +

+

Sum:

+
+ + +
From 3dacb416b756dfdd4b9add3500f8f0ef95773b9e Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Wed, 21 Jan 2026 16:06:39 +0100 Subject: [PATCH 15/39] [ADD] awesome_owl: add todo_list component Added a todo list composed of task as well as the necessary logic and interface needed to manage the addition, removal and completion of tasks. This corresponds to parts 7-11 of chapter 1 of the web framework tutorial. --- awesome_owl/static/src/counter/counter.xml | 12 +++--- awesome_owl/static/src/playground.js | 3 +- awesome_owl/static/src/playground.xml | 25 ++++++------ awesome_owl/static/src/todo_list/todo_item.js | 12 ++++++ .../static/src/todo_list/todo_item.xml | 13 +++++++ awesome_owl/static/src/todo_list/todo_list.js | 38 +++++++++++++++++++ .../static/src/todo_list/todo_list.xml | 11 ++++++ 7 files changed, 95 insertions(+), 19 deletions(-) create mode 100644 awesome_owl/static/src/todo_list/todo_item.js create mode 100644 awesome_owl/static/src/todo_list/todo_item.xml create mode 100644 awesome_owl/static/src/todo_list/todo_list.js create mode 100644 awesome_owl/static/src/todo_list/todo_list.xml diff --git a/awesome_owl/static/src/counter/counter.xml b/awesome_owl/static/src/counter/counter.xml index 5178de316d5..ea1829c0ff6 100644 --- a/awesome_owl/static/src/counter/counter.xml +++ b/awesome_owl/static/src/counter/counter.xml @@ -1,9 +1,9 @@ - -
-

Counter:

- -
-
+ +
+

Counter:

+ +
+
diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js index 9b6e9c50f68..9988f6a3611 100644 --- a/awesome_owl/static/src/playground.js +++ b/awesome_owl/static/src/playground.js @@ -2,11 +2,12 @@ import { Component, markup, useState } from "@odoo/owl"; import { Card } from "./card/card" import { Counter } from "./counter/counter" +import { TodoList } from "./todo_list/todo_list" export class Playground extends Component { static template = "my_module.Playground"; - static components = { Card, Counter }; + static components = { Card, Counter, TodoList }; value1 = "
some text 1
"; value2 = markup("
some text 2
"); diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml index 399c330e20f..df21d4ed34b 100644 --- a/awesome_owl/static/src/playground.xml +++ b/awesome_owl/static/src/playground.xml @@ -1,15 +1,16 @@ - -
-

- Hello world: - - -

-

Sum:

-
- - -
+ +
+

+ Hello world: + + +

+

Sum:

+
+ + + +
diff --git a/awesome_owl/static/src/todo_list/todo_item.js b/awesome_owl/static/src/todo_list/todo_item.js new file mode 100644 index 00000000000..cc1e64fe19f --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_item.js @@ -0,0 +1,12 @@ +import { Component } from "@odoo/owl"; + + +export class TodoItem extends Component { + static template = "my_module.TodoItem"; + + static props = { + todo: {type: Object, shape: {id: Number, description: String, isCompleted: Boolean }}, + toggleState: {type: Function}, + removeTodo: {type: Function} + } +} diff --git a/awesome_owl/static/src/todo_list/todo_item.xml b/awesome_owl/static/src/todo_list/todo_item.xml new file mode 100644 index 00000000000..22bbeb8a8ca --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_item.xml @@ -0,0 +1,13 @@ + + + +
+

+ + . + + +

+
+
+
diff --git a/awesome_owl/static/src/todo_list/todo_list.js b/awesome_owl/static/src/todo_list/todo_list.js new file mode 100644 index 00000000000..70f426ac786 --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_list.js @@ -0,0 +1,38 @@ +import { Component, onMounted, useRef, useState } from "@odoo/owl"; + +import { TodoItem } from "./todo_item" + + +export class TodoList extends Component { + static template = "my_module.TodoList"; + static components = { TodoItem }; + + setup() { + this.state = useState({ todos: [] }); + this.taskCounter = 0; + + this.inputRef = useRef('todo_list_input'); + onMounted(() => { + this.inputRef.el.focus(); + }); + } + + addTodo(ev) { + if (ev.keyCode === 13 && ev.srcElement.value != "") { + this.state.todos.push({id: this.taskCounter++, description: ev.srcElement.value, isCompleted: false}); + ev.srcElement.value = ""; + } + } + + toggleTaskCompletion(toggled_item_id) { + var toggled_item = this.state.todos.find(todo_item => todo_item.id === toggled_item_id); + toggled_item.isCompleted = !toggled_item.isCompleted; + } + + removeTask(task_id) { + const removal_index = this.state.todos.findIndex((todo_item) => todo_item.id === task_id); + if (removal_index >= 0) { + this.state.todos.splice(removal_index, 1); + } + } +} diff --git a/awesome_owl/static/src/todo_list/todo_list.xml b/awesome_owl/static/src/todo_list/todo_list.xml new file mode 100644 index 00000000000..a2a958a0c9b --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_list.xml @@ -0,0 +1,11 @@ + + + +
+ +

+ +

+
+
+
From 01cd97aaa184bbd8ed3db42a992bdde50b5f0664 Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Wed, 21 Jan 2026 16:26:09 +0100 Subject: [PATCH 16/39] [IMP] awesome_owl: enable using templates in card body Allows the card body to contain template components and added a button to toggle visibility of the body. This corresponds to parts 13-14 of chapter 1 of the web framework tutorial. --- awesome_owl/static/src/card/card.js | 12 ++++++++++-- awesome_owl/static/src/card/card.xml | 7 ++++--- awesome_owl/static/src/playground.xml | 14 +++++++++++--- awesome_owl/static/src/todo_list/todo_item.js | 2 +- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/awesome_owl/static/src/card/card.js b/awesome_owl/static/src/card/card.js index 39cd854bfeb..f5ae82ee602 100644 --- a/awesome_owl/static/src/card/card.js +++ b/awesome_owl/static/src/card/card.js @@ -1,4 +1,4 @@ -import { Component } from "@odoo/owl"; +import { Component, useState } from "@odoo/owl"; export class Card extends Component { @@ -6,6 +6,14 @@ export class Card extends Component { static props = { title: {type: String}, - content: {type: String}, + slots: {type: Object, optional: true} + } + + setup() { + this.state = useState({ isOpen: true }); + } + + toggleCardBody() { + this.state.isOpen = !this.state.isOpen; } } diff --git a/awesome_owl/static/src/card/card.xml b/awesome_owl/static/src/card/card.xml index 9a6d789f87e..089392276db 100644 --- a/awesome_owl/static/src/card/card.xml +++ b/awesome_owl/static/src/card/card.xml @@ -2,9 +2,10 @@
-
-
-

+ +
+
+

diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml index df21d4ed34b..4d956e29eb8 100644 --- a/awesome_owl/static/src/playground.xml +++ b/awesome_owl/static/src/playground.xml @@ -9,8 +9,16 @@

Sum:

- - - + + + + + + + + + + +
diff --git a/awesome_owl/static/src/todo_list/todo_item.js b/awesome_owl/static/src/todo_list/todo_item.js index cc1e64fe19f..01c25864c37 100644 --- a/awesome_owl/static/src/todo_list/todo_item.js +++ b/awesome_owl/static/src/todo_list/todo_item.js @@ -5,7 +5,7 @@ export class TodoItem extends Component { static template = "my_module.TodoItem"; static props = { - todo: {type: Object, shape: {id: Number, description: String, isCompleted: Boolean }}, + todo: {type: Object, shape: {id: Number, description: String, isCompleted: Boolean}}, toggleState: {type: Function}, removeTodo: {type: Function} } From 93b879cd5d37492212364176a6e841790b67f2d4 Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Thu, 22 Jan 2026 09:39:45 +0100 Subject: [PATCH 17/39] [LINT] estate, awesome_owl: fix code formatting Fixed a couple of formatting issues to make the code more readable. --- awesome_owl/static/src/card/card.js | 4 ++-- awesome_owl/static/src/counter/counter.js | 2 +- awesome_owl/static/src/todo_list/todo_item.js | 6 +++--- estate/models/estate_property.py | 6 +++++- estate/models/res_users.py | 8 ++++++-- 5 files changed, 17 insertions(+), 9 deletions(-) diff --git a/awesome_owl/static/src/card/card.js b/awesome_owl/static/src/card/card.js index f5ae82ee602..7a6293d0634 100644 --- a/awesome_owl/static/src/card/card.js +++ b/awesome_owl/static/src/card/card.js @@ -5,8 +5,8 @@ export class Card extends Component { static template = "my_module.Card"; static props = { - title: {type: String}, - slots: {type: Object, optional: true} + title: { type: String }, + slots: { type: Object, optional: true } } setup() { diff --git a/awesome_owl/static/src/counter/counter.js b/awesome_owl/static/src/counter/counter.js index 859aba35c68..fb8427f10ec 100644 --- a/awesome_owl/static/src/counter/counter.js +++ b/awesome_owl/static/src/counter/counter.js @@ -4,7 +4,7 @@ import { Component, useState } from "@odoo/owl"; export class Counter extends Component { static template = "my_module.Counter"; static props = { - onChange: {type: Function, optional: true} + onChange: { type: Function, optional: true } } setup() { diff --git a/awesome_owl/static/src/todo_list/todo_item.js b/awesome_owl/static/src/todo_list/todo_item.js index 01c25864c37..e414a72032d 100644 --- a/awesome_owl/static/src/todo_list/todo_item.js +++ b/awesome_owl/static/src/todo_list/todo_item.js @@ -5,8 +5,8 @@ export class TodoItem extends Component { static template = "my_module.TodoItem"; static props = { - todo: {type: Object, shape: {id: Number, description: String, isCompleted: Boolean}}, - toggleState: {type: Function}, - removeTodo: {type: Function} + todo: { type: Object, shape: {id: Number, description: String, isCompleted: Boolean} }, + toggleState: { type: Function }, + removeTodo: { type: Function } } } diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py index 5a5ddebe7c9..9ab5adfbbb7 100644 --- a/estate/models/estate_property.py +++ b/estate/models/estate_property.py @@ -32,7 +32,11 @@ class EstateProperty(models.Model): ] ) active = fields.Boolean(default=True) - state = fields.Selection(required=True, default="new", copy=False, string="status", + state = fields.Selection( + required=True, + default="new", + copy=False, + string="status", selection=[ ('new', 'New'), ('offer_received', 'Offer Received'), diff --git a/estate/models/res_users.py b/estate/models/res_users.py index 0c68d7e79ef..ecae1ef0fa1 100644 --- a/estate/models/res_users.py +++ b/estate/models/res_users.py @@ -3,5 +3,9 @@ class ResUsers(models.Model): _inherit = ["res.users"] - property_ids = fields.One2many("estate.property", "salesman_id", string="Real Estate Properties", - domain=['|', ('state', '=', 'new'), ('state', '=', 'offer_received')]) + property_ids = fields.One2many( + "estate.property", + "salesman_id", + string="Real Estate Properties", + domain=['|', ('state', '=', 'new'), ('state', '=', 'offer_received')] + ) From b742565b98d32192b2f8facf7ee712cd13ee4a47 Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Thu, 22 Jan 2026 11:23:54 +0100 Subject: [PATCH 18/39] [ADD] awesome_dashboard: add order statistics dashboard Created a dashboard displaying order statistics to centralize information. The statistics are cached to avoid duplicating rpc calls when goind back to the page (e.g. using breadcrumbs and such). This corresponds to parts 1-5 of chapter 2 of the web framework tutorial. --- awesome_dashboard/static/src/dashboard.js | 38 ++++++++++++++++++- awesome_dashboard/static/src/dashboard.scss | 7 ++++ awesome_dashboard/static/src/dashboard.xml | 18 ++++++++- .../src/dashboard_item/dashboard_item.js | 14 +++++++ .../src/dashboard_item/dashboard_item.scss | 8 ++++ .../src/dashboard_item/dashboard_item.xml | 8 ++++ .../static/src/statistics_service.js | 17 +++++++++ 7 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 awesome_dashboard/static/src/dashboard.scss create mode 100644 awesome_dashboard/static/src/dashboard_item/dashboard_item.js create mode 100644 awesome_dashboard/static/src/dashboard_item/dashboard_item.scss create mode 100644 awesome_dashboard/static/src/dashboard_item/dashboard_item.xml create mode 100644 awesome_dashboard/static/src/statistics_service.js diff --git a/awesome_dashboard/static/src/dashboard.js b/awesome_dashboard/static/src/dashboard.js index c4fb245621b..fa2a995bde4 100644 --- a/awesome_dashboard/static/src/dashboard.js +++ b/awesome_dashboard/static/src/dashboard.js @@ -1,8 +1,44 @@ -import { Component } from "@odoo/owl"; +import { Component, onWillStart } from "@odoo/owl"; import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { Layout } from "@web/search/layout"; + +import { DashboardItem } from "./dashboard_item/dashboard_item"; + class AwesomeDashboard extends Component { static template = "awesome_dashboard.AwesomeDashboard"; + static components = { DashboardItem, Layout }; + + setup() { + this.action_service = useService("action"); + this.statistics_service = useService("awesome_dashboard.statistics"); + + onWillStart(async () => { + const raw_stats = await this.statistics_service.loadStatistics(); + this.stats = [ + { id: 0, description: "Number of new orders this month", value: raw_stats.nb_new_orders }, + { id: 1, description: "Total amount of new orders this month", value: raw_stats.total_amount }, + { id: 2, description: "Average amount of t-shirt by order this month", value: raw_stats.average_quantity, size: 2 }, + { id: 3, description: "Number of cancelled orders this month", value: raw_stats.nb_cancelled_orders }, + { id: 4, description: "Average time for an order to go from 'new' to 'sent' or 'cancelled'", value: raw_stats.average_time, size: 2 }, + ] + }) + } + + openPartnerKanbanView() { + this.action_service.doAction("base.action_partner_form"); + } + + openCrmLeads() { + this.action_service.doAction({ + type: 'ir.actions.act_window', + name: 'CRM leads', + target: 'current', + res_model: 'crm.lead', + views: [[false, 'list'], [false, 'form']], + }); + } } registry.category("actions").add("awesome_dashboard.dashboard", AwesomeDashboard); diff --git a/awesome_dashboard/static/src/dashboard.scss b/awesome_dashboard/static/src/dashboard.scss new file mode 100644 index 00000000000..428638b831a --- /dev/null +++ b/awesome_dashboard/static/src/dashboard.scss @@ -0,0 +1,7 @@ +.o_dashboard { + background-color: grey; +} + +.btn-primary { + margin-inline: 5px; +} diff --git a/awesome_dashboard/static/src/dashboard.xml b/awesome_dashboard/static/src/dashboard.xml index 1a2ac9a2fed..c23b8c84a45 100644 --- a/awesome_dashboard/static/src/dashboard.xml +++ b/awesome_dashboard/static/src/dashboard.xml @@ -2,7 +2,23 @@ - hello dashboard + + + + + + + +
+ + +
+ +
+
+
+
+
diff --git a/awesome_dashboard/static/src/dashboard_item/dashboard_item.js b/awesome_dashboard/static/src/dashboard_item/dashboard_item.js new file mode 100644 index 00000000000..291db20c103 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_item/dashboard_item.js @@ -0,0 +1,14 @@ +import { Component } from "@odoo/owl"; + + +export class DashboardItem extends Component { + static template = "awesome_dashboard.DashboardItem"; + static props = { + size: { type: Number, optional: true }, + slots: { type: Object, optional: true }, + }; + + static defaultProps = { + size: 1, + }; +} diff --git a/awesome_dashboard/static/src/dashboard_item/dashboard_item.scss b/awesome_dashboard/static/src/dashboard_item/dashboard_item.scss new file mode 100644 index 00000000000..8321919cc44 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_item/dashboard_item.scss @@ -0,0 +1,8 @@ +.o_dashboard_item { + border: 2px solid black; + background-color: white; + color: black; + margin: 10px; + padding: 10px; + text-align: center; +} diff --git a/awesome_dashboard/static/src/dashboard_item/dashboard_item.xml b/awesome_dashboard/static/src/dashboard_item/dashboard_item.xml new file mode 100644 index 00000000000..c2d2041dbeb --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_item/dashboard_item.xml @@ -0,0 +1,8 @@ + + + +
+ +
+
+
diff --git a/awesome_dashboard/static/src/statistics_service.js b/awesome_dashboard/static/src/statistics_service.js new file mode 100644 index 00000000000..f8b8143d19d --- /dev/null +++ b/awesome_dashboard/static/src/statistics_service.js @@ -0,0 +1,17 @@ +import { rpc } from "@web/core/network/rpc"; +import { registry } from "@web/core/registry"; +import { memoize } from "@web/core/utils/functions"; + + +async function loadStatistics() { + return await rpc("/awesome_dashboard/statistics"); +} + +const statisticsService = { + start() { + loadStatistics = memoize(loadStatistics); + return { loadStatistics }; + }, +}; + +registry.category("services").add("awesome_dashboard.statistics", statisticsService); From d3085d0026b1e9d05dc240b4e2fd8a25d9be198d Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Thu, 22 Jan 2026 13:36:54 +0100 Subject: [PATCH 19/39] [ADD] awesome_dashboard: add pie charts to dashboard Added a pie chart component to display charts on the dashboard. This corresponds to part 6 of chapter 2 of the web framework tutorial. --- awesome_dashboard/static/src/dashboard.js | 4 +- awesome_dashboard/static/src/dashboard.xml | 18 +++++--- .../static/src/pie_chart/pie_chart.js | 46 +++++++++++++++++++ .../static/src/pie_chart/pie_chart.xml | 6 +++ 4 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 awesome_dashboard/static/src/pie_chart/pie_chart.js create mode 100644 awesome_dashboard/static/src/pie_chart/pie_chart.xml diff --git a/awesome_dashboard/static/src/dashboard.js b/awesome_dashboard/static/src/dashboard.js index fa2a995bde4..55389c197fd 100644 --- a/awesome_dashboard/static/src/dashboard.js +++ b/awesome_dashboard/static/src/dashboard.js @@ -4,11 +4,12 @@ import { useService } from "@web/core/utils/hooks"; import { Layout } from "@web/search/layout"; import { DashboardItem } from "./dashboard_item/dashboard_item"; +import { PieChart } from "./pie_chart/pie_chart"; class AwesomeDashboard extends Component { static template = "awesome_dashboard.AwesomeDashboard"; - static components = { DashboardItem, Layout }; + static components = { DashboardItem, Layout, PieChart }; setup() { this.action_service = useService("action"); @@ -16,6 +17,7 @@ class AwesomeDashboard extends Component { onWillStart(async () => { const raw_stats = await this.statistics_service.loadStatistics(); + this.size_pie_chart_data = raw_stats.orders_by_size; this.stats = [ { id: 0, description: "Number of new orders this month", value: raw_stats.nb_new_orders }, { id: 1, description: "Total amount of new orders this month", value: raw_stats.total_amount }, diff --git a/awesome_dashboard/static/src/dashboard.xml b/awesome_dashboard/static/src/dashboard.xml index c23b8c84a45..202ccd5d06e 100644 --- a/awesome_dashboard/static/src/dashboard.xml +++ b/awesome_dashboard/static/src/dashboard.xml @@ -10,14 +10,20 @@
- - -
- -
-
+ + +
+ +
+
+ + + + + + diff --git a/awesome_dashboard/static/src/pie_chart/pie_chart.js b/awesome_dashboard/static/src/pie_chart/pie_chart.js new file mode 100644 index 00000000000..ece50301092 --- /dev/null +++ b/awesome_dashboard/static/src/pie_chart/pie_chart.js @@ -0,0 +1,46 @@ +import { Component, onWillStart, onWillUnmount, useEffect, useRef } from "@odoo/owl"; +import { loadJS } from "@web/core/assets"; + + +export class PieChart extends Component { + static template = "awesome_dashboard.PieChart"; + + static props = { + chart_data: {type: Object}, + }; + + setup() { + this.canvasRef = useRef("pie_chart_canvas"); + this.chart = null; + + onWillStart(() => loadJS(["/web/static/lib/Chart/Chart.js"])); + + useEffect(() => this.renderChart()); + onWillUnmount(() => this.destroyChart()); + } + + renderChart() { + this.destroyChart(); + this.chart = new Chart( + this.canvasRef.el, + { + type: "pie", + data: { + labels: Object.keys(this.props.chart_data), + datasets: [ + { + label: "orders_by_size", + data: Object.values(this.props.chart_data), + }, + ], + }, + }, + ); + } + + destroyChart() { + if (this.chart) { + this.chart.destroy(); + } + } +} diff --git a/awesome_dashboard/static/src/pie_chart/pie_chart.xml b/awesome_dashboard/static/src/pie_chart/pie_chart.xml new file mode 100644 index 00000000000..2f6c0fc9fec --- /dev/null +++ b/awesome_dashboard/static/src/pie_chart/pie_chart.xml @@ -0,0 +1,6 @@ + + + + + + From 970353cea6929ef0c6e285bd1eba35ed0bc29769 Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Thu, 22 Jan 2026 14:55:20 +0100 Subject: [PATCH 20/39] [IMP] awesome_dashboard: update dashboard in real time Dashboard data now updates periodically to keep the information up to date, changes occuring when the user is tabbed in to the dashboard now update the relevant fields on each component. This corresponds to part 7 of chapter 2 of the web framework tutorial. --- awesome_dashboard/static/src/dashboard.js | 24 ++++++++++--------- awesome_dashboard/static/src/dashboard.xml | 4 ++-- .../static/src/statistics_service.js | 18 ++++++++------ 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/awesome_dashboard/static/src/dashboard.js b/awesome_dashboard/static/src/dashboard.js index 55389c197fd..4c18c40115f 100644 --- a/awesome_dashboard/static/src/dashboard.js +++ b/awesome_dashboard/static/src/dashboard.js @@ -1,4 +1,4 @@ -import { Component, onWillStart } from "@odoo/owl"; +import { Component, onWillRender, useState } from "@odoo/owl"; import { registry } from "@web/core/registry"; import { useService } from "@web/core/utils/hooks"; import { Layout } from "@web/search/layout"; @@ -13,18 +13,20 @@ class AwesomeDashboard extends Component { setup() { this.action_service = useService("action"); - this.statistics_service = useService("awesome_dashboard.statistics"); + this.raw_stats = useState(useService("awesome_dashboard.statistics")); + this.stats = []; - onWillStart(async () => { - const raw_stats = await this.statistics_service.loadStatistics(); - this.size_pie_chart_data = raw_stats.orders_by_size; + // Before rendering the dashboard items we want to ensure the values are up to date with the latest update from the statistics service. + // This would be unnecessary if I pulled values directly from the stateful component 'raw_stats' in dashboard.xml, I'm just messing around + // with the hooks to use a data structure defined outside of the statistics service which manages this component's state. + onWillRender(() => { this.stats = [ - { id: 0, description: "Number of new orders this month", value: raw_stats.nb_new_orders }, - { id: 1, description: "Total amount of new orders this month", value: raw_stats.total_amount }, - { id: 2, description: "Average amount of t-shirt by order this month", value: raw_stats.average_quantity, size: 2 }, - { id: 3, description: "Number of cancelled orders this month", value: raw_stats.nb_cancelled_orders }, - { id: 4, description: "Average time for an order to go from 'new' to 'sent' or 'cancelled'", value: raw_stats.average_time, size: 2 }, - ] + { id: 0, description: "Number of new orders this month", value: this.raw_stats.nb_new_orders }, + { id: 1, description: "Total amount of new orders this month", value: this.raw_stats.total_amount }, + { id: 2, description: "Average amount of t-shirt by order this month", value: this.raw_stats.average_quantity, size: 2 }, + { id: 3, description: "Number of cancelled orders this month", value: this.raw_stats.nb_cancelled_orders }, + { id: 4, description: "Average time for an order to go from 'new' to 'sent' or 'cancelled'", value: this.raw_stats.average_time, size: 2 }, + ]; }) } diff --git a/awesome_dashboard/static/src/dashboard.xml b/awesome_dashboard/static/src/dashboard.xml index 202ccd5d06e..2653e360b7e 100644 --- a/awesome_dashboard/static/src/dashboard.xml +++ b/awesome_dashboard/static/src/dashboard.xml @@ -19,9 +19,9 @@ - + - + diff --git a/awesome_dashboard/static/src/statistics_service.js b/awesome_dashboard/static/src/statistics_service.js index f8b8143d19d..98330748e51 100644 --- a/awesome_dashboard/static/src/statistics_service.js +++ b/awesome_dashboard/static/src/statistics_service.js @@ -1,16 +1,20 @@ +import { reactive } from "@odoo/owl"; import { rpc } from "@web/core/network/rpc"; import { registry } from "@web/core/registry"; -import { memoize } from "@web/core/utils/functions"; -async function loadStatistics() { - return await rpc("/awesome_dashboard/statistics"); -} - const statisticsService = { start() { - loadStatistics = memoize(loadStatistics); - return { loadStatistics }; + let statistics = reactive({ isReady: false }); + + async function loadStatistics() { + let updated_stats = await rpc("/awesome_dashboard/statistics"); + Object.assign(statistics, updated_stats, { isReady: true }); + } + + setInterval(loadStatistics, 10 * 60 * 1000); + loadStatistics(); + return statistics; }, }; From 0773df9a186ab966fb6cdc349f23ac5659708ab9 Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Thu, 22 Jan 2026 15:52:11 +0100 Subject: [PATCH 21/39] [IMP] awesome_dashboard: make dashboard generic Moved all hardcoded values to dashboard_items.js to make the dashboard and its components more generic and reusable. This corresponds to part 9 of chapter 2 of the web framework tutorial. --- awesome_dashboard/static/src/dashboard.js | 23 ++----- awesome_dashboard/static/src/dashboard.xml | 16 ++--- .../static/src/dashboard_items.js | 62 +++++++++++++++++++ .../static/src/number_card/number_card.js | 11 ++++ .../static/src/number_card/number_card.xml | 7 +++ .../static/src/pie_chart/pie_chart.js | 7 +-- .../src/pie_chart_card/pie_chart_card.js | 14 +++++ .../src/pie_chart_card/pie_chart_card.xml | 7 +++ 8 files changed, 111 insertions(+), 36 deletions(-) create mode 100644 awesome_dashboard/static/src/dashboard_items.js create mode 100644 awesome_dashboard/static/src/number_card/number_card.js create mode 100644 awesome_dashboard/static/src/number_card/number_card.xml create mode 100644 awesome_dashboard/static/src/pie_chart_card/pie_chart_card.js create mode 100644 awesome_dashboard/static/src/pie_chart_card/pie_chart_card.xml diff --git a/awesome_dashboard/static/src/dashboard.js b/awesome_dashboard/static/src/dashboard.js index 4c18c40115f..cdf3ed62ad7 100644 --- a/awesome_dashboard/static/src/dashboard.js +++ b/awesome_dashboard/static/src/dashboard.js @@ -1,33 +1,20 @@ -import { Component, onWillRender, useState } from "@odoo/owl"; +import { Component, useState } from "@odoo/owl"; import { registry } from "@web/core/registry"; import { useService } from "@web/core/utils/hooks"; import { Layout } from "@web/search/layout"; import { DashboardItem } from "./dashboard_item/dashboard_item"; -import { PieChart } from "./pie_chart/pie_chart"; +import { items } from "./dashboard_items"; class AwesomeDashboard extends Component { static template = "awesome_dashboard.AwesomeDashboard"; - static components = { DashboardItem, Layout, PieChart }; + static components = { DashboardItem, Layout }; setup() { this.action_service = useService("action"); - this.raw_stats = useState(useService("awesome_dashboard.statistics")); - this.stats = []; - - // Before rendering the dashboard items we want to ensure the values are up to date with the latest update from the statistics service. - // This would be unnecessary if I pulled values directly from the stateful component 'raw_stats' in dashboard.xml, I'm just messing around - // with the hooks to use a data structure defined outside of the statistics service which manages this component's state. - onWillRender(() => { - this.stats = [ - { id: 0, description: "Number of new orders this month", value: this.raw_stats.nb_new_orders }, - { id: 1, description: "Total amount of new orders this month", value: this.raw_stats.total_amount }, - { id: 2, description: "Average amount of t-shirt by order this month", value: this.raw_stats.average_quantity, size: 2 }, - { id: 3, description: "Number of cancelled orders this month", value: this.raw_stats.nb_cancelled_orders }, - { id: 4, description: "Average time for an order to go from 'new' to 'sent' or 'cancelled'", value: this.raw_stats.average_time, size: 2 }, - ]; - }) + this.statistics = useState(useService("awesome_dashboard.statistics")); + this.items = items; } openPartnerKanbanView() { diff --git a/awesome_dashboard/static/src/dashboard.xml b/awesome_dashboard/static/src/dashboard.xml index 2653e360b7e..9dadc84c4c1 100644 --- a/awesome_dashboard/static/src/dashboard.xml +++ b/awesome_dashboard/static/src/dashboard.xml @@ -8,22 +8,14 @@ - +
- - -
- -
+ + +
- - - - - -
diff --git a/awesome_dashboard/static/src/dashboard_items.js b/awesome_dashboard/static/src/dashboard_items.js new file mode 100644 index 00000000000..00f073f3368 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_items.js @@ -0,0 +1,62 @@ +import { NumberCard } from "./number_card/number_card"; +import { PieChartCard } from "./pie_chart_card/pie_chart_card"; + + +export const items = [ + { + id: "nb_new_orders", + description: "Number of new orders", + Component: NumberCard, + props: (data) => ({ + title: "Number of new orders this month", + value: data.nb_new_orders, + }), + }, + { + id: "total_amount", + description: "Total number of orders", + Component: NumberCard, + props: (data) => ({ + title: "Total amount of new orders this month", + value: data.total_amount, + }), + }, + { + id: "average_quantity", + description: "Average amount by order", + Component: NumberCard, + size: 2, + props: (data) => ({ + title: "Average amount of t-shirt by order this month", + value: data.average_quantity, + }), + }, + { + id: "nb_cancelled_orders", + description: "Number of cancelled orders", + Component: NumberCard, + props: (data) => ({ + title: "Number of cancelled orders this month", + value: data.nb_cancelled_orders, + }), + }, + { + id: "average_time", + description: "Average time to ship/cancel", + Component: NumberCard, + size: 2, + props: (data) => ({ + title: "Average time for an order to go from 'new' to 'sent' or 'cancelled'", + value: data.average_time, + }), + }, + { + id: "orders_by_size", + description: "Pie chart of the number of orders by size", + Component: PieChartCard, + props: (data) => ({ + title: "Number of orders by size", + value: data.orders_by_size, + }), + }, +] diff --git a/awesome_dashboard/static/src/number_card/number_card.js b/awesome_dashboard/static/src/number_card/number_card.js new file mode 100644 index 00000000000..8c709fa1c7e --- /dev/null +++ b/awesome_dashboard/static/src/number_card/number_card.js @@ -0,0 +1,11 @@ +import { Component } from "@odoo/owl"; + + +export class NumberCard extends Component { + static template = "awesome_dashboard.NumberCard"; + + static props = { + title: { type: String }, + value: { type: Number }, + }; +} diff --git a/awesome_dashboard/static/src/number_card/number_card.xml b/awesome_dashboard/static/src/number_card/number_card.xml new file mode 100644 index 00000000000..fb256813ea7 --- /dev/null +++ b/awesome_dashboard/static/src/number_card/number_card.xml @@ -0,0 +1,7 @@ + + + +
+ +
+
diff --git a/awesome_dashboard/static/src/pie_chart/pie_chart.js b/awesome_dashboard/static/src/pie_chart/pie_chart.js index ece50301092..e42cb396484 100644 --- a/awesome_dashboard/static/src/pie_chart/pie_chart.js +++ b/awesome_dashboard/static/src/pie_chart/pie_chart.js @@ -27,12 +27,7 @@ export class PieChart extends Component { type: "pie", data: { labels: Object.keys(this.props.chart_data), - datasets: [ - { - label: "orders_by_size", - data: Object.values(this.props.chart_data), - }, - ], + datasets: [{ data: Object.values(this.props.chart_data) }], }, }, ); diff --git a/awesome_dashboard/static/src/pie_chart_card/pie_chart_card.js b/awesome_dashboard/static/src/pie_chart_card/pie_chart_card.js new file mode 100644 index 00000000000..e06a38b4b01 --- /dev/null +++ b/awesome_dashboard/static/src/pie_chart_card/pie_chart_card.js @@ -0,0 +1,14 @@ +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 }, + value: { type: Number }, + }; +} diff --git a/awesome_dashboard/static/src/pie_chart_card/pie_chart_card.xml b/awesome_dashboard/static/src/pie_chart_card/pie_chart_card.xml new file mode 100644 index 00000000000..67b5bde3e7b --- /dev/null +++ b/awesome_dashboard/static/src/pie_chart_card/pie_chart_card.xml @@ -0,0 +1,7 @@ + + + +
+ +
+
From b43d4b99c5b5feaa046391bc5a2fade5da206a8c Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Fri, 23 Jan 2026 09:24:18 +0100 Subject: [PATCH 22/39] [IMP] awesome_dashboard: make dashboard customizable Moved the dashboard items to a registry to facilitate extending the dashboard from other addons. Added configuration options to hide items, hidden items are saved in browser storage to make sure the configuration persists when reloading the page. This corresponds to parts 10-11 of chapter 2 of the web framework tutorial. --- .../static/src/config_dialog/config_dialog.js | 20 +++++++++++++++++++ .../src/config_dialog/config_dialog.xml | 14 +++++++++++++ awesome_dashboard/static/src/dashboard.js | 13 ++++++++++-- awesome_dashboard/static/src/dashboard.xml | 3 ++- .../static/src/dashboard_items.js | 6 +++++- 5 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 awesome_dashboard/static/src/config_dialog/config_dialog.js create mode 100644 awesome_dashboard/static/src/config_dialog/config_dialog.xml diff --git a/awesome_dashboard/static/src/config_dialog/config_dialog.js b/awesome_dashboard/static/src/config_dialog/config_dialog.js new file mode 100644 index 00000000000..f93e1946961 --- /dev/null +++ b/awesome_dashboard/static/src/config_dialog/config_dialog.js @@ -0,0 +1,20 @@ +import { Component } from "@odoo/owl"; +import { browser } from "@web/core/browser/browser"; +import { CheckBox } from "@web/core/checkbox/checkbox"; +import { Dialog } from "@web/core/dialog/dialog"; + + +export class ConfigDialog extends Component { + static template = "awesome_dashboard.ConfigDialog"; + static components = { Dialog, CheckBox }; + + static props = { + items: { type: Object }, + }; + + applyConfig() { + let hidden_item_ids = this.props.items.filter(item => !item.visible).map(item => item.id); + browser.localStorage.setItem("hidden_item_ids", hidden_item_ids); + this.props.close(); + } +} diff --git a/awesome_dashboard/static/src/config_dialog/config_dialog.xml b/awesome_dashboard/static/src/config_dialog/config_dialog.xml new file mode 100644 index 00000000000..3208d217d64 --- /dev/null +++ b/awesome_dashboard/static/src/config_dialog/config_dialog.xml @@ -0,0 +1,14 @@ + + + + + Click on a card to toggle its visibility + + + + + + + + + diff --git a/awesome_dashboard/static/src/dashboard.js b/awesome_dashboard/static/src/dashboard.js index cdf3ed62ad7..11eea6cf125 100644 --- a/awesome_dashboard/static/src/dashboard.js +++ b/awesome_dashboard/static/src/dashboard.js @@ -1,10 +1,11 @@ import { Component, useState } from "@odoo/owl"; +import { browser } from "@web/core/browser/browser"; import { registry } from "@web/core/registry"; import { useService } from "@web/core/utils/hooks"; import { Layout } from "@web/search/layout"; import { DashboardItem } from "./dashboard_item/dashboard_item"; -import { items } from "./dashboard_items"; +import { ConfigDialog } from "./config_dialog/config_dialog"; class AwesomeDashboard extends Component { @@ -14,7 +15,11 @@ class AwesomeDashboard extends Component { setup() { this.action_service = useService("action"); this.statistics = useState(useService("awesome_dashboard.statistics")); - this.items = items; + this.dialog_service = useService("dialog"); + + this.items = useState(registry.category("awesome_dashboard").getAll()); + let hidden_item_ids = browser.localStorage.getItem("hidden_item_ids").split(","); + this.items.forEach(item => Object.assign(item, { visible: !hidden_item_ids.includes(item.id) })); } openPartnerKanbanView() { @@ -30,6 +35,10 @@ class AwesomeDashboard extends Component { views: [[false, 'list'], [false, 'form']], }); } + + openDashboardSettings() { + this.dialog_service.add(ConfigDialog, { items: this.items }); + } } registry.category("actions").add("awesome_dashboard.dashboard", AwesomeDashboard); diff --git a/awesome_dashboard/static/src/dashboard.xml b/awesome_dashboard/static/src/dashboard.xml index 9dadc84c4c1..cb9333920d7 100644 --- a/awesome_dashboard/static/src/dashboard.xml +++ b/awesome_dashboard/static/src/dashboard.xml @@ -4,13 +4,14 @@ +
- + diff --git a/awesome_dashboard/static/src/dashboard_items.js b/awesome_dashboard/static/src/dashboard_items.js index 00f073f3368..da2e6565f8a 100644 --- a/awesome_dashboard/static/src/dashboard_items.js +++ b/awesome_dashboard/static/src/dashboard_items.js @@ -1,8 +1,10 @@ +import { registry } from "@web/core/registry"; + import { NumberCard } from "./number_card/number_card"; import { PieChartCard } from "./pie_chart_card/pie_chart_card"; -export const items = [ +const items = [ { id: "nb_new_orders", description: "Number of new orders", @@ -60,3 +62,5 @@ export const items = [ }), }, ] + +items.forEach(item => registry.category("awesome_dashboard").add(item.id, item)) From 591db8ceb4275a8256f4736121adab5af222684f Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Fri, 23 Jan 2026 11:40:31 +0100 Subject: [PATCH 23/39] [ADD] awesome_clicker: create clicker game interface Added a clicker game than can be accessed from the systray. Features include: - A systray item tracking your clicks anywhere in Odoo - A pop-over menu with the full game interface accessed from the systray - A clicker service centralizing all gamestate data - The ability to purchase clickBots to automate click farming - Various UI improvements to the game menu This corresponds to parts 1-9 of chapter 1 of the 'master the web framework' tutorial. --- .../static/src/click_value/click_value.js | 17 +++++++++++ .../static/src/click_value/click_value.xml | 8 ++++++ awesome_clicker/static/src/clicker_model.js | 25 +++++++++++++++++ awesome_clicker/static/src/clicker_service.js | 17 +++++++++++ .../clicker_systray_item.js | 28 +++++++++++++++++++ .../clicker_systray_item.xml | 9 ++++++ .../static/src/client_action/client_action.js | 17 +++++++++++ .../src/client_action/client_action.xml | 20 +++++++++++++ 8 files changed, 141 insertions(+) create mode 100644 awesome_clicker/static/src/click_value/click_value.js create mode 100644 awesome_clicker/static/src/click_value/click_value.xml create mode 100644 awesome_clicker/static/src/clicker_model.js create mode 100644 awesome_clicker/static/src/clicker_service.js create mode 100644 awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.js create mode 100644 awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.xml create mode 100644 awesome_clicker/static/src/client_action/client_action.js create mode 100644 awesome_clicker/static/src/client_action/client_action.xml diff --git a/awesome_clicker/static/src/click_value/click_value.js b/awesome_clicker/static/src/click_value/click_value.js new file mode 100644 index 00000000000..a54ddab5ce1 --- /dev/null +++ b/awesome_clicker/static/src/click_value/click_value.js @@ -0,0 +1,17 @@ +import { Component } from "@odoo/owl"; +import { humanNumber } from "@web/core/utils/numbers"; + +import { useClicker } from "../clicker_service"; + + +export class ClickValue extends Component { + static template = "awesome_clicker.ClickValue"; + + setup() { + this.clicker = useClicker(); + } + + getClickValueDisplay() { + return humanNumber(this.clicker.clicks, { decimals: 1 }); + } +} diff --git a/awesome_clicker/static/src/click_value/click_value.xml b/awesome_clicker/static/src/click_value/click_value.xml new file mode 100644 index 00000000000..63daa1fd995 --- /dev/null +++ b/awesome_clicker/static/src/click_value/click_value.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/awesome_clicker/static/src/clicker_model.js b/awesome_clicker/static/src/clicker_model.js new file mode 100644 index 00000000000..c56ae162e20 --- /dev/null +++ b/awesome_clicker/static/src/clicker_model.js @@ -0,0 +1,25 @@ +import { Reactive } from "@web/core/utils/reactive"; + + +export class ClickerModel extends Reactive { + + constructor() { + super(); + + this.clicks = 0; + this.level = 1; + this.clickBots = 0; + + document.addEventListener("click", () => this.increment(1), { capture: true }); + setInterval(() => this.clicks += this.clickBots * 10, 10 * 1000); + } + + increment(inc) { + this.clicks += inc; + } + + purchaseClickBot() { + this.clickBots++; + this.clicks -= 1000; + } +} diff --git a/awesome_clicker/static/src/clicker_service.js b/awesome_clicker/static/src/clicker_service.js new file mode 100644 index 00000000000..7985d785563 --- /dev/null +++ b/awesome_clicker/static/src/clicker_service.js @@ -0,0 +1,17 @@ +import { useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { ClickerModel } from "./clicker_model"; + + +const clickerService = { + start() { + return new ClickerModel(); + } +} + +export function useClicker() { + return useState(useService("awesome_clicker.clicker_service")); +} + +registry.category("services").add("awesome_clicker.clicker_service", clickerService); diff --git a/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.js b/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.js new file mode 100644 index 00000000000..f0124c01332 --- /dev/null +++ b/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.js @@ -0,0 +1,28 @@ +import { Component } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; + +import { useClicker } from "../clicker_service"; +import { ClickValue } from "../click_value/click_value"; + + +export class ClickerSystrayItem extends Component { + static template = "awesome_clicker.ClickerSystrayItem"; + static components = { ClickValue }; + + setup() { + this.clicker = useClicker(); + this.action_service = useService("action"); + } + + openClickerWindow() { + this.action_service.doAction({ + type: "ir.actions.client", + tag: "awesome_clicker.client_action", + target: "new", + name: "Clicker" + }); + } +} + +registry.category("systray").add("awesome_clicker.ClickerSystrayItem", { Component: ClickerSystrayItem }); diff --git a/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.xml b/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.xml new file mode 100644 index 00000000000..6a2897e1ebe --- /dev/null +++ b/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.xml @@ -0,0 +1,9 @@ + + + +
+ Clicks: + +
+
+
diff --git a/awesome_clicker/static/src/client_action/client_action.js b/awesome_clicker/static/src/client_action/client_action.js new file mode 100644 index 00000000000..a8538e36b07 --- /dev/null +++ b/awesome_clicker/static/src/client_action/client_action.js @@ -0,0 +1,17 @@ +import { Component } from "@odoo/owl"; +import { registry } from "@web/core/registry"; + +import { useClicker } from "../clicker_service"; +import { ClickValue } from "../click_value/click_value"; + + +export class ClientAction extends Component { + static template = "awesome_clicker.ClientAction"; + static components = { ClickValue }; + + setup() { + this.clicker = useClicker(); + } +} + +registry.category("actions").add("awesome_clicker.client_action", ClientAction); diff --git a/awesome_clicker/static/src/client_action/client_action.xml b/awesome_clicker/static/src/client_action/client_action.xml new file mode 100644 index 00000000000..1796db8db82 --- /dev/null +++ b/awesome_clicker/static/src/client_action/client_action.xml @@ -0,0 +1,20 @@ + + + +
+
+ Clicks: +
+ +

Bots

+ +
+ x Clickbots (10 clicks/10s) + +
+
+
+
From 8780201e999587ed2c397e0bbd437332a555817b Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Fri, 23 Jan 2026 13:45:46 +0100 Subject: [PATCH 24/39] [IMP] awesome_clicker: add progression systems and unlocks Added additional click farming methods unlocked when a certain number of clicks is reached, these milestones triggers graphical effects and update the game interface dynamically. Random rewards were also defined but not yet implemented into odoo logic. This corresponds to parts 10-13 of chapter 1 of the 'master the web framework' tutorial. --- awesome_clicker/static/src/click_rewards.js | 24 ++++++++ awesome_clicker/static/src/clicker_model.js | 56 +++++++++++++++++-- awesome_clicker/static/src/clicker_service.js | 16 +++++- .../src/client_action/client_action.xml | 19 +++++-- awesome_clicker/static/src/utils.js | 11 ++++ 5 files changed, 112 insertions(+), 14 deletions(-) create mode 100644 awesome_clicker/static/src/click_rewards.js create mode 100644 awesome_clicker/static/src/utils.js diff --git a/awesome_clicker/static/src/click_rewards.js b/awesome_clicker/static/src/click_rewards.js new file mode 100644 index 00000000000..91827f3db8e --- /dev/null +++ b/awesome_clicker/static/src/click_rewards.js @@ -0,0 +1,24 @@ +export const rewards = [ + { + description: "Get 1 click bot", + apply(clicker) { + clicker.increment(1); + }, + maxLevel: 3, + }, + { + description: "Get 10 click bot", + apply(clicker) { + clicker.increment(10); + }, + minLevel: 3, + maxLevel: 4, + }, + { + description: "Increase bot power!", + apply(clicker) { + clicker.multipler += 1; + }, + minLevel: 3, + }, +]; diff --git a/awesome_clicker/static/src/clicker_model.js b/awesome_clicker/static/src/clicker_model.js index c56ae162e20..a5cea96ae90 100644 --- a/awesome_clicker/static/src/clicker_model.js +++ b/awesome_clicker/static/src/clicker_model.js @@ -1,5 +1,14 @@ +import { EventBus } from "@odoo/owl"; import { Reactive } from "@web/core/utils/reactive"; +import { chooseReward } from "./utils"; + + +export const LEVEL_REQUIREMENTS = [ + { level: 1, requirement: 1000, event_name: "MILESTONE_1k", message: "Level up! You have unlocked ClickBots." }, + { level: 2, requirement: 5000, event_name: "MILESTONE_5k", message: "Level up! You have unlocked BigBots." }, + { level: 3, requirement: 100000, event_name: "MILESTONE_100k", message: "Level up! You have unlocked Power." }, +]; export class ClickerModel extends Reactive { @@ -7,19 +16,54 @@ export class ClickerModel extends Reactive { super(); this.clicks = 0; - this.level = 1; - this.clickBots = 0; + this.level = 0; + this.bots = [ + { + name: "ClickBot", + quantity: 0, + price: 1000, + yield: 10, + level_required: 1, + }, + { + name: "BigBot", + quantity: 0, + price: 5000, + yield: 100, + level_required: 2, + } + ]; + this.power = 1; + this.bus = new EventBus(); document.addEventListener("click", () => this.increment(1), { capture: true }); - setInterval(() => this.clicks += this.clickBots * 10, 10 * 1000); + setInterval(() => { + this.bots.forEach(bot => this.clicks += bot.yield * this.power * bot.quantity); + }, 10 * 1000); } increment(inc) { this.clicks += inc; + + LEVEL_REQUIREMENTS.forEach(milestone => { + if (this.clicks > milestone.requirement && this.level < milestone.level) { + this.level++; + this.bus.trigger(milestone.event_name); + } + }) + } + + purchaseBot(bot) { + bot.quantity++; + this.clicks -= bot.price; + } + + purchasePower() { + this.power++; + this.clicks -= 50000; } - purchaseClickBot() { - this.clickBots++; - this.clicks -= 1000; + getReward() { + return chooseReward(this.level); } } diff --git a/awesome_clicker/static/src/clicker_service.js b/awesome_clicker/static/src/clicker_service.js index 7985d785563..74ab5397040 100644 --- a/awesome_clicker/static/src/clicker_service.js +++ b/awesome_clicker/static/src/clicker_service.js @@ -1,12 +1,22 @@ import { useState } from "@odoo/owl"; import { registry } from "@web/core/registry"; import { useService } from "@web/core/utils/hooks"; -import { ClickerModel } from "./clicker_model"; + +import { ClickerModel, LEVEL_REQUIREMENTS } from "./clicker_model"; const clickerService = { - start() { - return new ClickerModel(); + dependencies: ["effect"], + start(env, services) { + let clicker_model = new ClickerModel(); + + LEVEL_REQUIREMENTS.forEach(milestone => + clicker_model.bus.addEventListener( + milestone.event_name, + () => services.effect.add({ message: milestone.message }), + ) + ) + return clicker_model; } } diff --git a/awesome_clicker/static/src/client_action/client_action.xml b/awesome_clicker/static/src/client_action/client_action.xml index 1796db8db82..682592669e9 100644 --- a/awesome_clicker/static/src/client_action/client_action.xml +++ b/awesome_clicker/static/src/client_action/client_action.xml @@ -4,15 +4,24 @@
Clicks: -

Bots

-
- x Clickbots (10 clicks/10s) - +
+ + +
+ x multiplier to all bot clicks +
diff --git a/awesome_clicker/static/src/utils.js b/awesome_clicker/static/src/utils.js new file mode 100644 index 00000000000..dc82a91816f --- /dev/null +++ b/awesome_clicker/static/src/utils.js @@ -0,0 +1,11 @@ +import { rewards } from "./click_rewards"; + + +export function chooseReward(current_level) { + let available_rewards = rewards.filter(reward => + (!reward.minLevel || reward.minLevel <= current_level) && + (!reward.maxLevel || reward.maxLevel >= current_level) + ); + let reward_index = Math.ceil(Math.random() * available_rewards.length) - 1 + return available_rewards[reward_index]; +} From cbbf0fc439869b239544feefed0d4fe8c15a3abf Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Mon, 26 Jan 2026 10:38:21 +0100 Subject: [PATCH 25/39] [IMP] awesome_clicker: add random reward pop-ups Added a 1% chance of obtaining a reward when opening any form view, rewards are conditional on clicker level and can be claimed from sticky notification pop-ups. A command provider was also added to create shortcuts to frequently used functions. This corresponds to parts 14-15 of chapter 1 of the 'master the web framework' tutorial. --- awesome_clicker/static/src/click_rewards.js | 10 +++---- awesome_clicker/static/src/clicker_model.js | 19 ++++++------ awesome_clicker/static/src/clicker_service.js | 29 ++++++++++++++++++- .../src/client_action/client_action.xml | 6 ++-- .../static/src/command_provider.js | 28 ++++++++++++++++++ .../src/form_controller/from_controller.js | 15 ++++++++++ 6 files changed, 89 insertions(+), 18 deletions(-) create mode 100644 awesome_clicker/static/src/command_provider.js create mode 100644 awesome_clicker/static/src/form_controller/from_controller.js diff --git a/awesome_clicker/static/src/click_rewards.js b/awesome_clicker/static/src/click_rewards.js index 91827f3db8e..ac8540f1b6e 100644 --- a/awesome_clicker/static/src/click_rewards.js +++ b/awesome_clicker/static/src/click_rewards.js @@ -1,15 +1,15 @@ export const rewards = [ { - description: "Get 1 click bot", + description: "Get 1 ClickBot", apply(clicker) { - clicker.increment(1); + clicker.bots.clickbot.quantity++; }, maxLevel: 3, }, { - description: "Get 10 click bot", + description: "Get 1 BigBot", apply(clicker) { - clicker.increment(10); + clicker.bots.bigbot.quantity++; }, minLevel: 3, maxLevel: 4, @@ -17,7 +17,7 @@ export const rewards = [ { description: "Increase bot power!", apply(clicker) { - clicker.multipler += 1; + clicker.power++; }, minLevel: 3, }, diff --git a/awesome_clicker/static/src/clicker_model.js b/awesome_clicker/static/src/clicker_model.js index a5cea96ae90..063be2d7552 100644 --- a/awesome_clicker/static/src/clicker_model.js +++ b/awesome_clicker/static/src/clicker_model.js @@ -17,28 +17,28 @@ export class ClickerModel extends Reactive { this.clicks = 0; this.level = 0; - this.bots = [ - { + this.bots = { + clickbot: { name: "ClickBot", quantity: 0, price: 1000, yield: 10, level_required: 1, }, - { + bigbot: { name: "BigBot", quantity: 0, price: 5000, yield: 100, level_required: 2, } - ]; + }; this.power = 1; this.bus = new EventBus(); document.addEventListener("click", () => this.increment(1), { capture: true }); setInterval(() => { - this.bots.forEach(bot => this.clicks += bot.yield * this.power * bot.quantity); + Object.values(this.bots).forEach(bot => this.clicks += bot.yield * this.power * bot.quantity); }, 10 * 1000); } @@ -53,9 +53,10 @@ export class ClickerModel extends Reactive { }) } - purchaseBot(bot) { - bot.quantity++; - this.clicks -= bot.price; + purchaseBot(bot_name) { + let purchased_bot = Object.values(this.bots).find(bot => bot.name === bot_name); + purchased_bot.quantity++; + this.clicks -= purchased_bot.price; } purchasePower() { @@ -64,6 +65,6 @@ export class ClickerModel extends Reactive { } getReward() { - return chooseReward(this.level); + this.bus.trigger("RANDOM_REWARD", chooseReward(this.level)); } } diff --git a/awesome_clicker/static/src/clicker_service.js b/awesome_clicker/static/src/clicker_service.js index 74ab5397040..73326d3e639 100644 --- a/awesome_clicker/static/src/clicker_service.js +++ b/awesome_clicker/static/src/clicker_service.js @@ -6,7 +6,7 @@ import { ClickerModel, LEVEL_REQUIREMENTS } from "./clicker_model"; const clickerService = { - dependencies: ["effect"], + dependencies: ["action", "effect", "notification"], start(env, services) { let clicker_model = new ClickerModel(); @@ -16,6 +16,33 @@ const clickerService = { () => services.effect.add({ message: milestone.message }), ) ) + + clicker_model.bus.addEventListener( + "RANDOM_REWARD", + (ev) => { + const closeNotification = services.notification.add( + `Congratulations, you won a reward: '${ev.detail.description}'`, + { + type: "success", + sticky: true, + buttons: [{ + name: "Collect", + onClick: () => { + ev.detail.apply(clicker_model); + closeNotification(); + services.action.doAction({ + type: "ir.actions.client", + tag: "awesome_clicker.client_action", + target: "new", + name: "Clicker" + }); + } + }], + } + ) + } + ) + return clicker_model; } } diff --git a/awesome_clicker/static/src/client_action/client_action.xml b/awesome_clicker/static/src/client_action/client_action.xml index 682592669e9..22b839d6d0e 100644 --- a/awesome_clicker/static/src/client_action/client_action.xml +++ b/awesome_clicker/static/src/client_action/client_action.xml @@ -4,15 +4,15 @@
Clicks: -

Bots

- +
x ( clicks/10s) -
diff --git a/awesome_clicker/static/src/command_provider.js b/awesome_clicker/static/src/command_provider.js new file mode 100644 index 00000000000..42531efc792 --- /dev/null +++ b/awesome_clicker/static/src/command_provider.js @@ -0,0 +1,28 @@ +import { registry } from "@web/core/registry"; + + +registry.category("command_provider").add( + "clicker_game", { + provide: (env, options) => { return [ + { + name: "Open Clicker Game", + category: "activity", + action() { + env.services.action.doAction({ + type: "ir.actions.client", + tag: "awesome_clicker.client_action", + target: "new", + name: "Clicker" + }); + } + }, + { + name: "Buy 1 ClickBot", + category: "activity", + action() { + env.services["awesome_clicker.clicker_service"].purchaseBot("ClickBot"); + } + } + ]} + } +); diff --git a/awesome_clicker/static/src/form_controller/from_controller.js b/awesome_clicker/static/src/form_controller/from_controller.js new file mode 100644 index 00000000000..94a45813141 --- /dev/null +++ b/awesome_clicker/static/src/form_controller/from_controller.js @@ -0,0 +1,15 @@ +import { FormController } from '@web/views/form/form_controller'; +import { patch } from "@web/core/utils/patch"; + +import { useClicker } from "../clicker_service"; + + +patch(FormController.prototype, { + setup() { + super.setup(...arguments); + let clicker = useClicker(); + if (Math.random < 0.01) { + clicker.getReward(); + } + } +}); From 2db39670fa072bab3318dc4a86bd3326213be3a7 Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Mon, 26 Jan 2026 13:12:28 +0100 Subject: [PATCH 26/39] [IMP] awesome_clicker: add tree resources and revamp menus Added a new resource type: trees, which produce a new currency type. The interface has been revamped to accomodate these new additions, including the systray item which is now a dropdown menu for quick access to the most relevant information. A handful of debug command were added to the command provider to reduce the tedium of reaching high levels when testing advanced features. This corresponds to parts 16-18 of chapter 1 of the 'master the web framework' tutorial. --- awesome_clicker/static/src/clicker_model.js | 40 +++++++++++++- .../clicker_systray_item.js | 4 +- .../clicker_systray_item.xml | 29 ++++++++-- .../static/src/client_action/client_action.js | 3 +- .../src/client_action/client_action.xml | 55 +++++++++++++------ .../static/src/command_provider.js | 43 ++++++++++++++- 6 files changed, 148 insertions(+), 26 deletions(-) diff --git a/awesome_clicker/static/src/clicker_model.js b/awesome_clicker/static/src/clicker_model.js index 063be2d7552..7e5faec53f7 100644 --- a/awesome_clicker/static/src/clicker_model.js +++ b/awesome_clicker/static/src/clicker_model.js @@ -8,6 +8,7 @@ export const LEVEL_REQUIREMENTS = [ { level: 1, requirement: 1000, event_name: "MILESTONE_1k", message: "Level up! You have unlocked ClickBots." }, { level: 2, requirement: 5000, event_name: "MILESTONE_5k", message: "Level up! You have unlocked BigBots." }, { level: 3, requirement: 100000, event_name: "MILESTONE_100k", message: "Level up! You have unlocked Power." }, + { level: 4, requirement: 1000000, event_name: "MILESTONE_1M", message: "Level up! You have unlocked Trees." }, ]; export class ClickerModel extends Reactive { @@ -17,6 +18,7 @@ export class ClickerModel extends Reactive { this.clicks = 0; this.level = 0; + this.power = 1; this.bots = { clickbot: { name: "ClickBot", @@ -33,20 +35,40 @@ export class ClickerModel extends Reactive { level_required: 2, } }; - this.power = 1; + this.trees = { + pear: { + name: "Pear Tree", + fruit_name: "Pear", + quantity: 0, + price: 1000000, + fruits: 0, + level_required: 4, + }, + cherry: { + name: "Cherry Tree", + fruit_name: "Cherry", + quantity: 0, + price: 1000000, + fruits: 0, + level_required: 4, + } + } this.bus = new EventBus(); document.addEventListener("click", () => this.increment(1), { capture: true }); setInterval(() => { Object.values(this.bots).forEach(bot => this.clicks += bot.yield * this.power * bot.quantity); }, 10 * 1000); + setInterval(() => { + Object.values(this.trees).forEach(tree => tree.fruits += tree.quantity); + }, 30 * 1000); } increment(inc) { this.clicks += inc; LEVEL_REQUIREMENTS.forEach(milestone => { - if (this.clicks > milestone.requirement && this.level < milestone.level) { + if (this.clicks >= milestone.requirement && this.level < milestone.level) { this.level++; this.bus.trigger(milestone.event_name); } @@ -59,6 +81,12 @@ export class ClickerModel extends Reactive { this.clicks -= purchased_bot.price; } + purchaseTree(tree_name) { + let purchased_tree = Object.values(this.trees).find(tree => tree.name === tree_name); + purchased_tree.quantity++; + this.clicks -= purchased_tree.price; + } + purchasePower() { this.power++; this.clicks -= 50000; @@ -67,4 +95,12 @@ export class ClickerModel extends Reactive { getReward() { this.bus.trigger("RANDOM_REWARD", chooseReward(this.level)); } + + getTotalTreeCount() { + return Object.values(this.trees).map(tree => tree.quantity).reduce((sum, qty) => sum + qty); + } + + getTotalFruitCount() { + return Object.values(this.trees).map(tree => tree.fruits).reduce((sum, qty) => sum + qty); + } } diff --git a/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.js b/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.js index f0124c01332..a630120c846 100644 --- a/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.js +++ b/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.js @@ -1,4 +1,6 @@ import { Component } from "@odoo/owl"; +import { Dropdown } from "@web/core/dropdown/dropdown"; +import { DropdownItem } from "@web/core/dropdown/dropdown_item"; import { registry } from "@web/core/registry"; import { useService } from "@web/core/utils/hooks"; @@ -8,7 +10,7 @@ import { ClickValue } from "../click_value/click_value"; export class ClickerSystrayItem extends Component { static template = "awesome_clicker.ClickerSystrayItem"; - static components = { ClickValue }; + static components = { ClickValue, Dropdown, DropdownItem }; setup() { this.clicker = useClicker(); diff --git a/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.xml b/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.xml index 6a2897e1ebe..c1543620b4e 100644 --- a/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.xml +++ b/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.xml @@ -1,9 +1,30 @@ -
- Clicks: - -
+ + + + + + + + + + + + + + x + ( ) + + + +
diff --git a/awesome_clicker/static/src/client_action/client_action.js b/awesome_clicker/static/src/client_action/client_action.js index a8538e36b07..0355b794ab9 100644 --- a/awesome_clicker/static/src/client_action/client_action.js +++ b/awesome_clicker/static/src/client_action/client_action.js @@ -1,4 +1,5 @@ import { Component } from "@odoo/owl"; +import { Notebook } from "@web/core/notebook/notebook"; import { registry } from "@web/core/registry"; import { useClicker } from "../clicker_service"; @@ -7,7 +8,7 @@ import { ClickValue } from "../click_value/click_value"; export class ClientAction extends Component { static template = "awesome_clicker.ClientAction"; - static components = { ClickValue }; + static components = { ClickValue, Notebook }; setup() { this.clicker = useClicker(); diff --git a/awesome_clicker/static/src/client_action/client_action.xml b/awesome_clicker/static/src/client_action/client_action.xml index 22b839d6d0e..ffd4839ecaa 100644 --- a/awesome_clicker/static/src/client_action/client_action.xml +++ b/awesome_clicker/static/src/client_action/client_action.xml @@ -2,28 +2,49 @@
-
+
Clicks: -
-

Bots

+ + +

Bots

+ +
+ x ( clicks/10s) + +
+
- -
- x ( clicks/10s) - -
-
+
+ x multiplier to all bot clicks + +
+
-
- x multiplier to all bot clicks - -
+ +

Fruits

+ + + x + + +

Trees

+ +
+ x (1 /30s) + +
+
+
+
diff --git a/awesome_clicker/static/src/command_provider.js b/awesome_clicker/static/src/command_provider.js index 42531efc792..f9ee8bd8b5e 100644 --- a/awesome_clicker/static/src/command_provider.js +++ b/awesome_clicker/static/src/command_provider.js @@ -22,7 +22,48 @@ registry.category("command_provider").add( action() { env.services["awesome_clicker.clicker_service"].purchaseBot("ClickBot"); } - } + }, + + // Commands below are strictly for debugging, they are not part of the tutorial exercises + { + name: "Increment Clicker by 1,000", + category: "debug", + action() { + env.services["awesome_clicker.clicker_service"].increment(1000); + } + }, + { + name: "Increment Clicker by 100,000", + category: "debug", + action() { + env.services["awesome_clicker.clicker_service"].increment(100000); + } + }, + { + name: "Increment Clicker by 1,000,000", + category: "debug", + action() { + env.services["awesome_clicker.clicker_service"].increment(1000000); + } + }, + { + name: "Grant Clicker Reward", + category: "debug", + action() { + env.services["awesome_clicker.clicker_service"].getReward(); + } + }, + { + name: "Reset Clicker Game", + category: "debug", + action() { + let clicker = env.services["awesome_clicker.clicker_service"]; + Object.values(clicker.bots).forEach(bot => bot.quantity = 0); + clicker.power = 1; + clicker.level = 0; + clicker.clicks = 0; + } + }, ]} } ); From a2e0c008efbd741b2cf1808efcf7cee06b6ee4fa Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Mon, 26 Jan 2026 14:50:31 +0100 Subject: [PATCH 27/39] [IMP] awesome_clicker: add game state persistence Game state is now saved to local browser storage every 10s so you don't lose progress when refreshing the page. Support has also been added to migrate clicker data to newer versions without loss. This corresponds to parts 19-21 of chapter 1 of the 'master the web framework' tutorial. --- awesome_clicker/static/src/clicker_model.js | 15 +++++++++-- awesome_clicker/static/src/clicker_service.js | 20 +++++++++++++- awesome_clicker/static/src/migration.js | 27 +++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 awesome_clicker/static/src/migration.js diff --git a/awesome_clicker/static/src/clicker_model.js b/awesome_clicker/static/src/clicker_model.js index 7e5faec53f7..0feefc358a5 100644 --- a/awesome_clicker/static/src/clicker_model.js +++ b/awesome_clicker/static/src/clicker_model.js @@ -1,4 +1,5 @@ import { EventBus } from "@odoo/owl"; +import { browser } from "@web/core/browser/browser"; import { Reactive } from "@web/core/utils/reactive"; import { chooseReward } from "./utils"; @@ -16,6 +17,7 @@ export class ClickerModel extends Reactive { constructor() { super(); + this.version = "1.1"; this.clicks = 0; this.level = 0; this.power = 1; @@ -51,6 +53,14 @@ export class ClickerModel extends Reactive { price: 1000000, fruits: 0, level_required: 4, + }, + peach: { + name: "Peach Tree", + fruit_name: "Peach", + quantity: 0, + price: 1000000, + fruits: 0, + level_required: 4, } } @@ -58,6 +68,7 @@ export class ClickerModel extends Reactive { document.addEventListener("click", () => this.increment(1), { capture: true }); setInterval(() => { Object.values(this.bots).forEach(bot => this.clicks += bot.yield * this.power * bot.quantity); + browser.localStorage.setItem("clicker_state", JSON.stringify(this)); }, 10 * 1000); setInterval(() => { Object.values(this.trees).forEach(tree => tree.fruits += tree.quantity); @@ -97,10 +108,10 @@ export class ClickerModel extends Reactive { } getTotalTreeCount() { - return Object.values(this.trees).map(tree => tree.quantity).reduce((sum, qty) => sum + qty); + return Object.values(this.trees).map(tree => tree.quantity).reduce((sum, qty) => { sum + qty }, 0); } getTotalFruitCount() { - return Object.values(this.trees).map(tree => tree.fruits).reduce((sum, qty) => sum + qty); + return Object.values(this.trees).map(tree => tree.fruits).reduce((sum, qty) => { sum + qty }, 0); } } diff --git a/awesome_clicker/static/src/clicker_service.js b/awesome_clicker/static/src/clicker_service.js index 73326d3e639..d64957819a7 100644 --- a/awesome_clicker/static/src/clicker_service.js +++ b/awesome_clicker/static/src/clicker_service.js @@ -1,14 +1,32 @@ import { useState } from "@odoo/owl"; +import { browser } from "@web/core/browser/browser"; import { registry } from "@web/core/registry"; import { useService } from "@web/core/utils/hooks"; import { ClickerModel, LEVEL_REQUIREMENTS } from "./clicker_model"; +import { migrate } from "./migration"; +function initClickerState() { + let clicker_model = new ClickerModel(); + let local_state = JSON.parse(browser.localStorage.getItem("clicker_state")); + + if (!local_state) { + return clicker_model; + } + + if (local_state.version != clicker_model.version) { + migrate(local_state, clicker_model.version); + } + + delete local_state.bus; + return Object.assign(clicker_model, local_state); +} + const clickerService = { dependencies: ["action", "effect", "notification"], start(env, services) { - let clicker_model = new ClickerModel(); + let clicker_model = initClickerState(); LEVEL_REQUIREMENTS.forEach(milestone => clicker_model.bus.addEventListener( diff --git a/awesome_clicker/static/src/migration.js b/awesome_clicker/static/src/migration.js new file mode 100644 index 00000000000..82c84748357 --- /dev/null +++ b/awesome_clicker/static/src/migration.js @@ -0,0 +1,27 @@ +const MIGRATIONS = [ + { + version_from: "1.0", + version_to: "1.1", + apply(local_state) { + console.log("Migrating from 1.0 to 1.1"); + Object.assign(local_state.trees, { + peach: { + name: "Peach Tree", + fruit_name: "Peach", + quantity: 0, + price: 1000000, + fruits: 0, + level_required: 4, + } + }); + }, + } +] + +export function migrate(local_state, target_version) { + while (local_state.version != target_version) { + let migration = MIGRATIONS.find(update => update.version_from === local_state.version); + migration.apply(local_state); + local_state.version = migration.version_to; + } +} From 3e0d2e41e1c72cd9ad099ed3e17e79f2c0aac10e Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Tue, 27 Jan 2026 13:37:24 +0100 Subject: [PATCH 28/39] [ADD] awesome_gallery: create new 'gallery' view type Added a new view type called gallery to the contacts page meant to display a table of pictures corresponding to each partner. The view is currently a placeholder definition to be filled in later commits. This corresponds to part 1 of the 'Create a gallery view' js tutorial. --- awesome_gallery/models/ir_ui_view.py | 3 +++ .../static/src/gallery_controller.js | 10 ++++++++++ .../static/src/gallery_controller.xml | 6 ++++++ awesome_gallery/static/src/gallery_view.js | 13 ++++++++++++ awesome_gallery/views/views.xml | 20 +++++++++++++------ 5 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 awesome_gallery/static/src/gallery_controller.js create mode 100644 awesome_gallery/static/src/gallery_controller.xml create mode 100644 awesome_gallery/static/src/gallery_view.js diff --git a/awesome_gallery/models/ir_ui_view.py b/awesome_gallery/models/ir_ui_view.py index 0c11b8298ac..a589605b527 100644 --- a/awesome_gallery/models/ir_ui_view.py +++ b/awesome_gallery/models/ir_ui_view.py @@ -6,3 +6,6 @@ class View(models.Model): _inherit = 'ir.ui.view' type = fields.Selection(selection_add=[('gallery', "Awesome Gallery")]) + + def _get_view_info(self): + return {'gallery': {'icon': 'fa fa-picture-o'}} | super()._get_view_info() diff --git a/awesome_gallery/static/src/gallery_controller.js b/awesome_gallery/static/src/gallery_controller.js new file mode 100644 index 00000000000..b09bf344136 --- /dev/null +++ b/awesome_gallery/static/src/gallery_controller.js @@ -0,0 +1,10 @@ +import { Component } from "@odoo/owl"; +import { standardViewProps } from "@web/views/standard_view_props"; + + +export class GalleryController extends Component { + static template = "awesome_gallery.GalleryController"; + static props = { + ...standardViewProps, + }; +} diff --git a/awesome_gallery/static/src/gallery_controller.xml b/awesome_gallery/static/src/gallery_controller.xml new file mode 100644 index 00000000000..b13a71dad8e --- /dev/null +++ b/awesome_gallery/static/src/gallery_controller.xml @@ -0,0 +1,6 @@ + + + +
Hello world
+
+
diff --git a/awesome_gallery/static/src/gallery_view.js b/awesome_gallery/static/src/gallery_view.js new file mode 100644 index 00000000000..d7236cd9fc6 --- /dev/null +++ b/awesome_gallery/static/src/gallery_view.js @@ -0,0 +1,13 @@ +import { registry } from "@web/core/registry"; +import { GalleryController } from "./gallery_controller"; + + +export const galleryView = { + type: "gallery", + display_name: "Gallery", + icon: "fa fa-picture-o", + multiRecord: true, + Controller: GalleryController, +}; + +registry.category("views").add("gallery", galleryView); diff --git a/awesome_gallery/views/views.xml b/awesome_gallery/views/views.xml index 56327365875..979a29a1a8e 100644 --- a/awesome_gallery/views/views.xml +++ b/awesome_gallery/views/views.xml @@ -1,18 +1,26 @@ + + awesome_gallery.orders.gallery + res.partner + + + + + Contacts res.partner - kanban,tree,form,activity + kanban,list,form,activity,gallery {'default_is_company': True} -

- Create a Contact in your address book -

- Odoo helps you track all activities related to your contacts. -

+

+ Create a Contact in your address book +

+ Odoo helps you track all activities related to your contacts. +

From 9fb06c896af9947304d9e16885c27378aeb309b9 Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Tue, 27 Jan 2026 16:28:41 +0100 Subject: [PATCH 29/39] [IMP] awesome_kanban: define kanban view extension Part 1 of 'Customize a kanban view tutorial'. This override of the KanbanController class is empty, so it adds no behavior for now, this is just a basis to work off of. --- awesome_kanban/static/src/awesome_kanban_view.js | 11 ++++++++++- awesome_kanban/static/src/kanban_controller.js | 3 +++ 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 awesome_kanban/static/src/kanban_controller.js diff --git a/awesome_kanban/static/src/awesome_kanban_view.js b/awesome_kanban/static/src/awesome_kanban_view.js index 0da52b22c9d..460a485a7dd 100644 --- a/awesome_kanban/static/src/awesome_kanban_view.js +++ b/awesome_kanban/static/src/awesome_kanban_view.js @@ -1 +1,10 @@ -// TODO: Define here your AwesomeKanban view +import { kanbanView } from "@web/views/kanban/kanban_view"; +import { registry } from "@web/core/registry"; +import { AwesomeKanbanController } from "./kanban_controller"; + +const awesomeKanbanController = { + ...kanbanView, + Controller: AwesomeKanbanController, +}; + +registry.category("views").add("awesome_kanban", awesomeKanbanController); diff --git a/awesome_kanban/static/src/kanban_controller.js b/awesome_kanban/static/src/kanban_controller.js new file mode 100644 index 00000000000..850707d897b --- /dev/null +++ b/awesome_kanban/static/src/kanban_controller.js @@ -0,0 +1,3 @@ +import { KanbanController } from "@web/views/kanban/kanban_controller"; + +export class AwesomeKanbanController extends KanbanController {} From 4b07c855e59ea07e2903794cad168c9ac428a78a Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Tue, 27 Jan 2026 17:13:36 +0100 Subject: [PATCH 30/39] [IMP] awesome_gallery: load partners image data Gallery view now loads data from the partner records and displays the extracted info. This corresponds to parts 2-4 of the 'Create a gallery view' tutorial. --- .../static/src/gallery_arch_parser.js | 10 ++++++ .../static/src/gallery_controller.js | 34 ++++++++++++++++++- .../static/src/gallery_controller.xml | 11 +++++- awesome_gallery/static/src/gallery_view.js | 13 +++++++ 4 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 awesome_gallery/static/src/gallery_arch_parser.js diff --git a/awesome_gallery/static/src/gallery_arch_parser.js b/awesome_gallery/static/src/gallery_arch_parser.js new file mode 100644 index 00000000000..009b2f8a4c6 --- /dev/null +++ b/awesome_gallery/static/src/gallery_arch_parser.js @@ -0,0 +1,10 @@ +export class GalleryArchParser { + parse(xmlDoc) { + const imageField = xmlDoc.getAttribute("image_field"); + const limit = xmlDoc.getAttribute("limit") || 80; + return { + imageField, + limit, + }; + } +} diff --git a/awesome_gallery/static/src/gallery_controller.js b/awesome_gallery/static/src/gallery_controller.js index b09bf344136..646a2f568da 100644 --- a/awesome_gallery/static/src/gallery_controller.js +++ b/awesome_gallery/static/src/gallery_controller.js @@ -1,4 +1,6 @@ -import { Component } from "@odoo/owl"; +import { Layout } from "@web/search/layout"; +import { useService } from "@web/core/utils/hooks"; +import { Component, onWillStart, onWillUpdateProps, useState } from "@odoo/owl"; import { standardViewProps } from "@web/views/standard_view_props"; @@ -6,5 +8,35 @@ export class GalleryController extends Component { static template = "awesome_gallery.GalleryController"; static props = { ...standardViewProps, + archInfo: Object, }; + static components = { Layout }; + + setup() { + this.orm = useService("orm"); + this.images = useState({ data: [] }); + onWillStart(async () => { + const { records } = await this.loadImages(this.props.domain); + this.images.data = records; + }); + + onWillUpdateProps(async (nextProps) => { + if (JSON.stringify(nextProps.domain) !== JSON.stringify(this.props.domain)) { + const { records } = await this.loadImages(nextProps.domain); + this.images.data = records; + } + }); + } + + loadImages(domain) { + return this.orm.webSearchRead(this.props.resModel, domain, { + limit: this.props.archInfo.limit, + specification: { + [this.props.archInfo.imageField]: {}, + }, + context: { + bin_size: true, + } + }); + } } diff --git a/awesome_gallery/static/src/gallery_controller.xml b/awesome_gallery/static/src/gallery_controller.xml index b13a71dad8e..a06179bad76 100644 --- a/awesome_gallery/static/src/gallery_controller.xml +++ b/awesome_gallery/static/src/gallery_controller.xml @@ -1,6 +1,15 @@ + -
Hello world
+ + +

+ id: + bin_size: +

+
+
+
diff --git a/awesome_gallery/static/src/gallery_view.js b/awesome_gallery/static/src/gallery_view.js index d7236cd9fc6..6e44d92e31d 100644 --- a/awesome_gallery/static/src/gallery_view.js +++ b/awesome_gallery/static/src/gallery_view.js @@ -1,5 +1,6 @@ import { registry } from "@web/core/registry"; import { GalleryController } from "./gallery_controller"; +import { GalleryArchParser } from "./gallery_arch_parser"; export const galleryView = { @@ -8,6 +9,18 @@ export const galleryView = { icon: "fa fa-picture-o", multiRecord: true, Controller: GalleryController, + ArchParser: GalleryArchParser, + + props(genericProps, view) { + const { ArchParser } = view; + const { arch } = genericProps; + const archInfo = new ArchParser().parse(arch); + + return { + ...genericProps, + archInfo, + }; + }, }; registry.category("views").add("gallery", galleryView); From 3225bb39179e2dbb0671252f8d1ff5897efbc65f Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Wed, 28 Jan 2026 14:36:08 +0100 Subject: [PATCH 31/39] [IMP] estate: add data and demo record Added a few generic property types that are always loaded by the estate module, to get the users started. Additionally, demo data has been defined for testing and demo purposes that can optionally be downloaded on module installation. This corresponds to the 'Define module data' tutorial. --- estate/__manifest__.py | 9 +++- estate/data/estate.property.type.csv | 5 +++ estate/demo/estate.property.offer.xml | 34 +++++++++++++++ estate/demo/estate.property.xml | 60 +++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 estate/data/estate.property.type.csv create mode 100644 estate/demo/estate.property.offer.xml create mode 100644 estate/demo/estate.property.xml diff --git a/estate/__manifest__.py b/estate/__manifest__.py index c2eeed87740..5bba2abdc0e 100644 --- a/estate/__manifest__.py +++ b/estate/__manifest__.py @@ -5,12 +5,17 @@ ], 'application': True, 'data': [ + 'views/estate_property_offer_views.xml', 'views/estate_property_type_views.xml', 'views/estate_property_tag_views.xml', - 'views/estate_property_offer_views.xml', 'views/estate_property_views.xml', 'views/estate_menus.xml', 'views/res_users_views.xml', 'security/ir.model.access.csv', - ] + 'data/estate.property.type.csv', + ], + 'demo': [ + 'demo/estate.property.xml', + 'demo/estate.property.offer.xml', + ], } diff --git a/estate/data/estate.property.type.csv b/estate/data/estate.property.type.csv new file mode 100644 index 00000000000..4d0d7a60f78 --- /dev/null +++ b/estate/data/estate.property.type.csv @@ -0,0 +1,5 @@ +"id","name" +property_type_residential,"Residential" +property_type_commercial,"Commercial" +property_type_industrial,"Industrial" +property_type_land,"Land" diff --git a/estate/demo/estate.property.offer.xml b/estate/demo/estate.property.offer.xml new file mode 100644 index 00000000000..4466beb42b4 --- /dev/null +++ b/estate/demo/estate.property.offer.xml @@ -0,0 +1,34 @@ + + + + + 10000 + 14 + + + + + + + 1500000 + 14 + + + + + + + 1500001 + 14 + + + + + + + + + + + + diff --git a/estate/demo/estate.property.xml b/estate/demo/estate.property.xml new file mode 100644 index 00000000000..626ba6ba0b4 --- /dev/null +++ b/estate/demo/estate.property.xml @@ -0,0 +1,60 @@ + + + Big Villa + new + A nice and big villa + 12345 + 2020-02-02 + 1600000 + 6 + 100 + 4 + True + True + 100000 + south + + + + + Trailer home + cancelled + Home in a trailer park + 54321 + 1970-01-01 + 100000 + 120000 + 1 + 10 + 4 + False + + + + + Storefront + new + Storefront in a market + 5050 + 1990-07-08 + 1000000 + 0 + 50 + 2 + False + + + + + From 3367e1716bf13b580e3fcdb4e54581198fc07749 Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Wed, 28 Jan 2026 16:19:54 +0100 Subject: [PATCH 32/39] [FIX] estate: check property sale conditions Fixed 2 issues: - Properties could be sold without having any offers - Offers could still be created on properties marked as sold Added sanity check tests to ensure the property is in a valid state when being sold, and stays valid after sale. This corresponds to the 'Safeguard your code with unit tests' tutorial. --- estate/models/estate_property.py | 2 + estate/models/estate_property_offer.py | 2 + estate/tests/__init__.py | 1 + estate/tests/test_estate.py | 59 ++++++++++++++++++++++++++ 4 files changed, 64 insertions(+) create mode 100644 estate/tests/__init__.py create mode 100644 estate/tests/test_estate.py diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py index 9ab5adfbbb7..a64b0799dc3 100644 --- a/estate/models/estate_property.py +++ b/estate/models/estate_property.py @@ -88,6 +88,8 @@ def action_mark_as_sold(self): for record in self: if record.state == 'cancelled': raise UserError("A Cancelled property cannot be sold") + elif not record.offer_ids: + raise UserError("A property without offers cannot be sold") else: record.state = 'sold' return True diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py index 26a6f4d0f04..fdbd3a5f52f 100644 --- a/estate/models/estate_property_offer.py +++ b/estate/models/estate_property_offer.py @@ -55,6 +55,8 @@ def action_refuse_offer(self): def create(self, vals_list): for vals in vals_list: property_record = self.env["estate.property"].browse(vals["property_id"]) + if property_record.state == 'sold': + raise UserError('Offers cannot be created for sold properties') if property_record.offer_ids and vals["price"] < max(property_record.offer_ids.mapped("price")): raise UserError("Newly created offer price must not be lower than the current best offer") else: diff --git a/estate/tests/__init__.py b/estate/tests/__init__.py new file mode 100644 index 00000000000..dfd37f0be11 --- /dev/null +++ b/estate/tests/__init__.py @@ -0,0 +1 @@ +from . import test_estate diff --git a/estate/tests/test_estate.py b/estate/tests/test_estate.py new file mode 100644 index 00000000000..f9918616d62 --- /dev/null +++ b/estate/tests/test_estate.py @@ -0,0 +1,59 @@ +from odoo.exceptions import UserError +from odoo.tests import Form, tagged +from odoo.tests.common import TransactionCase + + +@tagged('post_install', '-at_install') +class EstateTestCase(TransactionCase): + + @classmethod + def setUpClass(cls): + super(EstateTestCase, cls).setUpClass() + + cls.property = cls.env['estate.property'].create({ + 'name': 'property', + 'state': 'new', + 'expected_price': 100000, + }) + + def test_prevent_offer_creation_on_property(self): + self.property.state = 'sold' + self.assertEqual(self.property.state, 'sold') + + with self.assertRaises(UserError): + self.property.offer_ids.create([{ + 'partner_id': self.env.uid, + 'property_id': self.property.id, + 'price': 100000, + }]) + + def test_prevent_sale_without_offer(self): + self.assertEqual(len(self.property.offer_ids), 0, 'Property should not have any offers') + with self.assertRaises(UserError): + self.property.action_mark_as_sold() + + def test_mark_property_as_sold(self): + self.property.offer_ids.create([{ + 'partner_id': self.env.uid, + 'property_id': self.property.id, + 'price': 100000, + }]) + self.property.action_mark_as_sold() + self.assertEqual(self.property.state, 'sold', 'Property should have been updated to sold state') + + def test_property_form_garden_reset(self): + form = Form(self.env['estate.property']) + form.name = 'Garden Test Property' + form.expected_price = 10000 + + form.garden = True + property_with_garden = form.save() + self.assertEqual(property_with_garden.garden, True) + self.assertEqual(property_with_garden.garden_area, 10) + self.assertEqual(property_with_garden.garden_orientation, 'north') + + form.garden = False + property_without_garden = form.save() + self.assertEqual(property_without_garden.garden, False) + self.assertEqual(property_without_garden.garden_area, 0) + self.assertEqual(property_without_garden.garden_orientation, False) From bafb81d115197ca55367bb99b37a6f2691ebd913 Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Thu, 29 Jan 2026 15:21:22 +0100 Subject: [PATCH 33/39] [ADD] importable_estate: create import module for estate Created a copy of the estate module using only xml files to make the module importable. This is part of the 'Write importable modules' tutorial. --- .../clicker_systray_item.xml | 2 +- importable_estate/__init__.py | 0 importable_estate/__manifest__.py | 9 ++++++++ importable_estate/models/estate_property.xml | 22 +++++++++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 importable_estate/__init__.py create mode 100644 importable_estate/__manifest__.py create mode 100644 importable_estate/models/estate_property.xml diff --git a/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.xml b/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.xml index c1543620b4e..7de447b26a1 100644 --- a/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.xml +++ b/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.xml @@ -1,7 +1,7 @@ - + + +
From d16f815e3d69686b631b5a6c2532d0ae8711578b Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Fri, 30 Jan 2026 13:15:09 +0100 Subject: [PATCH 38/39] [IMP] awesome_kanban: add customer list and filters Added a new element to the CRM kanban view listing all customers. Customers can be clicked in the list to filter the kanban view for all tasks related to that particular customer. This corresponds to parts 2-4 of the 'Customize a kanban view' tutorial. --- .../static/src/awesome_kanban_view.js | 2 +- .../static/src/customer_list/customer_list.js | 18 +++++++++++ .../src/customer_list/customer_list.scss | 7 ++++ .../src/customer_list/customer_list.xml | 15 +++++++++ .../static/src/kanban_controller.js | 3 -- .../kanban_controller/kanban_controller.js | 32 +++++++++++++++++++ .../kanban_controller/kanban_controller.xml | 10 ++++++ 7 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 awesome_kanban/static/src/customer_list/customer_list.js create mode 100644 awesome_kanban/static/src/customer_list/customer_list.scss create mode 100644 awesome_kanban/static/src/customer_list/customer_list.xml delete mode 100644 awesome_kanban/static/src/kanban_controller.js create mode 100644 awesome_kanban/static/src/kanban_controller/kanban_controller.js create mode 100644 awesome_kanban/static/src/kanban_controller/kanban_controller.xml diff --git a/awesome_kanban/static/src/awesome_kanban_view.js b/awesome_kanban/static/src/awesome_kanban_view.js index 460a485a7dd..d7dc711d62c 100644 --- a/awesome_kanban/static/src/awesome_kanban_view.js +++ b/awesome_kanban/static/src/awesome_kanban_view.js @@ -1,6 +1,6 @@ import { kanbanView } from "@web/views/kanban/kanban_view"; import { registry } from "@web/core/registry"; -import { AwesomeKanbanController } from "./kanban_controller"; +import { AwesomeKanbanController } from "./kanban_controller/kanban_controller"; const awesomeKanbanController = { ...kanbanView, diff --git a/awesome_kanban/static/src/customer_list/customer_list.js b/awesome_kanban/static/src/customer_list/customer_list.js new file mode 100644 index 00000000000..c9f9d95a3a9 --- /dev/null +++ b/awesome_kanban/static/src/customer_list/customer_list.js @@ -0,0 +1,18 @@ +import { Component, onWillStart } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; + + +export class CustomerList extends Component { + static template = "awesome_kanban.CustomerList"; + + static props = { selectCustomer: Function }; + + setup() { + super.setup(); + this.orm = useService("orm"); + + onWillStart(async () => + this.customers = await this.orm.searchRead("res.partner", [], ["display_name"]) + ) + } +} diff --git a/awesome_kanban/static/src/customer_list/customer_list.scss b/awesome_kanban/static/src/customer_list/customer_list.scss new file mode 100644 index 00000000000..e1829dc48a5 --- /dev/null +++ b/awesome_kanban/static/src/customer_list/customer_list.scss @@ -0,0 +1,7 @@ +.o_customer_list { + float: left; + width: 300px; + height: 100%; + background-color: black; + text-align: center; +} diff --git a/awesome_kanban/static/src/customer_list/customer_list.xml b/awesome_kanban/static/src/customer_list/customer_list.xml new file mode 100644 index 00000000000..67022e65241 --- /dev/null +++ b/awesome_kanban/static/src/customer_list/customer_list.xml @@ -0,0 +1,15 @@ + + + + +
+

Customers

+ + + +
+
+ +
diff --git a/awesome_kanban/static/src/kanban_controller.js b/awesome_kanban/static/src/kanban_controller.js deleted file mode 100644 index 850707d897b..00000000000 --- a/awesome_kanban/static/src/kanban_controller.js +++ /dev/null @@ -1,3 +0,0 @@ -import { KanbanController } from "@web/views/kanban/kanban_controller"; - -export class AwesomeKanbanController extends KanbanController {} diff --git a/awesome_kanban/static/src/kanban_controller/kanban_controller.js b/awesome_kanban/static/src/kanban_controller/kanban_controller.js new file mode 100644 index 00000000000..baae7801459 --- /dev/null +++ b/awesome_kanban/static/src/kanban_controller/kanban_controller.js @@ -0,0 +1,32 @@ +import { KanbanController } from "@web/views/kanban/kanban_controller"; + +import { CustomerList } from "../customer_list/customer_list"; + + +export class AwesomeKanbanController extends KanbanController { + static template = "awesome_kanban.AwesomeKanbanController"; + static components = { ...KanbanController.components, CustomerList }; + + setup() { + super.setup(); + this.searchKey = Symbol("isFromAwesomeKanban"); + } + + onCustomerSelected(customer_id, customer_name) { + const customerFilters = this.env.searchModel.getSearchItems((searchItem) => + searchItem.isFromAwesomeKanban + ); + + for (const customerFilter of customerFilters) { + if (customerFilter.isActive) { + this.env.searchModel.toggleSearchItem(customerFilter.id); + } + } + + this.env.searchModel.createNewFilters([{ + description: customer_name, + domain: [["partner_id", "=", customer_id]], + isFromAwesomeKanban: true, + }]) + } +} diff --git a/awesome_kanban/static/src/kanban_controller/kanban_controller.xml b/awesome_kanban/static/src/kanban_controller/kanban_controller.xml new file mode 100644 index 00000000000..ed634cedabd --- /dev/null +++ b/awesome_kanban/static/src/kanban_controller/kanban_controller.xml @@ -0,0 +1,10 @@ + + + + + + + + + + From f3cfa4b14da6fda51516f65cbc752307faf4a43b Mon Sep 17 00:00:00 2001 From: Yoann Baron Date: Fri, 30 Jan 2026 15:00:21 +0100 Subject: [PATCH 39/39] [IMP] awesome_kanban: add filter options to customer list Added the possibility to filter for customers with active opportunities only, and an input to search for partners by name. This corresponds to parts 5-7 of the 'Customize a kanban view' tutorial. --- .../static/src/customer_list/customer_list.js | 27 ++++++++++++++++--- .../src/customer_list/customer_list.xml | 5 +++- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/awesome_kanban/static/src/customer_list/customer_list.js b/awesome_kanban/static/src/customer_list/customer_list.js index c9f9d95a3a9..eb669b3f451 100644 --- a/awesome_kanban/static/src/customer_list/customer_list.js +++ b/awesome_kanban/static/src/customer_list/customer_list.js @@ -1,5 +1,6 @@ -import { Component, onWillStart } from "@odoo/owl"; +import { Component, onWillStart, useState } from "@odoo/owl"; import { useService } from "@web/core/utils/hooks"; +import { fuzzyLookup } from "@web/core/utils/search"; export class CustomerList extends Component { @@ -10,9 +11,27 @@ export class CustomerList extends Component { setup() { super.setup(); this.orm = useService("orm"); + this.customers = useState({ data: [] }); + this.state = useState({ nameFilter: "", isActiveChecked: false }); - onWillStart(async () => - this.customers = await this.orm.searchRead("res.partner", [], ["display_name"]) - ) + onWillStart(async () => this.customers.data = await this.loadCustomers()) + } + + get displayedCustomers() { + return this.filterCustomersByName(this.state.nameFilter); + } + + async toggleActiveCustomerFilter(ev) { + this.state.isActiveChecked = ev.target.checked; + this.customers.data = await this.loadCustomers(); + } + + loadCustomers() { + let domain = this.state.isActiveChecked ? [["opportunity_ids", "!=", false]] : []; + return this.orm.searchRead("res.partner", domain, ["display_name"]); + } + + filterCustomersByName(filter) { + return filter === "" ? this.customers.data : fuzzyLookup(filter, this.customers.data, (customer) => customer.display_name); } } diff --git a/awesome_kanban/static/src/customer_list/customer_list.xml b/awesome_kanban/static/src/customer_list/customer_list.xml index 67022e65241..faaea70186e 100644 --- a/awesome_kanban/static/src/customer_list/customer_list.xml +++ b/awesome_kanban/static/src/customer_list/customer_list.xml @@ -4,7 +4,10 @@

Customers

- + Active customers + + +