From 7c4e591d58dd2b882f0c237d52490b2756c94203 Mon Sep 17 00:00:00 2001 From: Laurent Smet Date: Thu, 20 Jan 2022 16:38:03 +0000 Subject: [PATCH] [REF] base,account: Refactor PDF generation to allow CH QR-bills For the Switzerland localization, we need to append some extra pages during the PDF generation such as QR barcodes or ISR payment slip. There were multiple difficulties with the current codes: - The ir.attachment was generated before the hook allowing to append the extra pages. - There is no existing hook allowing to get the pages for each invoice when printing on multiple records at once. This commit aims to dispatch the generation of PDFs from the creating of extra attachments: - `_render_qweb_pdf_prepare_streams` is called first and creates a stream for each record containing the PDF pages. - Then, the ir.attachment are generated if necessary. - Finally, all streams are aggregated together to get the final PDF. Task: 2726507 Part-of: odoo/odoo#85150 --- addons/account/models/ir_actions_report.py | 70 ++- .../account_edi/models/ir_actions_report.py | 30 +- .../l10n_be_edi/models/ir_actions_report.py | 87 ++-- addons/l10n_be_edi/tests/test_efff_export.py | 3 +- odoo/addons/base/models/ir_actions_report.py | 450 ++++++++---------- 5 files changed, 296 insertions(+), 344 deletions(-) diff --git a/addons/account/models/ir_actions_report.py b/addons/account/models/ir_actions_report.py index cd9ea71b45f..fa61961eed4 100644 --- a/addons/account/models/ir_actions_report.py +++ b/addons/account/models/ir_actions_report.py @@ -1,58 +1,42 @@ # -*- coding: utf-8 -*- +import io + +from collections import OrderedDict from odoo import models, _ from odoo.exceptions import UserError -from odoo.tools import pdf class IrActionsReport(models.Model): _inherit = 'ir.actions.report' - def retrieve_attachment(self, record): - # get the original bills through the message_main_attachment_id field of the record - if self.report_name == 'account.report_original_vendor_bill' and record.message_main_attachment_id: - if record.message_main_attachment_id.mimetype == 'application/pdf' or \ - record.message_main_attachment_id.mimetype.startswith('image'): - return record.message_main_attachment_id - return super(IrActionsReport, self).retrieve_attachment(record) + def _render_qweb_pdf_prepare_streams(self, data, res_ids=None): + # Custom behavior for 'account.report_original_vendor_bill'. + if self.report_name != 'account.report_original_vendor_bill': + return super()._render_qweb_pdf_prepare_streams(data, res_ids=res_ids) - def _post_pdf(self, save_in_attachment, pdf_content=None, res_ids=None): - # don't include the generated dummy report - if self.report_name == 'account.report_original_vendor_bill': - pdf_content = None - res_ids = None - if not save_in_attachment: - raise UserError(_("No original vendor bills could be found for any of the selected vendor bills.")) - return super(IrActionsReport, self)._post_pdf(save_in_attachment, pdf_content=pdf_content, res_ids=res_ids) + invoices = self.env['account.move'].browse(res_ids) + if any(x.move_type not in ('in_invoice', 'in_receipt') for x in invoices): + raise UserError(_("You can only print the original document for vendor bills.")) - def _postprocess_pdf_report(self, record, buffer): - # don't save the 'account.report_original_vendor_bill' report as it's just a mean to print existing attachments - if self.report_name == 'account.report_original_vendor_bill': - return None - res = super(IrActionsReport, self)._postprocess_pdf_report(record, buffer) - if self.model == 'account.move' and record.state == 'posted' and record.is_sale_document(include_receipts=True): - attachment = self.retrieve_attachment(record) - if attachment: - attachment.register_as_main_attachment(force=False) - return res + original_attachments = invoices.message_main_attachment_id + if not original_attachments: + raise UserError(_("No original vendor bills could be found for any of the selected vendor bills.")) + + collected_streams = OrderedDict() + for invoice in invoices: + if invoice.message_main_attachment_id: + collected_streams[invoice.id] = { + 'stream': io.BytesIO(invoice.message_main_attachment_id.raw), + 'attachment': invoice.message_main_attachment_id, + } + return collected_streams def _render_qweb_pdf(self, res_ids=None, data=None): - # Overridden so that the print > invoices actions raises an error - # when trying to print a miscellaneous operation instead of an invoice. - if self.model == 'account.move' and res_ids: - invoice_reports = (self.env.ref('account.account_invoices_without_payment'), self.env.ref('account.account_invoices')) - if self in invoice_reports: - moves = self.env['account.move'].browse(res_ids) - if any(not move.is_invoice(include_receipts=True) for move in moves): - raise UserError(_("Only invoices could be printed.")) + # Check for reports only available for invoices. + if self.report_name in ('account.report_invoice_with_payments', 'account.report_invoice'): + invoices = self.env['account.move'].browse(res_ids) + if any(x.move_type == 'entry' for x in invoices): + raise UserError(_("Only invoices could be printed.")) return super()._render_qweb_pdf(res_ids=res_ids, data=data) - - def _retrieve_stream_from_attachment(self, attachment): - # Overridden in order to add a banner in the upper right corner of the exported Vendor Bill PDF. - stream = super()._retrieve_stream_from_attachment(attachment) - vendor_bill_export = self.env.ref('account.action_account_original_vendor_bill') - if self == vendor_bill_export and attachment.mimetype == 'application/pdf': - record = self.env[attachment.res_model].browse(attachment.res_id) - return pdf.add_banner(stream, record.name, logo=True) - return stream diff --git a/addons/account_edi/models/ir_actions_report.py b/addons/account_edi/models/ir_actions_report.py index fd0c767974f..611849d34e9 100644 --- a/addons/account_edi/models/ir_actions_report.py +++ b/addons/account_edi/models/ir_actions_report.py @@ -9,24 +9,36 @@ from odoo.tools.pdf import OdooPdfFileReader, OdooPdfFileWriter class IrActionsReport(models.Model): _inherit = 'ir.actions.report' - def _post_pdf(self, save_in_attachment, pdf_content=None, res_ids=None): - # OVERRIDE to embed some EDI documents inside the PDF. - if self.model == 'account.move' and res_ids and len(res_ids) == 1 and pdf_content: + def _render_qweb_pdf_prepare_streams(self, data, res_ids=None): + # EXTENDS base + collected_streams = super()._render_qweb_pdf_prepare_streams(data, res_ids=res_ids) + + if collected_streams \ + and res_ids \ + and len(res_ids) == 1 \ + and self.report_name in ('account.report_invoice_with_payments', 'account.report_invoice'): invoice = self.env['account.move'].browse(res_ids) if invoice.is_sale_document() and invoice.state != 'draft': to_embed = invoice.edi_document_ids # Add the attachments to the pdf file if to_embed: + pdf_stream = collected_streams[invoice.id]['stream'] + + # Read pdf content. + pdf_content = pdf_stream.getvalue() reader_buffer = io.BytesIO(pdf_content) reader = OdooPdfFileReader(reader_buffer, strict=False) + + # Post-process and embed the additional files. writer = OdooPdfFileWriter() writer.cloneReaderDocumentRoot(reader) for edi_document in to_embed: edi_document.edi_format_id._prepare_invoice_report(writer, edi_document) - buffer = io.BytesIO() - writer.write(buffer) - pdf_content = buffer.getvalue() - reader_buffer.close() - buffer.close() - return super(IrActionsReport, self)._post_pdf(save_in_attachment, pdf_content=pdf_content, res_ids=res_ids) + # Replace the current content. + pdf_stream.close() + new_pdf_stream = io.BytesIO() + writer.write(new_pdf_stream) + collected_streams[invoice.id]['stream'] = new_pdf_stream + + return collected_streams diff --git a/addons/l10n_be_edi/models/ir_actions_report.py b/addons/l10n_be_edi/models/ir_actions_report.py index da85872ef5c..057e7bd5c39 100644 --- a/addons/l10n_be_edi/models/ir_actions_report.py +++ b/addons/l10n_be_edi/models/ir_actions_report.py @@ -11,43 +11,56 @@ from xml.sax.saxutils import escape, quoteattr class IrActionsReport(models.Model): _inherit = 'ir.actions.report' - def _postprocess_pdf_report(self, record, buffer): - '''Add the pdf report in the e-fff XML as base64 string. - ''' - result = super()._postprocess_pdf_report(record, buffer) + def _l10n_be_edi_add_pdf_into_invoice_xml(self, invoice, stream_data): + edi_attachment = invoice.edi_document_ids.filtered(lambda d: d.edi_format_id.code == 'efff_1').attachment_id + if not edi_attachment: + return - if record._name == 'account.move': - edi_attachment = record.edi_document_ids.filtered(lambda d: d.edi_format_id.code == 'efff_1').attachment_id - if edi_attachment: - old_xml = base64.b64decode(edi_attachment.with_context(bin_size=False).datas, validate=True) - tree = etree.fromstring(old_xml) - anchor_elements = tree.xpath("//*[local-name()='AccountingSupplierParty']") - additional_document_elements = tree.xpath("//*[local-name()='AdditionalDocumentReference']") - if anchor_elements and not additional_document_elements: - pdf = base64.b64encode(buffer.getvalue()).decode() - pdf_name = '%s.pdf' % record._get_efff_name() - to_inject = ''' - - %s - - - %s - - - - ''' % (escape(pdf_name), quoteattr(pdf_name), pdf) + tree = etree.fromstring(edi_attachment.raw) + anchor_elements = tree.xpath("//*[local-name()='AccountingSupplierParty']") + additional_document_elements = tree.xpath("//*[local-name()='AdditionalDocumentReference']") + if anchor_elements and not additional_document_elements: + pdf_stream = stream_data['stream'] - anchor_index = tree.index(anchor_elements[0]) - tree.insert(anchor_index, etree.fromstring(to_inject)) - new_xml = etree.tostring(tree, pretty_print=True) - edi_attachment.write({ - 'res_model': 'account.move', - 'res_id': record.id, - 'datas': base64.b64encode(new_xml), - 'mimetype': 'application/xml', - }) + # Read pdf content. + pdf_content_b64 = base64.b64encode(pdf_stream.getvalue()).decode() + pdf_name = f'{invoice._get_efff_name()}.pdf' - return result + # Inject the newly generated PDF. + to_inject = ''' + + %s + + + %s + + + + ''' % (escape(pdf_name), quoteattr(pdf_name), pdf_content_b64) + + anchor_index = tree.index(anchor_elements[0]) + tree.insert(anchor_index, etree.fromstring(to_inject)) + new_xml = etree.tostring(tree, pretty_print=True) + edi_attachment.write({ + 'res_model': 'account.move', + 'res_id': invoice.id, + 'raw': new_xml, + 'mimetype': 'application/xml', + }) + + def _render_qweb_pdf_prepare_streams(self, data, res_ids=None): + # EXTENDS base + # Add the pdf report in the e-fff XML as base64 string. + collected_streams = super()._render_qweb_pdf_prepare_streams(data, res_ids=res_ids) + + if collected_streams \ + and res_ids \ + and self.report_name in ('account.report_invoice_with_payments', 'account.report_invoice'): + for res_id, stream_data in collected_streams.items(): + invoice = self.env['account.move'].browse(res_id) + self._l10n_be_edi_add_pdf_into_invoice_xml(invoice, stream_data) + + return collected_streams diff --git a/addons/l10n_be_edi/tests/test_efff_export.py b/addons/l10n_be_edi/tests/test_efff_export.py index a4a28f267c7..dd5e79f6e7d 100644 --- a/addons/l10n_be_edi/tests/test_efff_export.py +++ b/addons/l10n_be_edi/tests/test_efff_export.py @@ -59,7 +59,8 @@ class TestUBLBE(AccountEdiTestCommon): pdf_writer = PdfFileWriter() pdf_writer.addBlankPage(42, 42) pdf_writer.write(pdf_buffer) - self.env.ref('account.account_invoices_without_payment')._postprocess_pdf_report(invoice, pdf_buffer) + stream_data = {'stream': pdf_buffer} + self.env.ref('account.account_invoices_without_payment')._l10n_be_edi_add_pdf_into_invoice_xml(invoice, stream_data) pdf_buffer.close() attachment = invoice._get_edi_attachment(self.edi_format) diff --git a/odoo/addons/base/models/ir_actions_report.py b/odoo/addons/base/models/ir_actions_report.py index d3be54967db..8a954087d4c 100644 --- a/odoo/addons/base/models/ir_actions_report.py +++ b/odoo/addons/base/models/ir_actions_report.py @@ -7,11 +7,9 @@ from odoo.exceptions import UserError, AccessError from odoo.tools.safe_eval import safe_eval, time from odoo.tools.misc import find_in_path from odoo.tools import config, is_html_empty -from odoo.sql_db import TestCursor from odoo.http import request from odoo.osv.expression import NEGATIVE_TERM_OPERATORS, FALSE_DOMAIN -import base64 import io import logging import os @@ -188,16 +186,6 @@ class IrActionsReport(models.Model): #-------------------------------------------------------------------------- # Main report methods #-------------------------------------------------------------------------- - def _retrieve_stream_from_attachment(self, attachment): - #This import is needed to make sure a PDF stream can be saved in Image - from PIL import PdfImagePlugin - if attachment.mimetype.startswith('image'): - stream = io.BytesIO(base64.b64decode(attachment.datas)) - img = Image.open(stream) - output_stream = io.BytesIO() - img.convert("RGB").save(output_stream, format="pdf") - return output_stream - return io.BytesIO(base64.decodebytes(attachment.datas)) def retrieve_attachment(self, record): '''Retrieve an attachment for a specific record. @@ -214,34 +202,6 @@ class IrActionsReport(models.Model): ('res_id', '=', record.id) ], limit=1) - def _postprocess_pdf_report(self, record, buffer): - '''Hook to handle post-processing during the pdf report generation. - - The basic behavior consists of creating a new attachment containing the - pdf base64 encoded. - - :param record: The record that will own the attachment. - :param io.BytesIO buffer: The PDF content as a bytes buffer. - :return: The input buffer. - ''' - attachment_name = safe_eval(self.attachment, {'object': record, 'time': time}) - if not attachment_name: - return None - attachment_vals = { - 'name': attachment_name, - 'raw': buffer.getvalue(), - 'res_model': self.model, - 'res_id': record.id, - 'type': 'binary', - } - try: - self.env['ir.attachment'].create(attachment_vals) - except AccessError: - _logger.info("Cannot save PDF report %r as attachment", attachment_vals['name']) - else: - _logger.info('The PDF document %s is now saved in the database', attachment_vals['name']) - return buffer - @api.model def get_wkhtmltopdf_state(self): '''Get the current state of wkhtmltopdf: install, ok, upgrade, workers or broken. @@ -641,154 +601,22 @@ class IrActionsReport(models.Model): ) return view_obj._render_template(template, values).encode() - def _post_pdf(self, save_in_attachment, pdf_content=None, res_ids=None): - '''Merge the existing attachments by adding one by one the content of the attachments - and then, we add the pdf_content if exists. Create the attachments for each record individually - if required. - - :param save_in_attachment: The retrieved attachments as map record.id -> attachment_id. - :param pdf_content: The pdf content newly generated by wkhtmltopdf. - :param res_ids: the ids of record to allow postprocessing. - :return: The pdf content of the merged pdf. - ''' - - def close_streams(streams): - for stream in streams: - try: - stream.close() - except Exception: - pass - - # Check special case having only one record with existing attachment. - # In that case, return directly the attachment content. - # In that way, we also ensure the embedded files are well preserved. - if len(save_in_attachment) == 1 and not pdf_content: - return list(save_in_attachment.values())[0].getvalue() - - # Create a list of streams representing all sub-reports part of the final result - # in order to append the existing attachments and the potentially modified sub-reports - # by the _postprocess_pdf_report calls. - streams = [] - - # In wkhtmltopdf has been called, we need to split the pdf in order to call the postprocess method. - if pdf_content: - pdf_content_stream = io.BytesIO(pdf_content) - # Build a record_map mapping id -> record - record_map = {r.id: r for r in self.env[self.model].browse([res_id for res_id in res_ids if res_id])} - - # If no value in attachment or no record specified, only append the whole pdf. - if not record_map or not self.attachment: - streams.append(pdf_content_stream) - else: - if len(res_ids) == 1: - # Only one record, so postprocess directly and append the whole pdf. - if res_ids[0] in record_map and not res_ids[0] in save_in_attachment: - new_stream = self._postprocess_pdf_report(record_map[res_ids[0]], pdf_content_stream) - # If the buffer has been modified, mark the old buffer to be closed as well. - if new_stream and new_stream != pdf_content_stream: - close_streams([pdf_content_stream]) - pdf_content_stream = new_stream - streams.append(pdf_content_stream) - else: - # In case of multiple docs, we need to split the pdf according the records. - # To do so, we split the pdf based on top outlines computed by wkhtmltopdf. - # An outline is a html tag found on the document. To retrieve this table, - # we look on the pdf structure using pypdf to compute the outlines_pages from - # the top level heading in /Outlines. - reader = PdfFileReader(pdf_content_stream) - root = reader.trailer['/Root'] - if '/Outlines' in root and '/First' in root['/Outlines']: - outlines_pages = [] - node = root['/Outlines']['/First'] - while True: - outlines_pages.append(root['/Dests'][node['/Dest']][0]) - if '/Next' not in node: - break - node = node['/Next'] - outlines_pages = sorted(set(outlines_pages)) - # There should be only one top-level heading by document - assert len(outlines_pages) == len(res_ids) - # There should be a top-level heading on first page - assert outlines_pages[0] == 0 - for i, num in enumerate(outlines_pages): - to = outlines_pages[i + 1] if i + 1 < len(outlines_pages) else reader.numPages - attachment_writer = PdfFileWriter() - for j in range(num, to): - attachment_writer.addPage(reader.getPage(j)) - stream = io.BytesIO() - attachment_writer.write(stream) - if res_ids[i] and res_ids[i] not in save_in_attachment: - new_stream = self._postprocess_pdf_report(record_map[res_ids[i]], stream) - # If the buffer has been modified, mark the old buffer to be closed as well. - if new_stream and new_stream != stream: - close_streams([stream]) - stream = new_stream - streams.append(stream) - close_streams([pdf_content_stream]) - else: - # If no outlines available, do not save each record - streams.append(pdf_content_stream) - - # If attachment_use is checked, the records already having an existing attachment - # are not been rendered by wkhtmltopdf. So, create a new stream for each of them. - if self.attachment_use: - for stream in save_in_attachment.values(): - streams.append(stream) - - # Build the final pdf. - # If only one stream left, no need to merge them (and then, preserve embedded files). - if len(streams) == 1: - result = streams[0].getvalue() - else: - try: - result = self._merge_pdfs(streams) - except utils.PdfReadError: - raise UserError(_("One of the documents you are trying to merge is encrypted")) - - # We have to close the streams after PdfFileWriter's call to write() - close_streams(streams) - return result - - def _get_unreadable_pdfs(self, streams): - unreadable_streams = [] - - for stream in streams: - writer = PdfFileWriter() - result_stream = io.BytesIO() - try: - reader = PdfFileReader(stream) - writer.appendPagesFromReader(reader) - writer.write(result_stream) - except utils.PdfReadError: - unreadable_streams.append(stream) - - return unreadable_streams - - def _raise_on_unreadable_pdfs(self, streams, stream_record): - unreadable_pdfs = self._get_unreadable_pdfs(streams) - if unreadable_pdfs: - records = [stream_record[s].name for s in unreadable_pdfs if s in stream_record] - raise UserError(_( - "Odoo is unable to merge the PDFs attached to the following records:\n" - "%s\n\n" - "Please exclude them from the selection to continue. It's possible to " - "still retrieve those PDFs by selecting each of the affected records " - "individually, which will avoid merging.") % "\n".join(records)) - + @api.model def _merge_pdfs(self, streams): writer = PdfFileWriter() for stream in streams: - reader = PdfFileReader(stream) - writer.appendPagesFromReader(reader) + try: + reader = PdfFileReader(stream) + writer.appendPagesFromReader(reader) + except utils.PdfReadError: + raise UserError(_("Odoo is unable to merge the generated PDFs.")) result_stream = io.BytesIO() streams.append(result_stream) writer.write(result_stream) - return result_stream.getvalue() + return result_stream - def _render_qweb_pdf(self, res_ids=None, data=None): - """ - :rtype: bytes - """ + def _render_qweb_pdf_prepare_streams(self, data, res_ids=None): + self.ensure_one() if not data: data = {} data.setdefault('report_type', 'pdf') @@ -796,88 +624,202 @@ class IrActionsReport(models.Model): # access the report details with sudo() but evaluation context as sudo(False) self_sudo = self.sudo() + collected_streams = OrderedDict() + + # Fetch the existing attachments from the database for later use. + # Reload the stream from the attachment in case of 'attachment_use'. + if res_ids: + records = self.env[self_sudo.model].browse(res_ids) + for record in records: + stream = None + attachment = None + if self_sudo.attachment: + attachment = self_sudo.retrieve_attachment(record) + + # Extract the stream from the attachment. + if attachment and self_sudo.attachment_use: + stream = io.BytesIO(attachment.raw) + + # Ensure the stream can be saved in Image. + if attachment.mimetype.startswith('image'): + img = Image.open(stream) + new_stream = io.BytesIO() + img.convert("RGB").save(new_stream, format="pdf") + stream.close() + stream = new_stream + + collected_streams[record.id] = { + 'stream': stream, + 'attachment': attachment, + } + + # Call 'wkhtmltopdf' to generate the missing streams. + res_ids_wo_stream = [res_id for res_id, stream_data in collected_streams.items() if not stream_data['stream']] + is_whtmltopdf_needed = not res_ids or res_ids_wo_stream + + if is_whtmltopdf_needed: + + if self.get_wkhtmltopdf_state() == 'install': + # wkhtmltopdf is not installed + # the call should be catched before (cf /report/check_wkhtmltopdf) but + # if get_pdf is called manually (email template), the check could be + # bypassed + raise UserError(_("Unable to find Wkhtmltopdf on this system. The PDF can not be created.")) + + # Disable the debug mode in the PDF rendering in order to not split the assets bundle + # into separated files to load. This is done because of an issue in wkhtmltopdf + # failing to load the CSS/Javascript resources in time. + # Without this, the header/footer of the reports randomly disappear + # because the resources files are not loaded in time. + # https://github.com/wkhtmltopdf/wkhtmltopdf/issues/2083 + additional_context = {'debug': False} + + # As the assets are generated during the same transaction as the rendering of the + # templates calling them, there is a scenario where the assets are unreachable: when + # you make a request to read the assets while the transaction creating them is not done. + # Indeed, when you make an asset request, the controller has to read the `ir.attachment` + # table. + # This scenario happens when you want to print a PDF report for the first time, as the + # assets are not in cache and must be generated. To workaround this issue, we manually + # commit the writes in the `ir.attachment` table. It is done thanks to a key in the context. + if not config['test_enable']: + additional_context['commit_assetsbundle'] = True + + html = self_sudo.with_context(**additional_context)._render_qweb_html(res_ids_wo_stream, data=data)[0] + + bodies, html_ids, header, footer, specific_paperformat_args = self._prepare_html(html, report_model=self_sudo.model) + + if self_sudo.attachment and set(res_ids) != set(html_ids): + raise UserError(_( + "The report's template %r is wrong, please contact your administrator. \n\n" + "Can not separate file to save as attachment because the report's template does not contains the" + " attributes 'data-oe-model' and 'data-oe-id' on the div with 'article' classname.", + self.name, + )) + + pdf_content = self._run_wkhtmltopdf( + bodies, + header=header, + footer=footer, + landscape=self._context.get('landscape'), + specific_paperformat_args=specific_paperformat_args, + set_viewport_size=self._context.get('set_viewport_size'), + ) + pdf_content_stream = io.BytesIO(pdf_content) + + # Printing a PDF report without any records. The content could be returned directly. + if not res_ids: + return { + False: { + 'stream': pdf_content_stream, + 'attachment': None, + } + } + + # Split the pdf for each record using the PDF outlines. + + if len(res_ids_wo_stream) == 1: + # Only one record: append the whole PDF. + collected_streams[res_ids_wo_stream[0]]['stream'] = pdf_content_stream + else: + # In case of multiple docs, we need to split the pdf according the records. + # To do so, we split the pdf based on top outlines computed by wkhtmltopdf. + # An outline is a html tag found on the document. To retrieve this table, + # we look on the pdf structure using pypdf to compute the outlines_pages from + # the top level heading in /Outlines. + reader = PdfFileReader(pdf_content_stream) + root = reader.trailer['/Root'] + has_valid_outlines = '/Outlines' in root and '/First' in root['/Outlines'] + if not has_valid_outlines: + return {False: { + 'report_action': self, + 'stream': pdf_content_stream, + 'attachment': None, + }} + + outlines_pages = [] + node = root['/Outlines']['/First'] + while True: + outlines_pages.append(root['/Dests'][node['/Dest']][0]) + if '/Next' not in node: + break + node = node['/Next'] + outlines_pages = sorted(set(outlines_pages)) + # There should be only one top-level heading by document + assert len(outlines_pages) == len(res_ids_wo_stream) + # There should be a top-level heading on first page + assert outlines_pages[0] == 0 + for i, num in enumerate(outlines_pages): + to = outlines_pages[i + 1] if i + 1 < len(outlines_pages) else reader.numPages + attachment_writer = PdfFileWriter() + for j in range(num, to): + attachment_writer.addPage(reader.getPage(j)) + stream = io.BytesIO() + attachment_writer.write(stream) + collected_streams[res_ids_wo_stream[i]]['stream'] = stream + + return collected_streams + + def _render_qweb_pdf(self, res_ids=None, data=None): + self.ensure_one() + if not data: + data = {} + data.setdefault('report_type', 'pdf') + self_sudo = self.sudo() + # In case of test environment without enough workers to perform calls to wkhtmltopdf, # fallback to render_html. if (tools.config['test_enable'] or tools.config['test_file']) and not self.env.context.get('force_report_rendering'): return self_sudo._render_qweb_html(res_ids, data=data) - # As the assets are generated during the same transaction as the rendering of the - # templates calling them, there is a scenario where the assets are unreachable: when - # you make a request to read the assets while the transaction creating them is not done. - # Indeed, when you make an asset request, the controller has to read the `ir.attachment` - # table. - # This scenario happens when you want to print a PDF report for the first time, as the - # assets are not in cache and must be generated. To workaround this issue, we manually - # commit the writes in the `ir.attachment` table. It is done thanks to a key in the context. - context = dict(self.env.context) - if not config['test_enable']: - context['commit_assetsbundle'] = True + collected_streams = self._render_qweb_pdf_prepare_streams(data, res_ids=res_ids) - # Disable the debug mode in the PDF rendering in order to not split the assets bundle - # into separated files to load. This is done because of an issue in wkhtmltopdf - # failing to load the CSS/Javascript resources in time. - # Without this, the header/footer of the reports randomly disappear - # because the resources files are not loaded in time. - # https://github.com/wkhtmltopdf/wkhtmltopdf/issues/2083 - context['debug'] = False + # Generate the ir.attachment if needed. + if self.attachment: + attachment_vals_list = [] + for res_id, stream_data in collected_streams.items(): + # An attachment already exists. + if stream_data['attachment']: + continue + + record = self.env[self_sudo.model].browse(res_id) + attachment_name = safe_eval(self.attachment, {'object': record, 'time': time}) + + # Unable to compute a name for the attachment. + if not attachment_name: + continue + + attachment_vals_list.append({ + 'name': attachment_name, + 'raw': stream_data['stream'].getvalue(), + 'res_model': self.model, + 'res_id': record.id, + 'type': 'binary', + }) + + if attachment_vals_list: + attachment_names = ', '.join(x['name'] for x in attachment_vals_list) + try: + self.env['ir.attachment'].create(attachment_vals_list) + except AccessError: + _logger.info("Cannot save PDF report %r attachments for user %r", attachment_names, self.env.user.display_name) + else: + _logger.info("The PDF documents %r are now saved in the database", attachment_names) + + # Merge all streams together for a single record. + streams_to_merge = [x['stream'] for x in collected_streams.values()] + if len(streams_to_merge) == 1: + pdf_content = streams_to_merge[0].getvalue() + else: + with self._merge_pdfs(streams_to_merge) as pdf_merged_stream: + pdf_content = pdf_merged_stream.getvalue() + + for stream in streams_to_merge: + stream.close() - save_in_attachment = OrderedDict() - # Maps the streams in `save_in_attachment` back to the records they came from - stream_record = dict() if res_ids: - # Dispatch the records by ones having an attachment and ones requesting a call to - # wkhtmltopdf. - Model = self.env[self_sudo.model] - record_ids = Model.browse(res_ids) - wk_record_ids = Model - if self_sudo.attachment: - for record_id in record_ids: - attachment = self_sudo.retrieve_attachment(record_id) - if attachment: - stream = self_sudo._retrieve_stream_from_attachment(attachment) - save_in_attachment[record_id.id] = stream - stream_record[stream] = record_id - if not self_sudo.attachment_use or not attachment: - wk_record_ids += record_id - else: - wk_record_ids = record_ids - res_ids = wk_record_ids.ids + _logger.info("The PDF report has been generated for model: %s, records %s.", self_sudo.model, str(res_ids)) - # A call to wkhtmltopdf is mandatory in 2 cases: - # - The report is not linked to a record. - # - The report is not fully present in attachments. - if save_in_attachment and not res_ids: - _logger.info('The PDF report has been generated from attachments.') - self._raise_on_unreadable_pdfs(save_in_attachment.values(), stream_record) - return self_sudo._post_pdf(save_in_attachment), 'pdf' - - if self.get_wkhtmltopdf_state() == 'install': - # wkhtmltopdf is not installed - # the call should be catched before (cf /report/check_wkhtmltopdf) but - # if get_pdf is called manually (email template), the check could be - # bypassed - raise UserError(_("Unable to find Wkhtmltopdf on this system. The PDF can not be created.")) - - html = self_sudo.with_context(context)._render_qweb_html(res_ids, data=data)[0] - - bodies, html_ids, header, footer, specific_paperformat_args = self.with_context(context)._prepare_html(html, report_model=self_sudo.model) - - if self_sudo.attachment and set(res_ids) != set(html_ids): - raise UserError(_("The report's template '%s' is wrong, please contact your administrator. \n\n" - "Can not separate file to save as attachment because the report's template does not contains the attributes 'data-oe-model' and 'data-oe-id' on the div with 'article' classname.") % self.name) - - pdf_content = self._run_wkhtmltopdf( - bodies, - header=header, - footer=footer, - landscape=context.get('landscape'), - specific_paperformat_args=specific_paperformat_args, - set_viewport_size=context.get('set_viewport_size'), - ) - if res_ids: - self._raise_on_unreadable_pdfs(save_in_attachment.values(), stream_record) - _logger.info('The PDF report has been generated for model: %s, records %s.' % (self_sudo.model, str(res_ids))) - return self_sudo._post_pdf(save_in_attachment, pdf_content=pdf_content, res_ids=html_ids), 'pdf' return pdf_content, 'pdf' @api.model