diff --git a/report_xlsx/README.rst b/report_xlsx/README.rst index bd2a96e5..61b54432 100644 --- a/report_xlsx/README.rst +++ b/report_xlsx/README.rst @@ -53,6 +53,15 @@ A report XML record :: attachment_use="False" /> +**XLSX Header & Footer** + +You can configure them on the menu *Settings > Technical > Reports > XLSX Header/Footer* following the syntax from +`xlsxwriter documentation `_. + +Example of Header / Footer syntax : ``&LPage &P of &N &CFilename: &F &RSheetname: &A`` + +On a report XML with ``report_type == 'xlsx'`` you can specified the Header and Footer you configured. + .. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas :alt: Try me on Runbot :target: https://runbot.odoo-community.org/runbot/143/10.0 @@ -72,6 +81,7 @@ Contributors ------------ * Adrien Peiffer +* Arnaud Pineux Maintainer ---------- diff --git a/report_xlsx/__manifest__.py b/report_xlsx/__manifest__.py index 2d8368b3..5118d400 100644 --- a/report_xlsx/__manifest__.py +++ b/report_xlsx/__manifest__.py @@ -16,5 +16,11 @@ 'depends': [ 'base', ], + 'data': [ + 'security/ir.model.access.csv', + + 'views/header_footer.xml', + 'views/ir_report.xml', + ], 'installable': True, } diff --git a/report_xlsx/models/__init__.py b/report_xlsx/models/__init__.py index 521aa82b..22cf61c7 100644 --- a/report_xlsx/models/__init__.py +++ b/report_xlsx/models/__init__.py @@ -2,4 +2,5 @@ # Copyright 2015 ACSONE SA/NV () # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).+ +from . import header_footer from . import ir_report diff --git a/report_xlsx/models/header_footer.py b/report_xlsx/models/header_footer.py new file mode 100644 index 00000000..a88b8679 --- /dev/null +++ b/report_xlsx/models/header_footer.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 ACSONE SA/NV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import io +import ast +from odoo import fields, models, api, _ +from odoo.exceptions import ValidationError + + +class ReportHeaderFooter(models.Model): + _name = 'report.xlsx.hf' + + name = fields.Char(string="Name", required=True) + hf_type = fields.Selection( + [('header', 'Header'), ('footer', 'Footer')], + string="Type", + required=True) + value = fields.Char(string="Value") + manual_options = fields.Char(string="Options") + image_left = fields.Binary(string='Image left') + image_left_name = fields.Char('File Name') + image_center = fields.Binary(string='Image center') + image_center_name = fields.Char('File Name') + image_right = fields.Binary(string='Image right') + image_right_name = fields.Char('File Name') + header_report_ids = fields.One2many( + 'ir.actions.report.xml', + 'header_id', + string="Associated report(s)") + footer_report_ids = fields.One2many( + 'ir.actions.report.xml', + 'footer_id', + string="Associated report(s)") + + @api.multi + @api.constrains('manual_options') + def _check_manual_options(self): + for rec in self: + if rec.manual_options: + options = ast.literal_eval(rec.manual_options) + if not isinstance(options, dict): + raise ValidationError( + _('The Header/Footer is not configured properly.\ + Options must be a dictionary.')) + + @api.multi + @api.constrains('image_left', 'image_center', 'image_right') + def _check_images(self): + for rec in self: + error = "" + if rec.image_left and ("&L&G" not in rec.value + and "&L&[Picture]" not in rec.value): + error += _('You must specify the control character &L&G or \ + &L&[Picture] in the "Value" when you add an "Image left".\n') + if rec.image_center and ("&C&G" not in rec.value + and "&C&[Picture]" not in rec.value): + error += _('You must specify the control character &C&G or \ + &C&[Picture] in the "Value" when you add an "Image center".\n') + if rec.image_right and ("&R&G" not in rec.value + and "&R&[Picture]" not in rec.value): + error += _('You must specify the control character &R&G or \ + &R&[Picture] in the "Value" when you add an "Image right".\n') + if error: + raise ValidationError(error) + + @api.multi + def get_options(self): + self.ensure_one() + options = {} + if self.manual_options: + options = ast.literal_eval(self.manual_options) + if self.image_left: + options['image_left'] = self.image_left_name + options['image_data_left'] = io.BytesIO( + self.image_left.decode('base64')) + if self.image_center: + options['image_center'] = self.image_center_name + options['image_data_center'] = io.BytesIO( + self.image_center.decode('base64')) + if self.image_right: + options['image_right'] = self.image_right_name + options['image_data_right'] = io.BytesIO( + self.image_right.decode('base64')) + return options diff --git a/report_xlsx/models/ir_report.py b/report_xlsx/models/ir_report.py index 7bf10a9a..04c740c5 100644 --- a/report_xlsx/models/ir_report.py +++ b/report_xlsx/models/ir_report.py @@ -9,3 +9,9 @@ class IrActionsReportXml(models.Model): _inherit = 'ir.actions.report.xml' report_type = fields.Selection(selection_add=[("xlsx", "xlsx")]) + header_id = fields.Many2one('report.xlsx.hf', + string="Header", + domain=[('hf_type', '=', 'header')]) + footer_id = fields.Many2one('report.xlsx.hf', + string="Footer", + domain=[('hf_type', '=', 'footer')]) diff --git a/report_xlsx/report/report_xlsx.py b/report_xlsx/report/report_xlsx.py index a199beb5..3f0f6b15 100644 --- a/report_xlsx/report/report_xlsx.py +++ b/report_xlsx/report/report_xlsx.py @@ -28,6 +28,18 @@ class ReportXlsx(report_sxw): return self.create_xlsx_report(ids, data, report) return super(ReportXlsx, self).create(cr, uid, ids, data, context) + def create_workbook(self, file_data, data, objs, report): + workbook = xlsxwriter.Workbook(file_data, self.get_workbook_options()) + self.generate_xlsx_report(workbook, data, objs) + for sheet in workbook.worksheets(): + if report and report.header_id and report.header_id.value: + sheet.set_header(report.header_id.value, + report.header_id.get_options()) + if report and report.footer_id and report.footer_id.value: + sheet.set_footer(report.footer_id.value, + report.footer_id.get_options()) + return workbook + def create_xlsx_report(self, ids, data, report): self.parser_instance = self.parser( self.env.cr, self.env.uid, self.name2, self.env.context) @@ -35,8 +47,7 @@ class ReportXlsx(report_sxw): self.env.cr, self.env.uid, ids, self.env.context) self.parser_instance.set_context(objs, data, ids, 'xlsx') file_data = StringIO() - workbook = xlsxwriter.Workbook(file_data, self.get_workbook_options()) - self.generate_xlsx_report(workbook, data, objs) + workbook = self.create_workbook(file_data, data, objs, report) workbook.close() file_data.seek(0) return (file_data.read(), 'xlsx') diff --git a/report_xlsx/security/ir.model.access.csv b/report_xlsx/security/ir.model.access.csv new file mode 100644 index 00000000..cc3ddea2 --- /dev/null +++ b/report_xlsx/security/ir.model.access.csv @@ -0,0 +1,3 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +"access_report_xlsx_hf_all","report_xlsx_hf","model_report_xlsx_hf",,1,0,0,0 +"access_report_xlsx_hf_group_system","report_xlsx_hf_group_system","model_report_xlsx_hf","base.group_system",1,1,1,1 diff --git a/report_xlsx/tests/__init__.py b/report_xlsx/tests/__init__.py new file mode 100644 index 00000000..88627160 --- /dev/null +++ b/report_xlsx/tests/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 ACSONE SA/NV () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import test_header_footer diff --git a/report_xlsx/tests/test_header_footer.py b/report_xlsx/tests/test_header_footer.py new file mode 100644 index 00000000..6c74f64d --- /dev/null +++ b/report_xlsx/tests/test_header_footer.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 ACSONE SA/NV () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import odoo.tests.common as common +from odoo.exceptions import ValidationError +from cStringIO import StringIO +from odoo.addons.report_xlsx.report.report_xlsx import ReportXlsx + + +class PartnerXlsx(ReportXlsx): + + def generate_xlsx_report(self, workbook, data, partners): + sheet = workbook.add_worksheet('sheet') + sheet.write(0, 0, 'test') + + +class TestHeaderFooter(common.TransactionCase): + + @classmethod + def setUpClass(cls): + super(TestHeaderFooter, cls).setUpClass() + cls.report = PartnerXlsx('report.res.partner.xlsx', + 'res.partner') + + def setUp(self): + super(TestHeaderFooter, self).setUp() + + # Create Header + self.header_001 = self.env['report.xlsx.hf'].create({ + 'name': 'Header 001', + 'hf_type': 'header', + 'value': '&LPage &P of &N &CFilename: &F &RSheetname: &A', + }) + # Create Footer + self.footer_001 = self.env['report.xlsx.hf'].create({ + 'name': 'Footer 001', + 'hf_type': 'footer', + 'value': '&LCurrent date: &D &RCurrent time: &T', + }) + # Create Report + self.report_xlsx = self.env['ir.actions.report.xml'].create({ + 'report_name': 'res.partner.xlsx', + 'name': 'XLSX report', + 'report_type': 'xlsx', + 'model': 'res.partner', + 'header_id': self.header_001.id, + 'footer_id': self.footer_001.id, + }) + + def test_header_footer(self): + """ + Check that the header and footer have been added to the worksheets + """ + file_data = StringIO() + partner = self.env['res.partner'].browse([1]) + workbook = self.report.create_workbook( + file_data, {}, partner, self.report_xlsx) + header = u'&LPage &P of &N &CFilename: &F &RSheetname: &A' + footer = u'&LCurrent date: &D &RCurrent time: &T' + + for sheet in workbook.worksheets(): + self.assertEqual(header, sheet.header) + self.assertEqual(footer, sheet.footer) + + def test_wrong_options(self): + """ + Check that options must be a dict + """ + with self.assertRaises(ValidationError): + self.env['report.xlsx.hf'].create({ + 'name': 'Header ERROR', + 'hf_type': 'header', + 'value': '&LPage &P of &N &CFilename: &F &RSheetname: &A', + 'manual_options': "1234", + }) + + def test_image_options(self): + """ + Check that, adding image, modify the options + """ + header = self.env['report.xlsx.hf'].create({ + 'name': 'Header IMAGE', + 'hf_type': 'header', + 'value': '&L&G &C&G &R&G', + 'image_left': + 'R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', + 'image_left_name': 'image_left.jpg', + 'image_center': + 'R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', + 'image_center_name': 'image_center.jpg', + 'image_right': + 'R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', + 'image_right_name': 'image_right.jpg', + }) + options = header.get_options() + self.assertEqual(options.get('image_left'), 'image_left.jpg') + self.assertEqual(options.get('image_center'), 'image_center.jpg') + self.assertEqual(options.get('image_right'), 'image_right.jpg') + self.assertTrue(options.get('image_data_left')) + self.assertTrue(options.get('image_data_center')) + self.assertTrue(options.get('image_data_right')) diff --git a/report_xlsx/views/header_footer.xml b/report_xlsx/views/header_footer.xml new file mode 100644 index 00000000..309eb249 --- /dev/null +++ b/report_xlsx/views/header_footer.xml @@ -0,0 +1,64 @@ + + + + + report_xlsx_hf Tree + report.xlsx.hf + tree + + + + + + + + + + + report_xlsx_hf Form + report.xlsx.hf + form + +
+ + + + + +