Commit ff1b3d15 by Eric Fischer

Add ability to hide exam after due date

TNL-4366

This commit adds a new field, `hide_after_due` to the ProctoredExam model. This field
is intended to allow course authors to override the default setting and keep exam
results hidden from learners after the due date for the exam has passed.

Also included are migrations, tests, and api updates to allow this functionality to
be used.
parent fcd3a1d6
...@@ -57,7 +57,7 @@ SHOW_EXPIRY_MESSAGE_DURATION = 1 * 60 # duration within which expiry message is ...@@ -57,7 +57,7 @@ SHOW_EXPIRY_MESSAGE_DURATION = 1 * 60 # duration within which expiry message is
def create_exam(course_id, content_id, exam_name, time_limit_mins, due_date=None, 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, hide_after_due=False):
""" """
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.
If that pair already exists, then raise exception. If that pair already exists, then raise exception.
...@@ -77,19 +77,20 @@ def create_exam(course_id, content_id, exam_name, time_limit_mins, due_date=None ...@@ -77,19 +77,20 @@ def create_exam(course_id, content_id, exam_name, time_limit_mins, due_date=None
due_date=due_date, 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,
hide_after_due=hide_after_due,
) )
log_msg = ( log_msg = (
u'Created exam ({exam_id}) with parameters: course_id={course_id}, ' u'Created exam ({exam_id}) with parameters: course_id={course_id}, '
u'content_id={content_id}, exam_name={exam_name}, time_limit_mins={time_limit_mins}, ' u'content_id={content_id}, exam_name={exam_name}, time_limit_mins={time_limit_mins}, '
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}, hide_after_due={hide_after_due}'.format(
exam_id=proctored_exam.id, exam_id=proctored_exam.id,
course_id=course_id, content_id=content_id, course_id=course_id, content_id=content_id,
exam_name=exam_name, time_limit_mins=time_limit_mins, exam_name=exam_name, time_limit_mins=time_limit_mins,
is_proctored=is_proctored, is_practice_exam=is_practice_exam, 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, hide_after_due=hide_after_due
) )
) )
log.info(log_msg) log.info(log_msg)
...@@ -202,7 +203,7 @@ def get_review_policy_by_exam_id(exam_id): ...@@ -202,7 +203,7 @@ def get_review_policy_by_exam_id(exam_id):
def update_exam(exam_id, exam_name=None, time_limit_mins=None, due_date=constants.MINIMUM_TIME, 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, hide_after_due=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.
If an argument is not passed in, then do not change it's current value. If an argument is not passed in, then do not change it's current value.
...@@ -214,10 +215,10 @@ def update_exam(exam_id, exam_name=None, time_limit_mins=None, due_date=constant ...@@ -214,10 +215,10 @@ def update_exam(exam_id, exam_name=None, time_limit_mins=None, due_date=constant
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}, due_date={due_date}' 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}, hide_after_due={hide_after_due}'.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,
due_date=due_date, 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, hide_after_due=hide_after_due
) )
) )
log.info(log_msg) log.info(log_msg)
...@@ -240,6 +241,8 @@ def update_exam(exam_id, exam_name=None, time_limit_mins=None, due_date=constant ...@@ -240,6 +241,8 @@ def update_exam(exam_id, exam_name=None, time_limit_mins=None, due_date=constant
proctored_exam.external_id = external_id proctored_exam.external_id = external_id
if is_active is not None: if is_active is not None:
proctored_exam.is_active = is_active proctored_exam.is_active = is_active
if hide_after_due is not None:
proctored_exam.hide_after_due = hide_after_due
proctored_exam.save() proctored_exam.save()
# read back exam so we can emit an event on it # read back exam so we can emit an event on it
...@@ -1444,9 +1447,10 @@ def _get_timed_exam_view(exam, context, exam_id, user_id, course_id): ...@@ -1444,9 +1447,10 @@ def _get_timed_exam_view(exam, context, exam_id, user_id, course_id):
elif attempt_status == ProctoredExamStudentAttemptStatus.ready_to_submit: elif attempt_status == ProctoredExamStudentAttemptStatus.ready_to_submit:
student_view_template = 'timed_exam/ready_to_submit.html' student_view_template = 'timed_exam/ready_to_submit.html'
elif attempt_status == ProctoredExamStudentAttemptStatus.submitted: elif attempt_status == ProctoredExamStudentAttemptStatus.submitted:
# check if the exam's due_date has passed then we return None # If we are not hiding the exam after the due_date has passed,
# check if the exam's due_date has passed. If so, return None
# so that the user can see his exam answers in read only mode. # so that the user can see his exam answers in read only mode.
if has_due_date_passed(exam['due_date']): if not exam['hide_after_due'] and has_due_date_passed(exam['due_date']):
return None return None
student_view_template = 'timed_exam/submitted.html' student_view_template = 'timed_exam/submitted.html'
...@@ -1500,7 +1504,7 @@ def _get_timed_exam_view(exam, context, exam_id, user_id, course_id): ...@@ -1500,7 +1504,7 @@ def _get_timed_exam_view(exam, context, exam_id, user_id, course_id):
django_context.update({ django_context.update({
'total_time': total_time, 'total_time': total_time,
'has_due_date': has_due_date, 'will_be_revealed': has_due_date and not exam['hide_after_due'],
'exam_id': exam_id, 'exam_id': exam_id,
'exam_name': exam['exam_name'], 'exam_name': exam['exam_name'],
'progress_page_url': progress_page_url, 'progress_page_url': progress_page_url,
...@@ -1816,7 +1820,8 @@ def get_student_view(user_id, course_id, content_id, ...@@ -1816,7 +1820,8 @@ def get_student_view(user_id, course_id, content_id,
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) due_date=context.get('due_date', None),
hide_after_due=context.get('hide_after_due', None),
) )
exam = get_exam_by_content_id(course_id, content_id) exam = get_exam_by_content_id(course_id, content_id)
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('edx_proctoring', '0004_auto_20160201_0523'),
]
operations = [
migrations.AddField(
model_name='proctoredexam',
name='hide_after_due',
field=models.BooleanField(default=False),
),
]
...@@ -50,6 +50,9 @@ class ProctoredExam(TimeStampedModel): ...@@ -50,6 +50,9 @@ class ProctoredExam(TimeStampedModel):
# Whether this exam will be active. # Whether this exam will be active.
is_active = models.BooleanField(default=False) is_active = models.BooleanField(default=False)
# Whether to hide this exam after the due date
hide_after_due = models.BooleanField(default=False)
class Meta: class Meta:
""" Meta class for this Django model """ """ Meta class for this Django model """
unique_together = (('course_id', 'content_id'),) unique_together = (('course_id', 'content_id'),)
......
...@@ -25,6 +25,7 @@ class ProctoredExamSerializer(serializers.ModelSerializer): ...@@ -25,6 +25,7 @@ class ProctoredExamSerializer(serializers.ModelSerializer):
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) due_date = serializers.DateTimeField(required=False, format=None)
hide_after_due = serializers.BooleanField(required=True)
class Meta: class Meta:
""" """
...@@ -34,7 +35,8 @@ class ProctoredExamSerializer(serializers.ModelSerializer): ...@@ -34,7 +35,8 @@ 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", "due_date" "time_limit_mins", "is_proctored", "is_practice_exam", "is_active",
"due_date", "hide_after_due"
) )
......
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
{% blocktrans %} {% blocktrans %}
Your grade for this timed exam will be immediately available on the <a href="{{progress_page_url}}">Progress</a> page. Your grade for this timed exam will be immediately available on the <a href="{{progress_page_url}}">Progress</a> page.
{% endblocktrans %} {% endblocktrans %}
{% if has_due_date %} {% if will_be_revealed %}
{% blocktrans %} {% blocktrans %}
After the due date has passed, you can review the exam, but you cannot change your answers. After the due date has passed, you can review the exam, but you cannot change your answers.
{% endblocktrans %} {% endblocktrans %}
......
...@@ -413,6 +413,18 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -413,6 +413,18 @@ class ProctoredExamApiTests(LoggedInTestCase):
self.assertEqual(update_proctored_exam.course_id, 'test_course') self.assertEqual(update_proctored_exam.course_id, 'test_course')
self.assertEqual(update_proctored_exam.content_id, 'test_content_id') self.assertEqual(update_proctored_exam.content_id, 'test_content_id')
def test_update_timed_exam(self):
"""
test update the existing timed exam
"""
updated_timed_exam_id = update_exam(self.timed_exam_id, hide_after_due=True)
self.assertEqual(self.timed_exam_id, updated_timed_exam_id)
update_timed_exam = ProctoredExam.objects.get(id=updated_timed_exam_id)
self.assertEqual(update_timed_exam.hide_after_due, True)
def test_update_non_existing_exam(self): def test_update_non_existing_exam(self):
""" """
test to update the non-existing proctored exam test to update the non-existing proctored exam
...@@ -1022,7 +1034,8 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1022,7 +1034,8 @@ class ProctoredExamApiTests(LoggedInTestCase):
context={ context={
'is_proctored': True, 'is_proctored': True,
'display_name': self.exam_name, 'display_name': self.exam_name,
'default_time_limit_mins': 90 'default_time_limit_mins': 90,
'hide_after_due': False,
} }
) )
self.assertIn( self.assertIn(
...@@ -1041,6 +1054,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1041,6 +1054,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
'display_name': self.exam_name, 'display_name': self.exam_name,
'default_time_limit_mins': 90, 'default_time_limit_mins': 90,
'is_practice_exam': True, 'is_practice_exam': True,
'hide_after_due': False,
} }
) )
self.assertIn(self.start_a_practice_exam_msg.format(exam_name=self.exam_name), rendered_response) self.assertIn(self.start_a_practice_exam_msg.format(exam_name=self.exam_name), rendered_response)
...@@ -1186,7 +1200,8 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1186,7 +1200,8 @@ class ProctoredExamApiTests(LoggedInTestCase):
'is_proctored': False, 'is_proctored': False,
'is_practice_exam': True, 'is_practice_exam': True,
'display_name': self.exam_name, 'display_name': self.exam_name,
'default_time_limit_mins': 90 'default_time_limit_mins': 90,
'hide_after_due': False,
}, },
user_role='student' user_role='student'
) )
...@@ -1209,7 +1224,8 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1209,7 +1224,8 @@ class ProctoredExamApiTests(LoggedInTestCase):
'is_practice_exam': False, 'is_practice_exam': False,
'display_name': self.exam_name, 'display_name': self.exam_name,
'default_time_limit_mins': 90, 'default_time_limit_mins': 90,
'due_date': None 'due_date': None,
'hide_after_due': False,
}, },
user_role='student' user_role='student'
) )
...@@ -1250,7 +1266,8 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1250,7 +1266,8 @@ class ProctoredExamApiTests(LoggedInTestCase):
'is_practice_exam': True, 'is_practice_exam': True,
'display_name': self.exam_name, 'display_name': self.exam_name,
'default_time_limit_mins': 90, 'default_time_limit_mins': 90,
'due_date': None 'due_date': None,
'hide_after_due': False,
}, },
user_role='student' user_role='student'
) )
...@@ -1436,16 +1453,19 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1436,16 +1453,19 @@ class ProctoredExamApiTests(LoggedInTestCase):
@ddt.data( @ddt.data(
(datetime.now(pytz.UTC) + timedelta(days=1), False), (datetime.now(pytz.UTC) + timedelta(days=1), False),
(datetime.now(pytz.UTC) - timedelta(days=1), False),
(datetime.now(pytz.UTC) - timedelta(days=1), True), (datetime.now(pytz.UTC) - timedelta(days=1), True),
) )
@ddt.unpack @ddt.unpack
def test_get_studentview_submitted_timed_exam_with_past_due_date(self, due_date, has_due_date_passed): def test_get_studentview_submitted_timed_exam_with_past_due_date(self, due_date, hide_after_due):
""" """
Test for get_student_view timed exam with the due date. Test for get_student_view timed exam with the due date.
""" """
# exam is created with due datetime which has already passed # exam is created with due datetime which has already passed
exam_id = self._create_exam_with_due_time(is_proctored=False, due_date=due_date) exam_id = self._create_exam_with_due_time(is_proctored=False, due_date=due_date)
if hide_after_due:
update_exam(exam_id, hide_after_due=hide_after_due)
# now create the timed_exam attempt in the submitted state # now create the timed_exam attempt in the submitted state
self._create_exam_attempt(exam_id, status='submitted') self._create_exam_attempt(exam_id, status='submitted')
...@@ -1461,10 +1481,14 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1461,10 +1481,14 @@ class ProctoredExamApiTests(LoggedInTestCase):
'due_date': due_date, 'due_date': due_date,
} }
) )
if not has_due_date_passed: if datetime.now(pytz.UTC) < due_date:
self.assertIn(self.timed_exam_submitted, rendered_response)
self.assertIn(self.submitted_timed_exam_msg_with_due_date, rendered_response) self.assertIn(self.submitted_timed_exam_msg_with_due_date, rendered_response)
elif hide_after_due:
self.assertIn(self.timed_exam_submitted, rendered_response)
self.assertNotIn(self.submitted_timed_exam_msg_with_due_date, rendered_response)
else: else:
self.assertIsNone(None) self.assertIsNone(rendered_response)
def test_proctored_exam_attempt_with_past_due_datetime(self): def test_proctored_exam_attempt_with_past_due_datetime(self):
""" """
...@@ -1925,7 +1949,8 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1925,7 +1949,8 @@ class ProctoredExamApiTests(LoggedInTestCase):
context={ context={
'is_proctored': False, 'is_proctored': False,
'display_name': self.exam_name, 'display_name': self.exam_name,
'default_time_limit_mins': 90 'default_time_limit_mins': 90,
'hide_after_due': False,
} }
) )
self.assertNotIn( self.assertNotIn(
......
...@@ -23,7 +23,8 @@ class TestProctoredExamSerializer(unittest.TestCase): ...@@ -23,7 +23,8 @@ class TestProctoredExamSerializer(unittest.TestCase):
'external_id': '123', 'external_id': '123',
'is_proctored': 'bla', 'is_proctored': 'bla',
'is_practice_exam': 'bla', 'is_practice_exam': 'bla',
'is_active': 'f' 'is_active': 'f',
'hide_after_due': 't',
} }
serializer = ProctoredExamSerializer(data=data) serializer = ProctoredExamSerializer(data=data)
......
...@@ -101,7 +101,8 @@ class ProctoredExamViewTests(LoggedInTestCase): ...@@ -101,7 +101,8 @@ class ProctoredExamViewTests(LoggedInTestCase):
'external_id': '123', 'external_id': '123',
'is_proctored': True, 'is_proctored': True,
'is_practice_exam': False, 'is_practice_exam': False,
'is_active': True 'is_active': True,
'hide_after_due': False,
} }
response = self.client.post( response = self.client.post(
reverse('edx_proctoring.proctored_exam.exam'), reverse('edx_proctoring.proctored_exam.exam'),
...@@ -136,7 +137,8 @@ class ProctoredExamViewTests(LoggedInTestCase): ...@@ -136,7 +137,8 @@ class ProctoredExamViewTests(LoggedInTestCase):
'external_id': '123', 'external_id': '123',
'is_proctored': True, 'is_proctored': True,
'is_practice_exam': False, 'is_practice_exam': False,
'is_active': True 'is_active': True,
'hide_after_due': False,
} }
response = self.client.post( response = self.client.post(
reverse('edx_proctoring.proctored_exam.exam'), reverse('edx_proctoring.proctored_exam.exam'),
......
...@@ -189,7 +189,8 @@ class ProctoredExamView(AuthenticatedAPIView): ...@@ -189,7 +189,8 @@ class ProctoredExamView(AuthenticatedAPIView):
is_proctored=request.data.get('is_proctored', None), is_proctored=request.data.get('is_proctored', None),
is_practice_exam=request.data.get('is_practice_exam', None), is_practice_exam=request.data.get('is_practice_exam', None),
external_id=request.data.get('external_id', None), external_id=request.data.get('external_id', None),
is_active=request.data.get('is_active', None) is_active=request.data.get('is_active', None),
hide_after_due=request.data.get('hide_after_due', None),
) )
return Response({'exam_id': exam_id}) return Response({'exam_id': exam_id})
else: else:
...@@ -213,6 +214,7 @@ class ProctoredExamView(AuthenticatedAPIView): ...@@ -213,6 +214,7 @@ class ProctoredExamView(AuthenticatedAPIView):
is_practice_exam=request.data.get('is_practice_exam', None), is_practice_exam=request.data.get('is_practice_exam', None),
external_id=request.data.get('external_id', None), external_id=request.data.get('external_id', None),
is_active=request.data.get('is_active', None), is_active=request.data.get('is_active', None),
hide_after_due=request.data.get('hide_after_due', None),
) )
return Response({'exam_id': exam_id}) return Response({'exam_id': exam_id})
except ProctoredExamNotFoundException, ex: except ProctoredExamNotFoundException, ex:
......
...@@ -34,7 +34,7 @@ def load_requirements(*requirements_paths): ...@@ -34,7 +34,7 @@ def load_requirements(*requirements_paths):
setup( setup(
name='edx-proctoring', name='edx-proctoring',
version='0.12.15', version='0.12.16',
description='Proctoring subsystem for Open edX', description='Proctoring subsystem for Open edX',
long_description=open('README.md').read(), long_description=open('README.md').read(),
author='edX', author='edX',
......
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