diff --git a/addons/report/controllers/main.py b/addons/report/controllers/main.py index 5d901037c6a..bd5b41d9a9d 100644 --- a/addons/report/controllers/main.py +++ b/addons/report/controllers/main.py @@ -19,10 +19,22 @@ # ############################################################################## +from openerp.osv.osv import except_osv from openerp.addons.web import http +from openerp.tools.translate import _ from openerp.addons.web.http import request +import time +import base64 import logging +import tempfile +import lxml.html +import subprocess + +from pyPdf import PdfFileWriter, PdfFileReader +from werkzeug.test import Client +from werkzeug.wrappers import BaseResponse +from werkzeug.datastructures import Headers _logger = logging.getLogger(__name__) @@ -67,6 +79,310 @@ class Report(http.Controller): return request.registry['report'].render(request.cr, request.uid, [], report.report_file, docargs, context=request.context) + @http.route(['/report/pdf/'], type='http', auth="user", website=True) + def report_pdf(self, path=None, landscape=False, **post): + cr, uid, context = request.cr, request.uid, request.context + + # Get the report we are working on. + # Pattern is /report/module.reportname(?a=1) + reportname_in_path = path.split('/')[1].split('?')[0] + report = self._get_report_from_name(reportname_in_path) + + # Check attachment_use field. If set to true and an existing pdf is already saved, return + # this one now. If not, mark save it. + save_in_attachment = {} + + if report.attachment_use is True: + # Get the record ids we are working on. + path_ids = [int(i) for i in path.split('/')[2].split('?')[0].split(',')] + + save_in_attachment['model'] = report.model + save_in_attachment['loaded_documents'] = {} + + for path_id in path_ids: + obj = request.registry[report.model].browse(cr, uid, path_id) + filename = eval(report.attachment, {'object': obj, 'time': time}) + + if filename is False: # May be false if, for instance, the record is in draft state + continue + else: + alreadyindb = [('datas_fname', '=', filename), + ('res_model', '=', report.model), + ('res_id', '=', path_id)] + + attach_ids = request.registry['ir.attachment'].search(cr, uid, alreadyindb) + if attach_ids: + # Add the loaded pdf in the loaded_documents list + pdf = request.registry['ir.attachment'].browse(cr, uid, attach_ids[0]).datas + pdf = base64.decodestring(pdf) + save_in_attachment['loaded_documents'][path_id] = pdf + _logger.info('The PDF document %s was loaded from the database' % filename) + else: + # Mark current document to be saved + save_in_attachment[path_id] = filename + + # Get the paperformat associated to the report, if there is. + if not report.paperformat_id: + user = request.registry['res.users'].browse(cr, uid, uid, context=context) + paperformat = user.company_id.paperformat_id + else: + paperformat = report.paperformat_id + + # Get the html report. + html = self._get_url_content('/' + path, post) + + # Get some css and script in order to build a minimal html page for the report. + # This page will later be sent to wkhtmltopdf. + css = self._get_url_content('/report/static/src/css/reset.min.css') + css += self._get_url_content('/website/static/src/css/website.css') + subst = self._get_url_content('/report/static/src/js/subst.js') + + headerhtml = [] + contenthtml = [] + footerhtml = [] + minimalhtml = """ + + + + + + + + {2} + +""" + + # The retrieved html report must be simplified. We convert it into a xml tree + # via lxml in order to extract header, footer and all reportcontent. + try: + root = lxml.html.fromstring(html) + + for node in root.xpath("//html/head/style"): + css += node.text + + for node in root.xpath("//div[@class='header']"): + body = lxml.html.tostring(node) + header = minimalhtml.format(css, subst, body) + headerhtml.append(header) + + for node in root.xpath("//div[@class='footer']"): + body = lxml.html.tostring(node) + footer = minimalhtml.format(css, subst, body) + footerhtml.append(footer) + + for node in root.xpath("//div[@class='page']"): + # Previously, we marked some reports to be saved in attachment via their ids, so we + # must set a relation between report ids and reportcontent. We use the QWeb + # branding in order to do so: searching after a node having a data-oe-model + # attribute with the value of the current report model and read its oe-id attribute + oemodelnode = node.find(".//*[@data-oe-model='" + report.model + "']") + if oemodelnode is not None: + reportid = oemodelnode.get('data-oe-id', False) + if reportid is not False: + reportid = int(reportid) + else: + reportid = False + + body = lxml.html.tostring(node) + reportcontent = minimalhtml.format(css, '', body) + contenthtml.append(tuple([reportid, reportcontent])) + + except lxml.etree.XMLSyntaxError: + contenthtml = [] + contenthtml.append(html) + save_in_attachment = {} # Don't save this potentially malformed document + + # Get paperformat arguments set in the root html tag. They are prioritized over + # paperformat-record arguments. + specific_paperformat_args = {} + for attribute in root.items(): + if attribute[0].startswith('data-report-'): + specific_paperformat_args[attribute[0]] = attribute[1] + + # Execute wkhtmltopdf process. + pdf = self._generate_wkhtml_pdf(headerhtml, footerhtml, contenthtml, landscape, + paperformat, specific_paperformat_args, save_in_attachment) + + return self._make_pdf_response(pdf) + + def _get_url_content(self, url, post=None): + """Resolve an internal webpage url and return its content with the help of + werkzeug.test.client. + + :param url: string representinf the url to resolve + :param post: a dict representing the query string + :returns: a tuple str(html), int(statuscode) + """ + # Rebuilding the query string. + if post: + url += '?' + url += '&'.join('%s=%s' % (k, v) for (k, v) in post.iteritems()) + + # We have to pass the current headers in order to see the report. + reqheaders = Headers(request.httprequest.headers) + response = Client(request.httprequest.app, BaseResponse).get(url, headers=reqheaders, + follow_redirects=True) + content = response.data + + try: + content = content.decode('utf-8') + except UnicodeDecodeError: + pass + + return content + + def _generate_wkhtml_pdf(self, headers, footers, bodies, landscape, + paperformat, spec_paperformat_args=None, save_in_attachment=None): + """Execute wkhtmltopdf as a subprocess in order to convert html given in input into a pdf + document. + + :param header: list of string containing the headers + :param footer: list of string containing the footers + :param bodies: list of string containing the reports + :param landscape: boolean to force the pdf to be rendered under a landscape format + :param paperformat: ir.actions.report.paperformat to generate the wkhtmltopf arguments + :param specific_paperformat_args: dict of prioritized paperformat arguments + :param save_in_attachment: dict of reports to save/load in/from the db + :returns: Content of the pdf as a string + """ + command = ['wkhtmltopdf-0.12'] + tmp_dir = tempfile.gettempdir() + + # Display arguments + command_args = [] + + if paperformat: + command_args.extend(self._build_wkhtmltopdf_args(paperformat, spec_paperformat_args)) + + if landscape and '--orientation' in command_args: + command_args_copy = list(command_args) + for index, elem in enumerate(command_args_copy): + if elem == '--orientation': + del command_args[index] + del command_args[index] + command_args.extend(['--orientation', 'landscape']) + elif landscape and not '--orientation' in command_args: + command_args.extend(['--orientation', 'landscape']) + + pdfdocuments = [] + # HTML to PDF thanks to WKhtmltopdf + for index, reporthtml in enumerate(bodies): + command_arg_local = [] + pdfreport = tempfile.NamedTemporaryFile(suffix='.pdf', prefix='report.tmp.', + mode='w+b') + # Directly load the document if we have it + if save_in_attachment and save_in_attachment['loaded_documents'].get(reporthtml[0]): + pdfreport.write(save_in_attachment['loaded_documents'].get(reporthtml[0])) + pdfreport.seek(0) + pdfdocuments.append(pdfreport) + continue + + # Header stuff + if headers: + head_file = tempfile.NamedTemporaryFile(suffix='.html', prefix='report.header.tmp.', + dir=tmp_dir, mode='w+') + head_file.write(headers[index]) + head_file.seek(0) + command_arg_local.extend(['--header-html', head_file.name]) + + # Footer stuff + if footers: + foot_file = tempfile.NamedTemporaryFile(suffix='.html', prefix='report.footer.tmp.', + dir=tmp_dir, mode='w+') + foot_file.write(footers[index]) + foot_file.seek(0) + command_arg_local.extend(['--footer-html', foot_file.name]) + + # Body stuff + content_file = tempfile.NamedTemporaryFile(suffix='.html', prefix='report.body.tmp.', + dir=tmp_dir, mode='w+') + content_file.write(reporthtml[1]) + content_file.seek(0) + + try: + wkhtmltopdf = command + command_args + command_arg_local + wkhtmltopdf += [content_file.name] + [pdfreport.name] + + process = subprocess.Popen(wkhtmltopdf, stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + out, err = process.communicate() + + if process.returncode != 0: + raise except_osv(_('Report (PDF)'), + _('wkhtmltopdf-patched failed with error code = %s. ' + 'Message: %s') % (str(process.returncode), err)) + + # Save the pdf in attachment if marked + if reporthtml[0] is not False and save_in_attachment.get(reporthtml[0]): + attachment = { + 'name': save_in_attachment.get(reporthtml[0]), + 'datas': base64.encodestring(pdfreport.read()), + 'datas_fname': save_in_attachment.get(reporthtml[0]), + 'res_model': save_in_attachment.get('model'), + 'res_id': reporthtml[0], + } + request.registry['ir.attachment'].create(request.cr, request.uid, attachment) + _logger.info('The PDF document %s is now saved in the ' + 'database' % attachment['name']) + + pdfreport.seek(0) + pdfdocuments.append(pdfreport) + + if headers: + head_file.close() + if footers: + foot_file.close() + except: + raise + + # Get and return the full pdf + if len(pdfdocuments) == 1: + content = pdfdocuments[0].read() + pdfdocuments[0].close() + else: + content = self._merge_pdf(pdfdocuments) + + return content + + def _build_wkhtmltopdf_args(self, paperformat, specific_paperformat_args=None): + """Build arguments understandable by wkhtmltopdf from an ir.actions.report.paperformat + record. + + :paperformat: ir.actions.report.paperformat record associated to a document + :specific_paperformat_args: a dict containing prioritized wkhtmltopdf arguments + :returns: list of string containing the wkhtmltopdf arguments + """ + command_args = [] + if paperformat.format and paperformat.format != 'custom': + command_args.extend(['--page-size', paperformat.format]) + + if paperformat.page_height and paperformat.page_width and paperformat.format == 'custom': + command_args.extend(['--page-width', str(paperformat.page_width) + 'in']) + command_args.extend(['--page-height', str(paperformat.page_height) + 'in']) + + if specific_paperformat_args and specific_paperformat_args['data-report-margin-top']: + command_args.extend(['--margin-top', + str(specific_paperformat_args['data-report-margin-top'])]) + else: + command_args.extend(['--margin-top', str(paperformat.margin_top)]) + + if paperformat.margin_left: + command_args.extend(['--margin-left', str(paperformat.margin_left)]) + if paperformat.margin_bottom: + command_args.extend(['--margin-bottom', str(paperformat.margin_bottom)]) + if paperformat.margin_right: + command_args.extend(['--margin-right', str(paperformat.margin_right)]) + if paperformat.orientation: + command_args.extend(['--orientation', str(paperformat.orientation)]) + if paperformat.header_spacing: + command_args.extend(['--header-spacing', str(paperformat.header_spacing)]) + if paperformat.header_line: + command_args.extend(['--header-line']) + if paperformat.dpi: + command_args.extend(['--dpi', str(paperformat.dpi)]) + + return command_args + def _get_report_from_name(self, report_name): """Get the first record of ir.actions.report.xml having the argument as value for the field report_name. @@ -81,3 +397,33 @@ class Report(http.Controller): report = report_obj.browse(request.cr, request.uid, idreport[0], context=request.context) return report + + def _make_pdf_response(self, pdf): + """Make a request response for a PDF file with correct http headers. + + :param pdf: content of a pdf in a string + :returns: request response for a pdf document + """ + pdfhttpheaders = [('Content-Type', 'application/pdf'), + ('Content-Length', len(pdf))] + return request.make_response(pdf, headers=pdfhttpheaders) + + def _merge_pdf(self, documents): + """Merge PDF files into one. + + :param documents: list of pdf files + :returns: string containing the merged pdf + """ + writer = PdfFileWriter() + for document in documents: + reader = PdfFileReader(file(document.name, "rb")) + for page in range(0, reader.getNumPages()): + writer.addPage(reader.getPage(page)) + document.close() + + merged = tempfile.NamedTemporaryFile(suffix='.pdf', prefix='report.tmp.', mode='w+b') + writer.write(merged) + merged.seek(0) + content = merged.read() + merged.close() + return content diff --git a/addons/report/static/src/css/reset.min.css b/addons/report/static/src/css/reset.min.css new file mode 100644 index 00000000000..cc7842e37ef --- /dev/null +++ b/addons/report/static/src/css/reset.min.css @@ -0,0 +1,2 @@ +/* reset5 2011 opensource.736cs.com MIT - https://code.google.com/p/reset5 */ +html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,font,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,audio,canvas,details,figcaption,figure,footer,header,hgroup,mark,menu,meter,nav,output,progress,section,summary,time,video{border:0;outline:0;font-size:100%;vertical-align:baseline;background:transparent;margin:0;padding:0;}body{line-height:1;}article,aside,dialog,figure,footer,header,hgroup,nav,section,blockquote{display:block;}nav ul{list-style:none;}ol{list-style:decimal;}ul{list-style:disc;}ul ul{list-style:circle;}blockquote,q{quotes:none;}blockquote:before,blockquote:after,q:before,q:after{content:none;}ins{text-decoration:underline;}del{text-decoration:line-through;}mark{background:none;}abbr[title],dfn[title]{border-bottom:1px dotted #000;cursor:help;}table{border-collapse:collapse;border-spacing:0;}hr{display:block;height:1px;border:0;border-top:1px solid #ccc;margin:1em 0;padding:0;}input[type=submit],input[type=button],button{margin:0!important;padding:0!important;}input,select,a img{vertical-align:middle;} diff --git a/addons/report/static/src/js/subst.js b/addons/report/static/src/js/subst.js new file mode 100644 index 00000000000..076a5ff208a --- /dev/null +++ b/addons/report/static/src/js/subst.js @@ -0,0 +1,14 @@ +function subst() { + var vars = {}; + var x = document.location.search.substring(1).split('&'); + for (var i in x) { + var z = x[i].split('=', 2); + vars[z[0]] = unescape(z[1]); + } + var x=['frompage', 'topage', 'page', 'webpage', 'section', 'subsection', 'subsubsection']; + for (var i in x) { + var y = document.getElementsByClassName(x[i]); + for (var j=0; j