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.

372 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. import logging
  6. import os
  7. import subprocess
  8. import sys
  9. import tempfile
  10. from base64 import b64decode
  11. from contextlib import closing
  12. from io import BytesIO
  13. from zipfile import ZIP_DEFLATED, ZipFile
  14. import pkg_resources
  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", required=True
  60. )
  61. @api.multi
  62. def _is_valid_template_path(self, path):
  63. """ Check if the path is a trusted path for py3o templates.
  64. """
  65. real_path = os.path.realpath(path)
  66. root_path = tools.config.get_misc("report_py3o", "root_tmpl_path")
  67. if not root_path:
  68. logger.warning(
  69. "You must provide a root template path into odoo.cfg to be "
  70. "able to use py3o template configured with an absolute path "
  71. "%s",
  72. real_path,
  73. )
  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 " "path %s",
  79. real_path,
  80. root_path,
  81. )
  82. return is_valid
  83. @api.multi
  84. def _is_valid_template_filename(self, filename):
  85. """ Check if the filename can be used as py3o template
  86. """
  87. if filename and os.path.isfile(filename):
  88. fname, ext = os.path.splitext(filename)
  89. ext = ext.replace(".", "")
  90. try:
  91. fformat = Formats().get_format(ext)
  92. if fformat and fformat.native:
  93. return True
  94. except UnkownFormatException:
  95. logger.warning("Invalid py3o template %s", filename, exc_info=1)
  96. logger.warning("%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, 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(report_xml.py3o_template_id.py3o_template_data)
  143. else:
  144. tmpl_data = self._get_template_fallback(model_instance)
  145. if tmpl_data is None:
  146. # if for any reason the template is not found
  147. raise TemplateNotFound(_("No template found. Aborting."), sys.exc_info())
  148. return tmpl_data
  149. @api.multi
  150. def _extend_parser_context(self, context, report_xml):
  151. # add default extenders
  152. for fct in _extender_functions.get(None, []):
  153. fct(report_xml, context)
  154. # add extenders for registered on the template
  155. xml_id = report_xml.get_external_id().get(report_xml.id)
  156. if xml_id in _extender_functions:
  157. for fct in _extender_functions[xml_id]:
  158. fct(report_xml, context)
  159. @api.multi
  160. def _get_parser_context(self, model_instance, data):
  161. report_xml = self.ir_actions_report_id
  162. context = Py3oParserContext(self.env).localcontext
  163. context.update(report_xml._get_rendering_context(model_instance.ids, data))
  164. context["objects"] = model_instance
  165. self._extend_parser_context(context, report_xml)
  166. return context
  167. @api.multi
  168. def _postprocess_report(self, model_instance, result_path):
  169. if len(model_instance) == 1 and self.ir_actions_report_id.attachment:
  170. with open(result_path, "rb") as f:
  171. # we do all the generation process using files to avoid memory
  172. # consumption...
  173. # ... but odoo wants the whole data in memory anyways :)
  174. buffer = BytesIO(f.read())
  175. self.ir_actions_report_id.postprocess_pdf_report(model_instance, buffer)
  176. return result_path
  177. @api.multi
  178. def _create_single_report(self, model_instance, data):
  179. """ This function to generate our py3o report
  180. """
  181. self.ensure_one()
  182. result_fd, result_path = tempfile.mkstemp(
  183. suffix=".ods", prefix="p3o.report.tmp."
  184. )
  185. tmpl_data = self.get_template(model_instance)
  186. in_stream = BytesIO(tmpl_data)
  187. with closing(os.fdopen(result_fd, "wb+")) as out_stream:
  188. template = Template(in_stream, out_stream, escape_false=True)
  189. localcontext = self._get_parser_context(model_instance, data)
  190. template.render(localcontext)
  191. out_stream.seek(0)
  192. tmpl_data = out_stream.read()
  193. if self.env.context.get("report_py3o_skip_conversion"):
  194. return result_path
  195. result_path = self._convert_single_report(result_path, model_instance, data)
  196. return self._postprocess_report(model_instance, result_path)
  197. @api.multi
  198. def _convert_single_report(self, result_path, model_instance, data):
  199. """Run a command to convert to our target format"""
  200. if not self.ir_actions_report_id.is_py3o_native_format:
  201. command = self._convert_single_report_cmd(result_path, model_instance, data)
  202. logger.debug("Running command %s", command)
  203. output = subprocess.check_output(command, cwd=os.path.dirname(result_path))
  204. logger.debug("Output was %s", output)
  205. self._cleanup_tempfiles([result_path])
  206. result_path, result_filename = os.path.split(result_path)
  207. result_path = os.path.join(
  208. result_path,
  209. "%s.%s"
  210. % (
  211. os.path.splitext(result_filename)[0],
  212. self.ir_actions_report_id.py3o_filetype,
  213. ),
  214. )
  215. return result_path
  216. @api.multi
  217. def _convert_single_report_cmd(self, result_path, model_instance, data):
  218. """Return a command list suitable for use in subprocess.call"""
  219. lo_bin = self.ir_actions_report_id.lo_bin_path
  220. if not lo_bin:
  221. raise RuntimeError(
  222. _(
  223. "Libreoffice runtime not available. "
  224. "Please contact your administrator."
  225. )
  226. )
  227. return [
  228. lo_bin,
  229. "--headless",
  230. "--convert-to",
  231. self.ir_actions_report_id.py3o_filetype,
  232. result_path,
  233. ]
  234. @api.multi
  235. def _get_or_create_single_report(
  236. self, model_instance, data, existing_reports_attachment
  237. ):
  238. self.ensure_one()
  239. attachment = existing_reports_attachment.get(model_instance.id)
  240. if attachment and self.ir_actions_report_id.attachment_use:
  241. content = base64.decodestring(attachment.datas)
  242. report_file = tempfile.mktemp("." + self.ir_actions_report_id.py3o_filetype)
  243. with open(report_file, "wb") as f:
  244. f.write(content)
  245. return report_file
  246. return self._create_single_report(model_instance, data)
  247. @api.multi
  248. def _zip_results(self, reports_path):
  249. self.ensure_one()
  250. zfname_prefix = self.ir_actions_report_id.name
  251. result_path = tempfile.mktemp(suffix="zip", prefix="py3o-zip-result")
  252. with ZipFile(result_path, "w", ZIP_DEFLATED) as zf:
  253. cpt = 0
  254. for report in reports_path:
  255. fname = "%s_%d.%s" % (zfname_prefix, cpt, report.split(".")[-1])
  256. zf.write(report, fname)
  257. cpt += 1
  258. return result_path
  259. @api.model
  260. def _merge_pdf(self, reports_path):
  261. """ Merge PDF files into one.
  262. :param reports_path: list of path of pdf files
  263. :returns: path of the merged pdf
  264. """
  265. writer = PdfFileWriter()
  266. for path in reports_path:
  267. reader = PdfFileReader(path)
  268. writer.appendPagesFromReader(reader)
  269. merged_file_fd, merged_file_path = tempfile.mkstemp(
  270. suffix=".pdf", prefix="report.merged.tmp."
  271. )
  272. with closing(os.fdopen(merged_file_fd, "wb")) as merged_file:
  273. writer.write(merged_file)
  274. return merged_file_path
  275. @api.multi
  276. def _merge_results(self, reports_path):
  277. self.ensure_one()
  278. filetype = self.ir_actions_report_id.py3o_filetype
  279. if not reports_path:
  280. return False, False
  281. if len(reports_path) == 1:
  282. return reports_path[0], filetype
  283. if filetype == formats.FORMAT_PDF:
  284. return self._merge_pdf(reports_path), formats.FORMAT_PDF
  285. else:
  286. return self._zip_results(reports_path), "zip"
  287. @api.model
  288. def _cleanup_tempfiles(self, temporary_files):
  289. # Manual cleanup of the temporary files
  290. for temporary_file in temporary_files:
  291. try:
  292. os.unlink(temporary_file)
  293. except (OSError, IOError):
  294. logger.error("Error when trying to remove file %s" % temporary_file)
  295. @api.multi
  296. def create_report(self, res_ids, data):
  297. """ Override this function to handle our py3o report
  298. """
  299. model_instances = self.env[self.ir_actions_report_id.model].browse(res_ids)
  300. reports_path = []
  301. if len(res_ids) > 1 and self.ir_actions_report_id.py3o_multi_in_one:
  302. reports_path.append(self._create_single_report(model_instances, data))
  303. else:
  304. existing_reports_attachment = self.ir_actions_report_id._get_attachments(
  305. res_ids
  306. )
  307. for model_instance in model_instances:
  308. reports_path.append(
  309. self._get_or_create_single_report(
  310. model_instance, data, existing_reports_attachment
  311. )
  312. )
  313. result_path, filetype = self._merge_results(reports_path)
  314. reports_path.append(result_path)
  315. # Here is a little joke about Odoo
  316. # we do all the generation process using files to avoid memory
  317. # consumption...
  318. # ... but odoo wants the whole data in memory anyways :)
  319. with open(result_path, "r+b") as fd:
  320. res = fd.read()
  321. self._cleanup_tempfiles(set(reports_path))
  322. return res, filetype