From 2e9ef0b221ecf2c093c814609c6f079967fe9687 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Tue, 19 Nov 2019 14:36:37 +0100 Subject: [PATCH] [IMP] report_py3o, report_py3o_fusion_server: black, isort --- report_py3o/__manifest__.py | 42 +++--- report_py3o/controllers/main.py | 79 +++++----- report_py3o/demo/report_py3o.xml | 2 +- report_py3o/models/_py3o_parser_context.py | 91 +++++++---- report_py3o/models/ir_actions_report.py | 105 ++++++------- report_py3o/models/py3o_report.py | 166 ++++++++++----------- report_py3o/models/py3o_template.py | 19 +-- report_py3o/tests/test_report_py3o.py | 162 ++++++++++---------- 8 files changed, 342 insertions(+), 324 deletions(-) diff --git a/report_py3o/__manifest__.py b/report_py3o/__manifest__.py index 9b8a0833..4b45c018 100644 --- a/report_py3o/__manifest__.py +++ b/report_py3o/__manifest__.py @@ -1,29 +1,23 @@ # Copyright 2013 XCG Consulting (http://odoo.consulting) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). { - 'name': 'Py3o Report Engine', - 'summary': 'Reporting engine based on Libreoffice (ODT -> ODT, ' - 'ODT -> PDF, ODT -> DOC, ODT -> DOCX, ODS -> ODS, etc.)', - 'version': '12.0.2.0.2', - 'category': 'Reporting', - 'license': 'AGPL-3', - 'author': 'XCG Consulting,' - 'ACSONE SA/NV,' - 'Odoo Community Association (OCA)', - 'website': 'http://odoo.consulting/', - 'depends': ['web'], - 'external_dependencies': { - 'python': ['py3o.template', - 'py3o.formats', - 'PyPDF2'] - }, - 'data': [ - 'security/ir.model.access.csv', - 'views/menu.xml', - 'views/py3o_template.xml', - 'views/ir_actions_report.xml', - 'views/report_py3o.xml', - 'demo/report_py3o.xml', + "name": "Py3o Report Engine", + "summary": "Reporting engine based on Libreoffice (ODT -> ODT, " + "ODT -> PDF, ODT -> DOC, ODT -> DOCX, ODS -> ODS, etc.)", + "version": "12.0.2.0.2", + "category": "Reporting", + "license": "AGPL-3", + "author": "XCG Consulting," "ACSONE SA/NV," "Odoo Community Association (OCA)", + "website": "http://odoo.consulting/", + "depends": ["web"], + "external_dependencies": {"python": ["py3o.template", "py3o.formats", "PyPDF2"]}, + "data": [ + "security/ir.model.access.csv", + "views/menu.xml", + "views/py3o_template.xml", + "views/ir_actions_report.xml", + "views/report_py3o.xml", + "demo/report_py3o.xml", ], - 'installable': True, + "installable": True, } diff --git a/report_py3o/controllers/main.py b/report_py3o/controllers/main.py index 4b19c576..ac4decaf 100644 --- a/report_py3o/controllers/main.py +++ b/report_py3o/controllers/main.py @@ -2,58 +2,57 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import json import mimetypes + from werkzeug import exceptions, url_decode -from odoo.http import route, request +from odoo.http import request, route +from odoo.tools import html_escape from odoo.addons.web.controllers import main -from odoo.addons.web.controllers.main import ( - _serialize_exception, - content_disposition -) -from odoo.tools import html_escape +from odoo.addons.web.controllers.main import _serialize_exception, content_disposition class ReportController(main.ReportController): - @route() def report_routes(self, reportname, docids=None, converter=None, **data): - if converter != 'py3o': + if converter != "py3o": return super(ReportController, self).report_routes( - reportname=reportname, docids=docids, converter=converter, - **data) + reportname=reportname, docids=docids, converter=converter, **data + ) context = dict(request.env.context) if docids: - docids = [int(i) for i in docids.split(',')] - if data.get('options'): - data.update(json.loads(data.pop('options'))) - if data.get('context'): + docids = [int(i) for i in docids.split(",")] + if data.get("options"): + data.update(json.loads(data.pop("options"))) + if data.get("context"): # Ignore 'lang' here, because the context in data is the # one from the webclient *but* if the user explicitely wants to # change the lang, this mechanism overwrites it. - data['context'] = json.loads(data['context']) - if data['context'].get('lang'): - del data['context']['lang'] - context.update(data['context']) + data["context"] = json.loads(data["context"]) + if data["context"].get("lang"): + del data["context"]["lang"] + context.update(data["context"]) - ir_action = request.env['ir.actions.report'] + ir_action = request.env["ir.actions.report"] action_py3o_report = ir_action.get_from_report_name( - reportname, "py3o").with_context(context) + reportname, "py3o" + ).with_context(context) if not action_py3o_report: raise exceptions.HTTPException( - description='Py3o action report not found for report_name ' - '%s' % reportname) + description="Py3o action report not found for report_name " + "%s" % reportname + ) res, filetype = action_py3o_report.render(docids, data) - filename = action_py3o_report.gen_report_download_filename( - docids, data) + filename = action_py3o_report.gen_report_download_filename(docids, data) if not filename.endswith(filetype): filename = "{}.{}".format(filename, filetype) content_type = mimetypes.guess_type("x." + filetype)[0] - http_headers = [('Content-Type', content_type), - ('Content-Length', len(res)), - ('Content-Disposition', content_disposition(filename)) - ] + http_headers = [ + ("Content-Type", content_type), + ("Content-Length", len(res)), + ("Content-Disposition", content_disposition(filename)), + ] return request.make_response(res, headers=http_headers) @route() @@ -67,31 +66,29 @@ class ReportController(main.ReportController): """ requestcontent = json.loads(data) url, report_type = requestcontent[0], requestcontent[1] - if 'py3o' not in report_type: + if "py3o" not in report_type: return super(ReportController, self).report_download(data, token) try: - reportname = url.split('/report/py3o/')[1].split('?')[0] + reportname = url.split("/report/py3o/")[1].split("?")[0] docids = None - if '/' in reportname: - reportname, docids = reportname.split('/') + if "/" in reportname: + reportname, docids = reportname.split("/") if docids: # Generic report: response = self.report_routes( - reportname, docids=docids, converter='py3o') + reportname, docids=docids, converter="py3o" + ) else: # Particular report: # decoding the args represented in JSON - data = list(url_decode(url.split('?')[1]).items()) + data = list(url_decode(url.split("?")[1]).items()) response = self.report_routes( - reportname, converter='py3o', **dict(data)) - response.set_cookie('fileToken', token) + reportname, converter="py3o", **dict(data) + ) + response.set_cookie("fileToken", token) return response except Exception as e: se = _serialize_exception(e) - error = { - 'code': 200, - 'message': "Odoo Server Error", - 'data': se - } + error = {"code": 200, "message": "Odoo Server Error", "data": se} return request.make_response(html_escape(json.dumps(error))) diff --git a/report_py3o/demo/report_py3o.xml b/report_py3o/demo/report_py3o.xml index 6d894153..240960ea 100644 --- a/report_py3o/demo/report_py3o.xml +++ b/report_py3o/demo/report_py3o.xml @@ -16,5 +16,5 @@ report - + diff --git a/report_py3o/models/_py3o_parser_context.py b/report_py3o/models/_py3o_parser_context.py index c5b55951..37043515 100644 --- a/report_py3o/models/_py3o_parser_context.py +++ b/report_py3o/models/_py3o_parser_context.py @@ -2,24 +2,27 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import html -import time import logging - +import time from base64 import b64decode -from odoo.tools import misc, mail + +from odoo.tools import mail, misc logger = logging.getLogger(__name__) try: from genshi.core import Markup except ImportError: - logger.debug('Cannot import py3o.template') + logger.debug("Cannot import py3o.template") def format_multiline_value(value): if value: - return Markup(html.escape(value).replace('\n', ''). - replace('\t', '')) + return Markup( + html.escape(value) + .replace("\n", "") + .replace("\t", "") + ) return "" @@ -32,38 +35,52 @@ class Py3oParserContext(object): self._env = env self.localcontext = { - 'user': self._env.user, - 'lang': self._env.lang, + "user": self._env.user, + "lang": self._env.lang, # Odoo default format methods - 'o_format_lang': self._format_lang, + "o_format_lang": self._format_lang, # prefixes with o_ to avoid nameclash with default method provided # by py3o.template - 'o_format_date': self._format_date, + "o_format_date": self._format_date, # give access to the time lib - 'time': time, + "time": time, # keeps methods from report_sxw to ease migration - 'display_address': display_address, - 'formatLang': self._old_format_lang, - 'format_multiline_value': format_multiline_value, - 'html_sanitize': mail.html2plaintext, - 'b64decode': b64decode, + "display_address": display_address, + "formatLang": self._old_format_lang, + "format_multiline_value": format_multiline_value, + "html_sanitize": mail.html2plaintext, + "b64decode": b64decode, } - def _format_lang(self, value, lang_code=False, digits=None, grouping=True, - monetary=False, dp=False, currency_obj=False, - no_break_space=True): + def _format_lang( + self, + value, + lang_code=False, + digits=None, + grouping=True, + monetary=False, + dp=False, + currency_obj=False, + no_break_space=True, + ): env = self._env if lang_code: context = dict(env.context, lang=lang_code) env = env(context=context) formatted_value = misc.formatLang( - env, value, digits=digits, grouping=grouping, - monetary=monetary, dp=dp, currency_obj=currency_obj) + env, + value, + digits=digits, + grouping=grouping, + monetary=monetary, + dp=dp, + currency_obj=currency_obj, + ) if currency_obj and currency_obj.symbol and no_break_space: parts = [] - if currency_obj.position == 'after': + if currency_obj.position == "after": parts = formatted_value.rsplit(" ", 1) - elif currency_obj and currency_obj.position == 'before': + elif currency_obj and currency_obj.position == "before": parts = formatted_value.split(" ", 1) if parts: formatted_value = "\N{NO-BREAK SPACE}".join(parts) @@ -71,11 +88,20 @@ class Py3oParserContext(object): def _format_date(self, value, lang_code=False, date_format=False): return misc.format_date( - self._env, value, lang_code=lang_code, date_format=date_format) + self._env, value, lang_code=lang_code, date_format=date_format + ) - def _old_format_lang(self, value, digits=None, date=False, date_time=False, - grouping=True, monetary=False, dp=False, - currency_obj=False): + def _old_format_lang( + self, + value, + digits=None, + date=False, + date_time=False, + grouping=True, + monetary=False, + dp=False, + currency_obj=False, + ): """ :param value: The value to format :param digits: Number of digits to display by default @@ -95,8 +121,13 @@ class Py3oParserContext(object): """ if not date and not date_time: return self._format_lang( - value, digits=digits, grouping=grouping, - monetary=monetary, dp=dp, currency_obj=currency_obj, - no_break_space=True) + value, + digits=digits, + grouping=grouping, + monetary=monetary, + dp=dp, + currency_obj=currency_obj, + no_break_space=True, + ) return self._format_date(self._env, value) diff --git a/report_py3o/models/ir_actions_report.py b/report_py3o/models/ir_actions_report.py index 1e60a373..67045557 100644 --- a/report_py3o/models/ir_actions_report.py +++ b/report_py3o/models/ir_actions_report.py @@ -3,18 +3,18 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). import logging import time -from odoo import api, fields, models, _ + +from odoo import _, api, fields, models from odoo.exceptions import ValidationError from odoo.tools.misc import find_in_path from odoo.tools.safe_eval import safe_eval - logger = logging.getLogger(__name__) try: from py3o.formats import Formats except ImportError: - logger.debug('Cannot import py3o.formats') + logger.debug("Cannot import py3o.formats") PY3O_CONVERSION_COMMAND_PARAMETER = "py3o.conversion_command" @@ -25,15 +25,16 @@ class IrActionsReport(models.Model): The list is configurable in the configuration tab, see py3o_template.py """ - _inherit = 'ir.actions.report' + _inherit = "ir.actions.report" @api.multi @api.constrains("py3o_filetype", "report_type") def _check_py3o_filetype(self): for report in self: if report.report_type == "py3o" and not report.py3o_filetype: - raise ValidationError(_( - "Field 'Output Format' is required for Py3O report")) + raise ValidationError( + _("Field 'Output Format' is required for Py3O report") + ) @api.model def _get_py3o_filetypes(self): @@ -47,21 +48,15 @@ class IrActionsReport(models.Model): selections.append((name, description)) return selections - report_type = fields.Selection( - selection_add=[("py3o", "py3o")] - ) + report_type = fields.Selection(selection_add=[("py3o", "py3o")]) py3o_filetype = fields.Selection( - selection="_get_py3o_filetypes", - string="Output Format") - is_py3o_native_format = fields.Boolean( - compute='_compute_is_py3o_native_format' + selection="_get_py3o_filetypes", string="Output Format" ) - py3o_template_id = fields.Many2one( - 'py3o.template', - "Template") + is_py3o_native_format = fields.Boolean(compute="_compute_is_py3o_native_format") + py3o_template_id = fields.Many2one("py3o.template", "Template") module = fields.Char( - "Module", - help="The implementer module that provides this report") + "Module", help="The implementer module that provides this report" + ) py3o_template_fallback = fields.Char( "Fallback", size=128, @@ -69,24 +64,25 @@ class IrActionsReport(models.Model): "If the user does not provide a template this will be used " "it should be a relative path to root of YOUR module " "or an absolute path on your server." - )) - report_type = fields.Selection(selection_add=[('py3o', "Py3o")]) + ), + ) + report_type = fields.Selection(selection_add=[("py3o", "Py3o")]) py3o_multi_in_one = fields.Boolean( - string='Multiple Records in a Single Report', + string="Multiple Records in a Single Report", help="If you execute a report on several records, " "by default Odoo will generate a ZIP file that contains as many " "files as selected records. If you enable this option, Odoo will " - "generate instead a single report for the selected records.") + "generate instead a single report for the selected records.", + ) lo_bin_path = fields.Char( - string="Path to the libreoffice runtime", - compute="_compute_lo_bin_path" - ) + string="Path to the libreoffice runtime", compute="_compute_lo_bin_path" + ) is_py3o_report_not_available = fields.Boolean( - compute='_compute_py3o_report_not_available' - ) + compute="_compute_py3o_report_not_available" + ) msg_py3o_report_not_available = fields.Char( - compute='_compute_py3o_report_not_available' - ) + compute="_compute_py3o_report_not_available" + ) @api.model def _register_hook(self): @@ -106,8 +102,10 @@ class IrActionsReport(models.Model): @api.model def _get_lo_bin(self): - lo_bin = self.env['ir.config_parameter'].sudo().get_param( - PY3O_CONVERSION_COMMAND_PARAMETER, 'libreoffice', + lo_bin = ( + self.env["ir.config_parameter"] + .sudo() + .get_param(PY3O_CONVERSION_COMMAND_PARAMETER, "libreoffice") ) try: lo_bin = find_in_path(lo_bin) @@ -118,12 +116,12 @@ class IrActionsReport(models.Model): @api.depends("report_type", "py3o_filetype") @api.multi def _compute_is_py3o_native_format(self): - format = Formats() + fmt = Formats() for rec in self: if not rec.report_type == "py3o": continue filetype = rec.py3o_filetype - rec.is_py3o_native_format = format.get_format(filetype).native + rec.is_py3o_native_format = fmt.get_format(filetype).native @api.multi def _compute_lo_bin_path(self): @@ -139,21 +137,24 @@ class IrActionsReport(models.Model): continue if not rec.is_py3o_native_format and not rec.lo_bin_path: rec.is_py3o_report_not_available = True - rec.msg_py3o_report_not_available = _( - "The libreoffice runtime is required to genereate the " - "py3o report '%s' but is not found into the bin path. You " - "must install the libreoffice runtime on the server. If " - "the runtime is already installed and is not found by " - "Odoo, you can provide the full path to the runtime by " - "setting the key 'py3o.conversion_command' into the " - "configuration parameters." - ) % rec.name + rec.msg_py3o_report_not_available = ( + _( + "The libreoffice runtime is required to genereate the " + "py3o report '%s' but is not found into the bin path. You " + "must install the libreoffice runtime on the server. If " + "the runtime is already installed and is not found by " + "Odoo, you can provide the full path to the runtime by " + "setting the key 'py3o.conversion_command' into the " + "configuration parameters." + ) + % rec.name + ) @api.model def get_from_report_name(self, report_name, report_type): return self.search( - [("report_name", "=", report_name), - ("report_type", "=", report_type)]) + [("report_name", "=", report_name), ("report_type", "=", report_type)] + ) @api.multi def render_py3o(self, res_ids, data): @@ -161,10 +162,13 @@ class IrActionsReport(models.Model): if self.report_type != "py3o": raise RuntimeError( "py3o rendition is only available on py3o report.\n" - "(current: '{}', expected 'py3o'".format(self.report_type)) - return self.env['py3o.report'].create({ - 'ir_actions_report_id': self.id - }).create_report(res_ids, data) + "(current: '{}', expected 'py3o'".format(self.report_type) + ) + return ( + self.env["py3o.report"] + .create({"ir_actions_report_id": self.id}) + .create_report(res_ids, data) + ) @api.multi def gen_report_download_filename(self, res_ids, data): @@ -174,9 +178,8 @@ class IrActionsReport(models.Model): report = self.get_from_report_name(self.report_name, self.report_type) if report.print_report_name and not len(res_ids) > 1: obj = self.env[self.model].browse(res_ids) - return safe_eval(report.print_report_name, - {'object': obj, 'time': time}) - return "%s.%s" % (self.name, self.py3o_filetype) + return safe_eval(report.print_report_name, {"object": obj, "time": time}) + return "{}.{}".format(self.name, self.py3o_filetype) @api.multi def _get_attachments(self, res_ids): diff --git a/report_py3o/models/py3o_report.py b/report_py3o/models/py3o_report.py index 7777f7bb..b7b673fa 100644 --- a/report_py3o/models/py3o_report.py +++ b/report_py3o/models/py3o_report.py @@ -2,19 +2,20 @@ # Copyright 2016 ACSONE SA/NV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import base64 -from base64 import b64decode -from io import BytesIO import logging import os -from contextlib import closing import subprocess - -import pkg_resources import sys import tempfile -from zipfile import ZipFile, ZIP_DEFLATED +from base64 import b64decode +from contextlib import closing +from io import BytesIO +from zipfile import ZIP_DEFLATED, ZipFile + +import pkg_resources + +from odoo import _, api, fields, models, tools -from odoo import api, fields, models, tools, _ from ._py3o_parser_context import Py3oParserContext logger = logging.getLogger(__name__) @@ -23,15 +24,15 @@ try: from py3o.template import Template from py3o import formats except ImportError: - logger.debug('Cannot import py3o.template') + logger.debug("Cannot import py3o.template") try: from py3o.formats import Formats, UnkownFormatException except ImportError: - logger.debug('Cannot import py3o.formats') + logger.debug("Cannot import py3o.formats") try: from PyPDF2 import PdfFileWriter, PdfFileReader except ImportError: - logger.debug('Cannot import PyPDF2') + logger.debug("Cannot import PyPDF2") _extender_functions = {} @@ -59,12 +60,13 @@ def py3o_report_extender(report_xml_id=None): def fct1(fct): _extender_functions.setdefault(report_xml_id, []).append(fct) return fct + return fct1 @py3o_report_extender() def default_extend(report_xml, context): - context['report_xml'] = report_xml + context["report_xml"] = report_xml class Py3oReport(models.TransientModel): @@ -72,8 +74,7 @@ class Py3oReport(models.TransientModel): _description = "Report Py30" ir_actions_report_id = fields.Many2one( - comodel_name="ir.actions.report", - required=True + comodel_name="ir.actions.report", required=True ) @api.multi @@ -81,18 +82,22 @@ class Py3oReport(models.TransientModel): """ Check if the path is a trusted path for py3o templates. """ real_path = os.path.realpath(path) - root_path = tools.config.get_misc('report_py3o', 'root_tmpl_path') + root_path = tools.config.get_misc("report_py3o", "root_tmpl_path") if not root_path: logger.warning( "You must provide a root template path into odoo.cfg to be " "able to use py3o template configured with an absolute path " - "%s", real_path) + "%s", + real_path, + ) return False is_valid = real_path.startswith(root_path + os.path.sep) if not is_valid: logger.warning( - "Py3o template path is not valid. %s is not a child of root " - "path %s", real_path, root_path) + "Py3o template path is not valid. %s is not a child of root " "path %s", + real_path, + root_path, + ) return is_valid @api.multi @@ -101,16 +106,14 @@ class Py3oReport(models.TransientModel): """ if filename and os.path.isfile(filename): fname, ext = os.path.splitext(filename) - ext = ext.replace('.', '') + ext = ext.replace(".", "") try: fformat = Formats().get_format(ext) if fformat and fformat.native: return True except UnkownFormatException: - logger.warning("Invalid py3o template %s", filename, - exc_info=1) - logger.warning( - '%s is not a valid Py3o template filename', filename) + logger.warning("Invalid py3o template %s", filename, exc_info=1) + logger.warning("%s is not a valid Py3o template filename", filename) return False @api.multi @@ -125,13 +128,12 @@ class Py3oReport(models.TransientModel): if report_xml.module: # if the default is defined flbk_filename = pkg_resources.resource_filename( - "odoo.addons.%s" % report_xml.module, - tmpl_name, + "odoo.addons.%s" % report_xml.module, tmpl_name ) elif self._is_valid_template_path(tmpl_name): flbk_filename = os.path.realpath(tmpl_name) if self._is_valid_template_filename(flbk_filename): - with open(flbk_filename, 'rb') as tmpl: + with open(flbk_filename, "rb") as tmpl: return tmpl.read() return None @@ -163,19 +165,14 @@ class Py3oReport(models.TransientModel): report_xml = self.ir_actions_report_id if report_xml.py3o_template_id.py3o_template_data: # if a user gave a report template - tmpl_data = b64decode( - report_xml.py3o_template_id.py3o_template_data - ) + tmpl_data = b64decode(report_xml.py3o_template_id.py3o_template_data) else: tmpl_data = self._get_template_fallback(model_instance) if tmpl_data is None: # if for any reason the template is not found - raise TemplateNotFound( - _('No template found. Aborting.'), - sys.exc_info(), - ) + raise TemplateNotFound(_("No template found. Aborting."), sys.exc_info()) return tmpl_data @@ -194,23 +191,20 @@ class Py3oReport(models.TransientModel): def _get_parser_context(self, model_instance, data): report_xml = self.ir_actions_report_id context = Py3oParserContext(self.env).localcontext - context.update( - report_xml._get_rendering_context(model_instance.ids, data) - ) - context['objects'] = model_instance + context.update(report_xml._get_rendering_context(model_instance.ids, data)) + context["objects"] = model_instance self._extend_parser_context(context, report_xml) return context @api.multi def _postprocess_report(self, model_instance, result_path): if len(model_instance) == 1 and self.ir_actions_report_id.attachment: - with open(result_path, 'rb') as f: + with open(result_path, "rb") as f: # we do all the generation process using files to avoid memory # consumption... # ... but odoo wants the whole data in memory anyways :) buffer = BytesIO(f.read()) - self.ir_actions_report_id.postprocess_pdf_report( - model_instance, buffer) + self.ir_actions_report_id.postprocess_pdf_report(model_instance, buffer) return result_path @api.multi @@ -219,23 +213,22 @@ class Py3oReport(models.TransientModel): """ self.ensure_one() result_fd, result_path = tempfile.mkstemp( - suffix='.ods', prefix='p3o.report.tmp.') + suffix=".ods", prefix="p3o.report.tmp." + ) tmpl_data = self.get_template(model_instance) in_stream = BytesIO(tmpl_data) - with closing(os.fdopen(result_fd, 'wb+')) as out_stream: + with closing(os.fdopen(result_fd, "wb+")) as out_stream: template = Template(in_stream, out_stream, escape_false=True) localcontext = self._get_parser_context(model_instance, data) template.render(localcontext) out_stream.seek(0) tmpl_data = out_stream.read() - if self.env.context.get('report_py3o_skip_conversion'): + if self.env.context.get("report_py3o_skip_conversion"): return result_path - result_path = self._convert_single_report( - result_path, model_instance, data - ) + result_path = self._convert_single_report(result_path, model_instance, data) return self._postprocess_report(model_instance, result_path) @@ -243,21 +236,19 @@ class Py3oReport(models.TransientModel): def _convert_single_report(self, result_path, model_instance, data): """Run a command to convert to our target format""" if not self.ir_actions_report_id.is_py3o_native_format: - command = self._convert_single_report_cmd( - result_path, model_instance, data, - ) - logger.debug('Running command %s', command) - output = subprocess.check_output( - command, cwd=os.path.dirname(result_path), - ) - logger.debug('Output was %s', output) + command = self._convert_single_report_cmd(result_path, model_instance, data) + logger.debug("Running command %s", command) + output = subprocess.check_output(command, cwd=os.path.dirname(result_path)) + logger.debug("Output was %s", output) self._cleanup_tempfiles([result_path]) result_path, result_filename = os.path.split(result_path) result_path = os.path.join( - result_path, '%s.%s' % ( + result_path, + "%s.%s" + % ( os.path.splitext(result_filename)[0], - self.ir_actions_report_id.py3o_filetype - ) + self.ir_actions_report_id.py3o_filetype, + ), ) return result_path @@ -267,43 +258,42 @@ class Py3oReport(models.TransientModel): lo_bin = self.ir_actions_report_id.lo_bin_path if not lo_bin: raise RuntimeError( - _("Libreoffice runtime not available. " - "Please contact your administrator.") + _( + "Libreoffice runtime not available. " + "Please contact your administrator." + ) ) return [ lo_bin, - '--headless', - '--convert-to', + "--headless", + "--convert-to", self.ir_actions_report_id.py3o_filetype, result_path, ] @api.multi - def _get_or_create_single_report(self, model_instance, data, - existing_reports_attachment): + def _get_or_create_single_report( + self, model_instance, data, existing_reports_attachment + ): self.ensure_one() - attachment = existing_reports_attachment.get( - model_instance.id) + attachment = existing_reports_attachment.get(model_instance.id) if attachment and self.ir_actions_report_id.attachment_use: content = base64.decodestring(attachment.datas) - report_file = tempfile.mktemp( - "." + self.ir_actions_report_id.py3o_filetype) + report_file = tempfile.mktemp("." + self.ir_actions_report_id.py3o_filetype) with open(report_file, "wb") as f: f.write(content) return report_file - return self._create_single_report( - model_instance, data) + return self._create_single_report(model_instance, data) @api.multi def _zip_results(self, reports_path): self.ensure_one() zfname_prefix = self.ir_actions_report_id.name - result_path = tempfile.mktemp(suffix="zip", prefix='py3o-zip-result') - with ZipFile(result_path, 'w', ZIP_DEFLATED) as zf: + result_path = tempfile.mktemp(suffix="zip", prefix="py3o-zip-result") + with ZipFile(result_path, "w", ZIP_DEFLATED) as zf: cpt = 0 for report in reports_path: - fname = "%s_%d.%s" % ( - zfname_prefix, cpt, report.split('.')[-1]) + fname = "%s_%d.%s" % (zfname_prefix, cpt, report.split(".")[-1]) zf.write(report, fname) cpt += 1 @@ -321,8 +311,9 @@ class Py3oReport(models.TransientModel): reader = PdfFileReader(path) writer.appendPagesFromReader(reader) merged_file_fd, merged_file_path = tempfile.mkstemp( - suffix='.pdf', prefix='report.merged.tmp.') - with closing(os.fdopen(merged_file_fd, 'wb')) as merged_file: + suffix=".pdf", prefix="report.merged.tmp." + ) + with closing(os.fdopen(merged_file_fd, "wb")) as merged_file: writer.write(merged_file) return merged_file_path @@ -337,7 +328,7 @@ class Py3oReport(models.TransientModel): if filetype == formats.FORMAT_PDF: return self._merge_pdf(reports_path), formats.FORMAT_PDF else: - return self._zip_results(reports_path), 'zip' + return self._zip_results(reports_path), "zip" @api.model def _cleanup_tempfiles(self, temporary_files): @@ -346,29 +337,26 @@ class Py3oReport(models.TransientModel): try: os.unlink(temporary_file) except (OSError, IOError): - logger.error( - 'Error when trying to remove file %s' % temporary_file) + logger.error("Error when trying to remove file %s" % temporary_file) @api.multi def create_report(self, res_ids, data): """ Override this function to handle our py3o report """ - model_instances = self.env[self.ir_actions_report_id.model].browse( - res_ids) + model_instances = self.env[self.ir_actions_report_id.model].browse(res_ids) reports_path = [] - if ( - len(res_ids) > 1 and - self.ir_actions_report_id.py3o_multi_in_one): - reports_path.append( - self._create_single_report( - model_instances, data)) + if len(res_ids) > 1 and self.ir_actions_report_id.py3o_multi_in_one: + reports_path.append(self._create_single_report(model_instances, data)) else: - existing_reports_attachment = \ - self.ir_actions_report_id._get_attachments(res_ids) + existing_reports_attachment = self.ir_actions_report_id._get_attachments( + res_ids + ) for model_instance in model_instances: reports_path.append( self._get_or_create_single_report( - model_instance, data, existing_reports_attachment)) + model_instance, data, existing_reports_attachment + ) + ) result_path, filetype = self._merge_results(reports_path) reports_path.append(result_path) @@ -378,7 +366,7 @@ class Py3oReport(models.TransientModel): # consumption... # ... but odoo wants the whole data in memory anyways :) - with open(result_path, 'r+b') as fd: + with open(result_path, "r+b") as fd: res = fd.read() self._cleanup_tempfiles(set(reports_path)) return res, filetype diff --git a/report_py3o/models/py3o_template.py b/report_py3o/models/py3o_template.py index e2a3632b..1f937332 100644 --- a/report_py3o/models/py3o_template.py +++ b/report_py3o/models/py3o_template.py @@ -4,20 +4,21 @@ from odoo import fields, models class Py3oTemplate(models.Model): - _name = 'py3o.template' - _description = 'Py3o template' + _name = "py3o.template" + _description = "Py3o template" name = fields.Char(required=True) py3o_template_data = fields.Binary("LibreOffice Template") filetype = fields.Selection( selection=[ - ('odt', "ODF Text Document"), - ('ods', "ODF Spreadsheet"), - ('odp', "ODF Presentation"), - ('fodt', "ODF Text Document (Flat)"), - ('fods', "ODF Spreadsheet (Flat)"), - ('fodp', "ODF Presentation (Flat)"), + ("odt", "ODF Text Document"), + ("ods", "ODF Spreadsheet"), + ("odp", "ODF Presentation"), + ("fodt", "ODF Text Document (Flat)"), + ("fods", "ODF Spreadsheet (Flat)"), + ("fodp", "ODF Presentation (Flat)"), ], string="LibreOffice Template File Type", required=True, - default='odt') + default="odt", + ) diff --git a/report_py3o/tests/test_report_py3o.py b/report_py3o/tests/test_report_py3o.py index 3c41f599..c2e02b47 100644 --- a/report_py3o/tests/test_report_py3o.py +++ b/report_py3o/tests/test_report_py3o.py @@ -2,39 +2,40 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).). import base64 -from base64 import b64decode -import mock +import logging import os -import pkg_resources import shutil import tempfile +from base64 import b64decode, b64encode from contextlib import contextmanager +import mock +import pkg_resources +from PyPDF2 import PdfFileWriter +from PyPDF2.pdf import PageObject + from odoo import tools -from odoo.tests.common import TransactionCase 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 -from ..models._py3o_parser_context import format_multiline_value -from base64 import b64encode -from PyPDF2 import PdfFileWriter -from PyPDF2.pdf import PageObject -import logging logger = logging.getLogger(__name__) try: from genshi.core import Markup except ImportError: - logger.debug('Cannot import genshi.core') + logger.debug("Cannot import genshi.core") @contextmanager def temporary_copy(path): filname, ext = os.path.splitext(path) - tmp_filename = tempfile.mktemp(suffix='.' + ext) + tmp_filename = tempfile.mktemp(suffix="." + ext) try: shutil.copy2(path, tmp_filename) yield tmp_filename @@ -43,36 +44,35 @@ def temporary_copy(path): class TestReportPy3o(TransactionCase): - def setUp(self): super(TestReportPy3o, self).setUp() self.env.user.image = 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}) + 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.name, - "Field 'Output Format' is required for Py3O report") + e.exception.name, "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 - }) + 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: + 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 + 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.env.user.ids) self.assertEqual(call_count, patched_pdf.call_count) @@ -85,35 +85,35 @@ class TestReportPy3o(TransactionCase): def test_reports_merge_zip(self): self.report.py3o_filetype = "odt" - users = self.env['res.users'].search([]) + users = self.env["res.users"].search([]) self.assertTrue(len(users) > 0) - py3o_report = self.env['py3o.report'] + 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: + py3o_report.__class__, "_zip_results" + ) as patched_zip_results: patched_zip_results.side_effect = _zip_results content, filetype = self.report.render(users.ids) self.assertEqual(1, patched_zip_results.call_count) - self.assertEqual(filetype, 'zip') + self.assertEqual(filetype, "zip") def test_reports_merge_pdf(self): reports_path = [] - for i in range(0, 3): - result = tempfile.mktemp('.txt') + 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: + 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.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([]) + new_attachments = self.env["ir.attachment"].search([]) created_attachement = new_attachments - attachments self.assertEqual(1, len(created_attachement)) content = b64decode(created_attachement.datas) @@ -123,7 +123,7 @@ class TestReportPy3o(TransactionCase): # generated document created_attachement.datas = base64.encodestring(b"new content") res = self.report.render(self.env.user.ids) - self.assertEqual((b'new content', self.report.py3o_filetype), res) + self.assertEqual((b"new content", self.report.py3o_filetype), res) def test_report_post_process(self): """ @@ -131,24 +131,24 @@ class TestReportPy3o(TransactionCase): 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)]) + ir_attachment = self.env["ir.attachment"] + attachements = ir_attachment.search([(1, "=", 1)]) self._render_patched() - attachements = ir_attachment.search([(1, '=', 1)]) - attachements + 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 + ".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)) + self.assertEqual(b"test result", b64decode(attachements.datas)) - @tools.misc.mute_logger('odoo.addons.report_py3o.models.py3o_report') + @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) + "odoo.addons.%s" % self.report.module, tmpl_name + ) self.assertTrue(os.path.exists(flbk_filename)) res = self.report.render(self.env.user.ids) self.assertTrue(res) @@ -164,61 +164,63 @@ class TestReportPy3o(TransactionCase): self.report.render(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.dirname(tmp_filename)} + tools.config.misc["report_py3o"] = { + "root_tmpl_path": os.path.dirname(tmp_filename) + } res = self.report.render(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: + 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'}) + 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.env.user.ids) self.assertTrue(res) - @tools.misc.mute_logger('odoo.addons.report_py3o.models.py3o_report') + @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) + "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.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('.')) + 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)) + 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)) + 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)) + 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)) + 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')) + self.assertFalse(self.py3o_report._get_template_from_path("/etc/test.odt")) def test_escape_html_characters_format_multiline_value(self): - self.assertEqual(Markup('<>&test;'), - format_multiline_value('<>\n&test;')) + self.assertEqual( + Markup("<>&test;"), + format_multiline_value("<>\n&test;"), + ) def test_py3o_report_availability(self): # This test could fails if libreoffice is not available on the server @@ -229,8 +231,9 @@ class TestReportPy3o(TransactionCase): self.assertFalse(self.report.msg_py3o_report_not_available) # specify a wrong lo bin path - self.env['ir.config_parameter'].set_param( - PY3O_CONVERSION_COMMAND_PARAMETER, "/wrong_path") + self.env["ir.config_parameter"].set_param( + PY3O_CONVERSION_COMMAND_PARAMETER, "/wrong_path" + ) self.report.refresh() # no bin path available but the report is still available since # the output is into native format @@ -249,8 +252,9 @@ class TestReportPy3o(TransactionCase): self.report.render(self.env.user.ids) # if we reset the wrong path, everything should work - self.env['ir.config_parameter'].set_param( - PY3O_CONVERSION_COMMAND_PARAMETER, "libreoffice") + self.env["ir.config_parameter"].set_param( + PY3O_CONVERSION_COMMAND_PARAMETER, "libreoffice" + ) self.report.refresh() self.assertTrue(self.report.lo_bin_path) self.assertFalse(self.report.is_py3o_native_format)