From 08015cfb0185ebe59c3cbd5a23d942a1309ff26d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Montilla?= Date: Thu, 16 May 2024 20:49:30 +0000 Subject: [PATCH] feat: Add DeepLinkingForm to DeepLinkingView. --- openedx_lti_tool_plugin/deep_linking/forms.py | 88 +++++++++++++++ .../deep_linking/tests/test_forms.py | 106 ++++++++++++++++++ .../deep_linking/tests/test_views.py | 75 ++++++++++++- openedx_lti_tool_plugin/deep_linking/views.py | 34 ++++-- .../backends/learning_sequences_o_v1.py | 8 ++ .../edxapp_wrapper/learning_sequences.py | 11 ++ openedx_lti_tool_plugin/settings/common.py | 1 + openedx_lti_tool_plugin/settings/test.py | 1 + .../openedx_lti_tool_plugin/base.html | 11 ++ .../deep_linking/form.html | 11 ++ .../tests/backends_for_tests.py | 5 + 11 files changed, 338 insertions(+), 13 deletions(-) create mode 100644 openedx_lti_tool_plugin/deep_linking/forms.py create mode 100644 openedx_lti_tool_plugin/deep_linking/tests/test_forms.py create mode 100644 openedx_lti_tool_plugin/edxapp_wrapper/backends/learning_sequences_o_v1.py create mode 100644 openedx_lti_tool_plugin/edxapp_wrapper/learning_sequences.py create mode 100644 openedx_lti_tool_plugin/templates/openedx_lti_tool_plugin/base.html create mode 100644 openedx_lti_tool_plugin/templates/openedx_lti_tool_plugin/deep_linking/form.html diff --git a/openedx_lti_tool_plugin/deep_linking/forms.py b/openedx_lti_tool_plugin/deep_linking/forms.py new file mode 100644 index 0000000..14a2c05 --- /dev/null +++ b/openedx_lti_tool_plugin/deep_linking/forms.py @@ -0,0 +1,88 @@ +"""Django Forms.""" +from typing import List, Optional, Set, Tuple + +from django import forms +from django.http.request import HttpRequest +from django.urls import reverse +from django.utils.translation import gettext as _ +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.learning_sequences import course_context + + +class DeepLinkingForm(forms.Form): + """Deep Linking Form.""" + + def __init__(self, *args: tuple, request=None, **kwargs: dict): + """Class __init__ method. + + Initialize class instance attributes and add `content_items` field. + + Args: + *args: Variable length argument list. + request: HttpRequest object. + **kwargs: Arbitrary keyword arguments. + + """ + super().__init__(*args, **kwargs) + self.fields['content_items'] = forms.MultipleChoiceField( + choices=self.get_content_items_choices(request), + required=False, + widget=forms.CheckboxSelectMultiple, + label=_('Courses'), + ) + + def get_content_items_choices(self, request: HttpRequest) -> List[Tuple[str, str]]: + """Get `content_items` field choices. + + Args: + request: HttpRequest object. + + Returns: + List of tuples with choices for the `content_items` field. + + """ + return [ + self.get_content_items_choice(course, request) + for course in course_context().objects.all() + ] + + def get_content_items_choice(self, course, request: HttpRequest) -> Tuple[str, str]: + """Get `content_items` field choice. + + Args: + course (CourseContext): Course object. + request: HttpRequest object. + + Returns: + Tuple containing the choice value and name. + + .. _LTI Deep Linking Specification - LTI Resource Link: + https://www.imsglobal.org/spec/lti-dl/v2p0#lti-resource-link + + """ + relative_url = reverse( + f'{app_config.name}:1.3:resource-link:launch-course', + kwargs={'course_id': course.learning_context.context_key}, + ) + + return ( + request.build_absolute_uri(relative_url), + course.learning_context.title, + ) + + def get_deep_link_resources(self) -> Set[Optional[DeepLinkResource]]: + """Get DeepLinkResource objects from this form `cleaned_data` attribute. + + Returns: + Set of DeepLinkResource objects or an empty set + + .. _LTI 1.3 Advantage Tool implementation in Python - LTI Message Launches: + https://github.com/dmitry-viskov/pylti1.3?tab=readme-ov-file#deep-linking-responses + + """ + return { + DeepLinkResource().set_url(content_item) + for content_item in self.cleaned_data.get('content_items', []) + } diff --git a/openedx_lti_tool_plugin/deep_linking/tests/test_forms.py b/openedx_lti_tool_plugin/deep_linking/tests/test_forms.py new file mode 100644 index 0000000..2e512ba --- /dev/null +++ b/openedx_lti_tool_plugin/deep_linking/tests/test_forms.py @@ -0,0 +1,106 @@ +"""Tests forms module.""" +from unittest.mock import MagicMock, patch + +from django import forms +from django.test import TestCase + +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 + +MODULE_PATH = f'{MODULE_PATH}.forms' + + +class TestDeepLinkingForm(TestCase): + """Test DeepLinkingForm class.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.form_class = DeepLinkingForm + self.request = MagicMock() + self.learning_context = MagicMock(context_key='random-course-key', title='Test') + self.course = MagicMock(learning_context=self.learning_context) + + @patch(f'{MODULE_PATH}.forms.MultipleChoiceField') + @patch(f'{MODULE_PATH}._') + @patch.object(DeepLinkingForm, 'get_content_items_choices') + def test_init( + self, + get_content_items_choices_mock: MagicMock, + gettext_mock: MagicMock, + multiple_choice_field_mock: MagicMock, + ): + """Test `__init__` method.""" + self.assertEqual( + self.form_class(request=self.request).fields, + {'content_items': multiple_choice_field_mock.return_value}, + ) + get_content_items_choices_mock.assert_called_once_with(self.request) + gettext_mock.assert_called_once_with('Courses') + multiple_choice_field_mock.assert_called_once_with( + choices=get_content_items_choices_mock(), + required=False, + widget=forms.CheckboxSelectMultiple, + label=gettext_mock(), + ) + + @patch(f'{MODULE_PATH}.course_context') + @patch.object(DeepLinkingForm, 'get_content_items_choice') + @patch.object(DeepLinkingForm, '__init__', return_value=None) + def test_get_content_items_choices( + self, + deep_linking_form_init: MagicMock, # pylint: disable=unused-argument + get_content_items_choice_mock: MagicMock, + course_context_mock: MagicMock, + ): + """Test `get_content_items_choices` method.""" + course_context_mock.return_value.objects.all.return_value = [self.course] + + self.assertEqual( + self.form_class().get_content_items_choices(self.request), + [get_content_items_choice_mock.return_value], + ) + course_context_mock.assert_called_once_with() + course_context_mock().objects.all.assert_called_once_with() + get_content_items_choice_mock.assert_called_once_with(self.course, self.request) + + @patch(f'{MODULE_PATH}.reverse') + @patch.object(DeepLinkingForm, '__init__', return_value=None) + def test_get_content_items_choice( + self, + deep_linking_form_init: MagicMock, # pylint: disable=unused-argument + reverse_mock: MagicMock, + ): + """Test `get_content_items_choice` method.""" + self.assertEqual( + self.form_class().get_content_items_choice(self.course, self.request), + ( + self.request.build_absolute_uri.return_value, + self.course.learning_context.title, + ), + ) + reverse_mock.assert_called_once_with( + f'{app_config.name}:1.3:resource-link:launch-course', + kwargs={'course_id': self.course.learning_context.context_key}, + ) + self.request.build_absolute_uri.assert_called_once_with(reverse_mock()) + + @patch(f'{MODULE_PATH}.DeepLinkResource') + @patch.object(DeepLinkingForm, '__init__', return_value=None) + def test_get_deep_link_resources( + self, + deep_linking_form_init: MagicMock, # pylint: disable=unused-argument + deep_link_resource_mock: MagicMock, + ): + """Test `get_deep_link_resources` method.""" + content_item = 'https://example.com' + form = self.form_class() + form.cleaned_data = {'content_items': [content_item]} + + self.assertEqual( + form.get_deep_link_resources(), + {deep_link_resource_mock.return_value.set_url.return_value}, + ) + deep_link_resource_mock.assert_called_once_with() + deep_link_resource_mock().set_url.assert_called_once_with(content_item) 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 bf3dfbf..a7ada99 100644 --- a/openedx_lti_tool_plugin/deep_linking/tests/test_views.py +++ b/openedx_lti_tool_plugin/deep_linking/tests/test_views.py @@ -8,6 +8,7 @@ from openedx_lti_tool_plugin.apps import OpenEdxLtiToolPluginConfig as app_config 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.tests import MODULE_PATH from openedx_lti_tool_plugin.deep_linking.views import ( DeepLinkingFormView, @@ -144,8 +145,17 @@ def test_raises_deep_linking_exception( http_response_error_mock.assert_called_once_with(exception) +class TestDeepLinkingFormView(TestCase): + """Test DeepLinkingFormView.""" + + def test_class_attributes(self): + """Test class attributes.""" + self.assertEqual(DeepLinkingFormView.form_class, DeepLinkingForm) + + @patch.object(DeepLinkingFormView, 'get_message_from_cache') @patch(f'{MODULE_PATH}.validate_deep_linking_message') +@patch.object(DeepLinkingFormView, 'form_class') class TestDeepLinkingFormViewGet(TestCase): """Test DeepLinkingFormView get method.""" @@ -158,26 +168,37 @@ def setUp(self): self.url = reverse('1.3:deep-linking:form', args=[self.launch_id]) self.request = self.factory.get(self.url) - @patch(f'{MODULE_PATH}.HttpResponse') + @patch(f'{MODULE_PATH}.render') def test_get( self, - http_response_mock: MagicMock, + render_mock: MagicMock, + form_class_mock: MagicMock, validate_deep_linking_message_mock: MagicMock, get_message_from_cache_mock: MagicMock, ): """Test `get` method with valid launch_id (happy path).""" self.assertEqual( self.view_class.as_view()(self.request, self.launch_id), - http_response_mock.return_value, + render_mock.return_value, ) 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()) - http_response_mock.assert_called_once_with() + form_class_mock.assert_called_once_with(request=self.request) + render_mock.assert_called_once_with( + self.request, + 'openedx_lti_tool_plugin/deep_linking/form.html', + { + 'form': form_class_mock(), + 'form_url': f'{app_config.name}:1.3:deep-linking:form', + 'launch_id': self.launch_id, + }, + ) @patch.object(DeepLinkingFormView, 'http_response_error') def test_raises_lti_exception( self, http_response_error_mock: MagicMock, + form_class_mock: MagicMock, validate_deep_linking_message_mock: MagicMock, get_message_from_cache_mock: MagicMock, ): @@ -191,12 +212,14 @@ def test_raises_lti_exception( ) get_message_from_cache_mock.assert_called_once_with(self.request, self.launch_id) validate_deep_linking_message_mock.assert_not_called() + form_class_mock.assert_not_called() http_response_error_mock.assert_called_once_with(exception) @patch.object(DeepLinkingFormView, 'http_response_error') def test_raises_deep_linking_exception( self, http_response_error_mock: MagicMock, + form_class_mock: MagicMock, validate_deep_linking_message_mock: MagicMock, get_message_from_cache_mock: MagicMock, ): @@ -210,11 +233,13 @@ def test_raises_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()) + form_class_mock.assert_not_called() http_response_error_mock.assert_called_once_with(exception) @patch.object(DeepLinkingFormView, 'get_message_from_cache') @patch(f'{MODULE_PATH}.validate_deep_linking_message') +@patch.object(DeepLinkingFormView, 'form_class') class TestDeepLinkingFormViewPost(TestCase): """Test DeepLinkingFormView post method.""" @@ -231,6 +256,7 @@ def setUp(self): def test_post( self, http_response_mock: MagicMock, + form_class_mock: MagicMock, validate_deep_linking_message_mock: MagicMock, get_message_from_cache_mock: MagicMock, ): @@ -239,14 +265,48 @@ def test_post( self.view_class.as_view()(self.request, self.launch_id), http_response_mock.return_value, ) + form_class_mock.assert_called_once_with(self.request.POST, request=self.request) + form_class_mock().is_valid.assert_called_once_with() 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()) - http_response_mock.assert_called_once_with() + form_class_mock().get_deep_link_resources.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( + form_class_mock().get_deep_link_resources(), + ) + http_response_mock.assert_called_once_with( + get_message_from_cache_mock().get_deep_link().output_response_form(), + ) + + @patch.object(DeepLinkingFormView, 'http_response_error') + def test_with_invalid_form( + self, + http_response_error_mock: MagicMock, + form_class_mock: MagicMock, + validate_deep_linking_message_mock: MagicMock, + get_message_from_cache_mock: MagicMock, + ): + """Test with invalid form.""" + form_class_mock.return_value.is_valid.return_value = False + + self.assertEqual( + self.view_class.as_view()(self.request, self.launch_id), + http_response_error_mock.return_value, + ) + form_class_mock.assert_called_once_with(self.request.POST, request=self.request) + form_class_mock().is_valid.assert_called_once_with() + get_message_from_cache_mock.assert_not_called() + validate_deep_linking_message_mock.assert_not_called() + form_class_mock().get_deep_link_resources.assert_not_called() + 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() + http_response_error_mock.assert_called_once() @patch.object(DeepLinkingFormView, 'http_response_error') def test_raises_lti_exception( self, http_response_error_mock: MagicMock, + form_class_mock: MagicMock, validate_deep_linking_message_mock: MagicMock, get_message_from_cache_mock: MagicMock, ): @@ -258,6 +318,8 @@ def test_raises_lti_exception( self.view_class.as_view()(self.request, self.launch_id), http_response_error_mock.return_value, ) + form_class_mock.assert_called_once_with(self.request.POST, request=self.request) + form_class_mock().is_valid.assert_called_once_with() get_message_from_cache_mock.assert_called_once_with(self.request, self.launch_id) validate_deep_linking_message_mock.assert_not_called() http_response_error_mock.assert_called_once_with(exception) @@ -266,6 +328,7 @@ def test_raises_lti_exception( def test_raises_deep_linking_exception( self, http_response_error_mock: MagicMock, + form_class_mock: MagicMock, validate_deep_linking_message_mock: MagicMock, get_message_from_cache_mock: MagicMock, ): @@ -277,6 +340,8 @@ def test_raises_deep_linking_exception( self.view_class.as_view()(self.request, self.launch_id), http_response_error_mock.return_value, ) + form_class_mock.assert_called_once_with(self.request.POST, request=self.request) + form_class_mock().is_valid.assert_called_once_with() 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()) http_response_error_mock.assert_called_once_with(exception) diff --git a/openedx_lti_tool_plugin/deep_linking/views.py b/openedx_lti_tool_plugin/deep_linking/views.py index b9387b4..bfe87d7 100644 --- a/openedx_lti_tool_plugin/deep_linking/views.py +++ b/openedx_lti_tool_plugin/deep_linking/views.py @@ -4,7 +4,7 @@ from django.http import HttpResponse from django.http.request import HttpRequest -from django.shortcuts import redirect +from django.shortcuts import redirect, render from django.utils.decorators import method_decorator from django.utils.translation import gettext as _ from django.views.decorators.clickjacking import xframe_options_exempt @@ -14,6 +14,7 @@ from openedx_lti_tool_plugin.apps import OpenEdxLtiToolPluginConfig as app_config 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.http import LoggedHttpResponseBadRequest from openedx_lti_tool_plugin.views import LtiToolBaseView @@ -90,6 +91,9 @@ class DeepLinkingFormView(LtiToolBaseView): or more specific items to integrate back into the platform and also redirect the user's browser back to the platform along with details of the item(s) selected. + Attributes: + form_class (DeepLinkingForm): View Form class. + .. _LTI Deep Linking Specification - Workflow: https://www.imsglobal.org/spec/lti-dl/v2p0#workflow @@ -98,6 +102,8 @@ class DeepLinkingFormView(LtiToolBaseView): """ + form_class = DeepLinkingForm + def get( self, request: HttpRequest, @@ -119,9 +125,15 @@ def get( message = self.get_message_from_cache(request, launch_id) validate_deep_linking_message(message) - # TODO: Add template response with form to allow user to select - # the resource links they want and the launch message launch_id. - return HttpResponse() + return render( + request, + 'openedx_lti_tool_plugin/deep_linking/form.html', + { + 'form': self.form_class(request=request), + 'form_url': f'{app_config.name}:1.3:deep-linking:form', + 'launch_id': launch_id, + }, + ) except (LtiException, DeepLinkingException) as exc: return self.http_response_error(exc) @@ -144,13 +156,19 @@ def post( """ try: + form = self.form_class(request.POST, request=request) + + if not form.is_valid(): + raise DeepLinkingException(form.errors) + message = self.get_message_from_cache(request, launch_id) validate_deep_linking_message(message) - # TODO: Add code to obtain form and validate form with resource link data and - # add code to process form cleaned data and generate the Deep Linking response: - # https://github.com/dmitry-viskov/pylti1.3?tab=readme-ov-file#deep-linking-responses - return HttpResponse() + return HttpResponse( + message.get_deep_link().output_response_form( + form.get_deep_link_resources(), + ) + ) except (LtiException, DeepLinkingException) as exc: return self.http_response_error(exc) diff --git a/openedx_lti_tool_plugin/edxapp_wrapper/backends/learning_sequences_o_v1.py b/openedx_lti_tool_plugin/edxapp_wrapper/backends/learning_sequences_o_v1.py new file mode 100644 index 0000000..b5d644c --- /dev/null +++ b/openedx_lti_tool_plugin/edxapp_wrapper/backends/learning_sequences_o_v1.py @@ -0,0 +1,8 @@ +"""learning_sequences module backend (olive v1).""" +from openedx.core.djangoapps.content.learning_sequences.models import \ + CourseContext # type: ignore # pylint: disable=import-error + + +def course_context_backend(): + """Return CourseContext class.""" + return CourseContext diff --git a/openedx_lti_tool_plugin/edxapp_wrapper/learning_sequences.py b/openedx_lti_tool_plugin/edxapp_wrapper/learning_sequences.py new file mode 100644 index 0000000..0c8af8c --- /dev/null +++ b/openedx_lti_tool_plugin/edxapp_wrapper/learning_sequences.py @@ -0,0 +1,11 @@ +"""edx-platform learning_sequences module wrapper.""" +from importlib import import_module + +from django.conf import settings + + +def course_context(): + """Return CourseContext class.""" + return import_module( + settings.OLTITP_LEARNING_SEQUENCES_BACKEND, + ).course_context_backend() diff --git a/openedx_lti_tool_plugin/settings/common.py b/openedx_lti_tool_plugin/settings/common.py index 67700ab..126025d 100644 --- a/openedx_lti_tool_plugin/settings/common.py +++ b/openedx_lti_tool_plugin/settings/common.py @@ -50,3 +50,4 @@ def plugin_settings(settings: LazySettings): settings.OLTITP_STUDENT_BACKEND = f'{BACKENDS_MODULE_PATH}.student_module_o_v1' settings.OLTITP_GRADES_BACKEND = f'{BACKENDS_MODULE_PATH}.grades_module_o_v1' settings.OLTITP_USER_AUTHN_BACKEND = f'{BACKENDS_MODULE_PATH}.user_authn_module_o_v1' + settings.OLTITP_LEARNING_SEQUENCES_BACKEND = f'{BACKENDS_MODULE_PATH}.learning_sequences_o_v1' diff --git a/openedx_lti_tool_plugin/settings/test.py b/openedx_lti_tool_plugin/settings/test.py index 6c63633..975cfe1 100644 --- a/openedx_lti_tool_plugin/settings/test.py +++ b/openedx_lti_tool_plugin/settings/test.py @@ -72,3 +72,4 @@ OLTITP_STUDENT_BACKEND = OLTITP_TEST_BACKEND_MODULE_PATH OLTITP_GRADES_BACKEND = OLTITP_TEST_BACKEND_MODULE_PATH OLTITP_USER_AUTHN_BACKEND = OLTITP_TEST_BACKEND_MODULE_PATH +OLTITP_LEARNING_SEQUENCES_BACKEND = OLTITP_TEST_BACKEND_MODULE_PATH 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 new file mode 100644 index 0000000..909b82f --- /dev/null +++ b/openedx_lti_tool_plugin/templates/openedx_lti_tool_plugin/base.html @@ -0,0 +1,11 @@ + + + + + + {% block title %}{% endblock %} + + + {% block content %}{% 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 new file mode 100644 index 0000000..2faa3d1 --- /dev/null +++ b/openedx_lti_tool_plugin/templates/openedx_lti_tool_plugin/deep_linking/form.html @@ -0,0 +1,11 @@ +{% extends 'openedx_lti_tool_plugin/base.html' %} +{% load i18n %} + +{% block title %}Deep Linking{% endblock %} +{% block content %} +
+ {% csrf_token %} + {{ form }} + +
+{% endblock %} diff --git a/openedx_lti_tool_plugin/tests/backends_for_tests.py b/openedx_lti_tool_plugin/tests/backends_for_tests.py index 3c9a021..90c5bde 100644 --- a/openedx_lti_tool_plugin/tests/backends_for_tests.py +++ b/openedx_lti_tool_plugin/tests/backends_for_tests.py @@ -50,3 +50,8 @@ def set_logged_in_cookies_backend(*args: tuple, **kwargs: dict): **kwargs: Arbitrary keyword arguments. """ return Mock() + + +def course_context_backend(): + """Return CourseContext mock function.""" + return Mock()