Commit 5cb630bd by chrisndodge

Merge pull request #206 from edx/cdodge/analytic-events

Emit Analytics Events
parents fc072f90 9ec6b99d
......@@ -25,6 +25,7 @@ from edx_proctoring.exceptions import (
StudentExamAttemptedAlreadyStarted,
ProctoredExamIllegalStatusTransition,
ProctoredExamPermissionDenied,
ProctoredExamNotActiveException,
)
from edx_proctoring.models import (
ProctoredExam,
......@@ -40,7 +41,8 @@ from edx_proctoring.serializers import (
)
from edx_proctoring.utils import (
humanized_time,
has_client_app_shutdown
has_client_app_shutdown,
emit_event
)
from edx_proctoring.backends import get_backend_provider
......@@ -87,6 +89,10 @@ def create_exam(course_id, content_id, exam_name, time_limit_mins, due_date=None
)
log.info(log_msg)
# read back exam so we can emit an event on it
exam = get_exam_by_id(proctored_exam.id)
emit_event(exam, 'created')
return proctored_exam.id
......@@ -130,6 +136,11 @@ def update_exam(exam_id, exam_name=None, time_limit_mins=None, due_date=constant
if is_active is not None:
proctored_exam.is_active = is_active
proctored_exam.save()
# read back exam so we can emit an event on it
exam = get_exam_by_id(proctored_exam.id)
emit_event(exam, 'updated')
return proctored_exam.id
......@@ -194,7 +205,22 @@ def add_allowance_for_user(exam_id, user_info, key, value):
)
log.info(log_msg)
ProctoredExamStudentAllowance.add_allowance_for_user(exam_id, user_info, key, value)
try:
student_allowance, action = ProctoredExamStudentAllowance.add_allowance_for_user(exam_id, user_info, key, value)
except ProctoredExamNotActiveException:
raise ProctoredExamNotActiveException # let this exception raised so that we get 400 in case of inactive exam
if student_allowance is not None:
# emit an event for 'allowance.created|updated'
data = {
'allowance_user': student_allowance.user,
'allowance_proctored_exam': student_allowance.proctored_exam,
'allowance_key': student_allowance.key,
'allowance_value': student_allowance.value
}
exam = get_exam_by_id(exam_id)
emit_event(exam, 'allowance.{action}'.format(action=action), override_data=data)
def get_allowances_for_course(course_id, timed_exams_only=False):
......@@ -222,6 +248,17 @@ def remove_allowance_for_user(exam_id, user_id, key):
if student_allowance is not None:
student_allowance.delete()
# emit an event for 'allowance.deleted'
data = {
'allowance_user': student_allowance.user,
'allowance_proctored_exam': student_allowance.proctored_exam,
'allowance_key': student_allowance.key,
'allowance_value': student_allowance.value
}
exam = get_exam_by_id(exam_id)
emit_event(exam, 'allowance.deleted', override_data=data)
def _check_for_attempt_timeout(attempt):
"""
......@@ -601,6 +638,8 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True,
else:
return
exam = get_exam_by_id(exam_id)
#
# don't allow state transitions from a completed state to an incomplete state
# if a re-attempt is desired then the current attempt must be deleted
......@@ -646,7 +685,6 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True,
# trigger credit workflow, as needed
credit_service = get_runtime_service('credit')
exam = get_exam_by_id(exam_id)
if to_status == ProctoredExamStudentAttemptStatus.verified:
credit_requirement_status = 'satisfied'
elif to_status == ProctoredExamStudentAttemptStatus.submitted:
......@@ -689,35 +727,35 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True,
# one exam all other (un-completed) proctored exams will be likewise
# updated to reflect a declined status
# get all other unattempted exams and mark also as declined
_exams = ProctoredExam.get_all_exams_for_course(
all_other_exams = ProctoredExam.get_all_exams_for_course(
exam_attempt_obj.proctored_exam.course_id,
active_only=True
)
# we just want other exams which are proctored and are not practice
exams = [
_exam
for _exam in _exams
other_exams = [
other_exam
for other_exam in all_other_exams
if (
_exam.content_id != exam_attempt_obj.proctored_exam.content_id and
_exam.is_proctored and not _exam.is_practice_exam
other_exam.content_id != exam_attempt_obj.proctored_exam.content_id and
other_exam.is_proctored and not other_exam.is_practice_exam
)
]
for exam in exams:
for other_exam in other_exams:
# see if there was an attempt on those other exams already
attempt = get_exam_attempt(exam.id, user_id)
attempt = get_exam_attempt(other_exam.id, user_id)
if attempt and ProctoredExamStudentAttemptStatus.is_completed_status(attempt['status']):
# don't touch any completed statuses
# we won't revoke those
continue
if not attempt:
create_exam_attempt(exam.id, user_id, taking_as_proctored=False)
create_exam_attempt(other_exam.id, user_id, taking_as_proctored=False)
# update any new or existing status to declined
update_attempt_status(
exam.id,
other_exam.id,
user_id,
ProctoredExamStudentAttemptStatus.declined,
cascade_effects=False
......@@ -762,7 +800,15 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True,
credit_state.get('course_name', _('your course'))
)
return exam_attempt_obj.id
# emit an anlytics event based on the state transition
# we re-read this from the database in case fields got updated
# via workflow
attempt = get_exam_attempt(exam_id, user_id)
# we user the 'status' field as the name of the event 'verb'
emit_event(exam, attempt['status'], attempt=attempt)
return attempt['id']
def send_proctoring_attempt_status_email(exam_attempt_obj, course_name):
......@@ -857,6 +903,12 @@ def remove_exam_attempt(attempt_id):
req_name=content_id
)
# emit an event for 'deleted'
exam = get_exam_by_content_id(course_id, content_id)
serialized_attempt_obj = ProctoredExamStudentAttemptSerializer(existing_attempt)
attempt = serialized_attempt_obj.data
emit_event(exam, 'deleted', attempt=attempt)
def get_all_exams_for_course(course_id, timed_exams_only=False, active_only=False):
"""
......@@ -1524,12 +1576,16 @@ def _get_proctored_exam_view(exam, context, exam_id, user_id, course_id):
student_view_template = 'proctored_exam/pending-prerequisites.html'
else:
student_view_template = 'proctored_exam/entrance.html'
# emit an event that the user was presented with the option
# to start timed exam
emit_event(exam, 'option-presented')
elif attempt_status == ProctoredExamStudentAttemptStatus.started:
# when we're taking the exam we should not override the view
return None
elif attempt_status == ProctoredExamStudentAttemptStatus.expired:
student_view_template = 'proctored_exam/expired.html'
elif attempt_status == ProctoredExamStudentAttemptStatus.created:
elif attempt_status in [ProctoredExamStudentAttemptStatus.created,
ProctoredExamStudentAttemptStatus.download_software_clicked]:
provider = get_backend_provider()
student_view_template = 'proctored_exam/instructions.html'
context.update({
......
......@@ -23,13 +23,16 @@ from edx_proctoring.exceptions import (
ProctoredExamReviewAlreadyExists,
ProctoredExamBadReviewStatus,
)
from edx_proctoring.utils import locate_attempt_by_attempt_code
from edx_proctoring.utils import locate_attempt_by_attempt_code, emit_event
from edx_proctoring. models import (
ProctoredExamSoftwareSecureReview,
ProctoredExamSoftwareSecureComment,
ProctoredExamStudentAttemptStatus,
)
from edx_proctoring.serializers import (
ProctoredExamSerializer,
ProctoredExamStudentAttemptSerializer,
)
log = logging.getLogger(__name__)
......@@ -241,6 +244,20 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider):
self.on_review_saved(review, allow_status_update_on_fail=allow_status_update_on_fail)
# emit an event for 'review-received'
data = {
'review_attempt_code': review.attempt_code,
'review_raw_data': review.raw_data,
'review_status': review.review_status,
'review_video_url': review.video_url
}
serialized_attempt_obj = ProctoredExamStudentAttemptSerializer(attempt_obj)
attempt = serialized_attempt_obj.data
serialized_exam_object = ProctoredExamSerializer(attempt_obj.proctored_exam)
exam = serialized_exam_object.data
emit_event(exam, 'review-received', attempt=attempt, override_data=data)
def on_review_saved(self, review, allow_status_update_on_fail=False): # pylint: disable=arguments-differ
"""
called when a review has been save - either through API (on_review_callback) or via Django Admin panel
......
# pylint: disable=too-many-lines
"""
Data models for the proctoring subsystem
"""
......@@ -120,6 +121,10 @@ class ProctoredExamStudentAttemptStatus(object):
# been started
created = 'created'
# the student has clicked on the external
# software download link
download_software_clicked = 'download_software_clicked'
# the attempt is ready to start but requires
# user to acknowledge that he/she wants to start the exam
ready_to_start = 'ready_to_start'
......@@ -184,7 +189,8 @@ class ProctoredExamStudentAttemptStatus(object):
Returns a boolean if the passed in status is in an "incomplete" state.
"""
return status in [
cls.eligible, cls.created, cls.ready_to_start, cls.started, cls.ready_to_submit
cls.eligible, cls.created, cls.download_software_clicked, cls.ready_to_start, cls.started,
cls.ready_to_submit
]
@classmethod
......@@ -742,8 +748,11 @@ class ProctoredExamStudentAllowance(TimeStampedModel):
student_allowance = cls.objects.get(proctored_exam_id=exam_id, user_id=user_id, key=key)
student_allowance.value = value
student_allowance.save()
action = "updated"
except cls.DoesNotExist: # pylint: disable=no-member
cls.objects.create(proctored_exam_id=exam_id, user_id=user_id, key=key, value=value)
student_allowance = cls.objects.create(proctored_exam_id=exam_id, user_id=user_id, key=key, value=value)
action = "created"
return student_allowance, action
@classmethod
def is_allowance_value_valid(cls, allowance_type, allowance_value):
......
......@@ -76,7 +76,7 @@ class ProctoredExamStudentAttemptSerializer(serializers.ModelSerializer):
"id", "created", "modified", "user", "started_at", "completed_at",
"external_id", "status", "proctored_exam", "allowed_time_limit_mins",
"attempt_code", "is_sample_attempt", "taking_as_proctored", "last_poll_timestamp",
"last_poll_ipaddr", "review_policy_id"
"last_poll_ipaddr", "review_policy_id", "student_name"
)
......
......@@ -8,6 +8,7 @@ var edx = edx || {};
var examStatusReadableFormat = {
eligible: gettext('Eligible'),
created: gettext('Created'),
download_software_clicked: gettext('Download Software Clicked'),
ready_to_start: gettext('Ready to start'),
started: gettext('Started'),
ready_to_submit: gettext('Ready to submit'),
......
......@@ -26,7 +26,7 @@
{% endblocktrans %}
</p>
<p>
<span><a href="{{software_download_url}}" target="_blank">Start System Check</a></span>
<span><a href="#" id="software_download_link" data-action="click_download_software" target="_blank">Start System Check</a></span>
</p>
<p>
{% blocktrans %}
......@@ -108,4 +108,27 @@
});
}
$("#software_download_link").click(function (e) {
e.preventDefault();
var url = $('.instructions').data('exam-started-poll-url');
var action = $(this).data('action');
// open the new tab in the click event with an empty URL but show the message.
var newWindow = window.open("", "_blank");
$(newWindow.document.body).html("<p>Please wait while you are being redirected...</p>");
var self = this;
$.ajax({
url: url,
type: 'PUT',
data: {
action: action
},
success: function (data) {
newWindow.location = "{{software_download_url}}";
}
}).fail(function(){
newWindow.close();
});
});
</script>
......@@ -66,6 +66,7 @@ from .utils import (
from edx_proctoring.tests.test_services import (
MockCreditService,
MockInstructorService,
MockAnalyticsService,
)
from edx_proctoring.runtime import set_runtime_service, get_runtime_service
......@@ -124,6 +125,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
set_runtime_service('credit', MockCreditService())
set_runtime_service('instructor', MockInstructorService(is_user_course_staff=True))
set_runtime_service('analytics', MockAnalyticsService())
self.prerequisites = [
{
......@@ -657,7 +659,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
proctored_exam_student_attempt = self._create_unstarted_exam_attempt()
self.assertIsNone(proctored_exam_student_attempt.completed_at)
proctored_exam_attempt_id = stop_exam_attempt(
proctored_exam_student_attempt.proctored_exam, self.user_id
proctored_exam_student_attempt.proctored_exam.id, self.user_id
)
self.assertEqual(proctored_exam_student_attempt.id, proctored_exam_attempt_id)
......@@ -746,7 +748,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
proctored_exam_student_attempt = self._create_unstarted_exam_attempt()
self.assertIsNone(proctored_exam_student_attempt.completed_at)
proctored_exam_attempt_id = mark_exam_attempt_as_ready(
proctored_exam_student_attempt.proctored_exam, self.user_id
proctored_exam_student_attempt.proctored_exam.id, self.user_id
)
self.assertEqual(proctored_exam_student_attempt.id, proctored_exam_attempt_id)
......@@ -1038,6 +1040,27 @@ class ProctoredExamApiTests(LoggedInTestCase):
self.assertIn(self.chose_proctored_exam_msg, rendered_response)
self.assertIn(self.proctored_exam_optout_msg, rendered_response)
# now make sure content remains the same if
# the status transitions to 'download_software_clicked'
update_attempt_status(
self.proctored_exam_id,
self.user_id,
ProctoredExamStudentAttemptStatus.download_software_clicked
)
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.chose_proctored_exam_msg, rendered_response)
self.assertIn(self.proctored_exam_optout_msg, rendered_response)
def test_get_studentview_unstarted_practice_exam(self):
"""
Test for get_student_view Practice exam which has not started yet.
......@@ -1844,6 +1867,8 @@ class ProctoredExamApiTests(LoggedInTestCase):
(ProctoredExamStudentAttemptStatus.declined, ProctoredExamStudentAttemptStatus.eligible),
(ProctoredExamStudentAttemptStatus.timed_out, ProctoredExamStudentAttemptStatus.created),
(ProctoredExamStudentAttemptStatus.expired, ProctoredExamStudentAttemptStatus.created),
(ProctoredExamStudentAttemptStatus.timed_out, ProctoredExamStudentAttemptStatus.download_software_clicked),
(ProctoredExamStudentAttemptStatus.expired, ProctoredExamStudentAttemptStatus.download_software_clicked),
(ProctoredExamStudentAttemptStatus.submitted, ProctoredExamStudentAttemptStatus.ready_to_start),
(ProctoredExamStudentAttemptStatus.verified, ProctoredExamStudentAttemptStatus.started),
(ProctoredExamStudentAttemptStatus.rejected, ProctoredExamStudentAttemptStatus.started),
......@@ -1969,6 +1994,14 @@ class ProctoredExamApiTests(LoggedInTestCase):
}
),
(
ProctoredExamStudentAttemptStatus.download_software_clicked, {
'status': ProctoredExamStudentAttemptStatus.download_software_clicked,
'short_description': 'Taking As Proctored Exam',
'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False
}
),
(
ProctoredExamStudentAttemptStatus.ready_to_start, {
'status': ProctoredExamStudentAttemptStatus.ready_to_start,
'short_description': 'Taking As Proctored Exam',
......@@ -2295,6 +2328,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
@ddt.data(
ProctoredExamStudentAttemptStatus.eligible,
ProctoredExamStudentAttemptStatus.created,
ProctoredExamStudentAttemptStatus.download_software_clicked,
ProctoredExamStudentAttemptStatus.ready_to_start,
ProctoredExamStudentAttemptStatus.started,
ProctoredExamStudentAttemptStatus.ready_to_submit,
......@@ -2357,6 +2391,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
@ddt.data(
ProctoredExamStudentAttemptStatus.eligible,
ProctoredExamStudentAttemptStatus.created,
ProctoredExamStudentAttemptStatus.download_software_clicked,
ProctoredExamStudentAttemptStatus.submitted,
ProctoredExamStudentAttemptStatus.verified,
ProctoredExamStudentAttemptStatus.rejected,
......
......@@ -121,6 +121,21 @@ class ProctoredExamStudentAttemptTests(LoggedInTestCase):
Tests for the ProctoredExamStudentAttempt Model
"""
def test_exam_unicode(self):
"""
Serialize the object as a display string
"""
proctored_exam = ProctoredExam.objects.create(
course_id='test_course',
content_id='test_content',
exam_name='Test Exam',
external_id='123aXqe3',
time_limit_mins=90
)
string = unicode(proctored_exam)
self.assertEqual(string, "test_course: Test Exam (inactive)")
def test_delete_proctored_exam_attempt(self): # pylint: disable=invalid-name
"""
Deleting the proctored exam attempt creates an entry in the history table.
......@@ -132,6 +147,7 @@ class ProctoredExamStudentAttemptTests(LoggedInTestCase):
external_id='123aXqe3',
time_limit_mins=90
)
attempt = ProctoredExamStudentAttempt.objects.create(
proctored_exam_id=proctored_exam.id,
user_id=1,
......
......@@ -109,6 +109,17 @@ class MockInstructorService(object):
return self.is_user_course_staff
class MockAnalyticsService(object):
"""
A mock implementation of the 'analytics' service
"""
def emit_event(self, name, context, data):
"""
Do nothing
"""
pass
class TestProctoringService(unittest.TestCase):
"""
Tests for ProctoringService
......
......@@ -1030,6 +1030,47 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
response_data = json.loads(response.content)
self.assertEqual(response_data['exam_attempt_id'], old_attempt_id)
def test_download_software_clicked_action(self):
"""
Test if the download_software_clicked state is set
"""
# Create an exam.
proctored_exam = ProctoredExam.objects.create(
course_id='a/b/c',
content_id='test_content',
exam_name='Test Exam',
external_id='123aXqe3',
time_limit_mins=90
)
attempt_data = {
'exam_id': proctored_exam.id,
'user_id': self.student_taking_exam.id,
'external_id': proctored_exam.external_id
}
response = self.client.post(
reverse('edx_proctoring.proctored_exam.attempt.collection'),
attempt_data
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertGreater(response_data['exam_attempt_id'], 0)
old_attempt_id = response_data['exam_attempt_id']
response = self.client.put(
reverse('edx_proctoring.proctored_exam.attempt', args=[old_attempt_id]),
{
'action': 'click_download_software',
}
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertEqual(response_data['exam_attempt_id'], old_attempt_id)
attempt = get_exam_attempt_by_id(old_attempt_id)
self.assertEqual(attempt['status'], ProctoredExamStudentAttemptStatus.download_software_clicked)
@ddt.data(
('submit', ProctoredExamStudentAttemptStatus.submitted),
('decline', ProctoredExamStudentAttemptStatus.declined)
......@@ -1498,13 +1539,17 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
attempt_data
)
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
attempt = get_exam_attempt_by_id(data['exam_attempt_id'])
self.assertEqual(attempt['status'], ProctoredExamStudentAttemptStatus.submitted)
response = self.client.get(
reverse('edx_proctoring.proctored_exam.attempt.collection')
)
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertEqual(data['time_remaining_seconds'], 0)
self.assertNotIn('time_remaining_seconds', data)
def test_get_expired_exam_attempt(self):
"""
......
......@@ -16,6 +16,7 @@ from edx_proctoring.models import (
ProctoredExamStudentAttemptHistory,
)
from edx_proctoring import constants
from edx_proctoring.runtime import get_runtime_service
log = logging.getLogger(__name__)
......@@ -128,3 +129,69 @@ def has_client_app_shutdown(attempt):
elapsed_time = (datetime.now(pytz.UTC) - attempt['last_poll_timestamp']).total_seconds()
return elapsed_time > constants.SOFTWARE_SECURE_SHUT_DOWN_GRACEPERIOD
def emit_event(exam, event_short_name, attempt=None, override_data=None):
"""
Helper method to emit an analytics event
"""
exam_type = (
'timed' if not exam['is_proctored'] else
('practice' if exam['is_practice_exam'] else 'proctored')
)
# establish baseline schema for event 'context'
context = {
'course_id': exam['course_id']
}
# establish baseline schema for event 'data'
data = {
'exam_id': exam['id'],
'exam_content_id': exam['content_id'],
'exam_name': exam['exam_name'],
'exam_default_time_limit_mins': exam['time_limit_mins'],
'exam_is_proctored': exam['is_proctored'],
'exam_is_practice_exam': exam['is_practice_exam'],
'exam_is_active': exam['is_active']
}
if attempt:
# if an attempt is passed in then use that to add additional baseline
# schema elements
# let's compute the relative time we're firing the event
# compared to the start time, if the attempt has already started.
# This can be used to determine how far into an attempt a given
# event occured (e.g. "time to complete exam")
attempt_event_elapsed_time_secs = (
(datetime.now(pytz.UTC) - attempt['started_at']).seconds if attempt['started_at'] else
None
)
attempt_data = {
'attempt_id': attempt['id'],
'attempt_user_id': attempt['user']['id'],
'attempt_username': attempt['student_name'],
'attempt_started_at': attempt['started_at'],
'attempt_completed_at': attempt['completed_at'],
'attempt_code': attempt['attempt_code'],
'attempt_allowed_time_limit_mins': attempt['allowed_time_limit_mins'],
'attempt_status': attempt['status'],
'attempt_event_elapsed_time_secs': attempt_event_elapsed_time_secs
}
data.update(attempt_data)
name = '.'.join(['edx', 'special-exam', exam_type, 'attempt', event_short_name])
else:
name = '.'.join(['edx', 'special-exam', exam_type, event_short_name])
# allow caller to override event data
if override_data:
data.update(override_data)
service = get_runtime_service('analytics')
if service:
service.emit_event(name, context, data)
else:
log.warn('Analytics event service not configured. If this is a production environment, please resolve.')
......@@ -424,6 +424,12 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
request.user.id,
ProctoredExamStudentAttemptStatus.submitted
)
elif action == 'click_download_software':
exam_attempt_id = update_attempt_status(
attempt['proctored_exam']['id'],
request.user.id,
ProctoredExamStudentAttemptStatus.download_software_clicked
)
elif action == 'decline':
exam_attempt_id = update_attempt_status(
attempt['proctored_exam']['id'],
......@@ -569,7 +575,7 @@ class StudentProctoredExamAttemptCollection(AuthenticatedAPIView):
else:
response_dict = {
'in_timed_exam': False,
'is_proctored': False,
'is_proctored': False
}
return Response(
......
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