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',