From a7a003521d5ea9b6e2ae00a91d5f40abd5964c3f Mon Sep 17 00:00:00 2001 From: Alexis de Lattre Date: Wed, 25 Apr 2018 01:20:54 +0200 Subject: [PATCH] report_py3o_fusion_server: Add support for PDF Export options of libreoffice --- report_py3o/README.rst | 2 + report_py3o_fusion_server/README.rst | 13 +- report_py3o_fusion_server/__manifest__.py | 2 + .../demo/py3o_pdf_options.xml | 11 + report_py3o_fusion_server/models/__init__.py | 1 + .../models/ir_actions_report_xml.py | 4 + .../models/py3o_pdf_options.py | 316 ++++++++++++++++++ .../models/py3o_report.py | 6 + .../models/py3o_server.py | 4 + .../security/ir.model.access.csv | 6 +- .../tests/test_report_py3o_fusion_server.py | 5 + report_py3o_fusion_server/views/ir_report.xml | 1 + .../views/py3o_pdf_options.xml | 149 +++++++++ .../views/py3o_server.xml | 2 + 14 files changed, 517 insertions(+), 5 deletions(-) create mode 100644 report_py3o_fusion_server/demo/py3o_pdf_options.xml create mode 100644 report_py3o_fusion_server/models/py3o_pdf_options.py create mode 100644 report_py3o_fusion_server/views/py3o_pdf_options.xml diff --git a/report_py3o/README.rst b/report_py3o/README.rst index c7aca0bc..27c4d9eb 100644 --- a/report_py3o/README.rst +++ b/report_py3o/README.rst @@ -19,6 +19,8 @@ The key advantages of a Libreoffice based reporting engine are: * If you want your users to be able to modify the document after its generation by Odoo, just configure the document with ODT output (or DOC or DOCX) and the user will be able to modify the document with Libreoffice (or Word) after its generation by Odoo. * Easy development of spreadsheet reports in ODS format (XLS output possible). +This module *report_py3o* is the base module for the Py3o reporting engine. If used alone, it will spawn a libreoffice process for each ODT to PDF (or ODT to DOCX, ..) document conversion. This is slow and can become a problem if you have a lot of reports to convert from ODT to another format. In this case, you should consider the additionnal module *report_py3o_fusion_server* which is designed to work with a libreoffice daemon. With *report_py3o_fusion_server*, the technical environnement is more complex to setup because you have to install additionnal software components and run 2 daemons, but you have much better performances and you can configure the libreoffice PDF export options in Odoo (allows to generate PDF forms, PDF/A documents, password-protected PDFs, watermarked PDFs, etc.). + This reporting engine is an alternative to `Aeroo `_: these two reporting engines have similar features but their implementation is entirely different. You cannot use aeroo templates as drop in replacement though, you'll have to change a few details. Installation diff --git a/report_py3o_fusion_server/README.rst b/report_py3o_fusion_server/README.rst index 7c5f3013..36fa52cc 100644 --- a/report_py3o_fusion_server/README.rst +++ b/report_py3o_fusion_server/README.rst @@ -6,7 +6,14 @@ Py3o Report Engine - Fusion server support ========================================== -This module was written to let a py3o fusion server handle format conversion instead of local libreoffice. +This module was written to let a py3o fusion server handle format conversion instead of local libreoffice. If you install this module above the *report_py3o* module, you will have to deploy additionnal software components and run 3 daemons (libreoffice, py3o.fusion and py3o.renderserver). This additionnal complexiy comes with several advantages: + +* much better performances (libreoffice runs permanently in the background, no need to spawn a new libreoffice instance upon every document conversion). +* ability to configure PDF export options in Odoo. This will allow you to generate: + * PDF forms + * password-protected PDF documents + * PDF/A documents (required by some electronic invoicing standards such as Factur-X) + * watermarked PDF documents Installation ============ @@ -54,11 +61,11 @@ At the end, with the dependencies, you should have the following py3o python lib % pip freeze | grep py3o py3o.formats==0.3 - py3o.fusion==0.8.7 + py3o.fusion==0.8.8 py3o.renderclient==0.2 py3o.renderers.juno==0.8 py3o.renderserver==0.5.1 - py3o.template==0.9.11 + py3o.template==0.9.12 py3o.types==0.1.1 Start the Py3o Fusion server: diff --git a/report_py3o_fusion_server/__manifest__.py b/report_py3o_fusion_server/__manifest__.py index ba9a78b8..58d561c6 100644 --- a/report_py3o_fusion_server/__manifest__.py +++ b/report_py3o_fusion_server/__manifest__.py @@ -20,11 +20,13 @@ }, 'demo': [ "demo/report_py3o.xml", + "demo/py3o_pdf_options.xml", ], 'data': [ "views/ir_report.xml", 'security/ir.model.access.csv', 'views/py3o_server.xml', + 'views/py3o_pdf_options.xml', ], 'installable': True, } diff --git a/report_py3o_fusion_server/demo/py3o_pdf_options.xml b/report_py3o_fusion_server/demo/py3o_pdf_options.xml new file mode 100644 index 00000000..de1fec76 --- /dev/null +++ b/report_py3o_fusion_server/demo/py3o_pdf_options.xml @@ -0,0 +1,11 @@ + + + + + + PDF/A (for Factur-X invoices) + + + + + diff --git a/report_py3o_fusion_server/models/__init__.py b/report_py3o_fusion_server/models/__init__.py index 78c726c4..8ae69cab 100644 --- a/report_py3o_fusion_server/models/__init__.py +++ b/report_py3o_fusion_server/models/__init__.py @@ -2,5 +2,6 @@ # Copyright 2017 Therp BV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from . import ir_actions_report_xml +from . import py3o_pdf_options from . import py3o_report from . import py3o_server diff --git a/report_py3o_fusion_server/models/ir_actions_report_xml.py b/report_py3o_fusion_server/models/ir_actions_report_xml.py index d4fa0db6..167b4e62 100644 --- a/report_py3o_fusion_server/models/ir_actions_report_xml.py +++ b/report_py3o_fusion_server/models/ir_actions_report_xml.py @@ -39,3 +39,7 @@ class IrActionsReportXml(models.Model): py3o_server_id = fields.Many2one( "py3o.server", "Fusion Server") + pdf_options_id = fields.Many2one( + 'py3o.pdf.options', string='PDF Options', ondelete='restrict', + help="PDF options can be set per report, but also per Py3o Server. " + "If both are defined, the options on the report are used.") diff --git a/report_py3o_fusion_server/models/py3o_pdf_options.py b/report_py3o_fusion_server/models/py3o_pdf_options.py new file mode 100644 index 00000000..be991898 --- /dev/null +++ b/report_py3o_fusion_server/models/py3o_pdf_options.py @@ -0,0 +1,316 @@ +# -*- coding: utf-8 -*- +# Copyright 2018 Akretion (http://www.akretion.com) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError +import logging +logger = logging.getLogger(__name__) + + +class Py3oPdfOptions(models.Model): + _name = 'py3o.pdf.options' + _description = 'Define PDF export options for Libreoffice' + + name = fields.Char(required=True) + # GENERAL TAB + # UseLosslessCompression (bool) + image_compression = fields.Selection([ + ('lossless', 'Lossless Compression'), + ('jpeg', 'JPEG Compression'), + ], string='Image Compression', default='jpeg') + # Quality (int) + image_jpeg_quality = fields.Integer( + string='Image JPEG Quality', default=90, + help="Enter a percentage between 0 and 100.") + # ReduceImageResolution (bool) and MaxImageResolution (int) + image_reduce_resolution = fields.Selection([ + ('none', 'Disable'), + ('75', '75 DPI'), + ('150', '150 DPI'), + ('300', '300 DPI'), + ('600', '600 DPI'), + ('1200', '1200 DPI'), + ], string='Reduce Image Resolution', default='300') + watermark = fields.Boolean('Sign With Watermark') + # Watermark (string) + watermark_text = fields.Char('WaterMark Text') + # UseTaggedPDF (bool) + tagged_pdf = fields.Boolean('Tagged PDF (add document structure)') + # SelectPdfVersion (int) + # 0 = PDF 1.4 (default selection). + # 1 = PDF/A-1 (ISO 19005-1:2005) + pdfa = fields.Boolean( + 'Archive PDF/A-1a (ISO 19005-1)', + help="If you enable this option, you will not be able to " + "password-protect the document or apply other security settings.") + # ExportFormFields (bool) + pdf_form = fields.Boolean('Create PDF Form', default=True) + # FormsType (int) + pdf_form_format = fields.Selection([ + ('0', 'FDF'), + ('1', 'PDF'), + ('2', 'HTML'), + ('3', 'XML'), + ], string='Submit Format', default='0') + # AllowDuplicateFieldNames (bool) + pdf_form_allow_duplicate = fields.Boolean('Allow Duplicate Field Names') + # ExportBookmarks (bool) + export_bookmarks = fields.Boolean('Export Bookmarks', default=True) + # ExportPlaceholders (bool) + export_placeholders = fields.Boolean('Export Placeholders', default=True) + # ExportNotes (bool) + export_comments = fields.Boolean('Export Comments') + # ExportHiddenSlides (bool) ?? + export_hidden_slides = fields.Boolean( + 'Export Automatically Insered Blank Pages') + # Doesn't make sense to have the option "View PDF after export" ! :) + # INITIAL VIEW TAB + # InitialView (int) + initial_view = fields.Selection([ + ('0', 'Page Only'), + ('1', 'Bookmarks and Page'), + ('2', 'Thumnails and Page'), + ], string='Panes', default='0') + # InitialPage (int) + initial_page = fields.Integer(string='Initial Page', default=1) + # Magnification (int) + magnification = fields.Selection([ + ('0', 'Default'), + ('1', 'Fit in Window'), + ('2', 'Fit Width'), + ('3', 'Fit Visible'), + ('4', 'Zoom'), + ], string='Magnification', default='0') + # Zoom (int) + zoom = fields.Integer( + string='Zoom Factor', default=100, + help='Possible values: from 50 to 1600') + # PageLayout (int) + page_layout = fields.Selection([ + ('0', 'Default'), + ('1', 'Single Page'), + ('2', 'Continuous'), + ('3', 'Continuous Facing'), + ], string='Page Layout', default='0') + # USER INTERFACE TAB + # ResizeWindowToInitialPage (bool) + resize_windows_initial_page = fields.Boolean( + string='Resize Windows to Initial Page') + # CenterWindow (bool) + center_window = fields.Boolean(string='Center Window on Screen') + # OpenInFullScreenMode (bool) + open_fullscreen = fields.Boolean(string='Open in Full Screen Mode') + # DisplayPDFDocumentTitle (bool) + display_document_title = fields.Boolean(string='Display Document Title') + # HideViewerMenubar (bool) + hide_menubar = fields.Boolean(string='Hide Menubar') + # HideViewerToolbar (bool) + hide_toolbar = fields.Boolean(string='Hide Toolbar') + # HideViewerWindowControls (bool) + hide_window_controls = fields.Boolean(string='Hide Windows Controls') + # OpenBookmarkLevels (int) -1 = all (default) from 1 to 10 + open_bookmark_levels = fields.Selection([ + ('-1', 'All Levels'), + ('1', '1'), + ('2', '2'), + ('3', '3'), + ('4', '4'), + ('5', '5'), + ('6', '6'), + ('7', '7'), + ('8', '8'), + ('9', '9'), + ('10', '10'), + ], default='-1', string='Visible Bookmark Levels') + # LINKS TAB + # ExportBookmarksToPDFDestination (bool) + export_bookmarks_named_dest = fields.Boolean( + string='Export Bookmarks as Named Destinations') + # ConvertOOoTargetToPDFTarget (bool) + convert_doc_ref_to_pdf_target = fields.Boolean( + string='Convert Document References to PDF Targets') + # ExportLinksRelativeFsys (bool) + export_filesystem_urls = fields.Boolean( + string='Export URLs Relative to Filesystem') + # PDFViewSelection -> mnDefaultLinkAction (int) + cross_doc_link_action = fields.Selection([ + ('0', 'Default'), + ('1', 'Open with PDF Reader Application'), + ('2', 'Open with Internet Browser'), + ], string='Cross-document Links', default='default') + # SECURITY TAB + # EncryptFile (bool) + encrypt = fields.Boolean('Encrypt') + # DocumentOpenPassword (char) + document_password = fields.Char(string='Document Password') + # RestrictPermissions (bool) + restrict_permissions = fields.Boolean('Restrict Permissions') + # PermissionPassword (char) + permission_password = fields.Char(string='Permission Password') + # TODO PreparedPasswords + PreparedPermissionPassword + # I don't see those fields in the LO interface ! + # But they are used in the LO code... + # Printing (int) + printing = fields.Selection([ + ('0', 'Not Permitted'), + ('1', 'Low Resolution (150 dpi)'), + ('2', 'High Resolution'), + ], string='Printing', default='2') + # Changes (int) + changes = fields.Selection([ + ('0', 'Not Permitted'), + ('1', 'Inserting, Deleting and Rotating Pages'), + ('2', 'Filling in Form Fields'), + ('3', 'Commenting, Filling in Form Fields'), + ('4', 'Any Except Extracting Pages'), + ], string='Changes', default='4') + # EnableCopyingOfContent (bool) + content_copying_allowed = fields.Boolean( + string='Enable Copying of Content', default=True) + # EnableTextAccessForAccessibilityTools (bool) + text_access_accessibility_tools_allowed = fields.Boolean( + string='Enable Text Access for Accessibility Tools', default=True) + # DIGITAL SIGNATURE TAB + # This will be possible but not easy + # Because the certificate parameter is a pointer to a certificate + # already registered in LO + # On Linux LO reuses the Mozilla certificate store (on Windows the + # one from Windows) + # But there seems to be some possibilities to send this certificate via API + # It seems you can add temporary certificates during runtime: + # https://api.libreoffice.org/docs/idl/ref/interfacecom_1_1sun_1_1star_1_1security_1_1XCertificateContainer.html + # Here is an API to retrieve the known certificates: + # https://api.libreoffice.org/docs/idl/ref/interfacecom_1_1sun_1_1star_1_1xml_1_1crypto_1_1XSecurityEnvironment.html + # Thanks to 'samuel_m' on libreoffice-dev IRC chan for pointing me to this + + @api.constrains( + 'image_jpeg_quality', 'initial_page', 'pdfa', + 'cross_doc_link_action', 'magnification', 'zoom') + def check_pdf_options(self): + for opt in self: + if opt.image_jpeg_quality > 100 or opt.image_jpeg_quality < 1: + raise ValidationError(_( + "The parameter Image JPEG Quality must be between 1 %%" + " and 100 %% (current value: %s %%)") + % opt.image_jpeg_quality) + if opt.initial_page < 1: + raise ValidationError(_( + "The initial page parameter must be strictly positive " + "(current value: %d)") % opt.initial_page) + if opt.pdfa and opt.cross_doc_link_action == '1': + raise ValidationError(_( + "The PDF/A option is not compatible with " + "'Cross-document Links' = " + "'Open with PDF Reader Application'.")) + if opt.magnification == '4' and (opt.zoom < 50 or opt.zoom > 1600): + raise ValidationError(_( + "The value of the zoom factor must be between 50 and 1600 " + "(current value: %d)") % opt.zoom) + + @api.onchange('encrypt') + def encrypt_change(self): + if not self.encrypt: + self.document_password = False + + @api.onchange('restrict_permissions') + def restrict_permissions_change(self): + if not self.restrict_permissions: + self.permission_password = False + + @api.onchange('pdfa') + def pdfa_change(self): + if self.pdfa: + self.pdf_form = False + self.encrypt = False + self.restrict_permissions = False + + def odoo2libreoffice_options(self): + self.ensure_one() + options = {} + # GENERAL TAB + if self.image_compression == 'lossless': + options['UseLosslessCompression'] = True + else: + options['UseLosslessCompression'] = False + options['Quality'] = self.image_jpeg_quality + if self.image_reduce_resolution != 'none': + options['ReduceImageResolution'] = True + options['MaxImageResolution'] = int(self.image_reduce_resolution) + else: + options['ReduceImageResolution'] = False + if self.watermark and self.watermark_text: + options['Watermark'] = self.watermark_text + if self.pdfa: + options['SelectPdfVersion'] = 1 + options['UseTaggedPDF'] = self.tagged_pdf + else: + options['SelectPdfVersion'] = 0 + if self.pdf_form and self.pdf_form_format and not self.pdfa: + options['ExportFormFields'] = True + options['FormsType'] = int(self.pdf_form_format) + options['AllowDuplicateFieldNames'] = self.pdf_form_allow_duplicate + else: + options['ExportFormFields'] = False + + options.update({ + 'ExportBookmarks': self.export_bookmarks, + 'ExportPlaceholders': self.export_placeholders, + 'ExportNotes': self.export_comments, + 'ExportHiddenSlides': self.export_hidden_slides, + }) + + # INITIAL VIEW TAB + options.update({ + 'InitialView': int(self.initial_view), + 'InitialPage': self.initial_page, + 'Magnification': int(self.magnification), + 'PageLayout': int(self.page_layout), + }) + + if self.magnification == '4': + options['Zoom'] = self.zoom + + # USER INTERFACE TAB + options.update({ + 'ResizeWindowToInitialPage': self.resize_windows_initial_page, + 'CenterWindow': self.center_window, + 'OpenInFullScreenMode': self.open_fullscreen, + 'DisplayPDFDocumentTitle': self.display_document_title, + 'HideViewerMenubar': self.hide_menubar, + 'HideViewerToolbar': self.hide_toolbar, + 'HideViewerWindowControls': self.hide_window_controls, + }) + + if self.open_bookmark_levels: + options['OpenBookmarkLevels'] = int(self.open_bookmark_levels) + + # LINKS TAB + options.update({ + 'ExportBookmarksToPDFDestination': + self.export_bookmarks_named_dest, + 'ConvertOOoTargetToPDFTarget': self.convert_doc_ref_to_pdf_target, + 'ExportLinksRelativeFsys': self.export_filesystem_urls, + 'PDFViewSelection': int(self.cross_doc_link_action), + }) + + # SECURITY TAB + if not self.pdfa: + if self.encrypt and self.document_password: + options['EncryptFile'] = True + options['DocumentOpenPassword'] = self.document_password + if self.restrict_permissions and self.permission_password: + options.update({ + 'RestrictPermissions': True, + 'PermissionPassword': self.permission_password, + 'Printing': int(self.printing), + 'Changes': int(self.changes), + 'EnableCopyingOfContent': self.content_copying_allowed, + 'EnableTextAccessForAccessibilityTools': + self.text_access_accessibility_tools_allowed, + }) + + logger.debug( + 'Py3o PDF options ID %s converted to %s', self.id, options) + return options diff --git a/report_py3o_fusion_server/models/py3o_report.py b/report_py3o_fusion_server/models/py3o_report.py index 86bd26dd..3eb1aa44 100644 --- a/report_py3o_fusion_server/models/py3o_report.py +++ b/report_py3o_fusion_server/models/py3o_report.py @@ -75,6 +75,12 @@ class Py3oReport(models.TransientModel): } if report_xml.py3o_is_local_fusion: fields['skipfusion'] = '1' + if filetype == 'pdf': + options = report_xml.pdf_options_id or\ + report_xml.py3o_server_id.pdf_options_id + if options: + pdf_options_dict = options.odoo2libreoffice_options() + fields['pdf_options'] = json.dumps(pdf_options_dict) r = requests.post( report_xml.py3o_server_id.url, data=fields, files=files) if r.status_code != 200: diff --git a/report_py3o_fusion_server/models/py3o_server.py b/report_py3o_fusion_server/models/py3o_server.py index 099d355c..30d7d81a 100644 --- a/report_py3o_fusion_server/models/py3o_server.py +++ b/report_py3o_fusion_server/models/py3o_server.py @@ -13,3 +13,7 @@ class Py3oServer(models.Model): help="If your Py3o Fusion server is on the same machine and runs " "on the default port, the URL is http://localhost:8765/form") is_active = fields.Boolean("Active", default=True) + pdf_options_id = fields.Many2one( + 'py3o.pdf.options', string='PDF Options', ondelete='restrict', + help="PDF options can be set per Py3o Server but also per report. " + "If both are defined, the options on the report are used.") diff --git a/report_py3o_fusion_server/security/ir.model.access.csv b/report_py3o_fusion_server/security/ir.model.access.csv index 8015edc9..a7b13349 100644 --- a/report_py3o_fusion_server/security/ir.model.access.csv +++ b/report_py3o_fusion_server/security/ir.model.access.csv @@ -1,3 +1,5 @@ -"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" -access_py3o_server_admin,access_py3o_server_admin,model_py3o_server,base.group_no_one,1,1,1,1 +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_py3o_server_admin,access_py3o_server_admin,model_py3o_server,base.group_system,1,1,1,1 access_py3o_server_user,access_py3o_server_user,model_py3o_server,base.group_user,1,0,0,0 +access_py3o_pdf_options_admin,Full access to PDF options to Settings grp,model_py3o_pdf_options,base.group_system,1,1,1,1 +access_py3o_pdf_options_user,Read-only access to PDF options to employees,model_py3o_pdf_options,base.group_user,1,0,0,0 diff --git a/report_py3o_fusion_server/tests/test_report_py3o_fusion_server.py b/report_py3o_fusion_server/tests/test_report_py3o_fusion_server.py index ebe1a92d..127ed967 100644 --- a/report_py3o_fusion_server/tests/test_report_py3o_fusion_server.py +++ b/report_py3o_fusion_server/tests/test_report_py3o_fusion_server.py @@ -36,3 +36,8 @@ class TestReportPy3oFusionServer(test_report_py3o.TestReportPy3o): def test_reports_no_local_fusion(self): self.report.py3o_is_local_fusion = False self.test_reports() + + def test_odoo2libreoffice_options(self): + for options in self.env['py3o.pdf.options'].search([]): + options_dict = options.odoo2libreoffice_options() + self.assertIsInstance(options_dict, dict) diff --git a/report_py3o_fusion_server/views/ir_report.xml b/report_py3o_fusion_server/views/ir_report.xml index 35cba84f..173d6b3e 100644 --- a/report_py3o_fusion_server/views/ir_report.xml +++ b/report_py3o_fusion_server/views/ir_report.xml @@ -7,6 +7,7 @@ + diff --git a/report_py3o_fusion_server/views/py3o_pdf_options.xml b/report_py3o_fusion_server/views/py3o_pdf_options.xml new file mode 100644 index 00000000..3109758a --- /dev/null +++ b/report_py3o_fusion_server/views/py3o_pdf_options.xml @@ -0,0 +1,149 @@ + + + + + + + + py3o.pdf.options.form + py3o.pdf.options + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

The security settings are incompatible with the PDF/A-1a option in the General tab.

+
+
+
+
+
+
+
+ + + py3o.pdf.options.tree + py3o.pdf.options + + + + + + + + + Py3o PDF Export Options + py3o.pdf.options + tree,form + + + + + +
diff --git a/report_py3o_fusion_server/views/py3o_server.xml b/report_py3o_fusion_server/views/py3o_server.xml index 810e5918..2b245fc1 100644 --- a/report_py3o_fusion_server/views/py3o_server.xml +++ b/report_py3o_fusion_server/views/py3o_server.xml @@ -8,6 +8,7 @@
+
@@ -20,6 +21,7 @@ +