diff --git a/addons/sale_management/data/sale_order_template_demo.xml b/addons/sale_management/data/sale_order_template_demo.xml
index 056d1590a67..ecd80620995 100644
--- a/addons/sale_management/data/sale_order_template_demo.xml
+++ b/addons/sale_management/data/sale_order_template_demo.xml
@@ -6,14 +6,14 @@
- 4 Person Desk
+ Large Meeting Table45
- 4 Person Desk
-
+ Large Meeting Table
+ 1
diff --git a/addons/sale_pdf_quote_builder/__manifest__.py b/addons/sale_pdf_quote_builder/__manifest__.py
index b2a98a9904e..b26391dc58f 100644
--- a/addons/sale_pdf_quote_builder/__manifest__.py
+++ b/addons/sale_pdf_quote_builder/__manifest__.py
@@ -12,6 +12,6 @@
'demo': [
'data/sale_pdf_quote_builder_demo.xml',
],
- 'installable': True,
+ 'auto_install': True,
'license': 'LGPL-3',
}
diff --git a/addons/sale_pdf_quote_builder/data/sale_pdf_quote_builder_demo.xml b/addons/sale_pdf_quote_builder/data/sale_pdf_quote_builder_demo.xml
index 06a2f8933cc..97608042ec6 100644
--- a/addons/sale_pdf_quote_builder/data/sale_pdf_quote_builder_demo.xml
+++ b/addons/sale_pdf_quote_builder/data/sale_pdf_quote_builder_demo.xml
@@ -5,9 +5,11 @@
+ Header Example.pdf
+ Footer Example.pdf
diff --git a/addons/sale_pdf_quote_builder/models/__init__.py b/addons/sale_pdf_quote_builder/models/__init__.py
index 5c0848729e6..ba203da199d 100644
--- a/addons/sale_pdf_quote_builder/models/__init__.py
+++ b/addons/sale_pdf_quote_builder/models/__init__.py
@@ -3,4 +3,5 @@
from . import ir_actions_report
from . import product_document
from . import res_company
+from . import sale_order
from . import sale_order_template
diff --git a/addons/sale_pdf_quote_builder/models/ir_actions_report.py b/addons/sale_pdf_quote_builder/models/ir_actions_report.py
index 76a7daf5079..eb0dd17a09d 100644
--- a/addons/sale_pdf_quote_builder/models/ir_actions_report.py
+++ b/addons/sale_pdf_quote_builder/models/ir_actions_report.py
@@ -1,12 +1,7 @@
# -*- coding: utf-8 -*-
import io
-from zlib import error as zlib_error
-try:
- from PyPDF2.errors import PdfStreamError, PdfReadError
-except ImportError:
- from PyPDF2.utils import PdfStreamError, PdfReadError
-from odoo import _, models
+from odoo import models
from odoo.tools import format_amount, format_date, format_datetime, pdf
@@ -24,50 +19,105 @@ class IrActionsReport(models.Model):
initial_stream = result[order.id]['stream']
if initial_stream:
order_template = order.sale_order_template_id
- record = order_template or order.company_id
- header = record.sale_header
- footer = record.sale_footer
-
+ header_record = order_template if order_template.sale_header else order.company_id
+ footer_record = order_template if order_template.sale_footer else order.company_id
+ has_header = bool(header_record.sale_header)
+ has_footer = bool(footer_record.sale_footer)
included_product_docs = self.env['product.document']
for line in order.order_line:
- documents = (
- line.product_id.product_document_ids
- or line.product_template_id.product_document_ids
+ product_product_docs = line.product_id.product_document_ids
+ product_template_docs = line.product_template_id.product_document_ids
+ doc_to_include = (
+ product_product_docs.filtered(lambda d: d.attached_on == 'inside')
+ or product_template_docs.filtered(lambda d: d.attached_on == 'inside')
)
- doc_to_include = documents.filtered(lambda d: d.attached_on == 'inside')
included_product_docs = included_product_docs | doc_to_include
- if not header and not included_product_docs and not footer:
+ if (not has_header and not included_product_docs and not has_footer):
continue
IrBinary = self.env['ir.binary']
+ so_form_fields = self._get_so_form_fields_mapping(order)
pdf_data = []
- if header:
- header_stream = IrBinary._record_to_stream(record, 'sale_header').read()
+ if has_header:
+ header_stream = IrBinary._record_to_stream(header_record, 'sale_header').read()
+ header_stream = pdf.fill_form_fields_pdf(header_stream, so_form_fields)
pdf_data.append(header_stream)
- for included_doc in included_product_docs:
- doc_stream = IrBinary._record_to_stream(included_doc, 'datas').read()
- pdf_data.append(doc_stream)
+ if included_product_docs:
+ docs_streams = self._fill_sol_documents_fields(
+ order, included_product_docs, so_form_fields
+ )
+ pdf_data.extend(docs_streams)
+
pdf_data.append((initial_stream).getvalue())
- if footer:
- footer_stream = IrBinary._record_to_stream(record, 'sale_footer').read()
+ if has_footer:
+ footer_stream = IrBinary._record_to_stream(footer_record, 'sale_footer').read()
+ footer_stream = pdf.fill_form_fields_pdf(footer_stream, so_form_fields)
pdf_data.append(footer_stream)
- try:
- form_fields = self._get_form_fields_mapping(order)
- stream = io.BytesIO(pdf.merge_pdf(pdf_data, form_fields))
- result[order.id].update({'stream': stream})
- except (ValueError, PdfStreamError, PdfReadError, TypeError, zlib_error,
- NotImplementedError):
- order._message_log(body=_(
- "There was an error when trying to merge headers and footers to the "
- "original PDF.\n Please make sure the source file are valid."
- ))
+ stream = io.BytesIO(pdf.merge_pdf(pdf_data))
+ result[order.id].update({'stream': stream})
return result
- def _get_form_fields_mapping(self, order):
- """ Dictionary mapping specific pdf fields name to Odoo fields data.
+ def _fill_sol_documents_fields(self, order, documents, so_form_fields):
+ """ Fill sale order line documents fields with sale order and sale order lines fields data.
+
+ :param recordset order: sale.order record
+ :param recordset documents: product.document records
+ :param dict so_form_fields: sale order fields data
+ :return: a list of PDF
+ :rtype: list of datastrings
+ """
+ IrBinary = self.env['ir.binary']
+ docs_streams = []
+ for line in order.order_line:
+ if not documents:
+ return docs_streams
+ # Merge so and sol data, in case of the same field name: priority to the sol data
+ sol_form_fields = so_form_fields | self._get_sol_form_fields_mapping(line)
+ product_id = line.product_id.id
+ template_id = line.product_template_id.id
+ line_documents = documents.filtered(
+ lambda d: (d.res_model == 'product.product' and d.res_id == product_id)
+ or (d.res_model == 'product.template' and d.res_id == template_id)
+ )
+ for doc in line_documents:
+ doc_stream = IrBinary._record_to_stream(doc, 'datas').read()
+ doc_stream = pdf.fill_form_fields_pdf(doc_stream, sol_form_fields)
+ docs_streams.append(doc_stream)
+ documents -= line_documents
+ return docs_streams
+
+ def _get_sol_form_fields_mapping(self, line):
+ """ Dictionary mapping specific pdf fields name to Odoo fields data for a sale order line.
+ Override this method to add new fields to the mapping.
+
+ :param recordset line: sale.order.line record
+ :rtype: dict
+ :return: mapping of fields name to Odoo fields data
+
+ Note: line.ensure_one()
+ """
+ line.ensure_one()
+ env = self.with_context(use_babel=True).env
+ form_fields_mapping = {
+ 'description': line.name,
+ 'quantity': line.product_uom_qty,
+ 'uom': line.product_uom.name,
+ 'price_unit': format_amount(env, line.price_unit, line.currency_id),
+ 'discount': line.discount,
+ 'product_sale_price': format_amount(
+ env, line.product_id.lst_price, line.product_id.currency_id
+ ),
+ 'taxes': ', '.join(tax.name for tax in line.tax_id),
+ 'tax_excl_price': format_amount(env, line.price_subtotal, line.currency_id),
+ 'tax_incl_price': format_amount(env, line.price_total, line.currency_id),
+ }
+ return form_fields_mapping
+
+ def _get_so_form_fields_mapping(self, order):
+ """ Dictionary mapping specific pdf fields name to Odoo fields data for a sale order.
Override this method to add new fields to the mapping.
:param recordset order: sale.order record
@@ -78,16 +128,15 @@ class IrActionsReport(models.Model):
"""
order.ensure_one()
env = self.with_context(use_babel=True).env
- currency_id = order.currency_id
tz = order.partner_id.tz or self.env.user.tz or 'UTC'
lang_code = order.partner_id.lang or self.env.user.lang
form_fields_mapping = {
'name': order.name,
'partner_id__name': order.partner_id.name,
'user_id__name': order.user_id.name,
- 'amount_untaxed': format_amount(env, order.amount_untaxed, currency_id),
- 'amount_total': format_amount(env, order.amount_total, currency_id),
- 'commitment_date': format_datetime(env, order.commitment_date, tz=tz),
+ 'amount_untaxed': format_amount(env, order.amount_untaxed, order.currency_id),
+ 'amount_total': format_amount(env, order.amount_total, order.currency_id),
+ 'delivery_date': format_datetime(env, order.commitment_date, tz=tz),
'validity_date': format_date(env, order.validity_date, lang_code=lang_code),
'client_order_ref': order.client_order_ref or '',
}
diff --git a/addons/sale_pdf_quote_builder/models/res_company.py b/addons/sale_pdf_quote_builder/models/res_company.py
index 08e989d851a..cb44738deb6 100644
--- a/addons/sale_pdf_quote_builder/models/res_company.py
+++ b/addons/sale_pdf_quote_builder/models/res_company.py
@@ -7,4 +7,6 @@ class ResCompany(models.Model):
_inherit = 'res.company'
sale_header = fields.Binary(string="Header pages")
+ sale_header_name = fields.Char()
sale_footer = fields.Binary(string="Footer pages")
+ sale_footer_name = fields.Char()
diff --git a/addons/sale_pdf_quote_builder/models/sale_order.py b/addons/sale_pdf_quote_builder/models/sale_order.py
new file mode 100644
index 00000000000..85224abfef0
--- /dev/null
+++ b/addons/sale_pdf_quote_builder/models/sale_order.py
@@ -0,0 +1,13 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models
+
+
+class SaleOrder(models.Model):
+ _inherit = 'sale.order'
+
+ def _filter_product_documents(self, documents):
+ return (
+ super()._filter_product_documents(documents)
+ | documents.filtered(lambda document: document.attached_on == 'inside')
+ )
diff --git a/addons/sale_pdf_quote_builder/models/sale_order_template.py b/addons/sale_pdf_quote_builder/models/sale_order_template.py
index 833c93b3627..e69c807ba2f 100644
--- a/addons/sale_pdf_quote_builder/models/sale_order_template.py
+++ b/addons/sale_pdf_quote_builder/models/sale_order_template.py
@@ -8,5 +8,7 @@ class SaleOrderTemplate(models.Model):
sale_header = fields.Binary(
string="Header pages", default=lambda self: self.env.company.sale_header)
+ sale_header_name = fields.Char(default=lambda self: self.env.company.sale_header_name)
sale_footer = fields.Binary(
string="Footer pages", default=lambda self: self.env.company.sale_footer)
+ sale_footer_name = fields.Char(default=lambda self: self.env.company.sale_footer_name)
diff --git a/addons/sale_pdf_quote_builder/views/sale_order_template_views.xml b/addons/sale_pdf_quote_builder/views/sale_order_template_views.xml
index 956201a334f..5dea702381c 100644
--- a/addons/sale_pdf_quote_builder/views/sale_order_template_views.xml
+++ b/addons/sale_pdf_quote_builder/views/sale_order_template_views.xml
@@ -16,8 +16,10 @@
product descriptions, details of the quote and then the footer pages.
-
-
+
+
+
+
Products descriptions are pdf documents you can add directly on products.
diff --git a/addons/sale_pdf_quote_builder/wizards/res_config_settings.py b/addons/sale_pdf_quote_builder/wizards/res_config_settings.py
index 48175f0f994..5dc9de17116 100644
--- a/addons/sale_pdf_quote_builder/wizards/res_config_settings.py
+++ b/addons/sale_pdf_quote_builder/wizards/res_config_settings.py
@@ -7,4 +7,6 @@ class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
sale_header = fields.Binary(related='company_id.sale_header', readonly=False)
+ sale_header_name = fields.Char(related='company_id.sale_header_name')
sale_footer = fields.Binary(related='company_id.sale_footer', readonly=False)
+ sale_footer_name = fields.Char(related='company_id.sale_footer_name')
diff --git a/addons/sale_pdf_quote_builder/wizards/res_config_settings_views.xml b/addons/sale_pdf_quote_builder/wizards/res_config_settings_views.xml
index 3c9040f442e..df9f44739fe 100644
--- a/addons/sale_pdf_quote_builder/wizards/res_config_settings_views.xml
+++ b/addons/sale_pdf_quote_builder/wizards/res_config_settings_views.xml
@@ -12,12 +12,14 @@
help="Make your quote attractive by adding header pages, product descriptions and footer pages to your quote."
company_dependent="1">
+
-
+
+
-
+
diff --git a/odoo/tools/pdf.py b/odoo/tools/pdf.py
index 758478e4542..14b72b31e7f 100644
--- a/odoo/tools/pdf.py
+++ b/odoo/tools/pdf.py
@@ -77,35 +77,50 @@ class BrandedFileWriter(PdfFileWriter):
PdfFileWriter = BrandedFileWriter
-def merge_pdf(pdf_data, form_fields=None):
+def merge_pdf(pdf_data):
''' Merge a collection of PDF documents in one.
Note that the attachments are not merged.
:param list pdf_data: a list of PDF datastrings
- :param dict form_fields: a dictionary of form fields to update in the merged PDF
:return: a unique merged PDF datastring
'''
- form_fields = form_fields or {}
writer = PdfFileWriter()
for document in pdf_data:
reader = PdfFileReader(io.BytesIO(document), strict=False)
- has_fields = form_fields and bool(reader.getFields())
for page in range(0, reader.getNumPages()):
- page = reader.getPage(page)
- writer.addPage(page)
- if has_fields:
- try:
- writer.update_page_form_field_values(page, form_fields)
- except AttributeError: # This method was renamed in PyPDF2 2.0
- # This is a known bug on previous version of PyPDF2, fixed in 2.11
- if not page.get('/Annots'):
- _logger.info("No fields to update in this page")
- else:
- writer.updatePageFormFieldValues(page, form_fields)
+ writer.addPage(reader.getPage(page))
with io.BytesIO() as _buffer:
writer.write(_buffer)
return _buffer.getvalue()
+def fill_form_fields_pdf(document, form_fields=None):
+ ''' Fill in the form fields of a PDF
+ :param datastring document: a PDF datastring
+ :param dict form_fields: a dictionary of form fields to update in the PDF
+ :return: a filled PDF datastring
+ '''
+ form_fields = form_fields or {}
+ writer = PdfFileWriter()
+ reader = PdfFileReader(io.BytesIO(document), strict=False)
+ has_fields = form_fields and bool(reader.getFields())
+ if not has_fields:
+ return document
+
+ for page in range(0, reader.getNumPages()):
+ page = reader.getPage(page)
+ writer.addPage(page)
+ try:
+ writer.update_page_form_field_values(page, form_fields)
+ except AttributeError: # This method was renamed in PyPDF2 2.0
+ # This is a known bug on previous version of PyPDF2, fixed in 2.11
+ if not page.get('/Annots'):
+ _logger.info("No fields to update in this page")
+ else:
+ writer.updatePageFormFieldValues(page, form_fields)
+
+ with io.BytesIO() as _buffer:
+ writer.write(_buffer)
+ return _buffer.getvalue()
def rotate_pdf(pdf):
''' Rotate clockwise PDF (90°) into a new PDF.