Skip to content

Commit

Permalink
feat: send email on attempt update
Browse files Browse the repository at this point in the history
  • Loading branch information
zacharis278 committed Oct 20, 2023
1 parent 9b87fb7 commit 49c8976
Show file tree
Hide file tree
Showing 8 changed files with 256 additions and 0 deletions.
3 changes: 3 additions & 0 deletions edx_exams/apps/core/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from django.utils import dateparse, timezone
from opaque_keys.edx.keys import CourseKey, UsageKey

from edx_exams.apps.core.email import send_attempt_status_email
from edx_exams.apps.core.exam_types import OnboardingExamType, PracticeExamType, get_exam_type
from edx_exams.apps.core.exceptions import (
ExamAttemptAlreadyExists,
Expand Down Expand Up @@ -140,6 +141,8 @@ def update_attempt_status(attempt_id, to_status):
attempt_obj.status = to_status
attempt_obj.save()

send_attempt_status_email(attempt_obj)

return attempt_id


Expand Down
61 changes: 61 additions & 0 deletions edx_exams/apps/core/email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""
Handles sending emails to users about proctoring status changes.
"""
import logging

from django.conf import settings
from django.core.mail.message import EmailMessage
from django.template import loader

from edx_exams.apps.core.exam_types import get_exam_type
from edx_exams.apps.core.statuses import ExamAttemptStatus

log = logging.getLogger(__name__)


def send_attempt_status_email(attempt):
"""
Send email for attempt status if necessary
"""
exam = attempt.exam
exam_type = get_exam_type(exam.exam_type)

# do not send emails on practice exams or non-proctored exams
if not exam_type.is_proctored or exam_type.is_practice:
return

if attempt.status == ExamAttemptStatus.submitted:
email_template = 'email/proctoring_attempt_submitted.html'
elif attempt.status == ExamAttemptStatus.verified:
email_template = 'email/proctoring_attempt_verified.html'
elif attempt.status == ExamAttemptStatus.rejected:
email_template = 'email/proctoring_attempt_rejected.html'
else:
return # do not send emails for other statuses

email_template = loader.get_template(email_template)
course_url = f'{settings.LEARNING_MICROFRONTEND_URL}/course/{exam.course_id}'
contact_url = exam.provider.tech_support_email

email_subject = f'Proctored exam {exam.exam_name} for user {attempt.user.username}'
body = email_template.render({
'exam_name': exam.exam_name,
'course_url': course_url,
'contact_url': contact_url,
})

email = EmailMessage(
subject=email_subject,
body=body,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[attempt.user.email],
)
email.content_subtype = 'html'

try:
email.send()
except Exception as err: # pylint: disable=broad-except
log.error(
'Error while sending proctoring status email for '
f'user_id {attempt.user.id}, exam_id {exam.id}: {err}'
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{% load i18n %}

<p>
{% block introduction %}
{% blocktrans %}
Hello {{ username }},
{% endblocktrans %}
{% endblock %}
</p>
<p>
{% block status_information %}
{% blocktrans %}
Your proctored exam "{{ exam_name }}" in
<a href="{{ course_url }}">{{ course_url }}</a> was reviewed and the
course team has identified one or more violations of the proctored exam rules. Examples
of issues that may result in a rules violation include browsing
the internet, using a phone,
or getting help from another person. As a result of the identified issue(s),
you did not successfully meet the proctored exam requirements.
{% endblocktrans %}
{% endblock %}
</p>
<p>
{% block contact_information %}
{% blocktrans %}
To appeal your proctored exam results, please reach out with any relevant information
about your exam at
<a href="{{contact_url}}">
{{ contact_url_text }}
</a>.
{% endblocktrans %}
{% endblock %}
</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{% load i18n %}

<p>
{% block introduction %}
{% blocktrans %}
Hello {{ username }},
{% endblocktrans %}
{% endblock %}
</p>
<p>
{% block status_information %}
{% blocktrans %}
Your proctored exam "{{ exam_name }}" in
<a href="{{ course_url }}">{{ course_url }}</a> was submitted
successfully and will now be reviewed to ensure all exam
rules were followed. You should receive an email with your exam
status within 5 business days.
{% endblocktrans %}
{% endblock %}
</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{% load i18n %}

<p>
{% block introduction %}
{% blocktrans %}
Hello {{ username }},
{% endblocktrans %}
{% endblock %}
</p>
<p>
{% block status_information %}
{% blocktrans %}
Your proctored exam "{{ exam_name }}" in
<a href="{{ course_url }}">{{ course_url }}</a> was reviewed and you
met all proctoring requirements.
{% endblocktrans %}
{% endblock %}
</p>
<p>
{% block contact_information %}
{% blocktrans %}
If you have any questions about your results, you can reach out at
<a href="{{contact_url}}">
{{ contact_url }}
</a>.
{% endblocktrans %}
{% endblock %}
</p>
105 changes: 105 additions & 0 deletions edx_exams/apps/core/tests/test_email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""
Test email notifications for attempt status change
"""

import ddt
import mock
from django.core import mail
from django.test import TestCase

from edx_exams.apps.core.api import update_attempt_status
from edx_exams.apps.core.test_utils.factories import ExamAttemptFactory, ExamFactory, UserFactory


@ddt.ddt
class TestEmail(TestCase):
"""
Test email notifications for attempt status change
"""
def setUp(self):
super().setUp()

self.user = UserFactory.create()
self.proctored_exam = ExamFactory.create(
exam_type='proctored',
)
self.started_attempt = ExamAttemptFactory.create(
exam=self.proctored_exam,
user=self.user,
status='started',
)

@staticmethod
def _normalize_whitespace(string):
"""
Replaces newlines and multiple spaces with a single space.
"""
return ' '.join(string.replace('\n', '').split())

@ddt.data(
('submitted', 'was submitted successfully'),
('verified', 'was reviewed and you met all proctoring requirements'),
('rejected', 'the course team has identified one or more violations'),
)
@ddt.unpack
def test_send_email(self, status, expected_message):
"""
Test correct message is sent for statuses that trigger an email
"""
update_attempt_status(self.started_attempt.id, status)
self.assertEqual(len(mail.outbox), 1)
self.assertIn(self.started_attempt.user.email, mail.outbox[0].to)
self.assertIn(expected_message, self._normalize_whitespace(mail.outbox[0].body))

@mock.patch('edx_exams.apps.core.email.log.error')
def test_send_email_failure(self, mock_log_error):
"""
Test error is logged when an email fails to send
"""
with mock.patch('edx_exams.apps.core.email.EmailMessage.send', side_effect=Exception):
update_attempt_status(self.started_attempt.id, 'submitted')
mock_log_error.assert_called_once()
self.assertIn('Error while sending proctoring status email', mock_log_error.call_args[0][0])

@ddt.data(
'created',
'ready_to_start',
'download_software_clicked',
'started',
'ready_to_submit',
'error',
)
def test_status_should_not_send_email(self, status):
"""
Test no email is sent for statuses that should not trigger
"""
update_attempt_status(self.started_attempt.id, status)
self.assertEqual(len(mail.outbox), 0)

def test_non_proctored_exam_should_not_send_email(self):
"""
Test no email is sent for non-proctored exams
"""
timed_attempt = ExamAttemptFactory.create(
exam=ExamFactory.create(
exam_type='timed',
),
user=self.user,
status='started',
)
update_attempt_status(timed_attempt.id, 'submitted')
self.assertEqual(len(mail.outbox), 0)

def test_practice_exam_should_not_send_email(self):
"""
Test no email is sent for practice exams
"""
practice_proctored_attempt = ExamAttemptFactory.create(
exam=ExamFactory.create(
exam_type='onboarding',
),
user=self.user,
status='started',
)
update_attempt_status(practice_proctored_attempt.id, 'submitted')
self.assertEqual(len(mail.outbox), 0)
3 changes: 3 additions & 0 deletions edx_exams/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ def root(*path_fragments):
root('static'),
)

# EMAIL CONFIGURATION
DEFAULT_FROM_EMAIL: '[email protected]'

# TEMPLATE CONFIGURATION
# See: https://docs.djangoproject.com/en/2.2/ref/settings/#templates
TEMPLATES = [
Expand Down
3 changes: 3 additions & 0 deletions edx_exams/settings/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,6 @@

for override, value in DB_OVERRIDES.items():
DATABASES['default'][override] = value

# EMAIL CONFIGURATION
EMAIL_BACKEND = 'django_ses.SESBackend'

0 comments on commit 49c8976

Please sign in to comment.