Skip to content

Commit

Permalink
Add ability to hide exam after due date
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Eric Fischer committed Apr 21, 2016
1 parent fcd3a1d commit ff1b3d1
Show file tree
Hide file tree
Showing 10 changed files with 85 additions and 26 deletions.
27 changes: 16 additions & 11 deletions edx_proctoring/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down
19 changes: 19 additions & 0 deletions edx_proctoring/migrations/0005_proctoredexam_hide_after_due.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
3 changes: 3 additions & 0 deletions edx_proctoring/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),)
Expand Down
4 changes: 3 additions & 1 deletion edx_proctoring/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand All @@ -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"
)


Expand Down
2 changes: 1 addition & 1 deletion edx_proctoring/templates/timed_exam/submitted.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ <h3>
{% blocktrans %}
Your grade for this timed exam will be immediately available on the <a href="{{progress_page_url}}">Progress</a> 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 %}
Expand Down
41 changes: 33 additions & 8 deletions edx_proctoring/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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'
)
Expand All @@ -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'
)
Expand Down Expand Up @@ -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'
)
Expand Down Expand Up @@ -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')
Expand All @@ -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):
"""
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion edx_proctoring/tests/test_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
6 changes: 4 additions & 2 deletions edx_proctoring/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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'),
Expand Down
4 changes: 3 additions & 1 deletion edx_proctoring/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down

0 comments on commit ff1b3d1

Please sign in to comment.