[ADD] account_facturx: add import/export invoice using the Factur-x standard

Factur-x is the e-invoicing standard in vigor in FR & DE and, by extension, it will probably become the standard in Europe. Thus this will be used as default e-invoicing standard by Odoo.

The idea is simple:
  * when printing your customer invoice report (not in draft ofc), a Factur-x xml file is generated and embedded inside the PDF.
  * when importing supplier bills in the form of PDF files, we detect if any Factur-x xml file is attached and complete the data of the draft vendor bill accordingly. This is done during the message_post() function.

/!\ To be compliant with the Factur-x standard, the PDF should be PDF/A-3b compliant. (See: http://fnfe-mpe.org/wp-content/uploads/2018/07/1.-FACTUR-X-V1.1-2018-07-24-FR.pdf chapter 6)
    However, wkhtmltopdf is not PDF/A compliant and then, the generated PDF report are not fully compliant with the Factur-x standard.

Was task: 1859277
Was PR #25508
This commit is contained in:
Laurent Smet
2018-08-17 17:35:39 +02:00
committed by qdp-odoo
parent b0a29c9bad
commit 380f1c09ce
7 changed files with 575 additions and 14 deletions
+3
View File
@@ -0,0 +1,3 @@
# -*- encoding: utf-8 -*-
from . import models
+13
View File
@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
{
'name' : 'Import Vendor Bills From XML',
'version' : '1.0',
'category': 'Accounting',
'depends' : ['account'],
'data': [
'data/facturx_templates.xml',
],
'installable': True,
'application': False,
'auto_install': True,
}
@@ -0,0 +1,235 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<template id="account_invoice_line_facturx_export">
<t xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100">
<ram:IncludedSupplyChainTradeLineItem>
<!-- Line number. -->
<ram:AssociatedDocumentLineDocument>
<ram:LineID t-esc="line_counter"/>
</ram:AssociatedDocumentLineDocument>
<!-- Product. -->
<ram:SpecifiedTradeProduct>
<ram:GlobalID
schemeID="0160"
t-if="line.product_id and line.product_id.barcode"
t-esc="line.product_id.barcode"/>
<ram:SellerAssignedID
t-if="line.product_id and line.product_id.default_code"
t-esc="line.product_id.default_code"/>
<ram:Name t-esc="line.name"/>
</ram:SpecifiedTradeProduct>
<!-- Amounts. -->
<ram:SpecifiedLineTradeAgreement>
<t t-set="taxes" t-value="line.invoice_line_tax_ids.compute_all(line.price_unit)"/>
<ram:GrossPriceProductTradePrice>
<ram:ChargeAmount
t-att-currencyID="currency.name"
t-esc="format_monetary(taxes['total_excluded'], currency)"/>
<!-- Discount. -->
<ram:AppliedTradeAllowanceCharge t-if="line.discount">
<ram:ChargeIndicator>
<udt:Indicator t-esc="'false' if line.discount == 1 else 'true'"/>
</ram:ChargeIndicator>
<ram:CalculationPercent t-esc="line.discount"/>
</ram:AppliedTradeAllowanceCharge>
</ram:GrossPriceProductTradePrice>
</ram:SpecifiedLineTradeAgreement>
<!-- Quantity. -->
<ram:SpecifiedLineTradeDelivery>
<ram:BilledQuantity
t-esc="line.quantity"/>
</ram:SpecifiedLineTradeDelivery>
<ram:SpecifiedLineTradeSettlement>
<t t-foreach="line.invoice_line_tax_ids" t-as="tax">
<ram:ApplicableTradeTax t-if="tax.amount_type == 'percent'">
<ram:RateApplicablePercent t-esc="tax.amount"/>
</ram:ApplicableTradeTax>
</t>
<!-- Subtotal. -->
<ram:SpecifiedTradeSettlementLineMonetarySummation>
<ram:LineTotalAmount
t-att-currencyID="currency.name"
t-esc="format_monetary(line.price_subtotal, currency)"/>
</ram:SpecifiedTradeSettlementLineMonetarySummation>
</ram:SpecifiedLineTradeSettlement>
</ram:IncludedSupplyChainTradeLineItem>
</t>
</template>
<template id="account_invoice_partner_facturx_export">
<t xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100">
<!-- Contact. -->
<ram:Name t-if="partner.name" t-esc="partner.name"/>
<ram:DefinedTradeContact>
<ram:PersonName t-esc="partner.name"/>
<ram:TelephoneUniversalCommunication t-if="partner.phone or partner.mobile">
<ram:CompleteNumber t-esc="partner.phone or partner.mobile"/>
</ram:TelephoneUniversalCommunication>
<ram:EmailURIUniversalCommunication t-if="partner.email">
<ram:URIID schemeID='SMTP' t-esc="partner.email"/>
</ram:EmailURIUniversalCommunication>
</ram:DefinedTradeContact>
<!-- Address. -->
<ram:PostalTradeAddress>
<ram:PostcodeCode t-if="partner.zip" t-esc="partner.zip"/>
<ram:LineOne t-if="partner.street" t-esc="partner.street"/>
<ram:LineTwo t-if="partner.street2" t-esc="partner.street2"/>
<ram:CityName t-if="partner.city" t-esc="partner.city"/>
<ram:CountryID t-if="partner.country_id" t-esc="partner.country_id.code"/>
</ram:PostalTradeAddress>
</t>
</template>
<template id="account_invoice_facturx_export">
<t t-set="currency" t-value="record.currency_id or record.company_currency_id"/>
<rsm:CrossIndustryInvoice
xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100">
<!-- Factur-x level:
* minimum or basicwl: urn:factur-x.eu:1p0...
* basic: urn:cen.eu:en16931:2017:compliant:factur-x.eu:1p0:basic
* en16931: urn:cen.eu:en16931:2017
-->
<rsm:ExchangedDocumentContext>
<ram:GuidelineSpecifiedDocumentContextParameter>
<ram:ID>urn:cen.eu:en16931:2017</ram:ID>
</ram:GuidelineSpecifiedDocumentContextParameter>
</rsm:ExchangedDocumentContext>
<!-- Document Headers. -->
<rsm:ExchangedDocument>
<ram:ID t-esc="record.reference"/>
<ram:TypeCode t-esc="'381' if 'refund' in record.type else '380'"/>
<ram:IssueDateTime>
<udt:DateTimeString format="102" t-esc="format_date(record.date_invoice)"/>
</ram:IssueDateTime>
<ram:IncludedNote t-if="record.comment">
<ram:Content t-esc="record.comment"/>
</ram:IncludedNote>
</rsm:ExchangedDocument>
<rsm:SupplyChainTradeTransaction>
<!-- Invoice lines. -->
<t t-set="line_counter" t-value="0"/>
<t t-foreach="record.invoice_line_ids.filtered(lambda l: not l.display_type)" t-as="line">
<t t-set="line_counter" t-value="line_counter + 1"/>
<t t-call="account_facturx.account_invoice_line_facturx_export"/>
</t>
<!-- Partners. -->
<ram:ApplicableHeaderTradeAgreement>
<!-- Seller. -->
<ram:SellerTradeParty>
<!-- Address. -->
<t t-call="account_facturx.account_invoice_partner_facturx_export">
<t t-set="partner" t-value="record.company_id.partner_id"/>
</t>
<!-- VAT. -->
<ram:SpecifiedTaxRegistration t-if="record.company_id.vat">
<ram:ID schemeID="VA" t-esc="record.company_id.vat"/>
</ram:SpecifiedTaxRegistration>
</ram:SellerTradeParty>
<!-- Customer. -->
<ram:BuyerTradeParty>
<!-- Address. -->
<t t-call="account_facturx.account_invoice_partner_facturx_export">
<t t-set="partner" t-value="record.commercial_partner_id"/>
</t>
<!-- VAT. -->
<ram:SpecifiedTaxRegistration t-if="record.commercial_partner_id.vat">
<ram:ID schemeID="VA" t-esc="record.commercial_partner_id.vat"/>
</ram:SpecifiedTaxRegistration>
</ram:BuyerTradeParty>
<!-- Reference. -->
<ram:BuyerOrderReferencedDocument>
<ram:IssuerAssignedID t-esc="'%s: %s' % str(record.number, record.name) if record.name else record.number"/>
</ram:BuyerOrderReferencedDocument>
</ram:ApplicableHeaderTradeAgreement>
<!-- Delivery. Don't make a dependency with sale only for one field. -->
<ram:ApplicableHeaderTradeDelivery>
<ram:ShipToTradeParty
t-if="'partner_shipping_id' in record._fields and record.partner_shipping_id">
<t t-call="account_facturx.account_invoice_partner_facturx_export">
<t t-set="partner" t-value="record.partner_shipping_id"/>
</t>
</ram:ShipToTradeParty>
</ram:ApplicableHeaderTradeDelivery>
<!-- Taxes. -->
<ram:ApplicableHeaderTradeSettlement>
<!-- Bank account. -->
<ram:SpecifiedTradeSettlementPaymentMeans t-if="record.partner_bank_id.acc_type == 'iban'">
<ram:PayeePartyCreditorFinancialAccount>
<ram:IBANID t-esc="record.partner_bank_id.sanitized_acc_number"/>
</ram:PayeePartyCreditorFinancialAccount>
</ram:SpecifiedTradeSettlementPaymentMeans>
<!-- Tax Summary. -->
<t t-foreach="record.tax_line_ids" t-as="tax_line">
<ram:ApplicableTradeTax>
<ram:CalculatedAmount
t-att-currencyID="currency.name"
t-esc="format_monetary(tax_line.amount, currency)"/>
<ram:BasisAmount
t-att-currencyID="currency.name"
t-esc="format_monetary(tax_line.base, currency)"/>
<ram:RateApplicablePercent
t-if="tax_line.tax_id.amount_type == 'percent'"
t-esc="tax_line.tax_id.amount"/>
</ram:ApplicableTradeTax>
</t>
<!-- Payment Term. -->
<ram:SpecifiedTradePaymentTerms>
<ram:Description t-if="record.payment_term_id" t-esc="record.payment_term_id.name"/>
<ram:DueDateDateTime t-if="record.date_due">
<udt:DateTimeString t-esc="format_date(record.date_due)"/>
</ram:DueDateDateTime>
</ram:SpecifiedTradePaymentTerms>
<!-- Summary. -->
<ram:SpecifiedTradeSettlementHeaderMonetarySummation>
<ram:LineTotalAmount
t-att-currencyID="currency.name"
t-esc="format_monetary(record.amount_untaxed, currency)"/>
<ram:TaxBasisTotalAmount
t-att-currencyID="currency.name"
t-esc="format_monetary(sum(record.tax_line_ids.mapped('base')), currency)"/>
<ram:TaxTotalAmount
t-att-currencyID="currency.name"
t-esc="format_monetary(record.amount_tax, currency)"/>
<ram:GrandTotalAmount
t-att-currencyID="currency.name"
t-esc="format_monetary(record.amount_total, currency)"/>
<ram:TotalPrepaidAmount
t-att-currencyID="currency.name"
t-esc="format_monetary(record.amount_total - record.residual, currency)"/>
<ram:DuePayableAmount
t-att-currencyID="currency.name"
t-esc="format_monetary(record.residual, currency)"/>
</ram:SpecifiedTradeSettlementHeaderMonetarySummation>
</ram:ApplicableHeaderTradeSettlement>
</rsm:SupplyChainTradeTransaction>
</rsm:CrossIndustryInvoice>
</template>
</data>
</odoo>
@@ -0,0 +1,4 @@
# -*- encoding: utf-8 -*-
from . import account_invoice
from . import ir_actions_report
@@ -0,0 +1,262 @@
# -*- coding: utf-8 -*-
from odoo import api, models, fields, tools, _
from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, pycompat, float_repr
from odoo.tests.common import Form
from datetime import datetime
from lxml import etree
from PyPDF2 import PdfFileReader
from collections import namedtuple
import io
import base64
DEFAULT_FACTURX_DATE_FORMAT = '%Y%m%d'
class AccountInvoice(models.Model):
_inherit = 'account.invoice'
_name = 'account.invoice'
@api.multi
def _export_as_facturx_xml(self):
''' Create the Factur-x xml file content.
:return: The XML content as str.
'''
self.ensure_one()
def format_date(dt):
# Format the date in the Factur-x standard.
dt = dt or datetime.now()
return dt.strftime(DEFAULT_FACTURX_DATE_FORMAT)
def format_monetary(number, currency):
# Format the monetary values to avoid trailing decimals (e.g. 90.85000000000001).
return float_repr(number, currency.decimal_places)
# Create file content.
template_values = {
'record': self,
'format_date': format_date,
'format_monetary': format_monetary,
}
content = self.env.ref('account_facturx.account_invoice_facturx_export').render(template_values)
return b"<?xml version='1.0' encoding='UTF-8'?>" + content
@api.multi
def _import_facturx_invoice(self, tree):
''' Extract invoice values from the Factur-x xml tree passed as parameter.
:param tree: The tree of the Factur-x xml file.
:return: A dictionary containing account.invoice values to create/update it.
'''
amount_total_import = None
# type must be present in the context to get the right behavior of the _default_journal method (account.invoice).
# journal_id must be present in the context to get the right behavior of the _default_account method (account.invoice.line).
self_ctx = self.with_context(type='in_invoice')
journal_id = self_ctx._default_journal().id
self_ctx = self_ctx.with_context(journal_id=journal_id)
# self could be a single record (editing) or be empty (new).
with Form(self_ctx, view='account.invoice_supplier_form') as invoice_form:
# Partner (first step to avoid warning 'Warning! You must first select a partner.').
elements = tree.xpath('//ram:SellerTradeParty/ram:Name', namespaces=tree.nsmap)
partner_name = elements and elements[0].text
elements = tree.xpath('//ram:SellerTradeParty/ram:SpecifiedTaxRegistration/ram:ID', namespaces=tree.nsmap)
partner = elements and self.env['res.partner'].search([('vat', '=', elements[0].text)], limit=1)
if not partner:
partner = elements and self.env['res.partner'].search([('name', 'ilike', partner_name)], limit=1)
if not partner:
elements = tree.xpath('//ram:SellerTradeParty//ram:URIID[@schemeID=\'SMTP\']', namespaces=tree.nsmap)
partner = elements and self.env['res.partner'].search([('email', '=', elements[0].text)], limit=1)
if partner:
invoice_form.partner_id = partner
# Reference.
elements = tree.xpath('//rsm:ExchangedDocument/ram:ID', namespaces=tree.nsmap)
if elements:
invoice_form.reference = elements[0].text
# Name.
elements = tree.xpath('//ram:BuyerOrderReferencedDocument/ram:IssuerAssignedID', namespaces=tree.nsmap)
if elements:
invoice_form.name = elements[0].text
# Comment.
elements = tree.xpath('//ram:IncludedNote/ram:Content', namespaces=tree.nsmap)
if elements:
invoice_form.comment = elements[0].text
# Refund type.
# There is two modes to handle refund in Factur-X:
# a) type_code == 380 for invoice, type_code == 381 for refund, all positive amounts.
# b) type_code == 380, negative amounts in case of refund.
# To handle both, we consider the 'a' mode and switch to 'b' if a negative amount is encountered.
elements = tree.xpath('//rsm:ExchangedDocument/ram:TypeCode', namespaces=tree.nsmap)
type_code = elements[0].text
refund_sign = 1
# Total amount.
elements = tree.xpath('//ram:GrandTotalAmount', namespaces=tree.nsmap)
if elements:
total_amount = float(elements[0].text)
# Handle 'b' refund mode.
if total_amount < 0 and type_code == '380':
refund_sign = -1
# Currency.
if elements[0].attrib.get('currencyID'):
currency_str = elements[0].attrib['currencyID']
currency = self.env.ref('base.%s' % currency_str.upper(), raise_if_not_found=False)
if currency != self.env.user.company_id.currency_id and currency.active:
invoice_form.currency_id = currency
# Store xml total amount.
amount_total_import = total_amount * refund_sign
# Date.
elements = tree.xpath('//rsm:ExchangedDocument/ram:IssueDateTime/udt:DateTimeString', namespaces=tree.nsmap)
if elements:
date_str = elements[0].text
date_obj = datetime.strptime(date_str, DEFAULT_FACTURX_DATE_FORMAT)
invoice_form.date_invoice = date_obj.strftime(DEFAULT_SERVER_DATE_FORMAT)
# Due date.
elements = tree.xpath('//ram:SpecifiedTradePaymentTerms/ram:DueDateDateTime/udt:DateTimeString', namespaces=tree.nsmap)
if elements:
date_str = elements[0].text
date_obj = datetime.strptime(date_str, DEFAULT_FACTURX_DATE_FORMAT)
invoice_form.date_due = date_obj.strftime(DEFAULT_SERVER_DATE_FORMAT)
# Invoice lines.
elements = tree.xpath('//ram:IncludedSupplyChainTradeLineItem', namespaces=tree.nsmap)
if elements:
for element in elements:
with invoice_form.invoice_line_ids.new() as invoice_line_form:
# Sequence.
line_elements = element.xpath('.//ram:AssociatedDocumentLineDocument/ram:LineID', namespaces=tree.nsmap)
if line_elements:
invoice_line_form.sequence = int(line_elements[0].text)
# Product.
line_elements = element.xpath('.//ram:SpecifiedTradeProduct/ram:Name', namespaces=tree.nsmap)
if line_elements:
invoice_line_form.name = line_elements[0].text
line_elements = element.xpath('.//ram:SpecifiedTradeProduct/ram:SellerAssignedID', namespaces=tree.nsmap)
if line_elements and line_elements[0].text:
product = self.env['product.product'].search([('default_code', '=', line_elements[0].text)])
if product:
invoice_line_form.product_id = product
# Price Unit.
line_elements = element.xpath('.//ram:GrossPriceProductTradePrice/ram:ChargeAmount', namespaces=tree.nsmap)
if line_elements:
invoice_line_form.price_unit = float(line_elements[0].text)
else:
line_elements = element.xpath('.//ram:NetPriceProductTradePrice/ram:ChargeAmount', namespaces=tree.nsmap)
if line_elements:
invoice_line_form.price_unit = float(line_elements[0].text)
# Quantity.
line_elements = element.xpath('.//ram:SpecifiedLineTradeDelivery/ram:BilledQuantity', namespaces=tree.nsmap)
if line_elements:
invoice_line_form.quantity = float(line_elements[0].text) * refund_sign
# Discount.
line_elements = element.xpath('.//ram:AppliedTradeAllowanceCharge/ram:CalculationPercent', namespaces=tree.nsmap)
if line_elements:
invoice_line_form.discount = float(line_elements[0].text)
# Taxes
line_elements = element.xpath('.//ram:SpecifiedLineTradeSettlement/ram:ApplicableTradeTax/ram:RateApplicablePercent', namespaces=tree.nsmap)
invoice_line_form.invoice_line_tax_ids.clear()
for tax_element in line_elements:
percentage = float(tax_element.text)
tax = self.env['account.tax'].search([
('company_id', '=', invoice_form.company_id.id),
('amount_type', '=', 'percent'),
('type_tax_use', '=', 'purchase'),
('amount', '=', percentage),
], limit=1)
if tax:
invoice_line_form.invoice_line_tax_ids.add(tax)
elif amount_total_import:
# No lines in BASICWL.
with invoice_form.invoice_line_ids.new() as invoice_line_form:
invoice_line_form.name = invoice_form.comment or '/'
invoice_line_form.quantity = 1
invoice_line_form.price_unit = amount_total_import
# Refund.
invoice_form.type = 'in_refund' if refund_sign == -1 else 'in_invoice'
return invoice_form.save()
@api.multi
@api.returns('self', lambda value: value.id)
def message_post(self, **kwargs):
# OVERRIDE
# /!\ 'default_res_id' in self._context is used to don't process attachment when using a form view.
res = super(AccountInvoice, self).message_post(**kwargs)
def _get_attachment_filename(attachment):
# Handle both _Attachment namedtuple in mail.thread or ir.attachment.
return hasattr(attachment, 'fname') and getattr(attachment, 'fname') or attachment.name
def _get_attachment_content(attachment):
# Handle both _Attachment namedtuple in mail.thread or ir.attachment.
return hasattr(attachment, 'content') and getattr(attachment, 'content') or base64.b64decode(attachment.datas)
if 'default_res_id' not in self._context and len(self) == 1 and self.state == 'draft' and self.type in ('in_invoice', 'in_refund'):
# Get attachments.
# - 'attachments' is a namedtuple defined in mail.thread looking like:
# _Attachment = namedtuple('Attachment', ('fname', 'content', 'info'))
# - 'attachment_ids' is a list of ir.attachment records ids.
attachments = kwargs.get('attachments', [])
if kwargs.get('attachment_ids'):
attachments += self.env['ir.attachment'].browse(kwargs['attachment_ids'])
for attachment in attachments:
filename = _get_attachment_filename(attachment)
content = _get_attachment_content(attachment)
# Check if the attachment is a pdf.
if not filename.endswith('.pdf'):
continue
buffer = io.BytesIO(content)
try:
reader = PdfFileReader(buffer)
# Search for Factur-x embedded file.
if reader.trailer['/Root'].get('/Names') and reader.trailer['/Root']['/Names'].get('/EmbeddedFiles'):
# N.B: embedded_files looks like:
# ['file.xml', {'/Type': '/Filespec', '/F': 'file.xml', '/EF': {'/F': IndirectObject(22, 0)}}]
embedded_files = reader.trailer['/Root']['/Names']['/EmbeddedFiles']['/Names']
# '[::2]' because it's a list [fn_1, content_1, fn_2, content_2, ..., fn_n, content_2]
for filename_obj, content_obj in list(pycompat.izip(embedded_files, embedded_files[1:]))[::2]:
content = content_obj.getObject()['/EF']['/F'].getData()
if filename_obj == 'factur-x.xml':
try:
tree = etree.fromstring(content)
except:
continue
self._import_facturx_invoice(tree)
buffer.close()
return res
except:
# Malformed PDF.
pass
buffer.close()
return res
@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from PyPDF2 import PdfFileWriter, PdfFileReader
import io
class IrActionsReport(models.Model):
_inherit = 'ir.actions.report'
@api.multi
def _post_pdf(self, save_in_attachment, pdf_content=None, res_ids=None):
# OVERRIDE
if self.model == 'account.invoice' and (res_ids or len(res_ids) == 1):
invoice = self.env['account.invoice'].browse(res_ids)
if invoice.type in ('out_invoice', 'out_refund') and invoice.state != 'draft':
xml_content = invoice._export_as_facturx_xml()
# Add attachment.
reader_buffer = io.BytesIO(pdf_content)
reader = PdfFileReader(reader_buffer)
writer = PdfFileWriter()
writer.cloneReaderDocumentRoot(reader)
writer.addAttachment('factur-x.xml', xml_content)
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)
+25 -14
View File
@@ -162,7 +162,7 @@ class IrActionsReport(models.Model):
:param record_id: The record that will own the attachment.
:param pdf_content: The optional name content of the file to avoid reading both times.
:return: The newly generated attachment if no AccessError, else None.
:return: A modified buffer if the previous one has been modified, None otherwise.
'''
attachment_name = safe_eval(self.attachment, {'object': record, 'time': time})
if not attachment_name:
@@ -174,14 +174,13 @@ class IrActionsReport(models.Model):
'res_model': self.model,
'res_id': record.id,
}
attachment = None
try:
attachment = self.env['ir.attachment'].create(attachment_vals)
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 attachment
return buffer
@api.model
def get_wkhtmltopdf_state(self):
@@ -521,7 +520,11 @@ class IrActionsReport(models.Model):
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:
self.postprocess_pdf_report(record_map[res_ids[0]], pdf_content_stream)
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.
@@ -542,7 +545,11 @@ class IrActionsReport(models.Model):
stream = io.BytesIO()
attachment_writer.write(stream)
if res_ids[i] and res_ids[i] not in save_in_attachment:
self.postprocess_pdf_report(record_map[res_ids[i]], stream)
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:
@@ -557,14 +564,18 @@ class IrActionsReport(models.Model):
streams.append(io.BytesIO(content))
# Build the final pdf.
writer = PdfFileWriter()
for stream in streams:
reader = PdfFileReader(stream)
writer.appendPagesFromReader(reader)
result_stream = io.BytesIO()
streams.append(result_stream)
writer.write(result_stream)
result = result_stream.getvalue()
# If only one stream left, no need to merge them (and then, preserve embedded files).
if len(streams) == 1:
result = streams[0].getvalue()
else:
writer = PdfFileWriter()
for stream in streams:
reader = PdfFileReader(stream)
writer.appendPagesFromReader(reader)
result_stream = io.BytesIO()
streams.append(result_stream)
writer.write(result_stream)
result = result_stream.getvalue()
# We have to close the streams after PdfFileWriter's call to write()
close_streams(streams)