# -*- coding: utf-8 -*- ############################################################################## # # OpenERP, Open Source Management Solution # Copyright (C) 2014-Today OpenERP SA (). # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # ############################################################################## from openerp.osv import osv from openerp.tools.translate import _ from openerp.tools import DEFAULT_SERVER_DATE_FORMAT, DEFAULT_SERVER_DATETIME_FORMAT, config from openerp.osv.fields import float as float_field, function as function_field, datetime as datetime_field import os import time import psutil import signal import base64 import logging import tempfile import lxml.html import cStringIO import subprocess from datetime import datetime from distutils.version import LooseVersion try: from pyPdf import PdfFileWriter, PdfFileReader except ImportError: PdfFileWriter = PdfFileReader = None _logger = logging.getLogger(__name__) class Report(osv.Model): _name = "report" _description = "Report" public_user = None #-------------------------------------------------------------------------- # Extension of ir_ui_view.render with arguments frequently used in reports #-------------------------------------------------------------------------- def get_digits(self, cr, uid, obj=None, f=None, dp=None): d = DEFAULT_DIGITS = 2 if dp: decimal_precision_obj = self.pool['decimal.precision'] ids = decimal_precision_obj.search(cr, uid, [('name', '=', dp)]) if ids: d = decimal_precision_obj.browse(cr, uid, ids)[0].digits elif obj and f: res_digits = getattr(obj._columns[f], 'digits', lambda x: ((16, DEFAULT_DIGITS))) if isinstance(res_digits, tuple): d = res_digits[1] else: d = res_digits(cr)[1] elif (hasattr(obj, '_field') and isinstance(obj._field, (float_field, function_field)) and obj._field.digits): d = obj._field.digits[1] or DEFAULT_DIGITS return d def _get_lang_dict(self, cr, uid): pool_lang = self.pool['res.lang'] lang = self.localcontext.get('lang', 'en_US') or 'en_US' lang_ids = pool_lang.search(cr, uid, [('code', '=', lang)])[0] lang_obj = pool_lang.browse(cr, uid, lang_ids) lang_dict = { 'lang_obj': lang_obj, 'date_format': lang_obj.date_format, 'time_format': lang_obj.time_format } self.lang_dict.update(lang_dict) self.default_lang[lang] = self.lang_dict.copy() return True def formatLang(self, value, digits=None, date=False, date_time=False, grouping=True, monetary=False, dp=False, currency_obj=False, cr=None, uid=None): """ Assuming 'Account' decimal.precision=3: formatLang(value) -> digits=2 (default) formatLang(value, digits=4) -> digits=4 formatLang(value, dp='Account') -> digits=3 formatLang(value, digits=5, dp='Account') -> digits=5 """ def get_date_length(date_format=DEFAULT_SERVER_DATE_FORMAT): return len((datetime.now()).strftime(date_format)) if digits is None: if dp: digits = self.get_digits(cr, uid, dp=dp) else: digits = self.get_digits(cr, uid, value) if isinstance(value, (str, unicode)) and not value: return '' if not self.lang_dict_called: self._get_lang_dict(cr, uid) self.lang_dict_called = True if date or date_time: if not str(value): return '' date_format = self.lang_dict['date_format'] parse_format = DEFAULT_SERVER_DATE_FORMAT if date_time: value = value.split('.')[0] date_format = date_format + " " + self.lang_dict['time_format'] parse_format = DEFAULT_SERVER_DATETIME_FORMAT if isinstance(value, basestring): # FIXME: the trimming is probably unreliable if format includes day/month names # and those would need to be translated anyway. date = datetime.strptime(value[:get_date_length(parse_format)], parse_format) elif isinstance(value, time.struct_time): date = datetime(*value[:6]) else: date = datetime(*value.timetuple()[:6]) if date_time: # Convert datetime values to the expected client/context timezone date = datetime_field.context_timestamp(cr, uid, timestamp=date, context=self.localcontext) return date.strftime(date_format.encode('utf-8')) res = self.lang_dict['lang_obj'].format('%.' + str(digits) + 'f', value, grouping=grouping, monetary=monetary) if currency_obj: if currency_obj.position == 'after': res = '%s %s' % (res, currency_obj.symbol) elif currency_obj and currency_obj.position == 'before': res = '%s %s' % (currency_obj.symbol, res) return res def render(self, cr, uid, ids, template, values=None, context=None): """Allow to render a QWeb template python-side. This function returns the 'ir.ui.view' render but embellish it with some variables/methods used in reports. :param values: additionnal methods/variables used in the rendering :returns: html representation of the template """ if values is None: values = {} if context is None: context = {} self.lang_dict = self.default_lang = {} self.lang_dict_called = False self.localcontext = { 'lang': context.get('lang'), 'tz': context.get('tz'), 'uid': context.get('uid'), } self._get_lang_dict(cr, uid) view_obj = self.pool['ir.ui.view'] def render_doc(doc_id, model, template): """Helper used when a report should be translated into the associated partner's lang. :param doc_id: id of the record to translate :param model: model of the record to translate :param template: name of the template to translate into the partner's lang """ ctx = context.copy() doc = self.pool[model].browse(cr, uid, doc_id, context=ctx) qcontext = values.copy() # Do not force-translate if we chose to display the report in a specific lang if ctx.get('translatable') is True: qcontext['o'] = doc else: ctx['lang'] = doc.partner_id.lang qcontext['o'] = self.pool[model].browse(cr, uid, doc_id, context=ctx) return view_obj.render(cr, uid, template, qcontext, context=ctx) values.update({ 'time': time, 'formatLang': lambda *args, **kwargs: self.formatLang(*args, cr=cr, uid=uid, **kwargs), 'get_digits': self.get_digits, 'render_doc': render_doc, 'editable': True, # Will active inherit_branding 'res_company': self.pool['res.users'].browse(cr, uid, uid).company_id }) return view_obj.render(cr, uid, template, values, context=context) #-------------------------------------------------------------------------- # Main reports methods #-------------------------------------------------------------------------- def get_html(self, cr, uid, ids, report_name, data=None, context=None): """This method generates and returns html version of a report. """ if context is None: context = {} if isinstance(ids, (str, unicode)): ids = [int(i) for i in ids.split(',')] if isinstance(ids, list): ids = list(set(ids)) if isinstance(ids, int): ids = [ids] # If the report is using a custom model to render its html, we must use it. # Otherwise, fallback on the generic html rendering. try: report_model_name = 'report.%s' % report_name particularreport_obj = self.pool[report_model_name] return particularreport_obj.render_html(cr, uid, ids, data=data, context=context) except: pass report = self._get_report_from_name(cr, uid, report_name) report_obj = self.pool[report.model] docs = report_obj.browse(cr, uid, ids, context=context) docargs = { 'doc_ids': ids, 'doc_model': report.model, 'docs': docs, } return self.render(cr, uid, [], report.report_name, docargs, context=context) def get_pdf(self, cr, uid, ids, report_name, html=None, data=None, context=None): """This method generates and returns pdf version of a report. """ if context is None: context = {} if isinstance(ids, (str, unicode)): ids = [int(i) for i in ids.split(',')] if isinstance(ids, list): ids = list(set(ids)) if isinstance(ids, int): ids = [ids] if html is None: html = self.get_html(cr, uid, ids, report_name, data=data, context=context) html = html.decode('utf-8') # Get the ir.actions.report.xml record we are working on. report = self._get_report_from_name(cr, uid, report_name) # Check attachment_use field. If set to true and an existing pdf is already saved, load # this one now. If not, mark save it. save_in_attachment = {} if report.attachment_use is True: save_in_attachment['model'] = report.model save_in_attachment['loaded_documents'] = {} for record_id in ids: obj = self.pool[report.model].browse(cr, uid, record_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', '=', record_id)] attach_ids = self.pool['ir.attachment'].search(cr, uid, alreadyindb) if attach_ids: # Add the loaded pdf in the loaded_documents list pdf = self.pool['ir.attachment'].browse(cr, uid, attach_ids[0]).datas pdf = base64.decodestring(pdf) save_in_attachment['loaded_documents'][record_id] = pdf _logger.info('The PDF document %s was loaded from the database' % filename) else: # Mark current document to be saved save_in_attachment[id] = filename # Get the paperformat associated to the report, otherwise fallback on the company one. if not report.paperformat_id: user = self.pool['res.users'].browse(cr, uid, uid) paperformat = user.company_id.paperformat_id else: paperformat = report.paperformat_id # Preparing the minimal html pages #subst = self._get_url_content('/report/static/src/js/subst.js')[0] # Used in age numbering subst = " " css = '' # Will contain local css headerhtml = [] contenthtml = [] footerhtml = [] base_url = self.pool['ir.config_parameter'].get_param(cr, uid, 'web.base.url') minimalhtml = """ {1} {2} """ # The retrieved html report must be simplified. We convert it into a xml tree # via lxml in order to extract headers, footers and content. 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, base_url) headerhtml.append(header) for node in root.xpath("//div[@class='footer']"): body = lxml.html.tostring(node) footer = minimalhtml.format(css, subst, body, base_url) 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 report's content. 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, base_url) 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] # Run wkhtmltopdf process pdf = self._generate_wkhtml_pdf( cr, uid, headerhtml, footerhtml, contenthtml, context.get('landscape'), paperformat, specific_paperformat_args, save_in_attachment ) return pdf def get_action(self, cr, uid, ids, report_name, datas=None, context=None): """Return an action of type ir.actions.report.xml. :param report_name: Name of the template to generate an action for """ # TODO: return the action for the ids passed in args if context is None: context = {} if datas is None: datas = {} report_obj = self.pool.get('ir.actions.report.xml') idreport = report_obj.search(cr, uid, [('report_name', '=', report_name)], context=context) try: report = report_obj.browse(cr, uid, idreport[0], context=context) except IndexError: raise osv.except_osv(_('Bad Report'), _('This report is not loaded into the database.')) action = { 'type': 'ir.actions.report.xml', 'report_name': report.report_name, 'report_type': report.report_type, 'report_file': report.report_file, } if datas: action['datas'] = datas return action #-------------------------------------------------------------------------- # Report generation helpers #-------------------------------------------------------------------------- def check_wkhtmltopdf(self): """Check the presence of wkhtmltopdf and return its version. If wkhtmltopdf cannot be found, return False. """ try: process = subprocess.Popen(['wkhtmltopdf', '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = process.communicate() if err: raise version = out.splitlines()[1].strip() version = version.split(' ')[1] if LooseVersion(version) < LooseVersion('0.12.0'): _logger.warning('Upgrade WKHTMLTOPDF to (at least) 0.12.0') return 'upgrade' return True except: _logger.error('You need WKHTMLTOPDF to print a pdf version of this report.') return False def _generate_wkhtml_pdf(self, cr, uid, 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'] tmp_dir = tempfile.gettempdir() command_args = [] # Passing the cookie to wkhtmltopdf in order to resolve URL. try: from openerp.addons.web.http import request command_args.extend(['--cookie', 'session_id', request.httprequest.cookies['session_id']]) except: pass # Display arguments 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.flush() 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.flush() 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.flush() 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.flush() try: # If the server is running with only one worker, increase it to two to be able # to serve the http request from wkhtmltopdf. if config['workers'] == 1: ppid = psutil.Process(os.getpid()).ppid os.kill(ppid, signal.SIGTTIN) 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 config['workers'] == 1: os.kill(ppid, signal.SIGTTOU) if process.returncode != 0: raise osv.except_osv(_('Report (PDF)'), _('wkhtmltopdf 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], } self.pool['ir.attachment'].create(cr, uid, attachment) _logger.info('The PDF document %s is now saved in the ' 'database' % attachment['name']) pdfreport.flush() 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 _get_report_from_name(self, cr, uid, report_name): """Get the first record of ir.actions.report.xml having the ``report_name`` as value for the field report_name. """ report_obj = self.pool['ir.actions.report.xml'] qwebtypes = ['qweb-pdf', 'qweb-html'] conditions = [('report_type', 'in', qwebtypes), ('report_name', '=', report_name)] idreport = report_obj.search(cr, uid, conditions)[0] return report_obj.browse(cr, uid, idreport) def _build_wkhtmltopdf_args(self, paperformat, specific_paperformat_args=None): """Build arguments understandable by wkhtmltopdf from a report.paperformat record. :paperformat: report.paperformat record :specific_paperformat_args: a dict containing prioritized wkhtmltopdf arguments :returns: list of string representing 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'])]) elif paperformat.margin_top: 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 _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 = cStringIO.StringIO() writer.write(merged) merged.seek(0) content = merged.read() merged.close() return content def eval_params(self, dict_param): """Parse a dict generated by the webclient (javascript) into a python dict. """ for key, value in dict_param.iteritems(): if value.lower() == 'false': dict_param[key] = False elif value.lower() == 'true': dict_param[key] = True elif ',' in value: dict_param[key] = [int(i) for i in value.split(',')] elif '%2C' in value: dict_param[key] = [int(i) for i in value.split('%2C')] else: try: i = int(value) dict_param[key] = i except (ValueError, TypeError): pass data = {} data['form'] = dict_param return data