+
Py3o Report Engine
+
+
+

+
The py3o reporting engine is a reporting engine for Odoo based on
+Libreoffice:
+
+- the report is created with Libreoffice (ODT or ODS),
+- the report is stored on the server in OpenDocument format (.odt or
+.ods file)
+- the report is sent to the user in OpenDocument format or in any output
+format supported by Libreoffice (PDF, HTML, DOC, DOCX, Docbook, XLS,
+etc.)
+
+
The key advantages of a Libreoffice based reporting engine are:
+
+- no need to be a developer to create or modify a report: the report is
+created and modified with Libreoffice. So this reporting engine has a
+full WYSIWYG report development tool!
+- For a PDF report in A4/Letter format, it’s easier to develop it with a
+tool such as Libreoffice that is designed to create A4/Letter
+documents than to develop it in HTML/CSS, also some print
+peculiarities (backgrounds, margin boxes) are not very well supported
+by the HTML/CSS based solutions.
+- If you want your users to be able to modify the document after its
+generation by Odoo, just configure the document with ODT output (or
+DOC or DOCX) and the user will be able to modify the document with
+Libreoffice (or Word) after its generation by Odoo.
+- Easy development of spreadsheet reports in ODS format (XLS output
+possible).
+
+
This module report_py3o is the base module for the Py3o reporting
+engine. If used alone, it will spawn a libreoffice process for each ODT
+to PDF (or ODT to DOCX, ..) document conversion. This is slow and can
+become a problem if you have a lot of reports to convert from ODT to
+another format. In this case, you should consider the additionnal module
+report_py3o_fusion_server which is designed to work with a libreoffice
+daemon. With report_py3o_fusion_server, the technical environnement is
+more complex to setup because you have to install additionnal software
+components and run 2 daemons, but you have much better performances and
+you can configure the libreoffice PDF export options in Odoo (allows to
+generate PDF forms, PDF/A documents, password-protected PDFs,
+watermarked PDFs, etc.).
+
This reporting engine is an alternative to
+Aeroo: these two
+reporting engines have similar features but their implementation is
+entirely different. You cannot use aeroo templates as drop in
+replacement though, you’ll have to change a few details.
+
Table of contents
+
+
+
+
Install the required python libs:
+
+pip install py3o.template
+pip install py3o.formats
+
+
To allow the conversion of ODT or ODS reports to other formats (PDF,
+DOC, DOCX, etc.), install libreoffice:
+
+apt-get --no-install-recommends install libreoffice
+
+
+
+
+
For example, to replace the native invoice report by a custom py3o
+report, add the following XML file in your custom module:
+
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+
+<record id="account.account_invoices" model="ir.actions.report">
+ <field name="report_type">py3o</field>
+ <field name="py3o_filetype">odt</field>
+ <field name="module">my_custom_module_base</field>
+ <field name="py3o_template_fallback">report/account_invoice.odt</field>
+</record>
+
+</odoo>
+
+
where my_custom_module_base is the name of the custom Odoo module. In
+this example, the invoice ODT file is located in
+my_custom_module_base/report/account_invoice.odt.
+
It’s also possible to reference a template located in a trusted path of
+your Odoo server. In this case you must let the module entry empty and
+specify the path to the template as py3o_template_fallback.
+
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+
+<record id="account.account_invoices" model="ir.actions.report">
+ <field name="report_type">py3o</field>
+ <field name="py3o_filetype">odt</field>
+ <field name="py3o_template_fallback">/odoo/templates/py3o/report/account_invoice.odt</field>
+</record>
+
+</odoo>
+
+
Moreover, you must also modify the Odoo server configuration file to
+declare the allowed root directory for your py3o templates. Only
+templates located into this directory can be loaded by py3o report.
+
+[options]
+...
+
+[report_py3o]
+root_tmpl_path=/odoo/templates/py3o
+
+
If you want an invoice in PDF format instead of ODT format, the XML file
+should look like:
+
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+
+<record id="account.account_invoices" model="ir.actions.report">
+ <field name="report_type">py3o</field>
+ <field name="py3o_filetype">pdf</field>
+ <field name="module">my_custom_module_base</field>
+ <field name="py3o_template_fallback">report/account_invoice.odt</field>
+</record>
+
+</odoo>
+
+
If you want to add a new py3o PDF report (and not replace a native
+report), the XML file should look like this:
+
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+
+<record id="partner_summary_report" model="ir.actions.report">
+ <field name="name">Partner Summary</field>
+ <field name="model">res.partner</field>
+ <field name="report_name">res.partner.summary</field>
+ <field name="report_type">py3o</field>
+ <field name="py3o_filetype">pdf</field>
+ <field name="module">my_custom_module_base</field>
+ <field name="py3o_template_fallback">report/partner_summary.odt</field>
+ <!-- Add entry in "Print" drop-down list -->
+ <field name="binding_type">report</field>
+ <field name="binding_model_id" ref="base.model_res_partner"/>
+</record>
+
+</odoo>
+
+
+
+
+
py3o.conversion_command
+
The command to be used to run the conversion, libreoffice by
+default. If you change this, whatever you set here must accept the
+parameters --headless --convert-to $ext $file and put the
+resulting file into $file’s directory with extension $ext. The
+command will be started in $file’s directory.
+
+
+
+
+
+
The templating language is extensively
+documented,
+the records are exposed in libreoffice as objects, on which you can
+also call functions.
+
+
+
+
user
+
Browse record of current user
+
+
+
lang
+
The user’s company’s language as string (ISO code)
+
+
+
b64decode
+
base64.b64decode
+
+
+
format_multiline_value(string)
+
Generate the ODF equivalent of <br/> and for multiline
+fields (ODF is XML internally, so those would be skipped otherwise)
+
+
+
html_sanitize(string)
+
Sanitize HTML string
+
+
+
time
+
Python’s time module
+
+
+
display_address(partner)
+
Return a formatted string of the partner’s address
+
+
+
o_format_lang(value, lang_code=False, digits=None, grouping=True,
+monetary=False, dp=False, currency_obj=False, no_break_space=True)
+
Return a formatted numeric or monetary value according to the context
+language and timezone
+
+
+
o_format_date(value, lang_code=False, date_format=False)
+
Return a formatted date or time value according to the context
+language and timezone
+
+
+
+
+
Sample py3o report templates for the main Odoo native reports (invoice,
+sale order, purchase order, picking, etc.) are available on the Github
+project
+odoo-py3o-report-templates.
+
+
+
+
+
+- generate barcode ?
+- add more detailed example in demo file to showcase features
+- add migration guide aeroo -> py3o
+
+
+
+
+
Bugs are tracked on GitHub Issues.
+In case of trouble, please check there if your issue has already been reported.
+If you spotted it first, help us to smash it by providing a detailed and welcomed
+feedback.
+
Do not contact contributors directly about support or help with technical issues.
+
+
+
+
+
+
+- XCG Consulting
+- ACSONE SA/NV
+
+
+
+
+
+
This module is maintained by the OCA.
+
+
+
+
OCA, or the Odoo Community Association, is a nonprofit organization whose
+mission is to support the collaborative development of Odoo features and
+promote its widespread use.
+
This module is part of the OCA/reporting-engine project on GitHub.
+
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
+
+
+
+
+
diff --git a/report_py3o/static/src/js/py3oactionservice.esm.js b/report_py3o/static/src/js/py3oactionservice.esm.js
new file mode 100644
index 0000000000..41ed91282a
--- /dev/null
+++ b/report_py3o/static/src/js/py3oactionservice.esm.js
@@ -0,0 +1,54 @@
+/** @odoo-module **/
+
+import {download} from "@web/core/network/download";
+import {registry} from "@web/core/registry";
+
+registry
+ .category("ir.actions.report handlers")
+ .add("py3o_handler", async function (action, options, env) {
+ if (action.report_type === "py3o") {
+ let url = `/report/py3o/${action.report_name}`;
+ const actionContext = action.context || {};
+ if (
+ typeof action.data === "undefined" ||
+ action.data === null ||
+ typeof action.data === "object"
+ ) {
+ // Build a query string with `action.data` (it's the place where reports
+ // using a wizard to customize the output traditionally put their options)
+ if (actionContext.active_ids) {
+ var activeIDsPath = "/" + actionContext.active_ids.join(",");
+ url += activeIDsPath;
+ }
+ } else {
+ var serializedOptionsPath =
+ "?options=" + encodeURIComponent(JSON.stringify(action.data));
+ serializedOptionsPath +=
+ "&context=" + encodeURIComponent(JSON.stringify(actionContext));
+ url += serializedOptionsPath;
+ }
+ env.services.ui.block();
+ try {
+ await download({
+ url: "/report/download",
+ data: {
+ data: JSON.stringify([url, action.report_type]),
+ context: JSON.stringify(env.services.user.context),
+ },
+ });
+ } finally {
+ env.services.ui.unblock();
+ }
+ const onClose = options.onClose;
+ if (action.close_on_report_download) {
+ return env.services.action.doAction(
+ {type: "ir.actions.act_window_close"},
+ {onClose}
+ );
+ } else if (onClose) {
+ onClose();
+ }
+ return Promise.resolve(true);
+ }
+ return Promise.resolve(false);
+ });
diff --git a/report_py3o/tests/__init__.py b/report_py3o/tests/__init__.py
new file mode 100644
index 0000000000..13bc3247b7
--- /dev/null
+++ b/report_py3o/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_report_py3o
diff --git a/report_py3o/tests/test_report_py3o.py b/report_py3o/tests/test_report_py3o.py
new file mode 100644
index 0000000000..1dcd704a1a
--- /dev/null
+++ b/report_py3o/tests/test_report_py3o.py
@@ -0,0 +1,274 @@
+# Copyright 2016 ACSONE SA/NV
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).).
+
+import base64
+import logging
+import os
+import shutil
+import sys
+import tempfile
+from base64 import b64decode, b64encode
+from contextlib import contextmanager
+from unittest import mock
+
+import pkg_resources
+from packaging import version
+from PyPDF2 import PdfFileWriter
+
+from odoo import tools
+from odoo.exceptions import ValidationError
+from odoo.tests.common import TransactionCase
+
+from odoo.addons.base.tests.test_mimetypes import PNG
+
+from ..models._py3o_parser_context import format_multiline_value
+from ..models.ir_actions_report import PY3O_CONVERSION_COMMAND_PARAMETER
+from ..models.py3o_report import TemplateNotFound
+
+logger = logging.getLogger(__name__)
+
+try:
+ from genshi.core import Markup
+except ImportError:
+ logger.debug("Cannot import genshi.core")
+
+# Ensuring compatibility with PyPDF2 versions depending on the Python version
+PYTHON_VERSION = version.parse(sys.version.split(" ")[0])
+
+# Conditional import based on Python version
+if PYTHON_VERSION <= version.parse("3.10"):
+ from PyPDF2.pdf import PageObject
+else:
+ from PyPDF2 import PageObject
+
+
+@contextmanager
+def temporary_copy(path):
+ filname, ext = os.path.splitext(path)
+ tmp_filename = tempfile.mktemp(suffix="." + ext)
+ try:
+ shutil.copy2(path, tmp_filename)
+ yield tmp_filename
+ finally:
+ os.unlink(tmp_filename)
+
+
+class TestReportPy3o(TransactionCase):
+ def setUp(self):
+ super().setUp()
+ self.env.user.image_1920 = PNG
+ self.report = self.env.ref("report_py3o.res_users_report_py3o")
+ self.py3o_report = self.env["py3o.report"].create(
+ {"ir_actions_report_id": self.report.id}
+ )
+
+ def test_required_py3_filetype(self):
+ self.assertEqual(self.report.report_type, "py3o")
+ with self.assertRaises(ValidationError) as e:
+ self.report.py3o_filetype = False
+ self.assertEqual(
+ e.exception.args[0], "Field 'Output Format' is required for Py3O report"
+ )
+
+ def _render_patched(self, result_text="test result", call_count=1):
+ py3o_report = self.env["py3o.report"]
+ py3o_report_obj = py3o_report.create({"ir_actions_report_id": self.report.id})
+ with mock.patch.object(
+ py3o_report.__class__, "_create_single_report"
+ ) as patched_pdf:
+ result = tempfile.mktemp(".txt")
+ with open(result, "w") as fp:
+ fp.write(result_text)
+ patched_pdf.side_effect = (
+ lambda record, data: py3o_report_obj._postprocess_report(record, result)
+ or result
+ )
+ # test the call the the create method inside our custom parser
+ self.report._render(self.report.id, self.env.user.ids)
+ self.assertEqual(call_count, patched_pdf.call_count)
+ # generated files no more exists
+ self.assertFalse(os.path.exists(result))
+
+ def test_reports(self):
+ res = self.report._render(self.report.id, self.env.user.ids)
+ self.assertTrue(res)
+
+ def test_reports_merge_zip(self):
+ self.report.py3o_filetype = "odt"
+ users = self.env["res.users"].search([])
+ self.assertTrue(len(users) > 0)
+ py3o_report = self.env["py3o.report"]
+ _zip_results = self.py3o_report._zip_results
+ with mock.patch.object(
+ py3o_report.__class__, "_zip_results"
+ ) as patched_zip_results:
+ patched_zip_results.side_effect = _zip_results
+ content, filetype = self.report._render(self.report.id, users.ids)
+ self.assertEqual(1, patched_zip_results.call_count)
+ self.assertEqual(filetype, "zip")
+
+ def test_reports_merge_pdf(self):
+ reports_path = []
+ for _i in range(0, 3):
+ result = tempfile.mktemp(".txt")
+ writer = PdfFileWriter()
+ writer.addPage(PageObject.createBlankPage(width=100, height=100))
+ with open(result, "wb") as fp:
+ writer.write(fp)
+ reports_path.append(result)
+ res = self.py3o_report._merge_pdf(reports_path)
+ self.assertTrue(res)
+
+ def test_report_load_from_attachment(self):
+ self.report.write({"attachment_use": True, "attachment": "'my_saved_report'"})
+ attachments = self.env["ir.attachment"].search([])
+ self._render_patched()
+ new_attachments = self.env["ir.attachment"].search([])
+ created_attachement = new_attachments - attachments
+ self.assertEqual(1, len(created_attachement))
+ content = b64decode(created_attachement.datas)
+ self.assertEqual(b"test result", content)
+ # put a new content into tha attachement and check that the next
+ # time we ask the report we received the saved attachment not a newly
+ # generated document
+ created_attachement.datas = base64.b64encode(b"new content")
+ res = self.report._render(self.report.id, self.env.user.ids)
+ self.assertEqual((b"new content", self.report.py3o_filetype), res)
+
+ def test_report_post_process(self):
+ """
+ By default the post_process method is in charge to save the
+ generated report into an ir.attachment if requested.
+ """
+ self.report.attachment = "object.name + '.txt'"
+ ir_attachment = self.env["ir.attachment"]
+ attachements = ir_attachment.search([(1, "=", 1)])
+ self._render_patched()
+ attachements = ir_attachment.search([(1, "=", 1)]) - attachements
+ self.assertEqual(1, len(attachements.ids))
+ self.assertEqual(self.env.user.name + ".txt", attachements.name)
+ self.assertEqual(self.env.user._name, attachements.res_model)
+ self.assertEqual(self.env.user.id, attachements.res_id)
+ self.assertEqual(b"test result", b64decode(attachements.datas))
+
+ @tools.misc.mute_logger("odoo.addons.report_py3o.models.py3o_report")
+ def test_report_template_configs(self):
+ # the demo template is specified with a relative path in in the module
+ # path
+ tmpl_name = self.report.py3o_template_fallback
+ flbk_filename = pkg_resources.resource_filename(
+ "odoo.addons.%s" % self.report.module, tmpl_name
+ )
+ self.assertTrue(os.path.exists(flbk_filename))
+ res = self.report._render(self.report.id, self.env.user.ids)
+ self.assertTrue(res)
+ # The generation fails if the template is not found
+ self.report.module = False
+ with self.assertRaises(TemplateNotFound), self.env.cr.savepoint():
+ self.report._render(self.report.id, self.env.user.ids)
+
+ # the template can also be provided as an abspath if it's root path
+ # is trusted
+ self.report.py3o_template_fallback = flbk_filename
+ with self.assertRaises(TemplateNotFound):
+ self.report._render(self.report.id, self.env.user.ids)
+ with temporary_copy(flbk_filename) as tmp_filename:
+ self.report.py3o_template_fallback = tmp_filename
+ tools.config.misc["report_py3o"] = {
+ "root_tmpl_path": os.path.realpath(os.path.dirname(tmp_filename))
+ }
+ res = self.report._render(self.report.id, self.env.user.ids)
+ self.assertTrue(res)
+
+ # the tempalte can also be provided as a binary field
+ self.report.py3o_template_fallback = False
+
+ with open(flbk_filename, "rb") as tmpl_file:
+ tmpl_data = b64encode(tmpl_file.read())
+ py3o_template = self.env["py3o.template"].create(
+ {
+ "name": "test_template",
+ "py3o_template_data": tmpl_data,
+ "filetype": "odt",
+ }
+ )
+ self.report.py3o_template_id = py3o_template
+ self.report.py3o_template_fallback = flbk_filename
+ res = self.report._render(self.report.id, self.env.user.ids)
+ self.assertTrue(res)
+
+ @tools.misc.mute_logger("odoo.addons.report_py3o.models.py3o_report")
+ def test_report_template_fallback_validity(self):
+ tmpl_name = self.report.py3o_template_fallback
+ flbk_filename = pkg_resources.resource_filename(
+ "odoo.addons.%s" % self.report.module, tmpl_name
+ )
+ # an exising file in a native format is a valid template if it's
+ self.assertTrue(self.py3o_report._get_template_from_path(tmpl_name))
+ self.report.module = None
+ # a directory is not a valid template..
+ self.assertFalse(self.py3o_report._get_template_from_path("/etc/"))
+ self.assertFalse(self.py3o_report._get_template_from_path("."))
+ # an vaild template outside the root_tmpl_path is not a valid template
+ # path
+ # located in trusted directory
+ self.report.py3o_template_fallback = flbk_filename
+ self.assertFalse(self.py3o_report._get_template_from_path(flbk_filename))
+ with temporary_copy(flbk_filename) as tmp_filename:
+ self.assertTrue(self.py3o_report._get_template_from_path(tmp_filename))
+ # check security
+ self.assertFalse(
+ self.py3o_report._get_template_from_path("rm -rf . & %s" % flbk_filename)
+ )
+ # a file in a non native LibreOffice format is not a valid template
+ with tempfile.NamedTemporaryFile(suffix=".toto") as f:
+ self.assertFalse(self.py3o_report._get_template_from_path(f.name))
+ # non exising files are not valid template
+ self.assertFalse(self.py3o_report._get_template_from_path("/etc/test.odt"))
+
+ def test_escape_html_characters_format_multiline_value(self):
+ self.assertEqual(
+ Markup("<>