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 000000000..42ace5395 Binary files /dev/null and b/report_xlsx/tests/sample_files/test_partner_report_boilerplate_template.xlsx differ 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/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 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, +)