diff --git a/hr_payroll_document/README.rst b/hr_payroll_document/README.rst index 1f35c9eb..e4be8b6a 100644 --- a/hr_payroll_document/README.rst +++ b/hr_payroll_document/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ===================== HR - Payroll Document ===================== @@ -17,7 +13,7 @@ HR - Payroll Document .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fpayroll-lightgray.png?logo=github diff --git a/hr_payroll_document/__manifest__.py b/hr_payroll_document/__manifest__.py index 9b8342f9..104dfbdc 100644 --- a/hr_payroll_document/__manifest__.py +++ b/hr_payroll_document/__manifest__.py @@ -8,7 +8,12 @@ "version": "17.0.1.1.0", "depends": ["hr", "base_vat"], "maintainers": ["peluko00"], - "external_dependencies": {"python": ["pypdf"]}, + "external_dependencies": { + "python": [ + "pypdf", + "PyMuPDF", + ], + }, "data": [ "wizard/payroll_management_wizard.xml", "security/ir.model.access.csv", diff --git a/hr_payroll_document/models/hr_employee.py b/hr_payroll_document/models/hr_employee.py index cd6e1a07..54f673e6 100644 --- a/hr_payroll_document/models/hr_employee.py +++ b/hr_payroll_document/models/hr_employee.py @@ -1,4 +1,7 @@ -from odoo import _, fields, models +# Copyright 2025 Simone Rubino - PyTech +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models from odoo.exceptions import ValidationError @@ -43,10 +46,19 @@ def action_get_payroll_tree_view(self): ) return action - def write(self, vals): - res = super().write(vals) - if "identification_id" in vals and not self.env["res.partner"].simple_vat_check( - self.env.company.country_id.code, vals["identification_id"] - ): - raise ValidationError(_("The field identification ID is not valid")) - return res + def _validate_payroll_identification(self, code=None): + # Override if the identification should be validated in another way + if code is None and len(self) == 1: + code = self.identification_id + if country_code := self.env.company.country_id.code: + is_valid = self.env["res.partner"].simple_vat_check(country_code, code) + else: + is_valid = True + return is_valid + + @api.constrains("identification_id") + def _constrain_payroll_identification(self): + # Only check the employees that have an `identification_id` + for employee in self.filtered("identification_id"): + if not employee._validate_payroll_identification(): + raise ValidationError(_("The field identification ID is not valid")) diff --git a/hr_payroll_document/static/description/index.html b/hr_payroll_document/static/description/index.html index ccdb6179..bbc68dce 100644 --- a/hr_payroll_document/static/description/index.html +++ b/hr_payroll_document/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +HR - Payroll Document -
+
+

HR - Payroll Document

- - -Odoo Community Association - -
-

HR - Payroll Document

-

Beta License: AGPL-3 OCA/payroll Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/payroll Translate me on Weblate Try me on Runboat

This module have a wizard view to manage the different payrolls of employees which is identified by the identification_id attribute.

By default, the employee’s payroll is encrypted using their @@ -393,7 +388,7 @@

HR - Payroll Document

-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -401,15 +396,15 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • APSL
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -438,6 +433,5 @@

Maintainers

-
diff --git a/hr_payroll_document/tests/common.py b/hr_payroll_document/tests/common.py new file mode 100644 index 00000000..0fc6cc24 --- /dev/null +++ b/hr_payroll_document/tests/common.py @@ -0,0 +1,71 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import base64 +import contextlib +from unittest import mock + +from odoo.tests import common +from odoo.tools.misc import file_path as open_file_path + +from odoo.addons.mail.tests.common import mail_new_test_user + + +class TestHrPayrollDocument(common.TransactionCase): + def setUp(self): + super().setUp() + self.env.user.tz = "Europe/Brussels" + self.user_admin = self.env.ref("base.user_admin") + + # Fix Company without country + self.env.company.country_id = False + + # Test users to use through the various tests + self.user_employee = mail_new_test_user( + self.env, login="david", groups="base.group_user" + ) + self.user_employee_id = self.user_employee.id + + # Hr Data + self.employee_emp = self.env["hr.employee"].create( + { + "name": "David Employee", + "user_id": self.user_employee_id, + "company_id": 1, + "identification_id": "30831011V", + } + ) + + self.wizard = self._create_wizard( + "January", "hr_payroll_document/tests/test.pdf" + ) + + def _create_wizard(self, subject, file_path): + with open(open_file_path(file_path), "rb") as pdf_file: + encoded_string = base64.b64encode(pdf_file.read()) + ir_values = { + "name": "test", + "type": "binary", + "datas": encoded_string, + "store_fname": encoded_string, + "res_model": "payroll.management.wizard", + "res_id": 1, + } + self.attachment = self.env["ir.attachment"].create(ir_values) + self.subject = subject + return self.env["payroll.management.wizard"].create( + {"payrolls": [self.attachment.id], "subject": self.subject} + ) + + @contextlib.contextmanager + def _mock_valid_identification(self, employee, identification_code): + def _mocked_validate_payroll_identification(self, code=None): + if code is None: + code = employee.identification_id + return code == identification_code + + with mock.patch.object( + type(employee), + "_validate_payroll_identification", + _mocked_validate_payroll_identification, + ) as patch: + patch.side_effect = _mocked_validate_payroll_identification + yield diff --git a/hr_payroll_document/tests/test_broken_image.pdf b/hr_payroll_document/tests/test_broken_image.pdf new file mode 100644 index 00000000..2d08e214 Binary files /dev/null and b/hr_payroll_document/tests/test_broken_image.pdf differ diff --git a/hr_payroll_document/tests/test_hr_payroll_document.py b/hr_payroll_document/tests/test_hr_payroll_document.py index 6bcd7d3e..c3159861 100644 --- a/hr_payroll_document/tests/test_hr_payroll_document.py +++ b/hr_payroll_document/tests/test_hr_payroll_document.py @@ -5,53 +5,13 @@ from odoo import _ from odoo.exceptions import UserError, ValidationError -from odoo.tests import common -from odoo.tools.misc import file_path -from odoo.addons.mail.tests.common import mail_new_test_user +from odoo.addons.hr_payroll_document.tests.common import TestHrPayrollDocument -class TestHRPayrollDocument(common.TransactionCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.env.user.tz = "Europe/Brussels" - cls.user_admin = cls.env.ref("base.user_admin") - - # Fix Company without country - cls.env.company.country_id = False - - # Test users to use through the various tests - cls.user_employee = mail_new_test_user( - cls.env, login="david", groups="base.group_user" - ) - cls.user_employee_id = cls.user_employee.id - - # Hr Data - cls.employee_emp = cls.env["hr.employee"].create( - { - "name": "David Employee", - "user_id": cls.user_employee_id, - "company_id": 1, - "identification_id": "30831011V", - } - ) - - with open(file_path("hr_payroll_document/tests/test.pdf"), "rb") as pdf_file: - encoded_string = base64.b64encode(pdf_file.read()) - ir_values = { - "name": "test", - "type": "binary", - "datas": encoded_string, - "store_fname": encoded_string, - "res_model": "payroll.management.wizard", - "res_id": 1, - } - cls.attachment = cls.env["ir.attachment"].create(ir_values) - cls.subject = "January" - cls.wizard = cls.env["payroll.management.wizard"].create( - {"payrolls": [cls.attachment.id], "subject": cls.subject} - ) +class TestHRPayrollDocument(TestHrPayrollDocument): + def setUp(self, *args, **kwargs): + super().setUp(*args, **kwargs) def fill_company_id(self): self.env.company.country_id = self.env["res.country"].search( @@ -59,24 +19,25 @@ def fill_company_id(self): ) def test_extension_error(self): - with open(file_path("hr_payroll_document/tests/test.docx"), "rb") as pdf_file: - encoded_string = base64.b64encode(pdf_file.read()) - ir_values = { - "name": "test", - "type": "binary", - "datas": encoded_string, - "store_fname": encoded_string, - "res_model": "payroll.management.wizard", - "res_id": 1, - } - self.attachment = self.env["ir.attachment"].create(ir_values) - self.subject = "January" - self.wizard = self.env["payroll.management.wizard"].create( - {"payrolls": [self.attachment.id], "subject": self.subject} + self.wizard = self._create_wizard( + "January", "hr_payroll_document/tests/test.docx" ) with self.assertRaises(ValidationError): self.wizard.send_payrolls() + def test_pdf_broken_image(self): + """If the PDF cannot be processed with PyPDF, try with another reader.""" + self.fill_company_id() + identification_code = "xXXXXXXXXXXXXXXX" + with self._mock_valid_identification(self.employee_emp, identification_code): + self.employee_emp.identification_id = identification_code + self.wizard = self._create_wizard( + "Subject", "hr_payroll_document/tests/test_broken_image.pdf" + ) + with self._mock_valid_identification(self.employee_emp, identification_code): + result_action = self.wizard.send_payrolls() + self.assertEqual(result_action["params"]["title"], _("Payrolls sent")) + def test_company_id_required(self): with self.assertRaises(UserError): self.wizard.send_payrolls() diff --git a/hr_payroll_document/wizard/payroll_management_wizard.py b/hr_payroll_document/wizard/payroll_management_wizard.py index 012ad1a2..ce5a1908 100644 --- a/hr_payroll_document/wizard/payroll_management_wizard.py +++ b/hr_payroll_document/wizard/payroll_management_wizard.py @@ -1,7 +1,10 @@ import base64 +import io from base64 import b64decode -from pypdf import PdfReader, PdfWriter +import pymupdf +from pypdf import PdfReader, PdfWriter, errors +from reportlab.pdfgen import canvas from odoo import _, fields, models from odoo.exceptions import UserError, ValidationError @@ -19,55 +22,121 @@ class PayrollManagamentWizard(models.TransientModel): "ir.attachment", "payrol_rel", "doc_id", "attach_id3", copy=False, required=True ) - def send_payrolls(self): - not_found = set() - self.merge_pdfs() - reader = PdfReader("/tmp/merged-pdf.pdf") - employees = set() - - # Validate if company have country - if not self.env.company.country_id: - raise UserError(_("You must to filled country field of company")) + def _get_fallback_reader(self, pdf_reader): + # Read the file with another reader + doc = pymupdf.Document(stream=pdf_reader.stream) + + # Create a new PDF with only the extracted content + pdf_content = io.BytesIO() + pdf_canvas = canvas.Canvas(pdf_content) + for page_number in range(pdf_reader.get_num_pages()): + page_content = doc[page_number].get_text().split() + # Create a new page with the read content + pdf_canvas.drawString(0, 0, " ".join(page_content)) + pdf_canvas.showPage() + pdf_canvas.save() + + # Return a PyPDF reader for the new PDF, + # that now is readable + return PdfReader(pdf_content) + + def _read_page_content(self, pdf_reader, page, fallback_reader=None): + try: + page_content = page.extract_text().split() + except errors.PdfReadError: + if fallback_reader: + # The original page cannot be read: + # read the simplified page in the fallback_reader + page_number = pdf_reader.get_page_number(page) + fallback_page = fallback_reader.get_page(page_number) + page_content = fallback_page.extract_text().split() + else: + raise + return page_content + + def _extract_employees(self, pdf_reader, fallback_reader=None): + employee_to_pages = dict() + not_found_ids = set() # Find all IDs of the employees - for page in reader.pages: - for value in page.extract_text().split(): + for page in pdf_reader.pages: + page_content = self._read_page_content( + pdf_reader, page, fallback_reader=fallback_reader + ) + for value in page_content: if self.validate_id(value) and value != self.env.company.vat: employee = self.env["hr.employee"].search( [("identification_id", "=", value)] ) if employee: - employees.add(employee) + employee_to_pages.setdefault(employee, []).append(page) else: - not_found.add(value) + not_found_ids.add(value) + break - for employee in list(employees): - pdfWriter = PdfWriter() - for page in reader.pages: - if employee.identification_id in page.extract_text(): - # Save pdf with payrolls of employee - pdfWriter.add_page(page) + return employee_to_pages, not_found_ids - path = "/tmp/" + _("Payroll ") + employee.name + ".pdf" + def _build_employee_payroll(self, file_name, pdf_pages, encryption_key=None): + """Return the path to the created payroll. - if not employee.no_payroll_encryption: - # Encrypt the payroll file - # with the identification identifier of the employee - pdfWriter.encrypt(employee.identification_id, algorithm="AES-256") + Optionally encrypt the payroll file with `encryption_key`. + """ + pdfWriter = PdfWriter() + for page in pdf_pages: + pdfWriter.add_page(page) - f = open(path, "wb") - pdfWriter.write(f) - f.close() + path = "/tmp/" + file_name - # Send payroll to the employee - self.send_mail(employee, path) + if encryption_key: + pdfWriter.encrypt(encryption_key, algorithm="AES-256") + + with open(path, "wb") as f: + pdfWriter.write(f) + return path + def _show_employees_action(self): action = self.env["ir.actions.actions"]._for_xml_id( "hr_payroll_document.payrolls_view_action" ) action["views"] = [ [self.env.ref("hr_payroll_document.view_payroll_tree").id, "list"] ] + return action + + def send_payrolls(self): + self.merge_pdfs() + # Validate if company have country + if not self.env.company.country_id: + raise UserError(_("You must to filled country field of company")) + + reader = PdfReader("/tmp/merged-pdf.pdf") + + try: + employee_to_pages, not_found = self._extract_employees(reader) + except errors.PdfReadError: + # Couldn't read the file, try again with another reader + fallback_reader = self._get_fallback_reader(reader) + employee_to_pages, not_found = self._extract_employees( + reader, fallback_reader=fallback_reader + ) + + for employee, pages in employee_to_pages.items(): + encryption_key = ( + None if employee.no_payroll_encryption else employee.identification_id + ) + path = self._build_employee_payroll( + _( + "Payroll %(subject)s %(employee)s.pdf", + employee=employee.name, + subject=self.subject, + ), + pages, + encryption_key=encryption_key, + ) + # Send payroll to the employee + self.send_mail(employee, path) + + action = self._show_employees_action() if not_found: return { "type": "ir.actions.client", @@ -151,7 +220,5 @@ def send_mail(self, employee, path): employee.id, force_send=True ) - def validate_id(self, number): - return self.env["res.partner"].simple_vat_check( - self.env.company.country_id.code, number - ) + def validate_id(self, code): + return self.env["hr.employee"]._validate_payroll_identification(code=code) diff --git a/requirements.txt b/requirements.txt index 1ab56ace..45526854 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ # generated from manifests external_dependencies +PyMuPDF pypdf