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