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 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. result_path = self._convert_single_report(
  221. result_path, model_instance, data
  222. )
  223. if len(model_instance) == 1:
  224. self._postprocess_report(
  225. result_path, model_instance.id, save_in_attachment)
  226. return result_path
  227. @api.multi
  228. def _convert_single_report(self, result_path, model_instance, data):
  229. """Run a command to convert to our target format"""
  230. filetype = self.ir_actions_report_xml_id.py3o_filetype
  231. if not Formats().get_format(filetype).native:
  232. command = self._convert_single_report_cmd(
  233. result_path, model_instance, data,
  234. )
  235. logger.debug('Running command %s', command)
  236. output = subprocess.check_output(
  237. command, cwd=os.path.dirname(result_path),
  238. )
  239. logger.debug('Output was %s', output)
  240. self._cleanup_tempfiles([result_path])
  241. result_path, result_filename = os.path.split(result_path)
  242. result_path = os.path.join(
  243. result_path, '%s.%s' % (
  244. os.path.splitext(result_filename)[0], filetype
  245. )
  246. )
  247. return result_path
  248. @api.multi
  249. def _convert_single_report_cmd(self, result_path, model_instance, data):
  250. """Return a command list suitable for use in subprocess.call"""
  251. return [
  252. self.env['ir.config_parameter'].get_param(
  253. 'py3o.conversion_command', 'libreoffice',
  254. ),
  255. '--headless',
  256. '--convert-to',
  257. self.ir_actions_report_xml_id.py3o_filetype,
  258. result_path,
  259. ]
  260. @api.multi
  261. def _get_or_create_single_report(self, model_instance, data,
  262. save_in_attachment):
  263. self.ensure_one()
  264. if save_in_attachment and save_in_attachment[
  265. 'loaded_documents'].get(model_instance.id):
  266. d = save_in_attachment[
  267. 'loaded_documents'].get(model_instance.id)
  268. report_file = tempfile.mktemp(
  269. "." + self.ir_actions_report_xml_id.py3o_filetype)
  270. with open(report_file, "wb") as f:
  271. f.write(d)
  272. return report_file
  273. return self._create_single_report(
  274. model_instance, data, save_in_attachment)
  275. @api.multi
  276. def _zip_results(self, reports_path):
  277. self.ensure_one()
  278. zfname_prefix = self.ir_actions_report_xml_id.name
  279. result_path = tempfile.mktemp(suffix="zip", prefix='py3o-zip-result')
  280. with ZipFile(result_path, 'w', ZIP_DEFLATED) as zf:
  281. cpt = 0
  282. for report in reports_path:
  283. fname = "%s_%d.%s" % (
  284. zfname_prefix, cpt, report.split('.')[-1])
  285. zf.write(report, fname)
  286. cpt += 1
  287. return result_path
  288. @api.multi
  289. def _merge_results(self, reports_path):
  290. self.ensure_one()
  291. filetype = self.ir_actions_report_xml_id.py3o_filetype
  292. if not reports_path:
  293. return False, False
  294. if len(reports_path) == 1:
  295. return reports_path[0], filetype
  296. if filetype == formats.FORMAT_PDF:
  297. return self._merge_pdf(reports_path), formats.FORMAT_PDF
  298. else:
  299. return self._zip_results(reports_path), 'zip'
  300. @api.model
  301. def _cleanup_tempfiles(self, temporary_files):
  302. # Manual cleanup of the temporary files
  303. for temporary_file in temporary_files:
  304. try:
  305. os.unlink(temporary_file)
  306. except (OSError, IOError):
  307. logger.error(
  308. 'Error when trying to remove file %s' % temporary_file)
  309. @api.multi
  310. def create_report(self, res_ids, data):
  311. """ Override this function to handle our py3o report
  312. """
  313. model_instances = self.env[self.ir_actions_report_xml_id.model].browse(
  314. res_ids)
  315. save_in_attachment = self._check_attachment_use(
  316. res_ids, self.ir_actions_report_xml_id) or {}
  317. reports_path = []
  318. if (
  319. len(res_ids) > 1 and
  320. self.ir_actions_report_xml_id.py3o_multi_in_one):
  321. reports_path.append(
  322. self._create_single_report(
  323. model_instances, data, save_in_attachment))
  324. else:
  325. for model_instance in model_instances:
  326. reports_path.append(
  327. self._get_or_create_single_report(
  328. model_instance, data, save_in_attachment))
  329. result_path, filetype = self._merge_results(reports_path)
  330. reports_path.append(result_path)
  331. # Here is a little joke about Odoo
  332. # we do all the generation process using files to avoid memory
  333. # consumption...
  334. # ... but odoo wants the whole data in memory anyways :)
  335. with open(result_path, 'r+b') as fd:
  336. res = fd.read()
  337. self._cleanup_tempfiles(set(reports_path))
  338. return res, filetype