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. 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. from genshi.core import Markup
  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