[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
This commit is contained in:
Laurent Smet
2022-04-29 11:00:56 +02:00
parent ce08fba382
commit 7c4e591d58
5 changed files with 296 additions and 344 deletions
+27 -43
View File
@@ -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
+21 -9
View File
@@ -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
+50 -37
View File
@@ -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 = '''
<cac:AdditionalDocumentReference
xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2">
<cbc:ID>%s</cbc:ID>
<cac:Attachment>
<cbc:EmbeddedDocumentBinaryObject mimeCode="application/pdf" filename=%s>
%s
</cbc:EmbeddedDocumentBinaryObject>
</cac:Attachment>
</cac:AdditionalDocumentReference>
''' % (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 = '''
<cac:AdditionalDocumentReference
xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2">
<cbc:ID>%s</cbc:ID>
<cac:Attachment>
<cbc:EmbeddedDocumentBinaryObject mimeCode="application/pdf" filename=%s>
%s
</cbc:EmbeddedDocumentBinaryObject>
</cac:Attachment>
</cac:AdditionalDocumentReference>
''' % (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
+2 -1
View File
@@ -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)
+196 -254
View File
@@ -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 <h?> 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 <h?> 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