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