feat: add onboarding status wrapper
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
),
|
||||
]
|
||||
|
||||
98
lms/djangoapps/support/views/onboarding_status.py
Normal file
98
lms/djangoapps/support/views/onboarding_status.py
Normal 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)
|
||||
Reference in New Issue
Block a user