diff --git a/report_builder/README.rst b/report_builder/README.rst new file mode 100644 index 0000000000..94dc4bd2fb --- /dev/null +++ b/report_builder/README.rst @@ -0,0 +1,99 @@ +============== +Report Builder +============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:f45b4a6b59a17fad4d9603c51083eb77b9b988b216b103b3600394c638819ffb + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Freporting--engine-lightgray.png?logo=github + :target: https://github.com/OCA/reporting-engine/tree/18.0/report_builder + :alt: OCA/reporting-engine +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/reporting-engine-18-0/reporting-engine-18-0-report_builder + :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/reporting-engine&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This modules is a grid-based KPI reporting tool that works across any +Odoo model โ€” not just accounting. Rows are KPIs defined by readable +formulas referencing pre-definied KPI computations. + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +This module is inspired by mis-builder and shares its goal of grid-based +KPI reporting. However, it takes a different architectural approach: +rather than being tightly coupled to accounting, it is designed to be +pluggable on any Odoo model through small glue modules. + +A migration path from mis-builder is planned but not yet available. Both +modules can coexist in the meantime. + +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 +------- + +* Dixmit + +Contributors +------------ + +- `Dixmit `__ + + - Enric Tobella + +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. + +.. |maintainer-etobella| image:: https://github.com/etobella.png?size=40px + :target: https://github.com/etobella + :alt: etobella + +Current `maintainer `__: + +|maintainer-etobella| + +This module is part of the `OCA/reporting-engine `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/report_builder/__init__.py b/report_builder/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/report_builder/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/report_builder/__manifest__.py b/report_builder/__manifest__.py new file mode 100644 index 0000000000..eefe6d760a --- /dev/null +++ b/report_builder/__manifest__.py @@ -0,0 +1,37 @@ +# Copyright 2025 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Report Builder", + "summary": """Allow to generate dynamic reports easily in Odoo""", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "Dixmit,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/reporting-engine", + "depends": [ + "board", + "date_range", + "report_xlsx", + ], + "data": [ + "security/ir.model.access.csv", + "views/menu.xml", + "views/report_instance.xml", + "views/report_instance_column.xml", + "views/report_template.xml", + "views/report_template_kpi.xml", + "views/report_style.xml", + ], + "demo": [], + "assets": { + "web.assets_backend": [ + "report_builder/static/src/components/**/*.esm.js", + "report_builder/static/src/components/**/*.xml", + "report_builder/static/src/components/**/*.scss", + ], + "web.assets_unit_tests": [ + "report_builder/static/tests/**/*.test.js", + ], + }, + "maintainers": ["etobella"], +} diff --git a/report_builder/models/__init__.py b/report_builder/models/__init__.py new file mode 100644 index 0000000000..78db058dad --- /dev/null +++ b/report_builder/models/__init__.py @@ -0,0 +1,7 @@ +from . import report_template +from . import report_instance +from . import report_template_kpi +from . import report_template_kpi_item +from . import report_template_kpi_query_kind +from . import report_style +from . import report_instance_column diff --git a/report_builder/models/report_instance.py b/report_builder/models/report_instance.py new file mode 100644 index 0000000000..89f1058184 --- /dev/null +++ b/report_builder/models/report_instance.py @@ -0,0 +1,106 @@ +# Copyright 2025 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo import api, fields, models + + +class ReportInstance(models.Model): + _name = "report.instance" + _description = "Instance of Report" + + name = fields.Char(required=True) + template_id = fields.Many2one("report.template", required=True) + source = fields.Selection(related="template_id.source", store=True) + show_search_bar = fields.Boolean(default=False) + search_view_id = fields.Many2one(related="template_id.search_view_id") + search_res_model = fields.Char(related="template_id.search_model_id.model") + show_settings = fields.Boolean(default=True) + show_pivot_date = fields.Boolean(default=True) + base_date = fields.Date() + currency_id = fields.Many2one( + "res.currency", + required=True, + default=lambda self: self.env.company.currency_id, + ) + + data = fields.Json(compute="_compute_data") + column_ids = fields.One2many( + "report.instance.column", + "instance_id", + ) + active = fields.Boolean(default=True) + + @api.depends("template_id", "column_ids") + def _compute_data(self): + for record in self: + record.data = record._get_data() + + def _get_data(self): + rows = [] + style = self.template_id.style_id._get_style() + for kpi in self.template_id.kpi_ids: + kpi_style, kpi_parameters = kpi.style_id._get_style_css(style) + rows.append( + { + "id": kpi.id, + "name": kpi.name, + "is_currency": kpi.is_currency, + "style": kpi_style, + "parameters": kpi_parameters, + } + ) + columns = self.column_ids._get_data() + return { + "date": fields.Date.to_string(self.base_date or fields.Date.today()), + "rows": rows, + "columns": columns, + } + + def process_information(self, pivot_date, domain=None): + self.ensure_one() + cols = self.column_ids._get_data(fields.Date.from_string(pivot_date)) + kpi_data = {} + self.template_id.kpi_ids._process_information( + cols, kpi_data, domain=domain, **self._extra_process_keys() + ) + return kpi_data + + def _extra_process_keys(self): + """ + This method can be overridden in subclasses to provide extra keys + for the _process_information method. + """ + return {} + + def view_report_instance(self): + self.ensure_one() + action = self.env["ir.actions.act_window"]._for_xml_id( + "report_builder.report_instance_view_act_window" + ) + action.update({"res_id": self.id}) + return action + + def get_display_settings_action(self): + self.ensure_one() + action = self.env["ir.actions.act_window"]._for_xml_id( + "report_builder.report_instance_act_window" + ) + action.update( + { + "res_id": self.id, + "view_mode": "form", + "views": [view for view in action["views"] if view[1] == "form"], + } + ) + return action + + def get_pdf_report_action(self, pivot_date, domain=None): + # TODO: implement the method to return the action for PDF report generation + self.ensure_one() + return {} + + def get_xlsx_report_action(self, pivot_date, domain=None): + # TODO: implement the method to return the action for XLSX report generation + self.ensure_one() + return {} diff --git a/report_builder/models/report_instance_column.py b/report_builder/models/report_instance_column.py new file mode 100644 index 0000000000..d24d5bb1ce --- /dev/null +++ b/report_builder/models/report_instance_column.py @@ -0,0 +1,104 @@ +# Copyright 2025 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models + + +class ReportInstanceColumn(models.Model): + _name = "report.instance.column" + _description = "Report Instance Column" # TODO + + name = fields.Char(required=True) + instance_id = fields.Many2one("report.instance", required=True) + mode = fields.Selection( + [ + ("date", "Fixed Date"), + ("relative", "Relative to Base Date"), + ], + default="relative", + ) + date_from = fields.Date() + date_to = fields.Date() + date_type = fields.Selection( + [ + ("days", "Day"), + ("weeks", "Week"), + ("months", "Month"), + ("years", "Year"), + ], + default="months", + string="Period type", + ) + duration_type = fields.Selection( + [ + ("days", "Day"), + ("weeks", "Week"), + ("months", "Month"), + ("years", "Year"), + ], + default="months", + ) + is_ytd = fields.Boolean( + default=False, + string="Year to date", + help="Forces the start date to Jan 1st of the relevant year", + ) + offset = fields.Integer(help="Offset from current period", default=-1) + duration = fields.Integer(help="Number of periods", default=1) + compute_date_from = fields.Date( + compute="_compute_compute_date", + ) + compute_date_to = fields.Date( + compute="_compute_compute_date", + ) + sequence = fields.Integer( + default=10, + ) + + @api.depends( + "instance_id.base_date", + "date_type", + "date_from", + "date_to", + "mode", + "is_ytd", + "offset", + "duration", + "duration_type", + ) + def _compute_compute_date(self): + for record in self: + record.compute_date_from, record.compute_date_to = ( + record._get_compute_date() + ) + + def _get_compute_date(self, pivot_date=None): + if self.mode == "date": + return self.date_from, self.date_to + elif self.mode == "relative": + compute_date_from = ( + pivot_date or self.instance_id.base_date or fields.Date.today() + ) + relativedelta(**{self.date_type: self.offset}) + compute_date_to = compute_date_from + relativedelta( + **{self.duration_type: self.duration} + ) + if self.is_ytd: + compute_date_from = compute_date_from.replace(month=1, day=1) + return compute_date_from, compute_date_to + + def _get_data(self, pivot_date=None): + columns = [] + for column in self: + date_from, date_to = column._get_compute_date(pivot_date) + columns.append( + { + "id": column.id, + "name": column.name, + "mode": column.mode, + "date_from": fields.Date.to_string(date_from), + "date_to": fields.Date.to_string(date_to), + } + ) + return columns diff --git a/report_builder/models/report_style.py b/report_builder/models/report_style.py new file mode 100644 index 0000000000..20c0bbc2a1 --- /dev/null +++ b/report_builder/models/report_style.py @@ -0,0 +1,126 @@ +# Copyright 2025 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ReportStyle(models.Model): + _name = "report.style" + _description = "Report Style" + + name = fields.Char(required=True) + text_color = fields.Char( + help="Text color in valid RGB code (from #000000 to #FFFFFF)", + default="#000000", + ) + text_color_inherit = fields.Boolean(default=True) + background_color = fields.Char( + help="Background color in valid RGB code (from #000000 to #FFFFFF)", + default="#FFFFFF", + ) + background_color_inherit = fields.Boolean(default=True) + font_style = fields.Selection( + [ + ("normal", "Normal"), + ("italic", "Italic"), + ("oblique", "Oblique"), + ] + ) + font_style_inherit = fields.Boolean(default=True) + font_weight = fields.Selection( + [ + ("normal", "Normal"), + ("bold", "Bold"), + ("bolder", "Bolder"), + ("lighter", "Lighter"), + ] + ) + font_weight_inherit = fields.Boolean(default=True) + font_size = fields.Selection( + [ + ("xx-small", "XX-Small"), + ("x-small", "X-Small"), + ("small", "Small"), + ("medium", "Medium"), + ("large", "Large"), + ("x-large", "X-Large"), + ("xx-large", "XX-Large"), + ("xxx-large", "XXX-Large"), + ] + ) + font_size_inherit = fields.Boolean(default=True) + indent_level = fields.Integer() + indent_level_inherit = fields.Boolean(default=True) + prefix = fields.Char() + prefix_inherit = fields.Boolean(default=True) + suffix = fields.Char() + suffix_inherit = fields.Boolean(default=True) + rounding = fields.Integer() + rounding_inherit = fields.Boolean(default=True) + divider = fields.Selection( + [ + ("1e-6", "ยต"), + ("1e-3", "m"), + ("1", "1"), + ("1e3", "k"), + ("1e6", "M"), + ], + string="Factor", + default="1", + ) + divider_inherit = fields.Boolean(default=True) + hide_empty = fields.Boolean(default=False) + hide_empty_inherit = fields.Boolean(default=True) + hide_always = fields.Boolean(default=False) + hide_always_inherit = fields.Boolean(default=True) + + def _get_style(self, style=None): + """Get style values from the current style or the given style.""" + if not style: + style = {} + if not self: + return style + return { + "color": self.text_color + or (self.text_color_inherit and style.get("color")), + "background-color": self.background_color + or (self.background_color_inherit and style.get("background-color")), + "font-style": self.font_style + or (self.font_style_inherit and style.get("font-style")), + "font-weight": self.font_weight + or (self.font_weight_inherit and style.get("font-weight")), + "font-size": self.font_size + or (self.font_size_inherit and style.get("font-size")), + "padding-left": self.indent_level + or (self.indent_level_inherit and style.get("padding")) + or 1, + "prefix": self.prefix or (self.prefix_inherit and style.get("prefix")), + "suffix": self.suffix or (self.suffix_inherit and style.get("suffix")), + "rounding": self.rounding + or (self.rounding_inherit and style.get("rounding")), + "divider": self.divider or (self.divider_inherit and style.get("divider")), + "hide_empty": self.hide_empty + or (self.hide_empty_inherit and style.get("hide_empty")), + "hide_always": self.hide_always + or (self.hide_always_inherit and style.get("hide_always")), + } + + def _get_style_map(self): + return [ + ("background-color", lambda r: r), + ("color", lambda r: r), + ("font-style", lambda r: r), + ("font-weight", lambda r: r), + ("font-size", lambda r: r), + ("padding-left", lambda r: f"{r * 20}px"), + ("border-radius", lambda r: f"{r}px"), + ] + + def _get_style_css(self, style): + css_style = self._get_style(style) + computed_style = [] + for key, parse_style in self._get_style_map(): + if key in css_style: + if parse_style(css_style[key]): + computed_style.append(f"{key}: {parse_style(css_style.get(key))}") + return ";".join(computed_style), css_style diff --git a/report_builder/models/report_template.py b/report_builder/models/report_template.py new file mode 100644 index 0000000000..8b1e308988 --- /dev/null +++ b/report_builder/models/report_template.py @@ -0,0 +1,29 @@ +# Copyright 2025 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ReportTemplate(models.Model): + _name = "report.template" + _description = "Template" + + name = fields.Char(required=True) + source = fields.Selection( + selection=lambda self: self.env["report.template.kpi.query.kind"] + ._fields["source"] + .selection, + required=True, + default="account", + ) + kpi_ids = fields.One2many( + "report.template.kpi", + "template_id", + ) + search_view_id = fields.Many2one( + "ir.ui.view", + domain="[('type', '=', 'search'), ('model', '=', search_model)]", + ) + search_model_id = fields.Many2one("ir.model") + search_model = fields.Char(related="search_model_id.model") + style_id = fields.Many2one("report.style") diff --git a/report_builder/models/report_template_kpi.py b/report_builder/models/report_template_kpi.py new file mode 100644 index 0000000000..14582c4519 --- /dev/null +++ b/report_builder/models/report_template_kpi.py @@ -0,0 +1,90 @@ +# Copyright 2025 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class ReportTemplateKpi(models.Model): + _name = "report.template.kpi" + _description = "KPI" + _order = "template_id, sequence ASC, name" + + sequence = fields.Integer(default=10) + template_id = fields.Many2one("report.template", required=True, ondelete="cascade") + name = fields.Char(required=True) + code = fields.Char( + compute="_compute_code", + store=True, + readonly=False, + ) + invisible = fields.Boolean(default=False) + item_ids = fields.One2many( + "report.template.kpi.item", + "parent_kpi_id", + ) + mis_builder_formula = fields.Char( + compute="_compute_mis_builder_formula", + help="Formula for MIS Builder", + ) + style_id = fields.Many2one("report.style") + is_currency = fields.Boolean(default=True) + + @api.depends( + "item_ids", + "item_ids.code", + "item_ids.positive", + "item_ids.domain", + "item_ids.code", + "item_ids.kpi_id", + ) + def _compute_mis_builder_formula(self): + for record in self: + record.mis_builder_formula = record._get_mis_builder_formula() + + def _get_mis_builder_formula(self): + """ + Generate the MIS Builder formula based on the items. + This method should be overridden in subclasses if needed. + """ + formula_parts = [] + for item in self.item_ids: + if item.kind == "query": + part = f"{item.query_kind_id.code}[{item.code}]" + if item.domain: + part = f"{part}{item.domain}" + elif item.kind == "kpi": + part = f"{item.kpi_id.code}" + else: + continue + if not item.positive: + part = f"-{part}" + else: + part = f"+{part}" if formula_parts else part + formula_parts.append(part) + return "".join(formula_parts) + + @api.depends("name") + def _compute_code(self): + for record in self: + record.code = (record.name or " ").replace(" ", "_").lower() + + def _process_information(self, cols, kpi_data, **kwargs): + unprocessed_items = self.browse() + for kpi in self: + if not kpi._process_item(cols, kpi_data, **kwargs): + unprocessed_items |= kpi + if len(unprocessed_items) == len(self): + raise ValidationError(_("KPIs cannot be processed")) + if unprocessed_items: + unprocessed_items._process_information(cols, kpi_data, **kwargs) + + def _process_item(self, cols, kpi_data, **kwargs): + if any( + item.kpi_id.id not in kpi_data + for item in self.item_ids + if item.kind == "kpi" + ): + return False + kpi_data[self.id] = self.item_ids._get_kpi_data(cols, kpi_data, **kwargs) + return True diff --git a/report_builder/models/report_template_kpi_item.py b/report_builder/models/report_template_kpi_item.py new file mode 100644 index 0000000000..5f8ab8eb42 --- /dev/null +++ b/report_builder/models/report_template_kpi_item.py @@ -0,0 +1,88 @@ +# Copyright 2025 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from collections import defaultdict + +from odoo import api, fields, models + + +class ReportTemplateKpiItem(models.Model): + _name = "report.template.kpi.item" + _description = "Report Template Kpi Item" + _order = "sequence ASC, id ASC" + + sequence = fields.Integer(default=20) + kind = fields.Selection( + [("query", "Query"), ("kpi", "KPI")], + default="query", + ) + parent_kpi_id = fields.Many2one( + "report.template.kpi", + ondelete="cascade", + required=True, + ) + kpi_id = fields.Many2one( + "report.template.kpi", + ondelete="cascade", + domain="[('template_id', '=', template_id), ('id', '!=', parent_kpi_id)]", + ) + query_kind_id = fields.Many2one( + "report.template.kpi.query.kind", + domain="[('source', '=', source)]", + ) + code = fields.Char() + source = fields.Selection( + selection=lambda self: self.env["report.template.kpi.query.kind"] + ._fields["source"] + .selection, + compute="_compute_source", + readonly=False, + store=True, + ) + domain = fields.Char(help="Domain to filter the records for this KPI item. ") + template_id = fields.Many2one( + related="parent_kpi_id.template_id", + ) + positive = fields.Boolean( + help="If checked, the KPI item will be considered positive. " + "If not checked, it will be considered negative.", + default=True, + ) + + @api.depends("parent_kpi_id") + def _compute_source(self): + for record in self: + record.source = record.parent_kpi_id.template_id.source + + def _get_kpi_data(self, cols, kpi_data, **kwargs): + """ + Process the KPI item and return the data for the KPI. + This method should be overridden in subclasses if needed. + """ + value = defaultdict(lambda: {"total": 0, "values": defaultdict(lambda: 0)}) + for item in self: + item._get_kpi_value(cols, kpi_data, value, **kwargs) + return value + + def _get_kpi_value(self, cols, kpi_data, value, **kwargs): + """ + Get the value for the KPI item. + This method should be overridden in subclasses if needed. + """ + for col in cols: + multiplier = 1 if self.positive else -1 + values = {} + if self.kind == "query": + kpi_value, values = getattr( + self, f"_get_kpi_value_{self.source}_{self.query_kind_id.code}" + )(col, kpi_data, **kwargs) + elif self.kind == "kpi": + kpi_value = kpi_data.get(self.kpi_id.id, {}).get(col["id"], 0)["total"] + values = ( + kpi_data.get(self.kpi_id.id, {}) + .get(col["id"], {}) + .get("values", {}) + ) + for val in values: + value[col["id"]]["values"][val] += multiplier * values[val] + value[col["id"]]["total"] += multiplier * kpi_value diff --git a/report_builder/models/report_template_kpi_query_kind.py b/report_builder/models/report_template_kpi_query_kind.py new file mode 100644 index 0000000000..7a155f5baf --- /dev/null +++ b/report_builder/models/report_template_kpi_query_kind.py @@ -0,0 +1,20 @@ +# Copyright 2025 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ReportTemplateKpiQueryKind(models.Model): + """ + This model should be filled only with master data, not by the user itself + """ + + _name = "report.template.kpi.query.kind" + _description = "KPI Query Kind" + + name = fields.Char(required=True) + code = fields.Char(required=True) + source = fields.Selection( + selection=[], + required=True, + ) diff --git a/report_builder/pyproject.toml b/report_builder/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/report_builder/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/report_builder/readme/CONTEXT.md b/report_builder/readme/CONTEXT.md new file mode 100644 index 0000000000..d8003cae73 --- /dev/null +++ b/report_builder/readme/CONTEXT.md @@ -0,0 +1,4 @@ +This module is inspired by mis-builder and shares its goal of grid-based KPI reporting. +However, it takes a different architectural approach: rather than being tightly coupled to accounting, it is designed to be pluggable on any Odoo model through small glue modules. + +A migration path from mis-builder is planned but not yet available. Both modules can coexist in the meantime. diff --git a/report_builder/readme/CONTRIBUTORS.md b/report_builder/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..2c066ba7f4 --- /dev/null +++ b/report_builder/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Dixmit](https://www.dixmit.com) + - Enric Tobella diff --git a/report_builder/readme/DESCRIPTION.md b/report_builder/readme/DESCRIPTION.md new file mode 100644 index 0000000000..6c165ad869 --- /dev/null +++ b/report_builder/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +This modules is a grid-based KPI reporting tool that works across any Odoo model โ€” not just accounting. +Rows are KPIs defined by readable formulas referencing pre-definied KPI computations. diff --git a/report_builder/security/ir.model.access.csv b/report_builder/security/ir.model.access.csv new file mode 100644 index 0000000000..b194981287 --- /dev/null +++ b/report_builder/security/ir.model.access.csv @@ -0,0 +1,14 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_report_instance,access_report_instance,model_report_instance,base.group_user,1,0,0,0 +manage_report_instance,manage_report_instance,model_report_instance,base.group_system,1,1,1,1 +access_report_instance_column,access_report_instance_columns,model_report_instance_column,base.group_user,1,0,0,0 +manage_report_instance_column,manage_report_instance_columns,model_report_instance_column,base.group_system,1,1,1,1 +access_report_template,access_report_template,model_report_template,base.group_user,1,0,0,0 +manage_report_template,manage_report_template,model_report_template,base.group_system,1,1,1,1 +access_report_template_kpi,access_report_template_kpi,model_report_template_kpi,base.group_user,1,0,0,0 +manage_report_template_kpi,manage_report_template_kpi,model_report_template_kpi,base.group_system,1,1,1,1 +access_report_template_kpi_item,access_report_template_kpi_item,model_report_template_kpi_item,base.group_user,1,0,0,0 +manage_report_template_kpi_item,manage_report_template_kpi_item,model_report_template_kpi_item,base.group_system,1,1,1,1 +access_report_style,access_report_style,model_report_style,base.group_user,1,0,0,0 +manage_report_style,manage_report_style,model_report_style,base.group_system,1,1,1,1 +access_report_template_kpi_query_kind,access_report_template_kpi_query_kind,model_report_template_kpi_query_kind,base.group_system,1,0,0,0 diff --git a/report_builder/static/description/icon.png b/report_builder/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/report_builder/static/description/icon.png differ diff --git a/report_builder/static/description/index.html b/report_builder/static/description/index.html new file mode 100644 index 0000000000..b42e9d8945 --- /dev/null +++ b/report_builder/static/description/index.html @@ -0,0 +1,440 @@ + + + + + +Report Builder + + + +
+

Report Builder

+ + +

Beta License: AGPL-3 OCA/reporting-engine Translate me on Weblate Try me on Runboat

+

This modules is a grid-based KPI reporting tool that works across any +Odoo model โ€” not just accounting. Rows are KPIs defined by readable +formulas referencing pre-definied KPI computations.

+

Table of contents

+ +
+

Use Cases / Context

+

This module is inspired by mis-builder and shares its goal of grid-based +KPI reporting. However, it takes a different architectural approach: +rather than being tightly coupled to accounting, it is designed to be +pluggable on any Odoo model through small glue modules.

+

A migration path from mis-builder is planned but not yet available. Both +modules can coexist in the meantime.

+
+
+

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

+
    +
  • Dixmit
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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.

+

Current maintainer:

+

etobella

+

This module is part of the OCA/reporting-engine project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/report_builder/static/src/components/report_builder/report_builder.esm.js b/report_builder/static/src/components/report_builder/report_builder.esm.js new file mode 100644 index 0000000000..f9c73ddc6b --- /dev/null +++ b/report_builder/static/src/components/report_builder/report_builder.esm.js @@ -0,0 +1,137 @@ +import {Component, onWillStart, onWillUnmount, useState, useSubEnv} from "@odoo/owl"; +import {parseDate, serializeDate} from "@web/core/l10n/dates"; +import {useBus, useService} from "@web/core/utils/hooks"; +import {DateTimeInput} from "@web/core/datetime/datetime_input"; +import {SearchBar} from "@web/search/search_bar/search_bar"; +import {SearchModel} from "@web/search/search_model"; +import {formatMonetary} from "@web/views/fields/formatters"; +import {registry} from "@web/core/registry"; + +export class ReportBuilderValue extends Component { + get formattedValue() { + const {value, currency_id} = this.props; + if (value && value.total) { + return formatMonetary(value.total, {currencyId: currency_id[0]}); + } + return ""; + } +} +ReportBuilderValue.template = "report_builder.ReportBuilderValue"; +ReportBuilderValue.props = { + value: {type: Object}, + currency_id: {type: Object, optional: true}, +}; + +export class ReportBuilder extends Component { + setup() { + super.setup(); + this.state = useState({date: false, data: {}}); + this.orm = useService("orm"); + this.view = useService("view"); + this.dialog = useService("dialog"); + this.bus_service = useService("bus_service"); + this.action_service = useService("action"); + useSubEnv({ + searchModel: new SearchModel(this.env, { + orm: this.orm, + view: this.view, + dialog: this.dialog, + }), + }); + useBus(this.env.searchModel, "update", async () => { + await this.env.searchModel.sectionsPromise; + this.updateData(); + }); + onWillStart(this.onWillStart); + onWillUnmount(() => { + this.bus_service.deleteChannel("report_builder"); + }); + } + async onWillStart() { + this.state.pivot_date = parseDate(this.props.record.data.data.date); + if (this.showSearchBar) { + await this.env.searchModel.load({ + resModel: this.props.record.data.search_res_model, + searchViewId: this.props.record.data.search_view_id[0], + }); + } + this.updateData(); + } + get showSearchBar() { + return ( + this.props.record.data.show_search_bar && + this.props.record.data.search_res_model && + this.props.record.data.search_view_id + ); + } + async updateData() { + const domain = this.showSearchBar ? this.env.searchModel.domain : []; + const data = await this.orm.call( + this.props.record.model.config.resModel, + "process_information", + [this.props.record.resIds, serializeDate(this.state.pivot_date), domain] + ); + this.state.data = data; + } + onPivotDateChanged(pivot_date) { + this.state.pivot_date = pivot_date; + this.updateData(); + } + refresh() { + this.updateData(); + } + async printPdf() { + this.action_service.doAction( + await this.orm.call( + this.props.record.model.config.resModel, + "get_pdf_report_action", + [ + this.props.record.resIds[0], + serializeDate(this.state.pivot_date), + this.env.searchModel.domain, + ] + ) + ); + } + async printXlsx() { + this.action_service.doAction( + await this.orm.call( + this.props.record.model.config.resModel, + "get_xlsx_report_action", + [ + this.props.record.resIds[0], + serializeDate(this.state.pivot_date), + this.env.searchModel.domain, + ] + ) + ); + } + async displaySettings() { + this.action_service.doAction( + await this.orm.call( + this.props.record.model.config.resModel, + "get_display_settings_action", + [this.props.record.resIds[0]] + ) + ); + } +} + +ReportBuilder.components = {SearchBar, DateTimeInput, ReportBuilderValue}; +ReportBuilder.template = "report_builder.ReportBuilder"; + +export const reportBuilder = { + component: ReportBuilder, + fieldDependencies: [ + {name: "name", type: "char"}, + {name: "data", type: "json"}, + {name: "currency_id", type: "many2one", relation: "res.currency"}, + {name: "show_search_bar", type: "boolean"}, + {name: "search_view_id", type: "many2one"}, + {name: "search_res_model", type: "char"}, + {name: "show_settings", type: "boolean"}, + {name: "show_pivot_date", type: "boolean"}, + ], +}; + +registry.category("fields").add("report_builder", reportBuilder); diff --git a/report_builder/static/src/components/report_builder/report_builder.scss b/report_builder/static/src/components/report_builder/report_builder.scss new file mode 100644 index 0000000000..b394e7214e --- /dev/null +++ b/report_builder/static/src/components/report_builder/report_builder.scss @@ -0,0 +1,28 @@ +.o_web_client { + .o_field_report_builder { + width: 100%; + + .o_report_builder { + width: 100%; + .o_report_builder_table { + a { + // we don't want the link color, to respect user styles + color: inherit; + &:hover { + // underline links on hover to give a visual cue + text-decoration: underline; + } + } + .report_builder_amount { + text-align: right; + } + .report_builder_collabel { + text-align: center; + } + .report_builder_rowlabel { + text-align: left; + } + } + } + } +} diff --git a/report_builder/static/src/components/report_builder/report_builder.xml b/report_builder/static/src/components/report_builder/report_builder.xml new file mode 100644 index 0000000000..944aa7ea5e --- /dev/null +++ b/report_builder/static/src/components/report_builder/report_builder.xml @@ -0,0 +1,82 @@ + + + + + + +
+
+
+ +
+
+
+ +
+
+ + + + +
+
+
+
+ + + + + + + + + + + + +
+ + +
+ + + +
+
+
+
+
diff --git a/report_builder/static/tests/views/report_builder.test.js b/report_builder/static/tests/views/report_builder.test.js new file mode 100644 index 0000000000..8d10815a4a --- /dev/null +++ b/report_builder/static/tests/views/report_builder.test.js @@ -0,0 +1,81 @@ +import {animationFrame, expect, test} from "@odoo/hoot"; +import {defineModels, fields, models, mountView} from "@web/../tests/web_test_helpers"; +import {defineMailModels} from "@mail/../tests/mail_test_helpers"; + +class ReportInstance extends models.Model { + _name = "report.instance"; + + name = fields.Char(); + data = fields.Json(); + currency_id = fields.Many2one({relation: "res.currency"}); + show_search_bar = fields.Boolean(); + search_view_id = fields.Many2one({relation: "ir.ui.view"}); + search_res_model = fields.Char(); + show_settings = fields.Boolean(); + show_pivot_date = fields.Boolean(); + + process_information() { + return { + 2: { + 4: {total: 1}, + 5: {total: 2}, + }, + 3: { + 4: {total: 3}, + 5: {total: 4}, + }, + }; + } + + _views = { + form: ` +
+ + + +
+ `, + }; + _records = [ + { + id: 1, + name: "Report 1", + currency_id: 1, + data: { + date: "2024-01-01", + rows: [ + { + id: 2, + name: "Row 1", + }, + { + id: 3, + name: "Row 2", + }, + ], + columns: [ + {id: 4, name: "Column 1"}, + {id: 5, name: "Column 2"}, + ], + }, + }, + ]; +} +defineModels([ReportInstance]); +// As we use mail as a dependancy, we need to declare models. +defineMailModels(); + +test("Check selected item", async () => { + await mountView({ + type: "form", + resId: 1, + resIds: [1], + resModel: "report.instance", + }); + await animationFrame(); + expect(".o_report_builder_value").toHaveCount(4); + expect('.o_report_builder_value:contains("$ 1.00")').toHaveCount(1); + expect('.o_report_builder_value:contains("$ 2.00")').toHaveCount(1); + expect('.o_report_builder_value:contains("$ 3.00")').toHaveCount(1); + expect('.o_report_builder_value:contains("$ 4.00")').toHaveCount(1); +}); diff --git a/report_builder/tests/__init__.py b/report_builder/tests/__init__.py new file mode 100644 index 0000000000..e5eae51cb0 --- /dev/null +++ b/report_builder/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_report_builder +from . import test_js diff --git a/report_builder/tests/fake_models.py b/report_builder/tests/fake_models.py new file mode 100644 index 0000000000..df50853c8a --- /dev/null +++ b/report_builder/tests/fake_models.py @@ -0,0 +1,47 @@ +# Copyright 2025 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo import fields, models +from odoo.osv.expression import OR +from odoo.tools.safe_eval import safe_eval + + +class ReportingDummyModel(models.Model): + _name = "reporting.dummy.model" + _description = "Reporting Dummy Model" + + name = fields.Char() + date = fields.Date() + value = fields.Float() + + +class ReportTemplateKpiQueryKind(models.Model): + _inherit = "report.template.kpi.query.kind" + + source = fields.Selection( + selection_add=[("dummy", "Dummy")], + ondelete={"dummy": "cascade"}, + ) + + +class ReportTemplateKpiItem(models.Model): + _inherit = "report.template.kpi.item" + + def _get_kpi_value_dummy_dummy(self, col, kpi_data, **kwargs): + domain = [ + ("date", ">=", col["date_from"]), + ("date", "<=", col["date_to"]), + ] + if self.domain: + domain += safe_eval(self.domain) + if domain: + domain += domain + if self.code: + domain += OR( + [[("name", "=ilike", code.strip())] for code in self.code.split(",")] + ) + values = self.env["reporting.dummy.model"]._read_group( + domain, groupby=("name",), aggregates=("value:sum",) + ) + return sum(val[1] for val in values), {val[0]: val[1] for val in values} diff --git a/report_builder/tests/test_js.py b/report_builder/tests/test_js.py new file mode 100644 index 0000000000..0bad406cd4 --- /dev/null +++ b/report_builder/tests/test_js.py @@ -0,0 +1,21 @@ +# Copyright 2025 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import odoo +from odoo.tools import mute_logger + +from odoo.addons.web.tests.test_js import WebSuite + + +@odoo.tests.tagged("post_install", "-at_install") +class TestReportBuilderJS(WebSuite): + """Test Report Builder JS code""" + + def get_hoot_filters(self): + self._test_params = [("+", "@report_builder")] + return super().get_hoot_filters() + + @mute_logger( + "odoo.addons.report_builder.tests.test_js.TestReportBuilderJS.test_report_builder" + ) + def test_report_builder(self): + self.test_unit_desktop() diff --git a/report_builder/tests/test_report_builder.py b/report_builder/tests/test_report_builder.py new file mode 100644 index 0000000000..7f4e7e864e --- /dev/null +++ b/report_builder/tests/test_report_builder.py @@ -0,0 +1,419 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import date + +from freezegun import freeze_time +from odoo_test_helper import FakeModelLoader + +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + + +class TestReportBuilder(TransactionCase): + def setUp(self): + super().setUp() + self.loader = FakeModelLoader(self.env, self.__module__) + self.loader.backup_registry() + from .fake_models import ( + ReportingDummyModel, + ReportTemplateKpiItem, + ReportTemplateKpiQueryKind, + ) + + self.loader.update_registry( + ( + ReportingDummyModel, + ReportTemplateKpiQueryKind, + ReportTemplateKpiItem, + ) + ) + self.addCleanup(self.loader.restore_registry) + self.style = self.env["report.style"].create( + {"name": "Style", "font_size": "medium", "font_size_inherit": False} + ) + self.style_01 = self.env["report.style"].create( + { + "name": "Style 01", + } + ) + self.style_02 = self.env["report.style"].create( + {"name": "Style 02", "font_size": "small", "font_size_inherit": False} + ) + for i in range(31): + self.env["reporting.dummy.model"].create( + {"name": f"{i}", "value": i, "date": f"2023-01-{(i%31)+1}"} + ) + self.report = self.env["report.template"].create( + { + "name": "Test Report", + "source": "dummy", + "style_id": self.style.id, + } + ) + self.report_kpi = {} + self.query_kind = self.env["report.template.kpi.query.kind"].create( + { + "name": "Dummy Query", + "code": "dummy", + "source": "dummy", + } + ) + for i in range(1, 10): + kpi = self.env["report.template.kpi"].create( + { + "name": f"KPI {i}", + "template_id": self.report.id, + "item_ids": [ + ( + 0, + 0, + { + "kind": "query", + "source": "dummy", + "query_kind_id": self.query_kind.id, + "code": f"{i}%", + }, + ), + ], + "style_id": self.style_01.id if i % 2 == 0 else self.style_02.id, + } + ) + self.report_kpi[i] = kpi + self.kpi_related = self.env["report.template.kpi"].create( + { + "name": "KPI 10", + "template_id": self.report.id, + "item_ids": [ + ( + 0, + 0, + { + "kind": "kpi", + "kpi_id": self.report_kpi[1].id, + "positive": False, + }, + ) + ], + } + ) + + def test_report(self): + report = self.env["report.instance"].create( + { + "name": "Test Report Instance", + "template_id": self.report.id, + "column_ids": [ + ( + 0, + 0, + { + "name": "Column 1", + "date_from": "2023-01-01", + "date_to": "2023-01-10", + "mode": "date", + }, + ), + ( + 0, + 0, + { + "name": "Column 2", + "date_from": "2023-01-11", + "date_to": "2023-01-31", + "mode": "date", + }, + ), + ], + } + ) + column_1 = report.column_ids[0] + column_2 = report.column_ids[1] + data = report.process_information("2023-01-31") + self.assertEqual( + data[self.report_kpi[1].id][column_1.id]["total"], + 1, + ) + self.assertEqual( + data[self.report_kpi[1].id][column_2.id]["total"], + 145, + ) + self.assertEqual( + data[self.report_kpi[2].id][column_1.id]["total"], + 2, + ) + self.assertEqual( + data[self.report_kpi[2].id][column_2.id]["total"], + 245, + ) + self.assertEqual( + data[self.report_kpi[3].id][column_1.id]["total"], + 3, + ) + self.assertEqual( + data[self.report_kpi[3].id][column_2.id]["total"], + 30, + ) + self.assertEqual( + data[self.report_kpi[4].id][column_1.id]["total"], + 4, + ) + self.assertEqual( + data[self.report_kpi[4].id][column_2.id]["total"], + 0, + ) + self.assertEqual( + data[self.report_kpi[5].id][column_1.id]["total"], + 5, + ) + self.assertEqual( + data[self.report_kpi[5].id][column_2.id]["total"], + 0, + ) + self.assertEqual( + data[self.report_kpi[6].id][column_1.id]["total"], + 6, + ) + self.assertEqual( + data[self.report_kpi[6].id][column_2.id]["total"], + 0, + ) + self.assertEqual( + data[self.report_kpi[7].id][column_1.id]["total"], + 7, + ) + self.assertEqual( + data[self.report_kpi[7].id][column_2.id]["total"], + 0, + ) + self.assertEqual( + data[self.report_kpi[8].id][column_1.id]["total"], + 8, + ) + self.assertEqual( + data[self.report_kpi[8].id][column_2.id]["total"], + 0, + ) + self.assertEqual( + data[self.report_kpi[9].id][column_1.id]["total"], + 9, + ) + self.assertEqual( + data[self.report_kpi[9].id][column_2.id]["total"], + 0, + ) + self.assertEqual( + data[self.kpi_related.id][column_1.id]["total"], + -1, + ) + self.assertEqual( + data[self.kpi_related.id][column_2.id]["total"], + -145, + ) + + def test_style(self): + report = self.env["report.instance"].create( + { + "name": "Test Report Instance", + "template_id": self.report.id, + } + ) + style_data = report.data + for i in range(0, 9): + if i % 2 == 0: + self.assertEqual( + style_data["rows"][i]["parameters"]["font-size"], "small" + ) + self.assertRegex(style_data["rows"][i]["style"], r"font-size: small") + else: + self.assertEqual( + style_data["rows"][i]["parameters"]["font-size"], "medium" + ) + self.assertRegex(style_data["rows"][i]["style"], r"font-size: medium") + + def test_report_kpi(self): + report = self.env["report.template"].create( + { + "name": "Test Report", + "source": "dummy", + } + ) + report_kpi = self.env["report.template.kpi"].create( + { + "name": "KPI", + "template_id": report.id, + "item_ids": [ + ( + 0, + 0, + { + "kind": "query", + "source": "dummy", + "query_kind_id": self.query_kind.id, + "code": "%", + }, + ), + ], + } + ) + report_kpi_2 = self.env["report.template.kpi"].create( + { + "name": "KPI 2", + "template_id": report.id, + "item_ids": [ + ( + 0, + 0, + { + "kind": "kpi", + "kpi_id": report_kpi.id, + }, + ), + ], + } + ) + instance = self.env["report.instance"].create( + { + "name": "Test Report Instance", + "template_id": report.id, + "column_ids": [ + ( + 0, + 0, + { + "name": "Column 1", + "date_from": "2023-01-01", + "date_to": "2023-01-31", + "mode": "date", + }, + ), + ], + } + ) + data = instance.process_information("2023-01-31") + self.assertEqual(465, data[report_kpi.id][instance.column_ids[0].id]["total"]) + self.assertEqual(465, data[report_kpi_2.id][instance.column_ids[0].id]["total"]) + + def test_report_error(self): + report = self.env["report.template"].create( + { + "name": "Test Report", + "source": "dummy", + } + ) + report_kpi = self.env["report.template.kpi"].create( + { + "name": "KPI", + "template_id": report.id, + "item_ids": [ + ( + 0, + 0, + { + "kind": "query", + "source": "dummy", + "query_kind_id": self.query_kind.id, + "code": "%", + }, + ), + ], + } + ) + report_kpi_2 = self.env["report.template.kpi"].create( + { + "name": "KPI 2", + "template_id": report.id, + "item_ids": [ + ( + 0, + 0, + { + "kind": "kpi", + "kpi_id": report_kpi.id, + }, + ), + ], + } + ) + report_kpi.item_ids.write( + { + "kind": "kpi", + "kpi_id": report_kpi_2.id, + } + ) + + instance = self.env["report.instance"].create( + { + "name": "Test Report Instance", + "template_id": report.id, + } + ) + with self.assertRaises(ValidationError): + instance.process_information("2023-01-31") + + def test_mis_formula(self): + self.assertEqual(self.report_kpi[1].code, "kpi_1") + self.assertEqual(self.report_kpi[1].mis_builder_formula, "dummy[1%]") + self.report_kpi[1].item_ids.domain = "[('field', '=', 'value')]" + self.assertEqual( + self.report_kpi[1].mis_builder_formula, "dummy[1%][('field', '=', 'value')]" + ) + self.assertEqual(self.kpi_related.mis_builder_formula, "-kpi_1") + + def test_date(self): + report = self.env["report.instance"].create( + { + "name": "Test Report Instance", + "template_id": self.report.id, + "column_ids": [ + ( + 0, + 0, + { + "name": "Column 1", + "date_from": "2023-01-01", + "date_to": "2023-01-10", + "mode": "date", + }, + ), + ], + } + ) + column = report.column_ids + self.assertEqual(column.compute_date_from, date(2023, 1, 1)) + self.assertEqual(column.compute_date_to, date(2023, 1, 10)) + column.write( + { + "mode": "relative", + "date_type": "months", + "duration_type": "months", + "duration": 1, + "offset": 0, + "is_ytd": False, + } + ) + with freeze_time("2023-03-01"): + self.assertEqual(column.compute_date_from, date(2023, 3, 1)) + self.assertEqual(column.compute_date_to, date(2023, 4, 1)) + column.is_ytd = True + with freeze_time("2023-03-01"): + self.assertEqual(column.compute_date_from, date(2023, 1, 1)) + self.assertEqual(column.compute_date_to, date(2023, 4, 1)) + + def test_actions(self): + report = self.env["report.instance"].create( + { + "name": "Test Report Instance", + "template_id": self.report.id, + } + ) + view_action = report.view_report_instance() + self.assertEqual(view_action["res_id"], report.id) + self.assertEqual(view_action["view_mode"], "form") + self.assertTrue(any(view[1] == "form" for view in view_action["views"])) + display_settings_action = report.get_display_settings_action() + self.assertEqual(display_settings_action["res_id"], report.id) + self.assertEqual(display_settings_action["view_mode"], "form") + self.assertTrue( + any(view[1] == "form" for view in display_settings_action["views"]) + ) diff --git a/report_builder/views/menu.xml b/report_builder/views/menu.xml new file mode 100644 index 0000000000..33937ec411 --- /dev/null +++ b/report_builder/views/menu.xml @@ -0,0 +1,9 @@ + + + + diff --git a/report_builder/views/report_instance.xml b/report_builder/views/report_instance.xml new file mode 100644 index 0000000000..74f4254160 --- /dev/null +++ b/report_builder/views/report_instance.xml @@ -0,0 +1,113 @@ + + + + + report.instance + + +
+
+ + + + + + + + report.instance + + +
+
+ + +
+
+
+
+ + + + + + + + + + + + + + + + + + +
+ + + + + + report.instance + + + + +