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.

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