From 0857add7d0bc6d8d5e1a33d1537b75c826772a08 Mon Sep 17 00:00:00 2001 From: jacklinke Date: Mon, 21 Oct 2024 23:24:59 -0400 Subject: [PATCH] Major refactor --- .../example/management/__init__.py | 0 .../example/management/commands/__init__.py | 0 .../management/commands/showtemplates.py | 36 +++ example_project/settings.py | 1 + noxfile.py | 12 +- pyproject.toml | 4 +- src/templated_email_md/apps.py | 10 + src/templated_email_md/backend.py | 289 +++++++++++++----- src/templated_email_md/exceptions.py | 13 + uv.lock | 20 +- 10 files changed, 299 insertions(+), 86 deletions(-) create mode 100644 example_project/example/management/__init__.py create mode 100644 example_project/example/management/commands/__init__.py create mode 100644 example_project/example/management/commands/showtemplates.py create mode 100644 src/templated_email_md/apps.py create mode 100644 src/templated_email_md/exceptions.py diff --git a/example_project/example/management/__init__.py b/example_project/example/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/example_project/example/management/commands/__init__.py b/example_project/example/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/example_project/example/management/commands/showtemplates.py b/example_project/example/management/commands/showtemplates.py new file mode 100644 index 0000000..dba0e59 --- /dev/null +++ b/example_project/example/management/commands/showtemplates.py @@ -0,0 +1,36 @@ +"""A management command to list all templates in the project.""" +import os +from importlib import import_module + +from django.conf import settings +from django.core.management.base import BaseCommand +from django.template.loader import get_template + + +class Command(BaseCommand): + """A management command to list all templates in the project.""" + help = "List all templates" + + def handle(self, *args, **options): + templates = [] + for app in settings.INSTALLED_APPS: + try: + app_module = import_module(app) + app_dir = os.path.dirname(app_module.__file__) + templates_dir = os.path.join(app_dir, 'templates') + + if os.path.exists(templates_dir): + for root, _, files in os.walk(templates_dir): + for file in files: + templated_email_file_extension = getattr(settings, 'TEMPLATED_EMAIL_FILE_EXTENSION', 'md') + if file.endswith('.html') or file.endswith(f'.{templated_email_file_extension}'): + template_path = os.path.join(root, file) + try: + get_template(template_path) + templates.append(template_path) + except Exception: + pass + except ImportError: + pass + + self.stdout.write('\n'.join(templates)) diff --git a/example_project/settings.py b/example_project/settings.py index d971b36..6c73899 100644 --- a/example_project/settings.py +++ b/example_project/settings.py @@ -42,6 +42,7 @@ "django.contrib.messages", "django.contrib.staticfiles", "anymail", + "templated_email_md", "example_project.example", ] diff --git a/noxfile.py b/noxfile.py index 140ddc1..261fd6d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -150,18 +150,10 @@ def safety(session: Session, django: str) -> None: @nox.parametrize("django", DJANGO_VERSIONS) def tests(session: Session, django: str) -> None: """Run the test suite.""" - session.install(f"django=={django}") - session.install(".") - session.install( - "coverage[toml]", - "pytest", - "pytest-django", - "requests", - "pygments", - ) + session.run("uv", "sync", "--prerelease=allow", "--extra=dev") try: - session.run("coverage", "run", "-m", "pytest", *session.posargs) + session.run("coverage", "run", "-m", "pytest", "-vv", *session.posargs) finally: if session.interactive: session.notify("coverage", posargs=[]) diff --git a/pyproject.toml b/pyproject.toml index ca9570b..7d52711 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ "premailer~=3.10", "html2text~=2024.2", "django-templated-email~=3.0", + "django-render-block~=0.8", ] [project.urls] @@ -73,7 +74,8 @@ dev-dependencies = [ "xdoctest[colors]>=1.1.3", "myst-parser>=4.0.0", "linkify-it-py==2.0.3", - "python-environ>=0.4.54", + "django-anymail~=12.0", + "python-environ~=0.4", ] [tool.black] diff --git a/src/templated_email_md/apps.py b/src/templated_email_md/apps.py new file mode 100644 index 0000000..b3259f5 --- /dev/null +++ b/src/templated_email_md/apps.py @@ -0,0 +1,10 @@ +"""App configuration for templated_email_md.""" + +from django.apps import AppConfig + + +class TemplatedEmailMdConfig(AppConfig): + """App configuration for templated_email_md.""" + + name = "templated_email_md" + verbose_name = "Templated Email Markdown" diff --git a/src/templated_email_md/backend.py b/src/templated_email_md/backend.py index dc18082..c861012 100644 --- a/src/templated_email_md/backend.py +++ b/src/templated_email_md/backend.py @@ -1,19 +1,27 @@ """Backend that uses Django templates and allows writing email content in Markdown.""" import logging +import re +from typing import Any +from typing import Dict +from typing import Optional +from typing import Union import html2text import markdown import premailer from django.conf import settings from django.template import Context -from django.template import TemplateDoesNotExist +from django.template import Template from django.template.loader import get_template from django.utils.translation import gettext as _ from render_block import BlockNotFound from render_block import render_block_to_string from templated_email.backends.vanilla_django import TemplateBackend +from templated_email_md.exceptions import CSSInliningError +from templated_email_md.exceptions import MarkdownRenderError + logger = logging.getLogger(__name__) @@ -25,15 +33,149 @@ class MarkdownTemplateBackend(TemplateBackend): The plain text version is generated from the final HTML using html2text. """ - def __init__(self, fail_silently=False, template_prefix=None, template_suffix=None, **kwargs): + def __init__( + self, + fail_silently: bool = False, + template_prefix: Optional[str] = None, + template_suffix: Optional[str] = None, + **kwargs: Any, + ): + """Initialize the MarkdownTemplateBackend. + + Args: + fail_silently: Whether to suppress exceptions and return a fallback response + template_prefix: Prefix for template names + template_suffix: Suffix for template names + **kwargs: Additional keyword arguments + """ super().__init__( - fail_silently=fail_silently, template_prefix=template_prefix, template_suffix=template_suffix, **kwargs + fail_silently=fail_silently, + template_prefix=template_prefix, + template_suffix=template_suffix, + **kwargs, ) + self.template_suffix = template_suffix or getattr(settings, 'TEMPLATED_EMAIL_FILE_EXTENSION', 'md') + self.fail_silently = fail_silently self.base_html_template = getattr( - settings, "TEMPLATED_EMAIL_BASE_HTML_TEMPLATE", "templated_email/markdown_base.html" + settings, + "TEMPLATED_EMAIL_BASE_HTML_TEMPLATE", + "templated_email/markdown_base.html", ) + self.markdown_extensions = getattr( + settings, + "TEMPLATED_EMAIL_MARKDOWN_EXTENSIONS", + [ + "markdown.extensions.meta", + "markdown.extensions.tables", + "markdown.extensions.extra", + ], + ) + + def _render_markdown(self, content: str) -> str: + """Convert Markdown content to HTML. + + Args: + content: Markdown content to convert + + Returns: + Converted HTML content + + Raises: + MarkdownRenderError: If Markdown conversion fails + """ + try: + return markdown.markdown(content, extensions=self.markdown_extensions) + except Exception as e: + logger.error("Failed to render Markdown: %s", e) + if self.fail_silently: + return content # Return raw content if conversion fails + raise MarkdownRenderError(f"Failed to render Markdown: {e}") from e + + def _inline_css(self, html: str) -> str: + """Inline CSS styles in HTML content. + + Args: + html: HTML content to process + + Returns: + HTML with inlined CSS + + Raises: + CSSInliningError: If CSS inlining fails + """ + try: + return premailer.transform( + html=html, + strip_important=False, + keep_style_tags=True, + cssutils_logging_level=logging.ERROR, + ) + except Exception as e: + logger.error("Failed to inline CSS: %s", e) + if self.fail_silently: + return html # Return original HTML if inlining fails + raise CSSInliningError(f"Failed to inline CSS: {e}") from e + + def _get_template_path(self, template_name: str, template_dir: Optional[str], file_extension: Optional[str]) -> str: + """Construct the full template path.""" + extension = file_extension or self.template_suffix + if extension.startswith('.'): + extension = extension[1:] + + prefix = template_dir if template_dir else (self.template_prefix or '') + template_path = f"{prefix}{template_name}" + if not template_path.endswith(f".{extension}"): + template_path = f"{template_path}.{extension}" + + return template_path - def _render_email(self, template_name, context, template_dir=None, file_extension=None): + def _extract_blocks(self, template_content: str, context: Dict[str, Any]) -> Dict[str, str]: + """Extract and render template blocks.""" + blocks = {} + + # Find subject block + subject_start = template_content.find("{% block subject %}") + if subject_start != -1: + subject_end = template_content.find("{% endblock %}", subject_start) + if subject_end != -1: + subject = template_content[subject_start + 19:subject_end].strip() + # Render any template variables in subject + subject_template = Template(subject) + blocks['subject'] = subject_template.render(Context(context)) + # Remove subject block from content + template_content = ( + template_content[:subject_start].strip() + + template_content[subject_end + 13:].strip() + ) + + blocks['content'] = template_content.strip() + return blocks + + def _generate_plain_text(self, html_content: str) -> str: + """Generate plain text content from HTML. + + Args: + html_content: HTML content to convert + + Returns: + Plain text content without Markdown formatting + """ + h = html2text.HTML2Text() + h.ignore_links = False + h.ignore_images = True + h.body_width = 0 + h.ignore_emphasis = True # Do not add '*' around bold/italic text + h.mark_code = False # Do not add backticks around code + h.wrap_links = False # Do not wrap links in brackets + return h.handle(html_content).strip() + + def _render_email( + self, + template_name: Union[str, list, tuple], + context: Dict[str, Any], + template_dir: Optional[str] = None, + file_extension: Optional[str] = None, + ) -> Dict[str, str]: """Render the email content using the Markdown template and base HTML template. Args: @@ -43,83 +185,82 @@ def _render_email(self, template_name, context, template_dir=None, file_extensio file_extension (str): The file extension of the template file. Returns: - dict: A dictionary containing the rendered HTML, plain text, and subject. + Dictionary containing the rendered HTML, plain text, and subject. """ - response = {} - - file_extension = file_extension or self.template_suffix - if file_extension.startswith("."): - file_extension = file_extension[1:] - template_extension = f".{file_extension}" - - if isinstance(template_name, (tuple, list)): - prefixed_templates = template_name - else: - prefixed_templates = [template_name] - - full_template_names = [] - for one_prefixed_template in prefixed_templates: - one_full_template_name = "".join((template_dir or self.template_prefix, one_prefixed_template)) - if not one_full_template_name.endswith(template_extension): - one_full_template_name += template_extension - full_template_names.append(one_full_template_name) - - # Load the Markdown template - for template_path in full_template_names: + fallback_content = _("Email template rendering failed.") + + try: + template_path = self._get_template_path( + template_name if isinstance(template_name, str) else template_name[0], + template_dir, + file_extension + ) + + # Use render_block_to_string to get 'subject' block try: + subject = render_block_to_string(template_path, 'subject', context).strip() + except BlockNotFound: + subject = _("No Subject") + + # Override subject if 'subject' is in context + subject = context.get('subject', subject) + + # Use render_block_to_string to get 'content' block + try: + content = render_block_to_string(template_path, 'content', context).strip() + except BlockNotFound: + # If 'content' block is not defined, render the entire template without the 'subject' block md_template = get_template(template_path) - break - except TemplateDoesNotExist: - continue - else: - raise TemplateDoesNotExist(f"No Markdown email template found for {template_name}") - - # Render the Markdown template with context to get the Markdown content - render_context = Context(context) - md_content = md_template.render(render_context) - - # Convert the Markdown content to HTML - html_message = markdown.markdown( - md_content, - extensions=[ - "markdown.extensions.meta", - "markdown.extensions.tables", - "markdown.extensions.extra", - ], - ) + template_source = md_template.template.source + # Remove the 'subject' block from the template source + pattern = r'{% block subject %}.*?{% endblock %}' + content_without_subject = re.sub(pattern, '', template_source, flags=re.DOTALL).strip() + content_template = Template(content_without_subject) + content = content_template.render(Context(context)) - # Load the base HTML template - base_template = get_template(self.base_html_template) + # Convert markdown content to HTML + html_content = self._render_markdown(content) - # Update context with rendered HTML content - context["markdown_content"] = html_message + # Get the base template + base_template = get_template(self.base_html_template) - # Render the base HTML template with context - rendered_html = base_template.render(context) + # Create context for base template with all needed variables + base_context = { + **context, # Original context + 'markdown_content': html_content, + 'subject': context.get('subject', subject), + } - # Use Premailer to inline CSS - inlined_html = premailer.transform( - html=rendered_html, - strip_important=False, - keep_style_tags=True, - cssutils_logging_level=logging.ERROR, - ) + # Render base template + rendered_html = base_template.render(base_context) - response["html"] = inlined_html + # Inline CSS + inlined_html = self._inline_css(rendered_html) - # Generate plain text version using html2text - response["plain"] = html2text.html2text(inlined_html) + # Generate plain text from HTML content (not the full email template) + plain_text = self._generate_plain_text(html_content) - # Get the email subject - if "subject" in context: - response["subject"] = context["subject"] - else: - # Try to get 'subject' block from the base template - try: - subject = render_block_to_string([self.base_html_template], "subject", context) - response["subject"] = subject.strip() - except BlockNotFound: - # Use default subject - response["subject"] = _("No Subject") + return { + 'html': inlined_html, + 'plain': plain_text, + 'subject': subject, + } + + except Exception as e: + logger.error("Failed to render email: %s", str(e)) + if self.fail_silently: + return { + 'html': fallback_content, + 'plain': fallback_content, + 'subject': _("No Subject"), + } + raise - return response + def _get_subject_from_template(self, context: Dict[str, Any]) -> Optional[str]: + """Extract subject from template block.""" + try: + return render_block_to_string( + [self.base_html_template], "subject", context + ).strip() + except BlockNotFound: + return None diff --git a/src/templated_email_md/exceptions.py b/src/templated_email_md/exceptions.py new file mode 100644 index 0000000..639347b --- /dev/null +++ b/src/templated_email_md/exceptions.py @@ -0,0 +1,13 @@ +"""Exceptions for the MarkdownTemplateBackend.""" + + +class MarkdownTemplateBackendError(Exception): + """Base exception for MarkdownTemplateBackend errors.""" + + +class MarkdownRenderError(MarkdownTemplateBackendError): + """Raised when Markdown rendering fails.""" + + +class CSSInliningError(MarkdownTemplateBackendError): + """Raised when CSS inlining fails.""" diff --git a/uv.lock b/uv.lock index ffee334..bc4b06c 100644 --- a/uv.lock +++ b/uv.lock @@ -490,6 +490,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/b8/f205f2b8c44c6cdc555c4f56bbe85ceef7f67c0cf1caa8abe078bb7e32bd/Django-5.1.2-py3-none-any.whl", hash = "sha256:f11aa87ad8d5617171e3f77e1d5d16f004b79a2cf5d2e1d2b97a6a1f8e9ba5ed", size = 8276058 }, ] +[[package]] +name = "django-anymail" +version = "12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/cd/34eca54757511df252ae8b56f5b75f3d137cc51ba53c7c5c74b8d449fa0d/django_anymail-12.0.tar.gz", hash = "sha256:65789c1b0f42915aa0450a4f173f77572d4c552979b748ddd8125af41972ad30", size = 95175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/09/83c613d8781d56af7b8736c7c968f8dc0487763b2680ae9d726a7313c9de/django_anymail-12.0-py3-none-any.whl", hash = "sha256:de8458d713d0f9776da9ed04dcd3a0161be23e9ecbcb49dcf149219700ecd274", size = 131312 }, +] + [[package]] name = "django-render-block" version = "0.10" @@ -518,6 +532,7 @@ source = { editable = "." } dependencies = [ { name = "click" }, { name = "django" }, + { name = "django-render-block" }, { name = "django-templated-email" }, { name = "html2text" }, { name = "markdown" }, @@ -530,6 +545,7 @@ dev = [ { name = "black" }, { name = "coverage", extra = ["toml"] }, { name = "darglint" }, + { name = "django-anymail" }, { name = "flake8" }, { name = "flake8-bugbear" }, { name = "flake8-docstrings" }, @@ -560,6 +576,7 @@ dev = [ requires-dist = [ { name = "click", specifier = "~=8.1" }, { name = "django", specifier = ">=4.2" }, + { name = "django-render-block", specifier = "~=0.8" }, { name = "django-templated-email", specifier = "~=3.0" }, { name = "html2text", specifier = "~=2024.2" }, { name = "markdown", specifier = "~=3.7" }, @@ -572,6 +589,7 @@ dev = [ { name = "black", specifier = ">=24.4.2" }, { name = "coverage", extras = ["toml"], specifier = ">=7.5.1" }, { name = "darglint", specifier = ">=1.8.1" }, + { name = "django-anymail", specifier = "~=12.0" }, { name = "flake8", specifier = "==7.0.0" }, { name = "flake8-bugbear", specifier = ">=24.4.26" }, { name = "flake8-docstrings", specifier = ">=1.7.0" }, @@ -589,7 +607,7 @@ dev = [ { name = "pytest", specifier = ">=8.3.3" }, { name = "pytest-cov", specifier = ">=5.0.0" }, { name = "pytest-django", specifier = ">=4.9.0" }, - { name = "python-environ", specifier = ">=0.4.54" }, + { name = "python-environ", specifier = "~=0.4" }, { name = "pyupgrade", specifier = ">=3.15.2" }, { name = "safety", specifier = ">=3.2.0" }, { name = "sphinx", specifier = ">=8.0.2" },