Skip to content

Commit

Permalink
feat: launch instructor tool (#146)
Browse files Browse the repository at this point in the history
Creates a new view that will initiate a basic LTI 1.3 launch into the proctoring vendor's instructor tool
  • Loading branch information
Zacharis278 committed Jun 28, 2023
1 parent a3a4e29 commit cdb5835
Show file tree
Hide file tree
Showing 4 changed files with 115 additions and 43 deletions.
11 changes: 11 additions & 0 deletions edx_exams/apps/core/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,17 @@ def get_exam_by_content_id(course_id, content_id):
return None


def get_exam_by_id(exam_id):
"""
Retrieve an exam by id
"""
try:
exam = Exam.objects.get(id=exam_id)
return exam
except Exam.DoesNotExist:
return None


def get_course_exams(course_id):
"""
Retrieve all active exams for a course
Expand Down
109 changes: 66 additions & 43 deletions edx_exams/apps/lti/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
"""
import json
import logging
import uuid
from unittest.mock import patch
from urllib.parse import urljoin

Expand All @@ -16,8 +15,12 @@
from lti_consumer.models import LtiConfiguration, LtiProctoringConsumer

from edx_exams.apps.api.test_utils import ExamsAPITestCase, UserFactory
from edx_exams.apps.api.test_utils.factories import CourseExamConfigurationFactory, ExamAttemptFactory, ExamFactory
from edx_exams.apps.core.models import CourseExamConfiguration, Exam, ExamAttempt
from edx_exams.apps.api.test_utils.factories import (
CourseExamConfigurationFactory,
ExamAttemptFactory,
ExamFactory,
ProctoringProviderFactory
)
from edx_exams.apps.core.statuses import ExamAttemptStatus
from edx_exams.apps.lti.utils import get_lti_root

Expand Down Expand Up @@ -211,32 +214,14 @@ def setUp(self):
self.course_id = 'course-v1:edx+test+f19'
self.content_id = '11111111'

self.course_exam_config = CourseExamConfiguration.objects.create(
course_id=self.course_id,
provider=self.test_provider,
allow_opt_out=False
)

self.exam = Exam.objects.create(
resource_id=str(uuid.uuid4()),
self.exam = ExamFactory(
course_id=self.course_id,
provider=self.test_provider,
content_id=self.content_id,
exam_name='test_exam',
exam_type='proctored',
time_limit_mins=30,
due_date='2021-07-01 00:00:00',
hide_after_due=False,
is_active=True
)

self.attempt = ExamAttempt.objects.create(
self.attempt = ExamAttemptFactory(
user=self.user,
exam=self.exam,
attempt_number=1111111,
status=ExamAttemptStatus.created,
start_time=None,
allowed_time_limit_mins=None,
)

# Create an LtiConfiguration instance so that the config_id can be included in the Lti1p3LaunchData.
Expand Down Expand Up @@ -353,32 +338,14 @@ def setUp(self):
self.course_id = 'course-v1:edx+test+f19'
self.content_id = '11111111'

self.course_exam_config = CourseExamConfiguration.objects.create(
course_id=self.course_id,
provider=self.test_provider,
allow_opt_out=False
)

self.exam = Exam.objects.create(
resource_id=str(uuid.uuid4()),
self.exam = ExamFactory(
course_id=self.course_id,
provider=self.test_provider,
content_id=self.content_id,
exam_name='test_exam',
exam_type='proctored',
time_limit_mins=30,
due_date='2021-07-01 00:00:00',
hide_after_due=False,
is_active=True
)

self.attempt = ExamAttempt.objects.create(
self.attempt = ExamAttemptFactory(
user=self.user,
exam=self.exam,
attempt_number=1111111,
status=ExamAttemptStatus.created,
start_time=None,
allowed_time_limit_mins=None,
)

# Create an LtiConfiguration instance so that the config_id can be included in the Lti1p3LaunchData.
Expand Down Expand Up @@ -474,3 +441,59 @@ def test_end_assessment_unauthorized_user(self, mock_get_lti_launch_url): # pyl
response = self.client.get(self.url, **headers)

self.assertEqual(response.status_code, 403)


@patch('edx_exams.apps.lti.views.get_lti_1p3_launch_start_url', return_value='https://www.example.com')
class LtiInstructorLaunchTest(ExamsAPITestCase):
"""
Test launch_instructor_view
"""
def setUp(self):
super().setUp()
self.lti_configuration = LtiConfiguration.objects.create()
self.exam = ExamFactory(
provider=ProctoringProviderFactory(
lti_configuration_id=self.lti_configuration.id
),
)

def _get_launch_url(self, exam_id):
return reverse('lti:instructor_tool', kwargs={'exam_id': exam_id})

def test_lti_launch(self, mock_create_launch_url):
"""
Test that the view calls get_lti_1p3_launch_start_url with the correct data.
"""
headers = self.build_jwt_headers(self.user)
response = self.client.get(self._get_launch_url(self.exam.id), **headers)

mock_create_launch_url.assert_called_with(
Lti1p3LaunchData(
user_id=self.user.id,
user_role='instructor',
config_id=self.lti_configuration.config_id,
resource_link_id=self.exam.resource_id,
external_user_id=str(self.user.anonymous_user_id),
context_id=self.exam.course_id,
)
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, 'https://www.example.com')

def test_invalid_exam_id(self, mock_create_launch_url): # pylint: disable=unused-argument
"""
Test that a 400 response is returned when calling the view with an exam_id that does not exist.
"""
headers = self.build_jwt_headers(self.user)
response = self.client.get(self._get_launch_url(1000), **headers)

self.assertEqual(response.status_code, 400)

def test_requires_staff_user(self, mock_create_launch_url): # pylint: disable=unused-argument
"""
Test that a 403 response is returned when calling the view with a non-staff user.
"""
headers = self.build_jwt_headers(UserFactory(is_staff=False))
response = self.client.get(self._get_launch_url(self.exam.id), **headers)

self.assertEqual(response.status_code, 403)
1 change: 1 addition & 0 deletions edx_exams/apps/lti/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@
path('<int:lti_config_id>/acs', views.acs, name='acs'),
path('end_assessment/<int:attempt_id>', views.end_assessment, name='end_assessment'),
path('start_proctoring/<int:attempt_id>', views.start_proctoring, name='start_proctoring'),
path('exam/<int:exam_id>/instructor_tool', views.launch_instructor_tool, name='instructor_tool'),
]
37 changes: 37 additions & 0 deletions edx_exams/apps/lti/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from edx_exams.apps.core.api import (
get_attempt_by_id,
get_attempt_for_user_with_attempt_number_and_resource_id,
get_exam_by_id,
update_attempt_status
)
from edx_exams.apps.core.exceptions import ExamIllegalStatusTransition
Expand Down Expand Up @@ -256,3 +257,39 @@ def end_assessment(request, attempt_id):
return redirect(preflight_url)

return JsonResponse({})


@api_view(['GET'])
@require_http_methods(['GET'])
@authentication_classes((JwtAuthentication,))
@permission_classes((IsAuthenticated,))
def launch_instructor_tool(request, exam_id):
"""
View to initiate an LTI launch of the Instructor Tool for an exam.
"""
user = request.user

# TODO: this should eventually be replaced with a permission check
# for course staff
if not user.is_staff:
return Response(status=status.HTTP_403_FORBIDDEN)

exam = get_exam_by_id(exam_id)
if not exam:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={'detail': f'Exam with exam_id={exam_id} does not exist.'}
)

lti_config_id = exam.provider.lti_configuration_id
lti_config = LtiConfiguration.objects.get(id=lti_config_id)
launch_data = Lti1p3LaunchData(
user_id=user.id,
user_role='instructor',
config_id=lti_config.config_id,
resource_link_id=exam.resource_id,
external_user_id=str(user.anonymous_user_id),
context_id=exam.course_id,
)

return redirect(get_lti_1p3_launch_start_url(launch_data))

0 comments on commit cdb5835

Please sign in to comment.