From 8b78ab638e69e0c3549eaea776ab9f748f33d4dd Mon Sep 17 00:00:00 2001 From: pasaw Date: Tue, 10 Mar 2026 16:38:41 +0530 Subject: [PATCH 1/8] [ADD] estate: finish Chapters 2-4 (module & model) Setup and install real estate module Declaring model class and creating estate property table in database via odoo orm implementing access rights --- estate/__init__.py | 1 + estate/__manifest__.py | 20 +++ estate/demo/demo.xml | 198 ++++++++++++++++++++++++++++ estate/models/__init__.py | 1 + estate/models/estate_property.py | 29 ++++ estate/security/ir.model.access.csv | 2 + 6 files changed, 251 insertions(+) create mode 100644 estate/__init__.py create mode 100644 estate/__manifest__.py create mode 100644 estate/demo/demo.xml create mode 100644 estate/models/__init__.py create mode 100644 estate/models/estate_property.py create mode 100644 estate/security/ir.model.access.csv diff --git a/estate/__init__.py b/estate/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/estate/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/estate/__manifest__.py b/estate/__manifest__.py new file mode 100644 index 00000000000..1d861170f6c --- /dev/null +++ b/estate/__manifest__.py @@ -0,0 +1,20 @@ +{ + 'name': 'Real Estate', + 'version': '1.0', + 'license': 'LGPL-3', + 'summary': 'Real estate advertisement management', + 'description': """ + Real Estate tutorial module. + """, + 'author': 'Parth Sawant', + 'depends': ['base'], + 'category': 'Real Estate', + 'data': [ + 'security/ir.model.access.csv', + ], + 'demo': [ + 'demo/demo.xml', + ], + 'application': True, + 'installable': True, +} diff --git a/estate/demo/demo.xml b/estate/demo/demo.xml new file mode 100644 index 00000000000..1c2045ea42b --- /dev/null +++ b/estate/demo/demo.xml @@ -0,0 +1,198 @@ + + + + + Modern Downtown Apartment + Sleek apartment in the heart of the city + 10001 + 2025-06-01 + 250000 + 245000 + 2 + 85 + 1 + False + False + 0 + north + + + + Suburban Family Home + Spacious home with a large backyard + 20002 + 2025-07-15 + 450000 + 440000 + 4 + 180 + 3 + True + True + 120 + south + + + + Cozy Studio Near University + Compact studio ideal for students + 30003 + 2025-05-01 + 95000 + 90000 + 1 + 35 + 1 + False + False + 0 + east + + + + Luxury Beachfront Villa + Stunning villa with direct beach access + 40004 + 2025-09-01 + 1200000 + 1150000 + 5 + 350 + 4 + True + True + 500 + south + + + + Countryside Cottage + Charming cottage surrounded by nature + 50005 + 2025-08-01 + 175000 + 170000 + 2 + 90 + 2 + False + True + 200 + west + + + + City Center Penthouse + Exclusive penthouse with panoramic views + 60006 + 2025-10-01 + 850000 + 820000 + 3 + 210 + 2 + True + False + 0 + north + + + + Industrial Loft Conversion + Trendy loft in a converted warehouse + 70007 + 2025-06-15 + 320000 + 310000 + 2 + 130 + 1 + False + False + 0 + east + + + + Hillside Bungalow + Peaceful bungalow with valley views + 80008 + 2025-07-01 + 290000 + 280000 + 3 + 110 + 2 + True + True + 80 + west + + + + Gated Community Townhouse + Secure townhouse in a premium gated complex + 90009 + 2025-11-01 + 530000 + 515000 + 3 + 150 + 2 + True + True + 60 + south + + + + Riverside Duplex + Beautiful duplex with riverside views + 10010 + 2025-08-15 + 390000 + 375000 + 4 + 160 + 3 + True + True + 90 + east + + + + Mountain Cabin Retreat + Rustic cabin with stunning mountain views + 11011 + 2025-09-15 + 210000 + 200000 + 2 + 75 + 1 + False + True + 150 + south + + + + + Historic Downtown Loft + Unique loft in a historic building + 12012 + 2025-10-15 + 280000 + 270000 + 1 + 95 + 2 + False + False + 0 + north + + + + \ 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..99e54be1f31 --- /dev/null +++ b/estate/models/estate_property.py @@ -0,0 +1,29 @@ +from odoo import fields, models + + +class EstateProperty(models.Model): + _name = 'estate.property' + _description = 'Real Estate Property' + + name = fields.Char(string='Property Name', required=True, help='Enter the name of the property') + description = fields.Text(string='Property Description', help='Enter a description of the property') + postcode = fields.Char(string='Postcode', help='Enter the postcode of the property') + date_availability = fields.Date(string='Availability Date', help='Enter the date when the property becomes available') + expected_price = fields.Float(string='Expected Price', required=True, help='Enter the expected price of the property') + selling_price = fields.Float(string='Selling Price', help='Enter the selling price of the property') + bedrooms = fields.Integer(string='Number of Bedrooms', help='Enter the number of bedrooms in the property') + living_area = fields.Integer(string='Living Area', help='Enter the living area of the property in square meters') + facades = fields.Integer(string='Number of Facades', help='Enter the number of facades of the property') + garage = fields.Boolean(string='Garage', help='Check if the property has a garage') + garden = fields.Boolean(string='Garden', help='Check if the property has a garden') + garden_area = fields.Integer(string='Garden Area', help='Enter the area of the garden in square meters') + garden_orientation = fields.Selection( + selection=[ + ('north', 'North'), + ('south', 'South'), + ('east', 'East'), + ('west', 'West'), + ], + string='Garden Orientation', + help='Select the orientation of the garden' + ) diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv new file mode 100644 index 00000000000..ab63520e22b --- /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 +estate.access_estate_property,access_estate_property,estate.model_estate_property,base.group_user,1,1,1,1 \ No newline at end of file From 83d1383da7a674c7965b5ebbfbde499f139b6a3d Mon Sep 17 00:00:00 2001 From: pasaw Date: Thu, 12 Mar 2026 18:54:08 +0530 Subject: [PATCH 2/8] [ADD] estate: chapter 5 and chapter 6 (list and forms done) -added default view (list and form) in chapter 5 with some field attributes -added custom view (list and form) in chapter 6 --- estate/__manifest__.py | 2 + estate/models/estate_property.py | 22 +++++++-- estate/views/estate_menus.xml | 9 ++++ estate/views/estate_property_views.xml | 65 ++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 3 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 1d861170f6c..6dbc03807e5 100644 --- a/estate/__manifest__.py +++ b/estate/__manifest__.py @@ -11,6 +11,8 @@ 'category': 'Real Estate', 'data': [ 'security/ir.model.access.csv', + 'views/estate_property_views.xml', + 'views/estate_menus.xml', ], 'demo': [ 'demo/demo.xml', diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py index 99e54be1f31..238bed9fe3d 100644 --- a/estate/models/estate_property.py +++ b/estate/models/estate_property.py @@ -1,3 +1,4 @@ +from dateutil.relativedelta import relativedelta from odoo import fields, models @@ -8,10 +9,10 @@ class EstateProperty(models.Model): name = fields.Char(string='Property Name', required=True, help='Enter the name of the property') description = fields.Text(string='Property Description', help='Enter a description of the property') postcode = fields.Char(string='Postcode', help='Enter the postcode of the property') - date_availability = fields.Date(string='Availability Date', help='Enter the date when the property becomes available') + date_availability = fields.Date(string='Availability Date', help='Enter the date when the property becomes available', copy=False, default=lambda self: fields.Date.today() + relativedelta(months=3)) expected_price = fields.Float(string='Expected Price', required=True, help='Enter the expected price of the property') - selling_price = fields.Float(string='Selling Price', help='Enter the selling price of the property') - bedrooms = fields.Integer(string='Number of Bedrooms', help='Enter the number of bedrooms in the property') + selling_price = fields.Float(string='Selling Price', help='Enter the selling price of the property', readonly=True, copy=False) + bedrooms = fields.Integer(string='Number of Bedrooms', help='Enter the number of bedrooms in the property', default=2) living_area = fields.Integer(string='Living Area', help='Enter the living area of the property in square meters') facades = fields.Integer(string='Number of Facades', help='Enter the number of facades of the property') garage = fields.Boolean(string='Garage', help='Check if the property has a garage') @@ -27,3 +28,18 @@ class EstateProperty(models.Model): string='Garden Orientation', help='Select the orientation of the garden' ) + active = fields.Boolean(string='Active', default=True, help='Set to False to archive the property') + state = fields.Selection( + selection=[ + ('new', 'New Offer'), + ('offer_received', 'Offer Received'), + ('offer_accepted', 'Offer Accepted'), + ('sold', 'Sold'), + ('cancelled', 'Cancelled') + ], + string='Status', + required=True, + copy=False, + default='new', + help='Current status of the property' + ) diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml new file mode 100644 index 00000000000..ce491d4cbfb --- /dev/null +++ b/estate/views/estate_menus.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml new file mode 100644 index 00000000000..757fd9cf2a3 --- /dev/null +++ b/estate/views/estate_property_views.xml @@ -0,0 +1,65 @@ + + + + + estate.property.tree + estate.property + + + + + + + + + + + + + + + estate.property.form + estate.property + +
+ +

+ +

+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + + Properties + estate.property + list,form + +
From b97eef753b4f2d144bc0aa6368ba0a11f709a3c9 Mon Sep 17 00:00:00 2001 From: pasaw Date: Fri, 13 Mar 2026 19:10:25 +0530 Subject: [PATCH 3/8] [IMP] estate:chapter 6 -chapter 6 search view added --- estate/views/estate_property_views.xml | 33 ++++++++++++++++++++------ 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml index 757fd9cf2a3..2a4972bf5c6 100644 --- a/estate/views/estate_property_views.xml +++ b/estate/views/estate_property_views.xml @@ -1,11 +1,11 @@ - - estate.property.tree + + estate.property.list estate.property - + @@ -21,11 +21,8 @@ estate.property.form estate.property -
+ -

- -

@@ -55,6 +52,28 @@ + + estate.property.search + estate.property + + + + + + + + + + + + + + + From 1809d22f0d9f10b98ccc67c0c3be170e279efb7d Mon Sep 17 00:00:00 2001 From: pasaw Date: Wed, 18 Mar 2026 18:55:30 +0530 Subject: [PATCH 4/8] [ADD] estate: chapter 7 completed - Added new models: Property Type, Property Tag, and Property Offer. - Implemented relations: Many2one (Type, Buyer, Seller), Many2many (Tags), and One2many (Offers). - Added views (List, Form) for the new models. - Added 'image' field to Property form and module web icon. --- estate/__manifest__.py | 7 +- estate/demo/demo.xml | 7 +- estate/models/__init__.py | 5 ++ estate/models/estate_property.py | 63 ++++++++++------- estate/models/estate_property_offer.py | 21 ++++++ estate/models/estate_property_tag.py | 8 +++ estate/models/estate_property_type.py | 9 +++ estate/security/ir.model.access.csv | 6 +- estate/static/description/icon1.png | Bin 0 -> 19495 bytes estate/static/description/index.html | 3 + estate/views/estate_menus.xml | 10 +-- estate/views/estate_property_offer_views.xml | 28 ++++++++ estate/views/estate_property_tags_views.xml | 32 +++++++++ estate/views/estate_property_type_views.xml | 33 +++++++++ estate/views/estate_property_views.xml | 67 ++++++++++++------- 15 files changed, 240 insertions(+), 59 deletions(-) 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/static/description/icon1.png create mode 100644 estate/static/description/index.html create mode 100644 estate/views/estate_property_offer_views.xml create mode 100644 estate/views/estate_property_tags_views.xml create mode 100644 estate/views/estate_property_type_views.xml diff --git a/estate/__manifest__.py b/estate/__manifest__.py index 6dbc03807e5..baaefba05f1 100644 --- a/estate/__manifest__.py +++ b/estate/__manifest__.py @@ -12,11 +12,16 @@ 'data': [ 'security/ir.model.access.csv', 'views/estate_property_views.xml', + 'views/estate_property_type_views.xml', + 'views/estate_property_tags_views.xml', + 'views/estate_property_offer_views.xml', 'views/estate_menus.xml', + ], 'demo': [ 'demo/demo.xml', ], 'application': True, - 'installable': True, + } + diff --git a/estate/demo/demo.xml b/estate/demo/demo.xml index 1c2045ea42b..bb573f6c141 100644 --- a/estate/demo/demo.xml +++ b/estate/demo/demo.xml @@ -1,6 +1,4 @@ - - - + Modern Downtown Apartment Sleek apartment in the heart of the city @@ -193,6 +191,5 @@ 0 north + - - \ No newline at end of file diff --git a/estate/models/__init__.py b/estate/models/__init__.py index 5e1963c9d2f..784497bfea5 100644 --- a/estate/models/__init__.py +++ b/estate/models/__init__.py @@ -1 +1,6 @@ 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 238bed9fe3d..0910ffd699e 100644 --- a/estate/models/estate_property.py +++ b/estate/models/estate_property.py @@ -6,40 +6,55 @@ class EstateProperty(models.Model): _name = 'estate.property' _description = 'Real Estate Property' - name = fields.Char(string='Property Name', required=True, help='Enter the name of the property') - description = fields.Text(string='Property Description', help='Enter a description of the property') - postcode = fields.Char(string='Postcode', help='Enter the postcode of the property') - date_availability = fields.Date(string='Availability Date', help='Enter the date when the property becomes available', copy=False, default=lambda self: fields.Date.today() + relativedelta(months=3)) - expected_price = fields.Float(string='Expected Price', required=True, help='Enter the expected price of the property') - selling_price = fields.Float(string='Selling Price', help='Enter the selling price of the property', readonly=True, copy=False) - bedrooms = fields.Integer(string='Number of Bedrooms', help='Enter the number of bedrooms in the property', default=2) - living_area = fields.Integer(string='Living Area', help='Enter the living area of the property in square meters') - facades = fields.Integer(string='Number of Facades', help='Enter the number of facades of the property') - garage = fields.Boolean(string='Garage', help='Check if the property has a garage') - garden = fields.Boolean(string='Garden', help='Check if the property has a garden') - garden_area = fields.Integer(string='Garden Area', help='Enter the area of the garden in square meters') + name = fields.Char(string="Property Name", required=True, help='Enter the name of the property') + image = fields.Image(string="Property Image", max_width=1024, max_height=1024) + description = fields.Text(string="Property Description", help='Enter a description of the property') + postcode = fields.Char(string="Postcode", help='Enter the postcode of the property') + date_availability = fields.Date( + string="Availability Date", + help='Enter the date when the property becomes available', + copy=False, + default=lambda self: fields.Date.today() + relativedelta(months=3) + ) + expected_price = fields.Float(string="Expected Price", required=True, help='Enter the expected price of the property') + selling_price = fields.Float(string="Selling Price", help='Enter the selling price of the property', readonly=True, copy=False) + bedrooms = fields.Integer(string="Number of Bedrooms", help='Enter the number of bedrooms in the property', default=2) + living_area = fields.Integer(string="Living Area", help='Enter the living area of the property in square meters') + facades = fields.Integer(string="Number of Facades", help='Enter the number of facades of the property') + garage = fields.Boolean(string="Garage", help='Check if the property has a garage') + garden = fields.Boolean(string="Garden", help='Check if the property has a garden') + garden_area = fields.Integer(string="Garden Area", help='Enter the area of the garden in square meters') garden_orientation = fields.Selection( selection=[ - ('north', 'North'), - ('south', 'South'), - ('east', 'East'), - ('west', 'West'), + ('north', "North"), + ('south', "South"), + ('east', "East"), + ('west', "West"), ], - string='Garden Orientation', + string="Garden Orientation", help='Select the orientation of the garden' ) - active = fields.Boolean(string='Active', default=True, help='Set to False to archive the property') + active = fields.Boolean(string="Active", default=True, help='Set to False to archive the property') state = fields.Selection( selection=[ - ('new', 'New Offer'), - ('offer_received', 'Offer Received'), - ('offer_accepted', 'Offer Accepted'), - ('sold', 'Sold'), - ('cancelled', 'Cancelled') + ('new', "New Offer"), + ('offer_received', "Offer Received"), + ('offer_accepted', "Offer Accepted"), + ('sold', "Sold"), + ('cancelled', "Cancelled") ], - string='Status', + string="Status", required=True, copy=False, default='new', help='Current status of the property' ) + property_type_id = fields.Many2one('estate.property.type', string="Property Type") + buyer_id = fields.Many2one('res.partner', string="Buyer", copy=False) + seller_id = fields.Many2one('res.users', string="Salesperson", default=lambda self: self.env.user) + + tag_ids = fields.Many2many('estate.property.tag', string="Property Tags") + offer_ids = fields.One2many("estate.property.offer", "property_id", string="Offers") + + + diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py new file mode 100644 index 00000000000..a139abf2bad --- /dev/null +++ b/estate/models/estate_property_offer.py @@ -0,0 +1,21 @@ +from odoo import fields, models + +class EstatePropertyOffer(models.Model): + _name = 'estate.property.offer' + _description = 'Estate Property Offer' + + price = fields.Float(string="Price") + + status = fields.Selection( + selection=[ + ('accepted', "Accepted"), + ('refused', "Refused"), + ], + string="Status", + copy=False + ) + + partner_id = fields.Many2one('res.partner', required=True, string="Partner") + property_id = fields.Many2one('estate.property', required=True, string="Property") + + diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py new file mode 100644 index 00000000000..6e9e8a97e6f --- /dev/null +++ b/estate/models/estate_property_tag.py @@ -0,0 +1,8 @@ +from odoo import fields, models + +class EstatePropertyTag(models.Model): + _name = 'estate.property.tag' + _description = 'Property Tag' + + name = fields.Char(string="Name", required=True) + diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py new file mode 100644 index 00000000000..f063e7e0ad3 --- /dev/null +++ b/estate/models/estate_property_type.py @@ -0,0 +1,9 @@ +from odoo import fields, models + +class EstatePropertyType(models.Model): + _name = 'estate.property.type' + _description = 'Estate Property Type' + + name = fields.Char(string="Name", required=True) + + diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv index ab63520e22b..bd49f55738d 100644 --- a/estate/security/ir.model.access.csv +++ b/estate/security/ir.model.access.csv @@ -1,2 +1,6 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -estate.access_estate_property,access_estate_property,estate.model_estate_property,base.group_user,1,1,1,1 \ No newline at end of file +access_estate_property,estate.access_estate_property,model_estate_property,base.group_user,1,1,1,1 +access_estate_property_type,estate.access_estate_property_type,model_estate_property_type,base.group_user,1,1,1,1 +access_estate_property_tags,estate.access_estate_property_tags,model_estate_property_tag,base.group_user,1,1,1,1 + +access_estate_property_offer,estate.access_estate_property_offer,model_estate_property_offer,base.group_user,1,1,1,1 diff --git a/estate/static/description/icon1.png b/estate/static/description/icon1.png new file mode 100644 index 0000000000000000000000000000000000000000..3bc328b8c7c518419bee99017f7ff7d5d712e173 GIT binary patch literal 19495 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 literal 0 HcmV?d00001 diff --git a/estate/static/description/index.html b/estate/static/description/index.html new file mode 100644 index 00000000000..b0eea988291 --- /dev/null +++ b/estate/static/description/index.html @@ -0,0 +1,3 @@ +
+

This is a custom Real Estate application built following the official Odoo Server Framework 101 tutorial.

+
\ No newline at end of file diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml index ce491d4cbfb..933789bac30 100644 --- a/estate/views/estate_menus.xml +++ b/estate/views/estate_menus.xml @@ -1,9 +1,11 @@ - - + - + + + + - \ No newline at end of file + diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml new file mode 100644 index 00000000000..3a229ab15c9 --- /dev/null +++ b/estate/views/estate_property_offer_views.xml @@ -0,0 +1,28 @@ + + + estate.property.offer.list + estate.property.offer + + + + + + + + + + + estate.property.offer.form + estate.property.offer + + + + + + + + + + + + diff --git a/estate/views/estate_property_tags_views.xml b/estate/views/estate_property_tags_views.xml new file mode 100644 index 00000000000..2020c365a59 --- /dev/null +++ b/estate/views/estate_property_tags_views.xml @@ -0,0 +1,32 @@ + + + + estate.property.tag.list + estate.property.tag + + + + + + + + + estate.property.tag.form + estate.property.tag + +
+ + + + + +
+
+
+ + + 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..6864c710940 --- /dev/null +++ b/estate/views/estate_property_type_views.xml @@ -0,0 +1,33 @@ + + + + estate.property.type.list + estate.property.type + + + + + + + + + estate.property.type.form + estate.property.type + +
+ + + + + +
+
+
+ + + Property Types + estate.property.type + list,form + +
+ diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml index 2a4972bf5c6..0080ef35079 100644 --- a/estate/views/estate_property_views.xml +++ b/estate/views/estate_property_views.xml @@ -1,18 +1,20 @@ - - - + estate.property.list estate.property + + + + @@ -21,10 +23,13 @@ estate.property.form estate.property -
- + + + + + @@ -46,39 +51,53 @@ + + + + + + + + + + +
+ - estate.property.search - estate.property - - - - - - - - - - - + estate.property.view.search + estate.property + + + + + + + + + + + + - - Properties estate.property list,form
+ From cb8ff1b71ce109408c756ae80d0ba541aa575217 Mon Sep 17 00:00:00 2001 From: pasaw Date: Thu, 19 Mar 2026 06:25:31 +0530 Subject: [PATCH 5/8] [IMP] estate: fix linting issues --- estate/__manifest__.py | 2 -- estate/models/__init__.py | 2 -- estate/models/estate_property.py | 3 --- estate/models/estate_property_offer.py | 9 ++++----- estate/models/estate_property_tag.py | 2 +- estate/models/estate_property_type.py | 5 ++--- 6 files changed, 7 insertions(+), 16 deletions(-) diff --git a/estate/__manifest__.py b/estate/__manifest__.py index baaefba05f1..90fcf52daf7 100644 --- a/estate/__manifest__.py +++ b/estate/__manifest__.py @@ -22,6 +22,4 @@ 'demo/demo.xml', ], 'application': True, - } - diff --git a/estate/models/__init__.py b/estate/models/__init__.py index 784497bfea5..2f1821a39c1 100644 --- a/estate/models/__init__.py +++ b/estate/models/__init__.py @@ -2,5 +2,3 @@ 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 0910ffd699e..c812b799501 100644 --- a/estate/models/estate_property.py +++ b/estate/models/estate_property.py @@ -55,6 +55,3 @@ class EstateProperty(models.Model): tag_ids = fields.Many2many('estate.property.tag', string="Property 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 index a139abf2bad..6b783bb69b2 100644 --- a/estate/models/estate_property_offer.py +++ b/estate/models/estate_property_offer.py @@ -1,11 +1,12 @@ from odoo import fields, models + class EstatePropertyOffer(models.Model): _name = 'estate.property.offer' _description = 'Estate Property Offer' - + price = fields.Float(string="Price") - + status = fields.Selection( selection=[ ('accepted', "Accepted"), @@ -14,8 +15,6 @@ class EstatePropertyOffer(models.Model): string="Status", copy=False ) - + partner_id = fields.Many2one('res.partner', required=True, string="Partner") property_id = fields.Many2one('estate.property', required=True, string="Property") - - diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py index 6e9e8a97e6f..cc16de2e5e3 100644 --- a/estate/models/estate_property_tag.py +++ b/estate/models/estate_property_tag.py @@ -1,8 +1,8 @@ from odoo import fields, models + class EstatePropertyTag(models.Model): _name = 'estate.property.tag' _description = 'Property Tag' name = fields.Char(string="Name", required=True) - diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py index f063e7e0ad3..4b881c7e2af 100644 --- a/estate/models/estate_property_type.py +++ b/estate/models/estate_property_type.py @@ -1,9 +1,8 @@ from odoo import fields, models + class EstatePropertyType(models.Model): _name = 'estate.property.type' - _description = 'Estate Property Type' + _description = 'Estate Property Type' name = fields.Char(string="Name", required=True) - - From 722f7fb9d111660f46cd8e99fbef0f3fd6c6f215 Mon Sep 17 00:00:00 2001 From: pasaw Date: Thu, 19 Mar 2026 21:15:12 +0530 Subject: [PATCH 6/8] [IMP] estate: some improvements till chapter 7 - Added demo data for property types , property tags and enhanced search view --- .gitignore | 5 + README.md | 1734 +++++++++++++++++- estate/__manifest__.py | 2 + estate/demo/demo_tag.xml | 43 + estate/demo/demo_type.xml | 31 + estate/models/estate_property.py | 2 +- estate/views/estate_property_offer_views.xml | 1 + estate/views/estate_property_tags_views.xml | 3 +- estate/views/estate_property_type_views.xml | 1 + estate/views/estate_property_views.xml | 62 +- 10 files changed, 1854 insertions(+), 30 deletions(-) create mode 100644 estate/demo/demo_tag.xml create mode 100644 estate/demo/demo_type.xml diff --git a/.gitignore b/.gitignore index b6e47617de1..ccb1ceaef60 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,8 @@ dmypy.json # Pyre type checker .pyre/ + +# Ignore README files +README +README.* +README.md diff --git a/README.md b/README.md index a0158d919ee..43940eb3fe7 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,1727 @@ -# Odoo tutorials +# Odoo 19 — Complete Internals Guide +### From `./odoo-bin` to Browser and Back — Every Function, Every Table, Every Flow -This repository hosts the code for the bases of the modules used in the -[official Odoo tutorials](https://www.odoo.com/documentation/latest/developer/tutorials.html). +--- -It has 3 branches for each Odoo version: one for the bases, one for the -[Discover the JS framework](https://www.odoo.com/documentation/latest/developer/tutorials/discover_js_framework.html) -tutorial's solutions, and one for the -[Master the Odoo web framework](https://www.odoo.com/documentation/latest/developer/tutorials/master_odoo_web_framework.html) -tutorial's solutions. For example, `17.0`, `17.0-discover-js-framework-solutions` and -`17.0-master-odoo-web-framework-solutions`. +## HOW TO READ THIS + +Read top to bottom. Each chapter builds on the previous one. +By the end you will understand exactly what happens at every step — +which function calls which, what SQL gets executed, and how data moves. + +--- + +# CHAPTER 1 — Boot: `./odoo-bin` to HTTP Server Listening + +## The command you run + +```bash +./odoo-bin --addons-path=addons,../enterprise,../tutorials -d rd-demo -u estate +``` + +What each flag does: +``` +--addons-path tells Python where to find addon modules +-d rd-demo database name to connect to +-u estate upgrade (install/update) this module +``` + +## Step 1 — `odoo-bin` (community/odoo-bin:5) + +```python +if __name__ == "__main__": + odoo.cli.main() # that's it, just calls main() +``` + +## Step 2 — `main()` (community/odoo/cli/command.py:109) + +```python +def main(): + args = sys.argv[1:] + + # parse --addons-path early so we can find addon commands + if args[0].startswith('--addons-path='): + config._parse_config([args[0]]) # stores addons path in config + args = args[1:] + + # default command is 'server' if nothing specified + command_name = args[0] if args[0] not startswith('-' else 'server' + + command = find_command(command_name) # finds cli/server.py Server class + command().run(args) # Server().run(args) +``` + +## Step 3 — `Server.run()` (community/odoo/cli/server.py:125) + +```python +class Server(Command): + def run(self, args): + config.parser.prog = self.prog + main(args) # calls main() in same file +``` + +## Step 4 — `main(args)` (community/odoo/cli/server.py:95) + +```python +def main(args): + check_root_user() # warns if running as root + config.parse_config(args, setup_logging=True) + # NOW config contains: + # config['db_name'] = ['rd-demo'] + # config['update'] = {'estate': 1} + # config['addons_path']= ['addons', '../enterprise', '../tutorials'] + # config['http_port'] = 8069 + # config['workers'] = 0 (threaded mode) + + check_postgres_user() # exits if db user is 'postgres' + report_configuration() # logs version, addons paths + + for db_name in config['db_name']: + db._create_empty_database(db_name) # CREATE DATABASE rd-demo if not exists + config['init']['base'] = True # force base to load + + setup_pid_file() # write PID file + rc = server.start(preload=['rd-demo'], stop=False) + sys.exit(rc) +``` + +## Step 5 — `service/server.py start()` (community/odoo/service/server.py:1541) + +```python +def start(preload=None, stop=False): + load_server_wide_modules() # loads 'base', 'web' immediately + import odoo.http # creates odoo.http.root = Application() + + # pick server type: + if odoo.evented: server = GeventServer(odoo.http.root) + elif config['workers']: server = PreforkServer(odoo.http.root) + else: server = ThreadedServer(odoo.http.root) + # ^^^ default for development + + rc = server.run(preload=['rd-demo'], stop=False) + return rc +``` + +## Step 6 — `ThreadedServer.run()` (community/odoo/service/server.py:660) + +```python +def run(self, preload=None, stop=False): + with Registry._lock: + self.start(stop=False) # sets up signals + starts HTTP + rc = preload_registries(preload) # LOADS ALL MODULES + + self.cron_spawn() # starts cron threads + + while self.quit_signals_received == 0: # main loop, runs forever + self.process_limit() + time.sleep(60) # wakes on SIGTERM/SIGINT + + self.stop() # graceful shutdown +``` + +`self.start()` (community/odoo/service/server.py:597): +```python +def start(self, stop=False): + # set signal handlers: SIGINT=shutdown, SIGHUP=reload, SIGUSR1=cache stats + signal.signal(signal.SIGINT, self.signal_handler) + signal.signal(signal.SIGTERM, self.signal_handler) + signal.signal(signal.SIGHUP, self.signal_handler) + + if config['http_enable']: + self.http_spawn() # starts werkzeug in a thread + +def http_spawn(self): # community/odoo/service/server.py:589 + self.httpd = ThreadedWSGIServerReloadable( + self.interface, # '0.0.0.0' + self.port, # 8069 + self.app # odoo.http.root (WSGI app) + ) + Thread(target=self.httpd.serve_forever, daemon=True).start() + # HTTP server is now listening on port 8069 +``` + +--- + +# CHAPTER 2 — Module Loading (`-u estate`) + +## Step 7 — `preload_registries()` (community/odoo/service/server.py:1490) + +```python +def preload_registries(dbnames): + for dbname in dbnames: # ['rd-demo'] + threading.current_thread().dbname = dbname + update_module = config['update'] # {'estate': 1} from -u flag + + Registry.new(dbname, + update_module=True, + upgrade_modules=config['update'], # {'estate':1} + install_modules=config['init'], # {} + ) +``` + +## Step 8 — `Registry.new()` (community/odoo/orm/registry.py:129) + +The Registry is the central object. It holds ALL model classes for a database. + +```python +Registry.new('rd-demo', update_module=True, upgrade_modules=['estate']): + + registry = object.__new__(Registry) + registry.init('rd-demo') + # registry now has: + # registry.models = {} ← will hold all Model classes + # registry._init_modules = set() ← tracks what's loaded + # registry.ready = False + + cls.registries['rd-demo'] = registry # stored globally, one per database + + load_modules(registry, + update_module=True, + upgrade_modules=['estate']) + + registry._init = False + registry.ready = True # server is now ready for requests + registry.signal_changes() # notify other workers via DB +``` + +## Step 9 — `load_modules()` (community/odoo/modules/loading.py:332) + +```python +def load_modules(registry, update_module=False, upgrade_modules=()): + + initialize_sys_path() + # adds every path in --addons-path to sys.path and odoo.addons.__path__ + # so Python can find: import odoo.addons.estate + + with registry.cursor() as cr: + cr.execute("SET SESSION lock_timeout = '15s'") + + if not is_initialized(cr): + modules_db.initialize(cr) + # creates core tables: ir_module_module, ir_model, ir_model_fields + # ir_model_access, ir_model_data, ir_ui_view, ir_ui_menu, etc. + + # STEP 1: always load 'base' first + graph = ModuleGraph(cr, mode='update') + graph.extend(['base']) + env = api.Environment(cr, SUPERUSER_ID, {}) + load_module_graph(env, graph, update_module=True) + + # STEP 2: discover all modules, mark estate as 'to upgrade' + env['ir.module.module'].update_list() + # scans all addons paths, reads __manifest__.py files + # inserts missing modules into ir_module_module table + + # mark estate for upgrade + estate_module = env['ir.module.module'].search([('name','=','estate')]) + estate_module.button_upgrade() + # sets state = 'to upgrade' in ir_module_module + + # STEP 3: build full graph with all installed modules + estate + graph2 = ModuleGraph(cr, mode='update') + graph2.extend(all_installed_modules) + load_module_graph(env, graph2, update_module=True) +``` + +## Step 10 — Manifest Reading + +Before `load_module_graph` runs, `ModuleGraph` reads every `__manifest__.py`: + +```python +# community/odoo/modules/module.py +Manifest.for_addon('estate') + # looks in each path of odoo.addons.__path__: + # finds: /home/odoo/odoo19/tutorials/estate/__manifest__.py + with open(path) as f: + data = ast.literal_eval(f.read()) + # data = { + # 'name': 'Real Estate', + # 'version': '1.0', + # 'depends': ['base'], + # 'data': [ + # 'security/ir.model.access.csv', + # 'views/estate_property_views.xml', + # 'views/estate_menus.xml', + # ], + # 'installable': True, + # 'application': True, + # } + return Manifest(data) +``` + +**Dependency resolution** — `graph.extend(['estate'])`: +``` +estate depends on → ['base'] +base depends on → [] (root) + +Load order: base → estate +``` + +## Step 11 — `load_module_graph()` (community/odoo/modules/loading.py:107) + +This is the core loop. Runs for EACH module in dependency order: + +```python +def load_module_graph(env, graph, update_module=False): + migrations = MigrationManager(cr, graph) + + for index, package in enumerate(graph): + module_name = package.name # e.g. 'estate' + update_operation = ( + 'install' if package.state == 'to install' else + 'upgrade' if package.state == 'to upgrade' else # ← our case + None + ) + + # ── 1. PRE-MIGRATION ───────────────────────────── + if update_operation == 'upgrade': + migrations.migrate_module(package, 'pre') + # runs scripts/pre-migrate-*.py if they exist + + # ── 2. PYTHON IMPORT ───────────────────────────── + load_openerp_module('estate') + # → __import__('odoo.addons.estate') + # → runs estate/__init__.py which does: from . import models + # → runs estate/models/__init__.py which does: from . import estate_property + # → runs estate/models/estate_property.py + # → Python reads class EstateProperty(models.Model): + # _name = 'estate.property' + # name = fields.Char(required=True) + # expected_price = fields.Float(required=True) + # ... + # → MetaModel metaclass fires __init_subclass__ + # → registers class in MetaModel._module_to_models__['estate'] + + # ── 3. PRE-INIT HOOK ───────────────────────────── + if update_operation == 'install': + pre_init = package.manifest.get('pre_init_hook') + if pre_init: + getattr(py_module, pre_init)(env) # calls your hook function + + # ── 4. REGISTER MODELS ─────────────────────────── + model_names = registry.load(package) + # for each class in MetaModel._module_to_models__['estate']: + # registry.models['estate.property'] = + # registry.models['estate.property.type'] = + # registry.models['estate.property.offer']= + # returns ['estate.property', 'estate.property.type', ...] + + # ── 5. CREATE/UPDATE DATABASE TABLES ───────────── + if update_operation: + registry._setup_models__(cr, []) # wire up field descriptors + registry.init_models(cr, model_names, {'module': 'estate'}, install=True) + # → for each model: model._auto_init() ← CREATES TABLES + # → env['ir.model']._reflect_models() ← INSERT INTO ir_model + # → env['ir.model.fields']._reflect_fields() ← INSERT INTO ir_model_fields + # → registry.check_indexes() ← CREATE INDEX + # → registry.check_foreign_keys() ← ADD FOREIGN KEY + + # ── 6. LOAD DATA FILES ─────────────────────────── + if update_operation == 'install': + load_data(env, idref, 'init', kind='data', package=package) + # for each file in manifest['data']: + # convert_file(env, 'estate', filename, idref, 'init') + + # ── 7. POST-MIGRATION ──────────────────────────── + migrations.migrate_module(package, 'post') + + # ── 8. TRANSLATIONS ────────────────────────────── + module._update_translations() + + # ── 9. MARK INSTALLED ──────────────────────────── + module.write({'state': 'installed', 'latest_version': '1.0'}) + env.cr.commit() # COMMIT after each module + + # ── 10. POST-INIT HOOK ─────────────────────────── + if update_operation == 'install': + post_init = package.manifest.get('post_init_hook') + if post_init: + getattr(py_module, post_init)(env) +``` + +--- + +# CHAPTER 3 — Table Creation: Every Field Type to SQL + +## Step 12 — `_auto_init()` (community/odoo/orm/models.py:3169) + +Called for every model during `registry.init_models()`: + +```python +def _auto_init(self): + cr = self.env.cr + must_create_table = not sql.table_exists(cr, self._table) + # self._table = 'estate_property' (dots replaced with underscores) + + if must_create_table: + sql.create_model_table(cr, self._table, self._description, [ + (field.name, field.column_type[1] + (" NOT NULL" if field.required else ""), field.string) + for field in self._fields.values() + if field.name != 'id' and field.store and field.column_type + ]) + # SQL: + # CREATE TABLE estate_property ( + # id SERIAL NOT NULL, + # name varchar NOT NULL, + # description text, + # postcode varchar, + # expected_price numeric NOT NULL, + # selling_price numeric, + # bedrooms int4, + # state varchar NOT NULL, + # active bool, + # property_type_id int4, ← Many2one → integer + # salesman_id int4, + # buyer_id int4, + # create_uid int4, + # create_date timestamp, + # write_uid int4, + # write_date timestamp, + # PRIMARY KEY(id) + # ) + else: + # table exists → check for NEW fields only + columns = sql.table_columns(cr, self._table) + for field in self._fields.values(): + if field.store: + field.update_db(self, columns) # ALTER TABLE if needed + + self._add_sql_constraints() + # for each constraint in _sql_constraints: + # ALTER TABLE estate_property ADD CONSTRAINT ... +``` + +## Step 13 — Field Types: Python Class → PostgreSQL Column + +Every field type is defined in `community/odoo/orm/`: + +``` +PYTHON FIELD CLASS FILE _column_type POSTGRESQL COLUMN +───────────────────────────────────────────────────────────────────────────────────── +Boolean fields_misc.py:22 ('bool','bool') BOOLEAN +Integer fields_numeric.py:17 ('int4','int4') INTEGER +Float fields_numeric.py:60 ('numeric','numeric') NUMERIC +Char(size=255) fields_textual.py:461 ('varchar',pg_varchar) VARCHAR(255) +Char() fields_textual.py:461 ('varchar','varchar') VARCHAR +Text fields_textual.py:526 ('text','text') TEXT +Html fields_textual.py:541 ('text','text') TEXT +Date fields_temporal.py:106 ('date','date') DATE +Datetime fields_temporal.py:191 ('timestamp','ts...') TIMESTAMP +Selection fields_selection.py:20 ('varchar',pg_varchar) VARCHAR +Binary(attachment=F) fields_binary.py:30 ('bytea','bytea') BYTEA +Binary(attachment=T) fields_binary.py:30 None NO COLUMN (ir_attachment) +Json fields_misc.py:65 ('jsonb','jsonb') JSONB +Many2one fields_relational.py:213('int4','int4') INTEGER + FK +One2many fields_relational.py:836 None NO COLUMN (inverse) +Many2many fields_relational.py:1198 None NO COLUMN (junction table) +``` + +## Step 14 — `Field.update_db()` (community/odoo/orm/fields.py:1096) + +```python +def update_db(self, model, columns): + if not self.column_type: + return False # One2many, Many2many → nothing to do in this table + + column = columns.get(self.name) # existing column info from PostgreSQL + + self.update_db_column(model, column) # CREATE or ALTER column + self.update_db_notnull(model, column) # handle NOT NULL constraint + + return not column # True = new column (may need recompute) +``` + +`update_db_column()` (fields.py:1132): +```python +def update_db_column(self, model, column): + if not column: + # column does not exist yet + sql.create_column(model.env.cr, model._table, self.name, self.column_type[1], self.string) + # ALTER TABLE estate_property ADD COLUMN expected_price numeric + return + if column['udt_name'] == self.column_type[0]: + return # already the right type, skip + self._convert_db_column(model, column) + # ALTER TABLE estate_property ALTER COLUMN x TYPE new_type USING x::new_type +``` + +## Step 15 — Many2one: FK Constraint + +Many2one stores as `int4` column but also gets a FK: + +```sql +-- Column created by update_db_column(): +ALTER TABLE estate_property ADD COLUMN property_type_id int4 + +-- FK created by registry.check_foreign_keys(): +ALTER TABLE estate_property + ADD CONSTRAINT estate_property_property_type_id_fkey + FOREIGN KEY (property_type_id) + REFERENCES estate_property_type(id) + ON DELETE set null -- from field.ondelete (default for optional M2o) + -- required M2o defaults to ondelete='restrict' +``` + +## Step 16 — Many2many: Junction Table + +```python +# estate.property has tags = fields.Many2many('estate.tag') +# estate.tag table name = 'estate_tag' +# estate.property table = 'estate_property' + +# Auto-generated relation table name (alphabetical order): +# 'estate_property_estate_tag_rel' +# column1 = 'estate_property_id' +# column2 = 'estate_tag_id' +``` + +SQL created: +```sql +CREATE TABLE estate_property_estate_tag_rel ( + estate_property_id INTEGER NOT NULL + REFERENCES estate_property(id) ON DELETE cascade, + estate_tag_id INTEGER NOT NULL + REFERENCES estate_tag(id) ON DELETE cascade, + PRIMARY KEY (estate_property_id, estate_tag_id) +) +``` + +## Step 17 — One2many: No Column + +```python +# offer_ids = fields.One2many('estate.property.offer', 'property_id') +# One2many has NO column in estate_property table +# It works by querying the OTHER table: +# SELECT * FROM estate_property_offer WHERE property_id = +# The 'property_id' column lives on estate_property_offer (the Many2one side) +``` + +--- + +# CHAPTER 4 — Data Files: XML to Database Rows + +After tables are created, `load_data()` processes `manifest['data']` files. + +## Step 18 — `convert_file()` (community/odoo/tools/convert.py:667) + +```python +def convert_file(env, module, filename, idref, mode, noupdate): + ext = os.path.splitext(filename)[1].lower() + with file_open(pathname, 'rb') as fp: + if ext == '.csv': convert_csv_import(env, module, ...) + elif ext == '.xml': convert_xml_import(env, module, fp, ...) + elif ext == '.sql': convert_sql_import(env, fp) +``` + +## Step 19 — XML Parsing: `_tag_record()` (convert.py:336) + +For a view XML file like `views/estate_property_views.xml`: + +```xml + + estate.property.list + estate.property + + + + + + + + +``` + +`_tag_record()` does: +```python +rec_model = 'ir.ui.view' +rec_id = 'estate_property_view_list' +xid = 'estate.estate_property_view_list' + +res = { + 'name': 'estate.property.list', + 'model': 'estate.property', + 'arch': '...', +} + +data = {'xml_id': xid, 'values': res, 'noupdate': False} +record = env['ir.ui.view']._load_records([data], update=False) +# → ir.ui.view.create({'name':..., 'model':..., 'arch':...}) +# SQL: INSERT INTO ir_ui_view (name, model, arch_db, type, priority, mode, active, ...) +# VALUES ('estate.property.list', 'estate.property', '', 'list', 16, 'primary', true, ...) +# RETURNING id + +self.idref['estate.estate_property_view_list'] = record.id # 55 +``` + +## Step 20 — `ir_ui_view` Table Structure + +```sql +-- What's stored in PostgreSQL for every view: +CREATE TABLE ir_ui_view ( + id SERIAL PRIMARY KEY, + name VARCHAR NOT NULL, -- 'estate.property.list' + model VARCHAR, -- 'estate.property' + key VARCHAR, -- 'estate.estate_property_view_list' + priority INTEGER DEFAULT 16, -- lower = higher priority + type VARCHAR, -- 'list','form','search','kanban','graph','pivot' + arch_db TEXT, -- THE ACTUAL XML STORED HERE + arch_fs VARCHAR, -- file path (used in dev-xml mode) + arch_updated BOOLEAN, -- True if user modified via Studio + arch_prev TEXT, -- previous arch (soft reset) + inherit_id INTEGER REFERENCES ir_ui_view(id), -- for extension views + mode VARCHAR DEFAULT 'primary', -- 'primary' or 'extension' + active BOOLEAN DEFAULT true, + create_uid INTEGER, create_date TIMESTAMP, + write_uid INTEGER, write_date TIMESTAMP +) +``` + +## Step 21 — `_tag_menuitem()` (convert.py:275) + +```xml + + + +``` + +```python +def _tag_menuitem(self, rec, parent=None): + values = { + 'parent_id': False, + 'active': True, + 'sequence': 10, + 'name': 'Real Estate', + 'web_icon': 'estate,static/description/icon.png', + } + + if rec.get('action'): + act = self.env.ref('estate.estate_property_action').sudo() + values['action'] = "ir.actions.act_window,%d" % act.id + # Reference field: stores model_name,id as string + + data = {'xml_id': 'estate.estate_menu_root', 'values': values, 'noupdate': False} + menu = self.env['ir.ui.menu']._load_records([data], update=False) + # SQL: INSERT INTO ir_ui_menu (name, parent_id, sequence, active, action, web_icon, parent_path) + # VALUES ('Real Estate', NULL, 10, true, 'ir.actions.act_window,42', + # 'estate,static/description/icon.png', '/7/') + # RETURNING id + + for child in rec.iterchildren('menuitem'): + self._tag_menuitem(child, parent=menu.id) # recurse for children +``` + +## Step 22 — `ir_ui_menu` Table Structure + +```sql +CREATE TABLE ir_ui_menu ( + id SERIAL PRIMARY KEY, + name VARCHAR NOT NULL, -- 'Properties' + parent_id INTEGER REFERENCES ir_ui_menu(id), + parent_path VARCHAR, -- '/1/7/' (materialized path for tree) + sequence INTEGER DEFAULT 10, + active BOOLEAN DEFAULT true, + action VARCHAR, -- 'ir.actions.act_window,42' + web_icon VARCHAR, -- 'estate,static/description/icon.png' + create_uid INTEGER, create_date TIMESTAMP, + write_uid INTEGER, write_date TIMESTAMP +) +-- Many2many for group security: +CREATE TABLE ir_ui_menu_group_rel ( + menu_id INTEGER REFERENCES ir_ui_menu(id), + gid INTEGER REFERENCES res_groups(id) +) +``` + +## Step 23 — `ir.actions.act_window` Table Structure + +```xml + + Properties + estate.property + list,form + {'search_default_state': 'new'} + +``` + +```sql +-- ir.actions.act_window inherits from ir.actions via _inherits +-- Two tables are used: + +-- Parent table (base action): +INSERT INTO ir_actions (name, type, ...) +VALUES ('Properties', 'ir.actions.act_window', ...) +RETURNING id → 42 + +-- Child table (window-specific fields): +INSERT INTO ir_act_window (id, res_model, view_mode, context, domain, ...) +VALUES (42, 'estate.property', 'list,form', '{"search_default_state":"new"}', '[]', ...) +``` + +## Step 24 — CSV: `ir.model.access` + +```csv +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 +``` + +`convert_csv_import()` → loads via `ir.model.access._load_records()`: +```sql +INSERT INTO ir_model_access (name, model_id, group_id, perm_read, perm_write, perm_create, perm_unlink) +VALUES ('access_estate_property', , , true, true, true, true) +``` + +--- + +# CHAPTER 5 — HTTP Request Handling + +Server is running, user opens browser to `http://localhost:8069`. + +## Step 25 — WSGI Entry Point (community/odoo/http.py:2758) + +Every single HTTP request enters here: + +```python +class Application: + def __call__(self, environ, start_response): + # environ contains everything about the request: + # environ['REQUEST_METHOD'] = 'POST' + # environ['PATH_INFO'] = '/web/dataset/call_kw/estate.property/search_read' + # environ['HTTP_COOKIE'] = 'session_id=abc123...' + # environ['wsgi.input'] = + + # reset per-request counters on current thread + current_thread.query_count = 0 + current_thread.query_time = 0 + current_thread.perf_t0 = real_time() + + with HTTPRequest(environ) as httprequest: # werkzeug Request wrapper + request = Request(httprequest) + _request_stack.push(request) # thread-local + + request._post_init() + # reads session_id cookie → loads session from FileSystemSessionStore + # session contains: uid=2, db='rd-demo', context={'lang':'en_US'} + # sets request.db = 'rd-demo' + + if self.get_static_file(httprequest.path): + response = request._serve_static() # /estate/static/... files + elif request.db: + response = request._serve_db() # ← normal flow + else: + response = request._serve_nodb() # login page etc. + + return response(environ, start_response) +``` + +## Step 26 — `_serve_db()` (community/odoo/http.py:2213) + +```python +def _serve_db(self): + cr = None + try: + registry = Registry('rd-demo') # get from global cache + cr = registry.cursor(readonly=True) # psycopg2 connection, READ ONLY + self.registry = registry.check_signaling(cr) + # check_signaling: if any module was updated, reload registry + + threading.current_thread().dbname = 'rd-demo' + + # create Environment (cr, uid, context) + self.env = odoo.api.Environment(cr, self.session.uid, self.session.context) + # self.env['estate.property'] → gives model recordset bound to this cursor + user + + # find which controller to call + rule, args = self.registry['ir.http']._match(self.httprequest.path) + # werkzeug routing: '/web/dataset/call_kw/estate.property/search_read' + # matches route: '/web/dataset/call_kw/' + # rule.endpoint = DataSet.call_kw method + + self._set_request_dispatcher(rule) + # type='jsonrpc' → dispatcher = JsonRPCDispatcher + + serve_func = self._serve_ir_http(rule, args) + readonly = rule.endpoint.routing['readonly'] + # search_read has @api.readonly → True + # create/write/unlink → False (need RW cursor) + + if readonly: + threading.current_thread().cursor_mode = 'ro' + return service_model.retrying(serve_func, env=self.env) + else: + # close RO cursor, open RW cursor + cr.close() + cr = registry.cursor() # READ-WRITE cursor + self.env = self.env(cr=cr) + threading.current_thread().cursor_mode = 'rw' + return service_model.retrying(serve_func, env=self.env) + finally: + self.env = None + if cr: cr.close() # always return cursor to pool +``` + +## Step 27 — `ir.http._match()` (community/odoo/addons/base/models/ir_http.py:205) + +```python +@classmethod +def _match(cls, path_info): + rule, args = request.env['ir.http'].routing_map()\ + .bind_to_environ(request.httprequest.environ)\ + .match(path_info=path_info, return_rule=True) + return rule, args + # werkzeug does the actual URL matching + # '/web/dataset/call_kw/estate.property/search_read' + # → matches rule '/web/dataset/call_kw/' + # → args = {'path': 'estate.property/search_read'} + # → rule.endpoint = DataSet.call_kw (the controller method) +``` + +--- + +# CHAPTER 6 — JSON-RPC: Browser to ORM + +## Step 28 — JSON Body from Browser + +Every ORM call from the browser looks like this: + +```json +POST /web/dataset/call_kw/estate.property/search_read +Content-Type: application/json + +{ + "jsonrpc": "2.0", + "id": 1, + "method": "call", + "params": { + "model": "estate.property", + "method": "search_read", + "args": [[["state", "!=", "sold"]]], + "kwargs": { + "fields": ["name", "expected_price", "state"], + "limit": 80, + "offset": 0, + "context": {"lang": "en_US", "tz": "UTC", "uid": 2} + } + } +} +``` + +## Step 29 — `JsonRPCDispatcher.dispatch()` + +```python +# parses JSON body +params = request.get_json_data()['params'] +# {'model':'estate.property', 'method':'search_read', 'args':[...], 'kwargs':{...}} + +result = endpoint(**params) +# calls: DataSet.call_kw( +# model='estate.property', +# method='search_read', +# args=[[['state','!=','sold']]], +# kwargs={'fields':[...],'limit':80,'context':{...}}, +# path='estate.property/search_read' +# ) + +response = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "result": result +}) +return Response(response, content_type='application/json') +``` + +## Step 30 — Controller (community/addons/web/controllers/dataset.py:29) + +```python +class DataSet(http.Controller): + + @http.route(['/web/dataset/call_kw', '/web/dataset/call_kw/'], + type='jsonrpc', auth="user", readonly=_call_kw_readonly) + def call_kw(self, model, method, args, kwargs, path=None): + # model = 'estate.property' + # method = 'search_read' + # args = [[['state','!=','sold']]] + # kwargs = {'fields':[...],'limit':80} + + return call_kw(request.env[model], method, args, kwargs) + # request.env['estate.property'] → empty recordset of estate.property + # bound to current cursor + current user +``` + +## Step 31 — `service.model.call_kw()` (community/odoo/service/model.py:70) + +```python +def call_kw(model, name, args, kwargs): + # model = estate.property() (empty recordset) + # name = 'search_read' + # args = [[['state','!=','sold']]] + # kwargs= {'fields':[...],'limit':80,'context':{...}} + + # SECURITY CHECK: method must be public + method = get_public_method(model, name) + # get_public_method() [service/model.py:44]: + # if name.startswith('_'): raise AccessError (private method) + # if method._api_private == True: raise AccessError (@api.private) + # returns the actual function: BaseModel.search_read + + # search_read is decorated @api.model so no ids needed + if getattr(method, '_api_model', False): + recs = model # use model as-is (no browse needed) + + # pop context from kwargs, apply to recordset + kwargs = dict(kwargs) + context = kwargs.pop('context', None) or {} + recs = recs.with_context(context) + # creates new env with context merged in + + result = method(recs, *args, **kwargs) + # = EstateProperty.search_read( + # domain=[['state','!=','sold']], + # fields=['name','expected_price','state'], + # limit=80 + # ) + + # result is list[dict] → returned as-is (not a BaseModel) + return result +``` + +## Step 32 — `retrying()` (community/odoo/service/model.py:156) + +Wraps every ORM call with concurrency retry logic: + +```python +def retrying(func, env): + for tryno in range(1, 6): # up to 5 attempts + try: + result = func() # call the ORM method + if not env.cr._closed: + env.cr.flush() # write pending SQL to DB + break + except SerializationFailure: # two transactions conflict + env.cr.rollback() + env.transaction.reset() + env.registry.reset_changes() + wait = random.uniform(0, 2 ** tryno) # exponential backoff + time.sleep(wait) + # retry... + except IntegrityError: # duplicate key etc. + raise ValidationError("Operation cannot be completed: ...") + + env.cr.commit() # final commit + env.registry.signal_changes() # notify other workers + return result +``` + +--- + +# CHAPTER 7 — ORM: Python to SQL + +## Step 33 — `search_read()` (community/odoo/orm/models.py:5740) + +```python +def search_read(self, domain=None, fields=None, offset=0, limit=None, order=None): + # domain = [['state','!=','sold']] + # fields = ['name','expected_price','state'] + # limit = 80 + + if not fields: + fields = list(self.fields_get(attributes=())) # all fields + + records = self.search_fetch(domain or [], fields, offset=0, limit=80) + return records._read_format(fnames=fields) +``` + +## Step 34 — `search_fetch()` (models.py:1383) + +```python +@api.model +@api.readonly +def search_fetch(self, domain, field_names=None, offset=0, limit=None, order=None): + + # Step A: build Query object (NO SQL yet, just an AST) + query = self._search(domain, offset=0, limit=80, order='id') + + if query.is_empty(): + return self.browse() # optimization: skip if nothing to find + + fields_to_fetch = self._determine_fields_to_fetch(field_names) + # checks field access rights for each field + # returns list of Field objects: [Field(name), Field(expected_price), Field(state)] + + return self._fetch_query(query, fields_to_fetch) +``` + +## Step 35 — `_search()` (models.py:5319) + +Builds a Query object — no SQL executed yet: + +```python +def _search(self, domain, offset=0, limit=None, order=None): + + # 1. ACCESS CHECK + self.browse().check_access('read') + # → SELECT perm_read FROM ir_model_access + # WHERE model_id=(SELECT id FROM ir_model WHERE model='estate.property') + # AND (group_id IN (2,3,4) OR group_id IS NULL) -- user's groups + # LIMIT 1 + # raises AccessError if no row returned + + domain = Domain(domain) # [['state','!=','sold']] + + # 2. ADD active=True FILTER (if model has active field) + domain &= Domain('active', '=', True) + # domain is now: [['state','!=','sold'], ['active','=',True]] + + # 3. BUILD QUERY OBJECT + query = Query(self.env, 'estate_property', SQL.identifier('estate_property')) + query.add_where(domain._to_sql(self, 'estate_property', query)) + # _to_sql converts domain to: + # estate_property.state != 'sold' AND estate_property.active = true + + # 4. RECORD RULES (row-level security) + sec_domain = env['ir.rule']._compute_domain('estate.property', 'read') + # e.g. salesperson can only see their own: [['salesman_id','=',uid]] + query.add_where(sec_domain._to_sql(...)) + + # 5. ORDER/LIMIT/OFFSET + query.order = self._order_to_sql('id', query) # ORDER BY estate_property.id + query.limit = 80 + query.offset = 0 + + return query # ← STILL NO SQL EXECUTED +``` + +## Step 36 — `_fetch_query()` (models.py:3876) + +Here the SQL is actually executed: + +```python +def _fetch_query(self, query, fields): + # Separate column fields from non-column fields + column_fields = [name, expected_price, state] # have column_type + other_fields = [] # computed non-stored + + # Build SELECT terms + sql_terms = [SQL.identifier('estate_property', 'id')] + for field in column_fields: + sql = self._field_to_sql('estate_property', field.name, query) + sql_terms.append(sql) + + # ── EXECUTE SQL ────────────────────────────────────────────────────── + rows = self.env.execute_query(query.select(*sql_terms)) + # execute_query() [environments.py:527]: + # env.flush_query(query) ← flush pending writes that query touches + # env.cr.execute(query) ← psycopg2 sends SQL to PostgreSQL + # return env.cr.fetchall() ← get results back + + # ACTUAL SQL sent to PostgreSQL: + # SELECT estate_property.id, + # estate_property.name, + # estate_property.expected_price, + # estate_property.state + # FROM estate_property + # WHERE estate_property.state != 'sold' + # AND estate_property.active = true + # AND estate_property.salesman_id = 2 ← from record rule + # ORDER BY estate_property.id + # LIMIT 80 + # OFFSET 0 + + # rows = [ + # (1, 'Beach House', 200000.0, 'new'), + # (2, 'Mountain Villa', 350000.0, 'offer_received'), + # ] + + # unzip rows into columns + column_values = zip(*rows) + ids = next(column_values) # (1, 2) + fetched = self.browse(ids) # recordset: estate.property(1, 2) + + # ── POPULATE CACHE ─────────────────────────────────────────────────── + for field, values in zip(column_fields, column_values): + field._insert_cache(fetched, values) + # cache now contains: + # env.cache[(estate.property, 'name')] = {1: 'Beach House', 2: 'Mountain Villa'} + # env.cache[(estate.property, 'expected_price')] = {1: 200000.0, 2: 350000.0} + # env.cache[(estate.property, 'state')] = {1: 'new', 2: 'offer_received'} + + return fetched # estate.property(1, 2) +``` + +## Step 37 — `_read_format()` (models.py:3706) + +Converts recordset + cache into list of dicts: + +```python +def _read_format(self, fnames, load='_classic_read'): + # self = estate.property(1, 2) + # fnames = ['name', 'expected_price', 'state'] + + data = [(record, {'id': record.id}) for record in self] + # data = [(record1, {'id': 1}), (record2, {'id': 2})] + + for name in fnames: + field = self._fields[name] + convert = field.convert_to_read + for record, vals in data: + vals[name] = convert(record[name], record, use_display_name=True) + # record[name] reads from cache (NO SQL) + # convert_to_read: + # Char → str as-is + # Float → float as-is + # Selection → str (the key, not label) + # Many2one → (id, display_name) tuple + + result = [vals for record, vals in data if vals] + # result = [ + # {'id': 1, 'name': 'Beach House', 'expected_price': 200000.0, 'state': 'new'}, + # {'id': 2, 'name': 'Mountain Villa', 'expected_price': 350000.0, 'state': 'offer_received'}, + # ] + return result +``` + +## Step 38 — Response back to browser + +``` +_read_format() → list[dict] + ↑ returned to call_kw() → same list[dict] + ↑ returned to DataSet.call_kw → same list[dict] + ↑ returned to JsonRPCDispatcher + json.dumps({"jsonrpc":"2.0","id":1,"result":[{...},{...}]}) + ↑ retrying() calls env.cr.commit() + ↑ _serve_db() calls cr.close() (cursor returned to pool) + ↑ Application.__call__ returns Response to werkzeug + werkzeug sends HTTP 200 with JSON body to browser +``` + +--- + +# CHAPTER 8 — ORM Write Operations + +## Step 39 — `create()` (models.py:4608) + +``` +RPC call: estate.property.create({'name':'Beach House','expected_price':200000,'state':'new'}) +``` + +```python +def create(self, vals_list): + # vals_list = [{'name':'Beach House','expected_price':200000,'state':'new'}] + + self.check_access('create') + # → ir.model.access SQL check for 'create' permission + + new_vals_list = self._prepare_create_values(vals_list) # [models.py:4764] + # _add_missing_default_values(): + # bedrooms default=2 → vals['bedrooms'] = 2 + # active default=True → vals['active'] = True + # garden default=False → vals['garden'] = False + # strip: id, parent_path, create_uid, write_uid etc. + # add magic fields: + # vals['create_uid'] = 2 + # vals['create_date'] = '2026-03-19 10:30:00' + # vals['write_uid'] = 2 + # vals['write_date'] = '2026-03-19 10:30:00' + + # classify fields: + stored = {'name':'Beach House','expected_price':200000,'state':'new', + 'bedrooms':2,'active':True,'create_uid':2,...} + inversed = {} # no inverse fields in these vals + inherited= {} # no _inherits on estate.property + + records = self._create(data_list) # [models.py:4844] + # ── ACTUAL INSERT ──────────────────────────────────────────────────── + # cr.execute(SQL( + # 'INSERT INTO estate_property (active,bedrooms,create_date,create_uid, + # expected_price,name,state,write_date,write_uid) + # VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) + # RETURNING "id"', + # True, 2, '2026-03-19...', 2, 200000.0, 'Beach House', 'new', '2026-03-19...', 2 + # )) + # → id = 15 + + # records = estate.property(15,) + + # populate cache: + # env.cache[(estate.property,'name')][15] = 'Beach House' + # env.cache[(estate.property,'expected_price')][15] = 200000.0 + # env.cache[(estate.property,'state')][15] = 'new' + + # schedule computed fields: + records.modified(self._fields, create=True) + # marks total_area, best_price etc. as "needs recompute" + + self._validate_fields(vals) # run @api.constrains methods + + # retrying() will call env.cr.flush() + env.cr.commit() + + return records # estate.property(15,) + # call_kw() converts this to: 15 (the new id) + +# JSON response: {"jsonrpc":"2.0","id":1,"result":15} +``` + +## Step 40 — `write()` (models.py:4331) + +``` +RPC call: estate.property.browse([15]).write({'state':'offer_accepted','selling_price':195000}) +``` + +```python +def write(self, vals): + self.check_access('write') + # + record rule check: can this user write on record 15? + + # add magic fields + vals['write_uid'] = 2 + vals['write_date'] = '2026-03-19 10:35:00' + + # for relational fields: mark dependents BEFORE change + self.modified(fnames_modifying_relations, before=True) + + for field, value in sorted(field_values, key=lambda x: x[0].write_sequence): + field.write(self, value) + # puts value in cache for now (batched) + + self.modified(vals) # mark downstream computed fields + + # ── ACTUAL UPDATE ──────────────────────────────────────────────────── + # _write_multi() [models.py:4521] + # env.execute_query(SQL(""" + # UPDATE estate_property + # SET selling_price = __tmp.selling_price::numeric, + # state = __tmp.state::varchar, + # write_date = __tmp.write_date::timestamp, + # write_uid = __tmp.write_uid::int4 + # FROM (VALUES (15, 195000.0, 'offer_accepted', '2026-03-19...', 2)) + # AS "__tmp"("id", selling_price, state, write_date, write_uid) + # WHERE estate_property."id" = "__tmp"."id" + # """)) + + self._validate_fields(vals) # run @api.constrains + + return True +# JSON response: {"jsonrpc":"2.0","id":1,"result":true} +``` + +## Step 41 — `unlink()` (models.py:4191) + +```python +def unlink(self): + self.check_access('unlink') + + for func in self._ondelete_methods: + func(self) # e.g. archive related offers first + + self.env.flush_all() # write all pending changes before delete + + self.modified(self._fields, before=True) # mark dependents + + # ── ACTUAL DELETE ──────────────────────────────────────────────────── + cr.execute(SQL( + "DELETE FROM estate_property WHERE id IN %s", + (15,) + )) + # PostgreSQL ON DELETE CASCADE removes: + # estate_property_offer rows WHERE property_id=15 + # estate_property_estate_tag_rel rows WHERE estate_property_id=15 + + # clean up XML IDs + Data.search([('model','=','estate.property'),('res_id','in',[15])]).unlink() + # DELETE FROM ir_model_data WHERE model='estate.property' AND res_id=15 + + return True +# JSON response: {"jsonrpc":"2.0","id":1,"result":true} +``` + +--- + +# CHAPTER 9 — View Rendering at Runtime + +## Step 42 — User Opens the List View + +User clicks "Properties" menu in browser. + +**JS calls**: `POST /web/dataset/call_kw/estate.property/get_views` +```json +{ + "params": { + "model": "estate.property", + "method": "get_views", + "args": [], + "kwargs": { + "views": [[false, "list"], [false, "search"]] + } + } +} +``` + +## Step 43 — `get_views()` (community/odoo/addons/base/models/ir_ui_view.py:2893) + +```python +def get_views(self, views, options=None): + result = {} + result['views'] = { + v_type: self.get_view(v_id, v_type) + for [v_id, v_type] in views + } + # calls get_view(False, 'list') and get_view(False, 'search') + + result['models'] = { + model: {'fields': env[model].fields_get(allfields=model_fields)} + for model, model_fields in models.items() + } + return result +``` + +## Step 44 — `_get_view_cache()` (ir_ui_view.py:3079) + +```python +@tools.ormcache('self._get_view_cache_key(view_id, view_type)') +def _get_view_cache(self, view_id=None, view_type='list'): + arch, view = self._get_view(view_id, view_type) + arch, models = self._get_view_postprocessed(view, arch) + return {'arch': arch, 'id': view.id, 'model': view.model, 'models': models} + # result is cached per (view_id, view_type, lang) + # next request with same params skips all the work below +``` + +## Step 45 — `_get_view()` (ir_ui_view.py:2966) + +```python +def _get_view(self, view_id=None, view_type='list'): + IrUiView = self.env['ir.ui.view'].sudo() + + if not view_id: + # find best matching view + view_id = IrUiView.default_view('estate.property', 'list') + # SELECT id FROM ir_ui_view + # WHERE model='estate.property' AND type='list' + # AND mode='primary' AND active=true + # ORDER BY priority, name, id + # LIMIT 1 + # → 55 + + view = IrUiView.browse(55) + arch = view._get_combined_arch() + return arch, view +``` + +`_get_combined_arch()`: +```python +def _get_combined_arch(self): + # 1. get base arch + arch_str = self.arch_db # reads from ir_ui_view.arch_db column + # '...' + + arch = etree.fromstring(arch_str) # parse to lxml element + + # 2. find all extension views + extension_views = self.search([ + ('inherit_id', '=', self.id), + ('mode', '=', 'extension'), + ('active', '=', True), + ]) + # SELECT id FROM ir_ui_view WHERE inherit_id=55 AND mode='extension' AND active=true + + # 3. apply each extension in priority order + for child_view in extension_views: + child_arch = etree.fromstring(child_view.arch_db) + arch = apply_inheritance_specs(arch, child_arch) + # applies XPath: ... + # modifies the lxml tree in-place + + return arch +``` + +## Step 46 — Response to Browser + +```json +{ + "result": { + "views": { + "list": { + "arch": "", + "id": 55, + "model": "estate.property" + }, + "search": { + "arch": "", + "id": 56 + } + }, + "models": { + "estate.property": { + "fields": { + "name": {"type":"char", "string":"Title", "required":true}, + "expected_price": {"type":"float", "string":"Expected Price", "required":true}, + "state": {"type":"selection", "string":"Status", + "selection":[["new","New"],["offer_received","Offer Received"],["sold","Sold"]]}, + "property_type_id": {"type":"many2one","string":"Property Type","relation":"estate.property.type"} + } + } + } + } +} +``` + +**JS then calls `web_search_read`** → goes through ORM flow (Chapter 7) → gets data → renders table. + +--- + +# CHAPTER 10 — Recordset API + +The recordset is the core Python object you work with every day. +`estate.property(1, 2, 3)` means a recordset of 3 estate.property records. + +```python +# ── CREATING RECORDSETS ────────────────────────────────────────────────────── + +env['estate.property'] # empty recordset, no SQL +env['estate.property'].browse(15) # recordset(15,), no SQL +env['estate.property'].browse([1,2,3]) # recordset(1,2,3), no SQL + +env['estate.property'].search([('state','=','new')]) +# → SELECT ... WHERE state='new' → recordset of matching ids + +env.ref('estate.estate_menu_root') +# → SELECT res_id FROM ir_model_data WHERE module='estate' AND name='estate_menu_root' +# → ir.ui.menu(7,) + +# ── ENVIRONMENT MODIFICATION ───────────────────────────────────────────────── + +records.sudo() +# new env with uid=1 (superuser), bypasses ALL access checks +# use carefully + +records.with_user(uid) +# new env with different user + +records.with_context(key=value) +# adds to context dict +# env['estate.property'].with_context(lang='fr_FR') +# → all field reads return translated values + +records.with_env(other_env) +# completely swap environment + +# ── FILTERING ──────────────────────────────────────────────────────────────── + +records.filtered(lambda r: r.state == 'new') +# iterates self in Python, returns matching records +# reads from cache (may trigger SQL if not cached) +# → estate.property(1, 3) + +records.filtered('active') +# shorthand: lambda r: r['active'] + +records.filtered_domain([('state','=','new')]) +# applies domain filter in Python (no SQL) + +# ── MAPPING ────────────────────────────────────────────────────────────────── + +records.mapped('name') +# → ['Beach House', 'Mountain Villa'] + +records.mapped('property_type_id.name') +# follows relational chain: +# 1. fetches property_type_id for each record +# 2. fetches name on the comodel records +# → ['Apartment', 'House'] + +records.mapped(lambda r: r.expected_price * 1.1) +# → [220000.0, 385000.0] + +# ── SORTING ────────────────────────────────────────────────────────────────── + +records.sorted('expected_price') +# → sorted ascending by field value + +records.sorted('expected_price', reverse=True) +# → sorted descending + +records.sorted(key=lambda r: (r.state, r.expected_price)) +# → multi-key sort + +# ── SET OPERATIONS ─────────────────────────────────────────────────────────── + +r1 | r2 # union (deduplicated) +r1 & r2 # intersection +r1 - r2 # difference +r1 + r2 # concatenate (may have duplicates) +15 in r1 # True if record with id=15 is in recordset +len(r1) # number of records + +# ── EXISTENCE ──────────────────────────────────────────────────────────────── + +records.exists() +# SELECT id FROM estate_property WHERE id IN (1,2,3) +# returns only records that still exist in DB +# useful after possible deletion by other transactions + +# ── NEW RECORD (virtual) ───────────────────────────────────────────────────── + +new_rec = self.new({'name': 'Test', 'expected_price': 100000}) +# creates record in cache only, no INSERT +# used for onchange evaluation and precomputed fields +``` + +--- + +# CHAPTER 11 — All Tables Created by a Typical Addon + +``` +CORE TABLES (created by 'base' module on first run): +┌─────────────────────────┬──────────────────────────────────────────────┐ +│ ir_model │ registry of all models (estate.property etc) │ +│ ir_model_fields │ all fields of each model │ +│ ir_model_access │ perm_read/write/create/unlink per group │ +│ ir_model_data │ XML IDs: module.xml_id → model + res_id │ +│ ir_rule │ record-level access rules (domain per group) │ +│ ir_ui_view │ ALL view XMLs (arch_db column holds XML) │ +│ ir_ui_menu │ menu items tree │ +│ ir_actions │ base actions (parent of act_window etc) │ +│ ir_act_window │ act_window actions (inherits ir_actions) │ +│ ir_act_server │ server actions │ +│ res_groups │ security groups │ +│ res_users │ users │ +│ res_partner │ partners (base of users, customers etc) │ +│ ir_module_module │ installed/available modules list │ +└─────────────────────────┴──────────────────────────────────────────────┘ + +YOUR ADDON TABLES (created by estate module): +┌───────────────────────────────────────┬──────────────────────────────────┐ +│ estate_property │ main model │ +│ estate_property_type │ property types │ +│ estate_property_offer │ offers on properties │ +│ estate_tag │ tags │ +│ estate_property_estate_tag_rel │ M2m junction: property ↔ tags │ +└───────────────────────────────────────┴──────────────────────────────────┘ +``` + +--- + +# CHAPTER 12 — The Complete Story (One Page) + +``` +YOU RUN: ./odoo-bin --addons-path=... -d rd-demo -u estate + │ + ▼ + odoo-bin → cli/command.py:109 main() + find 'server' command → cli/server.py:95 main() + config.parse_config() ← stores all flags + db._create_empty_database('rd-demo') + service/server.py:1541 start() + ThreadedServer created + werkzeug thread spawned → port 8069 listening ✓ + │ + ▼ + preload_registries(['rd-demo']) + Registry.new('rd-demo') [orm/registry.py:129] + load_modules() [modules/loading.py:332] + │ + ▼ + FOR EACH module (base, mail, ..., estate): + read __manifest__.py → name, depends, data files + resolve dependency order via ModuleGraph + Python import → class definitions run + MetaModel registers all Model classes + registry.load() → registry.models['estate.property'] = + _auto_init() → CREATE TABLE estate_property (...) + field.update_db() → ALTER TABLE ADD COLUMN per field + Char/Text/Html → VARCHAR/TEXT + Integer → INTEGER (int4) + Float → NUMERIC + Boolean → BOOLEAN + Date/Datetime → DATE/TIMESTAMP + Selection → VARCHAR + Many2one → INTEGER + FK constraint + One2many → NO COLUMN (inverse of M2o) + Many2many → NO COLUMN + CREATE junction table + convert_file() for each data file: + XML: _tag_record() → ir.ui.view.create() → INSERT INTO ir_ui_view + XML: _tag_record() → ir.actions.act_window.create() → INSERT INTO ir_act_window + XML: _tag_menuitem() → ir.ui.menu.create() → INSERT INTO ir_ui_menu + CSV: ir.model.access → INSERT INTO ir_model_access + module.state = 'installed' → COMMIT ✓ + │ + ▼ + SERVER READY. Registry loaded. HTTP listening on :8069. + │ + ▼ + USER OPENS BROWSER → GET /odoo/estate + Application.__call__(environ) [http.py:2758] + Request._serve_db() [http.py:2213] + Registry('rd-demo').cursor() → psycopg2 connection + Environment(cr, uid=2, context) + ir.http._match(path) → werkzeug finds controller + JsonRPCDispatcher + │ + ▼ + JS: POST /web/dataset/call_kw/estate.property/get_views + get_views() → _get_view_cache() [ormcache] + SELECT id FROM ir_ui_view WHERE model=... AND type='list' + SELECT arch_db FROM ir_ui_view WHERE id=55 + apply extension views with lxml XPath + postprocess: access checks, modifiers + fields_get(): field metadata + → JSON: arch XML + field definitions back to browser + │ + ▼ + JS: POST /web/dataset/call_kw/estate.property/web_search_read + DataSet.call_kw() → service.call_kw() → Model.search_read() + search_fetch() + _search(): access check + record rules → Query object + _fetch_query(): + env.execute_query() → cr.execute(SELECT ... WHERE ... LIMIT 80) + PostgreSQL returns rows + field._insert_cache() → values in env.cache + _read_format(): reads cache → list[dict] + retrying(): env.cr.flush() + env.cr.commit() + → JSON: [{"id":1,"name":"Beach House",...}, ...] + │ + ▼ + OWL JS renders: + ListRenderer walks arch XML + each → picks widget by type: + char → + float → + selection → with autocomplete (name_search RPC) + boolean → + fills values from search_read result ✓ + │ + ▼ + USER CREATES A RECORD → clicks Save + POST /web/dataset/call_kw/estate.property/web_save + Model.create([{name, expected_price, state}]) + check_access('create') → ir.model.access SQL + _prepare_create_values() → add defaults + magic fields + _create(): + cr.execute(INSERT INTO estate_property (...) VALUES (...) RETURNING id) + → id = 15 + cache populated + computed fields scheduled for recompute + _validate_fields() → @constrains run + env.cr.flush() + env.cr.commit() + → JSON: {"result": 15} ✓ + │ + ▼ + USER EDITS A RECORD → clicks Save + Model.write({'state':'offer_accepted','selling_price':195000}) + check_access('write') + record rule check + field.write() → cache updated + _write_multi(): + UPDATE estate_property SET state=..., selling_price=... + WHERE id=15 + _validate_fields() → @constrains run + env.cr.commit() + → JSON: {"result": true} ✓ + │ + ▼ + USER DELETES A RECORD + Model.unlink() + check_access('unlink') + @ondelete methods run + env.flush_all() + DELETE FROM estate_property WHERE id IN (15,) + ir_model_data cleanup + ir_attachment cleanup + env.cr.commit() + → JSON: {"result": true} ✓ +``` + +--- + +# KEY FILES REFERENCE + +``` +STARTUP + community/odoo-bin:5 entry point + community/odoo/cli/command.py:109 main() CLI + community/odoo/cli/server.py:95 Server.main() + community/odoo/service/server.py:1541 start() + community/odoo/service/server.py:589 http_spawn() werkzeug + community/odoo/service/server.py:660 ThreadedServer.run() + community/odoo/service/server.py:1490 preload_registries() + +MODULE LOADING + community/odoo/orm/registry.py:129 Registry.new() + community/odoo/orm/registry.py:366 registry.load() register models + community/odoo/orm/registry.py:723 registry.init_models() + community/odoo/modules/loading.py:332 load_modules() + community/odoo/modules/loading.py:107 load_module_graph() + community/odoo/modules/module.py Manifest.for_addon() + +TABLE CREATION + community/odoo/orm/models.py:3169 _auto_init() CREATE TABLE + community/odoo/orm/fields.py:1096 Field.update_db() + community/odoo/orm/fields.py:1132 Field.update_db_column() ALTER TABLE + community/odoo/orm/fields.py:1150 Field.update_db_notnull() NOT NULL + +FIELD TYPES + community/odoo/orm/fields_numeric.py:17 Integer + community/odoo/orm/fields_numeric.py:60 Float + community/odoo/orm/fields_textual.py:461 Char + community/odoo/orm/fields_textual.py:526 Text + community/odoo/orm/fields_textual.py:541 Html + community/odoo/orm/fields_misc.py:22 Boolean + community/odoo/orm/fields_misc.py:65 Json + community/odoo/orm/fields_temporal.py:106 Date + community/odoo/orm/fields_temporal.py:191 Datetime + community/odoo/orm/fields_selection.py:20 Selection + community/odoo/orm/fields_relational.py:213 Many2one + community/odoo/orm/fields_relational.py:836 One2many + community/odoo/orm/fields_relational.py:1198 Many2many + +DATA FILE PARSING + community/odoo/tools/convert.py:667 convert_file() + community/odoo/tools/convert.py:336 _tag_record() → create() + community/odoo/tools/convert.py:275 _tag_menuitem() → ir.ui.menu + community/odoo/tools/convert.py:469 _tag_template() → ir.ui.view + +VIEW SYSTEM + community/odoo/addons/base/models/ir_ui_view.py:139 IrUiView model definition + community/odoo/addons/base/models/ir_ui_view.py:2893 get_views() RPC entry + community/odoo/addons/base/models/ir_ui_view.py:2966 _get_view() find + combine + community/odoo/addons/base/models/ir_ui_view.py:3079 _get_view_cache() ormcache + community/odoo/addons/base/models/ir_ui_menu.py:16 IrUiMenu model definition + +HTTP + RPC + community/odoo/http.py:2758 Application.__call__() WSGI + community/odoo/http.py:2213 _serve_db() + community/odoo/addons/base/models/ir_http.py:205 ir.http._match() routing + community/addons/web/controllers/dataset.py:28 DataSet.call_kw() controller + community/odoo/service/model.py:70 service.call_kw() + community/odoo/service/model.py:44 get_public_method() + community/odoo/service/model.py:137 execute_cr() + community/odoo/service/model.py:156 retrying() + +ORM READ + community/odoo/orm/models.py:5740 search_read() + community/odoo/orm/models.py:1383 search_fetch() + community/odoo/orm/models.py:5319 _search() → Query object + community/odoo/orm/models.py:3876 _fetch_query() → SQL + cache + community/odoo/orm/models.py:3706 _read_format() → list[dict] + community/odoo/orm/models.py:3466 read() + community/odoo/orm/environments.py:527 execute_query() → cr.execute() + +ORM WRITE + community/odoo/orm/models.py:4608 create() + community/odoo/orm/models.py:4764 _prepare_create_values() + community/odoo/orm/models.py:4844 _create() → INSERT SQL + community/odoo/orm/models.py:4331 write() + community/odoo/orm/models.py:4521 _write_multi() → UPDATE SQL + community/odoo/orm/models.py:4191 unlink() → DELETE SQL +``` + +--- + +*All file paths are relative to `/home/odoo/odoo19/`* +*All line numbers verified against Odoo 19 community source* diff --git a/estate/__manifest__.py b/estate/__manifest__.py index 90fcf52daf7..2af03883276 100644 --- a/estate/__manifest__.py +++ b/estate/__manifest__.py @@ -20,6 +20,8 @@ ], 'demo': [ 'demo/demo.xml', + 'demo/demo_tag.xml', + 'demo/demo_type.xml', ], 'application': True, } diff --git a/estate/demo/demo_tag.xml b/estate/demo/demo_tag.xml new file mode 100644 index 00000000000..78428084600 --- /dev/null +++ b/estate/demo/demo_tag.xml @@ -0,0 +1,43 @@ + + + + Cozy + + + + Modern + + + + Luxury + + + + Renovated + + + + New Build + + + + Sea View + + + + City Center + + + + Quiet Neighborhood + + + + Pet Friendly + + + + Gated Community + + + \ No newline at end of file diff --git a/estate/demo/demo_type.xml b/estate/demo/demo_type.xml new file mode 100644 index 00000000000..6d4f8a99ec9 --- /dev/null +++ b/estate/demo/demo_type.xml @@ -0,0 +1,31 @@ + + + + Apartment + + + + House + + + + Villa + + + + Studio + + + + Land + + + + Commercial + + + + Industrial + + + \ No newline at end of file diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py index c812b799501..7ad62a48736 100644 --- a/estate/models/estate_property.py +++ b/estate/models/estate_property.py @@ -41,7 +41,7 @@ class EstateProperty(models.Model): ('offer_received', "Offer Received"), ('offer_accepted', "Offer Accepted"), ('sold', "Sold"), - ('cancelled', "Cancelled") + ('canceled', "Canceled") ], string="Status", required=True, diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml index 3a229ab15c9..08934a0dae1 100644 --- a/estate/views/estate_property_offer_views.xml +++ b/estate/views/estate_property_offer_views.xml @@ -26,3 +26,4 @@
+ diff --git a/estate/views/estate_property_tags_views.xml b/estate/views/estate_property_tags_views.xml index 2020c365a59..9d0c0a42b58 100644 --- a/estate/views/estate_property_tags_views.xml +++ b/estate/views/estate_property_tags_views.xml @@ -4,7 +4,7 @@ estate.property.tag.list estate.property.tag - + @@ -30,3 +30,4 @@ list,form + diff --git a/estate/views/estate_property_type_views.xml b/estate/views/estate_property_type_views.xml index 6864c710940..a32f9be7745 100644 --- a/estate/views/estate_property_type_views.xml +++ b/estate/views/estate_property_type_views.xml @@ -31,3 +31,4 @@ + diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml index 0080ef35079..267474dead9 100644 --- a/estate/views/estate_property_views.xml +++ b/estate/views/estate_property_views.xml @@ -3,19 +3,20 @@ estate.property.list estate.property - - - - - - - - - - - - - + + + + + + + + + + + + + + @@ -24,7 +25,7 @@ estate.property
- + @@ -32,6 +33,7 @@ + @@ -55,12 +57,11 @@ + - - @@ -83,13 +84,35 @@ - + domain="[('state','in',['new','offer_received'])]"/> + + + + context="{'group_by':'postcode'}"/> + + + + @@ -101,3 +124,4 @@ + From 68a2994ecf1416eb421f80f9a4e3480102f921d6 Mon Sep 17 00:00:00 2001 From: pasaw Date: Thu, 19 Mar 2026 21:42:12 +0530 Subject: [PATCH 7/8] [REM] tutorials: remove README.md --- README.md | 1727 ----------------------------------------------------- 1 file changed, 1727 deletions(-) delete mode 100644 README.md diff --git a/README.md b/README.md deleted file mode 100644 index 43940eb3fe7..00000000000 --- a/README.md +++ /dev/null @@ -1,1727 +0,0 @@ -# Odoo 19 — Complete Internals Guide -### From `./odoo-bin` to Browser and Back — Every Function, Every Table, Every Flow - ---- - -## HOW TO READ THIS - -Read top to bottom. Each chapter builds on the previous one. -By the end you will understand exactly what happens at every step — -which function calls which, what SQL gets executed, and how data moves. - ---- - -# CHAPTER 1 — Boot: `./odoo-bin` to HTTP Server Listening - -## The command you run - -```bash -./odoo-bin --addons-path=addons,../enterprise,../tutorials -d rd-demo -u estate -``` - -What each flag does: -``` ---addons-path tells Python where to find addon modules --d rd-demo database name to connect to --u estate upgrade (install/update) this module -``` - -## Step 1 — `odoo-bin` (community/odoo-bin:5) - -```python -if __name__ == "__main__": - odoo.cli.main() # that's it, just calls main() -``` - -## Step 2 — `main()` (community/odoo/cli/command.py:109) - -```python -def main(): - args = sys.argv[1:] - - # parse --addons-path early so we can find addon commands - if args[0].startswith('--addons-path='): - config._parse_config([args[0]]) # stores addons path in config - args = args[1:] - - # default command is 'server' if nothing specified - command_name = args[0] if args[0] not startswith('-' else 'server' - - command = find_command(command_name) # finds cli/server.py Server class - command().run(args) # Server().run(args) -``` - -## Step 3 — `Server.run()` (community/odoo/cli/server.py:125) - -```python -class Server(Command): - def run(self, args): - config.parser.prog = self.prog - main(args) # calls main() in same file -``` - -## Step 4 — `main(args)` (community/odoo/cli/server.py:95) - -```python -def main(args): - check_root_user() # warns if running as root - config.parse_config(args, setup_logging=True) - # NOW config contains: - # config['db_name'] = ['rd-demo'] - # config['update'] = {'estate': 1} - # config['addons_path']= ['addons', '../enterprise', '../tutorials'] - # config['http_port'] = 8069 - # config['workers'] = 0 (threaded mode) - - check_postgres_user() # exits if db user is 'postgres' - report_configuration() # logs version, addons paths - - for db_name in config['db_name']: - db._create_empty_database(db_name) # CREATE DATABASE rd-demo if not exists - config['init']['base'] = True # force base to load - - setup_pid_file() # write PID file - rc = server.start(preload=['rd-demo'], stop=False) - sys.exit(rc) -``` - -## Step 5 — `service/server.py start()` (community/odoo/service/server.py:1541) - -```python -def start(preload=None, stop=False): - load_server_wide_modules() # loads 'base', 'web' immediately - import odoo.http # creates odoo.http.root = Application() - - # pick server type: - if odoo.evented: server = GeventServer(odoo.http.root) - elif config['workers']: server = PreforkServer(odoo.http.root) - else: server = ThreadedServer(odoo.http.root) - # ^^^ default for development - - rc = server.run(preload=['rd-demo'], stop=False) - return rc -``` - -## Step 6 — `ThreadedServer.run()` (community/odoo/service/server.py:660) - -```python -def run(self, preload=None, stop=False): - with Registry._lock: - self.start(stop=False) # sets up signals + starts HTTP - rc = preload_registries(preload) # LOADS ALL MODULES - - self.cron_spawn() # starts cron threads - - while self.quit_signals_received == 0: # main loop, runs forever - self.process_limit() - time.sleep(60) # wakes on SIGTERM/SIGINT - - self.stop() # graceful shutdown -``` - -`self.start()` (community/odoo/service/server.py:597): -```python -def start(self, stop=False): - # set signal handlers: SIGINT=shutdown, SIGHUP=reload, SIGUSR1=cache stats - signal.signal(signal.SIGINT, self.signal_handler) - signal.signal(signal.SIGTERM, self.signal_handler) - signal.signal(signal.SIGHUP, self.signal_handler) - - if config['http_enable']: - self.http_spawn() # starts werkzeug in a thread - -def http_spawn(self): # community/odoo/service/server.py:589 - self.httpd = ThreadedWSGIServerReloadable( - self.interface, # '0.0.0.0' - self.port, # 8069 - self.app # odoo.http.root (WSGI app) - ) - Thread(target=self.httpd.serve_forever, daemon=True).start() - # HTTP server is now listening on port 8069 -``` - ---- - -# CHAPTER 2 — Module Loading (`-u estate`) - -## Step 7 — `preload_registries()` (community/odoo/service/server.py:1490) - -```python -def preload_registries(dbnames): - for dbname in dbnames: # ['rd-demo'] - threading.current_thread().dbname = dbname - update_module = config['update'] # {'estate': 1} from -u flag - - Registry.new(dbname, - update_module=True, - upgrade_modules=config['update'], # {'estate':1} - install_modules=config['init'], # {} - ) -``` - -## Step 8 — `Registry.new()` (community/odoo/orm/registry.py:129) - -The Registry is the central object. It holds ALL model classes for a database. - -```python -Registry.new('rd-demo', update_module=True, upgrade_modules=['estate']): - - registry = object.__new__(Registry) - registry.init('rd-demo') - # registry now has: - # registry.models = {} ← will hold all Model classes - # registry._init_modules = set() ← tracks what's loaded - # registry.ready = False - - cls.registries['rd-demo'] = registry # stored globally, one per database - - load_modules(registry, - update_module=True, - upgrade_modules=['estate']) - - registry._init = False - registry.ready = True # server is now ready for requests - registry.signal_changes() # notify other workers via DB -``` - -## Step 9 — `load_modules()` (community/odoo/modules/loading.py:332) - -```python -def load_modules(registry, update_module=False, upgrade_modules=()): - - initialize_sys_path() - # adds every path in --addons-path to sys.path and odoo.addons.__path__ - # so Python can find: import odoo.addons.estate - - with registry.cursor() as cr: - cr.execute("SET SESSION lock_timeout = '15s'") - - if not is_initialized(cr): - modules_db.initialize(cr) - # creates core tables: ir_module_module, ir_model, ir_model_fields - # ir_model_access, ir_model_data, ir_ui_view, ir_ui_menu, etc. - - # STEP 1: always load 'base' first - graph = ModuleGraph(cr, mode='update') - graph.extend(['base']) - env = api.Environment(cr, SUPERUSER_ID, {}) - load_module_graph(env, graph, update_module=True) - - # STEP 2: discover all modules, mark estate as 'to upgrade' - env['ir.module.module'].update_list() - # scans all addons paths, reads __manifest__.py files - # inserts missing modules into ir_module_module table - - # mark estate for upgrade - estate_module = env['ir.module.module'].search([('name','=','estate')]) - estate_module.button_upgrade() - # sets state = 'to upgrade' in ir_module_module - - # STEP 3: build full graph with all installed modules + estate - graph2 = ModuleGraph(cr, mode='update') - graph2.extend(all_installed_modules) - load_module_graph(env, graph2, update_module=True) -``` - -## Step 10 — Manifest Reading - -Before `load_module_graph` runs, `ModuleGraph` reads every `__manifest__.py`: - -```python -# community/odoo/modules/module.py -Manifest.for_addon('estate') - # looks in each path of odoo.addons.__path__: - # finds: /home/odoo/odoo19/tutorials/estate/__manifest__.py - with open(path) as f: - data = ast.literal_eval(f.read()) - # data = { - # 'name': 'Real Estate', - # 'version': '1.0', - # 'depends': ['base'], - # 'data': [ - # 'security/ir.model.access.csv', - # 'views/estate_property_views.xml', - # 'views/estate_menus.xml', - # ], - # 'installable': True, - # 'application': True, - # } - return Manifest(data) -``` - -**Dependency resolution** — `graph.extend(['estate'])`: -``` -estate depends on → ['base'] -base depends on → [] (root) - -Load order: base → estate -``` - -## Step 11 — `load_module_graph()` (community/odoo/modules/loading.py:107) - -This is the core loop. Runs for EACH module in dependency order: - -```python -def load_module_graph(env, graph, update_module=False): - migrations = MigrationManager(cr, graph) - - for index, package in enumerate(graph): - module_name = package.name # e.g. 'estate' - update_operation = ( - 'install' if package.state == 'to install' else - 'upgrade' if package.state == 'to upgrade' else # ← our case - None - ) - - # ── 1. PRE-MIGRATION ───────────────────────────── - if update_operation == 'upgrade': - migrations.migrate_module(package, 'pre') - # runs scripts/pre-migrate-*.py if they exist - - # ── 2. PYTHON IMPORT ───────────────────────────── - load_openerp_module('estate') - # → __import__('odoo.addons.estate') - # → runs estate/__init__.py which does: from . import models - # → runs estate/models/__init__.py which does: from . import estate_property - # → runs estate/models/estate_property.py - # → Python reads class EstateProperty(models.Model): - # _name = 'estate.property' - # name = fields.Char(required=True) - # expected_price = fields.Float(required=True) - # ... - # → MetaModel metaclass fires __init_subclass__ - # → registers class in MetaModel._module_to_models__['estate'] - - # ── 3. PRE-INIT HOOK ───────────────────────────── - if update_operation == 'install': - pre_init = package.manifest.get('pre_init_hook') - if pre_init: - getattr(py_module, pre_init)(env) # calls your hook function - - # ── 4. REGISTER MODELS ─────────────────────────── - model_names = registry.load(package) - # for each class in MetaModel._module_to_models__['estate']: - # registry.models['estate.property'] = - # registry.models['estate.property.type'] = - # registry.models['estate.property.offer']= - # returns ['estate.property', 'estate.property.type', ...] - - # ── 5. CREATE/UPDATE DATABASE TABLES ───────────── - if update_operation: - registry._setup_models__(cr, []) # wire up field descriptors - registry.init_models(cr, model_names, {'module': 'estate'}, install=True) - # → for each model: model._auto_init() ← CREATES TABLES - # → env['ir.model']._reflect_models() ← INSERT INTO ir_model - # → env['ir.model.fields']._reflect_fields() ← INSERT INTO ir_model_fields - # → registry.check_indexes() ← CREATE INDEX - # → registry.check_foreign_keys() ← ADD FOREIGN KEY - - # ── 6. LOAD DATA FILES ─────────────────────────── - if update_operation == 'install': - load_data(env, idref, 'init', kind='data', package=package) - # for each file in manifest['data']: - # convert_file(env, 'estate', filename, idref, 'init') - - # ── 7. POST-MIGRATION ──────────────────────────── - migrations.migrate_module(package, 'post') - - # ── 8. TRANSLATIONS ────────────────────────────── - module._update_translations() - - # ── 9. MARK INSTALLED ──────────────────────────── - module.write({'state': 'installed', 'latest_version': '1.0'}) - env.cr.commit() # COMMIT after each module - - # ── 10. POST-INIT HOOK ─────────────────────────── - if update_operation == 'install': - post_init = package.manifest.get('post_init_hook') - if post_init: - getattr(py_module, post_init)(env) -``` - ---- - -# CHAPTER 3 — Table Creation: Every Field Type to SQL - -## Step 12 — `_auto_init()` (community/odoo/orm/models.py:3169) - -Called for every model during `registry.init_models()`: - -```python -def _auto_init(self): - cr = self.env.cr - must_create_table = not sql.table_exists(cr, self._table) - # self._table = 'estate_property' (dots replaced with underscores) - - if must_create_table: - sql.create_model_table(cr, self._table, self._description, [ - (field.name, field.column_type[1] + (" NOT NULL" if field.required else ""), field.string) - for field in self._fields.values() - if field.name != 'id' and field.store and field.column_type - ]) - # SQL: - # CREATE TABLE estate_property ( - # id SERIAL NOT NULL, - # name varchar NOT NULL, - # description text, - # postcode varchar, - # expected_price numeric NOT NULL, - # selling_price numeric, - # bedrooms int4, - # state varchar NOT NULL, - # active bool, - # property_type_id int4, ← Many2one → integer - # salesman_id int4, - # buyer_id int4, - # create_uid int4, - # create_date timestamp, - # write_uid int4, - # write_date timestamp, - # PRIMARY KEY(id) - # ) - else: - # table exists → check for NEW fields only - columns = sql.table_columns(cr, self._table) - for field in self._fields.values(): - if field.store: - field.update_db(self, columns) # ALTER TABLE if needed - - self._add_sql_constraints() - # for each constraint in _sql_constraints: - # ALTER TABLE estate_property ADD CONSTRAINT ... -``` - -## Step 13 — Field Types: Python Class → PostgreSQL Column - -Every field type is defined in `community/odoo/orm/`: - -``` -PYTHON FIELD CLASS FILE _column_type POSTGRESQL COLUMN -───────────────────────────────────────────────────────────────────────────────────── -Boolean fields_misc.py:22 ('bool','bool') BOOLEAN -Integer fields_numeric.py:17 ('int4','int4') INTEGER -Float fields_numeric.py:60 ('numeric','numeric') NUMERIC -Char(size=255) fields_textual.py:461 ('varchar',pg_varchar) VARCHAR(255) -Char() fields_textual.py:461 ('varchar','varchar') VARCHAR -Text fields_textual.py:526 ('text','text') TEXT -Html fields_textual.py:541 ('text','text') TEXT -Date fields_temporal.py:106 ('date','date') DATE -Datetime fields_temporal.py:191 ('timestamp','ts...') TIMESTAMP -Selection fields_selection.py:20 ('varchar',pg_varchar) VARCHAR -Binary(attachment=F) fields_binary.py:30 ('bytea','bytea') BYTEA -Binary(attachment=T) fields_binary.py:30 None NO COLUMN (ir_attachment) -Json fields_misc.py:65 ('jsonb','jsonb') JSONB -Many2one fields_relational.py:213('int4','int4') INTEGER + FK -One2many fields_relational.py:836 None NO COLUMN (inverse) -Many2many fields_relational.py:1198 None NO COLUMN (junction table) -``` - -## Step 14 — `Field.update_db()` (community/odoo/orm/fields.py:1096) - -```python -def update_db(self, model, columns): - if not self.column_type: - return False # One2many, Many2many → nothing to do in this table - - column = columns.get(self.name) # existing column info from PostgreSQL - - self.update_db_column(model, column) # CREATE or ALTER column - self.update_db_notnull(model, column) # handle NOT NULL constraint - - return not column # True = new column (may need recompute) -``` - -`update_db_column()` (fields.py:1132): -```python -def update_db_column(self, model, column): - if not column: - # column does not exist yet - sql.create_column(model.env.cr, model._table, self.name, self.column_type[1], self.string) - # ALTER TABLE estate_property ADD COLUMN expected_price numeric - return - if column['udt_name'] == self.column_type[0]: - return # already the right type, skip - self._convert_db_column(model, column) - # ALTER TABLE estate_property ALTER COLUMN x TYPE new_type USING x::new_type -``` - -## Step 15 — Many2one: FK Constraint - -Many2one stores as `int4` column but also gets a FK: - -```sql --- Column created by update_db_column(): -ALTER TABLE estate_property ADD COLUMN property_type_id int4 - --- FK created by registry.check_foreign_keys(): -ALTER TABLE estate_property - ADD CONSTRAINT estate_property_property_type_id_fkey - FOREIGN KEY (property_type_id) - REFERENCES estate_property_type(id) - ON DELETE set null -- from field.ondelete (default for optional M2o) - -- required M2o defaults to ondelete='restrict' -``` - -## Step 16 — Many2many: Junction Table - -```python -# estate.property has tags = fields.Many2many('estate.tag') -# estate.tag table name = 'estate_tag' -# estate.property table = 'estate_property' - -# Auto-generated relation table name (alphabetical order): -# 'estate_property_estate_tag_rel' -# column1 = 'estate_property_id' -# column2 = 'estate_tag_id' -``` - -SQL created: -```sql -CREATE TABLE estate_property_estate_tag_rel ( - estate_property_id INTEGER NOT NULL - REFERENCES estate_property(id) ON DELETE cascade, - estate_tag_id INTEGER NOT NULL - REFERENCES estate_tag(id) ON DELETE cascade, - PRIMARY KEY (estate_property_id, estate_tag_id) -) -``` - -## Step 17 — One2many: No Column - -```python -# offer_ids = fields.One2many('estate.property.offer', 'property_id') -# One2many has NO column in estate_property table -# It works by querying the OTHER table: -# SELECT * FROM estate_property_offer WHERE property_id = -# The 'property_id' column lives on estate_property_offer (the Many2one side) -``` - ---- - -# CHAPTER 4 — Data Files: XML to Database Rows - -After tables are created, `load_data()` processes `manifest['data']` files. - -## Step 18 — `convert_file()` (community/odoo/tools/convert.py:667) - -```python -def convert_file(env, module, filename, idref, mode, noupdate): - ext = os.path.splitext(filename)[1].lower() - with file_open(pathname, 'rb') as fp: - if ext == '.csv': convert_csv_import(env, module, ...) - elif ext == '.xml': convert_xml_import(env, module, fp, ...) - elif ext == '.sql': convert_sql_import(env, fp) -``` - -## Step 19 — XML Parsing: `_tag_record()` (convert.py:336) - -For a view XML file like `views/estate_property_views.xml`: - -```xml - - estate.property.list - estate.property - - - - - - - - -``` - -`_tag_record()` does: -```python -rec_model = 'ir.ui.view' -rec_id = 'estate_property_view_list' -xid = 'estate.estate_property_view_list' - -res = { - 'name': 'estate.property.list', - 'model': 'estate.property', - 'arch': '...', -} - -data = {'xml_id': xid, 'values': res, 'noupdate': False} -record = env['ir.ui.view']._load_records([data], update=False) -# → ir.ui.view.create({'name':..., 'model':..., 'arch':...}) -# SQL: INSERT INTO ir_ui_view (name, model, arch_db, type, priority, mode, active, ...) -# VALUES ('estate.property.list', 'estate.property', '', 'list', 16, 'primary', true, ...) -# RETURNING id - -self.idref['estate.estate_property_view_list'] = record.id # 55 -``` - -## Step 20 — `ir_ui_view` Table Structure - -```sql --- What's stored in PostgreSQL for every view: -CREATE TABLE ir_ui_view ( - id SERIAL PRIMARY KEY, - name VARCHAR NOT NULL, -- 'estate.property.list' - model VARCHAR, -- 'estate.property' - key VARCHAR, -- 'estate.estate_property_view_list' - priority INTEGER DEFAULT 16, -- lower = higher priority - type VARCHAR, -- 'list','form','search','kanban','graph','pivot' - arch_db TEXT, -- THE ACTUAL XML STORED HERE - arch_fs VARCHAR, -- file path (used in dev-xml mode) - arch_updated BOOLEAN, -- True if user modified via Studio - arch_prev TEXT, -- previous arch (soft reset) - inherit_id INTEGER REFERENCES ir_ui_view(id), -- for extension views - mode VARCHAR DEFAULT 'primary', -- 'primary' or 'extension' - active BOOLEAN DEFAULT true, - create_uid INTEGER, create_date TIMESTAMP, - write_uid INTEGER, write_date TIMESTAMP -) -``` - -## Step 21 — `_tag_menuitem()` (convert.py:275) - -```xml - - - -``` - -```python -def _tag_menuitem(self, rec, parent=None): - values = { - 'parent_id': False, - 'active': True, - 'sequence': 10, - 'name': 'Real Estate', - 'web_icon': 'estate,static/description/icon.png', - } - - if rec.get('action'): - act = self.env.ref('estate.estate_property_action').sudo() - values['action'] = "ir.actions.act_window,%d" % act.id - # Reference field: stores model_name,id as string - - data = {'xml_id': 'estate.estate_menu_root', 'values': values, 'noupdate': False} - menu = self.env['ir.ui.menu']._load_records([data], update=False) - # SQL: INSERT INTO ir_ui_menu (name, parent_id, sequence, active, action, web_icon, parent_path) - # VALUES ('Real Estate', NULL, 10, true, 'ir.actions.act_window,42', - # 'estate,static/description/icon.png', '/7/') - # RETURNING id - - for child in rec.iterchildren('menuitem'): - self._tag_menuitem(child, parent=menu.id) # recurse for children -``` - -## Step 22 — `ir_ui_menu` Table Structure - -```sql -CREATE TABLE ir_ui_menu ( - id SERIAL PRIMARY KEY, - name VARCHAR NOT NULL, -- 'Properties' - parent_id INTEGER REFERENCES ir_ui_menu(id), - parent_path VARCHAR, -- '/1/7/' (materialized path for tree) - sequence INTEGER DEFAULT 10, - active BOOLEAN DEFAULT true, - action VARCHAR, -- 'ir.actions.act_window,42' - web_icon VARCHAR, -- 'estate,static/description/icon.png' - create_uid INTEGER, create_date TIMESTAMP, - write_uid INTEGER, write_date TIMESTAMP -) --- Many2many for group security: -CREATE TABLE ir_ui_menu_group_rel ( - menu_id INTEGER REFERENCES ir_ui_menu(id), - gid INTEGER REFERENCES res_groups(id) -) -``` - -## Step 23 — `ir.actions.act_window` Table Structure - -```xml - - Properties - estate.property - list,form - {'search_default_state': 'new'} - -``` - -```sql --- ir.actions.act_window inherits from ir.actions via _inherits --- Two tables are used: - --- Parent table (base action): -INSERT INTO ir_actions (name, type, ...) -VALUES ('Properties', 'ir.actions.act_window', ...) -RETURNING id → 42 - --- Child table (window-specific fields): -INSERT INTO ir_act_window (id, res_model, view_mode, context, domain, ...) -VALUES (42, 'estate.property', 'list,form', '{"search_default_state":"new"}', '[]', ...) -``` - -## Step 24 — CSV: `ir.model.access` - -```csv -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 -``` - -`convert_csv_import()` → loads via `ir.model.access._load_records()`: -```sql -INSERT INTO ir_model_access (name, model_id, group_id, perm_read, perm_write, perm_create, perm_unlink) -VALUES ('access_estate_property', , , true, true, true, true) -``` - ---- - -# CHAPTER 5 — HTTP Request Handling - -Server is running, user opens browser to `http://localhost:8069`. - -## Step 25 — WSGI Entry Point (community/odoo/http.py:2758) - -Every single HTTP request enters here: - -```python -class Application: - def __call__(self, environ, start_response): - # environ contains everything about the request: - # environ['REQUEST_METHOD'] = 'POST' - # environ['PATH_INFO'] = '/web/dataset/call_kw/estate.property/search_read' - # environ['HTTP_COOKIE'] = 'session_id=abc123...' - # environ['wsgi.input'] = - - # reset per-request counters on current thread - current_thread.query_count = 0 - current_thread.query_time = 0 - current_thread.perf_t0 = real_time() - - with HTTPRequest(environ) as httprequest: # werkzeug Request wrapper - request = Request(httprequest) - _request_stack.push(request) # thread-local - - request._post_init() - # reads session_id cookie → loads session from FileSystemSessionStore - # session contains: uid=2, db='rd-demo', context={'lang':'en_US'} - # sets request.db = 'rd-demo' - - if self.get_static_file(httprequest.path): - response = request._serve_static() # /estate/static/... files - elif request.db: - response = request._serve_db() # ← normal flow - else: - response = request._serve_nodb() # login page etc. - - return response(environ, start_response) -``` - -## Step 26 — `_serve_db()` (community/odoo/http.py:2213) - -```python -def _serve_db(self): - cr = None - try: - registry = Registry('rd-demo') # get from global cache - cr = registry.cursor(readonly=True) # psycopg2 connection, READ ONLY - self.registry = registry.check_signaling(cr) - # check_signaling: if any module was updated, reload registry - - threading.current_thread().dbname = 'rd-demo' - - # create Environment (cr, uid, context) - self.env = odoo.api.Environment(cr, self.session.uid, self.session.context) - # self.env['estate.property'] → gives model recordset bound to this cursor + user - - # find which controller to call - rule, args = self.registry['ir.http']._match(self.httprequest.path) - # werkzeug routing: '/web/dataset/call_kw/estate.property/search_read' - # matches route: '/web/dataset/call_kw/' - # rule.endpoint = DataSet.call_kw method - - self._set_request_dispatcher(rule) - # type='jsonrpc' → dispatcher = JsonRPCDispatcher - - serve_func = self._serve_ir_http(rule, args) - readonly = rule.endpoint.routing['readonly'] - # search_read has @api.readonly → True - # create/write/unlink → False (need RW cursor) - - if readonly: - threading.current_thread().cursor_mode = 'ro' - return service_model.retrying(serve_func, env=self.env) - else: - # close RO cursor, open RW cursor - cr.close() - cr = registry.cursor() # READ-WRITE cursor - self.env = self.env(cr=cr) - threading.current_thread().cursor_mode = 'rw' - return service_model.retrying(serve_func, env=self.env) - finally: - self.env = None - if cr: cr.close() # always return cursor to pool -``` - -## Step 27 — `ir.http._match()` (community/odoo/addons/base/models/ir_http.py:205) - -```python -@classmethod -def _match(cls, path_info): - rule, args = request.env['ir.http'].routing_map()\ - .bind_to_environ(request.httprequest.environ)\ - .match(path_info=path_info, return_rule=True) - return rule, args - # werkzeug does the actual URL matching - # '/web/dataset/call_kw/estate.property/search_read' - # → matches rule '/web/dataset/call_kw/' - # → args = {'path': 'estate.property/search_read'} - # → rule.endpoint = DataSet.call_kw (the controller method) -``` - ---- - -# CHAPTER 6 — JSON-RPC: Browser to ORM - -## Step 28 — JSON Body from Browser - -Every ORM call from the browser looks like this: - -```json -POST /web/dataset/call_kw/estate.property/search_read -Content-Type: application/json - -{ - "jsonrpc": "2.0", - "id": 1, - "method": "call", - "params": { - "model": "estate.property", - "method": "search_read", - "args": [[["state", "!=", "sold"]]], - "kwargs": { - "fields": ["name", "expected_price", "state"], - "limit": 80, - "offset": 0, - "context": {"lang": "en_US", "tz": "UTC", "uid": 2} - } - } -} -``` - -## Step 29 — `JsonRPCDispatcher.dispatch()` - -```python -# parses JSON body -params = request.get_json_data()['params'] -# {'model':'estate.property', 'method':'search_read', 'args':[...], 'kwargs':{...}} - -result = endpoint(**params) -# calls: DataSet.call_kw( -# model='estate.property', -# method='search_read', -# args=[[['state','!=','sold']]], -# kwargs={'fields':[...],'limit':80,'context':{...}}, -# path='estate.property/search_read' -# ) - -response = json.dumps({ - "jsonrpc": "2.0", - "id": 1, - "result": result -}) -return Response(response, content_type='application/json') -``` - -## Step 30 — Controller (community/addons/web/controllers/dataset.py:29) - -```python -class DataSet(http.Controller): - - @http.route(['/web/dataset/call_kw', '/web/dataset/call_kw/'], - type='jsonrpc', auth="user", readonly=_call_kw_readonly) - def call_kw(self, model, method, args, kwargs, path=None): - # model = 'estate.property' - # method = 'search_read' - # args = [[['state','!=','sold']]] - # kwargs = {'fields':[...],'limit':80} - - return call_kw(request.env[model], method, args, kwargs) - # request.env['estate.property'] → empty recordset of estate.property - # bound to current cursor + current user -``` - -## Step 31 — `service.model.call_kw()` (community/odoo/service/model.py:70) - -```python -def call_kw(model, name, args, kwargs): - # model = estate.property() (empty recordset) - # name = 'search_read' - # args = [[['state','!=','sold']]] - # kwargs= {'fields':[...],'limit':80,'context':{...}} - - # SECURITY CHECK: method must be public - method = get_public_method(model, name) - # get_public_method() [service/model.py:44]: - # if name.startswith('_'): raise AccessError (private method) - # if method._api_private == True: raise AccessError (@api.private) - # returns the actual function: BaseModel.search_read - - # search_read is decorated @api.model so no ids needed - if getattr(method, '_api_model', False): - recs = model # use model as-is (no browse needed) - - # pop context from kwargs, apply to recordset - kwargs = dict(kwargs) - context = kwargs.pop('context', None) or {} - recs = recs.with_context(context) - # creates new env with context merged in - - result = method(recs, *args, **kwargs) - # = EstateProperty.search_read( - # domain=[['state','!=','sold']], - # fields=['name','expected_price','state'], - # limit=80 - # ) - - # result is list[dict] → returned as-is (not a BaseModel) - return result -``` - -## Step 32 — `retrying()` (community/odoo/service/model.py:156) - -Wraps every ORM call with concurrency retry logic: - -```python -def retrying(func, env): - for tryno in range(1, 6): # up to 5 attempts - try: - result = func() # call the ORM method - if not env.cr._closed: - env.cr.flush() # write pending SQL to DB - break - except SerializationFailure: # two transactions conflict - env.cr.rollback() - env.transaction.reset() - env.registry.reset_changes() - wait = random.uniform(0, 2 ** tryno) # exponential backoff - time.sleep(wait) - # retry... - except IntegrityError: # duplicate key etc. - raise ValidationError("Operation cannot be completed: ...") - - env.cr.commit() # final commit - env.registry.signal_changes() # notify other workers - return result -``` - ---- - -# CHAPTER 7 — ORM: Python to SQL - -## Step 33 — `search_read()` (community/odoo/orm/models.py:5740) - -```python -def search_read(self, domain=None, fields=None, offset=0, limit=None, order=None): - # domain = [['state','!=','sold']] - # fields = ['name','expected_price','state'] - # limit = 80 - - if not fields: - fields = list(self.fields_get(attributes=())) # all fields - - records = self.search_fetch(domain or [], fields, offset=0, limit=80) - return records._read_format(fnames=fields) -``` - -## Step 34 — `search_fetch()` (models.py:1383) - -```python -@api.model -@api.readonly -def search_fetch(self, domain, field_names=None, offset=0, limit=None, order=None): - - # Step A: build Query object (NO SQL yet, just an AST) - query = self._search(domain, offset=0, limit=80, order='id') - - if query.is_empty(): - return self.browse() # optimization: skip if nothing to find - - fields_to_fetch = self._determine_fields_to_fetch(field_names) - # checks field access rights for each field - # returns list of Field objects: [Field(name), Field(expected_price), Field(state)] - - return self._fetch_query(query, fields_to_fetch) -``` - -## Step 35 — `_search()` (models.py:5319) - -Builds a Query object — no SQL executed yet: - -```python -def _search(self, domain, offset=0, limit=None, order=None): - - # 1. ACCESS CHECK - self.browse().check_access('read') - # → SELECT perm_read FROM ir_model_access - # WHERE model_id=(SELECT id FROM ir_model WHERE model='estate.property') - # AND (group_id IN (2,3,4) OR group_id IS NULL) -- user's groups - # LIMIT 1 - # raises AccessError if no row returned - - domain = Domain(domain) # [['state','!=','sold']] - - # 2. ADD active=True FILTER (if model has active field) - domain &= Domain('active', '=', True) - # domain is now: [['state','!=','sold'], ['active','=',True]] - - # 3. BUILD QUERY OBJECT - query = Query(self.env, 'estate_property', SQL.identifier('estate_property')) - query.add_where(domain._to_sql(self, 'estate_property', query)) - # _to_sql converts domain to: - # estate_property.state != 'sold' AND estate_property.active = true - - # 4. RECORD RULES (row-level security) - sec_domain = env['ir.rule']._compute_domain('estate.property', 'read') - # e.g. salesperson can only see their own: [['salesman_id','=',uid]] - query.add_where(sec_domain._to_sql(...)) - - # 5. ORDER/LIMIT/OFFSET - query.order = self._order_to_sql('id', query) # ORDER BY estate_property.id - query.limit = 80 - query.offset = 0 - - return query # ← STILL NO SQL EXECUTED -``` - -## Step 36 — `_fetch_query()` (models.py:3876) - -Here the SQL is actually executed: - -```python -def _fetch_query(self, query, fields): - # Separate column fields from non-column fields - column_fields = [name, expected_price, state] # have column_type - other_fields = [] # computed non-stored - - # Build SELECT terms - sql_terms = [SQL.identifier('estate_property', 'id')] - for field in column_fields: - sql = self._field_to_sql('estate_property', field.name, query) - sql_terms.append(sql) - - # ── EXECUTE SQL ────────────────────────────────────────────────────── - rows = self.env.execute_query(query.select(*sql_terms)) - # execute_query() [environments.py:527]: - # env.flush_query(query) ← flush pending writes that query touches - # env.cr.execute(query) ← psycopg2 sends SQL to PostgreSQL - # return env.cr.fetchall() ← get results back - - # ACTUAL SQL sent to PostgreSQL: - # SELECT estate_property.id, - # estate_property.name, - # estate_property.expected_price, - # estate_property.state - # FROM estate_property - # WHERE estate_property.state != 'sold' - # AND estate_property.active = true - # AND estate_property.salesman_id = 2 ← from record rule - # ORDER BY estate_property.id - # LIMIT 80 - # OFFSET 0 - - # rows = [ - # (1, 'Beach House', 200000.0, 'new'), - # (2, 'Mountain Villa', 350000.0, 'offer_received'), - # ] - - # unzip rows into columns - column_values = zip(*rows) - ids = next(column_values) # (1, 2) - fetched = self.browse(ids) # recordset: estate.property(1, 2) - - # ── POPULATE CACHE ─────────────────────────────────────────────────── - for field, values in zip(column_fields, column_values): - field._insert_cache(fetched, values) - # cache now contains: - # env.cache[(estate.property, 'name')] = {1: 'Beach House', 2: 'Mountain Villa'} - # env.cache[(estate.property, 'expected_price')] = {1: 200000.0, 2: 350000.0} - # env.cache[(estate.property, 'state')] = {1: 'new', 2: 'offer_received'} - - return fetched # estate.property(1, 2) -``` - -## Step 37 — `_read_format()` (models.py:3706) - -Converts recordset + cache into list of dicts: - -```python -def _read_format(self, fnames, load='_classic_read'): - # self = estate.property(1, 2) - # fnames = ['name', 'expected_price', 'state'] - - data = [(record, {'id': record.id}) for record in self] - # data = [(record1, {'id': 1}), (record2, {'id': 2})] - - for name in fnames: - field = self._fields[name] - convert = field.convert_to_read - for record, vals in data: - vals[name] = convert(record[name], record, use_display_name=True) - # record[name] reads from cache (NO SQL) - # convert_to_read: - # Char → str as-is - # Float → float as-is - # Selection → str (the key, not label) - # Many2one → (id, display_name) tuple - - result = [vals for record, vals in data if vals] - # result = [ - # {'id': 1, 'name': 'Beach House', 'expected_price': 200000.0, 'state': 'new'}, - # {'id': 2, 'name': 'Mountain Villa', 'expected_price': 350000.0, 'state': 'offer_received'}, - # ] - return result -``` - -## Step 38 — Response back to browser - -``` -_read_format() → list[dict] - ↑ returned to call_kw() → same list[dict] - ↑ returned to DataSet.call_kw → same list[dict] - ↑ returned to JsonRPCDispatcher - json.dumps({"jsonrpc":"2.0","id":1,"result":[{...},{...}]}) - ↑ retrying() calls env.cr.commit() - ↑ _serve_db() calls cr.close() (cursor returned to pool) - ↑ Application.__call__ returns Response to werkzeug - werkzeug sends HTTP 200 with JSON body to browser -``` - ---- - -# CHAPTER 8 — ORM Write Operations - -## Step 39 — `create()` (models.py:4608) - -``` -RPC call: estate.property.create({'name':'Beach House','expected_price':200000,'state':'new'}) -``` - -```python -def create(self, vals_list): - # vals_list = [{'name':'Beach House','expected_price':200000,'state':'new'}] - - self.check_access('create') - # → ir.model.access SQL check for 'create' permission - - new_vals_list = self._prepare_create_values(vals_list) # [models.py:4764] - # _add_missing_default_values(): - # bedrooms default=2 → vals['bedrooms'] = 2 - # active default=True → vals['active'] = True - # garden default=False → vals['garden'] = False - # strip: id, parent_path, create_uid, write_uid etc. - # add magic fields: - # vals['create_uid'] = 2 - # vals['create_date'] = '2026-03-19 10:30:00' - # vals['write_uid'] = 2 - # vals['write_date'] = '2026-03-19 10:30:00' - - # classify fields: - stored = {'name':'Beach House','expected_price':200000,'state':'new', - 'bedrooms':2,'active':True,'create_uid':2,...} - inversed = {} # no inverse fields in these vals - inherited= {} # no _inherits on estate.property - - records = self._create(data_list) # [models.py:4844] - # ── ACTUAL INSERT ──────────────────────────────────────────────────── - # cr.execute(SQL( - # 'INSERT INTO estate_property (active,bedrooms,create_date,create_uid, - # expected_price,name,state,write_date,write_uid) - # VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) - # RETURNING "id"', - # True, 2, '2026-03-19...', 2, 200000.0, 'Beach House', 'new', '2026-03-19...', 2 - # )) - # → id = 15 - - # records = estate.property(15,) - - # populate cache: - # env.cache[(estate.property,'name')][15] = 'Beach House' - # env.cache[(estate.property,'expected_price')][15] = 200000.0 - # env.cache[(estate.property,'state')][15] = 'new' - - # schedule computed fields: - records.modified(self._fields, create=True) - # marks total_area, best_price etc. as "needs recompute" - - self._validate_fields(vals) # run @api.constrains methods - - # retrying() will call env.cr.flush() + env.cr.commit() - - return records # estate.property(15,) - # call_kw() converts this to: 15 (the new id) - -# JSON response: {"jsonrpc":"2.0","id":1,"result":15} -``` - -## Step 40 — `write()` (models.py:4331) - -``` -RPC call: estate.property.browse([15]).write({'state':'offer_accepted','selling_price':195000}) -``` - -```python -def write(self, vals): - self.check_access('write') - # + record rule check: can this user write on record 15? - - # add magic fields - vals['write_uid'] = 2 - vals['write_date'] = '2026-03-19 10:35:00' - - # for relational fields: mark dependents BEFORE change - self.modified(fnames_modifying_relations, before=True) - - for field, value in sorted(field_values, key=lambda x: x[0].write_sequence): - field.write(self, value) - # puts value in cache for now (batched) - - self.modified(vals) # mark downstream computed fields - - # ── ACTUAL UPDATE ──────────────────────────────────────────────────── - # _write_multi() [models.py:4521] - # env.execute_query(SQL(""" - # UPDATE estate_property - # SET selling_price = __tmp.selling_price::numeric, - # state = __tmp.state::varchar, - # write_date = __tmp.write_date::timestamp, - # write_uid = __tmp.write_uid::int4 - # FROM (VALUES (15, 195000.0, 'offer_accepted', '2026-03-19...', 2)) - # AS "__tmp"("id", selling_price, state, write_date, write_uid) - # WHERE estate_property."id" = "__tmp"."id" - # """)) - - self._validate_fields(vals) # run @api.constrains - - return True -# JSON response: {"jsonrpc":"2.0","id":1,"result":true} -``` - -## Step 41 — `unlink()` (models.py:4191) - -```python -def unlink(self): - self.check_access('unlink') - - for func in self._ondelete_methods: - func(self) # e.g. archive related offers first - - self.env.flush_all() # write all pending changes before delete - - self.modified(self._fields, before=True) # mark dependents - - # ── ACTUAL DELETE ──────────────────────────────────────────────────── - cr.execute(SQL( - "DELETE FROM estate_property WHERE id IN %s", - (15,) - )) - # PostgreSQL ON DELETE CASCADE removes: - # estate_property_offer rows WHERE property_id=15 - # estate_property_estate_tag_rel rows WHERE estate_property_id=15 - - # clean up XML IDs - Data.search([('model','=','estate.property'),('res_id','in',[15])]).unlink() - # DELETE FROM ir_model_data WHERE model='estate.property' AND res_id=15 - - return True -# JSON response: {"jsonrpc":"2.0","id":1,"result":true} -``` - ---- - -# CHAPTER 9 — View Rendering at Runtime - -## Step 42 — User Opens the List View - -User clicks "Properties" menu in browser. - -**JS calls**: `POST /web/dataset/call_kw/estate.property/get_views` -```json -{ - "params": { - "model": "estate.property", - "method": "get_views", - "args": [], - "kwargs": { - "views": [[false, "list"], [false, "search"]] - } - } -} -``` - -## Step 43 — `get_views()` (community/odoo/addons/base/models/ir_ui_view.py:2893) - -```python -def get_views(self, views, options=None): - result = {} - result['views'] = { - v_type: self.get_view(v_id, v_type) - for [v_id, v_type] in views - } - # calls get_view(False, 'list') and get_view(False, 'search') - - result['models'] = { - model: {'fields': env[model].fields_get(allfields=model_fields)} - for model, model_fields in models.items() - } - return result -``` - -## Step 44 — `_get_view_cache()` (ir_ui_view.py:3079) - -```python -@tools.ormcache('self._get_view_cache_key(view_id, view_type)') -def _get_view_cache(self, view_id=None, view_type='list'): - arch, view = self._get_view(view_id, view_type) - arch, models = self._get_view_postprocessed(view, arch) - return {'arch': arch, 'id': view.id, 'model': view.model, 'models': models} - # result is cached per (view_id, view_type, lang) - # next request with same params skips all the work below -``` - -## Step 45 — `_get_view()` (ir_ui_view.py:2966) - -```python -def _get_view(self, view_id=None, view_type='list'): - IrUiView = self.env['ir.ui.view'].sudo() - - if not view_id: - # find best matching view - view_id = IrUiView.default_view('estate.property', 'list') - # SELECT id FROM ir_ui_view - # WHERE model='estate.property' AND type='list' - # AND mode='primary' AND active=true - # ORDER BY priority, name, id - # LIMIT 1 - # → 55 - - view = IrUiView.browse(55) - arch = view._get_combined_arch() - return arch, view -``` - -`_get_combined_arch()`: -```python -def _get_combined_arch(self): - # 1. get base arch - arch_str = self.arch_db # reads from ir_ui_view.arch_db column - # '...' - - arch = etree.fromstring(arch_str) # parse to lxml element - - # 2. find all extension views - extension_views = self.search([ - ('inherit_id', '=', self.id), - ('mode', '=', 'extension'), - ('active', '=', True), - ]) - # SELECT id FROM ir_ui_view WHERE inherit_id=55 AND mode='extension' AND active=true - - # 3. apply each extension in priority order - for child_view in extension_views: - child_arch = etree.fromstring(child_view.arch_db) - arch = apply_inheritance_specs(arch, child_arch) - # applies XPath: ... - # modifies the lxml tree in-place - - return arch -``` - -## Step 46 — Response to Browser - -```json -{ - "result": { - "views": { - "list": { - "arch": "", - "id": 55, - "model": "estate.property" - }, - "search": { - "arch": "", - "id": 56 - } - }, - "models": { - "estate.property": { - "fields": { - "name": {"type":"char", "string":"Title", "required":true}, - "expected_price": {"type":"float", "string":"Expected Price", "required":true}, - "state": {"type":"selection", "string":"Status", - "selection":[["new","New"],["offer_received","Offer Received"],["sold","Sold"]]}, - "property_type_id": {"type":"many2one","string":"Property Type","relation":"estate.property.type"} - } - } - } - } -} -``` - -**JS then calls `web_search_read`** → goes through ORM flow (Chapter 7) → gets data → renders table. - ---- - -# CHAPTER 10 — Recordset API - -The recordset is the core Python object you work with every day. -`estate.property(1, 2, 3)` means a recordset of 3 estate.property records. - -```python -# ── CREATING RECORDSETS ────────────────────────────────────────────────────── - -env['estate.property'] # empty recordset, no SQL -env['estate.property'].browse(15) # recordset(15,), no SQL -env['estate.property'].browse([1,2,3]) # recordset(1,2,3), no SQL - -env['estate.property'].search([('state','=','new')]) -# → SELECT ... WHERE state='new' → recordset of matching ids - -env.ref('estate.estate_menu_root') -# → SELECT res_id FROM ir_model_data WHERE module='estate' AND name='estate_menu_root' -# → ir.ui.menu(7,) - -# ── ENVIRONMENT MODIFICATION ───────────────────────────────────────────────── - -records.sudo() -# new env with uid=1 (superuser), bypasses ALL access checks -# use carefully - -records.with_user(uid) -# new env with different user - -records.with_context(key=value) -# adds to context dict -# env['estate.property'].with_context(lang='fr_FR') -# → all field reads return translated values - -records.with_env(other_env) -# completely swap environment - -# ── FILTERING ──────────────────────────────────────────────────────────────── - -records.filtered(lambda r: r.state == 'new') -# iterates self in Python, returns matching records -# reads from cache (may trigger SQL if not cached) -# → estate.property(1, 3) - -records.filtered('active') -# shorthand: lambda r: r['active'] - -records.filtered_domain([('state','=','new')]) -# applies domain filter in Python (no SQL) - -# ── MAPPING ────────────────────────────────────────────────────────────────── - -records.mapped('name') -# → ['Beach House', 'Mountain Villa'] - -records.mapped('property_type_id.name') -# follows relational chain: -# 1. fetches property_type_id for each record -# 2. fetches name on the comodel records -# → ['Apartment', 'House'] - -records.mapped(lambda r: r.expected_price * 1.1) -# → [220000.0, 385000.0] - -# ── SORTING ────────────────────────────────────────────────────────────────── - -records.sorted('expected_price') -# → sorted ascending by field value - -records.sorted('expected_price', reverse=True) -# → sorted descending - -records.sorted(key=lambda r: (r.state, r.expected_price)) -# → multi-key sort - -# ── SET OPERATIONS ─────────────────────────────────────────────────────────── - -r1 | r2 # union (deduplicated) -r1 & r2 # intersection -r1 - r2 # difference -r1 + r2 # concatenate (may have duplicates) -15 in r1 # True if record with id=15 is in recordset -len(r1) # number of records - -# ── EXISTENCE ──────────────────────────────────────────────────────────────── - -records.exists() -# SELECT id FROM estate_property WHERE id IN (1,2,3) -# returns only records that still exist in DB -# useful after possible deletion by other transactions - -# ── NEW RECORD (virtual) ───────────────────────────────────────────────────── - -new_rec = self.new({'name': 'Test', 'expected_price': 100000}) -# creates record in cache only, no INSERT -# used for onchange evaluation and precomputed fields -``` - ---- - -# CHAPTER 11 — All Tables Created by a Typical Addon - -``` -CORE TABLES (created by 'base' module on first run): -┌─────────────────────────┬──────────────────────────────────────────────┐ -│ ir_model │ registry of all models (estate.property etc) │ -│ ir_model_fields │ all fields of each model │ -│ ir_model_access │ perm_read/write/create/unlink per group │ -│ ir_model_data │ XML IDs: module.xml_id → model + res_id │ -│ ir_rule │ record-level access rules (domain per group) │ -│ ir_ui_view │ ALL view XMLs (arch_db column holds XML) │ -│ ir_ui_menu │ menu items tree │ -│ ir_actions │ base actions (parent of act_window etc) │ -│ ir_act_window │ act_window actions (inherits ir_actions) │ -│ ir_act_server │ server actions │ -│ res_groups │ security groups │ -│ res_users │ users │ -│ res_partner │ partners (base of users, customers etc) │ -│ ir_module_module │ installed/available modules list │ -└─────────────────────────┴──────────────────────────────────────────────┘ - -YOUR ADDON TABLES (created by estate module): -┌───────────────────────────────────────┬──────────────────────────────────┐ -│ estate_property │ main model │ -│ estate_property_type │ property types │ -│ estate_property_offer │ offers on properties │ -│ estate_tag │ tags │ -│ estate_property_estate_tag_rel │ M2m junction: property ↔ tags │ -└───────────────────────────────────────┴──────────────────────────────────┘ -``` - ---- - -# CHAPTER 12 — The Complete Story (One Page) - -``` -YOU RUN: ./odoo-bin --addons-path=... -d rd-demo -u estate - │ - ▼ - odoo-bin → cli/command.py:109 main() - find 'server' command → cli/server.py:95 main() - config.parse_config() ← stores all flags - db._create_empty_database('rd-demo') - service/server.py:1541 start() - ThreadedServer created - werkzeug thread spawned → port 8069 listening ✓ - │ - ▼ - preload_registries(['rd-demo']) - Registry.new('rd-demo') [orm/registry.py:129] - load_modules() [modules/loading.py:332] - │ - ▼ - FOR EACH module (base, mail, ..., estate): - read __manifest__.py → name, depends, data files - resolve dependency order via ModuleGraph - Python import → class definitions run - MetaModel registers all Model classes - registry.load() → registry.models['estate.property'] = - _auto_init() → CREATE TABLE estate_property (...) - field.update_db() → ALTER TABLE ADD COLUMN per field - Char/Text/Html → VARCHAR/TEXT - Integer → INTEGER (int4) - Float → NUMERIC - Boolean → BOOLEAN - Date/Datetime → DATE/TIMESTAMP - Selection → VARCHAR - Many2one → INTEGER + FK constraint - One2many → NO COLUMN (inverse of M2o) - Many2many → NO COLUMN + CREATE junction table - convert_file() for each data file: - XML: _tag_record() → ir.ui.view.create() → INSERT INTO ir_ui_view - XML: _tag_record() → ir.actions.act_window.create() → INSERT INTO ir_act_window - XML: _tag_menuitem() → ir.ui.menu.create() → INSERT INTO ir_ui_menu - CSV: ir.model.access → INSERT INTO ir_model_access - module.state = 'installed' → COMMIT ✓ - │ - ▼ - SERVER READY. Registry loaded. HTTP listening on :8069. - │ - ▼ - USER OPENS BROWSER → GET /odoo/estate - Application.__call__(environ) [http.py:2758] - Request._serve_db() [http.py:2213] - Registry('rd-demo').cursor() → psycopg2 connection - Environment(cr, uid=2, context) - ir.http._match(path) → werkzeug finds controller - JsonRPCDispatcher - │ - ▼ - JS: POST /web/dataset/call_kw/estate.property/get_views - get_views() → _get_view_cache() [ormcache] - SELECT id FROM ir_ui_view WHERE model=... AND type='list' - SELECT arch_db FROM ir_ui_view WHERE id=55 - apply extension views with lxml XPath - postprocess: access checks, modifiers - fields_get(): field metadata - → JSON: arch XML + field definitions back to browser - │ - ▼ - JS: POST /web/dataset/call_kw/estate.property/web_search_read - DataSet.call_kw() → service.call_kw() → Model.search_read() - search_fetch() - _search(): access check + record rules → Query object - _fetch_query(): - env.execute_query() → cr.execute(SELECT ... WHERE ... LIMIT 80) - PostgreSQL returns rows - field._insert_cache() → values in env.cache - _read_format(): reads cache → list[dict] - retrying(): env.cr.flush() + env.cr.commit() - → JSON: [{"id":1,"name":"Beach House",...}, ...] - │ - ▼ - OWL JS renders: - ListRenderer walks arch XML - each → picks widget by type: - char → - float → - selection → with autocomplete (name_search RPC) - boolean → - fills values from search_read result ✓ - │ - ▼ - USER CREATES A RECORD → clicks Save - POST /web/dataset/call_kw/estate.property/web_save - Model.create([{name, expected_price, state}]) - check_access('create') → ir.model.access SQL - _prepare_create_values() → add defaults + magic fields - _create(): - cr.execute(INSERT INTO estate_property (...) VALUES (...) RETURNING id) - → id = 15 - cache populated - computed fields scheduled for recompute - _validate_fields() → @constrains run - env.cr.flush() + env.cr.commit() - → JSON: {"result": 15} ✓ - │ - ▼ - USER EDITS A RECORD → clicks Save - Model.write({'state':'offer_accepted','selling_price':195000}) - check_access('write') + record rule check - field.write() → cache updated - _write_multi(): - UPDATE estate_property SET state=..., selling_price=... - WHERE id=15 - _validate_fields() → @constrains run - env.cr.commit() - → JSON: {"result": true} ✓ - │ - ▼ - USER DELETES A RECORD - Model.unlink() - check_access('unlink') - @ondelete methods run - env.flush_all() - DELETE FROM estate_property WHERE id IN (15,) - ir_model_data cleanup - ir_attachment cleanup - env.cr.commit() - → JSON: {"result": true} ✓ -``` - ---- - -# KEY FILES REFERENCE - -``` -STARTUP - community/odoo-bin:5 entry point - community/odoo/cli/command.py:109 main() CLI - community/odoo/cli/server.py:95 Server.main() - community/odoo/service/server.py:1541 start() - community/odoo/service/server.py:589 http_spawn() werkzeug - community/odoo/service/server.py:660 ThreadedServer.run() - community/odoo/service/server.py:1490 preload_registries() - -MODULE LOADING - community/odoo/orm/registry.py:129 Registry.new() - community/odoo/orm/registry.py:366 registry.load() register models - community/odoo/orm/registry.py:723 registry.init_models() - community/odoo/modules/loading.py:332 load_modules() - community/odoo/modules/loading.py:107 load_module_graph() - community/odoo/modules/module.py Manifest.for_addon() - -TABLE CREATION - community/odoo/orm/models.py:3169 _auto_init() CREATE TABLE - community/odoo/orm/fields.py:1096 Field.update_db() - community/odoo/orm/fields.py:1132 Field.update_db_column() ALTER TABLE - community/odoo/orm/fields.py:1150 Field.update_db_notnull() NOT NULL - -FIELD TYPES - community/odoo/orm/fields_numeric.py:17 Integer - community/odoo/orm/fields_numeric.py:60 Float - community/odoo/orm/fields_textual.py:461 Char - community/odoo/orm/fields_textual.py:526 Text - community/odoo/orm/fields_textual.py:541 Html - community/odoo/orm/fields_misc.py:22 Boolean - community/odoo/orm/fields_misc.py:65 Json - community/odoo/orm/fields_temporal.py:106 Date - community/odoo/orm/fields_temporal.py:191 Datetime - community/odoo/orm/fields_selection.py:20 Selection - community/odoo/orm/fields_relational.py:213 Many2one - community/odoo/orm/fields_relational.py:836 One2many - community/odoo/orm/fields_relational.py:1198 Many2many - -DATA FILE PARSING - community/odoo/tools/convert.py:667 convert_file() - community/odoo/tools/convert.py:336 _tag_record() → create() - community/odoo/tools/convert.py:275 _tag_menuitem() → ir.ui.menu - community/odoo/tools/convert.py:469 _tag_template() → ir.ui.view - -VIEW SYSTEM - community/odoo/addons/base/models/ir_ui_view.py:139 IrUiView model definition - community/odoo/addons/base/models/ir_ui_view.py:2893 get_views() RPC entry - community/odoo/addons/base/models/ir_ui_view.py:2966 _get_view() find + combine - community/odoo/addons/base/models/ir_ui_view.py:3079 _get_view_cache() ormcache - community/odoo/addons/base/models/ir_ui_menu.py:16 IrUiMenu model definition - -HTTP + RPC - community/odoo/http.py:2758 Application.__call__() WSGI - community/odoo/http.py:2213 _serve_db() - community/odoo/addons/base/models/ir_http.py:205 ir.http._match() routing - community/addons/web/controllers/dataset.py:28 DataSet.call_kw() controller - community/odoo/service/model.py:70 service.call_kw() - community/odoo/service/model.py:44 get_public_method() - community/odoo/service/model.py:137 execute_cr() - community/odoo/service/model.py:156 retrying() - -ORM READ - community/odoo/orm/models.py:5740 search_read() - community/odoo/orm/models.py:1383 search_fetch() - community/odoo/orm/models.py:5319 _search() → Query object - community/odoo/orm/models.py:3876 _fetch_query() → SQL + cache - community/odoo/orm/models.py:3706 _read_format() → list[dict] - community/odoo/orm/models.py:3466 read() - community/odoo/orm/environments.py:527 execute_query() → cr.execute() - -ORM WRITE - community/odoo/orm/models.py:4608 create() - community/odoo/orm/models.py:4764 _prepare_create_values() - community/odoo/orm/models.py:4844 _create() → INSERT SQL - community/odoo/orm/models.py:4331 write() - community/odoo/orm/models.py:4521 _write_multi() → UPDATE SQL - community/odoo/orm/models.py:4191 unlink() → DELETE SQL -``` - ---- - -*All file paths are relative to `/home/odoo/odoo19/`* -*All line numbers verified against Odoo 19 community source* From 86825675be3c4962f0ab2d8ee4f50ac5cf3e2b08 Mon Sep 17 00:00:00 2001 From: pasaw Date: Wed, 25 Mar 2026 11:12:26 +0530 Subject: [PATCH 8/8] [ADD] estate: completed chapter 8 - add total_area computed field (living_area + garden_area) - add best_price computed field (max of offer prices) - add onchange on garden field (auto-fill area and orientation) - add date_deadline computed field on estate.property.offer - add inverse on date_deadline to sync validity days --- estate/models/estate_property.py | 31 ++++++++++++++++++-- estate/models/estate_property_offer.py | 21 +++++++++++-- estate/views/estate_property_offer_views.xml | 2 ++ estate/views/estate_property_views.xml | 3 ++ 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py index 7ad62a48736..f07137a79de 100644 --- a/estate/models/estate_property.py +++ b/estate/models/estate_property.py @@ -1,5 +1,6 @@ from dateutil.relativedelta import relativedelta -from odoo import fields, models + +from odoo import api,fields,models class EstateProperty(models.Model): @@ -14,7 +15,7 @@ class EstateProperty(models.Model): string="Availability Date", help='Enter the date when the property becomes available', copy=False, - default=lambda self: fields.Date.today() + relativedelta(months=3) + default=lambda self : fields.Date.today() + relativedelta(months=3) ) expected_price = fields.Float(string="Expected Price", required=True, help='Enter the expected price of the property') selling_price = fields.Float(string="Selling Price", help='Enter the selling price of the property', readonly=True, copy=False) @@ -51,7 +52,31 @@ class EstateProperty(models.Model): ) property_type_id = fields.Many2one('estate.property.type', string="Property Type") buyer_id = fields.Many2one('res.partner', string="Buyer", copy=False) - seller_id = fields.Many2one('res.users', string="Salesperson", default=lambda self: self.env.user) + seller_id = fields.Many2one('res.users', string="Salesperson", default=lambda self:self.env.user) tag_ids = fields.Many2many('estate.property.tag', string="Property Tags") offer_ids = fields.One2many("estate.property.offer", "property_id", string="Offers") + + total_area = fields.Integer(string="Total Area", compute="_compute_total_area", help="Total area of the property including living area and garden area", store=True ) + best_price = fields.Float(string="Best Offer", compute="_compute_best_price", help="Best offer received for the property", store=True) + + + + @api.depends("living_area","garden_area") + def _compute_total_area(self): + for record in self: + record.total_area = record.living_area + record.garden_area + + @api.depends("offer_ids.price") + def _compute_best_price(self): + for record in self: + record.best_price = max(record.offer_ids.mapped('price')) if record.offer_ids else 0 + + @api.onchange("garden") + def _onchange_garden(self): + if self.garden == True: + self.garden_area = 10 + self.garden_orientation ='north' + else: + self.garden_area = 0 + self.garden_orientation = False diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py index 6b783bb69b2..6cac8667250 100644 --- a/estate/models/estate_property_offer.py +++ b/estate/models/estate_property_offer.py @@ -1,5 +1,5 @@ -from odoo import fields, models - +from odoo import api,fields, models +from datetime import timedelta class EstatePropertyOffer(models.Model): _name = 'estate.property.offer' @@ -18,3 +18,20 @@ class EstatePropertyOffer(models.Model): partner_id = fields.Many2one('res.partner', required=True, string="Partner") property_id = fields.Many2one('estate.property', required=True, string="Property") + + validity = fields.Integer(string="Validity (days)", default=7, help='Number of days the offer is valid for') + + date_deadline = fields.Date(string="Deadline", compute="_compute_date_deadline", inverse="_inverse_date_deadline", help="Deadline for the offer based on the validity period") + + @api.depends("validity","create_date") + def _compute_date_deadline(self): + for record in self: + start = record.create_date.date() if record.create_date else fields.Date.today() + record.date_deadline = start + timedelta(days=record.validity) + + def _inverse_date_deadline(self): + for record in self: + start = record.create_date.date() if record.create_date else fields.Date.today() + record.validity = (record.date_deadline - start).days + + \ No newline at end of file diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml index 08934a0dae1..3eb2da09658 100644 --- a/estate/views/estate_property_offer_views.xml +++ b/estate/views/estate_property_offer_views.xml @@ -7,6 +7,8 @@ + + diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml index 267474dead9..d479e547c62 100644 --- a/estate/views/estate_property_views.xml +++ b/estate/views/estate_property_views.xml @@ -38,6 +38,7 @@ + @@ -51,6 +52,7 @@ + @@ -121,6 +123,7 @@ Properties estate.property list,form + {'search_default_affordable_or_new_big': 1}