[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.
This commit is contained in:
GuillemCForgeFlow
2023-10-13 15:18:14 +02:00
parent 60b69e56e9
commit c0b1078028
13 changed files with 322 additions and 0 deletions

View File

@@ -0,0 +1 @@
from . import report

View File

@@ -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,
}

View File

@@ -0,0 +1 @@
* Guillem Casassas <guillem.casassas@forgeflow.com>

View File

@@ -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.

View File

@@ -0,0 +1 @@
This module requires to have the ``openpyxl`` Python module installed.

View File

@@ -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.

View File

@@ -0,0 +1 @@
from . import report_abstract_xlsx

View File

@@ -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

View File

@@ -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",
}

View File

@@ -0,0 +1 @@
from . import test_report

View File

@@ -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)

View File

@@ -0,0 +1 @@
../../../../report_xlsx_boilerplate

View File

@@ -0,0 +1,6 @@
import setuptools
setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)