Skip to content

Commit

Permalink
Merge pull request #2685 from carpentries/feature/2679-preview-render…
Browse files Browse the repository at this point in the history
…ed-email

[Emails] Preview rendered jinja2+markdown email templates
  • Loading branch information
pbanaszkiewicz authored Aug 17, 2024
2 parents cdbf384 + 199ed6f commit d296515
Show file tree
Hide file tree
Showing 7 changed files with 471 additions and 19 deletions.
280 changes: 279 additions & 1 deletion amy/emails/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
from datetime import UTC, date, datetime, time, timedelta
from unittest.mock import patch
from datetime import timezone as dt_timezone
from unittest.mock import MagicMock, call, patch

from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.test import RequestFactory, TestCase
from django.utils import timezone
from jinja2 import DebugUndefined, Environment

from emails.models import ScheduledEmail
from emails.signals import Signal
from emails.utils import (
build_context_from_dict,
build_context_from_list,
combine_date_with_current_utc_time,
find_model_instance,
find_signal_by_name,
immediate_action,
jinjanify,
map_api_uri_to_model_or_value,
map_single_api_uri_to_model_or_value,
messages_action_cancelled,
messages_action_scheduled,
messages_action_updated,
Expand All @@ -20,6 +28,7 @@
messages_missing_template_link,
one_month_before,
person_from_request,
scalar_value_from_type,
session_condition,
shift_date_and_apply_current_utc_time,
two_months_after,
Expand Down Expand Up @@ -312,3 +321,272 @@ def test_find_signal_by_name__signal_found(self) -> None:

# Assert
self.assertEqual(result, expected)


class TestJinjanify(TestCase):
def test_jinjanify(self) -> None:
# Arrange
engine = Environment(autoescape=True, undefined=DebugUndefined)
template = "Hello, {{ name }}!"
context = {"name": "John"}

# Act
result = jinjanify(engine, template, context)

# Assert
self.assertEqual(result, "Hello, John!")


class TestScalarValueFromType(TestCase):
def test_scalar_value_from_type(self) -> None:
# Arrange
tz = dt_timezone(timedelta(hours=1, minutes=30))
args = [
("str", "test", "test"),
("int", "123", 123),
("float", "123.456", 123.456),
("bool", "True", True),
("bool", "true", True),
("bool", "t", True),
("bool", "1", True),
("bool", "False", False),
("bool", "false", False),
("bool", "f", False),
(
"date",
"2020-01-01T12:01:03+01:30",
datetime(2020, 1, 1, 12, 1, 3, tzinfo=tz),
),
(
"date",
"2020-01-01T12:01:03Z",
datetime(2020, 1, 1, 12, 1, 3, tzinfo=timezone.utc),
),
(
"date",
"2020-01-01T12:01:03+00:00",
datetime(2020, 1, 1, 12, 1, 3, tzinfo=timezone.utc),
),
("none", "", None),
("none", "whatever", None),
]

# Act & Assert
for type_, value, expected in args:
with self.subTest(type=type_, value=value):
result = scalar_value_from_type(type_, value)
self.assertEqual(result, expected)

def test_scalar_value_from_type__unsupported_type(self) -> None:
# Arrange
type_ = "unsupported"
value = "test"

# Act & Assert
with self.assertRaises(ValueError) as cm:
scalar_value_from_type(type_, value)
self.assertEqual(
str(cm.exception),
f"Unsupported scalar type {type_!r} (value {value!r}).",
)

def test_scalar_value_from_type__failed_to_parse(self) -> None:
# Arrange
args = [
("int", "test"),
("date", "test"),
]

# Act & Assert
for type_, value in args:
with self.subTest(type=type_, value=value):
with self.assertRaises(ValueError) as cm:
scalar_value_from_type(type_, value)
self.assertEqual(
str(cm.exception),
f"Failed to parse {value!r} for type {type_!r}.",
)


class TestFindModelInstance(TestCase):
def test_find_model_instance(self) -> None:
# Arrange
person = Person.objects.create()
model_name = "person"
model_pk = person.pk

# Act
result = find_model_instance(model_name, model_pk)

# Assert
self.assertEqual(result, person)

def test_find_model_instance__model_doesnt_exist(self) -> None:
# Arrange
model_name = "fake_model"
model_pk = 1

# Act & Assert
with self.assertRaises(ValueError) as cm:
find_model_instance(model_name, model_pk)
self.assertEqual(
str(cm.exception),
f"Model {model_name!r} not found.",
)

def test_find_model_instance__invalid_model_pk(self) -> None:
# Arrange
model_name = "person"
model_pk = "invalid_pk"

# Act & Assert
with self.assertRaises(ValueError) as cm:
find_model_instance(model_name, model_pk)
self.assertEqual(
str(cm.exception),
f"Failed to parse pk {model_pk!r} for model {model_name!r}: Field "
f"'id' expected a number but got '{model_pk}'.",
)

def test_find_model_instance__model_instance_doesnt_exist(self) -> None:
# Arrange
model_name = "person"
model_pk = 1

# Act & Assert
with self.assertRaises(ValueError) as cm:
find_model_instance(model_name, model_pk)
self.assertEqual(
str(cm.exception),
f"Model {model_name!r} with pk {model_pk!r} not found.",
)


class TestMapSingleApiUriToModelOrValue(TestCase):
@patch("emails.utils.scalar_value_from_type")
def test_map_single_api_uri_to_model_or_value__scalar(
self, mock_scalar_value_from_type: MagicMock
) -> None:
# Arrange
uri = "value:str#test"
# Act
map_single_api_uri_to_model_or_value(uri)
# Assert
mock_scalar_value_from_type.assert_called_once_with("str", "test")

@patch("emails.utils.find_model_instance")
def test_map_single_api_uri_to_model_or_value__model(
self, mock_find_model_instance: MagicMock
) -> None:
# Arrange
uri = "api:person#1"
# Act
map_single_api_uri_to_model_or_value(uri)
# Assert
mock_find_model_instance.assert_called_once_with("person", 1)

def test_map_single_api_uri_to_model_or_value__unsupported_uri(self) -> None:
# Arrange
uris = [
"invalid_uri",
"api2://",
"api2:model#1",
]
# Act & Assert
for uri in uris:
with self.subTest(uri=uri):
with self.assertRaises(ValueError) as cm:
map_single_api_uri_to_model_or_value(uri)
self.assertEqual(str(cm.exception), f"Unsupported URI {uri!r}.")

def test_map_single_api_uri_to_model_or_value__unparsable_uri(self) -> None:
# Arrange
uris = [
"api://",
"api:",
"api:model#test",
]
# Act & Assert
for uri in uris:
with self.subTest(uri=uri):
with self.assertRaises(ValueError) as cm:
map_single_api_uri_to_model_or_value(uri)
self.assertEqual(str(cm.exception), f"Failed to parse URI {uri!r}.")


class TestMapApiUriToModelOrValue(TestCase):
@patch("emails.utils.map_single_api_uri_to_model_or_value")
def test_map_api_uri_to_model_or_value(
self, mock_map_single_api_uri_to_model_or_value: MagicMock
) -> None:
# Arrange
single_uri_arg = "fake_uri"
multiple_uris_arg = ["fake_uri1", "fake_uri2"]

# Act
map_api_uri_to_model_or_value(single_uri_arg)
map_api_uri_to_model_or_value(multiple_uris_arg)

# Assert
mock_map_single_api_uri_to_model_or_value.assert_has_calls(
[call("fake_uri"), call("fake_uri1"), call("fake_uri2")]
)


class TestBuildContextFromDict(TestCase):
@patch("emails.utils.map_api_uri_to_model_or_value")
def test_build_context_from_dict(
self, mock_map_api_uri_to_model_or_value: MagicMock
) -> None:
# Arrange
context = {"key1": "uri1", "key2": "uri2", "key3": ["uri3", "uri4"]}
# Act
build_context_from_dict(context)
# Assert
mock_map_api_uri_to_model_or_value.assert_has_calls(
[call("uri1"), call("uri2"), call(["uri3", "uri4"])]
)

def test_integration(self) -> None:
# Arrange
person1 = Person.objects.create(username="test1", email="test1@example.org")
person2 = Person.objects.create(username="test2", email="test2@example.org")
context = {
"person1": f"api:person#{person1.pk}",
"list": [f"api:person#{person2.pk}", "value:str#test"],
}

# Act
result = build_context_from_dict(context)

# Assert
self.assertEqual(result, {"person1": person1, "list": [person2, "test"]})


class TestBuildContextFromList(TestCase):
@patch("emails.utils.map_api_uri_to_model_or_value")
def test_build_context_from_list(
self, mock_map_api_uri_to_model_or_value: MagicMock
) -> None:
# Arrange
context = [{"api_uri": "uri1", "property": "email"}, {"value_uri": "uri2"}]
# Act
build_context_from_list(context)
# Assert
mock_map_api_uri_to_model_or_value.assert_has_calls(
[call("uri1"), call("uri2")]
)

def test_integration(self) -> None:
# Arrange
person1 = Person.objects.create(username="test1", email="test1@example.org")
context = [
{"api_uri": f"api:person#{person1.pk}", "property": "email"},
{"value_uri": "value:str#test2@example.org"},
]

# Act
result = build_context_from_list(context)

# Assert
self.assertEqual(result, ["test1@example.org", "test2@example.org"])
44 changes: 34 additions & 10 deletions amy/emails/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,20 +290,29 @@ def test_view(self) -> None:
from_header="workshops@carpentries.org",
cc_header=["team@carpentries.org"],
bcc_header=[],
subject="Greetings {{ name }}",
body="Hello, {{ name }}! Nice to meet **you**.",
subject="Greetings {{ personal }} {{ family }}",
body="Hello, {{ hermione.full_name }}! Nice to meet **you**. Here's a "
"list of what you can do: {{ a_list }}.",
)
engine = EmailTemplate.get_engine()
context = {"name": "Harry"}
scheduled_email = ScheduledEmail.objects.create(
scheduled_at=timezone.now() + timedelta(hours=1),
to_header=["peter@spiderman.net"],
to_header_context_json=[
{"api_uri": f"api:person#{self.hermione.pk}", "property": "email"},
{"value_uri": "value:str#test2@example.org"},
],
from_header=template.from_header,
reply_to_header=template.reply_to_header,
cc_header=template.cc_header,
bcc_header=template.bcc_header,
subject=template.render_template(engine, template.subject, context),
body=template.render_template(engine, template.body, context),
subject=template.subject,
body=template.body,
context_json={
"hermione": f"api:person#{self.hermione.pk}",
"personal": "value:str#Harry",
"family": "value:str#Potter",
"a_list": ["value:int#1", "value:int#2"],
},
template=template,
)
url = reverse("scheduledemail_details", kwargs={"pk": scheduled_email.pk})
Expand All @@ -327,10 +336,6 @@ def test_view(self) -> None:
).order_by("-created_at")
),
)
self.assertEqual(
rv.context["rendered_body"],
"<p>Hello, Harry! Nice to meet <strong>you</strong>.</p>",
)
self.assertEqual(
rv.context["status_explanation"],
ScheduledEmailStatusExplanation[
Expand All @@ -340,6 +345,25 @@ def test_view(self) -> None:
self.assertEqual(
rv.context["ScheduledEmailStatusActions"], ScheduledEmailStatusActions
)
self.assertEqual(
rv.context["rendered_context"],
{
"a_list": [1, 2],
"hermione": self.hermione,
"personal": "Harry",
"family": "Potter",
},
)
self.assertEqual(
rv.context["rendered_body"],
"<p>Hello, Hermione Granger! Nice to meet <strong>you</strong>. "
"Here's a list of what you can do: [1, 2].</p>",
)
self.assertEqual(rv.context["rendered_subject"], "Greetings Harry Potter")
self.assertEqual(
rv.context["rendered_to_header_context"],
[self.hermione.email, "test2@example.org"],
)


class TestScheduledEmailUpdate(TestBase):
Expand Down
Loading

0 comments on commit d296515

Please sign in to comment.