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

feat: send proctoring update emails #201

Merged
merged 5 commits into from
Oct 24, 2023
Merged
Show file tree
Hide file tree
Changes from 2 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
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.
Copy link
Member

Choose a reason for hiding this comment

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

#NIT We are going to put all email sending functions into this file, correct? Then I would not be so specific on this comment. This comment is more for the function below, right?

"""
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 }}
Copy link
Member

Choose a reason for hiding this comment

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

Is this email the correct place to escalate to? It's set up per provider, and I feel like it would make more sense for the course team to review this.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this is a really good point. In my head this was the escalation email but that's not true, this email is just tech support for the tool. We don't save an escalation email for LTI but it sounds like we need one. I'm going to open a ticket to discuss adding this end to end.

For now I'm going to update this so this falls back on edx support in absence of a course team email.

</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
Copy link
Contributor Author

Choose a reason for hiding this comment

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

These used to have the course name here. For right now we don't have that in the data model.

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'
1 change: 1 addition & 0 deletions requirements/production.in
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Packages required in a production environment
-r base.txt

django-ses
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'll compile the new reqs before merging.

gevent
gunicorn
mysqlclient
Expand Down
Loading