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.

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