Skip to content
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

Merged
merged 16 commits into from
Jul 10, 2023
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions amy/emails/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from django.dispatch import receiver
from django.http import HttpRequest
from django.utils import timezone
from django.utils.html import format_html
from typing_extensions import Unpack

from emails.controller import EmailController
Expand Down Expand Up @@ -47,11 +48,12 @@ def persons_merged_receiver(sender: Any, **kwargs: Unpack[PersonsMergedKwargs])
}
signal = "persons_merged"
try:
scheduled_email = EmailController.schedule_email( # noqa
scheduled_email = EmailController.schedule_email(
signal=signal,
context=context,
scheduled_at=scheduled_at,
to_header=[person.email],
generic_relation_obj=person,
)
except EmailTemplate.DoesNotExist:
messages.warning(
Expand All @@ -61,6 +63,9 @@ def persons_merged_receiver(sender: Any, **kwargs: Unpack[PersonsMergedKwargs])
else:
messages.info(
request,
f"Action was scheduled: {scheduled_email.get_absolute_url()}.",
format_html(
'Action was scheduled: <a href="{}">{}</a>.',
scheduled_email.get_absolute_url(),
scheduled_email.pk,
),
)
# TODO: associate scheduled_email with person
31 changes: 31 additions & 0 deletions amy/emails/controller.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from datetime import datetime

from django.db.models import Model

from emails.models import (
EmailTemplate,
ScheduledEmail,
Expand All @@ -15,6 +17,7 @@ def schedule_email(
context: dict,
scheduled_at: datetime,
to_header: list[str],
generic_relation_obj: Model | None = None,
) -> ScheduledEmail:
template = EmailTemplate.objects.filter(active=True).get(signal=signal)
engine = EmailTemplate.get_engine()
Expand All @@ -33,6 +36,7 @@ def schedule_email(
subject=subject,
body=body,
template=template,
generic_relation=generic_relation_obj,
)
ScheduledEmailLog.objects.create(
details=f"Scheduled {signal} to run at {scheduled_at.isoformat()}",
Expand All @@ -41,3 +45,30 @@ def schedule_email(
scheduled_email=scheduled_email,
)
return scheduled_email

@staticmethod
def reschedule_email(
scheduled_email: ScheduledEmail, new_scheduled_at: datetime
) -> ScheduledEmail:
scheduled_email.scheduled_at = new_scheduled_at
scheduled_email.save()
ScheduledEmailLog.objects.create(
details=f"Rescheduled email to run at {new_scheduled_at.isoformat()}",
state_before=scheduled_email.state,
state_after=scheduled_email.state,
scheduled_email=scheduled_email,
)
return scheduled_email

@staticmethod
def cancel_email(scheduled_email: ScheduledEmail) -> ScheduledEmail:
old_state = scheduled_email.state
scheduled_email.state = ScheduledEmailStatus.CANCELLED
scheduled_email.save()
ScheduledEmailLog.objects.create(
details="Email was cancelled",
state_before=old_state,
state_after=scheduled_email.state,
scheduled_email=scheduled_email,
)
return scheduled_email
39 changes: 39 additions & 0 deletions amy/emails/forms.py
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):
Copy link
Contributor

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?

Email cancel form with buttons "Cancel the email" and "Cancel"

Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to:
obraz

helper = BootstrapHelper(submit_label="Cancel the email (prevent from running)")
25 changes: 25 additions & 0 deletions amy/emails/migrations/0002_auto_20230703_2116.py
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),
),
]
21 changes: 20 additions & 1 deletion amy/emails/models.py
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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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):
Expand Down
5 changes: 4 additions & 1 deletion amy/emails/tests/actions/test_persons_merged.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,9 @@ def test_action_triggered(self) -> None:
# Assert
scheduled_email = ScheduledEmail.objects.get(template=template)
mock_messages.info.assert_called_once_with(
request, f"Action was scheduled: {scheduled_email.get_absolute_url()}."
request,
f'Action was scheduled: <a href="{scheduled_email.get_absolute_url()}">'
f"{scheduled_email.pk}</a>.",
)

@override_settings(EMAIL_MODULE_ENABLED=True)
Expand Down Expand Up @@ -126,6 +128,7 @@ def test_email_scheduled(
context=context,
scheduled_at=scheduled_at,
to_header=[person.email],
generic_relation_obj=person,
)

@override_settings(EMAIL_MODULE_ENABLED=True)
Expand Down
125 changes: 120 additions & 5 deletions amy/emails/tests/test_controller.py
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):
Expand All @@ -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()}")
Expand All @@ -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]"],
)
Expand Down Expand Up @@ -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",
)
Loading
Loading