OCA reporting engine fork for dev and update.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

363 lines
13 KiB

8 years ago
8 years ago
8 years ago
  1. # -*- coding: utf-8 -*-
  2. # Copyright 2013 XCG Consulting (http://odoo.consulting)
  3. # Copyright 2016 ACSONE SA/NV
  4. # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
  5. import base64
  6. from base64 import b64decode
  7. from cStringIO import StringIO
  8. import json
  9. import logging
  10. import os
  11. from contextlib import closing
  12. import pkg_resources
  13. import requests
  14. import sys
  15. import tempfile
  16. from zipfile import ZipFile, ZIP_DEFLATED
  17. from odoo.exceptions import AccessError
  18. from odoo.exceptions import UserError
  19. from odoo.report.report_sxw import rml_parse
  20. from odoo import api, fields, models, tools, _
  21. logger = logging.getLogger(__name__)
  22. try:
  23. from py3o.template.helpers import Py3oConvertor
  24. from py3o.template import Template
  25. from py3o import formats
  26. except ImportError:
  27. logger.debug('Cannot import py3o.template')
  28. try:
  29. from py3o.formats import Formats, UnkownFormatException
  30. except ImportError:
  31. logger.debug('Cannot import py3o.formats')
  32. _extender_functions = {}
  33. class TemplateNotFound(Exception):
  34. pass
  35. def py3o_report_extender(report_xml_id=None):
  36. """
  37. A decorator to define function to extend the context sent to a template.
  38. This will be called at the creation of the report.
  39. The following arguments will be passed to it:
  40. - ir_report: report instance
  41. - localcontext: The context that will be passed to the report engine
  42. If no report_xml_id is given the extender is registered for all py3o
  43. reports
  44. Idea copied from CampToCamp report_webkit module.
  45. :param report_xml_id: xml id of the report
  46. :return: a decorated class
  47. """
  48. global _extender_functions
  49. def fct1(fct):
  50. _extender_functions.setdefault(report_xml_id, []).append(fct)
  51. return fct
  52. return fct1
  53. @py3o_report_extender()
  54. def defautl_extend(report_xml, localcontext):
  55. # add the base64decode function to be able do decode binary fields into
  56. # the template
  57. localcontext['b64decode'] = b64decode
  58. localcontext['report_xml'] = report_xml
  59. class Py3oReport(models.TransientModel):
  60. _name = "py3o.report"
  61. _inherit = 'report'
  62. _description = "Report Py30"
  63. ir_actions_report_xml_id = fields.Many2one(
  64. comodel_name="ir.actions.report.xml",
  65. required=True
  66. )
  67. @api.multi
  68. def _is_valid_template_path(self, path):
  69. """ Check if the path is a trusted path for py3o templates.
  70. """
  71. real_path = os.path.realpath(path)
  72. root_path = tools.config.get_misc('report_py3o', 'root_tmpl_path')
  73. if not root_path:
  74. logger.warning(
  75. "You must provide a root template path into odoo.cfg to be "
  76. "able to use py3o template configured with an absolute path "
  77. "%s", real_path)
  78. return False
  79. is_valid = real_path.startswith(root_path + os.path.sep)
  80. if not is_valid:
  81. logger.warning(
  82. "Py3o template path is not valid. %s is not a child of root "
  83. "path %s", real_path, root_path)
  84. return is_valid
  85. @api.multi
  86. def _is_valid_template_filename(self, filename):
  87. """ Check if the filename can be used as py3o template
  88. """
  89. if filename and os.path.isfile(filename):
  90. fname, ext = os.path.splitext(filename)
  91. ext = ext.replace('.', '')
  92. try:
  93. fformat = Formats().get_format(ext)
  94. if fformat and fformat.native:
  95. return True
  96. except UnkownFormatException:
  97. logger.warning("Invalid py3o template %s", filename,
  98. exc_info=1)
  99. logger.warning(
  100. '%s is not a valid Py3o template filename', filename)
  101. return False
  102. @api.multi
  103. def _get_template_from_path(self, tmpl_name):
  104. """ Return the template from the path to root of the module if specied
  105. or an absolute path on your server
  106. """
  107. if not tmpl_name:
  108. return None
  109. report_xml = self.ir_actions_report_xml_id
  110. flbk_filename = None
  111. if report_xml.module:
  112. # if the default is defined
  113. flbk_filename = pkg_resources.resource_filename(
  114. "odoo.addons.%s" % report_xml.module,
  115. tmpl_name,
  116. )
  117. elif self._is_valid_template_path(tmpl_name):
  118. flbk_filename = os.path.realpath(tmpl_name)
  119. if self._is_valid_template_filename(flbk_filename):
  120. with open(flbk_filename, 'r') as tmpl:
  121. return tmpl.read()
  122. return None
  123. @api.multi
  124. def _get_template_fallback(self, model_instance):
  125. """
  126. Return the template referenced in the report definition
  127. :return:
  128. """
  129. self.ensure_one()
  130. report_xml = self.ir_actions_report_xml_id
  131. return self._get_template_from_path(report_xml.py3o_template_fallback)
  132. @api.multi
  133. def get_template(self, model_instance):
  134. """private helper to fetch the template data either from the database
  135. or from the default template file provided by the implementer.
  136. ATM this method takes a report definition recordset
  137. to try and fetch the report template from database. If not found it
  138. will fallback to the template file referenced in the report definition.
  139. @returns: string or buffer containing the template data
  140. @raises: TemplateNotFound which is a subclass of
  141. odoo.exceptions.DeferredException
  142. """
  143. self.ensure_one()
  144. report_xml = self.ir_actions_report_xml_id
  145. if report_xml.py3o_template_id and report_xml.py3o_template_id.id:
  146. # if a user gave a report template
  147. tmpl_data = b64decode(
  148. report_xml.py3o_template_id.py3o_template_data
  149. )
  150. else:
  151. tmpl_data = self._get_template_fallback(model_instance)
  152. if tmpl_data is None:
  153. # if for any reason the template is not found
  154. raise TemplateNotFound(
  155. _(u'No template found. Aborting.'),
  156. sys.exc_info(),
  157. )
  158. return tmpl_data
  159. @api.multi
  160. def _extend_parser_context(self, context_instance, report_xml):
  161. # add default extenders
  162. for fct in _extender_functions.get(None, []):
  163. fct(report_xml, context_instance.localcontext)
  164. # add extenders for registered on the template
  165. xml_id = report_xml.get_external_id().get(report_xml.id)
  166. if xml_id in _extender_functions:
  167. for fct in _extender_functions[xml_id]:
  168. fct(report_xml, context_instance.localcontext)
  169. @api.multi
  170. def _get_parser_context(self, model_instance, data):
  171. report_xml = self.ir_actions_report_xml_id
  172. context_instance = rml_parse(self.env.cr, self.env.uid,
  173. report_xml.name,
  174. context=self.env.context)
  175. context_instance.set_context(model_instance, data, model_instance.ids,
  176. report_xml.report_type)
  177. self._extend_parser_context(context_instance, report_xml)
  178. return context_instance.localcontext
  179. @api.model
  180. def _postprocess_report(self, report_path, res_id, save_in_attachment):
  181. if save_in_attachment.get(res_id):
  182. with open(report_path, 'rb') as pdfreport:
  183. attachment = {
  184. 'name': save_in_attachment.get(res_id),
  185. 'datas': base64.encodestring(pdfreport.read()),
  186. 'datas_fname': save_in_attachment.get(res_id),
  187. 'res_model': save_in_attachment.get('model'),
  188. 'res_id': res_id,
  189. }
  190. try:
  191. self.env['ir.attachment'].create(attachment)
  192. except AccessError:
  193. logger.info("Cannot save PDF report %r as attachment",
  194. attachment['name'])
  195. else:
  196. logger.info(
  197. 'The PDF document %s is now saved in the database',
  198. attachment['name'])
  199. @api.multi
  200. def _create_single_report(self, model_instance, data, save_in_attachment):
  201. """ This function to generate our py3o report
  202. """
  203. self.ensure_one()
  204. report_xml = self.ir_actions_report_xml_id
  205. filetype = report_xml.py3o_filetype
  206. result_fd, result_path = tempfile.mkstemp(
  207. suffix='.' + filetype, prefix='p3o.report.tmp.')
  208. tmpl_data = self.get_template(model_instance)
  209. in_stream = StringIO(tmpl_data)
  210. with closing(os.fdopen(result_fd, 'w+')) as out_stream:
  211. template = Template(in_stream, out_stream, escape_false=True)
  212. localcontext = self._get_parser_context(model_instance, data)
  213. is_native = Formats().get_format(filetype).native
  214. if report_xml.py3o_is_local_fusion:
  215. template.render(localcontext)
  216. out_stream.seek(0)
  217. tmpl_data = out_stream.read()
  218. datadict = {}
  219. else:
  220. expressions = template.get_all_user_python_expression()
  221. py_expression = template.convert_py3o_to_python_ast(
  222. expressions)
  223. convertor = Py3oConvertor()
  224. data_struct = convertor(py_expression)
  225. datadict = data_struct.render(localcontext)
  226. if not is_native or not report_xml.py3o_is_local_fusion:
  227. # Call py3o.server to render the template in the desired format
  228. files = {
  229. 'tmpl_file': tmpl_data,
  230. }
  231. fields = {
  232. "targetformat": filetype,
  233. "datadict": json.dumps(datadict),
  234. "image_mapping": "{}",
  235. }
  236. if report_xml.py3o_is_local_fusion:
  237. fields['skipfusion'] = '1'
  238. r = requests.post(
  239. report_xml.py3o_server_id.url, data=fields, files=files)
  240. if r.status_code != 200:
  241. # server says we have an issue... let's tell that to enduser
  242. raise UserError(
  243. _('Fusion server error %s') % r.text,
  244. )
  245. chunk_size = 1024
  246. with open(result_path, 'w+') as fd:
  247. for chunk in r.iter_content(chunk_size):
  248. fd.write(chunk)
  249. self._postprocess_report(
  250. result_path, model_instance.id, save_in_attachment)
  251. return result_path
  252. @api.multi
  253. def _get_or_create_single_report(self, model_instance, data,
  254. save_in_attachment):
  255. self.ensure_one()
  256. if save_in_attachment and save_in_attachment[
  257. 'loaded_documents'].get(model_instance.id):
  258. d = save_in_attachment[
  259. 'loaded_documents'].get(model_instance.id)
  260. return d, self.ir_actions_report_xml_id.py3o_filetype
  261. return self._create_single_report(
  262. model_instance, data, save_in_attachment)
  263. @api.multi
  264. def _zip_results(self, reports_path):
  265. self.ensure_one()
  266. zfname_prefix = self.ir_actions_report_xml_id.name
  267. result_path = tempfile.mktemp(suffix="zip", prefix='py3o-zip-result')
  268. with ZipFile(result_path, 'w', ZIP_DEFLATED) as zf:
  269. cpt = 0
  270. for report in reports_path:
  271. fname = "%s_%d.%s" % (
  272. zfname_prefix, cpt, report.split('.')[-1])
  273. zf.write(report, fname)
  274. cpt += 1
  275. return result_path
  276. @api.multi
  277. def _merge_results(self, reports_path):
  278. self.ensure_one()
  279. filetype = self.ir_actions_report_xml_id.py3o_filetype
  280. if not reports_path:
  281. return False, False
  282. if len(reports_path) == 1:
  283. return reports_path[0], filetype
  284. if filetype == formats.FORMAT_PDF:
  285. return self._merge_pdf(reports_path), formats.FORMAT_PDF
  286. else:
  287. return self._zip_results(reports_path), 'zip'
  288. @api.model
  289. def _cleanup_tempfiles(self, temporary_files):
  290. # Manual cleanup of the temporary files
  291. for temporary_file in temporary_files:
  292. try:
  293. os.unlink(temporary_file)
  294. except (OSError, IOError):
  295. logger.error(
  296. 'Error when trying to remove file %s' % temporary_file)
  297. @api.multi
  298. def create_report(self, res_ids, data):
  299. """ Override this function to handle our py3o report
  300. """
  301. model_instances = self.env[self.ir_actions_report_xml_id.model].browse(
  302. res_ids)
  303. save_in_attachment = self._check_attachment_use(
  304. res_ids, self.ir_actions_report_xml_id) or {}
  305. reports_path = []
  306. for model_instance in model_instances:
  307. reports_path.append(
  308. self._get_or_create_single_report(
  309. model_instance, data, save_in_attachment))
  310. result_path, filetype = self._merge_results(reports_path)
  311. reports_path.append(result_path)
  312. # Here is a little joke about Odoo
  313. # we do all the generation process using files to avoid memory
  314. # consumption...
  315. # ... but odoo wants the whole data in memory anyways :)
  316. with open(result_path, 'r+b') as fd:
  317. res = fd.read()
  318. self._cleanup_tempfiles(set(reports_path))
  319. return res, filetype