Merge PR #798 into 13.0

Signed-off-by pedrobaeza
This commit is contained in:
OCA-git-bot
2023-10-16 17:57:05 +00:00
16 changed files with 340 additions and 4 deletions

View File

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

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

@@ -1,5 +1,7 @@
# generated from manifests external_dependencies
cryptography
endesive
openpyxl
py3o.formats
py3o.template
xlsxwriter

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