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 ( ...@@ -25,6 +25,7 @@ from edx_proctoring.exceptions import (
StudentExamAttemptedAlreadyStarted, StudentExamAttemptedAlreadyStarted,
ProctoredExamIllegalStatusTransition, ProctoredExamIllegalStatusTransition,
ProctoredExamPermissionDenied, ProctoredExamPermissionDenied,
ProctoredExamNotActiveException,
) )
from edx_proctoring.models import ( from edx_proctoring.models import (
ProctoredExam, ProctoredExam,
...@@ -40,7 +41,8 @@ from edx_proctoring.serializers import ( ...@@ -40,7 +41,8 @@ from edx_proctoring.serializers import (
) )
from edx_proctoring.utils import ( from edx_proctoring.utils import (
humanized_time, humanized_time,
has_client_app_shutdown has_client_app_shutdown,
emit_event
) )
from edx_proctoring.backends import get_backend_provider 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 ...@@ -87,6 +89,10 @@ def create_exam(course_id, content_id, exam_name, time_limit_mins, due_date=None
) )
log.info(log_msg) 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 return proctored_exam.id
...@@ -130,6 +136,11 @@ def update_exam(exam_id, exam_name=None, time_limit_mins=None, due_date=constant ...@@ -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: if is_active is not None:
proctored_exam.is_active = is_active proctored_exam.is_active = is_active
proctored_exam.save() 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 return proctored_exam.id
...@@ -194,7 +205,22 @@ def add_allowance_for_user(exam_id, user_info, key, value): ...@@ -194,7 +205,22 @@ def add_allowance_for_user(exam_id, user_info, key, value):
) )
log.info(log_msg) 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): 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): ...@@ -222,6 +248,17 @@ def remove_allowance_for_user(exam_id, user_id, key):
if student_allowance is not None: if student_allowance is not None:
student_allowance.delete() 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): 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, ...@@ -601,6 +638,8 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True,
else: else:
return return
exam = get_exam_by_id(exam_id)
# #
# don't allow state transitions from a completed state to an incomplete state # 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 # 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, ...@@ -646,7 +685,6 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True,
# trigger credit workflow, as needed # trigger credit workflow, as needed
credit_service = get_runtime_service('credit') credit_service = get_runtime_service('credit')
exam = get_exam_by_id(exam_id)
if to_status == ProctoredExamStudentAttemptStatus.verified: if to_status == ProctoredExamStudentAttemptStatus.verified:
credit_requirement_status = 'satisfied' credit_requirement_status = 'satisfied'
elif to_status == ProctoredExamStudentAttemptStatus.submitted: elif to_status == ProctoredExamStudentAttemptStatus.submitted:
...@@ -689,35 +727,35 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True, ...@@ -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 # one exam all other (un-completed) proctored exams will be likewise
# updated to reflect a declined status # updated to reflect a declined status
# get all other unattempted exams and mark also as declined # 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, exam_attempt_obj.proctored_exam.course_id,
active_only=True active_only=True
) )
# we just want other exams which are proctored and are not practice # we just want other exams which are proctored and are not practice
exams = [ other_exams = [
_exam other_exam
for _exam in _exams for other_exam in all_other_exams
if ( if (
_exam.content_id != exam_attempt_obj.proctored_exam.content_id and other_exam.content_id != exam_attempt_obj.proctored_exam.content_id and
_exam.is_proctored and not _exam.is_practice_exam 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 # 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']): if attempt and ProctoredExamStudentAttemptStatus.is_completed_status(attempt['status']):
# don't touch any completed statuses # don't touch any completed statuses
# we won't revoke those # we won't revoke those
continue continue
if not attempt: 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 any new or existing status to declined
update_attempt_status( update_attempt_status(
exam.id, other_exam.id,
user_id, user_id,
ProctoredExamStudentAttemptStatus.declined, ProctoredExamStudentAttemptStatus.declined,
cascade_effects=False cascade_effects=False
...@@ -762,7 +800,15 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True, ...@@ -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')) 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): def send_proctoring_attempt_status_email(exam_attempt_obj, course_name):
...@@ -857,6 +903,12 @@ def remove_exam_attempt(attempt_id): ...@@ -857,6 +903,12 @@ def remove_exam_attempt(attempt_id):
req_name=content_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): 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): ...@@ -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' student_view_template = 'proctored_exam/pending-prerequisites.html'
else: else:
student_view_template = 'proctored_exam/entrance.html' 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: elif attempt_status == ProctoredExamStudentAttemptStatus.started:
# when we're taking the exam we should not override the view # when we're taking the exam we should not override the view
return None return None
elif attempt_status == ProctoredExamStudentAttemptStatus.expired: elif attempt_status == ProctoredExamStudentAttemptStatus.expired:
student_view_template = 'proctored_exam/expired.html' 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() provider = get_backend_provider()
student_view_template = 'proctored_exam/instructions.html' student_view_template = 'proctored_exam/instructions.html'
context.update({ context.update({
......
...@@ -23,13 +23,16 @@ from edx_proctoring.exceptions import ( ...@@ -23,13 +23,16 @@ from edx_proctoring.exceptions import (
ProctoredExamReviewAlreadyExists, ProctoredExamReviewAlreadyExists,
ProctoredExamBadReviewStatus, 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 ( from edx_proctoring. models import (
ProctoredExamSoftwareSecureReview, ProctoredExamSoftwareSecureReview,
ProctoredExamSoftwareSecureComment, ProctoredExamSoftwareSecureComment,
ProctoredExamStudentAttemptStatus, ProctoredExamStudentAttemptStatus,
) )
from edx_proctoring.serializers import (
ProctoredExamSerializer,
ProctoredExamStudentAttemptSerializer,
)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -241,6 +244,20 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider): ...@@ -241,6 +244,20 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider):
self.on_review_saved(review, allow_status_update_on_fail=allow_status_update_on_fail) 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 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 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 Data models for the proctoring subsystem
""" """
...@@ -120,6 +121,10 @@ class ProctoredExamStudentAttemptStatus(object): ...@@ -120,6 +121,10 @@ class ProctoredExamStudentAttemptStatus(object):
# been started # been started
created = 'created' 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 # the attempt is ready to start but requires
# user to acknowledge that he/she wants to start the exam # user to acknowledge that he/she wants to start the exam
ready_to_start = 'ready_to_start' ready_to_start = 'ready_to_start'
...@@ -184,7 +189,8 @@ class ProctoredExamStudentAttemptStatus(object): ...@@ -184,7 +189,8 @@ 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 [
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 @classmethod
...@@ -742,8 +748,11 @@ class ProctoredExamStudentAllowance(TimeStampedModel): ...@@ -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 = cls.objects.get(proctored_exam_id=exam_id, user_id=user_id, key=key)
student_allowance.value = value student_allowance.value = value
student_allowance.save() student_allowance.save()
action = "updated"
except cls.DoesNotExist: # pylint: disable=no-member 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 @classmethod
def is_allowance_value_valid(cls, allowance_type, allowance_value): def is_allowance_value_valid(cls, allowance_type, allowance_value):
......
...@@ -76,7 +76,7 @@ class ProctoredExamStudentAttemptSerializer(serializers.ModelSerializer): ...@@ -76,7 +76,7 @@ class ProctoredExamStudentAttemptSerializer(serializers.ModelSerializer):
"id", "created", "modified", "user", "started_at", "completed_at", "id", "created", "modified", "user", "started_at", "completed_at",
"external_id", "status", "proctored_exam", "allowed_time_limit_mins", "external_id", "status", "proctored_exam", "allowed_time_limit_mins",
"attempt_code", "is_sample_attempt", "taking_as_proctored", "last_poll_timestamp", "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 || {}; ...@@ -8,6 +8,7 @@ var edx = edx || {};
var examStatusReadableFormat = { var examStatusReadableFormat = {
eligible: gettext('Eligible'), eligible: gettext('Eligible'),
created: gettext('Created'), created: gettext('Created'),
download_software_clicked: gettext('Download Software Clicked'),
ready_to_start: gettext('Ready to start'), ready_to_start: gettext('Ready to start'),
started: gettext('Started'), started: gettext('Started'),
ready_to_submit: gettext('Ready to submit'), ready_to_submit: gettext('Ready to submit'),
......
...@@ -26,7 +26,7 @@ ...@@ -26,7 +26,7 @@
{% endblocktrans %} {% endblocktrans %}
</p> </p>
<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>
<p> <p>
{% blocktrans %} {% blocktrans %}
...@@ -108,4 +108,27 @@ ...@@ -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> </script>
...@@ -66,6 +66,7 @@ from .utils import ( ...@@ -66,6 +66,7 @@ from .utils import (
from edx_proctoring.tests.test_services import ( from edx_proctoring.tests.test_services import (
MockCreditService, MockCreditService,
MockInstructorService, MockInstructorService,
MockAnalyticsService,
) )
from edx_proctoring.runtime import set_runtime_service, get_runtime_service from edx_proctoring.runtime import set_runtime_service, get_runtime_service
...@@ -124,6 +125,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -124,6 +125,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
set_runtime_service('credit', MockCreditService()) set_runtime_service('credit', MockCreditService())
set_runtime_service('instructor', MockInstructorService(is_user_course_staff=True)) set_runtime_service('instructor', MockInstructorService(is_user_course_staff=True))
set_runtime_service('analytics', MockAnalyticsService())
self.prerequisites = [ self.prerequisites = [
{ {
...@@ -657,7 +659,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -657,7 +659,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
proctored_exam_student_attempt = self._create_unstarted_exam_attempt() proctored_exam_student_attempt = self._create_unstarted_exam_attempt()
self.assertIsNone(proctored_exam_student_attempt.completed_at) self.assertIsNone(proctored_exam_student_attempt.completed_at)
proctored_exam_attempt_id = stop_exam_attempt( 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) self.assertEqual(proctored_exam_student_attempt.id, proctored_exam_attempt_id)
...@@ -746,7 +748,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -746,7 +748,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
proctored_exam_student_attempt = self._create_unstarted_exam_attempt() proctored_exam_student_attempt = self._create_unstarted_exam_attempt()
self.assertIsNone(proctored_exam_student_attempt.completed_at) self.assertIsNone(proctored_exam_student_attempt.completed_at)
proctored_exam_attempt_id = mark_exam_attempt_as_ready( 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) self.assertEqual(proctored_exam_student_attempt.id, proctored_exam_attempt_id)
...@@ -1038,6 +1040,27 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1038,6 +1040,27 @@ class ProctoredExamApiTests(LoggedInTestCase):
self.assertIn(self.chose_proctored_exam_msg, rendered_response) self.assertIn(self.chose_proctored_exam_msg, rendered_response)
self.assertIn(self.proctored_exam_optout_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): def test_get_studentview_unstarted_practice_exam(self):
""" """
Test for get_student_view Practice exam which has not started yet. Test for get_student_view Practice exam which has not started yet.
...@@ -1844,6 +1867,8 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1844,6 +1867,8 @@ class ProctoredExamApiTests(LoggedInTestCase):
(ProctoredExamStudentAttemptStatus.declined, ProctoredExamStudentAttemptStatus.eligible), (ProctoredExamStudentAttemptStatus.declined, ProctoredExamStudentAttemptStatus.eligible),
(ProctoredExamStudentAttemptStatus.timed_out, ProctoredExamStudentAttemptStatus.created), (ProctoredExamStudentAttemptStatus.timed_out, ProctoredExamStudentAttemptStatus.created),
(ProctoredExamStudentAttemptStatus.expired, 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.submitted, ProctoredExamStudentAttemptStatus.ready_to_start),
(ProctoredExamStudentAttemptStatus.verified, ProctoredExamStudentAttemptStatus.started), (ProctoredExamStudentAttemptStatus.verified, ProctoredExamStudentAttemptStatus.started),
(ProctoredExamStudentAttemptStatus.rejected, ProctoredExamStudentAttemptStatus.started), (ProctoredExamStudentAttemptStatus.rejected, ProctoredExamStudentAttemptStatus.started),
...@@ -1969,6 +1994,14 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -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, { ProctoredExamStudentAttemptStatus.ready_to_start, {
'status': ProctoredExamStudentAttemptStatus.ready_to_start, 'status': ProctoredExamStudentAttemptStatus.ready_to_start,
'short_description': 'Taking As Proctored Exam', 'short_description': 'Taking As Proctored Exam',
...@@ -2295,6 +2328,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -2295,6 +2328,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
@ddt.data( @ddt.data(
ProctoredExamStudentAttemptStatus.eligible, ProctoredExamStudentAttemptStatus.eligible,
ProctoredExamStudentAttemptStatus.created, ProctoredExamStudentAttemptStatus.created,
ProctoredExamStudentAttemptStatus.download_software_clicked,
ProctoredExamStudentAttemptStatus.ready_to_start, ProctoredExamStudentAttemptStatus.ready_to_start,
ProctoredExamStudentAttemptStatus.started, ProctoredExamStudentAttemptStatus.started,
ProctoredExamStudentAttemptStatus.ready_to_submit, ProctoredExamStudentAttemptStatus.ready_to_submit,
...@@ -2357,6 +2391,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -2357,6 +2391,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
@ddt.data( @ddt.data(
ProctoredExamStudentAttemptStatus.eligible, ProctoredExamStudentAttemptStatus.eligible,
ProctoredExamStudentAttemptStatus.created, ProctoredExamStudentAttemptStatus.created,
ProctoredExamStudentAttemptStatus.download_software_clicked,
ProctoredExamStudentAttemptStatus.submitted, ProctoredExamStudentAttemptStatus.submitted,
ProctoredExamStudentAttemptStatus.verified, ProctoredExamStudentAttemptStatus.verified,
ProctoredExamStudentAttemptStatus.rejected, ProctoredExamStudentAttemptStatus.rejected,
......
...@@ -121,6 +121,21 @@ class ProctoredExamStudentAttemptTests(LoggedInTestCase): ...@@ -121,6 +121,21 @@ class ProctoredExamStudentAttemptTests(LoggedInTestCase):
Tests for the ProctoredExamStudentAttempt Model 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 def test_delete_proctored_exam_attempt(self): # pylint: disable=invalid-name
""" """
Deleting the proctored exam attempt creates an entry in the history table. Deleting the proctored exam attempt creates an entry in the history table.
...@@ -132,6 +147,7 @@ class ProctoredExamStudentAttemptTests(LoggedInTestCase): ...@@ -132,6 +147,7 @@ class ProctoredExamStudentAttemptTests(LoggedInTestCase):
external_id='123aXqe3', external_id='123aXqe3',
time_limit_mins=90 time_limit_mins=90
) )
attempt = ProctoredExamStudentAttempt.objects.create( attempt = ProctoredExamStudentAttempt.objects.create(
proctored_exam_id=proctored_exam.id, proctored_exam_id=proctored_exam.id,
user_id=1, user_id=1,
......
...@@ -109,6 +109,17 @@ class MockInstructorService(object): ...@@ -109,6 +109,17 @@ class MockInstructorService(object):
return self.is_user_course_staff 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): class TestProctoringService(unittest.TestCase):
""" """
Tests for ProctoringService Tests for ProctoringService
......
...@@ -1030,6 +1030,47 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase): ...@@ -1030,6 +1030,47 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
response_data = json.loads(response.content) response_data = json.loads(response.content)
self.assertEqual(response_data['exam_attempt_id'], old_attempt_id) 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( @ddt.data(
('submit', ProctoredExamStudentAttemptStatus.submitted), ('submit', ProctoredExamStudentAttemptStatus.submitted),
('decline', ProctoredExamStudentAttemptStatus.declined) ('decline', ProctoredExamStudentAttemptStatus.declined)
...@@ -1498,13 +1539,17 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase): ...@@ -1498,13 +1539,17 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
attempt_data attempt_data
) )
self.assertEqual(response.status_code, 200) 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( response = self.client.get(
reverse('edx_proctoring.proctored_exam.attempt.collection') reverse('edx_proctoring.proctored_exam.attempt.collection')
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
data = json.loads(response.content) 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): def test_get_expired_exam_attempt(self):
""" """
......
...@@ -16,6 +16,7 @@ from edx_proctoring.models import ( ...@@ -16,6 +16,7 @@ from edx_proctoring.models import (
ProctoredExamStudentAttemptHistory, ProctoredExamStudentAttemptHistory,
) )
from edx_proctoring import constants from edx_proctoring import constants
from edx_proctoring.runtime import get_runtime_service
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -128,3 +129,69 @@ def has_client_app_shutdown(attempt): ...@@ -128,3 +129,69 @@ def has_client_app_shutdown(attempt):
elapsed_time = (datetime.now(pytz.UTC) - attempt['last_poll_timestamp']).total_seconds() elapsed_time = (datetime.now(pytz.UTC) - attempt['last_poll_timestamp']).total_seconds()
return elapsed_time > constants.SOFTWARE_SECURE_SHUT_DOWN_GRACEPERIOD 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): ...@@ -424,6 +424,12 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
request.user.id, request.user.id,
ProctoredExamStudentAttemptStatus.submitted 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': elif action == 'decline':
exam_attempt_id = update_attempt_status( exam_attempt_id = update_attempt_status(
attempt['proctored_exam']['id'], attempt['proctored_exam']['id'],
...@@ -569,7 +575,7 @@ class StudentProctoredExamAttemptCollection(AuthenticatedAPIView): ...@@ -569,7 +575,7 @@ class StudentProctoredExamAttemptCollection(AuthenticatedAPIView):
else: else:
response_dict = { response_dict = {
'in_timed_exam': False, 'in_timed_exam': False,
'is_proctored': False, 'is_proctored': False
} }
return Response( 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