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