From ff1b3d15d58f037fdbcb6ced61ee387efcb72c13 Mon Sep 17 00:00:00 2001 From: Eric Fischer Date: Wed, 20 Apr 2016 13:46:25 -0400 Subject: [PATCH] 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. --- edx_proctoring/api.py | 27 +++++++----- .../0005_proctoredexam_hide_after_due.py | 19 +++++++++ edx_proctoring/models.py | 3 ++ edx_proctoring/serializers.py | 4 +- .../templates/timed_exam/submitted.html | 2 +- edx_proctoring/tests/test_api.py | 41 +++++++++++++++---- edx_proctoring/tests/test_serializer.py | 3 +- edx_proctoring/tests/test_views.py | 6 ++- edx_proctoring/views.py | 4 +- setup.py | 2 +- 10 files changed, 85 insertions(+), 26 deletions(-) create mode 100644 edx_proctoring/migrations/0005_proctoredexam_hide_after_due.py diff --git a/edx_proctoring/api.py b/edx_proctoring/api.py index 763e485bd97..f450eaf3884 100644 --- a/edx_proctoring/api.py +++ b/edx_proctoring/api.py @@ -57,7 +57,7 @@ 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. 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 due_date=due_date, is_proctored=is_proctored, is_practice_exam=is_practice_exam, - is_active=is_active + is_active=is_active, + hide_after_due=hide_after_due, ) log_msg = ( 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'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, course_id=course_id, content_id=content_id, exam_name=exam_name, time_limit_mins=time_limit_mins, 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) @@ -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, - 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. 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 u'Updating exam_id {exam_id} with parameters ' 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'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, 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) @@ -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 if is_active is not None: proctored_exam.is_active = is_active + if hide_after_due is not None: + proctored_exam.hide_after_due = hide_after_due proctored_exam.save() # 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): elif attempt_status == ProctoredExamStudentAttemptStatus.ready_to_submit: student_view_template = 'timed_exam/ready_to_submit.html' 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. - if has_due_date_passed(exam['due_date']): + if not exam['hide_after_due'] and has_due_date_passed(exam['due_date']): return None student_view_template = 'timed_exam/submitted.html' @@ -1500,7 +1504,7 @@ def _get_timed_exam_view(exam, context, exam_id, user_id, course_id): django_context.update({ '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_name': exam['exam_name'], 'progress_page_url': progress_page_url, @@ -1816,7 +1820,8 @@ def get_student_view(user_id, course_id, content_id, time_limit_mins=context['default_time_limit_mins'], is_proctored=context.get('is_proctored', 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) diff --git a/edx_proctoring/migrations/0005_proctoredexam_hide_after_due.py b/edx_proctoring/migrations/0005_proctoredexam_hide_after_due.py new file mode 100644 index 00000000000..305345bca63 --- /dev/null +++ b/edx_proctoring/migrations/0005_proctoredexam_hide_after_due.py @@ -0,0 +1,19 @@ +# -*- 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), + ), + ] diff --git a/edx_proctoring/models.py b/edx_proctoring/models.py index 64e221f49c8..85e6c4f3871 100644 --- a/edx_proctoring/models.py +++ b/edx_proctoring/models.py @@ -50,6 +50,9 @@ class ProctoredExam(TimeStampedModel): # Whether this exam will be active. is_active = models.BooleanField(default=False) + # Whether to hide this exam after the due date + hide_after_due = models.BooleanField(default=False) + class Meta: """ Meta class for this Django model """ unique_together = (('course_id', 'content_id'),) diff --git a/edx_proctoring/serializers.py b/edx_proctoring/serializers.py index 9663cf14de6..64cb8c870dc 100644 --- a/edx_proctoring/serializers.py +++ b/edx_proctoring/serializers.py @@ -25,6 +25,7 @@ class ProctoredExamSerializer(serializers.ModelSerializer): is_practice_exam = serializers.BooleanField(required=True) is_proctored = serializers.BooleanField(required=True) due_date = serializers.DateTimeField(required=False, format=None) + hide_after_due = serializers.BooleanField(required=True) class Meta: """ @@ -34,7 +35,8 @@ class Meta: fields = ( "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" ) diff --git a/edx_proctoring/templates/timed_exam/submitted.html b/edx_proctoring/templates/timed_exam/submitted.html index a8a1c421d2b..1e7c19821de 100644 --- a/edx_proctoring/templates/timed_exam/submitted.html +++ b/edx_proctoring/templates/timed_exam/submitted.html @@ -18,7 +18,7 @@

{% blocktrans %} Your grade for this timed exam will be immediately available on the Progress page. {% endblocktrans %} - {% if has_due_date %} + {% if will_be_revealed %} {% blocktrans %} After the due date has passed, you can review the exam, but you cannot change your answers. {% endblocktrans %} diff --git a/edx_proctoring/tests/test_api.py b/edx_proctoring/tests/test_api.py index 322d5749e51..93d9b898a3e 100644 --- a/edx_proctoring/tests/test_api.py +++ b/edx_proctoring/tests/test_api.py @@ -413,6 +413,18 @@ def test_update_proctored_exam(self): self.assertEqual(update_proctored_exam.course_id, 'test_course') 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): """ test to update the non-existing proctored exam @@ -1022,7 +1034,8 @@ def test_get_student_view(self): context={ 'is_proctored': True, 'display_name': self.exam_name, - 'default_time_limit_mins': 90 + 'default_time_limit_mins': 90, + 'hide_after_due': False, } ) self.assertIn( @@ -1041,6 +1054,7 @@ def test_get_student_view(self): 'display_name': self.exam_name, 'default_time_limit_mins': 90, 'is_practice_exam': True, + 'hide_after_due': False, } ) self.assertIn(self.start_a_practice_exam_msg.format(exam_name=self.exam_name), rendered_response) @@ -1186,7 +1200,8 @@ def test_wrong_exam_combo(self): 'is_proctored': False, 'is_practice_exam': True, 'display_name': self.exam_name, - 'default_time_limit_mins': 90 + 'default_time_limit_mins': 90, + 'hide_after_due': False, }, user_role='student' ) @@ -1209,7 +1224,8 @@ def test_proctored_exam_passed_end_date(self): 'is_practice_exam': False, 'display_name': self.exam_name, 'default_time_limit_mins': 90, - 'due_date': None + 'due_date': None, + 'hide_after_due': False, }, user_role='student' ) @@ -1250,7 +1266,8 @@ def test_practice_exam_passed_end_date(self): 'is_practice_exam': True, 'display_name': self.exam_name, 'default_time_limit_mins': 90, - 'due_date': None + 'due_date': None, + 'hide_after_due': False, }, user_role='student' ) @@ -1436,16 +1453,19 @@ def test_get_studentview_started_timed_exam(self): @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), True), ) @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. """ # 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) + if hide_after_due: + update_exam(exam_id, hide_after_due=hide_after_due) # now create the timed_exam attempt in the submitted state self._create_exam_attempt(exam_id, status='submitted') @@ -1461,10 +1481,14 @@ def test_get_studentview_submitted_timed_exam_with_past_due_date(self, 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) + 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: - self.assertIsNone(None) + self.assertIsNone(rendered_response) def test_proctored_exam_attempt_with_past_due_datetime(self): """ @@ -1925,7 +1949,8 @@ def test_get_studentview_unstarted_timed_exam(self): context={ 'is_proctored': False, 'display_name': self.exam_name, - 'default_time_limit_mins': 90 + 'default_time_limit_mins': 90, + 'hide_after_due': False, } ) self.assertNotIn( diff --git a/edx_proctoring/tests/test_serializer.py b/edx_proctoring/tests/test_serializer.py index ee375bddf4d..edbdad15d21 100644 --- a/edx_proctoring/tests/test_serializer.py +++ b/edx_proctoring/tests/test_serializer.py @@ -23,7 +23,8 @@ def test_boolean_fields(self): 'external_id': '123', 'is_proctored': 'bla', 'is_practice_exam': 'bla', - 'is_active': 'f' + 'is_active': 'f', + 'hide_after_due': 't', } serializer = ProctoredExamSerializer(data=data) diff --git a/edx_proctoring/tests/test_views.py b/edx_proctoring/tests/test_views.py index 11312295030..8c9ff748590 100644 --- a/edx_proctoring/tests/test_views.py +++ b/edx_proctoring/tests/test_views.py @@ -101,7 +101,8 @@ def test_create_exam(self): 'external_id': '123', 'is_proctored': True, 'is_practice_exam': False, - 'is_active': True + 'is_active': True, + 'hide_after_due': False, } response = self.client.post( reverse('edx_proctoring.proctored_exam.exam'), @@ -136,7 +137,8 @@ def test_create_duplicate_exam(self): 'external_id': '123', 'is_proctored': True, 'is_practice_exam': False, - 'is_active': True + 'is_active': True, + 'hide_after_due': False, } response = self.client.post( reverse('edx_proctoring.proctored_exam.exam'), diff --git a/edx_proctoring/views.py b/edx_proctoring/views.py index b9a6737312e..739d82df018 100644 --- a/edx_proctoring/views.py +++ b/edx_proctoring/views.py @@ -189,7 +189,8 @@ def post(self, request): is_proctored=request.data.get('is_proctored', None), is_practice_exam=request.data.get('is_practice_exam', 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}) else: @@ -213,6 +214,7 @@ def put(self, request): is_practice_exam=request.data.get('is_practice_exam', None), external_id=request.data.get('external_id', 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}) except ProctoredExamNotFoundException, ex: diff --git a/setup.py b/setup.py index c3f48b4644e..642ad6640a1 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ def load_requirements(*requirements_paths): setup( name='edx-proctoring', - version='0.12.15', + version='0.12.16', description='Proctoring subsystem for Open edX', long_description=open('README.md').read(), author='edX',