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.

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