diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 1729bf51de1..91d640f6b21 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,10 @@ Change Log Unreleased ~~~~~~~~~~ +[4.2.0] - 2021-10-20 +~~~~~~~~~~~~~~~~~~~~ +* Timed exams should remain visible after the course end date has passed + [4.1.3] - 2021-10-15 ~~~~~~~~~~~~~~~~~~~~ * Always allow practice attempts to trigger grade/credit/certificate updates diff --git a/edx_proctoring/__init__.py b/edx_proctoring/__init__.py index 014b071b8d2..d073c2fb631 100644 --- a/edx_proctoring/__init__.py +++ b/edx_proctoring/__init__.py @@ -3,6 +3,6 @@ """ # Be sure to update the version number in edx_proctoring/package.json -__version__ = '4.1.3' +__version__ = '4.2.0' default_app_config = 'edx_proctoring.apps.EdxProctoringConfig' # pylint: disable=invalid-name diff --git a/edx_proctoring/api.py b/edx_proctoring/api.py index 5d149877ebf..841ece7fa1f 100644 --- a/edx_proctoring/api.py +++ b/edx_proctoring/api.py @@ -67,6 +67,7 @@ get_time_remaining_for_attempt, get_user_course_outline_details, has_due_date_passed, + has_end_date_passed, humanized_time, is_reattempting_exam, obscured_user_id, @@ -881,7 +882,13 @@ def is_exam_passed_due(exam, user=None): Return whether the due date has passed. Uses edx_when to lookup the date for the subsection. """ - return has_due_date_passed(get_exam_due_date(exam, user=user)) + return ( + has_due_date_passed(get_exam_due_date(exam, user=user)) + # if the exam is timed and passed the course end date, it should also be considered passed due + or ( + not exam['is_proctored'] and not exam['is_practice_exam'] and has_end_date_passed(exam['course_id']) + ) + ) def _was_review_status_acknowledged(is_status_acknowledged, exam): diff --git a/edx_proctoring/management/commands/tests/test_set_is_attempt_active.py b/edx_proctoring/management/commands/tests/test_set_is_attempt_active.py index 92815953372..2944e6b97ab 100644 --- a/edx_proctoring/management/commands/tests/test_set_is_attempt_active.py +++ b/edx_proctoring/management/commands/tests/test_set_is_attempt_active.py @@ -25,7 +25,7 @@ def setUp(self): set_runtime_service('grades', MockGradesService()) set_runtime_service('certificates', MockCertificateService()) self.exam_id = create_exam( - course_id='foo', + course_id='a/b/c', content_id='bar', exam_name='Test Exam', time_limit_mins=90 diff --git a/edx_proctoring/management/commands/tests/test_update_attempt_status_from_review.py b/edx_proctoring/management/commands/tests/test_update_attempt_status_from_review.py index 43cc9131694..baf223fe558 100644 --- a/edx_proctoring/management/commands/tests/test_update_attempt_status_from_review.py +++ b/edx_proctoring/management/commands/tests/test_update_attempt_status_from_review.py @@ -29,13 +29,13 @@ def setUp(self): set_runtime_service('grades', MockGradesService()) set_runtime_service('certificates', MockCertificateService()) self.first_exam_id = create_exam( - course_id='foo', + course_id='a/b/c', content_id='bar', exam_name='Test Exam 1', time_limit_mins=90 ) self.second_exam_id = create_exam( - course_id='foo', + course_id='a/b/c', content_id='baz', exam_name='Test Exam 2', time_limit_mins=90 diff --git a/edx_proctoring/management/commands/tests/test_update_attempts_for_exam.py b/edx_proctoring/management/commands/tests/test_update_attempts_for_exam.py index 73460112bfa..b578de9745c 100644 --- a/edx_proctoring/management/commands/tests/test_update_attempts_for_exam.py +++ b/edx_proctoring/management/commands/tests/test_update_attempts_for_exam.py @@ -36,7 +36,7 @@ def test_run_command(self): Run the management command """ exam_id = create_exam( - course_id='foo', + course_id='a/b/c', content_id='bar', exam_name='Test Exam 1', time_limit_mins=90 diff --git a/edx_proctoring/tests/test_student_view.py b/edx_proctoring/tests/test_student_view.py index ca901bfda34..f00cb52bea9 100644 --- a/edx_proctoring/tests/test_student_view.py +++ b/edx_proctoring/tests/test_student_view.py @@ -626,6 +626,83 @@ def test_get_studentview_submitted_timed_exam_with_past_due_date(self, due_date, else: self.assertIsNone(rendered_response) + @ddt.data( + datetime(2019, 4, 4).replace(tzinfo=pytz.UTC), + datetime.now(pytz.UTC) + timedelta(days=2), + datetime(9999, 4, 4).replace(tzinfo=pytz.UTC), + 'bad' + ) + @patch('edx_when.api.get_dates_for_course') + def test_get_studentview_submitted_timed_exam_past_course_end(self, end_date, mock_course_dates): + """ + Test get_student_view timed exam returns None if we are passed the course end date + This is to fix inconsistent behavior for timed exam visibility in self paced courses, + where timed exams would be visible after a self paced due date, but not visible after + the course end date. Timed exams in instructor-paced courses are always visible after + the exam due date, and therefore after the course end date. + """ + dates = [ + (('11111111', 'start'), datetime(2019, 3, 15).replace(tzinfo=pytz.UTC)), + (('22222222', 'due'), datetime(2019, 3, 28).replace(tzinfo=pytz.UTC)), + (('33333333', 'end'), end_date), + ] + mock_course_dates.return_value = dict(dates) + + self._create_exam_attempt(self.timed_exam_id, status='submitted') + + rendered_response = get_student_view( + user_id=self.user_id, + course_id=self.course_id, + content_id=self.content_id_timed, + context={ + 'is_proctored': False, + 'display_name': self.exam_name, + 'default_time_limit_mins': 10, + } + ) + + if not isinstance(end_date, str) and end_date < datetime.now(pytz.UTC): + self.assertIsNone(rendered_response) + else: + self.assertIn(self.timed_exam_submitted, rendered_response) + + @ddt.data( + True, + False + ) + @patch('edx_when.api.get_dates_for_course') + def test_get_studentview_submitted_timed_exam_past_course_end_no_date(self, include_end, mock_course_dates): + """ + Test get_student_view timed exam returns blocks learners if no end date is specified + This is to fix inconsistent behavior for timed exam visibility in self paced courses, + where timed exams would be visible after a self paced due date, but not visible after + the course end date. Timed exams in instructor-paced courses are always visible after + the exam due date, and therefore after the course end date. + """ + dates = [ + (('11111111', 'start'), datetime(2019, 3, 15).replace(tzinfo=pytz.UTC)), + (('22222222', 'due'), datetime(2019, 3, 28).replace(tzinfo=pytz.UTC)), + ] + if include_end: + dates.append((('33333333', 'end'), None)) + + mock_course_dates.return_value = dict(dates) + + self._create_exam_attempt(self.timed_exam_id, status='submitted') + + rendered_response = get_student_view( + user_id=self.user_id, + course_id=self.course_id, + content_id=self.content_id_timed, + context={ + 'is_proctored': False, + 'display_name': self.exam_name, + 'default_time_limit_mins': 10, + } + ) + + self.assertIn(self.timed_exam_submitted, rendered_response) + @ddt.data( (False, 'submitted', True, 1), (True, 'verified', False, 1), @@ -1291,7 +1368,7 @@ def test_get_studentview_unstarted_timed_exam(self): """ rendered_response = get_student_view( user_id=self.user_id, - course_id="abc", + course_id='d/e/f', content_id=self.content_id, context={ 'is_proctored': False, diff --git a/edx_proctoring/utils.py b/edx_proctoring/utils.py index 39b99333da8..085f262c57d 100644 --- a/edx_proctoring/utils.py +++ b/edx_proctoring/utils.py @@ -6,7 +6,7 @@ import hashlib import hmac import logging -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import pytz from cryptography.hazmat.backends import default_backend @@ -244,12 +244,33 @@ def has_due_date_passed(due_datetime): Return True if due date is lesser than current datetime, otherwise False and if due_datetime is None then we don't have to consider the due date for return False """ - if due_datetime: return due_datetime <= datetime.now(pytz.UTC) return False +def get_course_end_date(course_id): + """ + Return the end date for the given course id + """ + end_date = None + dates = when_api.get_dates_for_course(course_id) + end_dates = list(filter(lambda elem: elem[0][1] == 'end', dates.items())) + if end_dates and end_dates[0][1]: + try: + end_date = end_dates[0][1].replace(tzinfo=timezone.utc) + except (AttributeError, TypeError): + log.error('Could not retrieve course end date for course_id=%(course_id)s', {'course_id': course_id}) + return end_date + + +def has_end_date_passed(course_id): + """ + Return True if the course end date has passed, otherwise False + """ + return has_due_date_passed(get_course_end_date(course_id)) + + def get_exam_due_date(exam, user=None): """ Return the due date for the exam. diff --git a/package.json b/package.json index 6f98b4a5476..1ba4012f72c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@edx/edx-proctoring", "//": "Note that the version format is slightly different than that of the Python version when using prereleases.", - "version": "4.1.3", + "version": "4.2.0", "main": "edx_proctoring/static/index.js", "scripts": { "test": "gulp test"