diff --git a/report_py3o_fusion_server/README.rst b/report_py3o_fusion_server/README.rst index 7c5f30137..36fa52cc3 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 ba9a78b88..58d561c62 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 000000000..de1fec760 --- /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 78c726c4d..8ae69cab1 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 d4fa0db6f..167b4e627 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 000000000..be991898c --- /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 86bd26dd1..3eb1aa440 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 099d355c1..30d7d81aa 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 8015edc9b..a7b133497 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 ebe1a92d4..127ed9673 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 35cba84f5..173d6b3ef 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 000000000..3109758a4 --- /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 810e59181..2b245fc16 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 @@ +