diff --git a/account_email_to_pdf/__init__.py b/account_email_to_pdf/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/account_email_to_pdf/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/account_email_to_pdf/__manifest__.py b/account_email_to_pdf/__manifest__.py new file mode 100644 index 0000000..8b4e4f0 --- /dev/null +++ b/account_email_to_pdf/__manifest__.py @@ -0,0 +1,23 @@ +{ + "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. + +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", +} diff --git a/account_email_to_pdf/models/__init__.py b/account_email_to_pdf/models/__init__.py new file mode 100644 index 0000000..9c0a421 --- /dev/null +++ b/account_email_to_pdf/models/__init__.py @@ -0,0 +1 @@ +from . import account_move diff --git a/account_email_to_pdf/models/account_move.py b/account_email_to_pdf/models/account_move.py new file mode 100644 index 0000000..df99587 --- /dev/null +++ b/account_email_to_pdf/models/account_move.py @@ -0,0 +1,184 @@ +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.misc import find_in_path + +_logger = logging.getLogger(__name__) + + +class AccountMove(models.Model): + _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"): + # 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", {}) + if message_dict: + try: + # Create a PDF from the email content + pdf_attachment = self._create_pdf_from_email(message_dict) + if pdf_attachment: + _logger.info( + "Successfully created PDF attachment, proceeding with invoice creation" + ) + # We need to return the result of _extend_with_attachments directly + # as that's what the original method would return + # Convert the list to a recordset before passing to _extend_with_attachments + attachment_recordset = self.env["ir.attachment"].browse( + [pdf_attachment.id] + ) + return self._extend_with_attachments( + attachment_recordset, + new=bool(self._context.get("from_alias")), + ) + except Exception as e: + _logger.exception("Error creating PDF from email: %s", e) + + # 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. + + Args: + message_dict (dict): Email message dictionary + + Returns: + ir.attachment: The created attachment record or False if failed + """ + # 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", "") + + # 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") + + # Create HTML content for the PDF + html_content = f""" + + + + + +
+
From: {escape(email_from)}
+
Date: {escape(email_date)}
+
{escape(subject)}
+
+
+ {body} +
+ + + """ + + # Convert HTML to PDF + pdf_content = self._html_to_pdf(html_content) + if not pdf_content: + return False + + # Create a proper ir.attachment record + filename = f"Email_{subject.replace(' ', '_')[:30]}.pdf" + attachment_vals = { + "name": filename, + "datas": base64.b64encode(pdf_content), + "mimetype": "application/pdf", + "res_model": "mail.message", + "res_id": message_dict.get("id", 0), + } + + attachment = self.env["ir.attachment"].create(attachment_vals) + _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..8e19221 --- /dev/null +++ b/account_email_to_pdf/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_email_to_pdf +from . import test_email_integration diff --git a/account_email_to_pdf/tests/test_email_integration.py b/account_email_to_pdf/tests/test_email_integration.py new file mode 100644 index 0000000..5dbe556 --- /dev/null +++ b/account_email_to_pdf/tests/test_email_integration.py @@ -0,0 +1,88 @@ +import base64 +from odoo.tests.common import TransactionCase +from odoo.tools.misc import find_in_path +from unittest.mock import patch + + +class TestEmailProcessing(TransactionCase): + """Integration tests for email processing with PDF generation.""" + + @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 methods + cls.account_move = cls.env["account.move"] + + def test_create_pdf_from_email(self): + """Test that an email without attachments can be converted to PDF. + + This test directly verifies that the _create_pdf_from_email method + correctly generates a PDF attachment from an email message without + attachments, allowing the invoice creation process to continue. + """ + if not self.wkhtmltopdf_available: + self.skipTest("wkhtmltopdf not available") + + # Create a sample email message dictionary (similar to what would be parsed from an email) + message_dict = { + "subject": "Test Invoice", + "from": "test@example.com", + "to": "invoices@example.com", + "body": "

Invoice Test

This is a test invoice.

", + "attachments": [], # No attachments + "message_id": "", + } + + # Call the method directly to create a PDF from the email + attachment = self.account_move._create_pdf_from_email(message_dict) + + # Verify that an attachment was created + self.assertTrue(attachment, "An attachment should have been created") + # The actual name format is 'Email_' + subject + '.pdf' with spaces replaced by underscores + self.assertEqual( + attachment.name, + "Email_Test_Invoice.pdf", + "Attachment name should match expected format", + ) + self.assertEqual( + attachment.mimetype, "application/pdf", "Attachment should be a PDF" + ) + + # Verify the content of the PDF attachment + pdf_data = base64.b64decode(attachment.datas) + self.assertTrue(pdf_data.startswith(b"%PDF-"), "Content should be a valid PDF") + self.assertTrue(len(pdf_data) > 100, "PDF should have a reasonable size") + + def test_check_and_decode_attachment_with_empty_attachments(self): + """Test that _check_and_decode_attachment doesn't reject emails with no attachments.""" + if not self.wkhtmltopdf_available: + self.skipTest("wkhtmltopdf not available") + + # Set up a context with a message_dict to simulate email processing + message_dict = { + "subject": "Test Invoice", + "from": "test@example.com", + "to": "invoices@example.com", + "body": "

Invoice Test

This is a test invoice.

", + "attachments": [], # No attachments + "message_id": "", + } + + # Call the method with an empty attachments list + # We need to pass the message_dict in the context so _create_pdf_from_email can access it + result = self.account_move.with_context( + message_dict=message_dict + )._check_and_decode_attachment([]) + + # Verify that the result is not False (which would mean email rejection) + self.assertNotEqual( + result, + False, + "Should not reject the email when no attachments are provided", + ) + + # Verify that the result contains attachment data + self.assertTrue(result, "Should return attachment data") 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..1acddea --- /dev/null +++ b/account_email_to_pdf/tests/test_email_to_pdf.py @@ -0,0 +1,102 @@ +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") diff --git a/bemade_odoo_partner_scrapper/static/src/js/odoo_scrapper.js b/bemade_odoo_partner_scrapper/static/src/js/odoo_scrapper.js deleted file mode 100644 index cd7b9ef..0000000 --- a/bemade_odoo_partner_scrapper/static/src/js/odoo_scrapper.js +++ /dev/null @@ -1,68 +0,0 @@ -/** @odoo-module **/ - -import ListController from 'web.ListController'; -import ListView from 'web.ListView'; - -import KanbanController from 'web.KanbanController'; -import KanbanView from 'web.KanbanView'; - -const viewRegistry = require('web.view_registry'); - -const OdooScrapperListController = ListController.extend({ - // buttons_template must match the t-name on the template for the button (static xml) - buttons_template: 'odoo_scrapper.list_view_buttons', - events: _.extend({}, ListController.prototype.events, { - 'click .o_button_get_partner': '_onGetPartnerClick', - }), - // This may need to be async function() if there needs to be an await this._rpc({ ... }); call to not reload early - _onGetPartnerClick: function () { - this._rpc({ - model: 'res.partner', - method: 'get_odoo_partner', - args: [], - }).then(() => { - this.reload(); - }); - // Couldn't test this for real, but it runs the action. May need a this.reload() - }, -}); - -export const OdooScrapperListView = ListView.extend({ - config: _.extend({}, ListView.prototype.config, { - Controller: OdooScrapperListController, - }), -}); - -// key must match with the js_class attribute of the tree view you want to modify -viewRegistry.add('res_partner_odoo_scrapper_tree', OdooScrapperListView); - -const OdooScrapperKanbanController = KanbanController.extend({ - // buttons_template must match the t-name on the template for the button (static xml) - buttons_template: 'odoo_scrapper.kanban_view_buttons', - events: _.extend({}, KanbanController.prototype.events, { - 'click .o_button_get_partner': '_onGetPartnerClick', - }), - // This may need to be async function() if there needs to be an await this._rpc({ ... }); call to not reload early - _onGetPartnerClick: function () { - this._rpc({ - model: 'res.partner', - method: 'get_odoo_partner', - args: [], - }).then(() => { - this.reload(); - }); - // Couldn't test this for real, but it runs the action. May need a this.reload() - }, -}); - -export const OdooScrapperKanbanView = KanbanView.extend({ - config: _.extend({}, KanbanView.prototype.config, { - Controller: OdooScrapperKanbanController, - }), -}); - -// key must match with the js_class attribute of the tree view you want to modify -viewRegistry.add('res_partner_odoo_scrapper_kanban', OdooScrapperKanbanView); - - - diff --git a/bemade_odoo_partner_scrapper/static/src/xml/odoo_scrapper_templates.xml b/bemade_odoo_partner_scrapper/static/src/xml/odoo_scrapper_templates.xml deleted file mode 100644 index af0a1dd..0000000 --- a/bemade_odoo_partner_scrapper/static/src/xml/odoo_scrapper_templates.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/bemade_odoo_partner_scrapper/views/res_partner_views.xml b/bemade_odoo_partner_scrapper/views/res_partner_views.xml index 9dc982c..9e16ea9 100644 --- a/bemade_odoo_partner_scrapper/views/res_partner_views.xml +++ b/bemade_odoo_partner_scrapper/views/res_partner_views.xml @@ -8,19 +8,22 @@
- Odoo User - Odoo Partner + +Odoo User + +Odoo Partner
-

Partner

+

+ + Partner

- + @@ -38,9 +41,6 @@ res.partner - - res_partner_odoo_scrapper_tree - @@ -54,20 +54,17 @@ res.partner - - res_partner_odoo_scrapper_kanban - - + oe_kanban_global_click o_kanban_record_has_image_fill o_res_partner_kanban oe_kanban_color_#{record.color} - + - +