[ADD] sale_pdf_quote_builder:

When enable, this new feature adds the possibility to set a PDF header, footer and some product
documents to the quotation report.

These PDF can contains forms that'll then be filled using Odoo database.

This replace the previous sale quotation builder feature.

task-3249142

closes odoo/odoo#133773

Related: odoo/upgrade#5168
Related: odoo/documentation#5863
Signed-off-by: Victor Feyens (vfe) <vfe@odoo.com>
Co-authored-by: Victor Feyens <vfe@odoo.com>
Co-authored-by: Morgane Demesmaeker <edm@odoo.com>
This commit is contained in:
Victor Feyens
2023-10-03 19:19:57 +00:00
committed by Demesmaeker
co-authored by Morgane Demesmaeker
parent 693b62f38e
commit 6d1a78286a
26 changed files with 352 additions and 7 deletions
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="report_saleorder_document" inherit_id="sale.report_saleorder">
<template id="report_saleorder_document" inherit_id="sale.report_saleorder_raw">
<xpath expr='//t[@t-call="sale.report_saleorder_document"]' position="attributes">
<attribute name="t-if" add="doc.company_id.country_code != 'BR'" separator=" and "/>
</xpath>
+24
View File
@@ -183,6 +183,14 @@
<field name="description_sale">160x80cm, with large legs.</field>
</record>
<record id="product_product_4_product_template_document" model="product.document">
<field name="name">Customizable Desk Document.pdf</field>
<field name="datas" type="base64" file="product/static/demo/customizable_desk_document.pdf"/>
<field name="mimetype">application/pdf</field>
<field name="res_model">product.template</field>
<field name="res_id" ref="product.product_product_4_product_template"/>
</record>
<!-- the product template attribute lines have to be defined before creating the variants -->
<record id="product_4_attribute_1_product_template_attribute_line" model="product.template.attribute.line">
<field name="product_tmpl_id" ref="product_product_4_product_template"/>
@@ -516,6 +524,14 @@
<field name="image_1920" type="base64" file="product/static/img/product_product_25-image.png"/>
</record>
<record id="product_product_25_document" model="product.document">
<field name="name">Acoustic Bloc Screens Document.pdf</field>
<field name="datas" type="base64" file="product/static/demo/acoustic_bloc_screen_document.pdf"/>
<field name="mimetype">application/pdf</field>
<field name="res_model">product.product</field>
<field name="res_id" ref="product.product_product_25"/>
</record>
<record id="product_product_27" model="product.product">
<field name="name">Drawer</field>
<field name="categ_id" ref="product_category_5"/>
@@ -558,6 +574,14 @@
<field name="image_1920" type="base64" file="product/static/img/product_product_46-image.jpg"/>
</record>
<record id="consu_delivery_02_document" model="product.document">
<field name="name">Large Meeting Table Document.pdf</field>
<field name="datas" type="base64" file="product/static/demo/large_meeting_table_document.pdf"/>
<field name="mimetype">application/pdf</field>
<field name="res_model">product.product</field>
<field name="res_id" ref="product.consu_delivery_02"/>
</record>
<record id="consu_delivery_01" model="product.product">
<field name="name">Three-Seat Sofa</field>
<field name="categ_id" ref="product_category_5"/>
+6 -1
View File
@@ -125,7 +125,12 @@ class CustomerPortal(payment_portal.PaymentPortal):
return request.redirect('/my')
if report_type in ('html', 'pdf', 'text'):
return self._show_report(model=order_sudo, report_type=report_type, report_ref='sale.action_report_saleorder', download=download)
return self._show_report(
model=order_sudo,
report_type=report_type,
report_ref='sale.action_report_saleorder',
download=download,
)
if request.env.user.share and access_token:
# If a public/portal user accesses the order with the access token
@@ -194,8 +194,7 @@
</t>
</template>
<template id="report_saleorder">
<template id="report_saleorder_raw">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="sale.report_saleorder_document" t-lang="doc.partner_id.lang"/>
@@ -203,6 +202,10 @@
</t>
</template>
<template id="report_saleorder">
<t t-call="sale.report_saleorder_raw"/>
</template>
<template id="report_saleorder_pro_forma">
<t t-call="web.html_container">
<t t-set="is_pro_forma" t-value="True"/>
@@ -76,6 +76,7 @@ class ResConfigSettings(models.TransientModel):
module_sale_amazon = fields.Boolean("Amazon Sync")
module_sale_loyalty = fields.Boolean("Coupons & Loyalty")
module_sale_margin = fields.Boolean("Margins")
module_sale_pdf_quote_builder = fields.Boolean("PDF Quote builder")
#=== ONCHANGE METHODS ===#
@@ -107,6 +107,9 @@
<setting id="order_warnings" string="Sale Warnings" help="Get warnings in orders for products or customers">
<field name="group_warning_sale"/>
</setting>
<setting id="sale_pdf_quote_builder" string="PDF Quote builder" help="Make your quote attractive by adding header pages, product descriptions and footer pages to your quote.">
<field name="module_sale_pdf_quote_builder"/>
</setting>
<setting id="no_edit_order" help="No longer edit orders once confirmed">
<field name="group_auto_done_setting"/>
</setting>
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, api
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
@@ -0,0 +1,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
from . import wizards
@@ -0,0 +1,17 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': "Sales PDF Quotation Builder",
'category': 'Sales/Sales',
'description': "Build nice quotations",
'depends': ['sale_management'],
'data': [
'report/ir_actions_report.xml',
'views/sale_order_template_views.xml',
'wizards/res_config_settings_views.xml',
],
'demo': [
'data/sale_pdf_quote_builder_demo.xml',
],
'installable': True,
'license': 'LGPL-3',
}
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="base.main_company" model="res.company">
<field name="sale_header"
type="base64"
file="sale_pdf_quote_builder/static/quote_builder_pdf/quote_header_pages.pdf"/>
<field name="sale_footer"
type="base64"
file="sale_pdf_quote_builder/static/quote_builder_pdf/quote_footer_pages.pdf"/>
</record>
<record id="product.product_product_4_product_template_document" model="product.document">
<field name="attached_on">inside</field>
</record>
<record id="product.product_product_25_document" model="product.document">
<field name="attached_on">inside</field>
</record>
<record id="product.consu_delivery_02_document" model="product.document">
<field name="attached_on">inside</field>
</record>
</odoo>
@@ -0,0 +1,6 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import ir_actions_report
from . import product_document
from . import res_company
from . import sale_order_template
@@ -0,0 +1,93 @@
# -*- 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.tools import format_amount, format_date, pdf
class IrActionsReport(models.Model):
_inherit = 'ir.actions.report'
def _render_qweb_pdf_prepare_streams(self, report_ref, data, res_ids=None):
result = super()._render_qweb_pdf_prepare_streams(report_ref, data, res_ids=res_ids)
if self._get_report(report_ref).report_name != 'sale.report_saleorder':
return result
orders = self.env['sale.order'].browse(res_ids)
for order in orders:
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
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
)
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:
continue
IrBinary = self.env['ir.binary']
pdf_data = []
if header:
header_stream = IrBinary._record_to_stream(record, 'sale_header').read()
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)
pdf_data.append((initial_stream).getvalue())
if footer:
footer_stream = IrBinary._record_to_stream(record, 'sale_footer').read()
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):
record._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."
))
return result
def _get_form_fields_mapping(self, order):
""" Dictionary mapping specific pdf fields name to Odoo fields data.
Override this method to add new fields to the mapping.
:param recordset order: sale.order record
:rtype: dict
:return: mapping of fields name to Odoo fields data
Note: order.ensure_one()
"""
order.ensure_one()
tz = order.partner_id.tz or self.env.user.tz or 'UTC'
env = self.with_context(use_babel=True).env
currency_id = order.currency_id
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_date(env, order.commitment_date, tz=tz),
'validity_date': format_date(env, order.validity_date, tz=tz),
'client_order_ref': order.client_order_ref or '',
}
return form_fields_mapping
@@ -0,0 +1,30 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, fields, models
from odoo.exceptions import ValidationError
class ProductDocument(models.Model):
_inherit = 'product.document'
attached_on = fields.Selection(
selection_add=[('inside', "Inside quote")],
help="Allows you to share the document with your customers within a sale.\n"
"Leave it empty if you don't want to share this document with sales customer.\n"
"Quotation: the document will be sent to and accessible by customers at any time.\n"
"e.g. this option can be useful to share Product description files.\n"
"Confirmed order: the document will be sent to and accessible by customers.\n"
"e.g. this option can be useful to share User Manual or digital content bought"
" on ecommerce. \n"
"Inside quote: The document will be included in the pdf of the quotation between the "
"header pages and the quote table. ",
)
def write(self, vals):
res = super().write(vals)
if vals.keys() & {'attached_on', 'mimetype'}:
if any(
doc.attached_on == 'inside' and not doc.mimetype.endswith('pdf') for doc in self
):
raise ValidationError(_("Only PDF documents can be attached inside a quote."))
return res
@@ -0,0 +1,10 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ResCompany(models.Model):
_inherit = 'res.company'
sale_header = fields.Binary(string="Header pages")
sale_footer = fields.Binary(string="Footer pages")
@@ -0,0 +1,12 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class SaleOrderTemplate(models.Model):
_inherit = 'sale.order.template'
sale_header = fields.Binary(
string="Header pages", default=lambda self: self.env.company.sale_header)
sale_footer = fields.Binary(
string="Footer pages", default=lambda self: self.env.company.sale_footer)
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_report_saleorder_raw" model="ir.actions.report">
<field name="name">Quotation / Order</field>
<field name="model">sale.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">sale.report_saleorder_raw</field>
<field name="report_file">sale.report_saleorder_raw</field>
<field name="print_report_name">(object.state in ('draft', 'sent') and 'Quotation - %s' % (object.name)) or 'Order - %s' % (object.name)</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field>
</record>
<record id="sale.action_report_saleorder" model="ir.actions.report">
<field name="name">PDF Quote</field>
</record>
</odoo>
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="sale_order_template_form" model="ir.ui.view">
<field name="name">sale.order.template.form</field>
<field name="model">sale.order.template</field>
<field name="inherit_id" ref="sale_management.sale_order_template_view_form"/>
<field name="arch" type="xml">
<notebook position="inside">
<page name="pdf_quote" string="PDF Quote Builder">
<group>
<p class="text-muted" colspan="2">
Provide header pages and footer pages to compose an attractive quotation
with more information about your company, your products and your services.
The pdf of your quotes will be built by putting together header pages,
product descriptions, details of the quote and then the footer pages.
</p>
<group>
<field name="sale_header" options="{'accepted_file_extensions': '.pdf'}"/>
<field name="sale_footer" options="{'accepted_file_extensions': '.pdf'}"/>
</group>
<p class="text-muted" colspan="2">
Products descriptions are pdf documents you can add directly on products.
To do so, go on a product, find the "product documents" button, then add a
new pdf document with a visibility set as "Inside Quotes". For each product
in the quote, if the product has an "inside quotes" document, this document
will be added after header pages and before the quotation details.
</p>
<p class="text-muted" colspan="2">
Some information specific to the quote (customer name, quotation reference, ... )
can be injected in these documents using pdf forms.
Refer to the documentation to know more about this feature.
</p>
</group>
</page>
</notebook>
</field>
</record>
</odoo>
@@ -0,0 +1 @@
from . import res_config_settings
@@ -0,0 +1,10 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
sale_header = fields.Binary(related='company_id.sale_header', readonly=False)
sale_footer = fields.Binary(related='company_id.sale_footer', readonly=False)
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.sale.pdf.quote.builder</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="sale_management.res_config_settings_view_form"/>
<field name="arch" type="xml">
<setting id="sale_pdf_quote_builder" position="replace">
<setting id="sale_pdf_quote_builder"
string="PDF Quote builder"
help="Make your quote attractive by adding header pages, product descriptions and footer pages to your quote."
company_dependent="1">
<div class="mt16">
<label for="sale_header" class="me-2"/>
<field name="sale_header" options="{'accepted_file_extensions': '.pdf'}"/>
</div>
<div class="mt16">
<label for="sale_footer" class="me-2"/>
<field name="sale_footer" options="{'accepted_file_extensions': '.pdf'}"/>
</div>
</setting>
</setting>
</field>
</record>
</odoo>
+17 -2
View File
@@ -30,6 +30,7 @@ try:
PyPDF2.PdfFileReader = PdfFileReader
from PyPDF2 import PdfFileWriter, PdfFileReader
PdfFileReader.getFields = PdfFileReader.get_fields
PdfFileWriter._addObject = PdfFileWriter._add_object
except ImportError:
from PyPDF2 import PdfFileWriter, PdfFileReader
@@ -76,17 +77,31 @@ class BrandedFileWriter(PdfFileWriter):
PdfFileWriter = BrandedFileWriter
def merge_pdf(pdf_data):
def merge_pdf(pdf_data, form_fields=None):
''' 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()):
writer.addPage(reader.getPage(page))
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)
with io.BytesIO() as _buffer:
writer.write(_buffer)
return _buffer.getvalue()