Merge branch '18.0' of git.bemade.org:bemade/bemade-addons into HEAD

This commit is contained in:
Benoît Vézina 2025-03-27 15:34:32 -04:00
commit 85964620cf
10 changed files with 412 additions and 102 deletions

View file

@ -0,0 +1 @@
from . import models

View file

@ -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",
}

View file

@ -0,0 +1 @@
from . import account_move

View file

@ -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"""
<html>
<head>
<style>
body {{ font-family: Arial, sans-serif; margin: 20px; }}
.email-header {{ border-bottom: 1px solid #ccc; padding-bottom: 10px; margin-bottom: 20px; }}
.email-meta {{ color: #666; font-size: 0.9em; margin-bottom: 5px; }}
.email-subject {{ font-size: 1.2em; font-weight: bold; margin-bottom: 15px; }}
.email-body {{ line-height: 1.5; }}
</style>
</head>
<body>
<div class="email-header">
<div class="email-meta">From: {escape(email_from)}</div>
<div class="email-meta">Date: {escape(email_date)}</div>
<div class="email-subject">{escape(subject)}</div>
</div>
<div class="email-body">
{body}
</div>
</body>
</html>
"""
# 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

View file

@ -0,0 +1,2 @@
from . import test_email_to_pdf
from . import test_email_integration

View file

@ -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": "<html><body><h1>Invoice Test</h1><p>This is a test invoice.</p></body></html>",
"attachments": [], # No attachments
"message_id": "<test123@example.com>",
}
# 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": "<html><body><h1>Invoice Test</h1><p>This is a test invoice.</p></body></html>",
"attachments": [], # No attachments
"message_id": "<test123@example.com>",
}
# 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")

View file

@ -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 = """
<html>
<head>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { color: #333; }
</style>
</head>
<body>
<h1>Test HTML Document</h1>
<p>This is a test paragraph with <b>bold text</b> and <i>italic text</i>.</p>
<ul>
<li>List item 1</li>
<li>List item 2</li>
<li>List item 3</li>
</ul>
</body>
</html>
"""
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 = """
<html>
<head>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
</style>
</head>
<body>
<h1>Complex HTML Test</h1>
<table>
<tr>
<th>Header 1</th>
<th>Header 2</th>
<th>Header 3</th>
</tr>
<tr>
<td>Row 1, Cell 1</td>
<td>Row 1, Cell 2</td>
<td>Row 1, Cell 3</td>
</tr>
<tr>
<td>Row 2, Cell 1</td>
<td>Row 2, Cell 2</td>
<td>Row 2, Cell 3</td>
</tr>
</table>
</body>
</html>
"""
# 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")

View file

@ -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);

View file

@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates id="template" xml:space="preserve">
<t t-extend="ListView.buttons" t-name="odoo_scrapper.list_view_buttons"> <!-- t-name must match with js controller button template -->
<t t-jquery="button.o_list_button_add" t-operation="after">
<button type="button"
class="btn btn-primary ml4 o_button_get_partner"
title="Get Odoo Partner"> Get Odoo Partner </button>
</t>
</t>
<t t-extend="KanbanView.buttons" t-name="odoo_scrapper.kanban_view_buttons">
<t t-jquery="button" t-operation="after">
<button type="button"
class="btn btn-primary ml4 o_button_get_partner"
title="Get Odoo Partner"> Get Odoo Partner </button>
</t>
</t>
</templates>

View file

@ -8,19 +8,22 @@
<field name="arch" type="xml">
<field name="company_type" position="after">
<br/>
<field name="is_odoo_user"/>Odoo User
<field name="is_odoo_partner"/>Odoo Partner
<field name="is_odoo_user"/>
Odoo User
<field name="is_odoo_partner"/>
Odoo Partner
<br/>
<div invisible="is_odoo_partner == False">
<h3><field name="odoo_partner_type"/> Partner</h3>
<h3>
<field name="odoo_partner_type"/>
Partner</h3>
</div>
</field>
<group name="container_row_2" position="after">
<group name="container_row_3">
<field name="relation_all_ids">
<list>
<field name="other_partner_id" required="True" options="{'no_create': True}"
string="Customer" domain="[['relation_all_ids.type_selection_id.id', '=', 1]]"/>
<field name="other_partner_id" required="True" options="{'no_create': True}" string="Customer" domain="[['relation_all_ids.type_selection_id.id', '=', 1]]"/>
<field name="date_start"/>
<field name="date_end"/>
<field name="active" invisible="1"/>
@ -38,9 +41,6 @@
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_tree"/>
<field name="arch" type="xml">
<xpath expr="//list" position="attributes">
<attribute name="js_class">res_partner_odoo_scrapper_tree</attribute>
</xpath>
<xpath expr="//list" position="inside">
<field name="is_odoo_user"/>
<field name="is_odoo_partner"/>
@ -54,20 +54,17 @@
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.res_partner_kanban_view"/>
<field name="arch" type="xml">
<xpath expr="//kanban" position="attributes">
<attribute name="js_class">res_partner_odoo_scrapper_kanban</attribute>
</xpath>
<xpath expr="//kanban" position="inside">
<field name="odoo_partner_type"/>
</xpath>
<xpath expr="//div[hasclass('oe_kanban_global_click') and hasclass('o_kanban_record_has_image_fill') and hasclass('o_res_partner_kanban')]" position="attributes">
<xpath expr="//div[hasclass('o_kanban_image_fill')]" position="attributes">
<attribute name="t-attf-class">oe_kanban_global_click o_kanban_record_has_image_fill o_res_partner_kanban oe_kanban_color_#{record.color}</attribute>
</xpath>
<xpath expr="//strong[hasclass('o_kanban_record_title') and hasclass('oe_partner_heading')]" position="before">
<field name="display_name" position="before">
<strong t-if="record.odoo_partner_type.raw_value" class="o_kanban_record_subtitle oe_partner_heading">
<field name="odoo_partner_type"/>
</strong>
</xpath>
</field>
</field>
</record>