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 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
40 from contextlib import closing
41 from distutils.version import LooseVersion
42 from functools import partial
43 from pyPdf import PdfFileWriter, PdfFileReader
46 #--------------------------------------------------------------------------
48 #--------------------------------------------------------------------------
49 _logger = logging.getLogger(__name__)
51 def _get_wkhtmltopdf_bin():
52 wkhtmltopdf_bin = find_in_path('wkhtmltopdf')
53 if wkhtmltopdf_bin is None:
55 return wkhtmltopdf_bin
58 #--------------------------------------------------------------------------
59 # Check the presence of Wkhtmltopdf and return its version at Odoo start-up
60 #--------------------------------------------------------------------------
61 wkhtmltopdf_state = 'install'
63 process = subprocess.Popen(
64 [_get_wkhtmltopdf_bin(), '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE
66 except (OSError, IOError):
67 _logger.info('You need Wkhtmltopdf to print a pdf version of the reports.')
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'
76 wkhtmltopdf_state = 'ok'
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'
83 class Report(osv.Model):
85 _description = "Report"
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.
96 :param values: additionnal methods/variables used in the rendering
97 :returns: html representation of the template
105 context = dict(context, inherit_branding=True) # Tell QWeb to brand the generated html
107 view_obj = self.pool['ir.ui.view']
109 def translate_doc(doc_id, model, lang_field, template):
110 """Helper used when a report should be translated into a specific lang.
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')"/>
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
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:
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)
133 user = self.pool['res.users'].browse(cr, uid, uid)
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)
141 translate_doc=translate_doc,
144 res_company=user.company_id,
147 return view_obj.render(cr, uid, template, values, context=context)
149 #--------------------------------------------------------------------------
150 # Main report methods
151 #--------------------------------------------------------------------------
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.
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.
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)
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)
168 'doc_model': report.model,
171 return self.render(cr, uid, [], report.report_name, docargs, context=context)
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)
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.
186 html = self.get_html(cr, uid, ids, report_name, data=data, context=context)
188 html = html.decode('utf-8') # Ensure the current document is utf-8 encoded.
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
199 paperformat = report.paperformat_id
201 # Preparing the minimal html pages
202 css = '' # Will contain local css
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')
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)
213 # The received html report must be simplified. We convert it in a xml tree
214 # in order to extract headers, bodies and footers.
216 root = lxml.html.fromstring(html)
217 match_klass = "//div[contains(concat(' ', normalize-space(@class), ' '), ' {} ')]"
219 for node in root.xpath("//html/head/style"):
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)
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)
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:
240 oemodelnode = node.find(".//*[@data-oe-model='%s']" % report.model)
241 if oemodelnode is not None:
242 reportid = oemodelnode.get('data-oe-id')
244 reportid = int(reportid)
249 body = lxml.html.tostring(node)
250 reportcontent = render_minimal(dict(css=css, subst=False, body=body, base_url=base_url))
252 contenthtml.append(tuple([reportid, reportcontent]))
254 except lxml.etree.XMLSyntaxError:
256 contenthtml.append(html)
257 save_in_attachment = {} # Don't save this potentially malformed document
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]
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
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)
278 def get_action(self, cr, uid, ids, report_name, data=None, context=None):
279 """Return an action of type ir.actions.report.xml.
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
285 if not isinstance(ids, list):
287 context = dict(context or {}, active_ids=ids)
289 report_obj = self.pool['ir.actions.report.xml']
290 idreport = report_obj.search(cr, uid, [('report_name', '=', report_name)], context=context)
292 report = report_obj.browse(cr, uid, idreport[0], context=context)
294 raise osv.except_osv(
295 _('Bad Report Reference'),
296 _('This report is not loaded into the database: %s.' % report_name)
302 'type': 'ir.actions.report.xml',
303 'report_name': report.report_name,
304 'report_type': report.report_type,
305 'report_file': report.report_file,
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)
314 #--------------------------------------------------------------------------
315 # Report generation helpers
316 #--------------------------------------------------------------------------
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.
322 save_in_attachment = {}
323 save_in_attachment['model'] = report.model
324 save_in_attachment['loaded_documents'] = {}
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})
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)
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)
344 continue # Do not save this document as we already ignore it
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.
352 save_in_attachment[record_id] = filename # Mark current document to be saved
354 return save_in_attachment
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)
361 def _check_wkhtmltopdf(self):
362 return wkhtmltopdf_state
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
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
379 # Passing the cookie to wkhtmltopdf in order to resolve internal links.
382 command_args.extend(['--cookie', 'session_id', request.session.sid])
383 except AttributeError:
386 # Wkhtmltopdf arguments
387 command_args.extend(['--quiet']) # Less verbose error messages
389 # Convert the paperformat record into arguments
390 command_args.extend(self._build_wkhtmltopdf_args(paperformat, spec_paperformat_args))
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'])
403 # Execute WKhtmltopdf
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)
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)
419 os.close(pdfreport_fd)
421 # Wkhtmltopdf handles header/footer as separate pages. Create them if necessary.
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])
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])
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])
442 wkhtmltopdf = [_get_wkhtmltopdf_bin()] + command_args + local_command_args
443 wkhtmltopdf += [content_file_path] + [pdfreport_path]
445 process = subprocess.Popen(wkhtmltopdf, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
446 out, err = process.communicate()
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))
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:
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],
464 self.pool['ir.attachment'].create(cr, uid, attachment)
466 _logger.warning("Cannot save PDF report %r as attachment",
469 _logger.info('The PDF document %s is now saved in the database',
472 pdfdocuments.append(pdfreport_path)
476 # Return the entire document
477 if len(pdfdocuments) == 1:
478 entire_report_path = pdfdocuments[0]
480 entire_report_path = self._merge_pdf(pdfdocuments)
481 temporary_files.append(entire_report_path)
483 with open(entire_report_path, 'rb') as pdfdocument:
484 content = pdfdocument.read()
486 # Manual cleanup of the temporary files
487 for temporary_file in temporary_files:
489 os.unlink(temporary_file)
490 except (OSError, IOError):
491 _logger.error('Error when trying to remove file %s' % temporary_file)
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.
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)
505 def _build_wkhtmltopdf_args(self, paperformat, specific_paperformat_args=None):
506 """Build arguments understandable by wkhtmltopdf from a report.paperformat record.
508 :paperformat: report.paperformat record
509 :specific_paperformat_args: a dict containing prioritized wkhtmltopdf arguments
510 :returns: list of string representing the wkhtmltopdf arguments
513 if paperformat.format and paperformat.format != 'custom':
514 command_args.extend(['--page-size', paperformat.format])
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'])
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)])
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'])
532 command_args.extend(['--dpi', str(paperformat.dpi)])
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)])
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'])
552 def _merge_pdf(self, documents):
553 """Merge PDF files into one.
555 :param documents: list of path of pdf files
556 :returns: path of the merged pdf
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))
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)
571 for stream in streams:
574 return merged_file_path