Skip to content

Commit

Permalink
feat: send proctoring update emails (#201)
Browse files Browse the repository at this point in the history
Send an email when a proctored exam is submitted/verified/rejected
  • Loading branch information
zacharis278 authored Oct 24, 2023
1 parent 9b87fb7 commit 793794d
Show file tree
Hide file tree
Showing 19 changed files with 326 additions and 135 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 rendering templates and sending emails.
"""
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 = f'{settings.LMS_ROOT_URL}/support/contact_us'

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'
10 changes: 5 additions & 5 deletions requirements/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ attrs==23.1.0
# openedx-events
bleach==6.1.0
# via lti-consumer-xblock
boto3==1.28.64
boto3==1.28.68
# via fs-s3fs
botocore==1.31.64
botocore==1.31.68
# via
# boto3
# s3transfer
Expand All @@ -28,7 +28,7 @@ cffi==1.16.0
# via
# cryptography
# pynacl
charset-normalizer==3.3.0
charset-normalizer==3.3.1
# via requests
click==8.1.7
# via
Expand Down Expand Up @@ -209,7 +209,7 @@ markupsafe==2.1.3
# xblock
mysqlclient==2.2.0
# via -r requirements/base.in
newrelic==9.1.0
newrelic==9.1.1
# via edx-django-utils
oauthlib==3.2.2
# via
Expand Down Expand Up @@ -337,7 +337,7 @@ uritemplate==4.1.1
# via
# coreapi
# drf-yasg
urllib3==1.26.17
urllib3==1.26.18
# via
# botocore
# requests
Expand Down
2 changes: 1 addition & 1 deletion requirements/ci.txt
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,5 @@ tox==3.28.0
# via
# -c requirements/common_constraints.txt
# -r requirements/ci.in
virtualenv==20.24.5
virtualenv==20.24.6
# via tox
1 change: 1 addition & 0 deletions requirements/common_constraints.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@


# A central location for most common version constraints
# (across edx repos) for pip-installation.
#
Expand Down
Loading

0 comments on commit 793794d

Please sign in to comment.