From f3259fd6a10bd11f31863dac7df012b91ad43c23 Mon Sep 17 00:00:00 2001 From: Marc Durepos Date: Thu, 27 Mar 2025 10:49:45 -0400 Subject: [PATCH] updates to account_email_to_pdf - total rewrite --- account_email_to_pdf/__manifest__.py | 26 ++-- account_email_to_pdf/models/account_move.py | 122 ++++++++++++++---- account_email_to_pdf/tests/__init__.py | 1 + .../tests/test_email_to_pdf.py | 103 +++++++++++++++ 4 files changed, 216 insertions(+), 36 deletions(-) create mode 100644 account_email_to_pdf/tests/__init__.py create mode 100644 account_email_to_pdf/tests/test_email_to_pdf.py diff --git a/account_email_to_pdf/__manifest__.py b/account_email_to_pdf/__manifest__.py index f5c4d75..8b4e4f0 100644 --- a/account_email_to_pdf/__manifest__.py +++ b/account_email_to_pdf/__manifest__.py @@ -1,11 +1,11 @@ { - 'name': 'Account Email to PDF', - 'version': '18.0.1.0.0', - 'category': 'Accounting', - 'summary': 'Convert email messages to PDF attachments for vendor bills', - 'description': """ + "name": "Account Email to PDF", + "version": "18.0.1.0.0", + "category": "Accounting", + "summary": "Convert email messages to PDF attachments for vendor bills", + "description": """ Account Email to PDF -=================== +==================== This module converts email messages without attachments into PDF attachments when processing incoming emails for vendor bills. @@ -13,11 +13,11 @@ Instead of rejecting emails without attachments, the system will create a PDF from the email content and attach it to the message, allowing the vendor bill creation process to continue. """, - 'author': 'Bemade', - 'website': 'https://bemade.org', - 'depends': ['account'], - 'data': [], - 'installable': True, - 'auto_install': False, - 'license': 'LGPL-3', + "author": "Bemade", + "website": "https://bemade.org", + "depends": ["account"], + "data": [], + "installable": True, + "auto_install": False, + "license": "LGPL-3", } diff --git a/account_email_to_pdf/models/account_move.py b/account_email_to_pdf/models/account_move.py index e9eb647..14bed27 100644 --- a/account_email_to_pdf/models/account_move.py +++ b/account_email_to_pdf/models/account_move.py @@ -1,24 +1,28 @@ import base64 import logging +import os +import subprocess +import tempfile +from contextlib import closing from datetime import datetime from email.utils import formatdate from html import escape from odoo import models, fields -from odoo.tools.pdf import html_to_pdf +from odoo.tools.misc import find_in_path _logger = logging.getLogger(__name__) class AccountMove(models.Model): - _inherit = 'account.move' + _inherit = "account.move" def _check_and_decode_attachment(self, attachments): """Override to convert email message to PDF if no attachments are present.""" - if not attachments or self.env.context.get('no_new_invoice'): + if not attachments or self.env.context.get("no_new_invoice"): # Original code would return False here, causing email rejection # Instead, we'll create a PDF from the email message - message_dict = self.env.context.get('message_dict', {}) + message_dict = self.env.context.get("message_dict", {}) if message_dict: try: # Create a PDF from the email content @@ -28,25 +32,95 @@ class AccountMove(models.Model): return super()._check_and_decode_attachment([pdf_attachment]) except Exception as e: _logger.exception("Error creating PDF from email: %s", e) - - # If we couldn't create a PDF, fall back to original behavior - return False - - # If there are attachments, proceed with the original method + + # Proceed with the original method + # If we were unable to generate a PDF, the original method will bounce the email return super()._check_and_decode_attachment(attachments) - + + @classmethod + def _html_to_pdf(cls, html_content): + """Convert HTML content to PDF using wkhtmltopdf. + + Args: + html_content (str): HTML content to convert to PDF + + Returns: + bytes: PDF content as bytes or False if conversion failed + """ + wkhtmltopdf_bin = find_in_path("wkhtmltopdf") + if not wkhtmltopdf_bin: + _logger.error("Cannot find wkhtmltopdf executable in system path") + return False + + # Create temporary files for the HTML input and PDF output + html_file_fd, html_file_path = tempfile.mkstemp( + suffix=".html", prefix="email_to_pdf." + ) + pdf_file_fd, pdf_file_path = tempfile.mkstemp( + suffix=".pdf", prefix="email_to_pdf." + ) + + try: + # Write the HTML content to the temporary file + with closing(os.fdopen(html_file_fd, "wb")) as html_file: + html_file.write(html_content.encode("utf-8")) + + # Close the PDF file descriptor as wkhtmltopdf will write to it + os.close(pdf_file_fd) + + # Basic wkhtmltopdf command arguments + command = [wkhtmltopdf_bin] + command.extend(["--encoding", "utf-8"]) + command.extend(["--page-size", "A4"]) + command.extend(["--margin-top", "10mm"]) + command.extend(["--margin-bottom", "10mm"]) + command.extend(["--margin-left", "10mm"]) + command.extend(["--margin-right", "10mm"]) + command.append(html_file_path) + command.append(pdf_file_path) + + # Execute wkhtmltopdf + process = subprocess.Popen( + command, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + out, err = process.communicate() + + if process.returncode not in [0, 1]: + _logger.error( + "wkhtmltopdf failed with error code %s: %s", process.returncode, err + ) + return False + + # Read the generated PDF + with open(pdf_file_path, "rb") as pdf_file: + pdf_content = pdf_file.read() + + return pdf_content + + except Exception as e: + _logger.exception("Error during PDF generation: %s", e) + return False + + finally: + # Clean up temporary files + try: + os.unlink(html_file_path) + os.unlink(pdf_file_path) + except (OSError, IOError): + _logger.error("Failed to remove temporary files") + def _create_pdf_from_email(self, message_dict): """Create a PDF attachment from an email message.""" # Extract email details - email_from = message_dict.get('email_from', 'Unknown Sender') - email_date = message_dict.get('date', datetime.now()) - subject = message_dict.get('subject', 'No Subject') - body = message_dict.get('body', '') - + email_from = message_dict.get("email_from", "Unknown Sender") + email_date = message_dict.get("date", datetime.now()) + subject = message_dict.get("subject", "No Subject") + body = message_dict.get("body", "") + # Format the date if it's a datetime object if isinstance(email_date, datetime): - email_date = email_date.strftime('%Y-%m-%d %H:%M:%S') - + email_date = email_date.strftime("%Y-%m-%d %H:%M:%S") + # Create HTML content for the PDF html_content = f""" @@ -71,17 +145,19 @@ class AccountMove(models.Model): """ - + # Convert HTML to PDF - pdf_content = html_to_pdf(html_content) - + pdf_content = self._html_to_pdf(html_content) + if not pdf_content: + return False + # Create attachment tuple (filename, content, mime_type) filename = f"Email_{subject.replace(' ', '_')[:30]}.pdf" attachment = ( filename, - base64.b64encode(pdf_content).decode('utf-8'), - 'application/pdf' + base64.b64encode(pdf_content).decode("utf-8"), + "application/pdf", ) - + _logger.info("Created PDF attachment from email: %s", filename) return attachment diff --git a/account_email_to_pdf/tests/__init__.py b/account_email_to_pdf/tests/__init__.py new file mode 100644 index 0000000..00d0605 --- /dev/null +++ b/account_email_to_pdf/tests/__init__.py @@ -0,0 +1 @@ +from . import test_email_to_pdf diff --git a/account_email_to_pdf/tests/test_email_to_pdf.py b/account_email_to_pdf/tests/test_email_to_pdf.py new file mode 100644 index 0000000..954cf45 --- /dev/null +++ b/account_email_to_pdf/tests/test_email_to_pdf.py @@ -0,0 +1,103 @@ +import base64 +from odoo.tests.common import TransactionCase +from odoo.tools.misc import find_in_path + + +class TestHtmlToPdf(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Check if wkhtmltopdf is available + cls.wkhtmltopdf_available = bool(find_in_path("wkhtmltopdf")) + + # Get the model class to access the classmethod + cls.account_move = cls.env["account.move"] + + # Simple test HTML content + cls.test_html = """ + + + + + +

Test HTML Document

+

This is a test paragraph with bold text and italic text.

+ + + + """ + + def test_html_to_pdf_conversion(self): + """Test the direct HTML to PDF conversion.""" + if not self.wkhtmltopdf_available: + self.skipTest("wkhtmltopdf not available") + + # Call the method to convert HTML to PDF + pdf_content = self.account_move._html_to_pdf(self.test_html) + + # Verify the PDF was created + self.assertTrue(pdf_content, "PDF content should be generated") + + # Verify it's a valid PDF + self.assertTrue( + pdf_content.startswith(b"%PDF-"), "Content should be a valid PDF" + ) + self.assertTrue(len(pdf_content) > 100, "PDF should have reasonable size") + + def test_html_to_pdf_with_complex_content(self): + """Test HTML to PDF conversion with more complex content.""" + if not self.wkhtmltopdf_available: + self.skipTest("wkhtmltopdf not available") + + # More complex HTML with tables and images + complex_html = """ + + + + + +

Complex HTML Test

+ + + + + + + + + + + + + + + + +
Header 1Header 2Header 3
Row 1, Cell 1Row 1, Cell 2Row 1, Cell 3
Row 2, Cell 1Row 2, Cell 2Row 2, Cell 3
+ + + """ + + # Convert complex HTML to PDF + pdf_content = self.account_move._html_to_pdf(complex_html) + + # Verify the PDF was created + self.assertTrue( + pdf_content, "PDF content should be generated from complex HTML" + ) + self.assertTrue( + pdf_content.startswith(b"%PDF-"), "Content should be a valid PDF" + ) + self.assertTrue(len(pdf_content) > 100, "PDF should have reasonable size")