From e91aa7fb3440bd3022ddd8babcf6c491aa138769 Mon Sep 17 00:00:00 2001 From: Bianca Severino Date: Wed, 24 Feb 2021 10:23:23 -0500 Subject: [PATCH] Update learner onboarding status view to consider attempts from other courses --- CHANGELOG.rst | 4 + edx_proctoring/__init__.py | 2 +- edx_proctoring/models.py | 26 +++- .../js/views/proctored_exam_info.js | 41 +++++- .../spec/proctored_exam_info_spec.js | 68 ++++++++- edx_proctoring/statuses.py | 4 + edx_proctoring/tests/test_views.py | 138 ++++++++++-------- edx_proctoring/tests/utils.py | 31 +--- edx_proctoring/views.py | 40 +++-- package.json | 2 +- 10 files changed, 239 insertions(+), 117 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b55e9b72582..62171be857c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,10 @@ Change Log Unreleased ~~~~~~~~~~ +[3.7.0] - 2021-03-01 +~~~~~~~~~~~~~~~~~~~~ +* Update the learner onboarding status view to consider verified attempts from other courses. + [3.6.7] - 2021-02-24 ~~~~~~~~~~~~~~~~~~~~ * Fix requirements file diff --git a/edx_proctoring/__init__.py b/edx_proctoring/__init__.py index 8f0c4999e42..1f1962f8d90 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__ = '3.6.7' +__version__ = '3.7.0' default_app_config = 'edx_proctoring.apps.EdxProctoringConfig' # pylint: disable=invalid-name diff --git a/edx_proctoring/models.py b/edx_proctoring/models.py index 61743332993..89edcbbdd3c 100644 --- a/edx_proctoring/models.py +++ b/edx_proctoring/models.py @@ -6,6 +6,9 @@ # pylint: disable=model-missing-unicode +from datetime import datetime, timedelta + +import pytz from model_utils.models import TimeStampedModel from django.contrib.auth import get_user_model @@ -272,9 +275,10 @@ def get_all_exam_attempts_by_exam_id(self, exam_id): """ return self.filter(proctored_exam_id=exam_id) - def get_onboarding_attempts_by_course_id(self, course_id, users=None): + # pylint: disable=invalid-name + def get_proctored_practice_attempts_by_course_id(self, course_id, users=None): """ - Returns all onboarding attempts for a course, ordered by descending modified field. + Returns all proctored practice attempts for a course, ordered by descending modified field. Parameters: * course_id: ID of the course @@ -289,6 +293,24 @@ def get_onboarding_attempts_by_course_id(self, course_id, users=None): queryset = queryset.filter(user__in=users) return queryset + # pylint: disable=invalid-name + def get_last_verified_proctored_practice_attempt(self, user_id, proctoring_backend): + """ + Returns the user's last verified proctored practice attempt for a specific backend, + if it exists. This only considers attempts within the last two years, as attempts + before this point are considered expired. + + Parameters: + * user_id: ID of the user + * proctoring_backend: The name of the proctoring backend + """ + earliest_allowed_date = datetime.now(pytz.UTC) - timedelta(days=730) + return self.filter( + user_id=user_id, taking_as_proctored=True, proctored_exam__is_practice_exam=True, + proctored_exam__backend=proctoring_backend, modified__gt=earliest_allowed_date, + status=ProctoredExamStudentAttemptStatus.verified + ).order_by('-modified').first() + def get_filtered_exam_attempts(self, course_id, search_by): """ Returns the Student Exam Attempts for the given course_id filtered by search_by. diff --git a/edx_proctoring/static/proctoring/js/views/proctored_exam_info.js b/edx_proctoring/static/proctoring/js/views/proctored_exam_info.js index 48a44460e85..f724e8ed6dd 100644 --- a/edx_proctoring/static/proctoring/js/views/proctored_exam_info.js +++ b/edx_proctoring/static/proctoring/js/views/proctored_exam_info.js @@ -38,6 +38,26 @@ error: { status: gettext('Error'), message: gettext('An error has occurred during your onboarding exam. Please retry onboarding.') + }, + other_course_approved: { + status: gettext('Approved in Another Course'), + message: gettext( + 'Your onboarding profile has been approved in another course, ' + + 'so you are eligible to take proctored exams in this course. ' + + 'However, it is highly recommended that you complete this ' + + 'course\'s onboarding exam in order to ensure that your device ' + + 'still meets the requirements for proctoring.' + ) + }, + expiring_soon: { + status: gettext('Expiring Soon'), + message: gettext( + 'Your onboarding profile has been approved in another course, ' + + 'so you are eligible to take proctored exams in this course. ' + + 'However, your onboarding status is expiring soon. Please ' + + 'complete onboarding again to ensure that you will be ' + + 'able to continue taking proctored exams.' + ) } }; @@ -54,9 +74,9 @@ updateCss: function() { var $el = $(this.el); var color = '#b20610'; - if (this.status === 'verified') { + if (['verified', 'other_course_approved'].includes(this.status)) { color = '#008100'; - } else if (['submitted', 'second_review_required'].includes(this.status)) { + } else if (['submitted', 'second_review_required', 'expiring_soon'].includes(this.status)) { color = '#0d4e6c'; } @@ -104,6 +124,13 @@ } }, + isExpiringSoon: function(expirationDate) { + var today = new Date(); + var expirationDateObject = new Date(expirationDate); + // Return true if the expiration date is within 28 days + return today.getTime() > expirationDateObject.getTime() - 2419200000; + }, + shouldShowExamLink: function(status) { // show the exam link if the user should retry onboarding, or if they haven't submitted the exam var NO_SHOW_STATES = ['submitted', 'second_review_required', 'verified']; @@ -114,12 +141,16 @@ var statusText = {}; var data = this.model.toJSON(); if (this.template) { - this.status = data.onboarding_status; - statusText = this.getExamAttemptText(data.onboarding_status); + if (data.expiration_date && this.isExpiringSoon(data.expiration_date)) { + this.status = 'expiring_soon'; + } else { + this.status = data.onboarding_status; + } + statusText = this.getExamAttemptText(this.status); data = { onboardingStatus: statusText.status, onboardingMessage: statusText.message, - showOnboardingReminder: data.onboarding_status !== 'verified', + showOnboardingReminder: !['verified', 'other_course_approved'].includes(data.onboarding_status), showOnboardingExamLink: this.shouldShowExamLink(data.onboarding_status), onboardingLink: data.onboarding_link }; diff --git a/edx_proctoring/static/proctoring/spec/proctored_exam_info_spec.js b/edx_proctoring/static/proctoring/spec/proctored_exam_info_spec.js index 94ee3231655..5731341ff1a 100644 --- a/edx_proctoring/static/proctoring/spec/proctored_exam_info_spec.js +++ b/edx_proctoring/static/proctoring/spec/proctored_exam_info_spec.js @@ -12,7 +12,18 @@ describe('ProctoredExamInfo', function() { return ( { onboarding_status: status, - onboarding_link: 'onboarding_link' + onboarding_link: 'onboarding_link', + expiration_date: null + } + ); + } + + function expectedOtherCourseApprovedJson(expirationDate) { + return ( + { + onboarding_status: 'other_course_approved', + onboarding_link: 'onboarding_link', + expiration_date: expirationDate } ); } @@ -337,4 +348,59 @@ describe('ProctoredExamInfo', function() { expect(this.proctored_exam_info.$el.find('.action-onboarding').html()) .toContain('Complete Onboarding'); }); + + it('should render proctoring info panel correctly for other course approved', function() { + var expirationDate = new Date(); + // Set the expiration date 50 days in the future + expirationDate.setTime(expirationDate.getTime() + 3456900000); + this.server.respondWith('GET', '/api/edx_proctoring/v1/user_onboarding/status?course_id=test_course_id', + [ + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify(expectedOtherCourseApprovedJson(expirationDate.toString())) + ] + ); + this.proctored_exam_info = new edx.courseware.proctored_exam.ProctoredExamInfo({ + el: $('.proctoring-info-panel'), + model: new LearnerOnboardingModel() + }); + this.server.respond(); + this.server.respond(); + expect(this.proctored_exam_info.$el.find('.onboarding-status').html()) + .toContain('Approved in Another Course'); + expect(this.proctored_exam_info.$el.find('.onboarding-status-message').html()) + .toContain('it is highly recommended that you complete this course\'s onboarding exam'); + expect(this.proctored_exam_info.$el.find('.action-onboarding').html()) + .toContain('Complete Onboarding'); + }); + + it('should render proctoring info panel when expiring soon', function() { + var expirationDate = new Date(); + // This message will render if the expiration date is within 28 days + // Set the expiration date 10 days in future + expirationDate.setTime(expirationDate.getTime() + 864800000); + this.server.respondWith('GET', '/api/edx_proctoring/v1/user_onboarding/status?course_id=test_course_id', + [ + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify(expectedOtherCourseApprovedJson(expirationDate.toString())) + ] + ); + this.proctored_exam_info = new edx.courseware.proctored_exam.ProctoredExamInfo({ + el: $('.proctoring-info-panel'), + model: new LearnerOnboardingModel() + }); + this.server.respond(); + this.server.respond(); + expect(this.proctored_exam_info.$el.find('.onboarding-status').html()) + .toContain('Expiring Soon'); + expect(this.proctored_exam_info.$el.find('.onboarding-status-message').html()) + .toContain('However, your onboarding status is expiring soon.'); + expect(this.proctored_exam_info.$el.find('.action-onboarding').html()) + .toContain('Complete Onboarding'); + }); }); diff --git a/edx_proctoring/statuses.py b/edx_proctoring/statuses.py index 1da80e634c1..d327850bf0c 100644 --- a/edx_proctoring/statuses.py +++ b/edx_proctoring/statuses.py @@ -252,6 +252,10 @@ class InstructorDashboardOnboardingAttemptStatus: verified = 'verified' error = 'error' + # The following status is not a true attempt status, but is used when the + # user's onboarding profile is approved in a different course. + other_course_approved = 'other_course_approved' + onboarding_statuses = { ProctoredExamStudentAttemptStatus.created: setup_started, ProctoredExamStudentAttemptStatus.download_software_clicked: setup_started, diff --git a/edx_proctoring/tests/test_views.py b/edx_proctoring/tests/test_views.py index 356f6188414..87d53cde1e6 100644 --- a/edx_proctoring/tests/test_views.py +++ b/edx_proctoring/tests/test_views.py @@ -56,7 +56,7 @@ MockGradesService, MockInstructorService ) -from .utils import LoggedInTestCase, create_onboarding_exam +from .utils import LoggedInTestCase, ProctoredExamTestCase User = get_user_model() @@ -425,7 +425,7 @@ def test_get_exam_insufficient_args(self): self.assertEqual(response_data['time_limit_mins'], proctored_exam.time_limit_mins) -class TestStudentOnboardingStatusView(LoggedInTestCase): +class TestStudentOnboardingStatusView(ProctoredExamTestCase): """ Tests for StudentOnboardingStatusView """ @@ -434,6 +434,7 @@ def setUp(self): set_runtime_service('credit', MockCreditService()) set_runtime_service('instructor', MockInstructorService(is_user_course_staff=False)) self.other_user = User.objects.create(username='otheruser', password='test') + self.onboarding_exam = ProctoredExam.objects.get(id=self.onboarding_exam_id) def test_no_course_id(self): """ @@ -449,17 +450,16 @@ def test_no_username(self): """ Test that a request without a username returns the user's own onboarding status """ - onboarding_exam = create_onboarding_exam() # Create the user's own attempt - attempt_id = create_exam_attempt(onboarding_exam.id, self.user.id, True) + attempt_id = create_exam_attempt(self.onboarding_exam_id, self.user.id, True) update_attempt_status(attempt_id, ProctoredExamStudentAttemptStatus.submitted) # Create another user's attempt - other_attempt_id = create_exam_attempt(onboarding_exam.id, self.other_user.id, True) + other_attempt_id = create_exam_attempt(self.onboarding_exam_id, self.other_user.id, True) update_attempt_status(other_attempt_id, ProctoredExamStudentAttemptStatus.verified) # Assert that the onboarding status returned is 'submitted' response = self.client.get( reverse('edx_proctoring:user_onboarding.status') - + '?course_id={}'.format(onboarding_exam.course_id) + + '?course_id={}'.format(self.onboarding_exam.course_id) ) self.assertEqual(response.status_code, 200) response_data = json.loads(response.content.decode('utf-8')) @@ -469,10 +469,9 @@ def test_unauthorized(self): """ Test that non-staff cannot view other users' onboarding status """ - onboarding_exam = create_onboarding_exam() response = self.client.get( reverse('edx_proctoring:user_onboarding.status') - + '?username={}&course_id={}'.format(self.other_user.username, onboarding_exam.course_id) + + '?username={}&course_id={}'.format(self.other_user.username, self.course_id) ) self.assertEqual(response.status_code, 403) response_data = json.loads(response.content.decode('utf-8')) @@ -485,10 +484,9 @@ def test_staff_authorization(self): """ self.user.is_staff = True self.user.save() - onboarding_exam = create_onboarding_exam() response = self.client.get( reverse('edx_proctoring:user_onboarding.status') - + '?username={}&course_id={}'.format(self.other_user.username, onboarding_exam.course_id) + + '?username={}&course_id={}'.format(self.other_user.username, self.course_id) ) self.assertEqual(response.status_code, 200) # Should also work for course staff @@ -497,7 +495,7 @@ def test_staff_authorization(self): self.user.save() response = self.client.get( reverse('edx_proctoring:user_onboarding.status') - + '?username={}&course_id={}'.format(self.other_user.username, onboarding_exam.course_id) + + '?username={}&course_id={}'.format(self.other_user.username, self.course_id) ) self.assertEqual(response.status_code, 200) @@ -518,120 +516,129 @@ def test_no_exam_attempts(self): """ Test that the onboarding status is None if there are no exam attempts """ - onboarding_exam = create_onboarding_exam() response = self.client.get( reverse('edx_proctoring:user_onboarding.status') - + '?course_id={}'.format(onboarding_exam.course_id) + + '?course_id={}'.format(self.course_id) ) self.assertEqual(response.status_code, 200) response_data = json.loads(response.content.decode('utf-8')) self.assertIsNone(response_data['onboarding_status']) self.assertEqual(response_data['onboarding_link'], reverse( 'jump_to', - args=[onboarding_exam.course_id, onboarding_exam.content_id] + args=[self.course_id, self.onboarding_exam.content_id] )) def test_no_verified_attempts(self): """ Test that if there are no verified attempts, the most recent status is returned """ - onboarding_exam = create_onboarding_exam() # Create first attempt - attempt_id = create_exam_attempt(onboarding_exam.id, self.user.id, True) + attempt_id = create_exam_attempt(self.onboarding_exam_id, self.user_id, True) update_attempt_status(attempt_id, ProctoredExamStudentAttemptStatus.submitted) response = self.client.get( reverse('edx_proctoring:user_onboarding.status') - + '?course_id={}'.format(onboarding_exam.course_id) + + '?course_id={}'.format(self.course_id) ) self.assertEqual(response.status_code, 200) response_data = json.loads(response.content.decode('utf-8')) self.assertEqual(response_data['onboarding_status'], ProctoredExamStudentAttemptStatus.submitted) self.assertEqual(response_data['onboarding_link'], reverse( 'jump_to', - args=[onboarding_exam.course_id, onboarding_exam.content_id] + args=[self.course_id, self.onboarding_exam.content_id] )) # Create second attempt and assert that most recent attempt is returned - create_exam_attempt(onboarding_exam.id, self.user.id, True) + create_exam_attempt(self.onboarding_exam_id, self.user_id, True) response = self.client.get( reverse('edx_proctoring:user_onboarding.status') - + '?course_id={}'.format(onboarding_exam.course_id) + + '?course_id={}'.format(self.course_id) ) self.assertEqual(response.status_code, 200) response_data = json.loads(response.content.decode('utf-8')) self.assertEqual(response_data['onboarding_status'], ProctoredExamStudentAttemptStatus.created) self.assertEqual(response_data['onboarding_link'], reverse( 'jump_to', - args=[onboarding_exam.course_id, onboarding_exam.content_id] + args=[self.course_id, self.onboarding_exam.content_id] )) def test_get_verified_attempt(self): """ Test that if there is at least one verified attempt, the status returned is always verified """ - onboarding_exam = create_onboarding_exam() # Create first attempt - attempt_id = create_exam_attempt(onboarding_exam.id, self.user.id, True) + attempt_id = create_exam_attempt(self.onboarding_exam_id, self.user_id, True) update_attempt_status(attempt_id, ProctoredExamStudentAttemptStatus.verified) response = self.client.get( reverse('edx_proctoring:user_onboarding.status') - + '?course_id={}'.format(onboarding_exam.course_id) + + '?course_id={}'.format(self.course_id) ) self.assertEqual(response.status_code, 200) response_data = json.loads(response.content.decode('utf-8')) self.assertEqual(response_data['onboarding_status'], ProctoredExamStudentAttemptStatus.verified) self.assertEqual(response_data['onboarding_link'], reverse( 'jump_to', - args=[onboarding_exam.course_id, onboarding_exam.content_id] + args=[self.course_id, self.onboarding_exam.content_id] )) # Create second attempt and assert that verified attempt is still returned - create_exam_attempt(onboarding_exam.id, self.user.id, True) + create_exam_attempt(self.onboarding_exam_id, self.user_id, True) response = self.client.get( reverse('edx_proctoring:user_onboarding.status') - + '?course_id={}'.format(onboarding_exam.course_id) + + '?course_id={}'.format(self.course_id) ) self.assertEqual(response.status_code, 200) response_data = json.loads(response.content.decode('utf-8')) self.assertEqual(response_data['onboarding_status'], ProctoredExamStudentAttemptStatus.verified) self.assertEqual(response_data['onboarding_link'], reverse( 'jump_to', - args=[onboarding_exam.course_id, onboarding_exam.content_id] + args=[self.course_id, self.onboarding_exam.content_id] )) - def test_only_onboarding_exam(self): + def test_verified_in_another_course(self): """ - Test that only onboarding exam attempts are evaluated when requesting onboarding status + Test that, if there are no onboarding attempts in the current course, but there is at least + one verified attempt in another course, the status will return `other_course_approved` and + it will also return an `expiration_date` """ - # Create an onboarding exam, along with a practice exam and - # a proctored exam, all in the same course - onboarding_exam = create_onboarding_exam() - ProctoredExam.objects.create( - course_id='a/b/c', - content_id='practice_content', - exam_name='Practice Exam', - external_id='123aXqe4', + proctoring_backend = 'test' + other_course_id = 'x/y/z' + other_course_onboarding_exam = ProctoredExam.objects.create( + course_id=other_course_id, + content_id='test_content', + exam_name='Test Exam', + external_id='123aXqe3', time_limit_mins=90, is_active=True, + is_proctored=True, is_practice_exam=True, - backend='test', + backend=proctoring_backend ) - ProctoredExam.objects.create( - course_id='a/b/c', - content_id='proctored_content', - exam_name='Proctored Exam', - external_id='123aXqe5', - time_limit_mins=90, - is_active=True, - is_proctored=True, - backend='test', + # Create an attempt in the other course that has been verified + self._create_exam_attempt( + other_course_onboarding_exam.id, ProctoredExamStudentAttemptStatus.verified, True ) - # Assert that the onboarding exam link is returned + response = self.client.get( + reverse('edx_proctoring:user_onboarding.status') + + '?course_id={}'.format(self.course_id) + ) + self.assertEqual(response.status_code, 200) + response_data = json.loads(response.content.decode('utf-8')) + self.assertEqual(response_data['onboarding_status'], 'other_course_approved') + self.assertEqual(response_data['onboarding_link'], reverse( + 'jump_to', + args=[self.course_id, self.onboarding_exam.content_id] + )) + self.assertIsNotNone(response_data['expiration_date']) + + def test_only_onboarding_exam(self): + """ + Test that only onboarding exam attempts are evaluated when requesting onboarding status + """ response = self.client.get( reverse('edx_proctoring:user_onboarding.status') + '?course_id=a/b/c' ) self.assertEqual(response.status_code, 200) response_data = json.loads(response.content.decode('utf-8')) - onboarding_link = reverse('jump_to', args=['a/b/c', onboarding_exam.content_id]) + onboarding_link = reverse('jump_to', args=['a/b/c', self.onboarding_exam.content_id]) self.assertEqual(response_data['onboarding_link'], onboarding_link) def test_ignore_history_table(self): @@ -640,14 +647,12 @@ def test_ignore_history_table(self): """ self.user.is_staff = True self.user.save() - # Create an exam + attempt - onboarding_exam = create_onboarding_exam() # Verify the attempt and assert that the status returns correctly - attempt_id = create_exam_attempt(onboarding_exam.id, self.user.id, True) + attempt_id = create_exam_attempt(self.onboarding_exam_id, self.user_id, True) update_attempt_status(attempt_id, ProctoredExamStudentAttemptStatus.verified) response = self.client.get( reverse('edx_proctoring:user_onboarding.status') - + '?course_id={}'.format(onboarding_exam.course_id) + + '?course_id={}'.format(self.course_id) ) self.assertEqual(response.status_code, 200) response_data = json.loads(response.content.decode('utf-8')) @@ -659,7 +664,7 @@ def test_ignore_history_table(self): # Assert that the status has been cleared and is no longer verified response = self.client.get( reverse('edx_proctoring:user_onboarding.status') - + '?course_id={}'.format(onboarding_exam.course_id) + + '?course_id={}'.format(self.course_id) ) self.assertEqual(response.status_code, 200) response_data = json.loads(response.content.decode('utf-8')) @@ -669,11 +674,10 @@ def test_ineligible_for_onboarding_exam(self): """ Test that the request returns a 404 error if the user is not eligible for the onboarding exam """ - onboarding_exam = create_onboarding_exam() with mock_perm('edx_proctoring.can_take_proctored_exam'): response = self.client.get( reverse('edx_proctoring:user_onboarding.status') - + '?course_id={}'.format(onboarding_exam.course_id) + + '?course_id={}'.format(self.course_id) ) self.assertEqual(response.status_code, 404) response_data = json.loads(response.content.decode('utf-8')) @@ -682,7 +686,7 @@ def test_ineligible_for_onboarding_exam(self): @ddt.ddt -class TestStudentOnboardingStatusByCourseView(LoggedInTestCase): +class TestStudentOnboardingStatusByCourseView(ProctoredExamTestCase): """Tests for StudentOnboardingStatusByCourseView""" def setUp(self): super().setUp() @@ -712,7 +716,7 @@ def setUp(self): set_runtime_service('grades', MockGradesService()) set_runtime_service('instructor', MockInstructorService(is_user_course_staff=True)) - self.onboarding_exam = create_onboarding_exam() + self.onboarding_exam = ProctoredExam.objects.get(id=self.onboarding_exam_id) def test_no_onboarding_exams(self): self.onboarding_exam.delete() @@ -736,10 +740,18 @@ def test_backend_does_not_support_onboarding(self): test_backend.supports_onboarding = previous_value def test_multiple_onboarding_exams(self): - onboarding_exam_2 = create_onboarding_exam(content_id='test_content_2') + onboarding_exam_2_id = create_exam( + course_id=self.course_id, + content_id='test_content_2', + exam_name=self.exam_name, + time_limit_mins=self.default_time_limit, + is_practice_exam=True, + is_proctored=True, + backend='test', + ) create_exam_attempt(self.onboarding_exam.id, self.user.id, True) - onboarding_attempt_2_id = create_exam_attempt(onboarding_exam_2.id, self.user.id, True) + onboarding_attempt_2_id = create_exam_attempt(onboarding_exam_2_id, self.user.id, True) update_attempt_status(onboarding_attempt_2_id, ProctoredExamStudentAttemptStatus.submitted) # get serialized onboarding_attempt because modified time has changed @@ -747,7 +759,7 @@ def test_multiple_onboarding_exams(self): response = self.client.get(reverse( 'edx_proctoring:user_onboarding.status.course', - kwargs={'course_id': onboarding_exam_2.course_id} + kwargs={'course_id': self.course_id} ) ) response_data = json.loads(response.content.decode('utf-8')) @@ -781,7 +793,7 @@ def test_multiple_onboarding_exams(self): @patch('edx_proctoring.views.ATTEMPTS_PER_PAGE', 1) def test_basic_pagination(self): - create_exam_attempt(self.onboarding_exam.id, self.user.id, True) + create_exam_attempt(self.onboarding_exam.id, self.user_id, True) create_exam_attempt(self.onboarding_exam.id, self.learner_1.id, True) create_exam_attempt(self.onboarding_exam.id, self.learner_2.id, True) diff --git a/edx_proctoring/tests/utils.py b/edx_proctoring/tests/utils.py index bc1dc123d17..559555e2e14 100644 --- a/edx_proctoring/tests/utils.py +++ b/edx_proctoring/tests/utils.py @@ -19,7 +19,7 @@ from django.test.client import Client from edx_proctoring.api import create_exam -from edx_proctoring.models import ProctoredExam, ProctoredExamStudentAttempt +from edx_proctoring.models import ProctoredExamStudentAttempt from edx_proctoring.runtime import set_runtime_service from edx_proctoring.statuses import ProctoredExamStudentAttemptStatus from edx_proctoring.tests.test_services import MockCreditService, MockInstructorService @@ -373,32 +373,3 @@ def _normalize_whitespace(string): Replaces newlines and multiple spaces with a single space. """ return ' '.join(string.replace('\n', '').split()) - - -def create_onboarding_exam( - course_id='a/b/c', - content_id='test_content', - exam_name='Test Exam', - external_id='123aXqe3' -): - """ - Create and return an onboarding exam. - - Parameters: - * course_id: the course ID for the course in which to create the exam - * content_id: the content ID - * exam_name: the name of the exam - * external_id: the external ID of the exam - """ - onboarding_exam = ProctoredExam.objects.create( - course_id=course_id, - content_id=content_id, - exam_name=exam_name, - external_id=external_id, - time_limit_mins=90, - is_active=True, - is_proctored=True, - is_practice_exam=True, - backend='test', - ) - return onboarding_exam diff --git a/edx_proctoring/views.py b/edx_proctoring/views.py index d6cf0560dd8..5ca519c8e73 100644 --- a/edx_proctoring/views.py +++ b/edx_proctoring/views.py @@ -4,6 +4,7 @@ import json import logging +from datetime import timedelta from urllib.parse import urlencode import waffle @@ -313,6 +314,8 @@ class StudentOnboardingStatusView(ProctoredAPIView): * 'onboarding_status': String specifying the learner's onboarding status. ** Will return NULL if there are no onboarding attempts, or the given user does not exist * 'onboarding_link': Link to the onboarding exam. + * 'onboarding_expiration_date': If the learner's onboarding profile is approved in a different + course, the expiration date will be included. Will return NULL otherwise. """ def get(self, request): """ @@ -320,7 +323,8 @@ def get(self, request): """ data = { 'onboarding_status': None, - 'onboarding_link': None + 'onboarding_link': None, + 'expiration_date': None, } username = request.GET.get('username') @@ -364,17 +368,25 @@ def get(self, request): data['onboarding_link'] = reverse('jump_to', args=[course_id, onboarding_exam.content_id]) - attempts = ProctoredExamStudentAttempt.objects.get_onboarding_attempts_by_course_id(course_id, [user]) - if len(attempts) == 0: - # If there are no attempts, return the data with 'onboarding_status' set to None - return Response(data) + attempts = ProctoredExamStudentAttempt.objects.get_proctored_practice_attempts_by_course_id(course_id, [user]) - # Default to the most recent attempt if there are no verified attempts - relevant_attempt = attempts[0] - for attempt in attempts: - if attempt.status == ProctoredExamStudentAttemptStatus.verified: - relevant_attempt = attempt - data['onboarding_status'] = relevant_attempt.status + if len(attempts) == 0: + # If there are no attempts in the current course, check for a verified attempt in another course + other_course_verified_attempt = (ProctoredExamStudentAttempt.objects + .get_last_verified_proctored_practice_attempt( + user.id, onboarding_exam.backend + )) + if other_course_verified_attempt: + data['onboarding_status'] = InstructorDashboardOnboardingAttemptStatus.other_course_approved + data['expiration_date'] = other_course_verified_attempt.modified + timedelta(days=730) + else: + # Default to the most recent attempt in the course if there are no verified attempts + relevant_attempt = attempts[0] + for attempt in attempts: + if attempt.status == ProctoredExamStudentAttemptStatus.verified: + relevant_attempt = attempt + break + data['onboarding_status'] = relevant_attempt.status return Response(data) @@ -450,13 +462,13 @@ def get(self, request, course_id): allowed_enrollments_users = self._filter_users_by_username_or_email(allowed_enrollments_users, text_search) # get onboarding attempts for users for the course - onboarding_attempts = ProctoredExamStudentAttempt.objects.get_onboarding_attempts_by_course_id( + onboarding_attempts = ProctoredExamStudentAttempt.objects.get_proctored_practice_attempts_by_course_id( course_id, allowed_enrollments_users ).values('user_id', 'status', 'modified') # select a verified, or most recent, exam attempt per user - onboarding_attempts_per_user = self._get_relevant_onboarding_attempt_per_user(onboarding_attempts) + onboarding_attempts_per_user = self._get_relevant_attempt_per_user(onboarding_attempts) onboarding_data = [] for user in allowed_enrollments_users: @@ -502,7 +514,7 @@ def _get_query_params(self, text_search, statuses_filter): query_params['statuses'] = statuses_filter return query_params - def _get_relevant_onboarding_attempt_per_user(self, attempts): + def _get_relevant_attempt_per_user(self, attempts): """ Given an ordered list of attempts, return, for each learner, their most recent exam attempt. If the learner has a verified attempt, always return verified. diff --git a/package.json b/package.json index 608383b9047..ca5539ac726 100644 --- a/package.json +++ b/package.json @@ -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.6.7", + "version": "3.7.0", "main": "edx_proctoring/static/index.js", "repository": { "type": "git",