diff --git a/.travis.yml b/.travis.yml index 75e8bc4c09..fd7bbfc6ed 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,7 +14,7 @@ python: env: global: - - VERSION="9.0" TESTS="0" LINT_CHECK="0" TRANSIFEX="0" + - VERSION="10.0" TESTS="0" LINT_CHECK="0" TRANSIFEX="0" - TRANSIFEX_USER='transbot@odoo-community.org' - secure: NUsXwVrMntcqge1ozKW+DSkP7dq+Rla6JVvFF2c89/g+zJaIqQRi8EQBLoqNwCdMk+rjpQeZt/JPELjH+EzPcmGddhDxOgVB3nUT9LvFXGCHF+NjmHXqyba4tuc7BnpG1WDD+rSlxVCt1aIjNIhhaZ4ic0rCWpKNYu/yFTsmChc= matrix: diff --git a/report_py3o/models/__init__.py b/report_py3o/models/__init__.py index a8e7b0a69c..425cb3d9f0 100644 --- a/report_py3o/models/__init__.py +++ b/report_py3o/models/__init__.py @@ -1,3 +1,4 @@ from . import ir_actions_report_xml from . import py3o_template from . import py3o_server +from . import py3o_report diff --git a/report_py3o/models/ir_actions_report_xml.py b/report_py3o/models/ir_actions_report_xml.py index 3a290f1de4..cfbfeb41fc 100644 --- a/report_py3o/models/ir_actions_report_xml.py +++ b/report_py3o/models/ir_actions_report_xml.py @@ -1,13 +1,9 @@ # -*- coding: utf-8 -*- # Copyright 2013 XCG Consulting (http://odoo.consulting) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -import os import logging from odoo import api, fields, models, _ -from odoo.report.interface import report_int from odoo.exceptions import ValidationError -from odoo import addons -from ..py3o_parser import Py3oParser logger = logging.getLogger(__name__) @@ -85,43 +81,14 @@ def _get_py3o_filetypes(self): )) report_type = fields.Selection(selection_add=[('py3o', "Py3o")]) - @api.model_cr - def _lookup_report(self, name): - """Look up a report definition. - """ - # START section copied from odoo/addons/base/ir/ir_actions.py - # with small adaptations - # First lookup in the deprecated place, because if the report - # definition has not been updated, it is more likely the correct - # definition is there. Only reports with custom parser - # specified in Python are still there. - if 'report.' + name in report_int._reports: - new_report = report_int._reports['report.' + name] - if not isinstance(new_report, Py3oParser): - new_report = None - else: - self._cr.execute( - "SELECT * FROM ir_act_report_xml " - "WHERE report_name=%s AND report_type=%s", (name, 'py3o')) - report_data = self._cr.dictfetchone() - # END section copied from odoo/addons/base/ir/ir_actions.py - if report_data: - kwargs = {} - if report_data['parser']: - kwargs['parser'] = getattr(addons, report_data['parser']) - - new_report = Py3oParser( - 'report.' + report_data['report_name'], - report_data['model'], - os.path.join('addons', report_data['report_rml'] or '/'), - header=report_data['header'], - register=False, - **kwargs - ) - else: - new_report = None - - if new_report: - return new_report - else: - return super(IrActionsReportXml, self)._lookup_report(name) + @api.model + def render_report(self, res_ids, name, data): + action_py3o_report = self.search( + [("report_name", "=", name), + ("report_type", "=", "py3o")]) + if action_py3o_report: + return self.env['py3o.report'].create({ + 'ir_actions_report_xml_id': action_py3o_report.id + }).create_report(res_ids, data) + return super(IrActionsReportXml, self).render_report( + res_ids, name, data) diff --git a/report_py3o/models/py3o_report.py b/report_py3o/models/py3o_report.py new file mode 100644 index 0000000000..ccebbcb806 --- /dev/null +++ b/report_py3o/models/py3o_report.py @@ -0,0 +1,342 @@ +# -*- coding: utf-8 -*- +# Copyright 2013 XCG Consulting (http://odoo.consulting) +# Copyright 2016 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +import base64 +from base64 import b64decode +from cStringIO import StringIO +import json +import logging +import os +from contextlib import closing + +import pkg_resources +import requests +import sys +import tempfile +from zipfile import ZipFile, ZIP_DEFLATED + +from odoo.exceptions import AccessError +from odoo.exceptions import UserError +from odoo.report.report_sxw import rml_parse +from odoo import api, fields, models, _ + +logger = logging.getLogger(__name__) + +try: + from py3o.template.helpers import Py3oConvertor + from py3o.template import Template + from py3o import formats +except ImportError: + logger.debug('Cannot import py3o.template') +try: + from py3o.formats import Formats +except ImportError: + logger.debug('Cannot import py3o.formats') + + +_extender_functions = {} + + +class TemplateNotFound(Exception): + pass + + +def py3o_report_extender(report_xml_id=None): + """ + A decorator to define function to extend the context sent to a template. + This will be called at the creation of the report. + The following arguments will be passed to it: + - ir_report: report instance + - localcontext: The context that will be passed to the report engine + If no report_xml_id is given the extender is registered for all py3o + reports + Idea copied from CampToCamp report_webkit module. + + :param report_xml_id: xml id of the report + :return: a decorated class + """ + global _extender_functions + + def fct1(fct): + _extender_functions.setdefault(report_xml_id, []).append(fct) + return fct + return fct1 + + +@py3o_report_extender() +def defautl_extend(report_xml, localcontext): + # add the base64decode function to be able do decode binary fields into + # the template + localcontext['b64decode'] = b64decode + localcontext['report_xml'] = report_xml + + +class Py3oReport(models.TransientModel): + _name = "py3o.report" + _inherit = 'report' + _description = "Report Py30" + + ir_actions_report_xml_id = fields.Many2one( + comodel_name="ir.actions.report.xml", + required=True + ) + + @api.multi + def _get_template_from_path(self, tmpl_name): + """"Return the template from the path to root of the module if specied + or an absolute path on your server + """ + if not tmpl_name: + return None + report_xml = self.ir_actions_report_xml_id + flbk_filename = None + if report_xml.module: + # if the default is defined + flbk_filename = pkg_resources.resource_filename( + "odoo.addons.%s" % report_xml.module, + tmpl_name, + ) + elif os.path.isabs(tmpl_name): + # It is an absolute path + flbk_filename = os.path.normcase(os.path.normpath(tmpl_name)) + if flbk_filename and os.path.exists(flbk_filename): + # and it exists on the fileystem + with open(flbk_filename, 'r') as tmpl: + return tmpl.read() + return None + + @api.multi + def _get_template_fallback(self, model_instance): + """ + Return the template referenced in the report definition + :return: + """ + self.ensure_one() + report_xml = self.ir_actions_report_xml_id + return self._get_template_from_path(report_xml.py3o_template_fallback) + + @api.multi + def get_template(self, model_instance): + """private helper to fetch the template data either from the database + or from the default template file provided by the implementer. + + ATM this method takes a report definition recordset + to try and fetch the report template from database. If not found it + will fallback to the template file referenced in the report definition. + + @returns: string or buffer containing the template data + + @raises: TemplateNotFound which is a subclass of + odoo.exceptions.DeferredException + """ + self.ensure_one() + report_xml = self.ir_actions_report_xml_id + if report_xml.py3o_template_id and report_xml.py3o_template_id.id: + # if a user gave a report template + 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( + _(u'No template found. Aborting.'), + sys.exc_info(), + ) + + return tmpl_data + + @api.multi + def _extend_parser_context(self, context_instance, report_xml): + # add default extenders + for fct in _extender_functions.get(None, []): + fct(report_xml, context_instance.localcontext) + # add extenders for registered on the template + xml_id = report_xml.get_external_id().get(report_xml.id) + if xml_id in _extender_functions: + for fct in _extender_functions[xml_id]: + fct(report_xml, context_instance.localcontext) + + @api.multi + def _get_parser_context(self, model_instance, data): + report_xml = self.ir_actions_report_xml_id + context_instance = rml_parse(self.env.cr, self.env.uid, + report_xml.name, + context=self.env.context) + context_instance.set_context(model_instance, data, model_instance.ids, + report_xml.report_type) + self._extend_parser_context(context_instance, report_xml) + return context_instance.localcontext + + @api.model + def _get_report_from_name(self, report_name): + """Get the first record of ir.actions.report.xml having the + ``report_name`` as value for the field report_name. + """ + res = super(Py3oReport, self)._get_report_from_name(report_name) + if res: + return res + # maybe a py3o reprot + report_obj = self.env['ir.actions.report.xml'] + return report_obj.search( + [('report_type', '=', 'py3o'), + ('report_name', '=', report_name)]) + + @api.model + def _postprocess_report(self, report_path, res_id, save_in_attachment): + if save_in_attachment.get(res_id): + with open(report_path, 'rb') as pdfreport: + attachment = { + 'name': save_in_attachment.get(res_id), + 'datas': base64.encodestring(pdfreport.read()), + 'datas_fname': save_in_attachment.get(res_id), + 'res_model': save_in_attachment.get('model'), + 'res_id': res_id, + } + try: + self.env['ir.attachment'].create(attachment) + except AccessError: + logger.info("Cannot save PDF report %r as attachment", + attachment['name']) + else: + logger.info( + 'The PDF document %s is now saved in the database', + attachment['name']) + + @api.multi + def _create_single_report(self, model_instance, data, save_in_attachment): + """ This function to generate our py3o report + """ + self.ensure_one() + report_xml = self.ir_actions_report_xml_id + filetype = report_xml.py3o_filetype + result_fd, result_path = tempfile.mkstemp( + suffix='.' + filetype, prefix='p3o.report.tmp.') + tmpl_data = self.get_template(model_instance) + + in_stream = StringIO(tmpl_data) + with closing(os.fdopen(result_fd, 'w+')) as out_stream: + template = Template(in_stream, out_stream, escape_false=True) + localcontext = self._get_parser_context(model_instance, data) + is_native = Formats().get_format(filetype).native + if report_xml.py3o_is_local_fusion: + template.render(localcontext) + out_stream.seek(0) + in_stream = out_stream.read() + datadict = {} + else: + expressions = template.get_all_user_python_expression() + py_expression = template.convert_py3o_to_python_ast( + expressions) + convertor = Py3oConvertor() + data_struct = convertor(py_expression) + datadict = data_struct.render(localcontext) + + if not is_native or not report_xml.py3o_is_local_fusion: + # Call py3o.server to render the template in the desired format + files = { + 'tmpl_file': in_stream, + } + fields = { + "targetformat": filetype, + "datadict": json.dumps(datadict), + "image_mapping": "{}", + } + if report_xml.py3o_is_local_fusion: + fields['skipfusion'] = '1' + r = requests.post( + report_xml.py3o_server_id.url, data=fields, files=files) + if r.status_code != 200: + # server says we have an issue... let's tell that to enduser + raise UserError( + _('Fusion server error %s') % r.text, + ) + + chunk_size = 1024 + with open(result_path, 'w+') as fd: + for chunk in r.iter_content(chunk_size): + fd.write(chunk) + self._postprocess_report( + result_path, model_instance.id, save_in_attachment) + return result_path + + @api.multi + def _get_or_create_single_report(self, model_instance, data, + save_in_attachment): + self.ensure_one() + if save_in_attachment and save_in_attachment[ + 'loaded_documents'].get(model_instance.id): + d = save_in_attachment[ + 'loaded_documents'].get(model_instance.id) + return d, self.ir_actions_report_xml_id.py3o_filetype + return self._create_single_report( + model_instance, data, save_in_attachment) + + @api.multi + def _zip_results(self, reports_path): + self.ensure_one() + zfname_prefix = self.ir_actions_report_xml_id.name + 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]) + zf.write(report, fname) + + cpt += 1 + return result_path + + @api.multi + def _merge_results(self, reports_path): + self.ensure_one() + filetype = self.ir_actions_report_xml_id.py3o_filetype + if not reports_path: + return False, False + if len(reports_path) == 1: + return reports_path[0], filetype + if filetype == formats.FORMAT_PDF: + return self._merge_pdf(reports_path), formats.FORMAT_PDF + else: + return self._zip_results(reports_path), 'zip' + + @api.model + def _cleanup_tempfiles(self, temporary_files): + # Manual cleanup of the temporary files + for temporary_file in temporary_files: + try: + os.unlink(temporary_file) + except (OSError, IOError): + 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_xml_id.model].browse( + res_ids) + save_in_attachment = self._check_attachment_use( + model_instances, self.ir_actions_report_xml_id) or {} + reports_path = [] + for model_instance in model_instances: + reports_path.append( + self._get_or_create_single_report( + model_instance, data, save_in_attachment)) + + result_path, filetype = self._merge_results(reports_path) + reports_path.append(result_path) + + # Here is a little joke about Odoo + # we do all the generation process using files to avoid memory + # consumption... + # ... but odoo wants the whole data in memory anyways :) + + 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/py3o_parser.py b/report_py3o/py3o_parser.py deleted file mode 100644 index 4fc1e90d67..0000000000 --- a/report_py3o/py3o_parser.py +++ /dev/null @@ -1,220 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2013 XCG Consulting (http://odoo.consulting) -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) -from cStringIO import StringIO -import json -import pkg_resources -import os -import sys -from base64 import b64decode -import requests -from tempfile import NamedTemporaryFile -from odoo import api, _ -from odoo import exceptions -from odoo.report.report_sxw import report_sxw -import logging - -logger = logging.getLogger(__name__) - -try: - from py3o.template.helpers import Py3oConvertor - from py3o.template import Template -except ImportError: - logger.debug('Cannot import py3o.template') -try: - from py3o.formats import Formats -except ImportError: - logger.debug('Cannot import py3o.formats') - - -_extender_functions = {} - - -class TemplateNotFound(Exception): - pass - - -def py3o_report_extender(report_xml_id=None): - """ - A decorator to define function to extend the context sent to a template. - This will be called at the creation of the report. - The following arguments will be passed to it: - - ir_report: report instance - - localcontext: The context that will be passed to the report engine - If no report_xml_id is given the extender is registered for all py3o - reports - Idea copied from CampToCamp report_webkit module. - - :param report_xml_id: xml id of the report - :return: a decorated class - """ - global _extender_functions - - def fct1(fct): - _extender_functions.setdefault(report_xml_id, []).append(fct) - return fct - return fct1 - - -@py3o_report_extender() -def defautl_extend(report_xml, localcontext): - # add the base64decode function to be able do decode binary fields into - # the template - localcontext['b64decode'] = b64decode - localcontext['report_xml'] = report_xml - - -class Py3oParser(report_sxw): - """Custom class that use Py3o to render libroffice reports. - Code partially taken from CampToCamp's webkit_report.""" - - def get_template(self, report_obj): - """private helper to fetch the template data either from the database - or from the default template file provided by the implementer. - - ATM this method takes a report definition recordset - to try and fetch the report template from database. If not found it - will fallback to the template file referenced in the report definition. - - @param report_obj: a recordset representing the report defintion - @type report_obj: odoo.model.recordset instance - - @returns: string or buffer containing the template data - - @raises: TemplateNotFound which is a subclass of - odoo.exceptions.DeferredException - """ - - tmpl_data = None - - if report_obj.py3o_template_id and report_obj.py3o_template_id.id: - # if a user gave a report template - tmpl_data = b64decode( - report_obj.py3o_template_id.py3o_template_data - ) - - elif report_obj.py3o_template_fallback: - tmpl_name = report_obj.py3o_template_fallback - flbk_filename = None - if report_obj.module: - # if the default is defined - flbk_filename = pkg_resources.resource_filename( - "odoo.addons.%s" % report_obj.module, - tmpl_name, - ) - elif os.path.isabs(tmpl_name): - # It is an absolute path - flbk_filename = os.path.normcase(os.path.normpath(tmpl_name)) - if flbk_filename and os.path.exists(flbk_filename): - # and it exists on the fileystem - with open(flbk_filename, 'r') as tmpl: - tmpl_data = tmpl.read() - - if tmpl_data is None: - # if for any reason the template is not found - raise TemplateNotFound( - _(u'No template found. Aborting.'), - sys.exc_info(), - ) - - return tmpl_data - - def _extend_parser_context(self, parser_instance, report_xml): - # add default extenders - for fct in _extender_functions.get(None, []): - fct(report_xml, parser_instance.localcontext) - # add extenders for registered on the template - xml_id = report_xml.get_external_id().get(report_xml.id) - if xml_id in _extender_functions: - for fct in _extender_functions[xml_id]: - fct(report_xml, parser_instance.localcontext) - - def create_single_pdf(self, cr, uid, ids, data, report_xml, context=None): - """ Overide this function to generate our py3o report - """ - if report_xml.report_type != 'py3o': - return super(Py3oParser, self).create_single_pdf( - cr, uid, ids, data, report_xml, context=context - ) - - parser_instance = self.parser(cr, uid, self.name2, context=context) - parser_instance.set_context( - self.getObjects(cr, uid, ids, context), - data, ids, report_xml.report_type - ) - self._extend_parser_context(parser_instance, report_xml) - - tmpl_data = self.get_template(report_xml) - - in_stream = StringIO(tmpl_data) - out_stream = StringIO() - template = Template(in_stream, out_stream) - localcontext = parser_instance.localcontext - if report_xml.py3o_is_local_fusion: - template.render(localcontext) - in_stream = out_stream - datadict = {} - else: - expressions = template.get_all_user_python_expression() - py_expression = template.convert_py3o_to_python_ast(expressions) - convertor = Py3oConvertor() - data_struct = convertor(py_expression) - datadict = data_struct.render(localcontext) - - filetype = report_xml.py3o_filetype - is_native = Formats().get_format(filetype).native - if is_native: - res = out_stream.getvalue() - else: # Call py3o.server to render the template in the desired format - in_stream.seek(0) - files = { - 'tmpl_file': in_stream, - } - fields = { - "targetformat": filetype, - "datadict": json.dumps(datadict), - "image_mapping": "{}", - } - if report_xml.py3o_is_local_fusion: - fields['skipfusion'] = '1' - r = requests.post( - report_xml.py3o_server_id.url, data=fields, files=files) - if r.status_code != 200: - # server says we have an issue... let's tell that to enduser - raise exceptions.Warning( - _('Fusion server error %s') % r.text, - ) - - # Here is a little joke about Odoo - # we do nice chunked reading from the network... - chunk_size = 1024 - with NamedTemporaryFile( - suffix=filetype, - prefix='py3o-template-' - ) as fd: - for chunk in r.iter_content(chunk_size): - fd.write(chunk) - fd.seek(0) - # ... but odoo wants the whole data in memory anyways :) - res = fd.read() - - return res, filetype - - def create(self, cr, uid, ids, data, context=None): - """ Override this function to handle our py3o report - """ - env = api.Environment(cr, uid, context) - report_xmls = env['ir.actions.report.xml'].search( - [('report_name', '=', self.name[7:])]) - if not report_xmls: - return super(Py3oParser, self).create( - cr, uid, ids, data, context=context - ) - - result = self.create_source_pdf( - cr, uid, ids, data, report_xmls[0], context - ) - - if not result: - return False, False - return result diff --git a/report_py3o/tests/test_report_py3o.py b/report_py3o/tests/test_report_py3o.py index 18438c34a5..70e4a51398 100644 --- a/report_py3o/tests/test_report_py3o.py +++ b/report_py3o/tests/test_report_py3o.py @@ -5,13 +5,14 @@ import mock import os import pkg_resources +import tempfile from py3o.formats import Formats from odoo.tests.common import TransactionCase from odoo.exceptions import ValidationError -from ..py3o_parser import TemplateNotFound +from ..models.py3o_report import TemplateNotFound from base64 import b64encode @@ -56,14 +57,21 @@ def test_required_py3_filetype(self): "Field 'Output Format' is required for Py3O report") def test_reports(self): + py3o_report = self.env['py3o.report'] report = self.env.ref("report_py3o.res_users_report_py3o") - with mock.patch('odoo.addons.report_py3o.py3o_parser.' - 'Py3oParser.create_single_pdf') as patched_pdf: + 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('dummy') + patched_pdf.return_value = result # test the call the the create method inside our custom parser report.render_report(self.env.user.ids, report.report_name, {}) self.assertEqual(1, patched_pdf.call_count) + # generated files no more exists + self.assertFalse(os.path.exists(result)) res = report.render_report( self.env.user.ids, report.report_name, {}) self.assertTrue(res) @@ -98,7 +106,7 @@ def test_report_template_configs(self): report.render_report( self.env.user.ids, report.report_name, {}) - # the template can also be provivided as an abspaath + # the template can also be provided as an abspaath report.py3o_template_fallback = flbk_filename res = report.render_report( self.env.user.ids, report.report_name, {})