Commit 34e11630 by Hasnain Committed by Chris Dodge

Added new field "Due date"

parent 0ab7dfc5
...@@ -57,7 +57,7 @@ def is_feature_enabled(): ...@@ -57,7 +57,7 @@ def is_feature_enabled():
return hasattr(settings, 'FEATURES') and settings.FEATURES.get('ENABLE_PROCTORED_EXAMS', False) return hasattr(settings, 'FEATURES') and settings.FEATURES.get('ENABLE_PROCTORED_EXAMS', False)
def create_exam(course_id, content_id, exam_name, time_limit_mins, def create_exam(course_id, content_id, exam_name, time_limit_mins, due_date=None,
is_proctored=True, is_practice_exam=False, external_id=None, is_active=True): is_proctored=True, is_practice_exam=False, external_id=None, is_active=True):
""" """
Creates a new ProctoredExam entity, if the course_id/content_id pair do not already exist. Creates a new ProctoredExam entity, if the course_id/content_id pair do not already exist.
...@@ -75,6 +75,7 @@ def create_exam(course_id, content_id, exam_name, time_limit_mins, ...@@ -75,6 +75,7 @@ def create_exam(course_id, content_id, exam_name, time_limit_mins,
external_id=external_id, external_id=external_id,
exam_name=exam_name, exam_name=exam_name,
time_limit_mins=time_limit_mins, time_limit_mins=time_limit_mins,
due_date=due_date,
is_proctored=is_proctored, is_proctored=is_proctored,
is_practice_exam=is_practice_exam, is_practice_exam=is_practice_exam,
is_active=is_active is_active=is_active
...@@ -97,7 +98,7 @@ def create_exam(course_id, content_id, exam_name, time_limit_mins, ...@@ -97,7 +98,7 @@ def create_exam(course_id, content_id, exam_name, time_limit_mins,
return proctored_exam.id return proctored_exam.id
def update_exam(exam_id, exam_name=None, time_limit_mins=None, def update_exam(exam_id, exam_name=None, time_limit_mins=None, due_date=constants.MINIMUM_TIME,
is_proctored=None, is_practice_exam=None, external_id=None, is_active=None): is_proctored=None, is_practice_exam=None, external_id=None, is_active=None):
""" """
Given a Django ORM id, update the existing record, otherwise raise exception if not found. Given a Django ORM id, update the existing record, otherwise raise exception if not found.
...@@ -108,11 +109,11 @@ def update_exam(exam_id, exam_name=None, time_limit_mins=None, ...@@ -108,11 +109,11 @@ def update_exam(exam_id, exam_name=None, time_limit_mins=None,
log_msg = ( log_msg = (
u'Updating exam_id {exam_id} with parameters ' u'Updating exam_id {exam_id} with parameters '
u'exam_name={exam_name}, time_limit_mins={time_limit_mins}, ' u'exam_name={exam_name}, time_limit_mins={time_limit_mins}, due_date={due_date}'
u'is_proctored={is_proctored}, is_practice_exam={is_practice_exam}, ' u'is_proctored={is_proctored}, is_practice_exam={is_practice_exam}, '
u'external_id={external_id}, is_active={is_active}'.format( u'external_id={external_id}, is_active={is_active}'.format(
exam_id=exam_id, exam_name=exam_name, time_limit_mins=time_limit_mins, exam_id=exam_id, exam_name=exam_name, time_limit_mins=time_limit_mins,
is_proctored=is_proctored, is_practice_exam=is_practice_exam, due_date=due_date, is_proctored=is_proctored, is_practice_exam=is_practice_exam,
external_id=external_id, is_active=is_active external_id=external_id, is_active=is_active
) )
) )
...@@ -126,6 +127,8 @@ def update_exam(exam_id, exam_name=None, time_limit_mins=None, ...@@ -126,6 +127,8 @@ def update_exam(exam_id, exam_name=None, time_limit_mins=None,
proctored_exam.exam_name = exam_name proctored_exam.exam_name = exam_name
if time_limit_mins is not None: if time_limit_mins is not None:
proctored_exam.time_limit_mins = time_limit_mins proctored_exam.time_limit_mins = time_limit_mins
if due_date is not constants.MINIMUM_TIME:
proctored_exam.due_date = due_date
if is_proctored is not None: if is_proctored is not None:
proctored_exam.is_proctored = is_proctored proctored_exam.is_proctored = is_proctored
if is_practice_exam is not None: if is_practice_exam is not None:
...@@ -319,6 +322,13 @@ def update_exam_attempt(attempt_id, **kwargs): ...@@ -319,6 +322,13 @@ def update_exam_attempt(attempt_id, **kwargs):
exam_attempt_obj.save() exam_attempt_obj.save()
def _has_due_date_passed(due_datetime):
"""
return True if due date is lesser than current datetime, otherwise False
"""
return due_datetime <= datetime.now(pytz.UTC)
def create_exam_attempt(exam_id, user_id, taking_as_proctored=False): def create_exam_attempt(exam_id, user_id, taking_as_proctored=False):
""" """
Creates an exam attempt for user_id against exam_id. There should only be Creates an exam attempt for user_id against exam_id. There should only be
...@@ -350,19 +360,32 @@ def create_exam_attempt(exam_id, user_id, taking_as_proctored=False): ...@@ -350,19 +360,32 @@ def create_exam_attempt(exam_id, user_id, taking_as_proctored=False):
raise StudentExamAttemptAlreadyExistsException(err_msg) raise StudentExamAttemptAlreadyExistsException(err_msg)
allowed_time_limit_mins = exam['time_limit_mins'] allowed_time_limit_mins = exam['time_limit_mins']
due_datetime = exam['due_date']
current_datetime = datetime.now(pytz.UTC)
is_exam_past_due_date = False
# add in the allowed additional time # add in the allowed additional time
allowance_extra_mins = ProctoredExamStudentAllowance.get_additional_time_granted(exam_id, user_id) allowance_extra_mins = ProctoredExamStudentAllowance.get_additional_time_granted(exam_id, user_id)
if allowance_extra_mins: if allowance_extra_mins:
allowed_time_limit_mins += allowance_extra_mins allowed_time_limit_mins += allowance_extra_mins
if due_datetime:
if _has_due_date_passed(due_datetime):
is_exam_past_due_date = True
elif current_datetime + timedelta(minutes=allowed_time_limit_mins) > due_datetime:
# e.g current_datetime=09:00, due_datetime=10:00 and allowed_time_limit_mins=120(2hours)
# then allowed_time_limit_mins should be 60(1hour)
allowed_time_limit_mins = int((due_datetime - current_datetime).seconds / 60)
attempt_code = unicode(uuid.uuid4()).upper() attempt_code = unicode(uuid.uuid4()).upper()
external_id = None external_id = None
review_policy = ProctoredExamReviewPolicy.get_review_policy_for_exam(exam_id) review_policy = ProctoredExamReviewPolicy.get_review_policy_for_exam(exam_id)
review_policy_exception = ProctoredExamStudentAllowance.get_review_policy_exception(exam_id, user_id) review_policy_exception = ProctoredExamStudentAllowance.get_review_policy_exception(exam_id, user_id)
if taking_as_proctored: if not is_exam_past_due_date and taking_as_proctored:
scheme = 'https' if getattr(settings, 'HTTPS', 'on') == 'on' else 'http' scheme = 'https' if getattr(settings, 'HTTPS', 'on') == 'on' else 'http'
callback_url = '{scheme}://{hostname}{path}'.format( callback_url = '{scheme}://{hostname}{path}'.format(
scheme=scheme, scheme=scheme,
...@@ -422,6 +445,13 @@ def create_exam_attempt(exam_id, user_id, taking_as_proctored=False): ...@@ -422,6 +445,13 @@ def create_exam_attempt(exam_id, user_id, taking_as_proctored=False):
review_policy_id=review_policy.id if review_policy else None, review_policy_id=review_policy.id if review_policy else None,
) )
if is_exam_past_due_date:
update_attempt_status(
exam_id,
user_id,
ProctoredExamStudentAttemptStatus.declined
)
log_msg = ( log_msg = (
'Created exam attempt ({attempt_id}) for exam_id {exam_id} for ' 'Created exam attempt ({attempt_id}) for exam_id {exam_id} for '
'user_id {user_id} with taking as proctored = {taking_as_proctored} ' 'user_id {user_id} with taking as proctored = {taking_as_proctored} '
...@@ -1471,7 +1501,8 @@ def get_student_view(user_id, course_id, content_id, ...@@ -1471,7 +1501,8 @@ def get_student_view(user_id, course_id, content_id,
exam_name=context['display_name'], exam_name=context['display_name'],
time_limit_mins=context['default_time_limit_mins'], time_limit_mins=context['default_time_limit_mins'],
is_proctored=context.get('is_proctored', False), is_proctored=context.get('is_proctored', False),
is_practice_exam=context.get('is_practice_exam', False) is_practice_exam=context.get('is_practice_exam', False),
due_date=context.get('due_date', None)
) )
exam = get_exam_by_content_id(course_id, content_id) exam = get_exam_by_content_id(course_id, content_id)
......
...@@ -3,6 +3,7 @@ Lists of constants that can be used in the edX proctoring ...@@ -3,6 +3,7 @@ Lists of constants that can be used in the edX proctoring
""" """
from django.conf import settings from django.conf import settings
import datetime
SITE_NAME = ( SITE_NAME = (
settings.PROCTORING_SETTINGS['SITE_NAME'] if settings.PROCTORING_SETTINGS['SITE_NAME'] if
...@@ -53,3 +54,5 @@ SOFTWARE_SECURE_SHUT_DOWN_GRACEPERIOD = ( ...@@ -53,3 +54,5 @@ SOFTWARE_SECURE_SHUT_DOWN_GRACEPERIOD = (
'SOFTWARE_SECURE_SHUT_DOWN_GRACEPERIOD' in settings.PROCTORING_SETTINGS 'SOFTWARE_SECURE_SHUT_DOWN_GRACEPERIOD' in settings.PROCTORING_SETTINGS
else getattr(settings, 'SOFTWARE_SECURE_SHUT_DOWN_GRACEPERIOD', 10) else getattr(settings, 'SOFTWARE_SECURE_SHUT_DOWN_GRACEPERIOD', 10)
) )
MINIMUM_TIME = datetime.datetime.fromtimestamp(0)
...@@ -36,6 +36,9 @@ class ProctoredExam(TimeStampedModel): ...@@ -36,6 +36,9 @@ class ProctoredExam(TimeStampedModel):
# Time limit (in minutes) that a student can finish this exam. # Time limit (in minutes) that a student can finish this exam.
time_limit_mins = models.IntegerField() time_limit_mins = models.IntegerField()
# Due date is a deadline to finish the exam
due_date = models.DateTimeField(null=True)
# Whether this exam actually is proctored or not. # Whether this exam actually is proctored or not.
is_proctored = models.BooleanField() is_proctored = models.BooleanField()
......
...@@ -19,6 +19,7 @@ class ProctoredExamSerializer(serializers.ModelSerializer): ...@@ -19,6 +19,7 @@ class ProctoredExamSerializer(serializers.ModelSerializer):
is_active = serializers.BooleanField(required=True) is_active = serializers.BooleanField(required=True)
is_practice_exam = serializers.BooleanField(required=True) is_practice_exam = serializers.BooleanField(required=True)
is_proctored = serializers.BooleanField(required=True) is_proctored = serializers.BooleanField(required=True)
due_date = serializers.DateTimeField(required=False, format=None)
class Meta: class Meta:
""" """
...@@ -28,7 +29,7 @@ class ProctoredExamSerializer(serializers.ModelSerializer): ...@@ -28,7 +29,7 @@ class ProctoredExamSerializer(serializers.ModelSerializer):
fields = ( fields = (
"id", "course_id", "content_id", "external_id", "exam_name", "id", "course_id", "content_id", "external_id", "exam_name",
"time_limit_mins", "is_proctored", "is_practice_exam", "is_active" "time_limit_mins", "is_proctored", "is_practice_exam", "is_active", "due_date"
) )
......
...@@ -83,6 +83,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -83,6 +83,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
super(ProctoredExamApiTests, self).setUp() super(ProctoredExamApiTests, self).setUp()
self.default_time_limit = 21 self.default_time_limit = 21
self.course_id = 'test_course' self.course_id = 'test_course'
self.content_id_for_exam_with_due_date = 'test_content_due_date_id'
self.content_id = 'test_content_id' self.content_id = 'test_content_id'
self.content_id_timed = 'test_content_id_timed' self.content_id_timed = 'test_content_id_timed'
self.content_id_practice = 'test_content_id_practice' self.content_id_practice = 'test_content_id_practice'
...@@ -166,6 +167,18 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -166,6 +167,18 @@ class ProctoredExamApiTests(LoggedInTestCase):
time_limit_mins=self.default_time_limit time_limit_mins=self.default_time_limit
) )
def _create_proctored_exam_with_due_time(self, due_date=None):
"""
Calls the api's create_exam to create an exam object.
"""
return create_exam(
course_id=self.course_id,
content_id=self.content_id_for_exam_with_due_date,
exam_name=self.exam_name,
time_limit_mins=self.default_time_limit,
due_date=due_date
)
def _create_timed_exam(self): def _create_timed_exam(self):
""" """
Calls the api's create_exam to create an exam object. Calls the api's create_exam to create an exam object.
...@@ -305,7 +318,8 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -305,7 +318,8 @@ class ProctoredExamApiTests(LoggedInTestCase):
""" """
updated_proctored_exam_id = update_exam( updated_proctored_exam_id = update_exam(
self.proctored_exam_id, exam_name='Updated Exam Name', time_limit_mins=30, self.proctored_exam_id, exam_name='Updated Exam Name', time_limit_mins=30,
is_proctored=True, external_id='external_id', is_active=True is_proctored=True, external_id='external_id', is_active=True,
due_date=datetime.now(pytz.UTC)
) )
# only those fields were updated, whose # only those fields were updated, whose
...@@ -433,6 +447,49 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -433,6 +447,49 @@ class ProctoredExamApiTests(LoggedInTestCase):
remove_allowance_for_user(student_allowance.proctored_exam.id, self.user_id, self.key) remove_allowance_for_user(student_allowance.proctored_exam.id, self.user_id, self.key)
self.assertEqual(len(ProctoredExamStudentAllowance.objects.filter()), 0) self.assertEqual(len(ProctoredExamStudentAllowance.objects.filter()), 0)
def test_create_an_exam_attempt_with_due_datetime(self):
"""
Create the exam attempt with due date
"""
due_date = datetime.now(pytz.UTC) + timedelta(days=1)
# exam is created with due datetime > current_datetime and due_datetime < current_datetime + allowed_mins
exam_id = self._create_proctored_exam_with_due_time(due_date=due_date)
# due_date is exactly after 24 hours, our exam's allowed minutes are 21
# student will get full allowed minutes if student will start exam within next 23 hours and 39 minutes
# otherwise allowed minutes = due_datetime - exam_attempt_datetime
# so if students arrives after 23 hours and 45 minutes later then he will get only 15 minutes
minutes_before_past_due_date = 15
reset_time = due_date - timedelta(minutes=minutes_before_past_due_date)
with freeze_time(reset_time):
attempt_id = create_exam_attempt(exam_id, self.user_id)
attempt = get_exam_attempt_by_id(attempt_id)
self.assertTrue(
minutes_before_past_due_date - 1 <= attempt['allowed_time_limit_mins'] <= minutes_before_past_due_date
)
def test_create_an_exam_attempt_with_past_due_datetime(self):
"""
Create the exam attempt with past due date
"""
due_date = datetime.now(pytz.UTC) + timedelta(days=1)
# exam is created with due datetime which has already passed
exam_id = self._create_proctored_exam_with_due_time(due_date=due_date)
# due_date is exactly after 24 hours, if student arrives after 2 days
# then he can not attempt the proctored exam
reset_time = due_date + timedelta(days=2)
with freeze_time(reset_time):
attempt_id = create_exam_attempt(exam_id, self.user_id)
attempt = get_exam_attempt_by_id(attempt_id)
self.assertEqual(attempt['status'], ProctoredExamStudentAttemptStatus.declined)
def test_create_an_exam_attempt(self): def test_create_an_exam_attempt(self):
""" """
Create an unstarted exam attempt. Create an unstarted exam attempt.
......
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