mirror of
https://github.com/OCA/reporting-engine.git
synced 2025-02-16 16:30:38 +02:00
@@ -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)
|
||||
|
||||
Binary file not shown.
1
report_xlsx_boilerplate/__init__.py
Normal file
1
report_xlsx_boilerplate/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import report
|
||||
17
report_xlsx_boilerplate/__manifest__.py
Normal file
17
report_xlsx_boilerplate/__manifest__.py
Normal 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,
|
||||
}
|
||||
1
report_xlsx_boilerplate/readme/CONTRIBUTORS.rst
Normal file
1
report_xlsx_boilerplate/readme/CONTRIBUTORS.rst
Normal file
@@ -0,0 +1 @@
|
||||
* Guillem Casassas <guillem.casassas@forgeflow.com>
|
||||
4
report_xlsx_boilerplate/readme/DESCRIPTION.rst
Normal file
4
report_xlsx_boilerplate/readme/DESCRIPTION.rst
Normal 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.
|
||||
1
report_xlsx_boilerplate/readme/INSTALL.rst
Normal file
1
report_xlsx_boilerplate/readme/INSTALL.rst
Normal file
@@ -0,0 +1 @@
|
||||
This module requires to have the ``openpyxl`` Python module installed.
|
||||
15
report_xlsx_boilerplate/readme/USAGE.rst
Normal file
15
report_xlsx_boilerplate/readme/USAGE.rst
Normal 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.
|
||||
1
report_xlsx_boilerplate/report/__init__.py
Normal file
1
report_xlsx_boilerplate/report/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import report_abstract_xlsx
|
||||
153
report_xlsx_boilerplate/report/report_abstract_xlsx.py
Normal file
153
report_xlsx_boilerplate/report/report_abstract_xlsx.py
Normal 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
|
||||
19
report_xlsx_boilerplate/report/utils/xlsx_utils.py
Normal file
19
report_xlsx_boilerplate/report/utils/xlsx_utils.py
Normal 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",
|
||||
}
|
||||
1
report_xlsx_boilerplate/tests/__init__.py
Normal file
1
report_xlsx_boilerplate/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_report
|
||||
102
report_xlsx_boilerplate/tests/test_report.py
Normal file
102
report_xlsx_boilerplate/tests/test_report.py
Normal 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)
|
||||
@@ -1,5 +1,7 @@
|
||||
# generated from manifests external_dependencies
|
||||
cryptography
|
||||
endesive
|
||||
openpyxl
|
||||
py3o.formats
|
||||
py3o.template
|
||||
xlsxwriter
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
../../../../report_xlsx_boilerplate
|
||||
6
setup/report_xlsx_boilerplate/setup.py
Normal file
6
setup/report_xlsx_boilerplate/setup.py
Normal file
@@ -0,0 +1,6 @@
|
||||
import setuptools
|
||||
|
||||
setuptools.setup(
|
||||
setup_requires=['setuptools-odoo'],
|
||||
odoo_addon=True,
|
||||
)
|
||||
Reference in New Issue
Block a user