feat: add onboarding status wrapper

This commit is contained in:
Azan Bin Zahid
2021-12-14 21:09:36 +05:00
parent 6c5b1ef551
commit 5c886a0075
3 changed files with 323 additions and 3 deletions

View File

@@ -11,14 +11,25 @@ from unittest.mock import patch
from uuid import UUID, uuid4
import ddt
from django.conf import settings
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.db.models import signals
from django.http import HttpResponse
from django.urls import reverse
from django.test.utils import override_settings
from django.urls import reverse
from django.utils import timezone
from edx_proctoring.api import create_exam_attempt, update_attempt_status
from edx_proctoring.models import ProctoredExam
from edx_proctoring.runtime import set_runtime_service
from edx_proctoring.statuses import ProctoredExamStudentAttemptStatus
from edx_proctoring.tests.test_services import MockLearningSequencesService, MockScheduleItemData
from edx_proctoring.tests.utils import ProctoredExamTestCase
from opaque_keys.edx.locator import BlockUsageLocator
from organizations.tests.factories import OrganizationFactory
from pytz import UTC
from social_django.models import UserSocialAuth
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
@@ -42,8 +53,6 @@ from lms.djangoapps.verify_student.tests.factories import SSOVerificationFactory
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
from openedx.features.course_duration_limits.models import CourseDurationLimitConfig
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
class SupportViewTestCase(ModuleStoreTestCase):
@@ -1399,3 +1408,211 @@ class SAMLProvidersWithOrgTests(SupportViewTestCase):
response = self.client.get(self._url)
response_data = json.loads(response.content.decode('utf-8'))
assert response_data == self.org_key_list
class TestOnboardingView(SupportViewTestCase, ProctoredExamTestCase):
"""
Tests for OnboardingView
"""
def setUp(self):
super().setUp()
SupportStaffRole().add_users(self.user)
self.proctored_exam_id = self._create_proctored_exam()
self.onboarding_exam_id = self._create_onboarding_exam()
self.other_user = User.objects.create(username='otheruser', password='test')
self.other_course_content = 'block-v1:test+course+2+type@sequential+block@other_onboard'
self.other_course = CourseFactory.create(
org='x',
course='y',
run='z',
enable_proctored_exams=True,
proctoring_provider=settings.PROCTORING_BACKENDS['DEFAULT'],
)
yesterday = timezone.now() - timezone.timedelta(days=1)
self.course_scheduled_sections = {
BlockUsageLocator.from_string(self.content_id_onboarding): MockScheduleItemData(yesterday),
BlockUsageLocator.from_string(self.other_course_content): MockScheduleItemData(yesterday),
}
set_runtime_service('learning_sequences', MockLearningSequencesService(
list(self.course_scheduled_sections.keys()),
self.course_scheduled_sections,
))
self.onboarding_exam = ProctoredExam.objects.get(id=self.onboarding_exam_id)
def tearDown(self): # lint-amnesty, pylint: disable=super-method-not-called
"""
Override deafult implementation to prevent `default` key deletion from TRACKERS in
an inherited tearDown() method of ProctoredExamTestCase
"""
return
def _url(self, username):
return reverse("support:onboarding_status", kwargs={'username_or_email': username})
def _create_enrollment(self):
""" Create enrollment in default course """
# default course key = 'a/b/c'
self.course = CourseFactory.create(
org='a',
course='b',
run='c',
enable_proctored_exams=True,
proctoring_provider=settings.PROCTORING_BACKENDS['DEFAULT'],
)
CourseEnrollmentFactory(
is_active=True,
mode='verified',
course_id=self.course.id,
user=self.user
)
def test_wrong_username(self):
"""
Test that a request with a username which does not exits returns 404
"""
response = self.client.get(self._url(username='does_not_exist'))
self.assertEqual(response.status_code, 404)
response_data = json.loads(response.content.decode('utf-8'))
self.assertEqual(response_data['verified_in'], None)
self.assertEqual(response_data['current_status'], None)
def test_no_record(self):
"""
Test that a request with a username which do not have any onboarding exam returns empty data
"""
response = self.client.get(self._url(username=self.other_user.username))
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content.decode('utf-8'))
self.assertEqual(response_data['verified_in'], None)
self.assertEqual(response_data['current_status'], None)
def test_no_verified_attempts(self):
"""
Test that if there are no verified attempts, the most recent status is returned
"""
self._create_enrollment()
# create first attempt
attempt_id = create_exam_attempt(self.onboarding_exam_id, self.user.id, True)
update_attempt_status(attempt_id, ProctoredExamStudentAttemptStatus.submitted)
response = self.client.get(self._url(username=self.user.username))
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content.decode('utf-8'))
self.assertEqual(response_data['verified_in'], None)
self.assertEqual(
response_data['current_status']['onboarding_status'],
ProctoredExamStudentAttemptStatus.submitted
)
# Create second attempt and assert that most recent attempt is returned
create_exam_attempt(self.onboarding_exam_id, self.user.id, True)
response = self.client.get(self._url(username=self.user.username))
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content.decode('utf-8'))
self.assertEqual(response_data['verified_in'], None)
self.assertEqual(
response_data['current_status']['onboarding_status'],
ProctoredExamStudentAttemptStatus.created
)
def test_get_verified_attempt(self):
"""
Test that if there is at least one verified attempt, the status returned is always verified
"""
self._create_enrollment()
# Create first attempt
attempt_id = create_exam_attempt(self.onboarding_exam_id, self.user.id, True)
update_attempt_status(attempt_id, ProctoredExamStudentAttemptStatus.verified)
response = self.client.get(self._url(username=self.user.username))
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content.decode('utf-8'))
self.assertEqual(
response_data['verified_in']['onboarding_status'],
ProctoredExamStudentAttemptStatus.verified
)
self.assertEqual(
response_data['current_status']['onboarding_status'],
ProctoredExamStudentAttemptStatus.verified
)
# Create second attempt and assert that verified attempt is still returned
create_exam_attempt(self.onboarding_exam_id, self.user.id, True)
response = self.client.get(self._url(username=self.user.username))
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content.decode('utf-8'))
self.assertEqual(
response_data['verified_in']['onboarding_status'],
ProctoredExamStudentAttemptStatus.verified
)
self.assertEqual(
response_data['current_status']['onboarding_status'],
ProctoredExamStudentAttemptStatus.verified
)
def test_verified_in_another_course(self):
"""
Test that, if there is at least one verified attempt in any course for a given user,
the current status will return `other_course_approved`
"""
# Create a submitted attempt in the current course
attempt_id = create_exam_attempt(self.onboarding_exam_id, self.user.id, True)
update_attempt_status(attempt_id, ProctoredExamStudentAttemptStatus.submitted)
# Create an attempt in the other course that has been verified
other_course_id = 'x/y/z'
other_course_onboarding_exam = ProctoredExam.objects.create(
course_id=other_course_id,
content_id=self.other_course_content,
exam_name='Test Exam',
external_id='123aXqe3',
time_limit_mins=90,
is_active=True,
is_proctored=True,
is_practice_exam=True,
backend='test'
)
self.user_id = self.user.id
self._create_exam_attempt(other_course_onboarding_exam.id, ProctoredExamStudentAttemptStatus.verified, True)
# professional enrollment
CourseEnrollmentFactory(
is_active=True,
mode='professional',
course_id=self.other_course.id,
user=self.user
)
# default enrollment afterwards with submitted status
self._create_enrollment()
response = self.client.get(self._url(username=self.user.username))
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content.decode('utf-8'))
# assert that originally verified enrollment is reflected correctly
self.assertEqual(response_data['verified_in']['onboarding_status'], 'verified')
self.assertEqual(response_data['verified_in']['course_id'], 'x/y/z')
# assert that most recent enrollment (current status) has other_course_approved status
self.assertEqual(response_data['current_status']['onboarding_status'], 'other_course_approved')
self.assertEqual(response_data['current_status']['course_id'], 'a/b/c')

View File

@@ -19,6 +19,7 @@ from .views.program_enrollments import (
SAMLProvidersWithOrg,
)
from .views.sso_records import SsoView
from .views.onboarding_status import OnboardingView
COURSE_ENTITLEMENTS_VIEW = EntitlementSupportView.as_view()
@@ -71,4 +72,8 @@ urlpatterns = [
name='get_saml_providers'
),
re_path(r'sso_records/(?P<username_or_email>[\w.@+-]+)?$', SsoView.as_view(), name='sso_records'),
re_path(
r'onboarding_status/(?P<username_or_email>[\w.@+-]+)?$',
OnboardingView.as_view(), name='onboarding_status'
),
]

View File

@@ -0,0 +1,98 @@
"""
Views for Onboarding Status.
"""
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.db.models import Q
from django.utils.decorators import method_decorator
from edx_proctoring.statuses import ProctoredExamStudentAttemptStatus
from edx_proctoring.views import StudentOnboardingStatusView
from rest_framework.generics import GenericAPIView
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.util.json_request import JsonResponse
from lms.djangoapps.support.decorators import require_support_permission
from openedx.core.djangoapps.enrollments.api import get_enrollments
class OnboardingView(GenericAPIView):
"""
Return most recent and originally verified onboarding exam status for a given user.
Return 404 is user not found.
"""
@method_decorator(require_support_permission)
def get(self, request, username_or_email):
"""
* Example Request:
- GET /support/onboarding_status/<username_or_email>
* Example Response:
{
"verified_in": {
"onboarding_status": "verified",
"onboarding_link": "/courses/<course_id>/jump_to/<block_id>",
"expiration_date": null,
"onboarding_past_due": false,
"onboarding_release_date": "2016-01-01T00:00:00+00:00",
"review_requirements_url": "",
"course_id": "<course_id>",
"enrollment_date": "2021-12-29T14:30:18.895435Z",
"instructor_dashboard_link": "/courses/<course_id>/instructor#view-special_exams"
},
"current_status": {
"onboarding_status": "other_course_approved",
"onboarding_link": "/courses/<course_id>/jump_to/<block_id>",
"expiration_date": "2023-12-29T15:52:28.245Z",
"onboarding_past_due": false,
"onboarding_release_date": "2020-01-01T00:00:00+00:00",
"review_requirements_url": "",
"course_id": "<course_id>",
"enrollment_date": "2021-12-29T15:58:29.489916Z",
"instructor_dashboard_link": "/courses/<course_id>/instructor#view-special_exams"
}
}
"""
# return dict
onboarding_status = {
'verified_in': None,
'current_status': None
}
# make object mutable
request.GET = request.GET.copy()
try:
user = User.objects.get(Q(username=username_or_email) | Q(email=username_or_email))
except User.DoesNotExist:
return JsonResponse(onboarding_status, status=404)
request.GET['username'] = user.username
enrollments = get_enrollments(user.username)
enrollments = sorted(enrollments, key=lambda enrollment: enrollment['created'], reverse=True)
enrollments = filter(
lambda enrollment: enrollment['mode'] in [CourseMode.VERIFIED, CourseMode.PROFESSIONAL],
enrollments
)
for enrollment in enrollments:
request.GET['course_id'] = enrollment['course_details']['course_id']
status = StudentOnboardingStatusView().get(request).data
if 'onboarding_status' in status:
status['course_id'] = enrollment['course_details']['course_id']
status['enrollment_date'] = enrollment['created']
status['instructor_dashboard_link'] = \
'/courses/{}/instructor#view-special_exams'.format(status['course_id'])
# set most recent status only at first iteration
if onboarding_status['current_status'] is None:
onboarding_status['current_status'] = status
# stay in loop to find original verified enrollment. Expensive!
if status['onboarding_status'] == ProctoredExamStudentAttemptStatus.verified:
onboarding_status['verified_in'] = status
break
return JsonResponse(onboarding_status)