diff --git a/openedx_lti_tool_plugin/deep_linking/forms.py b/openedx_lti_tool_plugin/deep_linking/forms.py index d8a9b85..e1f8918 100644 --- a/openedx_lti_tool_plugin/deep_linking/forms.py +++ b/openedx_lti_tool_plugin/deep_linking/forms.py @@ -1,21 +1,10 @@ """Django Forms.""" -import json import logging -from importlib import import_module -from typing import List, Optional, Tuple from django import forms -from django.conf import settings -from django.http.request import HttpRequest -from django.urls import reverse -from django.utils.translation import gettext as _ -from jsonschema import validate from pylti1p3.deep_link_resource import DeepLinkResource -from openedx_lti_tool_plugin.apps import OpenEdxLtiToolPluginConfig as app_config -from openedx_lti_tool_plugin.edxapp_wrapper.site_configuration_module import configuration_helpers -from openedx_lti_tool_plugin.models import CourseContext -from openedx_lti_tool_plugin.utils import get_identity_claims +from openedx_lti_tool_plugin.validators import JSONSchemaValidator log = logging.getLogger(__name__) @@ -28,6 +17,7 @@ class DeepLinkingForm(forms.Form): 'items': { 'type': 'object', 'properties': { + 'type': {'type': 'string'}, 'url': {'type': 'string'}, 'title': {'type': 'string'}, }, @@ -35,161 +25,15 @@ class DeepLinkingForm(forms.Form): }, } - content_items = forms.MultipleChoiceField( + content_items = forms.JSONField( required=False, - widget=forms.CheckboxSelectMultiple, - label=_('Content Items'), + validators=[JSONSchemaValidator(CONTENT_ITEMS_SCHEMA)], ) - def __init__( - self, - *args: tuple, - request: HttpRequest, - launch_data: dict, - **kwargs: dict, - ): - """Class __init__ method. - - Initialize class instance attributes and set the choices - of the content_items field. - - Args: - *args: Variable length argument list. - request: HttpRequest object. - launch_data: Launch message data. - **kwargs: Arbitrary keyword arguments. - - """ - super().__init__(*args, **kwargs) - self.request = request - self.launch_data = launch_data - self.fields['content_items'].choices = self.get_content_items_choices() - - def get_content_items_choices(self) -> List[Optional[Tuple[str, str]]]: - """Get content_items field choices. - - This method will get the content_items field choices from a list - of content items dictionaries provided by the get_content_items method or - the get_content_items_from_provider method if a content items provider is setup. - - A content item is a JSON that represents a content the LTI Platform can consume, - this could be an LTI resource link launch URL, an URL to a resource hosted - on the internet, an HTML fragment, or any other kind of content type defined - by the `type` JSON attribute. - - Each choice that this method returns is a JSON string representing a content item. - - Returns: - A list of tuples with content_items field choices or empty list. - - .. _LTI Deep Linking Specification - Content Item Types: - https://www.imsglobal.org/spec/lti-dl/v2p0#content-item-types - - """ - return [ - (json.dumps(content_item), content_item.get('title', '')) - for content_item in ( - self.get_content_items_from_provider() - or self.get_content_items() - ) - ] - - def get_content_items_from_provider(self) -> List[Optional[dict]]: - """Get content items from a provider function. - - This method will try to obtain content items from a provider function. - To setup a provider function the OLTITP_DEEP_LINKING_CONTENT_ITEMS_PROVIDER setting - must be set to a string with the full path to the function that will act has a provider: - - Example: - OLTITP_DEEP_LINKING_CONTENT_ITEMS_PROVIDER = 'example.module.path.provider_function' - - This method will then try to import and call the function, the call will include - the HTTPRequest object and deep linking launch data dictionary received from - the deep linking request has arguments. - - The content items returned from the function must be a list of dictionaries, - this list will be validated with a JSON Schema validator using a schema defined - in the CONTENT_ITEMS_SCHEMA constant. - - Returns: - A list with content item dictionaries. - - An empty list if OLTITP_DEEP_LINKING_CONTENT_ITEMS_PROVIDER setting is None. - or there was an Exception importing or calling the provider function, - or the data returned by the provider function is not valid. - or the provider function returned an empty list. - - .. _LTI Deep Linking Specification - Content Item Types: - https://www.imsglobal.org/spec/lti-dl/v2p0#content-item-types - - """ - if not (setting := configuration_helpers().get_value( - 'OLTITP_DEEP_LINKING_CONTENT_ITEMS_PROVIDER', - settings.OLTITP_DEEP_LINKING_CONTENT_ITEMS_PROVIDER, - )): - return [] - - try: - path, name = str(setting).rsplit('.', 1) - content_items = getattr(import_module(path), name)( - self.request, - self.launch_data, - ) - validate(content_items, self.CONTENT_ITEMS_SCHEMA) - - return content_items - - except Exception as exc: # pylint: disable=broad-exception-caught - log_extra = { - 'setting': setting, - 'exception': str(exc), - } - log.error(f'Error obtaining content items from provider: {log_extra}') - - return [] - - def get_content_items(self) -> List[Optional[dict]]: - """Get content items. - - Returns: - A list of content item dictionaries or an empty list. - - .. _LTI Deep Linking Specification - Content Item Types: - https://www.imsglobal.org/spec/lti-dl/v2p0#content-item-types - - """ - iss, aud, _sub, _pii = get_identity_claims(self.launch_data) - - return [ - { - 'url': self.build_content_item_url(course), - 'title': course.title, - } - for course in CourseContext.objects.all_for_lti_tool(iss, aud) - ] - - def build_content_item_url(self, course: CourseContext) -> str: - """Build content item URL. - - Args: - course: CourseContext object. - - Returns: - An absolute LTI 1.3 resource link launch URL. - - """ - return self.request.build_absolute_uri( - reverse( - f'{app_config.name}:1.3:resource-link:launch-course', - kwargs={'course_id': course.course_id}, - ) - ) - def clean(self) -> dict: """Form clean. - This method will transform all the JSON strings from the cleaned content_items data + This method will transform all the dictionaries from the cleaned content_items data into a list of DeepLinkResource objects that will be added to the cleaned data dictionary deep_link_resources key. @@ -204,8 +48,8 @@ def clean(self) -> dict: deep_link_resources = [] for content_item in self.cleaned_data.get('content_items', []): - content_item = json.loads(content_item) deep_link_resource = DeepLinkResource() + deep_link_resource.set_type(content_item.get('type')) deep_link_resource.set_title(content_item.get('title')) deep_link_resource.set_url(content_item.get('url')) deep_link_resources.append(deep_link_resource) diff --git a/openedx_lti_tool_plugin/deep_linking/tests/test_forms.py b/openedx_lti_tool_plugin/deep_linking/tests/test_forms.py index 506ae22..2a36684 100644 --- a/openedx_lti_tool_plugin/deep_linking/tests/test_forms.py +++ b/openedx_lti_tool_plugin/deep_linking/tests/test_forms.py @@ -1,38 +1,26 @@ """Tests forms module.""" from unittest.mock import MagicMock, patch -from django.conf import settings from django.test import TestCase -from testfixtures import log_capture -from testfixtures.logcapture import LogCaptureForDecorator -from openedx_lti_tool_plugin.apps import OpenEdxLtiToolPluginConfig as app_config from openedx_lti_tool_plugin.deep_linking.forms import DeepLinkingForm from openedx_lti_tool_plugin.deep_linking.tests import MODULE_PATH -from openedx_lti_tool_plugin.tests import AUD, ISS MODULE_PATH = f'{MODULE_PATH}.forms' -class DeepLinkingFormTestCase(TestCase): - """DeepLinkingForm TestCase.""" +class TestDeepLinkingForm(TestCase): + """Test DeepLinkingForm class.""" def setUp(self): """Set up test fixtures.""" super().setUp() self.form_class = DeepLinkingForm - self.request = MagicMock() - self.launch_data = {} - self.form_kwargs = {'request': self.request, 'launch_data': self.launch_data} - self.title = 'example-title' - self.url = 'http://example.com' - self.content_item = {'url': self.url, 'title': self.title} - self.content_item_json = f'{{"url": "{self.url}", "title": "{self.title}"}}' - self.course = MagicMock() - - -class TestDeepLinkingForm(DeepLinkingFormTestCase): - """Test DeepLinkingForm class.""" + self.content_item = { + 'type': 'test-type', + 'url': 'tset-title', + 'title': 'http://test.com', + } def test_class_attributes(self): """Test class attributes.""" @@ -43,6 +31,7 @@ def test_class_attributes(self): 'items': { 'type': 'object', 'properties': { + 'type': {'type': 'string'}, 'url': {'type': 'string'}, 'title': {'type': 'string'}, }, @@ -51,238 +40,26 @@ def test_class_attributes(self): }, ) - @patch.object(DeepLinkingForm, 'get_content_items_choices', return_value=[]) - def test_init( - self, - get_content_items_choices_mock: MagicMock, - ): - """Test __init__ method.""" - form = self.form_class(**self.form_kwargs) - - self.assertEqual(form.request, self.request) - self.assertEqual(form.launch_data, self.launch_data) - self.assertEqual( - list(form.fields['content_items'].choices), - get_content_items_choices_mock.return_value, - ) - get_content_items_choices_mock.assert_called_once_with() - - @patch(f'{MODULE_PATH}.get_identity_claims') - @patch(f'{MODULE_PATH}.CourseContext') - @patch.object(DeepLinkingForm, 'build_content_item_url') - @patch.object(DeepLinkingForm, 'get_content_items_choices') - def test_get_content_items( - self, - get_content_items_choices_mock: MagicMock, # pylint: disable=unused-argument - build_content_item_url_mock: MagicMock, - course_context_mock: MagicMock, - get_identity_claims_mock: MagicMock, - ): - """Test get_content_items method.""" - get_identity_claims_mock.return_value = ISS, AUD, None, None - course_context_mock.objects.all_for_lti_tool.return_value = [self.course] - - self.assertEqual( - self.form_class(**self.form_kwargs).get_content_items(), - [ - { - 'url': build_content_item_url_mock.return_value, - 'title': self.course.title, - }, - ], - ) - get_identity_claims_mock.assert_called_once_with(self.launch_data) - course_context_mock.objects.all_for_lti_tool.assert_called_once_with(ISS, AUD) - build_content_item_url_mock.assert_called_once_with(self.course) - - @patch(f'{MODULE_PATH}.reverse') - @patch.object(DeepLinkingForm, 'get_content_items_choices') - def test_build_content_item_url( - self, - get_content_items_choices_mock: MagicMock, # pylint: disable=unused-argument - reverse_mock: MagicMock, - ): - """Test build_content_item_url method.""" - self.assertEqual( - self.form_class(**self.form_kwargs).build_content_item_url(self.course), - self.request.build_absolute_uri.return_value, - ) - reverse_mock.assert_called_once_with( - f'{app_config.name}:1.3:resource-link:launch-course', - kwargs={'course_id': self.course.course_id}, - ) - @patch(f'{MODULE_PATH}.super') - @patch(f'{MODULE_PATH}.json.loads') @patch(f'{MODULE_PATH}.DeepLinkResource') - @patch.object(DeepLinkingForm, '__init__', return_value=None) def test_clean( self, - init_mock: MagicMock, # pylint: disable=unused-argument deep_link_resource_mock: MagicMock, - json_loads_mock: MagicMock, super_mock: MagicMock, ): """Test clean method.""" - json_loads_mock.return_value = self.content_item - form = self.form_class(**self.form_kwargs) - form.cleaned_data = {'content_items': [self.content_item_json]} + form = self.form_class() + form.cleaned_data = {'content_items': [self.content_item]} self.assertEqual(form.clean(), form.cleaned_data) super_mock.assert_called_once_with() - json_loads_mock.assert_called_once_with(self.content_item_json) deep_link_resource_mock.assert_called_once_with() - deep_link_resource_mock().set_title.assert_called_once_with(self.title) - deep_link_resource_mock().set_url.assert_called_once_with(self.url) - - -@patch.object(DeepLinkingForm, 'get_content_items_from_provider') -@patch.object(DeepLinkingForm, 'get_content_items') -@patch(f'{MODULE_PATH}.json.dumps') -@patch.object(DeepLinkingForm, '__init__', return_value=None) -class TestDeepLinkingFormGetContentItemsChoices(DeepLinkingFormTestCase): - """Test DeepLinkingForm.get_content_items_choices method.""" - - def test_with_get_content_items_from_provider( - self, - init_mock: MagicMock, # pylint: disable=unused-argument - json_dumps_mock: MagicMock, - get_content_items_mock: MagicMock, - get_content_items_from_provider_mock: MagicMock, - ): - """Test with values from get_content_items_from_provider method.""" - get_content_items_from_provider_mock.return_value = [self.content_item] - - self.assertEqual( - self.form_class(**self.form_kwargs).get_content_items_choices(), - [(json_dumps_mock.return_value, self.title)], - ) - json_dumps_mock.assert_called_once_with(self.content_item) - get_content_items_from_provider_mock.assert_called_once_with() - get_content_items_mock.assert_not_called() - - def test_with_get_content_items( - self, - init_mock: MagicMock, # pylint: disable=unused-argument - json_dumps_mock: MagicMock, - get_content_items_mock: MagicMock, - get_content_items_from_provider_mock: MagicMock, - ): - """Test with values from get_content_items method.""" - get_content_items_from_provider_mock.return_value = [] - get_content_items_mock.return_value = [self.content_item] - - self.assertEqual( - self.form_class(**self.form_kwargs).get_content_items_choices(), - [(json_dumps_mock.return_value, self.title)], + deep_link_resource_mock().set_type.assert_called_once_with( + self.content_item['type'], ) - json_dumps_mock.assert_called_once_with(self.content_item) - get_content_items_from_provider_mock.assert_called_once_with() - get_content_items_mock.assert_called_once_with() - - -@patch(f'{MODULE_PATH}.configuration_helpers') -@patch(f'{MODULE_PATH}.import_module') -@patch(f'{MODULE_PATH}.getattr') -@patch(f'{MODULE_PATH}.validate') -@patch.object(DeepLinkingForm, 'get_content_items_choices') -class TestDeepLinkingFormGetContentItemsChoicesFromProvider(DeepLinkingFormTestCase): - """Test DeepLinkingForm.get_content_items_from_provider method.""" - - def setUp(self): - """Set up test fixtures.""" - super().setUp() - self.setting = settings.OLTITP_DEEP_LINKING_CONTENT_ITEMS_PROVIDER - self.setting_name = 'OLTITP_DEEP_LINKING_CONTENT_ITEMS_PROVIDER' - self.setting_module = 'example.module.path' - self.setting_function = 'example_function' - self.setting_value = f'{self.setting_module}.{self.setting_function}' - - def test_with_setting_value( - self, - get_content_items_choices_mock: MagicMock, # pylint: disable=unused-argument - validate_mock: MagicMock, - getattr_mock: MagicMock, - import_module_mock: MagicMock, - configuration_helpers_mock: MagicMock, - ): - """Test with setting value (happy path).""" - configuration_helpers_mock().get_value.return_value = self.setting_value - - self.assertEqual( - self.form_class(**self.form_kwargs).get_content_items_from_provider(), - getattr_mock.return_value.return_value, + deep_link_resource_mock().set_title.assert_called_once_with( + self.content_item['title'], ) - configuration_helpers_mock().get_value.assert_called_once_with( - self.setting_name, - self.setting, - ) - import_module_mock.assert_called_once_with(self.setting_module) - getattr_mock.assert_called_once_with(import_module_mock(), self.setting_function) - getattr_mock().assert_called_once_with(self.request, self.launch_data) - validate_mock.assert_called_once_with( - getattr_mock()(), - self.form_class.CONTENT_ITEMS_SCHEMA, - ) - - def test_without_setting_value( - self, - get_content_items_choices_mock: MagicMock, # pylint: disable=unused-argument - validate_mock: MagicMock, - getattr_mock: MagicMock, - import_module_mock: MagicMock, - configuration_helpers_mock: MagicMock, - ): - """Test without setting value.""" - configuration_helpers_mock().get_value.return_value = '' - - self.assertEqual( - self.form_class(**self.form_kwargs).get_content_items_from_provider(), - [], - ) - configuration_helpers_mock().get_value.assert_called_once_with( - self.setting_name, - self.setting, - ) - import_module_mock.assert_not_called() - getattr_mock.assert_not_called() - getattr_mock().assert_not_called() - validate_mock.assert_not_called() - - @log_capture() - def test_with_exception( - self, - log_mock: LogCaptureForDecorator, - get_content_items_choices_mock: MagicMock, # pylint: disable=unused-argument - validate_mock: MagicMock, - getattr_mock: MagicMock, - import_module_mock: MagicMock, - configuration_helpers_mock: MagicMock, - ): - """Test with Exception.""" - import_module_mock.side_effect = Exception('example-error-message') - configuration_helpers_mock().get_value.return_value = self.setting_value - - self.assertEqual( - self.form_class(**self.form_kwargs).get_content_items_from_provider(), - [], - ) - configuration_helpers_mock().get_value.assert_called_once_with( - self.setting_name, - self.setting, - ) - import_module_mock.assert_called_once_with(self.setting_module) - getattr_mock.assert_not_called() - getattr_mock().assert_not_called() - validate_mock.assert_not_called() - log_extra = { - 'setting': configuration_helpers_mock().get_value(), - 'exception': str(import_module_mock.side_effect), - } - log_mock.check( - ( - MODULE_PATH, - 'ERROR', - f'Error obtaining content items from provider: {log_extra}', - ), + deep_link_resource_mock().set_url.assert_called_once_with( + self.content_item['url'], ) diff --git a/openedx_lti_tool_plugin/deep_linking/tests/test_views.py b/openedx_lti_tool_plugin/deep_linking/tests/test_views.py index 3a970e6..3c1d11f 100644 --- a/openedx_lti_tool_plugin/deep_linking/tests/test_views.py +++ b/openedx_lti_tool_plugin/deep_linking/tests/test_views.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, PropertyMock, patch from uuid import uuid4 +from django.conf import settings from django.http.response import Http404 from django.test import RequestFactory, TestCase, override_settings from django.urls import reverse @@ -127,7 +128,7 @@ def test_class_attributes(self): @patch.object(DeepLinkingFormView, 'get_message_from_cache') @patch(f'{MODULE_PATH}.validate_deep_linking_message') -@patch.object(DeepLinkingFormView, 'form_class') +@patch(f'{MODULE_PATH}.configuration_helpers') class TestDeepLinkingFormViewGet(TestCase): """Test DeepLinkingFormView.get method.""" @@ -144,7 +145,7 @@ def setUp(self): def test_with_deep_linking_request( self, render_mock: MagicMock, - form_class_mock: MagicMock, + configuration_helpers_mock: MagicMock, validate_deep_linking_message_mock: MagicMock, get_message_from_cache_mock: MagicMock, ): @@ -155,17 +156,14 @@ def test_with_deep_linking_request( ) get_message_from_cache_mock.assert_called_once_with(self.request, self.launch_id) validate_deep_linking_message_mock.assert_called_once_with(get_message_from_cache_mock()) - get_message_from_cache_mock().get_launch_data.assert_called_once_with() - form_class_mock.assert_called_once_with( - request=self.request, - launch_data=get_message_from_cache_mock().get_launch_data(), + configuration_helpers_mock().get_value.assert_called_once_with( + 'OLTITP_DEEP_LINKING_FORM_TEMPLATE', + settings.OLTITP_DEEP_LINKING_FORM_TEMPLATE, ) render_mock.assert_called_once_with( self.request, - 'openedx_lti_tool_plugin/deep_linking/form.html', + configuration_helpers_mock().get_value(), { - 'form': form_class_mock(), - 'form_url': f'{app_config.name}:1.3:deep-linking:form', 'launch_id': self.launch_id, }, ) @@ -174,7 +172,7 @@ def test_with_deep_linking_request( def test_with_lti_exception( self, http_response_error_mock: MagicMock, - form_class_mock: MagicMock, + configuration_helpers_mock: MagicMock, validate_deep_linking_message_mock: MagicMock, get_message_from_cache_mock: MagicMock, ): @@ -188,15 +186,14 @@ def test_with_lti_exception( ) get_message_from_cache_mock.assert_called_once_with(self.request, self.launch_id) validate_deep_linking_message_mock.assert_not_called() - get_message_from_cache_mock.return_value.get_launch_data.assert_not_called() - form_class_mock.assert_not_called() + configuration_helpers_mock().get_value.assert_not_called() http_response_error_mock.assert_called_once_with(exception) @patch.object(DeepLinkingFormView, 'http_response_error') def test_with_deep_linking_exception( self, http_response_error_mock: MagicMock, - form_class_mock: MagicMock, + configuration_helpers_mock: MagicMock, validate_deep_linking_message_mock: MagicMock, get_message_from_cache_mock: MagicMock, ): @@ -210,8 +207,7 @@ def test_with_deep_linking_exception( ) get_message_from_cache_mock.assert_called_once_with(self.request, self.launch_id) validate_deep_linking_message_mock.assert_called_once_with(get_message_from_cache_mock()) - get_message_from_cache_mock().get_launch_data.assert_not_called() - form_class_mock.assert_not_called() + configuration_helpers_mock().get_value.assert_not_called() http_response_error_mock.assert_called_once_with(exception) @override_settings(OLTITP_ENABLE_LTI_TOOL=False) @@ -260,12 +256,7 @@ def test_with_deep_linking_request( ) get_message_from_cache_mock.assert_called_once_with(self.request, self.launch_id) validate_deep_linking_message_mock.assert_called_once_with(get_message_from_cache_mock()) - get_message_from_cache_mock().get_launch_data.assert_called_once_with() - form_class_mock.assert_called_once_with( - self.request.POST, - request=self.request, - launch_data=get_message_from_cache_mock().get_launch_data(), - ) + form_class_mock.assert_called_once_with(self.request.POST) form_class_mock().is_valid.assert_called_once_with() get_message_from_cache_mock().get_deep_link.assert_called_once_with() get_message_from_cache_mock().get_deep_link().output_response_form.assert_called_once_with( @@ -292,12 +283,7 @@ def test_with_invalid_form( ) get_message_from_cache_mock.assert_called_once_with(self.request, self.launch_id) validate_deep_linking_message_mock.assert_called_once_with(get_message_from_cache_mock()) - get_message_from_cache_mock().get_launch_data.assert_called_once_with() - form_class_mock.assert_called_once_with( - self.request.POST, - request=self.request, - launch_data=get_message_from_cache_mock().get_launch_data(), - ) + form_class_mock.assert_called_once_with(self.request.POST) form_class_mock().is_valid.assert_called_once_with() get_message_from_cache_mock().get_deep_link.assert_not_called() get_message_from_cache_mock().get_deep_link().output_response_form.assert_not_called() @@ -321,7 +307,6 @@ def test_with_lti_exception( ) get_message_from_cache_mock.assert_called_once_with(self.request, self.launch_id) validate_deep_linking_message_mock.assert_not_called() - get_message_from_cache_mock.return_value.get_launch_data.assert_not_called() form_class_mock.assert_not_called() form_class_mock().is_valid.assert_not_called() get_message_from_cache_mock.return_value.get_deep_link.assert_not_called() @@ -346,7 +331,6 @@ def test_with_deep_linking_exception( ) get_message_from_cache_mock.assert_called_once_with(self.request, self.launch_id) validate_deep_linking_message_mock.assert_called_once_with(get_message_from_cache_mock()) - get_message_from_cache_mock().get_launch_data.assert_not_called() form_class_mock.assert_not_called() form_class_mock().is_valid.assert_not_called() get_message_from_cache_mock().get_deep_link.assert_not_called() diff --git a/openedx_lti_tool_plugin/deep_linking/views.py b/openedx_lti_tool_plugin/deep_linking/views.py index a23e78b..22685f2 100644 --- a/openedx_lti_tool_plugin/deep_linking/views.py +++ b/openedx_lti_tool_plugin/deep_linking/views.py @@ -2,6 +2,7 @@ from typing import Union from uuid import uuid4 +from django.conf import settings from django.http import HttpResponse from django.http.request import HttpRequest from django.shortcuts import redirect, render @@ -16,6 +17,7 @@ from openedx_lti_tool_plugin.deep_linking.exceptions import DeepLinkingException from openedx_lti_tool_plugin.deep_linking.forms import DeepLinkingForm from openedx_lti_tool_plugin.deep_linking.utils import validate_deep_linking_message +from openedx_lti_tool_plugin.edxapp_wrapper.site_configuration_module import configuration_helpers from openedx_lti_tool_plugin.http import LoggedHttpResponseBadRequest from openedx_lti_tool_plugin.views import LTIToolView @@ -112,13 +114,11 @@ def get( # Render form template. return render( request, - 'openedx_lti_tool_plugin/deep_linking/form.html', + configuration_helpers().get_value( + 'OLTITP_DEEP_LINKING_FORM_TEMPLATE', + settings.OLTITP_DEEP_LINKING_FORM_TEMPLATE, + ), { - 'form': self.form_class( - request=request, - launch_data=message.get_launch_data(), - ), - 'form_url': f'{app_config.name}:1.3:deep-linking:form', 'launch_id': launch_id, }, ) @@ -149,11 +149,7 @@ def post( # Validate message. validate_deep_linking_message(message) # Initialize form. - form = self.form_class( - request.POST, - request=request, - launch_data=message.get_launch_data(), - ) + form = self.form_class(request.POST) # Validate form. if not form.is_valid(): raise DeepLinkingException(form.errors) diff --git a/openedx_lti_tool_plugin/settings/common.py b/openedx_lti_tool_plugin/settings/common.py index c8cd22b..8946ef4 100644 --- a/openedx_lti_tool_plugin/settings/common.py +++ b/openedx_lti_tool_plugin/settings/common.py @@ -44,7 +44,7 @@ def plugin_settings(settings: LazySettings): settings.OLTITP_ENABLE_LTI_TOOL = False # Deep linking settings - settings.OLTITP_DEEP_LINKING_CONTENT_ITEMS_PROVIDER = None + settings.OLTITP_DEEP_LINKING_FORM_TEMPLATE = 'openedx_lti_tool_plugin/deep_linking/form.html' # Backends settings settings.OLTITP_CORE_SIGNALS_BACKEND = f'{BACKENDS_MODULE_PATH}.core_signals_module_o_v1' diff --git a/openedx_lti_tool_plugin/settings/test.py b/openedx_lti_tool_plugin/settings/test.py index d0fd3d8..8863048 100644 --- a/openedx_lti_tool_plugin/settings/test.py +++ b/openedx_lti_tool_plugin/settings/test.py @@ -70,7 +70,7 @@ OLTITP_ENABLE_LTI_TOOL = True # Deep linking settings -OLTITP_DEEP_LINKING_CONTENT_ITEMS_PROVIDER = None +OLTITP_DEEP_LINKING_FORM_TEMPLATE = 'openedx_lti_tool_plugin/deep_linking/form.html' # Backend settings OLTITP_TEST_BACKEND_MODULE_PATH = 'openedx_lti_tool_plugin.tests.backends_for_tests' diff --git a/openedx_lti_tool_plugin/templates/openedx_lti_tool_plugin/base.html b/openedx_lti_tool_plugin/templates/openedx_lti_tool_plugin/base.html index 909b82f..c4a6911 100644 --- a/openedx_lti_tool_plugin/templates/openedx_lti_tool_plugin/base.html +++ b/openedx_lti_tool_plugin/templates/openedx_lti_tool_plugin/base.html @@ -1,11 +1,20 @@ + {% block head %} {% block title %}{% endblock %} + {% block head_styles %} + + {% endblock %} + {% block head_js %}{% endblock %} + {% endblock %} - {% block content %}{% endblock %} + {% block body %} + {% block body_content %}{% endblock %} + {% block body_js %}{% endblock %} + {% endblock %} diff --git a/openedx_lti_tool_plugin/templates/openedx_lti_tool_plugin/deep_linking/form.html b/openedx_lti_tool_plugin/templates/openedx_lti_tool_plugin/deep_linking/form.html index 2faa3d1..ae31246 100644 --- a/openedx_lti_tool_plugin/templates/openedx_lti_tool_plugin/deep_linking/form.html +++ b/openedx_lti_tool_plugin/templates/openedx_lti_tool_plugin/deep_linking/form.html @@ -1,11 +1,81 @@ {% extends 'openedx_lti_tool_plugin/base.html' %} {% load i18n %} -{% block title %}Deep Linking{% endblock %} -{% block content %} -
- {% csrf_token %} - {{ form }} - -
+{% block title %}{% translate 'Deep Linking UI' %}{% endblock %} + +{% block head %} +{{ block.super }} + + +{% endblock %} + +{% block body_content %} +{{ block.super }} +
+
+
+
+ {% csrf_token %} +
+ +
+
+
+
+{% endblock %} + +{% block body_js %} +{{ block.super }} + + {% endblock %} diff --git a/openedx_lti_tool_plugin/tests/test_validators.py b/openedx_lti_tool_plugin/tests/test_validators.py new file mode 100644 index 0000000..bd07c4b --- /dev/null +++ b/openedx_lti_tool_plugin/tests/test_validators.py @@ -0,0 +1,60 @@ +"""Tests validators module.""" +from unittest.mock import MagicMock, patch + +import jsonschema +from django.core.exceptions import ValidationError +from django.test import TestCase + +from openedx_lti_tool_plugin.tests import MODULE_PATH +from openedx_lti_tool_plugin.validators import JSONSchemaValidator + +MODULE_PATH = f'{MODULE_PATH}.validators' + + +class TestJSONSchemaValidator(TestCase): + """Test JSONSchemaValidator class.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.validator_class = JSONSchemaValidator + self.schema = {} + self.value = {} + self.message = 'test-message' + + def test_init(self): + """Test __init__ method.""" + instance = self.validator_class(self.schema) + + self.assertEqual(instance.schema, self.schema) + + @patch(f'{MODULE_PATH}.jsonschema.validate') + def test_call_with_valid_json(self, validate_mock: MagicMock): + """Test __call__ method with valid JSON value.""" + self.assertEqual( + self.validator_class(self.schema)(self.value), + self.value, + ) + validate_mock.assert_called_once_with(self.value, self.schema) + + @patch(f'{MODULE_PATH}.jsonschema.validate') + def test_call_with_validation_error(self, validate_mock: MagicMock): + """Test __call__ method with jsonschema.ValidationError.""" + validate_mock.side_effect = jsonschema.ValidationError(self.message) + + with self.assertRaises(ValidationError) as ctxm: + self.validator_class(self.schema)(self.value) + + self.assertEqual(str([self.message]), str(ctxm.exception)) + validate_mock.assert_called_once_with(self.value, self.schema) + + @patch(f'{MODULE_PATH}.jsonschema.validate') + def test_call_with_schema_error(self, validate_mock: MagicMock): + """Test __call__ method with jsonschema.SchemaError.""" + validate_mock.side_effect = jsonschema.SchemaError(self.message) + + with self.assertRaises(ValidationError) as ctxm: + self.validator_class(self.schema)(self.value) + + self.assertEqual(str([self.message]), str(ctxm.exception)) + validate_mock.assert_called_once_with(self.value, self.schema) diff --git a/openedx_lti_tool_plugin/validators.py b/openedx_lti_tool_plugin/validators.py new file mode 100644 index 0000000..3877e40 --- /dev/null +++ b/openedx_lti_tool_plugin/validators.py @@ -0,0 +1,42 @@ +"""Validators.""" +from typing import Any + +import jsonschema +from django.core.exceptions import ValidationError +from django.utils.deconstruct import deconstructible + + +@deconstructible +class JSONSchemaValidator: + """JSON Schema Validator. + + .. _JSON Schema Documentation: + https://json-schema.org/docs + + .. _JSON Schema specification for Python: + https://python-jsonschema.readthedocs.io/en/latest/ + + """ + + def __init__(self, schema: dict): + """Initialize class instance. + + Args: + schema: JSON Schema dictionary. + + """ + self.schema = schema + + def __call__(self, value: Any) -> Any: + """Validate value JSON Schema. + + Args: + value: JSON value. + + """ + try: + jsonschema.validate(value, self.schema) + except (jsonschema.ValidationError, jsonschema.SchemaError) as exc: + raise ValidationError(str(exc)) from exc + + return value diff --git a/requirements/base.in b/requirements/base.in index 3d7fd4a..a663ee4 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -10,3 +10,4 @@ celery # Asynchronous task execution library shortuuid # Library that generates concise, unambiguous, URL-safe UUIDs djangorestframework # Django REST framework is a powerful and flexible toolkit for building Web APIs. edx-drf-extensions # edX Django REST Framework Extensions +jsonschema # JSON Schema data validation diff --git a/requirements/base.txt b/requirements/base.txt index b354763..e2c6c57 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -12,6 +12,10 @@ asgiref==3.5.2 # via # -c requirements/constraints.txt # django +attrs==22.1.0 + # via + # -c requirements/constraints.txt + # jsonschema billiard==3.6.4.0 # via # -c requirements/constraints.txt @@ -120,10 +124,18 @@ idna==3.4 # via # -c requirements/constraints.txt # requests +importlib-resources==5.10.0 + # via + # -c requirements/constraints.txt + # jsonschema jinja2==3.1.2 # via # -c requirements/constraints.txt # code-annotations +jsonschema==4.16.0 + # via + # -c requirements/constraints.txt + # -r requirements/base.in jwcrypto==1.4.2 # via # -c requirements/constraints.txt @@ -144,6 +156,10 @@ pbr==5.10.0 # via # -c requirements/constraints.txt # stevedore +pkgutil-resolve-name==1.3.10 + # via + # -c requirements/constraints.txt + # jsonschema prompt-toolkit==3.0.31 # via # -c requirements/constraints.txt @@ -174,6 +190,10 @@ pynacl==1.5.0 # via # -c requirements/constraints.txt # edx-django-utils +pyrsistent==0.18.1 + # via + # -c requirements/constraints.txt + # jsonschema python-slugify==6.1.2 # via # -c requirements/constraints.txt @@ -236,3 +256,7 @@ wrapt==1.14.1 # via # -c requirements/constraints.txt # deprecated +zipp==3.9.0 + # via + # -c requirements/constraints.txt + # importlib-resources diff --git a/requirements/constraints.txt b/requirements/constraints.txt index b1f967b..7ff6fe2 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -9,17 +9,18 @@ # linking to it here is good. # use edx-platform package versions. -amqp==5.1.1 -billiard==3.6.4.0 -celery<=5.2.7 -click<=8.1.3 -click-didyoumean==0.3.0 -click-plugins==1.1.1 -click-repl==0.2.0 +amqp<=5.1.1 asgiref<=3.5.2 +attrs<=22.1.0 +billiard<=3.6.4.0 +celery<=5.2.7 certifi<=2022.9.24 cffi<=1.15.1 charset-normalizer<2.1.0 +click-didyoumean<=0.3.0 +click-plugins<=1.1.1 +click-repl<=0.2.0 +click<=8.1.3 code-annotations<=1.3.0 cryptography<=36.0.2 deprecated<=1.2.13 @@ -33,12 +34,15 @@ edx-drf-extensions<=10.3.0 edx-opaque-keys<=2.3.0 edx-toggles<=5.0.0 idna<=3.4 +importlib-resources<=5.10.0 jinja2<=3.1.2 +jsonschema<=4.16.0 jwcrypto<=1.4.2 -kombu==5.2.4 +kombu<=5.2.4 markupsafe<=2.1.1 newrelic<=8.2.1 pbr<=5.10.0 +pkgutil-resolve-name<=1.3.10 prompt-toolkit==3.0.31 psutil<=5.9.2 pycparser<=2.21 @@ -47,11 +51,12 @@ pyjwt[crypto]<=2.5.0 pylti1p3<=1.12.1 pymongo<=3.12.3 pynacl<=1.5.0 +pyrsistent<=0.18.1 python-slugify<=6.1.2 pytz<=2022.2.1 pyyaml<=6.0 requests<=2.28.1 -six==1.16.0 +six<=1.16.0 sqlparse<=0.4.3 stevedore<=4.0.0 text-unidecode<=1.3 @@ -59,6 +64,7 @@ tox<4.0.0 types-cryptography<=3.3.23.2 typing-extensions<=4.4.0 urllib3<=1.26.12 -vine==5.0.0 +vine<=5.0.0 wcwidth<=0.2.8 wrapt<=1.14.1 +zipp<=3.9.0 diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt index 931538a..46235c4 100644 --- a/requirements/pip-tools.txt +++ b/requirements/pip-tools.txt @@ -28,8 +28,10 @@ wheel==0.44.0 # via # -r requirements/pip-tools.in # pip-tools -zipp==3.20.0 - # via importlib-metadata +zipp==3.9.0 + # via + # -c requirements/constraints.txt + # importlib-metadata # The following packages are considered to be unsafe in a requirements file: pip==24.2 diff --git a/requirements/test.txt b/requirements/test.txt index 43f02ff..7237d10 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -16,10 +16,11 @@ asgiref==3.5.2 # django astroid==3.2.4 # via pylint -attrs==24.2.0 +attrs==22.1.0 # via + # -c requirements/constraints.txt + # -r requirements/base.txt # jsonschema - # referencing billiard==3.6.4.0 # via # -c requirements/constraints.txt @@ -155,10 +156,11 @@ idna==3.4 # -c requirements/constraints.txt # -r requirements/base.txt # requests -importlib-resources==6.4.0 +importlib-resources==5.10.0 # via + # -c requirements/constraints.txt + # -r requirements/base.txt # jsonschema - # jsonschema-specifications iniconfig==2.0.0 # via pytest isort==5.13.2 @@ -170,10 +172,11 @@ jinja2==3.1.2 # -c requirements/constraints.txt # -r requirements/base.txt # code-annotations -jsonschema==4.23.0 - # via -r requirements/test.in -jsonschema-specifications==2023.12.1 - # via jsonschema +jsonschema==4.16.0 + # via + # -c requirements/constraints.txt + # -r requirements/base.txt + # -r requirements/test.in jwcrypto==1.4.2 # via # -c requirements/constraints.txt @@ -206,7 +209,10 @@ pbr==5.10.0 # -r requirements/base.txt # stevedore pkgutil-resolve-name==1.3.10 - # via jsonschema + # via + # -c requirements/constraints.txt + # -r requirements/base.txt + # jsonschema platformdirs==4.2.2 # via # pylint @@ -242,7 +248,6 @@ pyjwt[crypto]==2.5.0 # -r requirements/base.txt # drf-jwt # edx-drf-extensions - # pyjwt # pylti1p3 pylint==3.2.6 # via @@ -267,6 +272,11 @@ pynacl==1.5.0 # -c requirements/constraints.txt # -r requirements/base.txt # edx-django-utils +pyrsistent==0.18.1 + # via + # -c requirements/constraints.txt + # -r requirements/base.txt + # jsonschema pytest==8.3.2 # via pytest-django pytest-django==4.8.0 @@ -287,20 +297,12 @@ pyyaml==6.0 # -c requirements/constraints.txt # -r requirements/base.txt # code-annotations -referencing==0.35.1 - # via - # jsonschema - # jsonschema-specifications requests==2.28.1 # via # -c requirements/constraints.txt # -r requirements/base.txt # edx-drf-extensions # pylti1p3 -rpds-py==0.20.0 - # via - # jsonschema - # referencing semantic-version==2.10.0 # via # -r requirements/base.txt @@ -379,5 +381,8 @@ wrapt==1.14.1 # -c requirements/constraints.txt # -r requirements/base.txt # deprecated -zipp==3.20.0 - # via importlib-resources +zipp==3.9.0 + # via + # -c requirements/constraints.txt + # -r requirements/base.txt + # importlib-resources