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.
+
+
List item 1
+
List item 2
+
List item 3
+
+
+
+ """
+
+ 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 1
+
Header 2
+
Header 3
+
+
+
Row 1, Cell 1
+
Row 1, Cell 2
+
Row 1, Cell 3
+
+
+
Row 2, Cell 1
+
Row 2, Cell 2
+
Row 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