[FIX] report: do not fail if PDF cannot be saved as attachment due to AccessError
[odoo/odoo.git] / addons / report / models / report.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2014-Today OpenERP SA (<http://www.openerp.com>).
6 #
7 #    This program is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU Affero General Public License as
9 #    published by the Free Software Foundation, either version 3 of the
10 #    License, or (at your option) any later version.
11 #
12 #    This program is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU Affero General Public License for more details.
16 #
17 #    You should have received a copy of the GNU Affero General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
19 #
20 ##############################################################################
21
22 from openerp import api
23 from openerp.exceptions import AccessError
24 from openerp.osv import osv
25 from openerp.tools import config, which
26 from openerp.tools.translate import _
27 from openerp.addons.web.http import request
28 from openerp.tools.safe_eval import safe_eval as eval
29
30 import re
31 import time
32 import base64
33 import logging
34 import tempfile
35 import lxml.html
36 import os
37 import subprocess
38 from contextlib import closing
39 from distutils.version import LooseVersion
40 from functools import partial
41 from pyPdf import PdfFileWriter, PdfFileReader
42
43
44 #--------------------------------------------------------------------------
45 # Helpers
46 #--------------------------------------------------------------------------
47 _logger = logging.getLogger(__name__)
48
49 def _get_wkhtmltopdf_bin():
50     defpath = os.environ.get('PATH', os.defpath).split(os.pathsep)
51     return which('wkhtmltopdf', path=os.pathsep.join(defpath))
52
53
54 #--------------------------------------------------------------------------
55 # Check the presence of Wkhtmltopdf and return its version at Odoo start-up
56 #--------------------------------------------------------------------------
57 wkhtmltopdf_state = 'install'
58 try:
59     process = subprocess.Popen(
60         [_get_wkhtmltopdf_bin(), '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE
61     )
62 except (OSError, IOError):
63     _logger.info('You need Wkhtmltopdf to print a pdf version of the reports.')
64 else:
65     _logger.info('Will use the Wkhtmltopdf binary at %s' % _get_wkhtmltopdf_bin())
66     out, err = process.communicate()
67     version = re.search('([0-9.]+)', out).group(0)
68     if LooseVersion(version) < LooseVersion('0.12.0'):
69         _logger.info('Upgrade Wkhtmltopdf to (at least) 0.12.0')
70         wkhtmltopdf_state = 'upgrade'
71     else:
72         wkhtmltopdf_state = 'ok'
73
74     if config['workers'] == 1:
75         _logger.info('You need to start Odoo with at least two workers to print a pdf version of the reports.')
76         wkhtmltopdf_state = 'workers'
77
78
79 class Report(osv.Model):
80     _name = "report"
81     _description = "Report"
82
83     public_user = None
84
85     #--------------------------------------------------------------------------
86     # Extension of ir_ui_view.render with arguments frequently used in reports
87     #--------------------------------------------------------------------------
88     def render(self, cr, uid, ids, template, values=None, context=None):
89         """Allow to render a QWeb template python-side. This function returns the 'ir.ui.view'
90         render but embellish it with some variables/methods used in reports.
91
92         :param values: additionnal methods/variables used in the rendering
93         :returns: html representation of the template
94         """
95         if values is None:
96             values = {}
97
98         if context is None:
99             context = {}
100
101         context.update(inherit_branding=True)  # Tell QWeb to brand the generated html
102
103         view_obj = self.pool['ir.ui.view']
104
105         def translate_doc(doc_id, model, lang_field, template):
106             """Helper used when a report should be translated into a specific lang.
107
108             <t t-foreach="doc_ids" t-as="doc_id">
109             <t t-raw="translate_doc(doc_id, doc_model, 'partner_id.lang', account.report_invoice_document')"/>
110             </t>
111
112             :param doc_id: id of the record to translate
113             :param model: model of the record to translate
114             :param lang_field': field of the record containing the lang
115             :param template: name of the template to translate into the lang_field
116             """
117             ctx = context.copy()
118             doc = self.pool[model].browse(cr, uid, doc_id, context=ctx)
119             qcontext = values.copy()
120             # Do not force-translate if we chose to display the report in a specific lang
121             if ctx.get('translatable') is True:
122                 qcontext['o'] = doc
123             else:
124                 # Reach the lang we want to translate the doc into
125                 ctx['lang'] = eval('doc.%s' % lang_field, {'doc': doc})
126                 qcontext['o'] = self.pool[model].browse(cr, uid, doc_id, context=ctx)
127             return view_obj.render(cr, uid, template, qcontext, context=ctx)
128
129         user = self.pool['res.users'].browse(cr, uid, uid)
130         website = None
131         if request and hasattr(request, 'website'):
132             if request.website is not None:
133                 website = request.website
134                 context = dict(context, translatable=context.get('lang') != request.website.default_lang_code)
135         values.update(
136             time=time,
137             translate_doc=translate_doc,
138             editable=True,
139             user=user,
140             res_company=user.company_id,
141             website=website,
142         )
143         return view_obj.render(cr, uid, template, values, context=context)
144
145     #--------------------------------------------------------------------------
146     # Main report methods
147     #--------------------------------------------------------------------------
148     @api.v7
149     def get_html(self, cr, uid, ids, report_name, data=None, context=None):
150         """This method generates and returns html version of a report.
151         """
152         # If the report is using a custom model to render its html, we must use it.
153         # Otherwise, fallback on the generic html rendering.
154         try:
155             report_model_name = 'report.%s' % report_name
156             particularreport_obj = self.pool[report_model_name]
157             return particularreport_obj.render_html(cr, uid, ids, data=data, context=context)
158         except KeyError:
159             report = self._get_report_from_name(cr, uid, report_name)
160             report_obj = self.pool[report.model]
161             docs = report_obj.browse(cr, uid, ids, context=context)
162             docargs = {
163                 'doc_ids': ids,
164                 'doc_model': report.model,
165                 'docs': docs,
166             }
167             return self.render(cr, uid, [], report.report_name, docargs, context=context)
168
169     @api.v8
170     def get_html(self, records, report_name, data=None):
171         return self._model.get_html(self._cr, self._uid, records.ids, report_name,
172                                     data=data, context=self._context)
173
174     @api.v7
175     def get_pdf(self, cr, uid, ids, report_name, html=None, data=None, context=None):
176         """This method generates and returns pdf version of a report.
177         """
178         if context is None:
179             context = {}
180
181         if html is None:
182             html = self.get_html(cr, uid, ids, report_name, data=data, context=context)
183
184         html = html.decode('utf-8')  # Ensure the current document is utf-8 encoded.
185
186         # Get the ir.actions.report.xml record we are working on.
187         report = self._get_report_from_name(cr, uid, report_name)
188         # Check if we have to save the report or if we have to get one from the db.
189         save_in_attachment = self._check_attachment_use(cr, uid, ids, report)
190         # Get the paperformat associated to the report, otherwise fallback on the company one.
191         if not report.paperformat_id:
192             user = self.pool['res.users'].browse(cr, uid, uid)
193             paperformat = user.company_id.paperformat_id
194         else:
195             paperformat = report.paperformat_id
196
197         # Preparing the minimal html pages
198         css = ''  # Will contain local css
199         headerhtml = []
200         contenthtml = []
201         footerhtml = []
202         base_url = self.pool['ir.config_parameter'].get_param(cr, uid, 'web.base.url')
203
204         # Minimal page renderer
205         view_obj = self.pool['ir.ui.view']
206         render_minimal = partial(view_obj.render, cr, uid, 'report.minimal_layout', context=context)
207
208         # The received html report must be simplified. We convert it in a xml tree
209         # in order to extract headers, bodies and footers.
210         try:
211             root = lxml.html.fromstring(html)
212
213             for node in root.xpath("//html/head/style"):
214                 css += node.text
215
216             for node in root.xpath("//div[@class='header']"):
217                 body = lxml.html.tostring(node)
218                 header = render_minimal(dict(css=css, subst=True, body=body, base_url=base_url))
219                 headerhtml.append(header)
220
221             for node in root.xpath("//div[@class='footer']"):
222                 body = lxml.html.tostring(node)
223                 footer = render_minimal(dict(css=css, subst=True, body=body, base_url=base_url))
224                 footerhtml.append(footer)
225
226             for node in root.xpath("//div[@class='page']"):
227                 # Previously, we marked some reports to be saved in attachment via their ids, so we
228                 # must set a relation between report ids and report's content. We use the QWeb
229                 # branding in order to do so: searching after a node having a data-oe-model
230                 # attribute with the value of the current report model and read its oe-id attribute
231                 if ids and len(ids) == 1:
232                     reportid = ids[0]
233                 else:
234                     oemodelnode = node.find(".//*[@data-oe-model='%s']" % report.model)
235                     if oemodelnode is not None:
236                         reportid = oemodelnode.get('data-oe-id')
237                         if reportid:
238                             reportid = int(reportid)
239                     else:
240                         reportid = False
241
242                 # Extract the body
243                 body = lxml.html.tostring(node)
244                 reportcontent = render_minimal(dict(css=css, subst=False, body=body, base_url=base_url))
245
246                 contenthtml.append(tuple([reportid, reportcontent]))
247
248         except lxml.etree.XMLSyntaxError:
249             contenthtml = []
250             contenthtml.append(html)
251             save_in_attachment = {}  # Don't save this potentially malformed document
252
253         # Get paperformat arguments set in the root html tag. They are prioritized over
254         # paperformat-record arguments.
255         specific_paperformat_args = {}
256         for attribute in root.items():
257             if attribute[0].startswith('data-report-'):
258                 specific_paperformat_args[attribute[0]] = attribute[1]
259
260         # Run wkhtmltopdf process
261         return self._run_wkhtmltopdf(
262             cr, uid, headerhtml, footerhtml, contenthtml, context.get('landscape'),
263             paperformat, specific_paperformat_args, save_in_attachment
264         )
265
266     @api.v8
267     def get_pdf(self, records, report_name, html=None, data=None):
268         return self._model.get_pdf(self._cr, self._uid, records.ids, report_name,
269                                    html=html, data=data, context=self._context)
270
271     @api.v7
272     def get_action(self, cr, uid, ids, report_name, data=None, context=None):
273         """Return an action of type ir.actions.report.xml.
274
275         :param ids: Ids of the records to print (if not used, pass an empty list)
276         :param report_name: Name of the template to generate an action for
277         """
278         if ids:
279             if not isinstance(ids, list):
280                 ids = [ids]
281             context = dict(context or {}, active_ids=ids)
282
283         report_obj = self.pool['ir.actions.report.xml']
284         idreport = report_obj.search(cr, uid, [('report_name', '=', report_name)], context=context)
285         try:
286             report = report_obj.browse(cr, uid, idreport[0], context=context)
287         except IndexError:
288             raise osv.except_osv(
289                 _('Bad Report Reference'),
290                 _('This report is not loaded into the database: %s.' % report_name)
291             )
292
293         return {
294             'context': context,
295             'data': data,
296             'type': 'ir.actions.report.xml',
297             'report_name': report.report_name,
298             'report_type': report.report_type,
299             'report_file': report.report_file,
300             'context': context,
301         }
302
303     @api.v8
304     def get_action(self, records, report_name, data=None):
305         return self._model.get_action(self._cr, self._uid, records.ids, report_name,
306                                       data=data, context=self._context)
307
308     #--------------------------------------------------------------------------
309     # Report generation helpers
310     #--------------------------------------------------------------------------
311     @api.v7
312     def _check_attachment_use(self, cr, uid, ids, report):
313         """ Check attachment_use field. If set to true and an existing pdf is already saved, load
314         this one now. Else, mark save it.
315         """
316         save_in_attachment = {}
317         save_in_attachment['model'] = report.model
318         save_in_attachment['loaded_documents'] = {}
319
320         if report.attachment:
321             for record_id in ids:
322                 obj = self.pool[report.model].browse(cr, uid, record_id)
323                 filename = eval(report.attachment, {'object': obj, 'time': time})
324
325                 # If the user has checked 'Reload from Attachment'
326                 if report.attachment_use:
327                     alreadyindb = [('datas_fname', '=', filename),
328                                    ('res_model', '=', report.model),
329                                    ('res_id', '=', record_id)]
330                     attach_ids = self.pool['ir.attachment'].search(cr, uid, alreadyindb)
331                     if attach_ids:
332                         # Add the loaded pdf in the loaded_documents list
333                         pdf = self.pool['ir.attachment'].browse(cr, uid, attach_ids[0]).datas
334                         pdf = base64.decodestring(pdf)
335                         save_in_attachment['loaded_documents'][record_id] = pdf
336                         _logger.info('The PDF document %s was loaded from the database' % filename)
337
338                         continue  # Do not save this document as we already ignore it
339
340                 # If the user has checked 'Save as Attachment Prefix'
341                 if filename is False:
342                     # May be false if, for instance, the 'attachment' field contains a condition
343                     # preventing to save the file.
344                     continue
345                 else:
346                     save_in_attachment[record_id] = filename  # Mark current document to be saved
347
348         return save_in_attachment
349
350     @api.v8
351     def _check_attachment_use(self, records, report):
352         return self._model._check_attachment_use(
353             self._cr, self._uid, records.ids, report, context=self._context)
354
355     def _check_wkhtmltopdf(self):
356         return wkhtmltopdf_state
357
358     def _run_wkhtmltopdf(self, cr, uid, headers, footers, bodies, landscape, paperformat, spec_paperformat_args=None, save_in_attachment=None):
359         """Execute wkhtmltopdf as a subprocess in order to convert html given in input into a pdf
360         document.
361
362         :param header: list of string containing the headers
363         :param footer: list of string containing the footers
364         :param bodies: list of string containing the reports
365         :param landscape: boolean to force the pdf to be rendered under a landscape format
366         :param paperformat: ir.actions.report.paperformat to generate the wkhtmltopf arguments
367         :param specific_paperformat_args: dict of prioritized paperformat arguments
368         :param save_in_attachment: dict of reports to save/load in/from the db
369         :returns: Content of the pdf as a string
370         """
371         command_args = []
372
373         # Passing the cookie to wkhtmltopdf in order to resolve internal links.
374         try:
375             if request:
376                 command_args.extend(['--cookie', 'session_id', request.session.sid])
377         except AttributeError:
378             pass
379
380         # Wkhtmltopdf arguments
381         command_args.extend(['--quiet'])  # Less verbose error messages
382         if paperformat:
383             # Convert the paperformat record into arguments
384             command_args.extend(self._build_wkhtmltopdf_args(paperformat, spec_paperformat_args))
385
386         # Force the landscape orientation if necessary
387         if landscape and '--orientation' in command_args:
388             command_args_copy = list(command_args)
389             for index, elem in enumerate(command_args_copy):
390                 if elem == '--orientation':
391                     del command_args[index]
392                     del command_args[index]
393                     command_args.extend(['--orientation', 'landscape'])
394         elif landscape and not '--orientation' in command_args:
395             command_args.extend(['--orientation', 'landscape'])
396
397         # Execute WKhtmltopdf
398         pdfdocuments = []
399         temporary_files = []
400
401         for index, reporthtml in enumerate(bodies):
402             local_command_args = []
403             pdfreport_fd, pdfreport_path = tempfile.mkstemp(suffix='.pdf', prefix='report.tmp.')
404             temporary_files.append(pdfreport_path)
405
406             # Directly load the document if we already have it
407             if save_in_attachment and save_in_attachment['loaded_documents'].get(reporthtml[0]):
408                 with closing(os.fdopen(pdfreport_fd, 'w')) as pdfreport:
409                     pdfreport.write(save_in_attachment['loaded_documents'][reporthtml[0]])
410                 pdfdocuments.append(pdfreport_path)
411                 continue
412             else:
413                 os.close(pdfreport_fd)
414
415             # Wkhtmltopdf handles header/footer as separate pages. Create them if necessary.
416             if headers:
417                 head_file_fd, head_file_path = tempfile.mkstemp(suffix='.html', prefix='report.header.tmp.')
418                 temporary_files.append(head_file_path)
419                 with closing(os.fdopen(head_file_fd, 'w')) as head_file:
420                     head_file.write(headers[index])
421                 local_command_args.extend(['--header-html', head_file_path])
422             if footers:
423                 foot_file_fd, foot_file_path = tempfile.mkstemp(suffix='.html', prefix='report.footer.tmp.')
424                 temporary_files.append(foot_file_path)
425                 with closing(os.fdopen(foot_file_fd, 'w')) as foot_file:
426                     foot_file.write(footers[index])
427                 local_command_args.extend(['--footer-html', foot_file_path])
428
429             # Body stuff
430             content_file_fd, content_file_path = tempfile.mkstemp(suffix='.html', prefix='report.body.tmp.')
431             temporary_files.append(content_file_path)
432             with closing(os.fdopen(content_file_fd, 'w')) as content_file:
433                 content_file.write(reporthtml[1])
434
435             try:
436                 wkhtmltopdf = [_get_wkhtmltopdf_bin()] + command_args + local_command_args
437                 wkhtmltopdf += [content_file_path] + [pdfreport_path]
438
439                 process = subprocess.Popen(wkhtmltopdf, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
440                 out, err = process.communicate()
441
442                 if process.returncode not in [0, 1]:
443                     raise osv.except_osv(_('Report (PDF)'),
444                                          _('Wkhtmltopdf failed (error code: %s). '
445                                            'Message: %s') % (str(process.returncode), err))
446
447                 # Save the pdf in attachment if marked
448                 if reporthtml[0] is not False and save_in_attachment.get(reporthtml[0]):
449                     with open(pdfreport_path, 'rb') as pdfreport:
450                         attachment = {
451                             'name': save_in_attachment.get(reporthtml[0]),
452                             'datas': base64.encodestring(pdfreport.read()),
453                             'datas_fname': save_in_attachment.get(reporthtml[0]),
454                             'res_model': save_in_attachment.get('model'),
455                             'res_id': reporthtml[0],
456                         }
457                         try:
458                             self.pool['ir.attachment'].create(cr, uid, attachment)
459                         except AccessError:
460                             _logger.warning("Cannot save PDF report %r as attachment",
461                                             attachment['name'])
462                         else:
463                             _logger.info('The PDF document %s is now saved in the database',
464                                          attachment['name'])
465
466                 pdfdocuments.append(pdfreport_path)
467             except:
468                 raise
469
470         # Return the entire document
471         if len(pdfdocuments) == 1:
472             entire_report_path = pdfdocuments[0]
473         else:
474             entire_report_path = self._merge_pdf(pdfdocuments)
475             temporary_files.append(entire_report_path)
476
477         with open(entire_report_path, 'rb') as pdfdocument:
478             content = pdfdocument.read()
479
480         # Manual cleanup of the temporary files
481         for temporary_file in temporary_files:
482             try:
483                 os.unlink(temporary_file)
484             except (OSError, IOError):
485                 _logger.error('Error when trying to remove file %s' % temporary_file)
486
487         return content
488
489     def _get_report_from_name(self, cr, uid, report_name):
490         """Get the first record of ir.actions.report.xml having the ``report_name`` as value for
491         the field report_name.
492         """
493         report_obj = self.pool['ir.actions.report.xml']
494         qwebtypes = ['qweb-pdf', 'qweb-html']
495         conditions = [('report_type', 'in', qwebtypes), ('report_name', '=', report_name)]
496         idreport = report_obj.search(cr, uid, conditions)[0]
497         return report_obj.browse(cr, uid, idreport)
498
499     def _build_wkhtmltopdf_args(self, paperformat, specific_paperformat_args=None):
500         """Build arguments understandable by wkhtmltopdf from a report.paperformat record.
501
502         :paperformat: report.paperformat record
503         :specific_paperformat_args: a dict containing prioritized wkhtmltopdf arguments
504         :returns: list of string representing the wkhtmltopdf arguments
505         """
506         command_args = []
507         if paperformat.format and paperformat.format != 'custom':
508             command_args.extend(['--page-size', paperformat.format])
509
510         if paperformat.page_height and paperformat.page_width and paperformat.format == 'custom':
511             command_args.extend(['--page-width', str(paperformat.page_width) + 'mm'])
512             command_args.extend(['--page-height', str(paperformat.page_height) + 'mm'])
513
514         if specific_paperformat_args and specific_paperformat_args.get('data-report-margin-top'):
515             command_args.extend(['--margin-top', str(specific_paperformat_args['data-report-margin-top'])])
516         elif paperformat.margin_top:
517             command_args.extend(['--margin-top', str(paperformat.margin_top)])
518
519         if specific_paperformat_args and specific_paperformat_args.get('data-report-dpi'):
520             command_args.extend(['--dpi', str(specific_paperformat_args['data-report-dpi'])])
521         elif paperformat.dpi:
522             if os.name == 'nt' and int(paperformat.dpi) <= 95:
523                 _logger.info("Generating PDF on Windows platform require DPI >= 96. Using 96 instead.")
524                 command_args.extend(['--dpi', '96'])
525             else:
526                 command_args.extend(['--dpi', str(paperformat.dpi)])
527
528         if specific_paperformat_args and specific_paperformat_args.get('data-report-header-spacing'):
529             command_args.extend(['--header-spacing', str(specific_paperformat_args['data-report-header-spacing'])])
530         elif paperformat.header_spacing:
531             command_args.extend(['--header-spacing', str(paperformat.header_spacing)])
532
533         if paperformat.margin_left:
534             command_args.extend(['--margin-left', str(paperformat.margin_left)])
535         if paperformat.margin_bottom:
536             command_args.extend(['--margin-bottom', str(paperformat.margin_bottom)])
537         if paperformat.margin_right:
538             command_args.extend(['--margin-right', str(paperformat.margin_right)])
539         if paperformat.orientation:
540             command_args.extend(['--orientation', str(paperformat.orientation)])
541         if paperformat.header_line:
542             command_args.extend(['--header-line'])
543
544         return command_args
545
546     def _merge_pdf(self, documents):
547         """Merge PDF files into one.
548
549         :param documents: list of path of pdf files
550         :returns: path of the merged pdf
551         """
552         writer = PdfFileWriter()
553         streams = []  # We have to close the streams *after* PdfFilWriter's call to write()
554         for document in documents:
555             pdfreport = file(document, 'rb')
556             streams.append(pdfreport)
557             reader = PdfFileReader(pdfreport)
558             for page in range(0, reader.getNumPages()):
559                 writer.addPage(reader.getPage(page))
560
561         merged_file_fd, merged_file_path = tempfile.mkstemp(suffix='.html', prefix='report.merged.tmp.')
562         with closing(os.fdopen(merged_file_fd, 'w')) as merged_file:
563             writer.write(merged_file)
564
565         for stream in streams:
566             stream.close()
567
568         return merged_file_path