Commit 28e82014 by chrisndodge

Merge pull request #104 from edx/hasnain-naveed/status_sending_email

send email on proctoring attempt status change
parents 2a7c7e96 03c92774
...@@ -14,7 +14,9 @@ from django.utils.translation import ugettext as _ ...@@ -14,7 +14,9 @@ from django.utils.translation import ugettext as _
from django.conf import settings from django.conf import settings
from django.template import Context, loader from django.template import Context, loader
from django.core.urlresolvers import reverse, NoReverseMatch from django.core.urlresolvers import reverse, NoReverseMatch
from django.core.mail.message import EmailMessage
from edx_proctoring import constants
from edx_proctoring.exceptions import ( from edx_proctoring.exceptions import (
ProctoredExamAlreadyExists, ProctoredExamAlreadyExists,
ProctoredExamNotFoundException, ProctoredExamNotFoundException,
...@@ -556,6 +558,7 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True, ...@@ -556,6 +558,7 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True,
# see if the status transition this changes credit requirement status # see if the status transition this changes credit requirement status
if ProctoredExamStudentAttemptStatus.needs_credit_status_update(to_status): if ProctoredExamStudentAttemptStatus.needs_credit_status_update(to_status):
# trigger credit workflow, as needed # trigger credit workflow, as needed
credit_service = get_runtime_service('credit') credit_service = get_runtime_service('credit')
...@@ -643,9 +646,80 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True, ...@@ -643,9 +646,80 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True,
exam_attempt_obj.started_at = datetime.now(pytz.UTC) exam_attempt_obj.started_at = datetime.now(pytz.UTC)
exam_attempt_obj.save() exam_attempt_obj.save()
# email will be send when the exam is proctored and not practice exam
# and the status is verified, submitted or rejected
should_send_status_email = (
exam_attempt_obj.taking_as_proctored and
not exam_attempt_obj.is_sample_attempt and
ProctoredExamStudentAttemptStatus.needs_status_change_email(exam_attempt_obj.status)
)
if should_send_status_email:
# trigger credit workflow, as needed
credit_service = get_runtime_service('credit')
# call service to get course name.
credit_state = credit_service.get_credit_state(
exam_attempt_obj.user_id,
exam_attempt_obj.proctored_exam.course_id,
return_course_name=True
)
send_proctoring_attempt_status_email(
exam_attempt_obj,
credit_state.get('course_name', _('your course'))
)
return exam_attempt_obj.id return exam_attempt_obj.id
def send_proctoring_attempt_status_email(exam_attempt_obj, course_name):
"""
Sends an email about change in proctoring attempt status.
"""
course_info_url = ''
email_template = loader.get_template('emails/proctoring_attempt_status_email.html')
try:
course_info_url = reverse('courseware.views.course_info', args=[exam_attempt_obj.proctored_exam.course_id])
except NoReverseMatch:
# we are allowing a failure here since we can't guarantee
# that we are running in-proc with the edx-platform LMS
# (for example unit tests)
pass
scheme = 'https' if getattr(settings, 'HTTPS', 'on') == 'on' else 'http'
course_url = '{scheme}://{site_name}{course_info_url}'.format(
scheme=scheme,
site_name=constants.SITE_NAME,
course_info_url=course_info_url
)
body = email_template.render(
Context({
'course_url': course_url,
'course_name': course_name,
'exam_name': exam_attempt_obj.proctored_exam.exam_name,
'status': ProctoredExamStudentAttemptStatus.get_status_alias(exam_attempt_obj.status),
'platform': constants.PLATFORM_NAME,
'contact_email': constants.CONTACT_EMAIL
})
)
subject = (
_('Proctoring Session Results Update for {course_name} {exam_name}').format(
course_name=course_name,
exam_name=exam_attempt_obj.proctored_exam.exam_name
)
)
EmailMessage(
body=body,
from_email=constants.FROM_EMAIL,
to=[exam_attempt_obj.user.email],
subject=subject
).send()
def remove_exam_attempt(attempt_id): def remove_exam_attempt(attempt_id):
""" """
Removes an exam attempt given the attempt id. Removes an exam attempt given the attempt id.
......
"""
Lists of constants that can be used in the edX proctoring
"""
from django.conf import settings
SITE_NAME = (
settings.PROCTORING_SETTINGS['SITE_NAME'] if
'SITE_NAME' in settings.PROCTORING_SETTINGS else settings.SITE_NAME
)
PLATFORM_NAME = (
settings.PROCTORING_SETTINGS['PLATFORM_NAME'] if
'PLATFORM_NAME' in settings.PROCTORING_SETTINGS else settings.PLATFORM_NAME
)
FROM_EMAIL = (
settings.PROCTORING_SETTINGS['STATUS_EMAIL_FROM_ADDRESS'] if
'STATUS_EMAIL_FROM_ADDRESS' in settings.PROCTORING_SETTINGS else settings.DEFAULT_FROM_EMAIL
)
CONTACT_EMAIL = (
settings.PROCTORING_SETTINGS['CONTACT_EMAIL'] if
'CONTACT_EMAIL' in settings.PROCTORING_SETTINGS else settings.CONTACT_EMAIL
)
...@@ -6,6 +6,7 @@ from django.db.models import Q ...@@ -6,6 +6,7 @@ from django.db.models import Q
from django.db.models.signals import post_save, pre_delete from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from model_utils.models import TimeStampedModel from model_utils.models import TimeStampedModel
from django.utils.translation import ugettext as _
from django.contrib.auth.models import User from django.contrib.auth.models import User
from edx_proctoring.exceptions import UserNotFoundException from edx_proctoring.exceptions import UserNotFoundException
...@@ -134,6 +135,13 @@ class ProctoredExamStudentAttemptStatus(object): ...@@ -134,6 +135,13 @@ class ProctoredExamStudentAttemptStatus(object):
# the exam is believed to be in error # the exam is believed to be in error
error = 'error' error = 'error'
# status alias for sending email
status_alias_mapping = {
submitted: _('pending'),
verified: _('satisfactory'),
rejected: _('unsatisfactory')
}
@classmethod @classmethod
def is_completed_status(cls, status): def is_completed_status(cls, status):
""" """
...@@ -141,10 +149,8 @@ class ProctoredExamStudentAttemptStatus(object): ...@@ -141,10 +149,8 @@ class ProctoredExamStudentAttemptStatus(object):
that it cannot go backwards in state that it cannot go backwards in state
""" """
return status in [ return status in [
ProctoredExamStudentAttemptStatus.declined, ProctoredExamStudentAttemptStatus.timed_out, cls.declined, cls.timed_out, cls.submitted, cls.verified, cls.rejected,
ProctoredExamStudentAttemptStatus.submitted, ProctoredExamStudentAttemptStatus.verified, cls.not_reviewed, cls.error
ProctoredExamStudentAttemptStatus.rejected, ProctoredExamStudentAttemptStatus.not_reviewed,
ProctoredExamStudentAttemptStatus.error
] ]
@classmethod @classmethod
...@@ -153,9 +159,7 @@ class ProctoredExamStudentAttemptStatus(object): ...@@ -153,9 +159,7 @@ class ProctoredExamStudentAttemptStatus(object):
Returns a boolean if the passed in status is in an "incomplete" state. Returns a boolean if the passed in status is in an "incomplete" state.
""" """
return status in [ return status in [
ProctoredExamStudentAttemptStatus.eligible, ProctoredExamStudentAttemptStatus.created, cls.eligible, cls.created, cls.ready_to_start, cls.started, cls.ready_to_submit
ProctoredExamStudentAttemptStatus.ready_to_start, ProctoredExamStudentAttemptStatus.started,
ProctoredExamStudentAttemptStatus.ready_to_submit
] ]
@classmethod @classmethod
...@@ -164,9 +168,8 @@ class ProctoredExamStudentAttemptStatus(object): ...@@ -164,9 +168,8 @@ class ProctoredExamStudentAttemptStatus(object):
Returns a boolean if the passed in to_status calls for an update to the credit requirement status. Returns a boolean if the passed in to_status calls for an update to the credit requirement status.
""" """
return to_status in [ return to_status in [
ProctoredExamStudentAttemptStatus.verified, ProctoredExamStudentAttemptStatus.rejected, cls.verified, cls.rejected, cls.declined, cls.not_reviewed, cls.submitted,
ProctoredExamStudentAttemptStatus.declined, ProctoredExamStudentAttemptStatus.not_reviewed, cls.error
ProctoredExamStudentAttemptStatus.submitted, ProctoredExamStudentAttemptStatus.error
] ]
@classmethod @classmethod
...@@ -176,10 +179,27 @@ class ProctoredExamStudentAttemptStatus(object): ...@@ -176,10 +179,27 @@ class ProctoredExamStudentAttemptStatus(object):
to other attempts. to other attempts.
""" """
return to_status in [ return to_status in [
ProctoredExamStudentAttemptStatus.rejected, cls.rejected, cls.declined
ProctoredExamStudentAttemptStatus.declined
] ]
@classmethod
def needs_status_change_email(cls, to_status):
"""
We need to send out emails for rejected, verified and submitted statuses.
"""
return to_status in [
cls.rejected, cls.submitted, cls.verified
]
@classmethod
def get_status_alias(cls, status):
"""
Returns status alias used in email
"""
return cls.status_alias_mapping.get(status, '')
class ProctoredExamStudentAttemptManager(models.Manager): class ProctoredExamStudentAttemptManager(models.Manager):
""" """
......
{% load i18n %}
{% blocktrans %}
This email is to let you know that the status of your proctoring session review for {{ exam_name }} in
<a href="{{ course_url }}">{{ course_name }} </a> is {{ status }}. If you have any questions about proctoring,
contact {{ platform }} support at {{ contact_email }}.
{% endblocktrans %}
\ No newline at end of file
# coding=utf-8
# pylint: disable=too-many-lines, invalid-name # pylint: disable=too-many-lines, invalid-name
""" """
...@@ -5,6 +6,7 @@ All tests for the models.py ...@@ -5,6 +6,7 @@ All tests for the models.py
""" """
import ddt import ddt
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.core import mail
from mock import patch from mock import patch
import pytz import pytz
from freezegun import freeze_time from freezegun import freeze_time
...@@ -107,6 +109,8 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -107,6 +109,8 @@ class ProctoredExamApiTests(LoggedInTestCase):
self.practice_exam_submitted_msg = 'You have submitted this practice proctored exam' self.practice_exam_submitted_msg = 'You have submitted this practice proctored exam'
self.ready_to_start_msg = 'Your Proctoring Installation and Set Up is Complete' self.ready_to_start_msg = 'Your Proctoring Installation and Set Up is Complete'
self.practice_exam_failed_msg = 'There was a problem with your practice proctoring session' self.practice_exam_failed_msg = 'There was a problem with your practice proctoring session'
self.proctored_exam_email_subject = 'Proctoring Session Results Update'
self.proctored_exam_email_body = 'the status of your proctoring session review'
set_runtime_service('credit', MockCreditService()) set_runtime_service('credit', MockCreditService())
set_runtime_service('instructor', MockInstructorService()) set_runtime_service('instructor', MockInstructorService())
...@@ -182,6 +186,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -182,6 +186,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
started_at=started_at if started_at else datetime.now(pytz.UTC), started_at=started_at if started_at else datetime.now(pytz.UTC),
status=ProctoredExamStudentAttemptStatus.started, status=ProctoredExamStudentAttemptStatus.started,
allowed_time_limit_mins=10, allowed_time_limit_mins=10,
taking_as_proctored=is_proctored,
is_sample_attempt=is_sample_attempt is_sample_attempt=is_sample_attempt
) )
...@@ -1472,7 +1477,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1472,7 +1477,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
""" """
Assert that we get the expected status summaries Assert that we get the expected status summaries
""" """
set_runtime_service('credit', MockCreditService(course_name=''))
expected = { expected = {
'status': ProctoredExamStudentAttemptStatus.eligible, 'status': ProctoredExamStudentAttemptStatus.eligible,
'short_description': 'Ungraded Practice Exam', 'short_description': 'Ungraded Practice Exam',
...@@ -1558,3 +1563,114 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1558,3 +1563,114 @@ class ProctoredExamApiTests(LoggedInTestCase):
self.assertEquals(attempt['last_poll_timestamp'], now) self.assertEquals(attempt['last_poll_timestamp'], now)
self.assertEquals(attempt['last_poll_ipaddr'], '1.1.1.1') self.assertEquals(attempt['last_poll_ipaddr'], '1.1.1.1')
@ddt.data(
ProctoredExamStudentAttemptStatus.submitted,
ProctoredExamStudentAttemptStatus.verified,
ProctoredExamStudentAttemptStatus.rejected
)
def test_send_email(self, status):
"""
Assert that email is sent on the following statuses of proctoring attempt.
"""
exam_attempt = self._create_started_exam_attempt()
credit_state = get_runtime_service('credit').get_credit_state(self.user_id, self.course_id)
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
status
)
self.assertEquals(len(mail.outbox), 1)
self.assertIn(self.proctored_exam_email_subject, mail.outbox[0].subject)
self.assertIn(self.proctored_exam_email_body, mail.outbox[0].body)
self.assertIn(ProctoredExamStudentAttemptStatus.get_status_alias(status), mail.outbox[0].body)
self.assertIn(credit_state['course_name'], mail.outbox[0].body)
def test_send_email_unicode(self):
"""
Assert that email can be sent with a unicode course name.
"""
course_name = u'अआईउऊऋऌ अआईउऊऋऌ'
set_runtime_service('credit', MockCreditService(course_name=course_name))
exam_attempt = self._create_started_exam_attempt()
credit_state = get_runtime_service('credit').get_credit_state(self.user_id, self.course_id)
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
ProctoredExamStudentAttemptStatus.submitted
)
self.assertEquals(len(mail.outbox), 1)
self.assertIn(self.proctored_exam_email_subject, mail.outbox[0].subject)
self.assertIn(course_name, mail.outbox[0].subject)
self.assertIn(self.proctored_exam_email_body, mail.outbox[0].body)
self.assertIn(
ProctoredExamStudentAttemptStatus.get_status_alias(
ProctoredExamStudentAttemptStatus.submitted
),
mail.outbox[0].body
)
self.assertIn(credit_state['course_name'], mail.outbox[0].body)
@ddt.data(
ProctoredExamStudentAttemptStatus.eligible,
ProctoredExamStudentAttemptStatus.created,
ProctoredExamStudentAttemptStatus.ready_to_start,
ProctoredExamStudentAttemptStatus.started,
ProctoredExamStudentAttemptStatus.ready_to_submit,
ProctoredExamStudentAttemptStatus.declined,
ProctoredExamStudentAttemptStatus.timed_out,
ProctoredExamStudentAttemptStatus.not_reviewed,
ProctoredExamStudentAttemptStatus.error
)
@patch.dict('settings.PROCTORING_SETTINGS', {'ALLOW_TIMED_OUT_STATE': True})
def test_not_send_email(self, status):
"""
Assert that email is not sent on the following statuses of proctoring attempt.
"""
exam_attempt = self._create_started_exam_attempt()
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
status
)
self.assertEquals(len(mail.outbox), 0)
@ddt.data(
ProctoredExamStudentAttemptStatus.submitted,
ProctoredExamStudentAttemptStatus.verified,
ProctoredExamStudentAttemptStatus.rejected
)
def test_not_send_email_sample_exam(self, status):
"""
Assert that email is not sent when there is practice/sample exam
"""
exam_attempt = self._create_started_exam_attempt(is_sample_attempt=True)
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
status
)
self.assertEquals(len(mail.outbox), 0)
@ddt.data(
ProctoredExamStudentAttemptStatus.submitted,
ProctoredExamStudentAttemptStatus.verified,
ProctoredExamStudentAttemptStatus.rejected
)
def test_not_send_email_timed_exam(self, status):
"""
Assert that email is not sent when exam is timed/not-proctoring
"""
exam_attempt = self._create_started_exam_attempt(is_proctored=False)
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
status
)
self.assertEquals(len(mail.outbox), 0)
...@@ -17,17 +17,19 @@ class MockCreditService(object): ...@@ -17,17 +17,19 @@ class MockCreditService(object):
Simple mock of the Credit Service Simple mock of the Credit Service
""" """
def __init__(self, enrollment_mode='verified', profile_fullname='Wolfgang von Strucker'): def __init__(self, enrollment_mode='verified', profile_fullname='Wolfgang von Strucker',
course_name='edx demo'):
""" """
Initializer Initializer
""" """
self.status = { self.status = {
'course_name': course_name,
'enrollment_mode': enrollment_mode, 'enrollment_mode': enrollment_mode,
'profile_fullname': profile_fullname, 'profile_fullname': profile_fullname,
'credit_requirement_status': [] 'credit_requirement_status': []
} }
def get_credit_state(self, user_id, course_key): # pylint: disable=unused-argument def get_credit_state(self, user_id, course_key, return_course_name=False): # pylint: disable=unused-argument
""" """
Mock implementation Mock implementation
""" """
......
...@@ -91,3 +91,6 @@ PROCTORING_SETTINGS = { ...@@ -91,3 +91,6 @@ PROCTORING_SETTINGS = {
}, },
'ALLOW_CALLBACK_SIMULATION': False 'ALLOW_CALLBACK_SIMULATION': False
} }
DEFAULT_FROM_EMAIL = 'no-reply@example.com'
CONTACT_EMAIL = 'info@edx.org'
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment