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. from contextlib import closing
  11. import subprocess
  12. import pkg_resources
  13. import sys
  14. import tempfile
  15. from zipfile import ZipFile, ZIP_DEFLATED
  16. from odoo.exceptions import AccessError
  17. from odoo.report.report_sxw import rml_parse
  18. from odoo import api, fields, models, tools, _
  19. logger = logging.getLogger(__name__)
  20. try:
  21. from py3o.template import Template
  22. from py3o import formats
  23. from genshi.core import Markup
  24. except ImportError:
  25. logger.debug('Cannot import py3o.template')
  26. try:
  27. from py3o.formats import Formats, UnkownFormatException
  28. except ImportError:
  29. logger.debug('Cannot import py3o.formats')
  30. _extender_functions = {}
  31. class TemplateNotFound(Exception):
  32. pass
  33. def py3o_report_extender(report_xml_id=None):
  34. """
  35. A decorator to define function to extend the context sent to a template.
  36. This will be called at the creation of the report.
  37. The following arguments will be passed to it:
  38. - ir_report: report instance
  39. - localcontext: The context that will be passed to the report engine
  40. If no report_xml_id is given the extender is registered for all py3o
  41. reports
  42. Idea copied from CampToCamp report_webkit module.
  43. :param report_xml_id: xml id of the report
  44. :return: a decorated class
  45. """
  46. global _extender_functions
  47. def fct1(fct):
  48. _extender_functions.setdefault(report_xml_id, []).append(fct)
  49. return fct
  50. return fct1
  51. def format_multiline_value(value):
  52. if value:
  53. return Markup(value.replace('<', '&lt;').replace('>', '&gt;').
  54. 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