Skip to content

Commit

Permalink
fix: exam content will not be viewable if due date has passed
Browse files Browse the repository at this point in the history
Previously, if a learner had submitted their proctored exam and if the
exam due date had passed, they were able to acknowledge their status
and view the exam content. This is no longer wanted and poses an integrity
threat. Students will not be able to acknowledge their status after this change,
and exam content will only be viewable if the django setting PROCTORED_EXAM_VIEWABLE_PAST_DUE
is set to True.
  • Loading branch information
alangsto committed Jun 7, 2021
1 parent b1a6f23 commit da3e1e2
Show file tree
Hide file tree
Showing 10 changed files with 110 additions and 12 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ Change Log
Unreleased
~~~~~~~~~~

[3.13.0] - 2021-06-07
~~~~~~~~~~~~~~~~~~~~~
* If the Django setting `PROCTORED_EXAM_VIEWABLE_PAST_DUE` is false, exam content will not be viewable past
an exam's due date, even if a learner has acknowledged their status.

[3.12.0] - 2021-06-04
~~~~~~~~~~~~~~~~~~~~~
* If the `is_integrity_signature_enabled` waffle flag is turned on, do not render the ID verification
Expand Down
2 changes: 1 addition & 1 deletion edx_proctoring/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
"""

# Be sure to update the version number in edx_proctoring/package.json
__version__ = '3.12.0'
__version__ = '3.13.0'

default_app_config = 'edx_proctoring.apps.EdxProctoringConfig' # pylint: disable=invalid-name
19 changes: 10 additions & 9 deletions edx_proctoring/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2281,7 +2281,8 @@ def _get_proctored_exam_context(exam, attempt, user_id, course_id, is_practice_e
'integration_specific_email': get_integration_specific_email(provider),
'exam_display_name': exam['exam_name'],
'reset_link': password_url,
'ping_interval': provider.ping_interval
'ping_interval': provider.ping_interval,
'can_view_content_past_due': constants.CONTENT_VIEWABLE_PAST_DUE_DATE,
}
if attempt:
context['exam_code'] = attempt['attempt_code']
Expand Down Expand Up @@ -2507,32 +2508,32 @@ def _get_proctored_exam_view(exam, context, exam_id, user_id, course_id):
elif attempt_status == ProctoredExamStudentAttemptStatus.timed_out:
raise NotImplementedError('There is no defined rendering for ProctoredExamStudentAttemptStatus.timed_out!')
elif attempt_status == ProctoredExamStudentAttemptStatus.submitted:
student_view_template = None if _was_review_status_acknowledged(
student_view_template = None if (_was_review_status_acknowledged(
attempt['is_status_acknowledged'],
exam
) else 'proctored_exam/submitted.html'
) and constants.CONTENT_VIEWABLE_PAST_DUE_DATE) else 'proctored_exam/submitted.html'
elif attempt_status == ProctoredExamStudentAttemptStatus.second_review_required:
# the student should still see a 'submitted'
# rendering even if the review needs a 2nd review
student_view_template = None if _was_review_status_acknowledged(
student_view_template = None if (_was_review_status_acknowledged(
attempt['is_status_acknowledged'],
exam
) else 'proctored_exam/submitted.html'
) and constants.CONTENT_VIEWABLE_PAST_DUE_DATE) else 'proctored_exam/submitted.html'
elif attempt_status == ProctoredExamStudentAttemptStatus.verified:
student_view_template = None if _was_review_status_acknowledged(
student_view_template = None if (_was_review_status_acknowledged(
attempt['is_status_acknowledged'],
exam
) else 'proctored_exam/verified.html'
) and constants.CONTENT_VIEWABLE_PAST_DUE_DATE) else 'proctored_exam/verified.html'
has_context_updated = verify_and_add_wait_deadline(context, exam, user_id)
# The edge case where student has already acknowledged the result
# but the course team changed the grace period
if has_context_updated and not student_view_template:
student_view_template = 'proctored_exam/verified.html'
elif attempt_status == ProctoredExamStudentAttemptStatus.rejected:
student_view_template = None if _was_review_status_acknowledged(
student_view_template = None if (_was_review_status_acknowledged(
attempt['is_status_acknowledged'],
exam
) else 'proctored_exam/rejected.html'
) and constants.CONTENT_VIEWABLE_PAST_DUE_DATE) else 'proctored_exam/rejected.html'
elif attempt_status == ProctoredExamStudentAttemptStatus.ready_to_submit:
student_view_template = 'proctored_exam/ready_to_submit.html'
elif attempt_status in ProctoredExamStudentAttemptStatus.onboarding_errors:
Expand Down
2 changes: 2 additions & 0 deletions edx_proctoring/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,5 @@
PING_FAILURE_PASSTHROUGH_TEMPLATE = 'edx_proctoring.{}_ping_failure_passthrough'

ONBOARDING_PROFILE_API = 'edx_proctoring.onboarding_profile_api'

CONTENT_VIEWABLE_PAST_DUE_DATE = getattr(settings, 'PROCTORED_EXAM_VIEWABLE_PAST_DUE', False)
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{% load i18n %}
{% if has_due_date_passed %}
{% if has_due_date_passed and can_view_content_past_due %}
<hr>
<p>
{% blocktrans %}
Expand Down
59 changes: 59 additions & 0 deletions edx_proctoring/tests/test_student_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from freezegun import freeze_time
from mock import MagicMock, patch

from django.test.utils import override_settings
from django.urls import reverse

from edx_proctoring.api import (
Expand Down Expand Up @@ -81,6 +82,7 @@ def setUp(self):
self.timed_footer_msg = 'Can I request additional time to complete my exam?'
self.wait_deadline_msg = "The result will be visible after"
self.inactive_account_msg = "You have not activated your account"
self.review_exam_msg = "To view your exam questions and responses"

def _render_exam(self, content_id, context_overrides=None):
"""
Expand Down Expand Up @@ -682,6 +684,7 @@ def test_get_studentview_submitted_timed_exam_with_grace_period(self, is_proctor
else:
self.assertNotIn(self.wait_deadline_msg, rendered_response)

@patch("edx_proctoring.api.constants.CONTENT_VIEWABLE_PAST_DUE_DATE", True)
def test_get_studentview_acknowledged_proctored_exam_with_grace_period(self):
"""
Verify the student view for an acknowledge proctored exam with an active
Expand Down Expand Up @@ -861,6 +864,58 @@ def test_get_studentview_submitted_status(self):
rendered_response = self.render_proctored_exam()
self.assertIn(self.proctored_exam_submitted_msg, rendered_response)

@override_settings(PROCTORED_EXAM_VIEWABLE_PAST_DUE=False)
@ddt.data(
(ProctoredExamStudentAttemptStatus.submitted, True),
(ProctoredExamStudentAttemptStatus.submitted, False),
(ProctoredExamStudentAttemptStatus.second_review_required, True),
(ProctoredExamStudentAttemptStatus.second_review_required, False),
(ProctoredExamStudentAttemptStatus.rejected, True),
(ProctoredExamStudentAttemptStatus.rejected, False),
(ProctoredExamStudentAttemptStatus.verified, True),
(ProctoredExamStudentAttemptStatus.verified, False)
)
@ddt.unpack
def test_get_studentview_without_viewable_content(self, status, status_acknowledged):
"""
Test for get_student_view proctored exam which has been submitted
but exam content is not viewable if the due date has passed
"""
due_date = datetime.now(pytz.UTC) + timedelta(minutes=40)
exam_id = self._create_exam_with_due_time(
is_proctored=True, due_date=due_date
)

exam_attempt = ProctoredExamStudentAttempt.objects.create(
proctored_exam_id=exam_id,
user=self.user,
allowed_time_limit_mins=30,
taking_as_proctored=True,
external_id='fdage332',
status=status,
)

exam_attempt.is_status_acknowledged = status_acknowledged
exam_attempt.save()

# due date is after 10 minutes
reset_time = datetime.now(pytz.UTC) + timedelta(minutes=60)
with freeze_time(reset_time):
rendered_response = get_student_view(
user_id=self.user.id,
course_id=self.course_id,
content_id=self.content_id_for_exam_with_due_date,
context={
'is_proctored': True,
'display_name': 'Test Exam',
'default_time_limit_mins': 30,
'due_date': due_date
}
)
self.assertIsNotNone(rendered_response)
self.assertNotIn(self.review_exam_msg, rendered_response)

@patch("edx_proctoring.api.constants.CONTENT_VIEWABLE_PAST_DUE_DATE", True)
@ddt.data(
60,
20,
Expand Down Expand Up @@ -906,6 +961,9 @@ def test_get_studentview_submitted_status_with_duedate_status_acknowledged(self,
}
)
self.assertIn(self.proctored_exam_submitted_msg, rendered_response)
if due_date_passed:
self.assertIn(self.review_exam_msg, rendered_response)

exam_attempt.is_status_acknowledged = True
exam_attempt.save()

Expand All @@ -925,6 +983,7 @@ def test_get_studentview_submitted_status_with_duedate_status_acknowledged(self,
else:
self.assertIsNotNone(rendered_response)

@patch("edx_proctoring.api.constants.CONTENT_VIEWABLE_PAST_DUE_DATE", True)
@patch('edx_when.api.get_date_for_block')
def test_get_studentview_submitted_personalize_scheduled_duedate_status_acknowledged(self, get_date_for_block_mock):
"""
Expand Down
22 changes: 22 additions & 0 deletions edx_proctoring/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from django.contrib.auth import get_user_model
from django.test.client import Client
from django.test.utils import override_settings
from django.urls import NoReverseMatch, reverse
from django.utils import timezone

Expand Down Expand Up @@ -3541,6 +3542,25 @@ def _create_proctored_exam_attempt_with_duedate(self, due_date=datetime.now(pytz
status=ProctoredExamStudentAttemptStatus.started
)

def test_attempt_review_status_callback_non_reviewable(self):
"""
Test the ProctoredExamAttemptReviewStatus view
"""
attempt = self._create_proctored_exam_attempt_with_duedate(
due_date=datetime.now(pytz.UTC) + timedelta(minutes=40)
)

response = self.client.put(
reverse(
'edx_proctoring:proctored_exam.attempt.review_status',
args=[attempt.id]
),
{},
content_type='application/json'
)
self.assertEqual(response.status_code, 404)

@override_settings(PROCTORED_EXAM_VIEWABLE_PAST_DUE=True)
def test_attempt_review_status_callback(self):
"""
Test the ProctoredExamAttemptReviewStatus view
Expand All @@ -3559,6 +3579,7 @@ def test_attempt_review_status_callback(self):
)
self.assertEqual(response.status_code, 200)

@override_settings(PROCTORED_EXAM_VIEWABLE_PAST_DUE=True)
def test_attempt_review_status_callback_with_doesnotexit_exception(self):
"""
Test the ProctoredExamAttemptReviewStatus view with does not exit exception
Expand All @@ -3578,6 +3599,7 @@ def test_attempt_review_status_callback_with_doesnotexit_exception(self):
self.assertEqual(response.status_code, 400)
self.assertRaises(StudentExamAttemptDoesNotExistsException)

@override_settings(PROCTORED_EXAM_VIEWABLE_PAST_DUE=True)
def test_attempt_review_status_callback_with_permission_exception(self):
"""
Test the ProctoredExamAttemptReviewStatus view with permission exception
Expand Down
7 changes: 7 additions & 0 deletions edx_proctoring/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from rest_framework.response import Response
from rest_framework.views import APIView

from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import Paginator
Expand Down Expand Up @@ -1360,6 +1361,12 @@ def put(self, request, attempt_id): # pylint: disable=unused-argument
"""
Update the is_status_acknowledged flag for the specific attempt
"""
if not getattr(settings, 'PROCTORED_EXAM_VIEWABLE_PAST_DUE', False):
return Response(
status=404,
data={'detail': _('Cannot update attempt review status')}
)

attempt = get_exam_attempt_by_id(attempt_id)

# make sure the the attempt belongs to the calling user_id
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@edx/edx-proctoring",
"//": "Be sure to update the version number in edx_proctoring/__init__.py",
"//": "Note that the version format is slightly different than that of the Python version when using prereleases.",
"version": "3.12.0",
"version": "3.13.0",
"main": "edx_proctoring/static/index.js",
"scripts": {
"test": "gulp test"
Expand Down
2 changes: 2 additions & 0 deletions test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@
CONTACT_EMAIL = 'info@edx.org'
TECH_SUPPORT_EMAIL = 'technical@example.com'

PROCTORED_EXAM_VIEWABLE_PAST_DUE = False

########## TEMPLATE CONFIGURATION
TEMPLATES = [{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
Expand Down

0 comments on commit da3e1e2

Please sign in to comment.