details:   https://code.tryton.org/tryton/commit/79ee566ee0dc
branch:    default
user:      Cédric Krier <[email protected]>
date:      Thu Dec 11 16:31:51 2025 +0100
description:
        Support the conversion of MJML reports to HTML

        Closes #12597
diffstat:

 trytond/CHANGELOG                    |   1 +
 trytond/doc/ref/report.rst           |  11 +++++++++++
 trytond/setup.py                     |   3 ++-
 trytond/tox.ini                      |   1 +
 trytond/trytond/report/__init__.py   |   4 ++--
 trytond/trytond/report/report.py     |  26 ++++++++++++++++++++++++++
 trytond/trytond/tests/report.mjml    |   9 +++++++++
 trytond/trytond/tests/report.py      |   5 +++++
 trytond/trytond/tests/report.xml     |   8 ++++++++
 trytond/trytond/tests/test_report.py |  18 ++++++++++++++++++
 10 files changed, 83 insertions(+), 3 deletions(-)

diffs (206 lines):

diff -r 817136daa763 -r 79ee566ee0dc trytond/CHANGELOG
--- a/trytond/CHANGELOG Wed Feb 18 11:28:35 2026 +0100
+++ b/trytond/CHANGELOG Thu Dec 11 16:31:51 2025 +0100
@@ -1,3 +1,4 @@
+* Support the conversion of MJML reports to HTML
 * Allow filtering users to be notified by cron tasks
 
 Version 7.8.0 - 2025-12-15
diff -r 817136daa763 -r 79ee566ee0dc trytond/doc/ref/report.rst
--- a/trytond/doc/ref/report.rst        Wed Feb 18 11:28:35 2026 +0100
+++ b/trytond/doc/ref/report.rst        Thu Dec 11 16:31:51 2025 +0100
@@ -129,6 +129,10 @@
 Email
 =====
 
+Email reports must have ``HTML`` as output format.
+`MJML <https://mjml.io/>`_ syntax can be used with the ``XML`` input format and
+``HTML`` output format to generate responsive emails.
+
 .. function:: get_email(report, record, languages)
 
    Returns the :py:class:`~email.message.EmailMessage` and title using the
@@ -136,3 +140,10 @@
    :class:`~trytond.model.ModelStorage` record for each language.
 
    .. note:: Order languages with the preferred last.
+
+.. function:: mjml_to_html(content)
+
+   Converts ``MJML`` content to ``HTML``.
+
+   .. warning::
+      It may return the content unconverted if some dependencies are missing.
diff -r 817136daa763 -r 79ee566ee0dc trytond/setup.py
--- a/trytond/setup.py  Wed Feb 18 11:28:35 2026 +0100
+++ b/trytond/setup.py  Thu Dec 11 16:31:51 2025 +0100
@@ -81,7 +81,7 @@
         'trytond.ir.ui': ['*.xml', '*.rng', '*.rnc'],
         'trytond.res': [
             'tryton.cfg', '*.xml', '*.html', 'view/*.xml', 'locale/*.po'],
-        'trytond.tests': ['tryton.cfg', '*.xml', '*.txt'],
+        'trytond.tests': ['tryton.cfg', '*.xml', '*.txt', '*.mjml'],
         },
     scripts=[
         'bin/trytond',
@@ -154,6 +154,7 @@
         'Levenshtein': ['python-Levenshtein'],
         'html2text': ['html2text'],
         'weasyprint': ['weasyprint'],
+        'mjml': ['mrml'],
         'coroutine': ['gevent>=1.1'],
         'image': ['pillow'],
         'barcode': ['python-barcode[images]'],
diff -r 817136daa763 -r 79ee566ee0dc trytond/tox.ini
--- a/trytond/tox.ini   Wed Feb 18 11:28:35 2026 +0100
+++ b/trytond/tox.ini   Thu Dec 11 16:31:51 2025 +0100
@@ -8,6 +8,7 @@
     barcode
     qrcode
     email-validation
+    mjml
 commands =
     coverage run --omit=*/tests/* -m xmlrunner discover -s trytond.tests 
{posargs}
 commands_post =
diff -r 817136daa763 -r 79ee566ee0dc trytond/trytond/report/__init__.py
--- a/trytond/trytond/report/__init__.py        Wed Feb 18 11:28:35 2026 +0100
+++ b/trytond/trytond/report/__init__.py        Thu Dec 11 16:31:51 2025 +0100
@@ -1,5 +1,5 @@
 # This file is part of Tryton.  The COPYRIGHT file at the top level of
 # this repository contains the full copyright notices and license terms.
-from .report import Report, get_email
+from .report import Report, get_email, mjml_to_html
 
-__all__ = [Report, get_email]
+__all__ = [Report, get_email, mjml_to_html]
diff -r 817136daa763 -r 79ee566ee0dc trytond/trytond/report/report.py
--- a/trytond/trytond/report/report.py  Wed Feb 18 11:28:35 2026 +0100
+++ b/trytond/trytond/report/report.py  Thu Dec 11 16:31:51 2025 +0100
@@ -130,6 +130,19 @@
             self.report_name, text, text_plural, n)
 
 
+def _lazy_import(name):
+    attr_name = f'_mod_{name}'
+    if not hasattr(_lazy_import, attr_name):
+        try:
+            mod = __import__(name)
+        except ImportError:
+            mod = None
+        setattr(_lazy_import, attr_name, mod)
+    else:
+        mod = getattr(_lazy_import, attr_name)
+    return mod
+
+
 class Report(URLMixin, PoolBase):
 
     @classmethod
@@ -414,6 +427,13 @@
                 and output_format == 'pdf'):
             return output_format, weasyprint.HTML(string=data).write_pdf()
 
+        if (input_format == 'xml'
+                and output_format == 'html'
+                and isinstance(data, str)
+                and data.startswith('<mjml')):
+            if mrml := _lazy_import('mrml'):
+                return output_format, mrml.to_html(data).content
+
         if input_format == output_format and output_format in MIMETYPES:
             return output_format, data
 
@@ -637,3 +657,9 @@
         msg.add_header(
             'Content-Language', ', '.join(l.code for l in languages))
     return msg, title
+
+
+def mjml_to_html(content):
+    if mrml := _lazy_import('mrml'):
+        content = mrml.to_html(content).content
+    return content
diff -r 817136daa763 -r 79ee566ee0dc trytond/trytond/tests/report.mjml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/trytond/trytond/tests/report.mjml Thu Dec 11 16:31:51 2025 +0100
@@ -0,0 +1,9 @@
+<mjml xmlns:py="http://genshi.edgewall.org/";>
+    <mj-body>
+        <mj-section>
+            <mj-column>
+                ${user.name}
+            </mj-column>
+        </mj-section>
+    </mj-body>
+</mjml>
diff -r 817136daa763 -r 79ee566ee0dc trytond/trytond/tests/report.py
--- a/trytond/trytond/tests/report.py   Wed Feb 18 11:28:35 2026 +0100
+++ b/trytond/trytond/tests/report.py   Thu Dec 11 16:31:51 2025 +0100
@@ -9,7 +9,12 @@
     __name__ = 'test.test_report'
 
 
+class TestReportMJML(Report):
+    __name__ = 'test.test_report_mjml'
+
+
 def register(module):
     Pool.register(
         TestReport,
+        TestReportMJML,
         module=module, type_='report')
diff -r 817136daa763 -r 79ee566ee0dc trytond/trytond/tests/report.xml
--- a/trytond/trytond/tests/report.xml  Wed Feb 18 11:28:35 2026 +0100
+++ b/trytond/trytond/tests/report.xml  Thu Dec 11 16:31:51 2025 +0100
@@ -9,5 +9,13 @@
             <field name="report">tests/report.txt</field>
             <field name="template_extension">txt</field>
         </record>
+
+        <record model="ir.action.report" id="test_report_mjml">
+            <field name="name">Test Report MJML</field>
+            <field name="report_name">test.test_report_mjml</field>
+            <field name="report">tests/report.mjml</field>
+            <field name="template_extension">xml</field>
+            <field name="extension">html</field>
+        </record>
     </data>
 </tryton>
diff -r 817136daa763 -r 79ee566ee0dc trytond/trytond/tests/test_report.py
--- a/trytond/trytond/tests/test_report.py      Wed Feb 18 11:28:35 2026 +0100
+++ b/trytond/trytond/tests/test_report.py      Thu Dec 11 16:31:51 2025 +0100
@@ -1,9 +1,15 @@
 # This file is part of Tryton.  The COPYRIGHT file at the top level of
 # this repository contains the full copyright notices and license terms.
 import datetime
+import unittest
 from email.message import EmailMessage
 from unittest.mock import Mock, patch
 
+try:
+    import mrml
+except ImportError:
+    mrml = None
+
 from trytond.model.exceptions import AccessError
 from trytond.pool import Pool
 from trytond.report.report import Report, get_email
@@ -87,6 +93,18 @@
         with self.assertRaises(AccessError):
             Report.execute([1], {'model': 'test.access'})
 
+    @unittest.skipUnless(mrml, "required mrml")
+    @with_transaction()
+    def test_convert_mjml_to_html(self):
+        "Test convert MJML to HTML"
+        pool = Pool()
+        Report = pool.get('test.test_report_mjml', type='report')
+
+        oext, content, _, _ = Report.execute([1], {})
+
+        self.assertEqual(oext, 'html')
+        self.assertEqual(content[:15], '<!doctype html>')
+
     @with_transaction()
     def test_get_email_html(self):
         "Test get email"

Reply via email to