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.

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