-
-
Notifications
You must be signed in to change notification settings - Fork 72
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Improve scheduled email views #2448
Changes from 12 commits
b08de34
b05538b
16bbab9
6d3518b
d371225
067d194
cb97130
24606b7
e1159e1
dd0dc6f
05dce87
91bcb3b
b1e3028
d363e1b
71f206a
c73d862
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
from django import forms | ||
|
||
from emails.models import ScheduledEmail | ||
from workshops.forms import BootstrapHelper | ||
|
||
|
||
class ScheduledEmailEditForm(forms.ModelForm): | ||
class Meta: | ||
model = ScheduledEmail | ||
fields = [ | ||
"to_header", | ||
"from_header", | ||
"reply_to_header", | ||
"cc_header", | ||
"bcc_header", | ||
"subject", | ||
"body", | ||
] | ||
|
||
def __init__(self, *args, **kwargs): | ||
super().__init__(*args, **kwargs) | ||
|
||
array_email_field_help_text = "Separate email addresses with a comma" | ||
self.fields["to_header"].help_text = array_email_field_help_text | ||
self.fields["cc_header"].help_text = array_email_field_help_text | ||
self.fields["bcc_header"].help_text = array_email_field_help_text | ||
|
||
|
||
class ScheduledEmailRescheduleForm(forms.Form): | ||
scheduled_at = forms.SplitDateTimeField( | ||
label=ScheduledEmail._meta.get_field("scheduled_at").verbose_name, | ||
help_text="Time in UTC", | ||
) | ||
|
||
helper = BootstrapHelper(submit_label="Update") | ||
|
||
|
||
class ScheduledEmailCancelForm(forms.Form): | ||
helper = BootstrapHelper(submit_label="Cancel the email (prevent from running)") |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
# Generated by Django 3.2.19 on 2023-07-03 21:16 | ||
|
||
from django.db import migrations, models | ||
import django.db.models.deletion | ||
|
||
|
||
class Migration(migrations.Migration): | ||
|
||
dependencies = [ | ||
('contenttypes', '0002_remove_content_type_name'), | ||
('emails', '0001_initial'), | ||
] | ||
|
||
operations = [ | ||
migrations.AddField( | ||
model_name='scheduledemail', | ||
name='generic_relation_content_type', | ||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='contenttypes.contenttype'), | ||
), | ||
migrations.AddField( | ||
model_name='scheduledemail', | ||
name='generic_relation_pk', | ||
field=models.PositiveIntegerField(blank=True, null=True), | ||
), | ||
] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,11 +1,14 @@ | ||
import uuid | ||
|
||
from django.conf import settings | ||
from django.contrib.contenttypes.fields import GenericForeignKey | ||
from django.contrib.contenttypes.models import ContentType | ||
from django.contrib.postgres.fields import ArrayField | ||
from django.core.exceptions import ValidationError | ||
from django.db import models | ||
from django.template import TemplateSyntaxError, engines | ||
from django.template.backends.base import BaseEngine | ||
from django.urls import reverse | ||
from reversion import revisions as reversion | ||
|
||
from workshops.mixins import ActiveMixin, CreatedMixin, CreatedUpdatedMixin | ||
|
@@ -97,6 +100,9 @@ def clean(self) -> None: | |
def __str__(self) -> str: | ||
return self.name | ||
|
||
def get_absolute_url(self) -> str: | ||
return reverse("email_template_detail", kwargs={"pk": self.pk}) | ||
|
||
|
||
class ScheduledEmailStatus(models.TextChoices): | ||
SCHEDULED = "scheduled" | ||
|
@@ -159,14 +165,27 @@ class ScheduledEmail(CreatedUpdatedMixin, models.Model): | |
verbose_name="Linked template", | ||
) | ||
|
||
# This generic relation is limited only to single relation, and only to models | ||
# defining their PK as numeric. | ||
generic_relation_content_type = models.ForeignKey( | ||
ContentType, | ||
on_delete=models.SET_NULL, | ||
null=True, | ||
blank=True, | ||
) | ||
generic_relation_pk = models.PositiveIntegerField(null=True, blank=True) | ||
generic_relation = GenericForeignKey( | ||
"generic_relation_content_type", "generic_relation_pk" | ||
) | ||
|
||
Comment on lines
+168
to
+180
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How do you see us making use of this generic object relation? Is this what will help us to e.g. check a Person's email address is up to date when sending an email? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For now it's only going to point at the main object, for which the email was created (e.g. a person for "persons_merged" signal, or a membership for "membership quarterly check"). I admit it's a subject to change, as we can only have 1 object in this generic relationship, and sometimes we would like to have more than that (e.g. for instructor not placed in a workshop we could have a person and an event). |
||
class Meta: | ||
indexes = [models.Index(fields=["state", "scheduled_at"])] | ||
|
||
def __str__(self) -> str: | ||
return f"{self.to_header}: {self.subject}" | ||
|
||
def get_absolute_url(self) -> str: | ||
return "#TODO" # reverse("model_detail", kwargs={"pk": self.pk}) | ||
return reverse("scheduled_email_detail", kwargs={"pk": self.pk}) | ||
|
||
|
||
class ScheduledEmailLog(CreatedMixin, models.Model): | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,9 +1,12 @@ | ||
from datetime import UTC, datetime | ||
|
||
from django.template.exceptions import TemplateSyntaxError | ||
from django.test import TestCase | ||
from django.utils import timezone | ||
|
||
from emails.controller import EmailController | ||
from emails.models import EmailTemplate, ScheduledEmailLog | ||
from emails.models import EmailTemplate, ScheduledEmailLog, ScheduledEmailStatus | ||
from workshops.models import Person | ||
|
||
|
||
class TestEmailController(TestCase): | ||
|
@@ -24,16 +27,16 @@ def test_schedule_email(self) -> None: | |
# Act | ||
scheduled_email = EmailController.schedule_email( | ||
signal, | ||
context={"name": "James"}, | ||
context={"name": "Harry"}, | ||
scheduled_at=now, | ||
to_header=["[email protected]"], | ||
) | ||
log = ScheduledEmailLog.objects.get(scheduled_email__pk=scheduled_email.pk) | ||
|
||
# Assert | ||
self.assertEqual(template, scheduled_email.template) | ||
self.assertEqual(scheduled_email.subject, "Greetings James") | ||
self.assertEqual(scheduled_email.body, "Hello, James! Nice to meet **you**.") | ||
self.assertEqual(scheduled_email.subject, "Greetings Harry") | ||
self.assertEqual(scheduled_email.body, "Hello, Harry! Nice to meet **you**.") | ||
self.assertEqual(scheduled_email.scheduled_at, now) | ||
self.assertEqual(log.scheduled_email, scheduled_email) | ||
self.assertEqual(log.details, f"Scheduled {signal} to run at {now.isoformat()}") | ||
|
@@ -47,7 +50,7 @@ def test_schedule_email__no_template(self) -> None: | |
with self.assertRaises(EmailTemplate.DoesNotExist): | ||
EmailController.schedule_email( | ||
signal, | ||
context={"name": "James"}, | ||
context={"name": "Harry"}, | ||
scheduled_at=now, | ||
to_header=["[email protected]"], | ||
) | ||
|
@@ -75,3 +78,115 @@ def test_schedule_email__invalid_template(self) -> None: | |
scheduled_at=now, | ||
to_header=["[email protected]"], | ||
) | ||
|
||
def test_schedule_email__generic_object_link(self) -> None: | ||
# Arrange | ||
now = timezone.now() | ||
signal = "test_email_template" | ||
person = Person(personal="Harry", family="Potter") | ||
EmailTemplate.objects.create( | ||
name="Test Email Template", | ||
signal=signal, | ||
from_header="[email protected]", | ||
cc_header=["[email protected]"], | ||
bcc_header=[], | ||
subject="Greetings {{ name }}", | ||
body="Hello, {{ name }}! Nice to meet **you**.", | ||
) | ||
|
||
# Act | ||
scheduled_email = EmailController.schedule_email( | ||
signal, | ||
context={"name": "Harry"}, | ||
scheduled_at=now, | ||
to_header=["[email protected]"], | ||
generic_relation_obj=person, | ||
) | ||
|
||
# Assert | ||
self.assertEqual(scheduled_email.generic_relation, person) | ||
|
||
def test_reschedule_email(self) -> None: | ||
# Arrange | ||
old_scheduled_date = datetime(2023, 7, 5, 10, 00, tzinfo=UTC) | ||
new_scheduled_date = datetime(2024, 7, 5, 10, 00, tzinfo=UTC) | ||
signal = "test_email_template" | ||
EmailTemplate.objects.create( | ||
name="Test Email Template", | ||
signal=signal, | ||
from_header="[email protected]", | ||
cc_header=["[email protected]"], | ||
bcc_header=[], | ||
subject="Greetings {{ name }}", | ||
body="Hello, {{ name }}! Nice to meet **you**.", | ||
) | ||
|
||
scheduled_email = EmailController.schedule_email( | ||
signal, | ||
context={"name": "Harry"}, | ||
scheduled_at=old_scheduled_date, | ||
to_header=["[email protected]"], | ||
) | ||
|
||
# Act | ||
logs_count = ScheduledEmailLog.objects.filter( | ||
scheduled_email=scheduled_email | ||
).count() | ||
scheduled_email = EmailController.reschedule_email( | ||
scheduled_email, new_scheduled_date | ||
) | ||
|
||
# Assert | ||
self.assertEqual(scheduled_email.scheduled_at, new_scheduled_date) | ||
self.assertEqual( | ||
ScheduledEmailLog.objects.filter(scheduled_email=scheduled_email).count(), | ||
logs_count + 1, | ||
) | ||
self.assertEqual( | ||
ScheduledEmailLog.objects.filter(scheduled_email=scheduled_email) | ||
.order_by("-created_at") | ||
.first() | ||
.details, # type: ignore | ||
f"Rescheduled email to run at {new_scheduled_date.isoformat()}", | ||
) | ||
|
||
def test_cancel_email(self) -> None: | ||
# Arrange | ||
now = timezone.now() | ||
signal = "test_email_template" | ||
EmailTemplate.objects.create( | ||
name="Test Email Template", | ||
signal=signal, | ||
from_header="[email protected]", | ||
cc_header=["[email protected]"], | ||
bcc_header=[], | ||
subject="Greetings {{ name }}", | ||
body="Hello, {{ name }}! Nice to meet **you**.", | ||
) | ||
|
||
scheduled_email = EmailController.schedule_email( | ||
signal, | ||
context={"name": "Harry"}, | ||
scheduled_at=now, | ||
to_header=["[email protected]"], | ||
) | ||
|
||
# Act | ||
logs_count = ScheduledEmailLog.objects.filter( | ||
scheduled_email=scheduled_email | ||
).count() | ||
scheduled_email = EmailController.cancel_email(scheduled_email) | ||
|
||
# Assert | ||
self.assertEqual(scheduled_email.state, ScheduledEmailStatus.CANCELLED) | ||
self.assertEqual( | ||
ScheduledEmailLog.objects.filter(scheduled_email=scheduled_email).count(), | ||
logs_count + 1, | ||
) | ||
self.assertEqual( | ||
ScheduledEmailLog.objects.filter(scheduled_email=scheduled_email) | ||
.order_by("-created_at") | ||
.first() | ||
.details, # type: ignore | ||
"Email was cancelled", | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The buttons on this form could be confusing, as both of them say "Cancel". Could you reword the usual "Cancel" button to "Return without cancelling" or similar?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should also think about this for the reschedule form - could a user misinterpret the meaning of the "Cancel" button there too?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that grey "Cancel" button used here is common on other forms as well. That's why I wanted to keep it the same, and instead extend the left cancel button (you can see it has a long label).
I am open to suggestions, but I'm not so sure "Return without cancelling" is the best choice. Maybe we can figure out different labels.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, finding something that works well alongside our existing patterns is tricky, and we should make sure this is included in user testing.
Perhaps we could use a question like "Are you sure you want to cancel this email?" with Yes/No buttons to get away from the confusion altogether?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good to me. This would work for cancelling the scheduled email, but not so much for the rescheduling form.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changed to: