1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2014-Today OpenERP SA (<http://www.openerp.com>).
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.
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.
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/>.
20 ##############################################################################
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
37 from contextlib import closing
38 from distutils.version import LooseVersion
39 from functools import partial
40 from pyPdf import PdfFileWriter, PdfFileReader
43 #--------------------------------------------------------------------------
45 #--------------------------------------------------------------------------
46 _logger = logging.getLogger(__name__)
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))
53 #--------------------------------------------------------------------------
54 # Check the presence of Wkhtmltopdf and return its version at Odoo start-up
55 #--------------------------------------------------------------------------
56 wkhtmltopdf_state = 'install'
58 process = subprocess.Popen(
59 [_get_wkhtmltopdf_bin(), '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE
61 except (OSError, IOError):
62 _logger.info('You need Wkhtmltopdf to print a pdf version of the reports.')
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'
71 wkhtmltopdf_state = 'ok'
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'
78 class Report(osv.Model):
80 _description = "Report"
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.
91 :param values: additionnal methods/variables used in the rendering
92 :returns: html representation of the template
100 context.update(inherit_branding=True) # Tell QWeb to brand the generated html
102 view_obj = self.pool['ir.ui.view']
104 def translate_doc(doc_id, model, lang_field, template):
105 """Helper used when a report should be translated into a specific lang.
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')"/>
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
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:
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)
128 user = self.pool['res.users'].browse(cr, uid, uid)
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)
136 translate_doc=translate_doc,
139 res_company=user.company_id,
142 return view_obj.render(cr, uid, template, values, context=context)
144 #--------------------------------------------------------------------------
145 # Main report methods
146 #--------------------------------------------------------------------------
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.
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.
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)
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)
163 'doc_model': report.model,
166 return self.render(cr, uid, [], report.report_name, docargs, context=context)
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)
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.
181 html = self.get_html(cr, uid, ids, report_name, data=data, context=context)
183 html = html.decode('utf-8') # Ensure the current document is utf-8 encoded.
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
194 paperformat = report.paperformat_id
196 # Preparing the minimal html pages
197 css = '' # Will contain local css
201 base_url = self.pool['ir.config_parameter'].get_param(cr, uid, 'web.base.url')
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)
207 # The received html report must be simplified. We convert it in a xml tree
208 # in order to extract headers, bodies and footers.
210 root = lxml.html.fromstring(html)
212 for node in root.xpath("//html/head/style"):
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)
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)
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:
233 oemodelnode = node.find(".//*[@data-oe-model='%s']" % report.model)
234 if oemodelnode is not None:
235 reportid = oemodelnode.get('data-oe-id')
237 reportid = int(reportid)
242 body = lxml.html.tostring(node)
243 reportcontent = render_minimal(dict(css=css, subst=False, body=body, base_url=base_url))
245 contenthtml.append(tuple([reportid, reportcontent]))
247 except lxml.etree.XMLSyntaxError:
249 contenthtml.append(html)
250 save_in_attachment = {} # Don't save this potentially malformed document
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]
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
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)
271 def get_action(self, cr, uid, ids, report_name, data=None, context=None):
272 """Return an action of type ir.actions.report.xml.
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
278 if not isinstance(ids, list):
280 context = dict(context or {}, active_ids=ids)
282 report_obj = self.pool['ir.actions.report.xml']
283 idreport = report_obj.search(cr, uid, [('report_name', '=', report_name)], context=context)
285 report = report_obj.browse(cr, uid, idreport[0], context=context)
287 raise osv.except_osv(
288 _('Bad Report Reference'),
289 _('This report is not loaded into the database: %s.' % report_name)
295 'type': 'ir.actions.report.xml',
296 'report_name': report.report_name,
297 'report_type': report.report_type,
298 'report_file': report.report_file,
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)
307 #--------------------------------------------------------------------------
308 # Report generation helpers
309 #--------------------------------------------------------------------------
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.
315 save_in_attachment = {}
316 save_in_attachment['model'] = report.model
317 save_in_attachment['loaded_documents'] = {}
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})
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)
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)
337 continue # Do not save this document as we already ignore it
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.
345 save_in_attachment[record_id] = filename # Mark current document to be saved
347 return save_in_attachment
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)
354 def _check_wkhtmltopdf(self):
355 return wkhtmltopdf_state
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
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
372 # Passing the cookie to wkhtmltopdf in order to resolve internal links.
375 command_args.extend(['--cookie', 'session_id', request.session.sid])
376 except AttributeError:
379 # Wkhtmltopdf arguments
380 command_args.extend(['--quiet']) # Less verbose error messages
382 # Convert the paperformat record into arguments
383 command_args.extend(self._build_wkhtmltopdf_args(paperformat, spec_paperformat_args))
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'])
396 # Execute WKhtmltopdf
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)
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)
412 os.close(pdfreport_fd)
414 # Wkhtmltopdf handles header/footer as separate pages. Create them if necessary.
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])
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])
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])
435 wkhtmltopdf = [_get_wkhtmltopdf_bin()] + command_args + local_command_args
436 wkhtmltopdf += [content_file_path] + [pdfreport_path]
438 process = subprocess.Popen(wkhtmltopdf, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
439 out, err = process.communicate()
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))
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:
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],
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'])
460 pdfdocuments.append(pdfreport_path)
464 # Return the entire document
465 if len(pdfdocuments) == 1:
466 entire_report_path = pdfdocuments[0]
468 entire_report_path = self._merge_pdf(pdfdocuments)
469 temporary_files.append(entire_report_path)
471 with open(entire_report_path, 'rb') as pdfdocument:
472 content = pdfdocument.read()
474 # Manual cleanup of the temporary files
475 for temporary_file in temporary_files:
477 os.unlink(temporary_file)
478 except (OSError, IOError):
479 _logger.error('Error when trying to remove file %s' % temporary_file)
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.
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)
493 def _build_wkhtmltopdf_args(self, paperformat, specific_paperformat_args=None):
494 """Build arguments understandable by wkhtmltopdf from a report.paperformat record.
496 :paperformat: report.paperformat record
497 :specific_paperformat_args: a dict containing prioritized wkhtmltopdf arguments
498 :returns: list of string representing the wkhtmltopdf arguments
501 if paperformat.format and paperformat.format != 'custom':
502 command_args.extend(['--page-size', paperformat.format])
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'])
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)])
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'])
520 command_args.extend(['--dpi', str(paperformat.dpi)])
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)])
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'])
540 def _merge_pdf(self, documents):
541 """Merge PDF files into one.
543 :param documents: list of path of pdf files
544 :returns: path of the merged pdf
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))
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)
559 for stream in streams:
562 return merged_file_path