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:
parent
cca34bca1f
commit
2a9ace2a38
4 changed files with 250 additions and 47 deletions
|
|
@ -203,10 +203,11 @@ Glossary
|
|||
setting` definitions.
|
||||
|
||||
email setting
|
||||
This refers to the settings for a particular :term:`email type`,
|
||||
i.e. its sender and recipients, subject etc. So each email type
|
||||
has a "collection" of settings, and that collection is referred
|
||||
to simply as an "email setting" in the singular.
|
||||
This refers to the :term:`config settings <config setting>` for a
|
||||
particular :term:`email type`, i.e. its sender and recipients,
|
||||
subject etc. So each email type has a "collection" of settings,
|
||||
and that collection is referred to simply as an "email setting"
|
||||
in the singular.
|
||||
|
||||
email template
|
||||
Usually this refers to the HTML or TXT template file, used to
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ Basics
|
|||
|
||||
To send an email you (usually) need 3 things:
|
||||
|
||||
* key - unique key identifying the type of email
|
||||
* template - template file to render message body
|
||||
* key - unique key identifying the :term:`email type`
|
||||
* template - :term:`email template` file to render message body
|
||||
* context - context dict for template file rendering
|
||||
|
||||
And actually the template just needs to exist somewhere it can be
|
||||
|
|
|
|||
|
|
@ -23,8 +23,10 @@
|
|||
"""
|
||||
Email Handler
|
||||
"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import logging
|
||||
import re
|
||||
import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
|
@ -60,6 +62,7 @@ class EmailSetting: # pylint: disable=too-few-public-methods
|
|||
|
||||
default_subject = "Something happened!"
|
||||
|
||||
# nb. this is not used for sending; only preview
|
||||
def sample_data(self):
|
||||
return {
|
||||
'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
|
||||
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
|
||||
|
||||
Default :attr:`Message.subject` for the email, if none is
|
||||
configured.
|
||||
Default subject for sending emails of this type.
|
||||
|
||||
Usually, if config does not override, this will become
|
||||
:attr:`Message.subject`.
|
||||
|
||||
This is technically a Mako template string, so it will be
|
||||
rendered with the email context. But in most cases that
|
||||
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
|
||||
|
||||
fallback_key = None
|
||||
"""
|
||||
Optional fallback key to use for config/template lookup, if
|
||||
nothing is found for :attr:`key`.
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.app = config.get_app()
|
||||
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):
|
||||
"""
|
||||
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:
|
||||
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 name in dir(module):
|
||||
obj = getattr(module, name)
|
||||
if (
|
||||
isinstance(obj, type)
|
||||
and obj is not EmailSetting
|
||||
and issubclass(obj, EmailSetting)
|
||||
and pattern.match(obj.__name__)
|
||||
):
|
||||
self.classes["email_settings"][obj.__name__] = obj
|
||||
|
||||
|
|
@ -368,7 +433,9 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods
|
|||
"""
|
||||
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
|
||||
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.
|
||||
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
|
||||
the message.
|
||||
|
|
@ -387,6 +455,10 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods
|
|||
:param default_subject: Optional :attr:`~Message.subject`
|
||||
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
|
||||
: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)
|
||||
if "subject" not in kwargs:
|
||||
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:
|
||||
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:
|
||||
kwargs["bcc"] = self.get_auto_bcc(key)
|
||||
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:
|
||||
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)
|
||||
|
||||
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):
|
||||
"""
|
||||
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")
|
||||
|
||||
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`
|
||||
|
|
@ -464,7 +578,8 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods
|
|||
This calls :meth:`get_auto_subject_template()` and then
|
||||
(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
|
||||
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 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.
|
||||
"""
|
||||
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:
|
||||
return template
|
||||
|
||||
context = context or {}
|
||||
context = self.get_email_context(key, 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
|
||||
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 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.
|
||||
"""
|
||||
# prefer configured subject specific to key
|
||||
template = self.config.get(f"{self.config.appname}.email.{key}.subject")
|
||||
if template:
|
||||
if template := self.config.get(f"{self.config.appname}.email.{key}.subject"):
|
||||
return template
|
||||
|
||||
# or use caller-specified default, if applicable
|
||||
if 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
|
||||
if not setting:
|
||||
setting = self.get_email_setting(key)
|
||||
if setting and setting.default_subject:
|
||||
return setting.default_subject
|
||||
if setting:
|
||||
if subject := setting.get_default_subject():
|
||||
return subject
|
||||
|
||||
# fall back to global default
|
||||
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=[]
|
||||
)
|
||||
|
||||
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`
|
||||
content for a message, as determined by config. This renders
|
||||
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:
|
||||
context = context or {}
|
||||
context = self.get_email_context(key, context)
|
||||
return template.render(**context)
|
||||
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
|
||||
:attr:`~wuttjamaican.email.Message.html_body` content for a
|
||||
message, as determined by config. This renders a template
|
||||
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:
|
||||
context = context or {}
|
||||
context = self.get_email_context(key, context)
|
||||
return template.render(**context)
|
||||
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()
|
||||
if mode == "txt":
|
||||
|
|
@ -605,9 +739,19 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods
|
|||
raise ValueError("requested mode not supported")
|
||||
|
||||
try:
|
||||
|
||||
# prefer specific template for key
|
||||
return templates.get_template(f"{key}.{mode}.mako")
|
||||
|
||||
except TopLevelLookupException:
|
||||
|
||||
# but can use fallback if applicable
|
||||
if fallback_key:
|
||||
try:
|
||||
return templates.get_template(f"{fallback_key}.{mode}.mako")
|
||||
except TopLevelLookupException:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
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
|
||||
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.
|
||||
|
|
@ -773,6 +924,7 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods
|
|||
:param key: When auto-creating a message, this is the
|
||||
:term:`email key` identifying the type of email to send.
|
||||
Used to lookup config settings and template files.
|
||||
See also the ``fallback_key`` param, below.
|
||||
|
||||
:param context: Context dict for rendering automatic email
|
||||
template(s).
|
||||
|
|
@ -812,6 +964,10 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods
|
|||
context = {'data': [1, 2, 3]}
|
||||
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
|
||||
:meth:`make_auto_message()`. So, not used if you provide
|
||||
the ``message``.
|
||||
|
|
@ -827,7 +983,9 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods
|
|||
# auto-create message from key + context
|
||||
if 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):
|
||||
raise RuntimeError(
|
||||
f"message (type: {key}) has no body - "
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from unittest import TestCase
|
|||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
from mako.template import Template
|
||||
|
||||
from wuttjamaican import email as mod
|
||||
from wuttjamaican.util import resource_path
|
||||
|
|
@ -20,6 +21,16 @@ class TestEmailSetting(ConfigTestCase):
|
|||
self.assertIs(setting.app, self.app)
|
||||
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):
|
||||
setting = mod.EmailSetting(self.config)
|
||||
self.assertEqual(setting.sample_data(), {})
|
||||
|
|
@ -299,7 +310,9 @@ class TestEmailHandler(ConfigTestCase):
|
|||
msg = handler.make_auto_message("foo", subject=None)
|
||||
get_auto_subject.assert_not_called()
|
||||
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
|
||||
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)
|
||||
get_auto_txt_body.assert_not_called()
|
||||
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
|
||||
with patch.object(handler, "get_auto_html_body") as get_auto_html_body:
|
||||
msg = handler.make_auto_message("foo", html_body=None)
|
||||
get_auto_html_body.assert_not_called()
|
||||
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):
|
||||
handler = self.make_handler()
|
||||
|
|
@ -384,6 +401,11 @@ class TestEmailHandler(ConfigTestCase):
|
|||
template = handler.get_auto_subject_template("foo")
|
||||
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
|
||||
providers = {
|
||||
"wuttatest": MagicMock(email_modules=["tests.test_email"]),
|
||||
|
|
@ -446,27 +468,49 @@ class TestEmailHandler(ConfigTestCase):
|
|||
self.assertEqual(recips, ["bob@example.com"])
|
||||
|
||||
def test_get_auto_body_template(self):
|
||||
from mako.template import Template
|
||||
|
||||
handler = self.make_handler()
|
||||
|
||||
# error if bad request
|
||||
self.assertRaises(ValueError, handler.get_auto_body_template, "foo", "BADTYPE")
|
||||
# error if invalid mode (must be 'html' or 'txt')
|
||||
self.assertRaises(ValueError, handler.get_auto_body_template, "foo", "BAD_MODE")
|
||||
|
||||
# empty by default
|
||||
template = handler.get_auto_body_template("foo", "txt")
|
||||
self.assertIsNone(template)
|
||||
# no template by default
|
||||
self.assertIsNone(handler.get_auto_body_template("foo", "html"))
|
||||
self.assertIsNone(handler.get_auto_body_template("foo", "txt"))
|
||||
|
||||
# but returns a template if it exists
|
||||
# mock template lookup
|
||||
providers = {
|
||||
"wuttatest": MagicMock(email_templates=["tests:email-templates"]),
|
||||
}
|
||||
with patch.object(self.app, "providers", new=providers):
|
||||
handler = self.make_handler()
|
||||
|
||||
# template exists (txt)
|
||||
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):
|
||||
handler = self.make_handler()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue