3
0
Fork 0

fix: add fallback_key support for email settings

sometimes (e.g. for "import/export warning") we need some common
template and/or config
This commit is contained in:
Lance Edgar 2025-12-19 21:16:43 -06:00
parent cca34bca1f
commit 2a9ace2a38
4 changed files with 250 additions and 47 deletions

View file

@ -203,10 +203,11 @@ Glossary
setting` definitions. setting` definitions.
email setting email setting
This refers to the settings for a particular :term:`email type`, This refers to the :term:`config settings <config setting>` for a
i.e. its sender and recipients, subject etc. So each email type particular :term:`email type`, i.e. its sender and recipients,
has a "collection" of settings, and that collection is referred subject etc. So each email type has a "collection" of settings,
to simply as an "email setting" in the singular. and that collection is referred to simply as an "email setting"
in the singular.
email template email template
Usually this refers to the HTML or TXT template file, used to Usually this refers to the HTML or TXT template file, used to

View file

@ -10,8 +10,8 @@ Basics
To send an email you (usually) need 3 things: To send an email you (usually) need 3 things:
* key - unique key identifying the type of email * key - unique key identifying the :term:`email type`
* template - template file to render message body * template - :term:`email template` file to render message body
* context - context dict for template file rendering * context - context dict for template file rendering
And actually the template just needs to exist somewhere it can be And actually the template just needs to exist somewhere it can be

View file

@ -23,8 +23,10 @@
""" """
Email Handler Email Handler
""" """
# pylint: disable=too-many-lines
import logging import logging
import re
import smtplib import smtplib
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
@ -60,6 +62,7 @@ class EmailSetting: # pylint: disable=too-few-public-methods
default_subject = "Something happened!" default_subject = "Something happened!"
# nb. this is not used for sending; only preview
def sample_data(self): def sample_data(self):
return { return {
'foo': 1234, 'foo': 1234,
@ -84,23 +87,80 @@ class EmailSetting: # pylint: disable=too-few-public-methods
* allows for hard-coded sample context which can be used to render * allows for hard-coded sample context which can be used to render
templates for preview templates for preview
.. attribute:: key
Unique identifier for this :term:`email type`.
This is the :term:`email key` used for config/template lookup,
e.g. when sending an email.
This is automatically set based on the *class name* so there is
no need (or point) to set it. But the attribute is here for
read access, for convenience / code readability::
class poser_alert_foo(EmailSetting):
default_subject = "Something happened!"
handler = app.get_email_handler()
setting = handler.get_email_setting("poser_alert_foo")
assert setting.key == "poser_alert_foo"
See also :attr:`fallback_key`.
.. attribute:: default_subject .. attribute:: default_subject
Default :attr:`Message.subject` for the email, if none is Default subject for sending emails of this type.
configured.
Usually, if config does not override, this will become
:attr:`Message.subject`.
This is technically a Mako template string, so it will be This is technically a Mako template string, so it will be
rendered with the email context. But in most cases that rendered with the email context. But in most cases that
feature can be ignored, and this will be a simple string. feature can be ignored, and this will be a simple string.
Calling code should not access this directly, but instead use
:meth:`get_default_subject()` .
""" """
default_subject = None default_subject = None
fallback_key = None
"""
Optional fallback key to use for config/template lookup, if
nothing is found for :attr:`key`.
"""
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
self.app = config.get_app() self.app = config.get_app()
self.key = self.__class__.__name__ self.key = self.__class__.__name__
def get_description(self):
"""
This must return the full description for the :term:`email
type`. It is not used for the sending of email; only for
settings administration.
Default logic will use the class docstring.
:returns: String description for the email type
"""
return self.__class__.__doc__.strip()
def get_default_subject(self):
"""
This must return the default subject, for sending emails of
this type.
If config does not override, this will become
:attr:`Message.subject`.
Default logic here returns :attr:`default_subject` as-is.
:returns: Default subject as string
"""
return self.default_subject
def sample_data(self): def sample_data(self):
""" """
Should return a dict with sample context needed to render the Should return a dict with sample context needed to render the
@ -325,13 +385,18 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods
""" """
if "email_settings" not in self.classes: if "email_settings" not in self.classes:
self.classes["email_settings"] = {} self.classes["email_settings"] = {}
# nb. we only want lower_case_names - all UpperCaseNames
# are assumed to be base classes
pattern = re.compile(r"^[a-z]")
for module in self.get_email_modules(): for module in self.get_email_modules():
for name in dir(module): for name in dir(module):
obj = getattr(module, name) obj = getattr(module, name)
if ( if (
isinstance(obj, type) isinstance(obj, type)
and obj is not EmailSetting
and issubclass(obj, EmailSetting) and issubclass(obj, EmailSetting)
and pattern.match(obj.__name__)
): ):
self.classes["email_settings"][obj.__name__] = obj self.classes["email_settings"][obj.__name__] = obj
@ -368,7 +433,9 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods
""" """
return Message(**kwargs) return Message(**kwargs)
def make_auto_message(self, key, context=None, default_subject=None, **kwargs): def make_auto_message(
self, key, context=None, default_subject=None, fallback_key=None, **kwargs
):
""" """
Make a new email message using config to determine its Make a new email message using config to determine its
properties, and auto-generating body from a template. properties, and auto-generating body from a template.
@ -379,7 +446,8 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods
:param key: Unique key for this particular "type" of message. :param key: Unique key for this particular "type" of message.
This key is used as a prefix for all config settings and This key is used as a prefix for all config settings and
template names pertinent to the message. template names pertinent to the message. See also the
``fallback_key`` param, below.
:param context: Context dict used to render template(s) for :param context: Context dict used to render template(s) for
the message. the message.
@ -387,6 +455,10 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods
:param default_subject: Optional :attr:`~Message.subject` :param default_subject: Optional :attr:`~Message.subject`
template/string to use, if config does not specify one. template/string to use, if config does not specify one.
:param fallback_key: Optional fallback :term:`email key` to
use for config/template lookup, if nothing is found for
``key``.
:param \\**kwargs: Any remaining kwargs are passed as-is to :param \\**kwargs: Any remaining kwargs are passed as-is to
:meth:`make_message()`. More on this below. :meth:`make_message()`. More on this below.
@ -411,7 +483,7 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods
kwargs["sender"] = self.get_auto_sender(key) kwargs["sender"] = self.get_auto_sender(key)
if "subject" not in kwargs: if "subject" not in kwargs:
kwargs["subject"] = self.get_auto_subject( kwargs["subject"] = self.get_auto_subject(
key, context, default=default_subject key, context, default=default_subject, fallback_key=fallback_key
) )
if "to" not in kwargs: if "to" not in kwargs:
kwargs["to"] = self.get_auto_to(key) kwargs["to"] = self.get_auto_to(key)
@ -420,11 +492,47 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods
if "bcc" not in kwargs: if "bcc" not in kwargs:
kwargs["bcc"] = self.get_auto_bcc(key) kwargs["bcc"] = self.get_auto_bcc(key)
if "txt_body" not in kwargs: if "txt_body" not in kwargs:
kwargs["txt_body"] = self.get_auto_txt_body(key, context) kwargs["txt_body"] = self.get_auto_txt_body(
key, context, fallback_key=fallback_key
)
if "html_body" not in kwargs: if "html_body" not in kwargs:
kwargs["html_body"] = self.get_auto_html_body(key, context) kwargs["html_body"] = self.get_auto_html_body(
key, context, fallback_key=fallback_key
)
return self.make_message(**kwargs) return self.make_message(**kwargs)
def get_email_context(self, key, context=None): # pylint: disable=unused-argument
"""
This must return the "full" context for rendering the email
subject and/or body templates.
Normally the input ``context`` is coming from the
:meth:`send_email()` param of the same name.
By default, this method modifies the input context to add the
following:
* ``config`` - reference to the :term:`config object`
* ``app`` - reference to the :term:`app handler`
Subclass may further modify as needed.
:param key: The :term:`email key` for which to get context.
:param context: Input context dict.
:returns: Final context dict
"""
if context is None:
context = {}
context.update(
{
"config": self.config,
"app": self.app,
}
)
return context
def get_auto_sender(self, key): def get_auto_sender(self, key):
""" """
Returns automatic Returns automatic
@ -455,7 +563,13 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods
return self.config.get(f"{self.config.appname}.email.default.replyto") return self.config.get(f"{self.config.appname}.email.default.replyto")
def get_auto_subject( # pylint: disable=too-many-arguments,too-many-positional-arguments def get_auto_subject( # pylint: disable=too-many-arguments,too-many-positional-arguments
self, key, context=None, rendered=True, setting=None, default=None self,
key,
context=None,
rendered=True,
setting=None,
default=None,
fallback_key=None,
): ):
""" """
Returns automatic :attr:`~wuttjamaican.email.Message.subject` Returns automatic :attr:`~wuttjamaican.email.Message.subject`
@ -464,7 +578,8 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods
This calls :meth:`get_auto_subject_template()` and then This calls :meth:`get_auto_subject_template()` and then
(usually) renders the result using the given context. (usually) renders the result using the given context.
:param key: Key for the :term:`email type`. :param key: Key for the :term:`email type`. See also the
``fallback_key`` param, below.
:param context: Dict of context for rendering the subject :param context: Dict of context for rendering the subject
template, if applicable. template, if applicable.
@ -479,16 +594,23 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods
:param default: Default subject to use if none is configured. :param default: Default subject to use if none is configured.
:param fallback_key: Optional fallback :term:`email key` to
use for config lookup, if nothing is found for ``key``.
:returns: Final subject text, either "raw" or rendered. :returns: Final subject text, either "raw" or rendered.
""" """
template = self.get_auto_subject_template(key, setting=setting, default=default) template = self.get_auto_subject_template(
key, setting=setting, default=default, fallback_key=fallback_key
)
if not rendered: if not rendered:
return template return template
context = context or {} context = self.get_email_context(key, context)
return Template(template).render(**context) return Template(template).render(**context)
def get_auto_subject_template(self, key, setting=None, default=None): def get_auto_subject_template(
self, key, setting=None, default=None, fallback_key=None
):
""" """
Returns the template string to use for automatic subject line Returns the template string to use for automatic subject line
of a message, as determined by config. of a message, as determined by config.
@ -509,22 +631,32 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods
:param default: Default subject to use if none is configured. :param default: Default subject to use if none is configured.
:param fallback_key: Optional fallback :term:`email key` to
use for config lookup, if nothing is found for ``key``.
:returns: Final subject template, as raw text. :returns: Final subject template, as raw text.
""" """
# prefer configured subject specific to key # prefer configured subject specific to key
template = self.config.get(f"{self.config.appname}.email.{key}.subject") if template := self.config.get(f"{self.config.appname}.email.{key}.subject"):
if template:
return template return template
# or use caller-specified default, if applicable # or use caller-specified default, if applicable
if default: if default:
return default return default
# or use fallback key, if provided
if fallback_key:
if template := self.config.get(
f"{self.config.appname}.email.{fallback_key}.subject"
):
return template
# or subject from email setting, if defined # or subject from email setting, if defined
if not setting: if not setting:
setting = self.get_email_setting(key) setting = self.get_email_setting(key)
if setting and setting.default_subject: if setting:
return setting.default_subject if subject := setting.get_default_subject():
return subject
# fall back to global default # fall back to global default
return self.config.get( return self.config.get(
@ -569,32 +701,34 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods
f"{self.config.appname}.email.default.{typ}", default=[] f"{self.config.appname}.email.default.{typ}", default=[]
) )
def get_auto_txt_body(self, key, context=None): def get_auto_txt_body(self, key, context=None, fallback_key=None):
""" """
Returns automatic :attr:`~wuttjamaican.email.Message.txt_body` Returns automatic :attr:`~wuttjamaican.email.Message.txt_body`
content for a message, as determined by config. This renders content for a message, as determined by config. This renders
a template with the given context. a template with the given context.
""" """
template = self.get_auto_body_template(key, "txt") template = self.get_auto_body_template(key, "txt", fallback_key=fallback_key)
if template: if template:
context = context or {} context = self.get_email_context(key, context)
return template.render(**context) return template.render(**context)
return None return None
def get_auto_html_body(self, key, context=None): def get_auto_html_body(self, key, context=None, fallback_key=None):
""" """
Returns automatic Returns automatic
:attr:`~wuttjamaican.email.Message.html_body` content for a :attr:`~wuttjamaican.email.Message.html_body` content for a
message, as determined by config. This renders a template message, as determined by config. This renders a template
with the given context. with the given context.
""" """
template = self.get_auto_body_template(key, "html") template = self.get_auto_body_template(key, "html", fallback_key=fallback_key)
if template: if template:
context = context or {} context = self.get_email_context(key, context)
return template.render(**context) return template.render(**context)
return None return None
def get_auto_body_template(self, key, mode): # pylint: disable=empty-docstring def get_auto_body_template( # pylint: disable=empty-docstring
self, key, mode, fallback_key=None
):
""" """ """ """
mode = mode.lower() mode = mode.lower()
if mode == "txt": if mode == "txt":
@ -605,9 +739,19 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods
raise ValueError("requested mode not supported") raise ValueError("requested mode not supported")
try: try:
# prefer specific template for key
return templates.get_template(f"{key}.{mode}.mako") return templates.get_template(f"{key}.{mode}.mako")
except TopLevelLookupException: except TopLevelLookupException:
pass
# but can use fallback if applicable
if fallback_key:
try:
return templates.get_template(f"{fallback_key}.{mode}.mako")
except TopLevelLookupException:
pass
return None return None
def get_notes(self, key): def get_notes(self, key):
@ -756,7 +900,14 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods
) )
def send_email( # pylint: disable=too-many-arguments,too-many-positional-arguments def send_email( # pylint: disable=too-many-arguments,too-many-positional-arguments
self, key=None, context=None, message=None, sender=None, recips=None, **kwargs self,
key=None,
context=None,
message=None,
sender=None,
recips=None,
fallback_key=None,
**kwargs,
): ):
""" """
Send an email message. Send an email message.
@ -773,6 +924,7 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods
:param key: When auto-creating a message, this is the :param key: When auto-creating a message, this is the
:term:`email key` identifying the type of email to send. :term:`email key` identifying the type of email to send.
Used to lookup config settings and template files. Used to lookup config settings and template files.
See also the ``fallback_key`` param, below.
:param context: Context dict for rendering automatic email :param context: Context dict for rendering automatic email
template(s). template(s).
@ -812,6 +964,10 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods
context = {'data': [1, 2, 3]} context = {'data': [1, 2, 3]}
app.send_email('foo', context, to='me@example.com', cc='bobby@example.com') app.send_email('foo', context, to='me@example.com', cc='bobby@example.com')
:param fallback_key: Optional fallback :term:`email key` to
use for config/template lookup, if nothing is found for
``key``.
:param \\**kwargs: Any remaining kwargs are passed along to :param \\**kwargs: Any remaining kwargs are passed along to
:meth:`make_auto_message()`. So, not used if you provide :meth:`make_auto_message()`. So, not used if you provide
the ``message``. the ``message``.
@ -827,7 +983,9 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods
# auto-create message from key + context # auto-create message from key + context
if sender: if sender:
kwargs["sender"] = sender kwargs["sender"] = sender
message = self.make_auto_message(key, context or {}, **kwargs) message = self.make_auto_message(
key, context or {}, fallback_key=fallback_key, **kwargs
)
if not (message.txt_body or message.html_body): if not (message.txt_body or message.html_body):
raise RuntimeError( raise RuntimeError(
f"message (type: {key}) has no body - " f"message (type: {key}) has no body - "

View file

@ -5,6 +5,7 @@ from unittest import TestCase
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
import pytest import pytest
from mako.template import Template
from wuttjamaican import email as mod from wuttjamaican import email as mod
from wuttjamaican.util import resource_path from wuttjamaican.util import resource_path
@ -20,6 +21,16 @@ class TestEmailSetting(ConfigTestCase):
self.assertIs(setting.app, self.app) self.assertIs(setting.app, self.app)
self.assertEqual(setting.key, "EmailSetting") self.assertEqual(setting.key, "EmailSetting")
def test_get_description(self):
class MockSetting(mod.EmailSetting):
"""
this should be a good test
"""
setting = MockSetting(self.config)
self.assertEqual(setting.get_description(), "this should be a good test")
def test_sample_data(self): def test_sample_data(self):
setting = mod.EmailSetting(self.config) setting = mod.EmailSetting(self.config)
self.assertEqual(setting.sample_data(), {}) self.assertEqual(setting.sample_data(), {})
@ -299,7 +310,9 @@ class TestEmailHandler(ConfigTestCase):
msg = handler.make_auto_message("foo", subject=None) msg = handler.make_auto_message("foo", subject=None)
get_auto_subject.assert_not_called() get_auto_subject.assert_not_called()
msg = handler.make_auto_message("foo") msg = handler.make_auto_message("foo")
get_auto_subject.assert_called_once_with("foo", {}, default=None) get_auto_subject.assert_called_once_with(
"foo", {}, default=None, fallback_key=None
)
# to # to
with patch.object(handler, "get_auto_to") as get_auto_to: with patch.object(handler, "get_auto_to") as get_auto_to:
@ -330,14 +343,18 @@ class TestEmailHandler(ConfigTestCase):
msg = handler.make_auto_message("foo", txt_body=None) msg = handler.make_auto_message("foo", txt_body=None)
get_auto_txt_body.assert_not_called() get_auto_txt_body.assert_not_called()
msg = handler.make_auto_message("foo") msg = handler.make_auto_message("foo")
get_auto_txt_body.assert_called_once_with("foo", {}) get_auto_txt_body.assert_called_once_with(
"foo", {"config": self.config, "app": self.app}, fallback_key=None
)
# html_body # html_body
with patch.object(handler, "get_auto_html_body") as get_auto_html_body: with patch.object(handler, "get_auto_html_body") as get_auto_html_body:
msg = handler.make_auto_message("foo", html_body=None) msg = handler.make_auto_message("foo", html_body=None)
get_auto_html_body.assert_not_called() get_auto_html_body.assert_not_called()
msg = handler.make_auto_message("foo") msg = handler.make_auto_message("foo")
get_auto_html_body.assert_called_once_with("foo", {}) get_auto_html_body.assert_called_once_with(
"foo", {"config": self.config, "app": self.app}, fallback_key=None
)
def test_get_auto_sender(self): def test_get_auto_sender(self):
handler = self.make_handler() handler = self.make_handler()
@ -384,6 +401,11 @@ class TestEmailHandler(ConfigTestCase):
template = handler.get_auto_subject_template("foo") template = handler.get_auto_subject_template("foo")
self.assertEqual(template, "Foo Message") self.assertEqual(template, "Foo Message")
# can configure via fallback_key
self.config.setdefault("wutta.email.bar.subject", "Bar Message")
template = handler.get_auto_subject_template("baz", fallback_key="bar")
self.assertEqual(template, "Bar Message")
# EmailSetting can provide default subject # EmailSetting can provide default subject
providers = { providers = {
"wuttatest": MagicMock(email_modules=["tests.test_email"]), "wuttatest": MagicMock(email_modules=["tests.test_email"]),
@ -446,26 +468,48 @@ class TestEmailHandler(ConfigTestCase):
self.assertEqual(recips, ["bob@example.com"]) self.assertEqual(recips, ["bob@example.com"])
def test_get_auto_body_template(self): def test_get_auto_body_template(self):
from mako.template import Template
handler = self.make_handler() handler = self.make_handler()
# error if bad request # error if invalid mode (must be 'html' or 'txt')
self.assertRaises(ValueError, handler.get_auto_body_template, "foo", "BADTYPE") self.assertRaises(ValueError, handler.get_auto_body_template, "foo", "BAD_MODE")
# empty by default # no template by default
template = handler.get_auto_body_template("foo", "txt") self.assertIsNone(handler.get_auto_body_template("foo", "html"))
self.assertIsNone(template) self.assertIsNone(handler.get_auto_body_template("foo", "txt"))
# but returns a template if it exists # mock template lookup
providers = { providers = {
"wuttatest": MagicMock(email_templates=["tests:email-templates"]), "wuttatest": MagicMock(email_templates=["tests:email-templates"]),
} }
with patch.object(self.app, "providers", new=providers): with patch.object(self.app, "providers", new=providers):
handler = self.make_handler() handler = self.make_handler()
template = handler.get_auto_body_template("test_foo", "txt")
self.assertIsInstance(template, Template) # template exists (txt)
self.assertEqual(template.uri, "test_foo.txt.mako") template = handler.get_auto_body_template("test_foo", "txt")
self.assertIsInstance(template, Template)
self.assertEqual(template.uri, "test_foo.txt.mako")
# template exists (html)
template = handler.get_auto_body_template("test_foo", "html")
self.assertIsInstance(template, Template)
self.assertEqual(template.uri, "test_foo.html.mako")
# no such template
template = handler.get_auto_body_template("no_such_template", "html")
self.assertIsNone(template)
# but can use fallback
template = handler.get_auto_body_template(
"no_such_template", "html", fallback_key="test_foo"
)
self.assertIsInstance(template, Template)
self.assertEqual(template.uri, "test_foo.html.mako")
# what if fallback is also not found
template = handler.get_auto_body_template(
"no_such_template", "html", fallback_key="this_neither"
)
self.assertIsNone(template)
def test_get_auto_txt_body(self): def test_get_auto_txt_body(self):
handler = self.make_handler() handler = self.make_handler()