From a3ee89fe2e9cdaf490c00860ff4da0b4fd5dd2d1 Mon Sep 17 00:00:00 2001 From: Stefan Rijnhart Date: Sun, 18 May 2025 19:02:01 +0200 Subject: [PATCH 01/10] [ADD] database_size --- database_size/README.rst | 147 ++++++ database_size/__init__.py | 2 + database_size/__manifest__.py | 24 + database_size/data/ir_cron_data.xml | 11 + database_size/i18n/database_size.pot | 397 +++++++++++++++ database_size/models/__init__.py | 4 + database_size/models/ir_model_index_size.py | 18 + .../models/ir_model_relation_size.py | 18 + database_size/models/ir_model_size.py | 316 ++++++++++++ database_size/models/res_config_settings.py | 32 ++ database_size/pyproject.toml | 3 + database_size/readme/CONFIGURE.md | 9 + database_size/readme/CONTRIBUTORS.md | 1 + database_size/readme/DESCRIPTION.md | 1 + database_size/readme/USAGE.md | 33 ++ database_size/report/__init__.py | 3 + database_size/report/ir_model_size_report.py | 232 +++++++++ .../report/ir_model_size_report_views.xml | 82 +++ database_size/security/ir.model.access.csv | 5 + database_size/static/description/icon.png | Bin 0 -> 10254 bytes database_size/static/description/index.html | 481 ++++++++++++++++++ .../static/images/compare_model_size.png | Bin 0 -> 98105 bytes database_size/static/images/model_size.png | Bin 0 -> 87735 bytes database_size/static/images/select_date.png | Bin 0 -> 24421 bytes .../src/scss/list_view_wrap_header.scss | 13 + database_size/tests/__init__.py | 1 + database_size/tests/test_database_size.py | 217 ++++++++ database_size/views/ir_model_size_views.xml | 121 +++++ .../views/res_config_settings_views.xml | 35 ++ 29 files changed, 2206 insertions(+) create mode 100644 database_size/README.rst create mode 100644 database_size/__init__.py create mode 100644 database_size/__manifest__.py create mode 100644 database_size/data/ir_cron_data.xml create mode 100644 database_size/i18n/database_size.pot create mode 100644 database_size/models/__init__.py create mode 100644 database_size/models/ir_model_index_size.py create mode 100644 database_size/models/ir_model_relation_size.py create mode 100644 database_size/models/ir_model_size.py create mode 100644 database_size/models/res_config_settings.py create mode 100644 database_size/pyproject.toml create mode 100644 database_size/readme/CONFIGURE.md create mode 100644 database_size/readme/CONTRIBUTORS.md create mode 100644 database_size/readme/DESCRIPTION.md create mode 100644 database_size/readme/USAGE.md create mode 100644 database_size/report/__init__.py create mode 100644 database_size/report/ir_model_size_report.py create mode 100644 database_size/report/ir_model_size_report_views.xml create mode 100644 database_size/security/ir.model.access.csv create mode 100644 database_size/static/description/icon.png create mode 100644 database_size/static/description/index.html create mode 100644 database_size/static/images/compare_model_size.png create mode 100644 database_size/static/images/model_size.png create mode 100644 database_size/static/images/select_date.png create mode 100644 database_size/static/src/scss/list_view_wrap_header.scss create mode 100644 database_size/tests/__init__.py create mode 100644 database_size/tests/test_database_size.py create mode 100644 database_size/views/ir_model_size_views.xml create mode 100644 database_size/views/res_config_settings_views.xml diff --git a/database_size/README.rst b/database_size/README.rst new file mode 100644 index 00000000000..38a5bdee386 --- /dev/null +++ b/database_size/README.rst @@ -0,0 +1,147 @@ +============= +Database Size +============= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:cf222bf7680352907c557e9d437a2fefc4f62edec36c28b61519fcde787047a7 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github + :target: https://github.com/OCA/server-tools/tree/18.0/database_size + :alt: OCA/server-tools +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-tools-18-0/server-tools-18-0-database_size + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/server-tools&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Monitor the size of your Odoo instance. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +To configure this module, you can review the scheduled action called +'Take model size measurements' and check the time at which you want it +to run. It should only run once a day. If it runs more often, it just +updates the existing set of sizes for the day. + +You may also review the Database Size settings in Odoo's general +settings and enable 'Purge Older Model Size Measurements'. This task +will by default delete most daily data older than a year except for the +data captured on the first day of each month. These retention periods +can be configured here as well. + +Usage +===== + +You can use this module to keep an eye on the development of the size of +your Odoo instance over time. Every day, a snapshot will be taken with +the full size of the database and the attachments. You can query these +daily snapshots, and you can compare the current size with a size at any +date of the past for which there is data. + +Enable debug mode, then go to menu Settings -> Technical -> Database +Size. + +|image1| + +The data that is gathered and that is displayed are: + +- Model Name - The name of the model to which the data is related +- Estimated Rows - The number of estimated rows according to the + Postgresql query planner. For performance reasons, taking the data + from the planner is preferred over doing an actual count, although the + results may be imprecise. +- Bare Table Size - The disk usage of the model table without indexes + etc. +- Index Size - The disk usage of the indexes in the model table. +- Many2many Tables Size - The disk usage of related many2many tables, + including their indexes. To prevent double counts, many2many tables + are only correlated with one of their tables (the largest of the two). +- Attachment Size - The disk usage of the attachments linked to the + model records. Because Odoo will deduplicate attachments by content, + attachments with the same content may be counted double in the + attachment size of other models, but will not be counted double when + linked to records of the same model more than once. +- Total Table Size - Bare Table Size + Index Size +- Total Database Size - Total Table Size + Many2many Tables Size +- Total Model Size - Total Database Size + Attachment Size + +If you click on individual records, you can inspect the sizes of each +index and many2many table. + +All sizes are in megabytes. + +In the 'Compare Size per Model' report view, you can find these data +twice: once for the selected measurement date (default: today), and once +for the selected comparison date (default: one month ago). + +|image2| + +If you want to compare arbitrary dates, you can start typing the date in +the search box. Be sure to enter the dates in the right format for your +localization. + +|image3| + +.. |image1| image:: https://raw.githubusercontent.com/OCA/server-tools/18.0/database_size/static/images/model_size.png +.. |image2| image:: https://raw.githubusercontent.com/OCA/server-tools/18.0/database_size/static/images/compare_model_size.png +.. |image3| image:: https://raw.githubusercontent.com/OCA/server-tools/18.0/database_size/static/images/select_date.png + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Opener B.V. + +Contributors +------------ + +- Stefan Rijnhart + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/server-tools `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/database_size/__init__.py b/database_size/__init__.py new file mode 100644 index 00000000000..bf588bc8b80 --- /dev/null +++ b/database_size/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import report diff --git a/database_size/__manifest__.py b/database_size/__manifest__.py new file mode 100644 index 00000000000..e50e324ff86 --- /dev/null +++ b/database_size/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2025 Opener B.V. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +{ + "name": "Database Size", + "version": "18.0.1.0.0", + "author": "Opener B.V.,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/server-tools", + "depends": ["base_setup"], + "license": "AGPL-3", + "category": "Tools", + "data": [ + "data/ir_cron_data.xml", + "security/ir.model.access.csv", + "views/ir_model_size_views.xml", + "views/res_config_settings_views.xml", + "report/ir_model_size_report_views.xml", + ], + "assets": { + "web.assets_backend": [ + "database_size/static/src/scss/list_view_wrap_header.scss", + ] + }, + "installable": True, +} diff --git a/database_size/data/ir_cron_data.xml b/database_size/data/ir_cron_data.xml new file mode 100644 index 00000000000..a25066acf6a --- /dev/null +++ b/database_size/data/ir_cron_data.xml @@ -0,0 +1,11 @@ + + + + model._measure() + 1 + days + + Take model size measurements + code + + diff --git a/database_size/i18n/database_size.pot b/database_size/i18n/database_size.pot new file mode 100644 index 00000000000..1c2dd87f9a1 --- /dev/null +++ b/database_size/i18n/database_size.pot @@ -0,0 +1,397 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * database_size +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: database_size +#: model_terms:ir.ui.view,arch_db:database_size.res_config_settings_view_form +msgid " days" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size__attachment_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size_report__attachment_size +msgid "Attachment Size" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,help:database_size.field_ir_model_size__attachment_size +msgid "" +"Attachment Size in MB. Includes overlap of files that are also attached to " +"other models." +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size__table_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size_report__table_size +msgid "Bare Table Size" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,help:database_size.field_ir_model_size__table_size +msgid "Bare Table Size in MB." +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size_report__diff_total_database_size +msgid "Change in Total Database Size" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size_report__diff_total_model_size +msgid "Change in Total Model Size" +msgstr "" + +#. module: database_size +#: model:ir.actions.act_window,name:database_size.ir_model_size_report_action +msgid "Compare Database Size per Model" +msgstr "" + +#. module: database_size +#: model:ir.ui.menu,name:database_size.ir_model_size_report_menu +msgid "Compare Size per Model" +msgstr "" + +#. module: database_size +#: model:ir.model,name:database_size.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_index_size__create_uid +#: model:ir.model.fields,field_description:database_size.field_ir_model_relation_size__create_uid +#: model:ir.model.fields,field_description:database_size.field_ir_model_size__create_uid +msgid "Created by" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_index_size__create_date +#: model:ir.model.fields,field_description:database_size.field_ir_model_relation_size__create_date +#: model:ir.model.fields,field_description:database_size.field_ir_model_size__create_date +msgid "Created on" +msgstr "" + +#. module: database_size +#: model:ir.ui.menu,name:database_size.database_size_menu +#: model_terms:ir.ui.view,arch_db:database_size.res_config_settings_view_form +msgid "Database Size" +msgstr "" + +#. module: database_size +#: model:ir.actions.act_window,name:database_size.ir_model_size_action +msgid "Database Size per Model" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size__measurement_date +#: model:ir.model.fields,field_description:database_size.field_ir_model_size_report__measurement_date +#: model_terms:ir.ui.view,arch_db:database_size.ir_model_size_view_search +msgid "Date of Measurement" +msgstr "" + +#. module: database_size +#: model_terms:ir.ui.view,arch_db:database_size.ir_model_size_report_view_tree +msgid "Details" +msgstr "" + +#. module: database_size +#: model:ir.model,name:database_size.model_ir_model_index_size +msgid "Disk space usage of a single index" +msgstr "" + +#. module: database_size +#: model:ir.model,name:database_size.model_ir_model_relation_size +msgid "Disk space usage of a single many2many table" +msgstr "" + +#. module: database_size +#: model:ir.model,name:database_size.model_ir_model_size +msgid "Disk space usage per model" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_index_size__display_name +#: model:ir.model.fields,field_description:database_size.field_ir_model_relation_size__display_name +#: model:ir.model.fields,field_description:database_size.field_ir_model_size__display_name +#: model:ir.model.fields,field_description:database_size.field_ir_model_size_report__display_name +msgid "Display Name" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size__tuples +#: model:ir.model.fields,field_description:database_size.field_ir_model_size_report__tuples +msgid "Estimated Rows" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,help:database_size.field_ir_model_size__measurement_date +msgid "For the exact time, check the record's write_date." +msgstr "" + +#. module: database_size +#: model_terms:ir.ui.view,arch_db:database_size.ir_model_size_view_search +msgid "Group By" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size_report__historical_attachment_size +msgid "Historical Attachment Size" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size_report__historical_table_size +msgid "Historical Bare Table Size" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size_report__historical_measurement_date +msgid "Historical Date of Measurement" +msgstr "" + +#. module: database_size +#: model:ir.model,name:database_size.model_ir_model_size_report +msgid "Historical Disk space usage per model" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size_report__historical_tuples +msgid "Historical Estimated Rows" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size_report__historical_indexes_size +msgid "Historical Index Size" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size_report__historical_relations_size +msgid "Historical Many2many Tables Size" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size_report__historical_total_database_size +msgid "Historical Total Database Size" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size_report__historical_total_model_size +msgid "Historical Total Model Size" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size_report__historical_total_table_size +msgid "Historical Total Table Size" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_index_size__id +#: model:ir.model.fields,field_description:database_size.field_ir_model_relation_size__id +#: model:ir.model.fields,field_description:database_size.field_ir_model_size__id +#: model:ir.model.fields,field_description:database_size.field_ir_model_size_report__id +msgid "ID" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size__indexes_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size_report__indexes_size +msgid "Index Size" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size__ir_model_index_size_ids +#: model_terms:ir.ui.view,arch_db:database_size.ir_model_size_view_form +msgid "Indexes" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_index_size__ir_model_size_id +#: model:ir.model.fields,field_description:database_size.field_ir_model_relation_size__ir_model_size_id +msgid "Ir Model Size" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_res_config_settings__database_size_retention_daily +msgid "Keep Daily Measurements for" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_res_config_settings__database_size_retention_monthly +msgid "Keep Monthly Measurements for" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_index_size__write_uid +#: model:ir.model.fields,field_description:database_size.field_ir_model_relation_size__write_uid +#: model:ir.model.fields,field_description:database_size.field_ir_model_size__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_index_size__write_date +#: model:ir.model.fields,field_description:database_size.field_ir_model_relation_size__write_date +#: model:ir.model.fields,field_description:database_size.field_ir_model_size__write_date +msgid "Last Updated on" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size__relations_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size_report__relations_size +msgid "Many2many Tables Size" +msgstr "" + +#. module: database_size +#: model_terms:ir.ui.view,arch_db:database_size.ir_model_size_view_form +msgid "Many2many tables" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size__model +#: model:ir.model.fields,field_description:database_size.field_ir_model_size_report__model +#: model_terms:ir.ui.view,arch_db:database_size.ir_model_size_view_search +msgid "Model" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size__model_name +#: model:ir.model.fields,field_description:database_size.field_ir_model_size_report__model_name +msgid "Model Name" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_index_size__name +#: model:ir.model.fields,field_description:database_size.field_ir_model_relation_size__name +msgid "Name" +msgstr "" + +#. module: database_size +#: model_terms:ir.ui.view,arch_db:database_size.ir_model_size_report_view_search +msgid "Notable Change" +msgstr "" + +#. module: database_size +#: model_terms:ir.ui.view,arch_db:database_size.ir_model_size_report_view_search +msgid "One Month Ago" +msgstr "" + +#. module: database_size +#: model_terms:ir.ui.view,arch_db:database_size.ir_model_size_report_view_search +msgid "One Year Ago" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_res_config_settings__database_size_purge +msgid "Purge Older Model Size Measurements" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size__ir_model_relation_size_ids +msgid "Relations" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,help:database_size.field_ir_model_size__tuples +msgid "Rows in use, including dead tuples" +msgstr "" + +#. module: database_size +#: model_terms:ir.ui.view,arch_db:database_size.res_config_settings_view_form +msgid "Set to 0 to keep monthly measurements forever." +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_index_size__size +#: model:ir.model.fields,field_description:database_size.field_ir_model_relation_size__size +msgid "Size" +msgstr "" + +#. module: database_size +#: model:ir.ui.menu,name:database_size.ir_model_size_menu +msgid "Size per Model" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_index_size__smart_search +#: model:ir.model.fields,field_description:database_size.field_ir_model_relation_size__smart_search +#: model:ir.model.fields,field_description:database_size.field_ir_model_size__smart_search +#: model:ir.model.fields,field_description:database_size.field_ir_model_size_report__smart_search +#: model:ir.model.fields,field_description:database_size.field_res_config_settings__smart_search +msgid "Smart Search" +msgstr "" + +#. module: database_size +#: model:ir.actions.server,name:database_size.ir_cron_ir_model_size_measure_ir_actions_server +msgid "Take model size measurements" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,help:database_size.field_res_config_settings__database_size_retention_monthly +msgid "" +"The period of time (in days) during which database size measurmeents are " +"kept of the first day of each month. If set to 0, measurements will be kept " +"forever." +msgstr "" + +#. module: database_size +#: model:ir.model.fields,help:database_size.field_res_config_settings__database_size_retention_daily +msgid "" +"The period of time (in days) during which the daily database size " +"measurements are kept. If set to 0, measurements will be kept forever." +msgstr "" + +#. module: database_size +#: model:ir.model.constraint,message:database_size.constraint_ir_model_size_uniq_model_measurement_date +msgid "There is already a measurement for this model on the given date" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size__total_database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size_report__total_database_size +msgid "Total Database Size" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size__total_model_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size_report__total_model_size +msgid "Total Model Size" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,help:database_size.field_ir_model_size__total_database_size +msgid "Total Model Size in MB. This includes many2many tables" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,help:database_size.field_ir_model_size__indexes_size +msgid "Total Size of Indexes in MB" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,help:database_size.field_ir_model_size__relations_size +msgid "Total Size of many2many relations in MB" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size__total_table_size +#: model:ir.model.fields,field_description:database_size.field_ir_model_size_report__total_table_size +msgid "Total Table Size" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,help:database_size.field_ir_model_size__total_table_size +msgid "Total Table Size in MB. This includes indexes and toast tables" +msgstr "" + +#. module: database_size +#: model:ir.model.fields,help:database_size.field_ir_model_size__total_model_size +msgid "Total model size in MB. This includes attachments." +msgstr "" diff --git a/database_size/models/__init__.py b/database_size/models/__init__.py new file mode 100644 index 00000000000..4824f73bded --- /dev/null +++ b/database_size/models/__init__.py @@ -0,0 +1,4 @@ +from . import ir_model_size +from . import ir_model_index_size +from . import ir_model_relation_size +from . import res_config_settings diff --git a/database_size/models/ir_model_index_size.py b/database_size/models/ir_model_index_size.py new file mode 100644 index 00000000000..572fbd055d2 --- /dev/null +++ b/database_size/models/ir_model_index_size.py @@ -0,0 +1,18 @@ +# Copyright 2025 Opener B.V. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class IrModelIndexSize(models.Model): + _name = "ir.model.index.size" + _description = "Disk space usage of a single index" + _order = "ir_model_size_id desc, size desc" + + name = fields.Char(required=True) + ir_model_size_id = fields.Many2one( + comodel_name="ir.model.size", + index=True, + ondelete="cascade", + required=True, + ) + size = fields.Integer() diff --git a/database_size/models/ir_model_relation_size.py b/database_size/models/ir_model_relation_size.py new file mode 100644 index 00000000000..d0c9333a5eb --- /dev/null +++ b/database_size/models/ir_model_relation_size.py @@ -0,0 +1,18 @@ +# Copyright 2025 Opener B.V. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class IrModelRelationSize(models.Model): + _name = "ir.model.relation.size" + _description = "Disk space usage of a single many2many table" + _order = "ir_model_size_id desc, size desc" + + name = fields.Char(required=True) + ir_model_size_id = fields.Many2one( + comodel_name="ir.model.size", + index=True, + ondelete="cascade", + required=True, + ) + size = fields.Integer() diff --git a/database_size/models/ir_model_size.py b/database_size/models/ir_model_size.py new file mode 100644 index 00000000000..9e92de10289 --- /dev/null +++ b/database_size/models/ir_model_size.py @@ -0,0 +1,316 @@ +# Copyright 2025 Opener B.V. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import logging +from datetime import timedelta + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class IrModelSize(models.Model): + _name = "ir.model.size" + _description = "Disk space usage per model" + _order = "measurement_date desc, total_model_size desc" + _rec_name = "model" + _sql_constraints = [ + ( + "uniq_model_measurement_date", + "unique(model, measurement_date)", + "There is already a measurement for this model on the given date", + ), + ] + model = fields.Char(index=True) + model_name = fields.Char( + compute="_compute_model_name", + store=True, + ) + measurement_date = fields.Date( + "Date of Measurement", + help="For the exact time, check the record's write_date.", + required=True, + ) + total_model_size = fields.Integer( + compute="_compute_total_sizes", + help="Total model size in MB. This includes attachments.", + store=True, + ) + total_database_size = fields.Integer( + compute="_compute_total_sizes", + help="Total Model Size in MB. This includes many2many tables", + store=True, + ) + total_table_size = fields.Integer( + help="Total Table Size in MB. This includes indexes and toast tables", + ) + table_size = fields.Integer( + string="Bare Table Size", + help="Bare Table Size in MB.", + ) + ir_model_index_size_ids = fields.One2many( + comodel_name="ir.model.index.size", + inverse_name="ir_model_size_id", + string="Indexes", + ) + ir_model_relation_size_ids = fields.One2many( + comodel_name="ir.model.relation.size", + inverse_name="ir_model_size_id", + string="Relations", + ) + indexes_size = fields.Integer( + compute="_compute_indexes_size", + help="Total Size of Indexes in MB", + store=True, + string="Index Size", + ) + relations_size = fields.Integer( + compute="_compute_relations_size", + help="Total Size of many2many relations in MB", + store=True, + string="Many2many Tables Size", + ) + tuples = fields.Integer( + string="Estimated Rows", + help="Rows in use, including dead tuples", + ) + attachment_size = fields.Integer( + help=( + "Attachment Size in MB. Includes overlap of files that are also " + "attached to other models." + ), + ) + + @api.depends("model") + def _compute_model_name(self): + """Assign the model's label""" + model2name = { + model.model: model.name for model in self.env["ir.model"].sudo().search([]) + } + for size in self: + size.model_name = model2name.get(size.model, "") + + @api.model + def read_group( + self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True + ): + """Enforce that grouped results are ordered. + + Odoo will happily use the grouping field for ordering unless groupby is a + list, and as it happens the grouping is usually passed as a list, for + example: ['measurement_date:day'] + """ + if not orderby and groupby and isinstance(groupby, list | set): + field = groupby[0].split(":")[0] + orderby = f"{field} desc" + return super().read_group( + domain, + fields, + groupby, + offset=offset, + limit=limit, + orderby=orderby, + lazy=lazy, + ) + + @api.depends( + "total_table_size", + "relations_size", + "attachment_size", + ) + def _compute_total_sizes(self): + for size in self: + size.total_database_size = size.total_table_size + size.relations_size + size.total_model_size = size.total_database_size + size.attachment_size + + @api.depends("ir_model_index_size_ids", "ir_model_index_size_ids.size") + def _compute_indexes_size(self): + for size in self: + size.indexes_size = sum(size.ir_model_index_size_ids.mapped("size")) + + @api.depends("ir_model_relation_size_ids", "ir_model_relation_size_ids.size") + def _compute_relations_size(self): + for size in self: + size.relations_size = sum(size.ir_model_relation_size_ids.mapped("size")) + + @staticmethod + def _normalize_size(size): + """Filter out -1s and compute as MB""" + if not size: + return 0 + return int(max(0, size) / (1024 * 1024)) + + @api.model + def _measure(self): + """Create the entries for today's report""" + today = fields.Date.context_today(self) + # Remove any previous report for the same day + self.search([("measurement_date", "=", today)]).unlink() + table2model = {} + for model in self.env.values(): + if not model._abstract and not model._transient: + model_model = model._name + table2model[model._table] = model_model + model2vals = { + model_model: { + "model": model_model, + "measurement_date": today, + "ir_model_index_size_ids": [], + "ir_model_relation_size_ids": [], + } + for model_model in table2model.values() + } + # Some many2many relation objects are linked explicitely to both models + # involved. To prevent counting them double, we will link them to the + # largest table. Gather all the related models first. + self.env.cr.execute( + """ + select name, array_agg(model) + from ir_model_relation group by name; + """ + ) + relation2model = dict(self.env.cr.fetchall()) + self.env.cr.execute( + """ + SELECT relname, + reltuples, + pg_total_relation_size (C.oid), + pg_relation_size (C.oid) + FROM pg_class C + LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace) + WHERE nspname NOT IN ( + 'information_schema', + 'pg_catalog', + 'pg_logical', + 'pg_toast' + ) + AND C.relkind = 'r' + """ + ) + # Gather sizes of model tables and many2many tables + rows = self.env.cr.fetchall() + for table, tuples, total_table_size, table_size in rows: + model = table2model.get(table) + if model: + model2vals[model].update( + { + "table_size": self._normalize_size(table_size), + "total_table_size": self._normalize_size(total_table_size), + "tuples": max(tuples, 0), + } + ) + # Second pass to throw in the relation tables with the largest relation + for table, _tuples, total_table_size, _table_size in rows: + if table in relation2model: + models = relation2model[table] + model = sorted( + models, + key=lambda model: model2vals.get(model, {"tuples": -99})["tuples"], + reverse=True, + )[0] + vals = model2vals.get(model) + if vals: + vals["ir_model_relation_size_ids"].append( + fields.Command.create( + { + "name": table, + "size": self._normalize_size(total_table_size), + } + ) + ) + # Gather sizes of indexes + self.env.cr.execute( + """ + SELECT i.relname table_name, + indexrelname index_name, + pg_relation_size(indexrelid) index_size + FROM pg_stat_all_indexes i + JOIN pg_class c ON i.relid=c.oid + WHERE schemaname NOT IN ( + 'information_schema', + 'pg_catalog', + 'pg_toast', + 'pg_logical' + ); + """ + ) + for table, index, size in self.env.cr.fetchall(): + vals = model2vals.get(table2model.get(table)) + if vals: + vals["ir_model_index_size_ids"].append( + fields.Command.create( + { + "name": index, + "size": self._normalize_size(size), + } + ) + ) + # Gather sizes of attachments. Deduplicate by checksum such that the + # attachment is attributed to the first model it was linked to. + self.env.cr.execute( + """ + with unique_attachments as ( + select res_model, + file_size, + row_number() over (partition by checksum order by id) as rowno + from ir_attachment + ) + select res_model, sum(file_size) + from unique_attachments + where rowno = 1 + group by res_model; + """ + ) + for model, size in self.env.cr.fetchall(): + vals = model2vals.get(model) + if vals: + vals["attachment_size"] = self._normalize_size(size) + vals_list = [val for val in model2vals.values() if "table_size" in val] + self.create(vals_list) + _logger.info("Created %s model database size records", len(vals_list)) + + @api.autovacuum + def _purge(self): + """Remove older model size records, if enabled in the General Settings.""" + if ( + not self.env["ir.config_parameter"] + .sudo() + .get_param("database_size.purge_enable") + ): + return + retention_daily = int( + self.env["ir.config_parameter"] + .sudo() + .get_param("database_size.retention_daily", 366) + ) + retention_monthly = int( + self.env["ir.config_parameter"] + .sudo() + .get_param("database_size.retention_monthly", 0) + ) + if retention_daily: + cutoff_date = fields.Date.today() - timedelta(days=retention_daily) + self.env.cr.execute( + """ + delete from ir_model_size + where measurement_date < %(cutoff_date)s + and extract(day from measurement_date) != 1; + """, + {"cutoff_date": cutoff_date}, + ) + _logger.info( + f"Deleted {self.env.cr.rowcount} ir_model_size from before " + f"{cutoff_date} from any other day than the first day of the month." + ) + if retention_monthly and retention_monthly > retention_daily: + cutoff_date = fields.Date.today() - timedelta(days=retention_monthly) + self.env.cr.execute( + """ + delete from ir_model_size + where measurement_date < %(cutoff_date)s; + """, + {"cutoff_date": cutoff_date}, + ) + _logger.info( + f"Deleted {self.env.cr.rowcount} ir_model_size from before " + f"{cutoff_date}." + ) diff --git a/database_size/models/res_config_settings.py b/database_size/models/res_config_settings.py new file mode 100644 index 00000000000..eff628ee7d3 --- /dev/null +++ b/database_size/models/res_config_settings.py @@ -0,0 +1,32 @@ +# Copyright 2025 Opener B.V. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + database_size_purge = fields.Boolean( + string="Purge Older Model Size Measurements", + config_parameter="database_size.purge_enable", + ) + database_size_retention_daily = fields.Integer( + string="Keep Daily Measurements for", + config_parameter="database_size.retention_daily", + help=( + "The period of time (in days) during which the daily database size " + "measurements are kept. If set to 0, measurements will be kept " + "forever." + ), + default="366", + ) + database_size_retention_monthly = fields.Integer( + string="Keep Monthly Measurements for", + config_parameter="database_size.retention_monthly", + help=( + "The period of time (in days) during which database size measurmeents " + "are kept of the first day of each month. If set to 0, measurements " + "will be kept forever." + ), + default="0", + ) diff --git a/database_size/pyproject.toml b/database_size/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/database_size/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/database_size/readme/CONFIGURE.md b/database_size/readme/CONFIGURE.md new file mode 100644 index 00000000000..ac526aec25c --- /dev/null +++ b/database_size/readme/CONFIGURE.md @@ -0,0 +1,9 @@ +To configure this module, you can review the scheduled action called 'Take model +size measurements' and check the time at which you want it to run. It should +only run once a day. If it runs more often, it just updates the existing set of +sizes for the day. + +You may also review the Database Size settings in Odoo's general settings and +enable 'Purge Older Model Size Measurements'. This task will by default delete +most daily data older than a year except for the data captured on the first day +of each month. These retention periods can be configured here as well. diff --git a/database_size/readme/CONTRIBUTORS.md b/database_size/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..8aa10dd3d4d --- /dev/null +++ b/database_size/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Stefan Rijnhart \<\> diff --git a/database_size/readme/DESCRIPTION.md b/database_size/readme/DESCRIPTION.md new file mode 100644 index 00000000000..7e3ca0243b2 --- /dev/null +++ b/database_size/readme/DESCRIPTION.md @@ -0,0 +1 @@ +Monitor the size of your Odoo instance. diff --git a/database_size/readme/USAGE.md b/database_size/readme/USAGE.md new file mode 100644 index 00000000000..a1f706a02ac --- /dev/null +++ b/database_size/readme/USAGE.md @@ -0,0 +1,33 @@ +You can use this module to keep an eye on the development of the size of your +Odoo instance over time. Every day, a snapshot will be taken with the full size +of the database and the attachments. You can query these daily snapshots, and +you can compare the current size with a size at any date of the past for which +there is data. + +Enable debug mode, then go to menu Settings -> Technical -> Database Size. + +![image1](https://raw.githubusercontent.com/OCA/server-tools/18.0/database_size/static/images/model_size.png) + +The data that is gathered and that is displayed are: + +* Model Name - The name of the model to which the data is related +* Estimated Rows - The number of estimated rows according to the Postgresql query planner. For performance reasons, taking the data from the planner is preferred over doing an actual count, although the results may be imprecise. +* Bare Table Size - The disk usage of the model table without indexes etc. +* Index Size - The disk usage of the indexes in the model table. +* Many2many Tables Size - The disk usage of related many2many tables, including their indexes. To prevent double counts, many2many tables are only correlated with one of their tables (the largest of the two). +* Attachment Size - The disk usage of the attachments linked to the model records. Because Odoo will deduplicate attachments by content, attachments with the same content may be counted double in the attachment size of other models, but will not be counted double when linked to records of the same model more than once. +* Total Table Size - Bare Table Size + Index Size +* Total Database Size - Total Table Size + Many2many Tables Size +* Total Model Size - Total Database Size + Attachment Size + +If you click on individual records, you can inspect the sizes of each index and many2many table. + +All sizes are in megabytes. + +In the 'Compare Size per Model' report view, you can find these data twice: once for the selected measurement date (default: today), and once for the selected comparison date (default: one month ago). + +![image2](https://raw.githubusercontent.com/OCA/server-tools/18.0/database_size/static/images/compare_model_size.png) + +If you want to compare arbitrary dates, you can start typing the date in the search box. Be sure to enter the dates in the right format for your localization. + +![image3](https://raw.githubusercontent.com/OCA/server-tools/18.0/database_size/static/images/select_date.png) diff --git a/database_size/report/__init__.py b/database_size/report/__init__.py new file mode 100644 index 00000000000..8d71005465e --- /dev/null +++ b/database_size/report/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2025 Opener B.V. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from . import ir_model_size_report diff --git a/database_size/report/ir_model_size_report.py b/database_size/report/ir_model_size_report.py new file mode 100644 index 00000000000..9bf57772818 --- /dev/null +++ b/database_size/report/ir_model_size_report.py @@ -0,0 +1,232 @@ +# Copyright 2025 Opener B.V. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models +from odoo.exceptions import UserError + + +class IrModelSizeReport(models.Model): + _name = "ir.model.size.report" + _description = "Historical Disk space usage per model" + _auto = False + _rec_name = "historical_measurement_date" + _order = "historical_measurement_date desc, diff_total_model_size desc" + + model = fields.Char() + model_name = fields.Char() + + measurement_date = fields.Date("Date of Measurement") + historical_measurement_date = fields.Date("Historical Date of Measurement") + + total_model_size = fields.Integer() + historical_total_model_size = fields.Integer() + diff_total_model_size = fields.Integer("Change in Total Model Size") + + total_database_size = fields.Integer() + historical_total_database_size = fields.Integer() + diff_total_database_size = fields.Integer("Change in Total Database Size") + + total_table_size = fields.Integer() + historical_total_table_size = fields.Integer() + + table_size = fields.Integer("Bare Table Size") + historical_table_size = fields.Integer("Historical Bare Table Size") + + indexes_size = fields.Integer("Index Size") + historical_indexes_size = fields.Integer("Historical Index Size") + + relations_size = fields.Integer("Many2many Tables Size") + historical_relations_size = fields.Integer("Historical Many2many Tables Size") + + tuples = fields.Integer("Estimated Rows") + historical_tuples = fields.Integer("Historical Estimated Rows") + + attachment_size = fields.Integer() + historical_attachment_size = fields.Integer() + + def action_open_model_sizes(self): + """Open the model_sizes from the report line. + + At this point, the 'virtual' report record might not exist anymore + so we fetch the dates from the context. + """ + self.ensure_one() + domain = [ + ("model", "=", self.model), + ( + "measurement_date", + "in", + ( + self.env.context.get("measurement_date"), + self.env.context.get("historical_measurement_date"), + ), + ), + ] + action = self.env["ir.actions.actions"]._for_xml_id( + "database_size.ir_model_size_action" + ) + action["domain"] = domain + return action + + @api.model + def _move_dates_to_context(self, domain): + """Move the requested comparison date from the domain into the context. + + The values in the context will be used when creating the virtual table + in `_table_query`. + """ + new_domain = [] + values = {} + for clause in domain or []: + for field in ("measurement_date", "historical_measurement_date"): + if not isinstance(clause, tuple | list) or clause[0] != field: + continue + if field in values: + raise UserError( + self.env._( + f"You cannot search on more than one value for {field} " + "at the same time." + ) + ) + if clause[1] in ("=", "==") and clause[2]: + values[field] = clause[2] + else: + raise UserError( + self.env._( + f"Searching {field} for '{clause[1]} {clause[2]}' is " + "not supported." + ) + ) + new_domain.append((1, "=", 1)) + else: + new_domain.append(clause) + if values: + self = self.with_context(**values) + return self, new_domain + + @api.model + def _where_calc(self, domain, active_test=True): + """Move the requested dates from the domain into the context""" + (self, new_domain) = self._move_dates_to_context(domain) + return super()._where_calc(new_domain, active_test=active_test) + + @api.model + def search(self, domain, offset=0, limit=None, order=None): + """Move the requested dates from the domain into the context""" + (self, new_domain) = self._move_dates_to_context(domain) + return super().search(new_domain, offset=offset, limit=limit, order=order) + + @api.model + def search_count(self, domain, limit=None): + """Move the requested dates from the domain into the context""" + (self, new_domain) = self._move_dates_to_context(domain) + return super().search_count(new_domain, limit=limit) + + @property + def _table_query(self): + """Report comparative database size changes between two dates. + + The dates are inserted in the context in this model's `search` method. + """ + measurement_date = self.env.context.get("measurement_date") + if measurement_date: + measurement_date = fields.Date.to_date(measurement_date) + if not self.env["ir.model.size"].search( + [("measurement_date", "=", measurement_date)], + limit=1, + ): + raise UserError( + self.env._( + "There is no data from " + f"{fields.Date.to_string(measurement_date)}" + ) + ) + else: + # Use the most recent measurement by default + measurement_date = ( + self.env["ir.model.size"] + .search([], order="id desc", limit=1) + .measurement_date + ) + if not measurement_date: + raise UserError(self.env._("There does not seem to be any data")) + + historical_measurement_date = self.env.context.get( + "historical_measurement_date" + ) + if historical_measurement_date: + historical_measurement_date = fields.Date.to_date( + historical_measurement_date + ) + if not self.env["ir.model.size"].search( + [("measurement_date", "=", historical_measurement_date)], + limit=1, + ): + raise UserError( + self.env._( + "There is no data from " + f"{fields.Date.to_string(historical_measurement_date)}" + ) + ) + else: + # Use last month by default + last_month = measurement_date - relativedelta(months=1) + historical_measurement_date = ( + self.env["ir.model.size"] + .search( + [ + ("measurement_date", ">=", last_month), + ("measurement_date", "<", measurement_date), + ], + order="measurement_date asc", + limit=1, + ) + .measurement_date + ) + if not historical_measurement_date: + raise UserError( + self.env._("There does not seem to be enough data to compare") + ) + + return self.env.cr.mogrify( + """ + select %(measurement_date)s as measurement_date, + %(historical_measurement_date)s as historical_measurement_date, + cur.id as id, + cur.model, + cur.model_name, + cur.total_model_size, + coalesce(hst.total_model_size, 0) as historical_total_model_size, + coalesce(cur.total_model_size) - coalesce(hst.total_model_size, 0) + as diff_total_model_size, + + cur.total_database_size, + coalesce(hst.total_database_size, 0) as historical_total_database_size, + coalesce(cur.total_database_size, 0) - coalesce(hst.total_database_size, 0) + as diff_total_database_size, + + cur.table_size, + coalesce(hst.table_size, 0) as historical_table_size, + cur.total_table_size, + coalesce(hst.total_table_size, 0) as historical_total_table_size, + cur.indexes_size, + coalesce(hst.indexes_size, 0) as historical_indexes_size, + cur.relations_size, + coalesce(hst.relations_size, 0) as historical_relations_size, + cur.tuples, + coalesce(hst.tuples, 0) as historical_tuples, + cur.attachment_size, + coalesce(hst.attachment_size, 0) as historical_attachment_size + + from ir_model_size cur + left join ir_model_size hst + on cur.model = hst.model + and hst.measurement_date = %(historical_measurement_date)s + where cur.measurement_date = %(measurement_date)s + """, + { + "measurement_date": measurement_date, + "historical_measurement_date": historical_measurement_date, + }, + ).decode("utf-8") diff --git a/database_size/report/ir_model_size_report_views.xml b/database_size/report/ir_model_size_report_views.xml new file mode 100644 index 00000000000..892d5ecd3c0 --- /dev/null +++ b/database_size/report/ir_model_size_report_views.xml @@ -0,0 +1,82 @@ + + + + ir.model.size.report + + + + + + + + + + + + + + + + ir.model.size.report + + + + + + +