From 60b69e56e9dffafa0a1d0c6abe4b791b9c689c01 Mon Sep 17 00:00:00 2001 From: GuillemCForgeFlow Date: Fri, 13 Oct 2023 15:15:50 +0200 Subject: [PATCH 1/2] [IMP]report_xlsx: improvements and adapt to add `report_xlsx_boilerplate` module - Add the xlsxwriter python external dependency in the manifest as it should have already been included in the past - Decouple workbook obtation - Add test file in order to add a test in the `report_xlsx_boilerplate` method. Only used if the latest module is installed. --- report_xlsx/report/report_abstract_xlsx.py | 20 ++++++++++++++---- ...t_partner_report_boilerplate_template.xlsx | Bin 0 -> 6317 bytes requirements.txt | 2 ++ 3 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 report_xlsx/tests/sample_files/test_partner_report_boilerplate_template.xlsx diff --git a/report_xlsx/report/report_abstract_xlsx.py b/report_xlsx/report/report_abstract_xlsx.py index 1abbdf727..320a4df4f 100644 --- a/report_xlsx/report/report_abstract_xlsx.py +++ b/report_xlsx/report/report_abstract_xlsx.py @@ -5,7 +5,7 @@ import logging import re from io import BytesIO -from odoo import models +from odoo import api, models _logger = logging.getLogger(__name__) @@ -71,9 +71,9 @@ class ReportXlsxAbstract(models.AbstractModel): def _get_objs_for_report(self, docids, data): """ - Returns objects for xlx report. From WebUI these + Returns objects for xlsx report. From WebUI these are either as docids taken from context.active_ids or - in the case of wizard are in data. Manual calls may rely + in the case of wizard are in data. Manual calls may rely on regular context, setting docids, or setting data. :param docids: list of integers, typically provided by @@ -101,7 +101,7 @@ class ReportXlsxAbstract(models.AbstractModel): def create_xlsx_report(self, docids, data): objs = self._get_objs_for_report(docids, data) file_data = BytesIO() - workbook = xlsxwriter.Workbook(file_data, self.get_workbook_options()) + workbook = self.get_workbook(file_data) self.generate_xlsx_report(workbook, data, objs) workbook.close() file_data.seek(0) @@ -116,3 +116,15 @@ class ReportXlsxAbstract(models.AbstractModel): def generate_xlsx_report(self, workbook, data, objs): raise NotImplementedError() + + @api.model + def _get_new_workbook(self, file_data): + """ + :return: empty Workbook + :rtype: xlsxwriter.Workbook object + """ + return xlsxwriter.Workbook(file_data, self.get_workbook_options()) + + @api.model + def get_workbook(self, file_data): + return self._get_new_workbook(file_data) diff --git a/report_xlsx/tests/sample_files/test_partner_report_boilerplate_template.xlsx b/report_xlsx/tests/sample_files/test_partner_report_boilerplate_template.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..42ace53952b6ab9cdcfdd13f43610d1a82e64a24 GIT binary patch literal 6317 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 literal 0 HcmV?d00001 diff --git a/requirements.txt b/requirements.txt index 603fb74cb..a6f2f37be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,7 @@ # generated from manifests external_dependencies cryptography endesive +openpyxl py3o.formats py3o.template +xlsxwriter From c0b1078028bccd5beb88881e0452f2a5398961d3 Mon Sep 17 00:00:00 2001 From: GuillemCForgeFlow Date: Fri, 13 Oct 2023 15:18:14 +0200 Subject: [PATCH 2/2] [ADD]report_xlsx_boilerplate New addon to have the possibility to add Excel boilerplate templates. This can be useful for those reports where we want to always have a predetermined structure in which we then want to add more information. Example: we always now that the Column Headers will be: Header 1, Header 2, ... We can add the Boilerplate with the Headers, and from there, in the `generate_xlsx_report` method, we will already have the predefined Excel so that we can edit on top of it. --- report_xlsx_boilerplate/__init__.py | 1 + report_xlsx_boilerplate/__manifest__.py | 17 ++ .../readme/CONTRIBUTORS.rst | 1 + .../readme/DESCRIPTION.rst | 4 + report_xlsx_boilerplate/readme/INSTALL.rst | 1 + report_xlsx_boilerplate/readme/USAGE.rst | 15 ++ report_xlsx_boilerplate/report/__init__.py | 1 + .../report/report_abstract_xlsx.py | 153 ++++++++++++++++++ .../report/utils/xlsx_utils.py | 19 +++ report_xlsx_boilerplate/tests/__init__.py | 1 + report_xlsx_boilerplate/tests/test_report.py | 102 ++++++++++++ .../odoo/addons/report_xlsx_boilerplate | 1 + setup/report_xlsx_boilerplate/setup.py | 6 + 13 files changed, 322 insertions(+) create mode 100644 report_xlsx_boilerplate/__init__.py create mode 100644 report_xlsx_boilerplate/__manifest__.py create mode 100644 report_xlsx_boilerplate/readme/CONTRIBUTORS.rst create mode 100644 report_xlsx_boilerplate/readme/DESCRIPTION.rst create mode 100644 report_xlsx_boilerplate/readme/INSTALL.rst create mode 100644 report_xlsx_boilerplate/readme/USAGE.rst create mode 100644 report_xlsx_boilerplate/report/__init__.py create mode 100644 report_xlsx_boilerplate/report/report_abstract_xlsx.py create mode 100644 report_xlsx_boilerplate/report/utils/xlsx_utils.py create mode 100644 report_xlsx_boilerplate/tests/__init__.py create mode 100644 report_xlsx_boilerplate/tests/test_report.py create mode 120000 setup/report_xlsx_boilerplate/odoo/addons/report_xlsx_boilerplate create mode 100644 setup/report_xlsx_boilerplate/setup.py diff --git a/report_xlsx_boilerplate/__init__.py b/report_xlsx_boilerplate/__init__.py new file mode 100644 index 000000000..4c4f242fa --- /dev/null +++ b/report_xlsx_boilerplate/__init__.py @@ -0,0 +1 @@ +from . import report diff --git a/report_xlsx_boilerplate/__manifest__.py b/report_xlsx_boilerplate/__manifest__.py new file mode 100644 index 000000000..d2183729f --- /dev/null +++ b/report_xlsx_boilerplate/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2023 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +{ + "name": "Base Report XLSX Boilerplate", + "version": "13.0.1.0.0", + "summary": """ + Module extending Base Report XLSX to add Boilerplate on XLSX reports. + """, + "category": "Reporting", + "license": "AGPL-3", + "website": "https://github.com/OCA/reporting-engine", + "author": "ForgeFlow, Odoo Community Association (OCA)", + "depends": ["report_xlsx"], + "external_dependencies": {"python": ["xlsxwriter", "openpyxl"]}, + "installable": True, + "application": False, +} diff --git a/report_xlsx_boilerplate/readme/CONTRIBUTORS.rst b/report_xlsx_boilerplate/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..163379ac6 --- /dev/null +++ b/report_xlsx_boilerplate/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Guillem Casassas diff --git a/report_xlsx_boilerplate/readme/DESCRIPTION.rst b/report_xlsx_boilerplate/readme/DESCRIPTION.rst new file mode 100644 index 000000000..89e4c8adc --- /dev/null +++ b/report_xlsx_boilerplate/readme/DESCRIPTION.rst @@ -0,0 +1,4 @@ +This addon provides the possibility to add a Boilerplate Template from which then you +can add changes on top of that. It can be useful for those reports that require to +always have a pre-defined structure and then some extra information starting from the +template. diff --git a/report_xlsx_boilerplate/readme/INSTALL.rst b/report_xlsx_boilerplate/readme/INSTALL.rst new file mode 100644 index 000000000..b3d94884c --- /dev/null +++ b/report_xlsx_boilerplate/readme/INSTALL.rst @@ -0,0 +1 @@ +This module requires to have the ``openpyxl`` Python module installed. diff --git a/report_xlsx_boilerplate/readme/USAGE.rst b/report_xlsx_boilerplate/readme/USAGE.rst new file mode 100644 index 000000000..2b781e817 --- /dev/null +++ b/report_xlsx_boilerplate/readme/USAGE.rst @@ -0,0 +1,15 @@ +An example of XLSX report which has a Boilerplate within the module called `module_name`: + +A python class :: + + from odoo import models + + class ReportBoilerplateXlsx(models.AbstractModel): + _name = "report.module_name.report_name" + _description = "Report Boilerplate" + _inherit = "report.report_xlsx.abstract" + + _boilerplate_template_file_path = "report/boilerplate_templates/report.xlsx" + +**IMPORTANT** +The XLSX Boilerplate file needs to be located inside a folder within the module directory. diff --git a/report_xlsx_boilerplate/report/__init__.py b/report_xlsx_boilerplate/report/__init__.py new file mode 100644 index 000000000..11bf01c92 --- /dev/null +++ b/report_xlsx_boilerplate/report/__init__.py @@ -0,0 +1 @@ +from . import report_abstract_xlsx diff --git a/report_xlsx_boilerplate/report/report_abstract_xlsx.py b/report_xlsx_boilerplate/report/report_abstract_xlsx.py new file mode 100644 index 000000000..3e38e79b2 --- /dev/null +++ b/report_xlsx_boilerplate/report/report_abstract_xlsx.py @@ -0,0 +1,153 @@ +# Copyright 2023 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import logging + +from odoo import _, api, models +from odoo.exceptions import ValidationError +from odoo.modules.module import get_module_resource + +from .utils.xlsx_utils import HORIZ_ALIG_ACPTD_VALS, VERT_ALIG_ACPTD_VALS + +_logger = logging.getLogger(__name__) + +try: + import openpyxl +except ImportError: + _logger.debug("Can't import openpyxl.") + + +class ReportXlsxAbstract(models.AbstractModel): + _inherit = "report.report_xlsx.abstract" + + # Path relative to the module location + _boilerplate_template_file_path = None + + @api.model + def _get_module_name_from_model_name(self): + """ + The _name of the model will always have the following structure: + `report.{module_name}.{report_name}` + """ + return self._name.split(".")[1] + + @api.model + def get_workbook(self, file_data): + if self._boilerplate_template_file_path is not None: + return self._get_boilerplate_template(file_data) + return super().get_workbook(file_data) + + def _get_boilerplate_template(self, file_data): + """ + :return: copy of the Boilerplate Template of the report if everything is + correctly set up, blank workbook otherwise + :rtype: xlsxwriter.Workbook object + """ + module = self._get_module_name_from_model_name() + module_path, file_name = self._boilerplate_template_file_path.rsplit("/", 1) + file_path = get_module_resource(module, module_path, file_name) + if not file_path: + raise ValidationError( + _( + "Boilerplate Template file path not properly defined: %s." + % self._boilerplate_template_file_path + ) + ) + try: + boilerplate_template = openpyxl.load_workbook(file_path) + return self._copy_xlsx(file_data, boilerplate_template) + except Exception as e: + _logger.exception(e) + return self._get_new_workbook(file_data) + + # flake8: noqa: C901 + @api.model + def _copy_cell_format(self, xlsx, cell): + """ + :return: a format object that needs to be applied coming from the + openyxl.cell.cell.Cell object + :rtype: xlsxwriter.format.Format + """ + cell_format = xlsx.add_format() + values_dict = dict() + bs_field = "border_style" + if hasattr(cell.fill.bgColor, "rgb") and cell.fill.bgColor.value != "00000000": + bg_color = "#" + cell.fill.bgColor.rgb[2:] + values_dict.setdefault("bg_color", bg_color) + if cell.font: + if hasattr(cell.font.color, "rgb"): + font_color = "#" + cell.font.color.rgb[2:] + values_dict.setdefault("font_color", font_color) + if cell.font.b: + values_dict.setdefault("bold", cell.font.b) + if cell.font.i: + values_dict.setdefault("italic", cell.font.i) + if cell.font.u: + values_dict.setdefault("underline", cell.font.u) + if cell.alignment: + if cell.alignment.wrapText: + values_dict.setdefault("text_wrap", cell.alignment.wrapText) + values_dict.setdefault("align", list()) + if cell.alignment.horizontal in HORIZ_ALIG_ACPTD_VALS.keys(): + values_dict["align"].append( + HORIZ_ALIG_ACPTD_VALS.get(cell.alignment.horizontal) + ) + if cell.alignment.vertical in VERT_ALIG_ACPTD_VALS.keys(): + values_dict["align"].append( + VERT_ALIG_ACPTD_VALS.get(cell.alignment.vertical) + ) + for side in ["left", "right", "top", "bottom"]: + if hasattr(cell.border, side) and hasattr( + getattr(cell.border, side), bs_field + ): + values_dict.setdefault(side, 1) + for key, value in values_dict.items(): + func_name = "set_%s" % key + if hasattr(cell_format, func_name): + if isinstance(value, list): + if not value: + continue + values_to_assign = value + else: + values_to_assign = [value] + for val in values_to_assign: + getattr(cell_format, func_name)(val) + return cell_format + + def _copy_xlsx(self, file_data, template_xlsx): + """ + :return: a copy of the openpyxl.Workbook object on a xlsxwriter.Workbook object. Converts all the content from one type of object to the other + :rtype: xlsxwriter.Workbook object + """ + new_xlsx = self._get_new_workbook(file_data) + template_sheets = template_xlsx.get_sheet_names() + for sheet_name in template_sheets: + new_xlsx.add_worksheet(sheet_name) + for sheet in template_sheets: + openpyxl_active_sheet = template_xlsx.get_sheet_by_name(sheet) + xlsxwriter_active_sheet = new_xlsx.get_worksheet_by_name(sheet) + for count, row in enumerate(openpyxl_active_sheet.rows): + for cell in row: + if isinstance(cell, openpyxl.cell.cell.MergedCell): + continue + else: + # 1. Set Column Width + column_index = cell.column - 1 + cell_width = openpyxl_active_sheet.column_dimensions[ + cell.column_letter + ].width + xlsxwriter_active_sheet.set_column( + column_index, column_index, cell_width + ) + # 2. Set Cell Format for each cell + cell_format = self._copy_cell_format(new_xlsx, cell) + xlsxwriter_active_sheet.write( + cell.coordinate, cell.value, cell_format + ) + # 3. Set Row Height for each row + row_index = cell.row - 1 + cell_height = openpyxl_active_sheet.row_dimensions[cell.row].height + xlsxwriter_active_sheet.set_row(row_index, cell_height) + # 4. Merge merged cells at the end + for merge_range in openpyxl_active_sheet.merged_cells: + xlsxwriter_active_sheet.merge_range(merge_range.coord, "") + return new_xlsx diff --git a/report_xlsx_boilerplate/report/utils/xlsx_utils.py b/report_xlsx_boilerplate/report/utils/xlsx_utils.py new file mode 100644 index 000000000..1c6cabd28 --- /dev/null +++ b/report_xlsx_boilerplate/report/utils/xlsx_utils.py @@ -0,0 +1,19 @@ +# Documentations used: +# Openpyxl: https://openpyxl.readthedocs.io/ +# XlsxWriter: https://xlsxwriter.readthedocs.io/ +HORIZ_ALIG_ACPTD_VALS = { + "left": "left", + "center": "center", + "right": "right", + "fill": "fill", + "justify": "justify", + "centerContinuous": "center_across", + "distributed": "distributed", +} +VERT_ALIG_ACPTD_VALS = { + "top": "top", + "center": "vcenter", + "bottom": "bottom", + "justify": "vjustify", + "distributed": "vdistributed", +} diff --git a/report_xlsx_boilerplate/tests/__init__.py b/report_xlsx_boilerplate/tests/__init__.py new file mode 100644 index 000000000..32ae3c2c3 --- /dev/null +++ b/report_xlsx_boilerplate/tests/__init__.py @@ -0,0 +1 @@ +from . import test_report diff --git a/report_xlsx_boilerplate/tests/test_report.py b/report_xlsx_boilerplate/tests/test_report.py new file mode 100644 index 000000000..84536d91e --- /dev/null +++ b/report_xlsx_boilerplate/tests/test_report.py @@ -0,0 +1,102 @@ +# Copyright 2023 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import logging + +import xlsxwriter + +from odoo.exceptions import ValidationError + +from odoo.addons.report_xlsx.tests.test_report import TestReport + +_logger = logging.getLogger(__name__) + + +class TestReportXlsxBoilerplate(TestReport): + def setUp(self): + super().setUp() + + def test_01_boilerplate_template_usage(self): + """ + Test to get the Boilerplate Template, and check its values + """ + # Define Boilerplate Template path + self.xlsx_report._boilerplate_template_file_path = ( + "tests/sample_files/test_partner_report_boilerplate_template.xlsx" + ) + workbook = self.xlsx_report._get_boilerplate_template(self.docs.ids) + sheet_names = ["Test Sheet 1", "Test Sheet 2"] + cell_str_tup_obj = xlsxwriter.worksheet.cell_string_tuple + for count, sheet_name in enumerate(sheet_names): + worksheet = workbook.get_worksheet_by_name(sheet_name) + self.assertTrue(worksheet, "The sheet should exist.") + cell_1 = worksheet.table.get(0, dict()).get(0, dict()) + if cell_1 and isinstance(cell_1, cell_str_tup_obj): + cell_1_string = cell_1[0] + cell_1_format = cell_1[1] + cell_2 = worksheet.table.get(3, dict()).get(0, dict()) + if cell_2 and isinstance(cell_2, cell_str_tup_obj): + cell_2_string = cell_2[0] + cell_2_format = cell_2[1] + shared_strings = sorted( + worksheet.str_table.string_table, + key=worksheet.str_table.string_table.get, + ) + if count == 0: + # Test Sheet 1 + self.assertTrue(cell_1, "Cell 1 should exist in sheet 1.") + self.assertFalse(cell_2, "Cell 2 should not exist in sheet 1.") + if isinstance(cell_1, cell_str_tup_obj): + cell_1_string_val = shared_strings[cell_1_string] + self.assertEqual( + cell_1_string_val, + "Test Partner\nTest Enter", + "The value of the cell of sheet 1 does not match.", + ) + self.assertTrue(cell_1_format.bold, "Cell should contain bold text.") + self.assertTrue( + cell_1_format.italic, "Cell should contain italic text." + ) + self.assertTrue( + cell_1_format.underline, "Cell should contain underlined text." + ) + self.assertTrue( + cell_1_format.text_wrap, "Cell should contain wrapped text." + ) + self.assertEqual(cell_1_format.font_color, "#000000") + self.assertEqual(cell_1_format.bg_color, "#FFFF00") + # Hardcoded values here as XlsxWriter Format class doesn't hold the + # 'string' values + self.assertEqual(cell_1_format.text_h_align, 2) + self.assertEqual(cell_1_format.text_v_align, 3) + else: + # Test Sheet 2 + self.assertTrue(cell_1, "Cell 1 should exist in sheet 2.") + self.assertTrue(cell_2, "Cell 2 should exist in sheet 2.") + if isinstance(cell_1, cell_str_tup_obj): + cell_1_string_val = shared_strings[cell_1_string] + self.assertEqual( + cell_1_string_val, + "", + "The content of the 0, 0 cell of sheet 2 should be empty.", + ) + if isinstance(cell_2, cell_str_tup_obj): + cell_2_string_val = shared_strings[cell_2_string] + self.assertEqual( + cell_2_string_val, + "Testing for sheet 2", + "The value of the cell 3,0 of sheet 2 does not match.", + ) + # Hardcoded values here as XlsxWriter Format class doesn't hold the + # 'string' values + self.assertEqual(cell_2_format.text_h_align, 1) + self.assertEqual(cell_2_format.text_v_align, 1) + + def test_02_boilerplate_template_wrong_path(self): + """ + Check that ValidationError is raised when the path is wrongly formated. + """ + self.xlsx_report._boilerplate_template_file_path = ( + "wrong_path/wrong_path_file.xlsx" + ) + with self.assertRaises(ValidationError): + self.xlsx_report._get_boilerplate_template(self.docs.ids) diff --git a/setup/report_xlsx_boilerplate/odoo/addons/report_xlsx_boilerplate b/setup/report_xlsx_boilerplate/odoo/addons/report_xlsx_boilerplate new file mode 120000 index 000000000..b6ba18f00 --- /dev/null +++ b/setup/report_xlsx_boilerplate/odoo/addons/report_xlsx_boilerplate @@ -0,0 +1 @@ +../../../../report_xlsx_boilerplate \ No newline at end of file diff --git a/setup/report_xlsx_boilerplate/setup.py b/setup/report_xlsx_boilerplate/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/report_xlsx_boilerplate/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)