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.

370 lines
13 KiB

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