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.

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