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 _
from django.conf import settings
from django.template import Context, loader
from django.core.urlresolvers import reverse, NoReverseMatch
from django.core.mail.message import EmailMessage
from edx_proctoring import constants
from edx_proctoring.exceptions import (
ProctoredExamAlreadyExists,
ProctoredExamNotFoundException,
......@@ -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
if ProctoredExamStudentAttemptStatus.needs_credit_status_update(to_status):
# trigger credit workflow, as needed
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,
exam_attempt_obj.started_at = datetime.now(pytz.UTC)
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
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):
"""
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
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
from model_utils.models import TimeStampedModel
from django.utils.translation import ugettext as _
from django.contrib.auth.models import User
from edx_proctoring.exceptions import UserNotFoundException
......@@ -134,6 +135,13 @@ class ProctoredExamStudentAttemptStatus(object):
# the exam is believed to be in error
error = 'error'
# status alias for sending email
status_alias_mapping = {
submitted: _('pending'),
verified: _('satisfactory'),
rejected: _('unsatisfactory')
}
@classmethod
def is_completed_status(cls, status):
"""
......@@ -141,10 +149,8 @@ class ProctoredExamStudentAttemptStatus(object):
that it cannot go backwards in state
"""
return status in [
ProctoredExamStudentAttemptStatus.declined, ProctoredExamStudentAttemptStatus.timed_out,
ProctoredExamStudentAttemptStatus.submitted, ProctoredExamStudentAttemptStatus.verified,
ProctoredExamStudentAttemptStatus.rejected, ProctoredExamStudentAttemptStatus.not_reviewed,
ProctoredExamStudentAttemptStatus.error
cls.declined, cls.timed_out, cls.submitted, cls.verified, cls.rejected,
cls.not_reviewed, cls.error
]
@classmethod
......@@ -153,9 +159,7 @@ class ProctoredExamStudentAttemptStatus(object):
Returns a boolean if the passed in status is in an "incomplete" state.
"""
return status in [
ProctoredExamStudentAttemptStatus.eligible, ProctoredExamStudentAttemptStatus.created,
ProctoredExamStudentAttemptStatus.ready_to_start, ProctoredExamStudentAttemptStatus.started,
ProctoredExamStudentAttemptStatus.ready_to_submit
cls.eligible, cls.created, cls.ready_to_start, cls.started, cls.ready_to_submit
]
@classmethod
......@@ -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.
"""
return to_status in [
ProctoredExamStudentAttemptStatus.verified, ProctoredExamStudentAttemptStatus.rejected,
ProctoredExamStudentAttemptStatus.declined, ProctoredExamStudentAttemptStatus.not_reviewed,
ProctoredExamStudentAttemptStatus.submitted, ProctoredExamStudentAttemptStatus.error
cls.verified, cls.rejected, cls.declined, cls.not_reviewed, cls.submitted,
cls.error
]
@classmethod
......@@ -176,10 +179,27 @@ class ProctoredExamStudentAttemptStatus(object):
to other attempts.
"""
return to_status in [
ProctoredExamStudentAttemptStatus.rejected,
ProctoredExamStudentAttemptStatus.declined
cls.rejected, cls.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):
"""
......
{% 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
"""
......@@ -5,6 +6,7 @@ All tests for the models.py
"""
import ddt
from datetime import datetime, timedelta
from django.core import mail
from mock import patch
import pytz
from freezegun import freeze_time
......@@ -107,6 +109,8 @@ class ProctoredExamApiTests(LoggedInTestCase):
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.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('instructor', MockInstructorService())
......@@ -182,6 +186,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
started_at=started_at if started_at else datetime.now(pytz.UTC),
status=ProctoredExamStudentAttemptStatus.started,
allowed_time_limit_mins=10,
taking_as_proctored=is_proctored,
is_sample_attempt=is_sample_attempt
)
......@@ -1472,7 +1477,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
"""
Assert that we get the expected status summaries
"""
set_runtime_service('credit', MockCreditService(course_name=''))
expected = {
'status': ProctoredExamStudentAttemptStatus.eligible,
'short_description': 'Ungraded Practice Exam',
......@@ -1558,3 +1563,114 @@ class ProctoredExamApiTests(LoggedInTestCase):
self.assertEquals(attempt['last_poll_timestamp'], now)
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):
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
"""
self.status = {
'course_name': course_name,
'enrollment_mode': enrollment_mode,
'profile_fullname': profile_fullname,
'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
"""
......
......@@ -91,3 +91,6 @@ PROCTORING_SETTINGS = {
},
'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