Skip to content

Commit

Permalink
feat: Add CourseContentItemViewSet
Browse files Browse the repository at this point in the history
refactor: LTI Tool Views
feat: Upgrade requirements
  • Loading branch information
kuipumu committed Aug 19, 2024
1 parent 34d59ce commit bed034c
Show file tree
Hide file tree
Showing 35 changed files with 1,026 additions and 316 deletions.
1 change: 1 addition & 0 deletions openedx_lti_tool_plugin/deep_linking/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""REST API."""
4 changes: 4 additions & 0 deletions openedx_lti_tool_plugin/deep_linking/api/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""Test api module."""
from openedx_lti_tool_plugin.deep_linking.tests import MODULE_PATH

MODULE_PATH = f'{MODULE_PATH}.api'
109 changes: 109 additions & 0 deletions openedx_lti_tool_plugin/deep_linking/api/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""Test views module."""
from unittest.mock import MagicMock, patch
from uuid import uuid4

from django.test import TestCase
from pylti1p3.exception import LtiException
from rest_framework.exceptions import APIException
from rest_framework.status import HTTP_400_BAD_REQUEST

from openedx_lti_tool_plugin.deep_linking.api.tests import MODULE_PATH
from openedx_lti_tool_plugin.deep_linking.api.v1.views import DeepLinkingViewSet
from openedx_lti_tool_plugin.deep_linking.exceptions import DeepLinkingException

MODULE_PATH = f'{MODULE_PATH}.views'


@patch(f'{MODULE_PATH}.validate_deep_linking_message')
@patch(f'{MODULE_PATH}.super')
class TestDeepLinkingViewSet(TestCase):
"""Test DeepLinkingViewSet class."""

def setUp(self):
"""Set up test fixtures."""
super().setUp()
self.view_class = DeepLinkingViewSet
self.view_self = MagicMock()
self.request = MagicMock()
self.launch_id = uuid4()
self.error_message = 'test-error-message'

def test_initial_with_valid_message(
self,
super_mock: MagicMock,
validate_deep_linking_message_mock: MagicMock,
):
"""Test initial method with valid DjangoMessageLaunch (happy path)."""
self.view_class.initial(
self.view_self,
self.request,
launch_id=self.launch_id,
)

super_mock.assert_called_once_with()
self.view_self.get_message_from_cache.assert_called_once_with(
self.request,
self.launch_id,
)
validate_deep_linking_message_mock.assert_called_once_with(
self.view_self.get_message_from_cache(),
)
self.view_self.get_message_from_cache().get_launch_data.assert_called_once_with()
self.assertEqual(
self.view_self.launch_data,
self.view_self.get_message_from_cache().get_launch_data(),
)

def test_initial_with_lti_exception(
self,
super_mock: MagicMock,
validate_deep_linking_message_mock: MagicMock,
):
"""Test initial method with LtiException."""
self.view_self.get_message_from_cache.side_effect = LtiException(
self.error_message,
)

with self.assertRaises(APIException) as ctxm:
self.view_class.initial(
self.view_self,
self.request,
launch_id=self.launch_id,
)

self.assertEqual(self.error_message, str(ctxm.exception))
self.assertEqual(HTTP_400_BAD_REQUEST, ctxm.exception.detail.code)
super_mock.assert_called_once_with()
self.view_self.get_message_from_cache.assert_called_once_with(
self.request,
self.launch_id,
)
validate_deep_linking_message_mock.assert_not_called()
self.view_self.get_message_from_cache.return_value.get_launch_data.assert_not_called()

def test_initial_with_deep_linking_exception(
self,
super_mock: MagicMock,
validate_deep_linking_message_mock: MagicMock,
):
"""Test initial method with DeepLinkingException."""
self.view_self.get_message_from_cache.side_effect = DeepLinkingException(
self.error_message,
)

with self.assertRaises(APIException) as ctxm:
self.view_class.initial(
self.view_self,
self.request,
launch_id=self.launch_id,
)

self.assertEqual(self.error_message, str(ctxm.exception))
self.assertEqual(HTTP_400_BAD_REQUEST, ctxm.exception.detail.code)
super_mock.assert_called_once_with()
self.view_self.get_message_from_cache.assert_called_once_with(
self.request,
self.launch_id,
)
validate_deep_linking_message_mock.assert_not_called()
self.view_self.get_message_from_cache.return_value.get_launch_data.assert_not_called()
13 changes: 13 additions & 0 deletions openedx_lti_tool_plugin/deep_linking/api/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Django URL Configuration.
Attributes:
app_name (str): URL pattern namespace.
urlpatterns (list): URL patterns list.
"""
from django.urls import include, path

app_name = 'api'
urlpatterns = [
path('v1/', include('openedx_lti_tool_plugin.deep_linking.api.v1.urls')),
]
1 change: 1 addition & 0 deletions openedx_lti_tool_plugin/deep_linking/api/v1/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""REST API V1."""
9 changes: 9 additions & 0 deletions openedx_lti_tool_plugin/deep_linking/api/v1/pagination.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Pagination."""
from rest_framework.pagination import PageNumberPagination


class ContentItemPagination(PageNumberPagination):
"""Content Item Pagination."""

page_size_query_param = 'page_size'
max_page_size = 100
33 changes: 33 additions & 0 deletions openedx_lti_tool_plugin/deep_linking/api/v1/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Django REST Framework Serializers."""
from rest_framework import serializers

from openedx_lti_tool_plugin.deep_linking.utils import build_resource_link_launch_url
from openedx_lti_tool_plugin.models import CourseContext


class CourseContentItemSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""Course Content Item Serializer.
.. _LTI Deep Linking Specification - Content Item Types:
https://www.imsglobal.org/spec/lti-dl/v2p0#content-item-types
"""

type = serializers.ReadOnlyField(default='ltiResourceLink')
url = serializers.SerializerMethodField()
title = serializers.CharField(allow_blank=True)

def get_url(self, course_context: CourseContext):
"""Get Content Item URL.
Args:
course_context: CourseContext object.
Returns:
Course LTI Resource Link Launch URL.
"""
return build_resource_link_launch_url(
self.context.get('request'),
course_context.course_id,
)
4 changes: 4 additions & 0 deletions openedx_lti_tool_plugin/deep_linking/api/v1/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""Test v1 module."""
from openedx_lti_tool_plugin.deep_linking.api.tests import MODULE_PATH

MODULE_PATH = f'{MODULE_PATH}.v1'
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Test pagination module."""
from django.test import TestCase

from openedx_lti_tool_plugin.deep_linking.api.v1.pagination import ContentItemPagination
from openedx_lti_tool_plugin.deep_linking.api.v1.tests import MODULE_PATH

MODULE_PATH = f'{MODULE_PATH}.pagination'


class TestContentItemPagination(TestCase):
"""Test ContentItemPagination class."""

def setUp(self):
"""Set up test fixtures."""
super().setUp()
self.pagination_class = ContentItemPagination

def test_class_attributes(self):
"""Test class attributes."""
self.assertEqual(self.pagination_class.page_size_query_param, 'page_size')
self.assertEqual(self.pagination_class.max_page_size, 100)
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Test serializers module."""
from unittest.mock import MagicMock, patch

from django.test import TestCase

from openedx_lti_tool_plugin.deep_linking.api.v1.serializers import CourseContentItemSerializer
from openedx_lti_tool_plugin.deep_linking.api.v1.tests import MODULE_PATH

MODULE_PATH = f'{MODULE_PATH}.serializers'


class TestCourseContentItemSerializer(TestCase):
"""Test CourseContentItemSerializer class."""

def setUp(self):
"""Set up test fixtures."""
super().setUp()
self.serializer_class = CourseContentItemSerializer
self.course_context = MagicMock(course_id=MagicMock())
self.request = MagicMock()
self.serializer_self = MagicMock(context={'request': self.request})

@patch(f'{MODULE_PATH}.build_resource_link_launch_url')
def test_get_url(
self,
build_resource_link_launch_url_mock: MagicMock,
):
"""Test get_url method."""
self.assertEqual(
self.serializer_class.get_url(
self.serializer_self,
self.course_context,
),
build_resource_link_launch_url_mock.return_value,
)
build_resource_link_launch_url_mock.assert_called_once_with(
self.request,
self.course_context.course_id,
)
23 changes: 23 additions & 0 deletions openedx_lti_tool_plugin/deep_linking/api/v1/tests/test_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Test urls module."""
from uuid import uuid4

from django.test import TestCase
from django.urls import resolve, reverse

from openedx_lti_tool_plugin.deep_linking.api.v1.views import CourseContentItemViewSet


class TestCourseContentItemViewSetUrlPatterns(TestCase):
"""Test CourseContentItemViewSet Django URL Configuration."""

def test_view_url(self):
"""Test View URL."""
self.assertEqual(
resolve(
reverse(
'1.3:deep-linking:api:v1:course-content-item-list',
args=[uuid4()],
),
).func.cls,
CourseContentItemViewSet,
)
62 changes: 62 additions & 0 deletions openedx_lti_tool_plugin/deep_linking/api/v1/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Test views module."""
from unittest.mock import MagicMock, patch
from uuid import uuid4

from django.http.response import Http404
from django.test import RequestFactory, TestCase, override_settings
from django.urls import reverse

from openedx_lti_tool_plugin.deep_linking.api.v1.pagination import ContentItemPagination
from openedx_lti_tool_plugin.deep_linking.api.v1.serializers import CourseContentItemSerializer
from openedx_lti_tool_plugin.deep_linking.api.v1.tests import MODULE_PATH
from openedx_lti_tool_plugin.deep_linking.api.v1.views import CourseContentItemViewSet
from openedx_lti_tool_plugin.models import CourseContext
from openedx_lti_tool_plugin.tests import AUD, ISS

MODULE_PATH = f'{MODULE_PATH}.views'


class TestCourseContentItemViewSet(TestCase):
"""Test CourseContentItemViewSet class."""

def setUp(self):
"""Set up test fixtures."""
super().setUp()
self.view_class = CourseContentItemViewSet
self.launch_data = {}
self.view_self = MagicMock(launch_data=self.launch_data)
self.request = RequestFactory().post(
reverse(
'1.3:deep-linking:api:v1:course-content-item-list',
args=[uuid4()],
),
)

def test_class_attributes(self):
"""Test class attributes."""
self.assertEqual(self.view_class.serializer_class, CourseContentItemSerializer)
self.assertEqual(self.view_class.pagination_class, ContentItemPagination)

@patch.object(CourseContext.objects, 'all_for_lti_tool')
@patch(f'{MODULE_PATH}.get_identity_claims')
def test_get_queryset(
self,
get_identity_claims_mock: MagicMock,
all_for_lti_tool_mock: MagicMock,
):
"""Test get_queryset method."""
get_identity_claims_mock.return_value = ISS, AUD, None, None

self.assertEqual(
self.view_class.get_queryset(self.view_self),
all_for_lti_tool_mock.return_value.filter_by_site_orgs.return_value,
)
get_identity_claims_mock.assert_called_once_with(self.launch_data)
all_for_lti_tool_mock.assert_called_once_with(ISS, AUD)
all_for_lti_tool_mock().filter_by_site_orgs.assert_called_once_with()

@override_settings(OLTITP_ENABLE_LTI_TOOL=False)
def test_with_lti_disabled(self):
"""Test raise 404 response when plugin is disabled."""
with self.assertRaises(Http404):
self.view_class.as_view({'get': 'list'})(self.request)
19 changes: 19 additions & 0 deletions openedx_lti_tool_plugin/deep_linking/api/v1/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Django URL Configuration.
Attributes:
app_name (str): URL pattern namespace.
urlpatterns (list): URL patterns list.
"""
from django.urls import path

from openedx_lti_tool_plugin.deep_linking.api.v1 import views

app_name = 'v1'
urlpatterns = [
path(
'<uuid:launch_id>/content_items/courses',
views.CourseContentItemViewSet.as_view({'get': 'list'}),
name='course-content-item-list',
),
]
45 changes: 45 additions & 0 deletions openedx_lti_tool_plugin/deep_linking/api/v1/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Django Views."""
from django.db.models import QuerySet
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
from rest_framework.mixins import ListModelMixin

from openedx_lti_tool_plugin.deep_linking.api.v1.pagination import ContentItemPagination
from openedx_lti_tool_plugin.deep_linking.api.v1.serializers import CourseContentItemSerializer
from openedx_lti_tool_plugin.deep_linking.api.views import DeepLinkingViewSet
from openedx_lti_tool_plugin.models import CourseContext
from openedx_lti_tool_plugin.utils import get_identity_claims


class CourseContentItemViewSet(
ListModelMixin,
DeepLinkingViewSet,
):
"""Course Content Item ViewSet.
A content item is a JSON that represents any content the LTI Platform can consume,
a content item could be an LTI resource link launch URL, a URL to a resource hosted
on the internet, an HTML fragment, or any other kind of content type.
This ViewSet returns a list of LTI Resource Link content items for each Course
available for the LtiTool related to the request launch data and the
site configuration `course_org_filter` setting.
"""

authentication_classes = (JwtAuthentication,)
serializer_class = CourseContentItemSerializer
pagination_class = ContentItemPagination

def get_queryset(self) -> QuerySet:
"""Get QuerySet.
Returns:
CourseContext QuerySet.
"""
# Obtain the Issuer and Audience claim from the launch data
# these claims will be used by the CourseContext.all_for_lti_tool method
# to query the pylti1.3 LtiTool model related to this launch data.
iss, aud, _sub, _pii = get_identity_claims(self.launch_data)

return CourseContext.objects.all_for_lti_tool(iss, aud).filter_by_site_orgs()
Loading

0 comments on commit bed034c

Please sign in to comment.