From 6fbbbc7bfb5ad97d7e10ed4bb88c5f27263ad8a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Montilla?= Date: Thu, 4 Jul 2024 05:05:52 +0000 Subject: [PATCH] feat: Get DeepLinkingForm content_items choices from provider function refactor: DeepLinkingForm --- openedx_lti_tool_plugin/deep_linking/forms.py | 212 +++++++---- .../deep_linking/tests/test_forms.py | 334 +++++++++++------- .../deep_linking/tests/test_views.py | 156 ++++---- openedx_lti_tool_plugin/deep_linking/views.py | 11 +- openedx_lti_tool_plugin/models.py | 67 +++- openedx_lti_tool_plugin/settings/common.py | 4 + openedx_lti_tool_plugin/settings/test.py | 17 +- .../tests/backends_for_tests.py | 10 +- openedx_lti_tool_plugin/tests/test_models.py | 131 ++++++- 9 files changed, 645 insertions(+), 297 deletions(-) diff --git a/openedx_lti_tool_plugin/deep_linking/forms.py b/openedx_lti_tool_plugin/deep_linking/forms.py index 02a0ae5..d8a9b85 100644 --- a/openedx_lti_tool_plugin/deep_linking/forms.py +++ b/openedx_lti_tool_plugin/deep_linking/forms.py @@ -1,131 +1,215 @@ """Django Forms.""" import json -from typing import Optional, Set, Tuple +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 pylti1p3.contrib.django.lti1p3_tool_config.models import LtiTool +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.deep_linking.exceptions import DeepLinkingException -from openedx_lti_tool_plugin.edxapp_wrapper.learning_sequences import course_context -from openedx_lti_tool_plugin.models import CourseAccessConfiguration -from openedx_lti_tool_plugin.waffle import COURSE_ACCESS_CONFIGURATION +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 + +log = logging.getLogger(__name__) class DeepLinkingForm(forms.Form): """Deep Linking Form.""" + CONTENT_ITEMS_SCHEMA = { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'url': {'type': 'string'}, + 'title': {'type': 'string'}, + }, + 'additionalProperties': True, + }, + } + content_items = forms.MultipleChoiceField( required=False, widget=forms.CheckboxSelectMultiple, - label=_('Courses'), + label=_('Content Items'), ) def __init__( self, *args: tuple, request: HttpRequest, - lti_tool: LtiTool, + launch_data: dict, **kwargs: dict, ): """Class __init__ method. - Initialize class instance attributes and add field choices - to the `content_items` field. + Initialize class instance attributes and set the choices + of the content_items field. Args: *args: Variable length argument list. request: HttpRequest object. - lti_tool: LtiTool model instance. + launch_data: Launch message data. **kwargs: Arbitrary keyword arguments. """ super().__init__(*args, **kwargs) self.request = request - self.lti_tool = lti_tool + self.launch_data = launch_data self.fields['content_items'].choices = self.get_content_items_choices() - def get_content_items_choices(self) -> Set[Optional[Tuple[str, str]]]: - """Get `content_items` field 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: - Set of tuples with choices for the `content_items` field or an empty set. + 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 { - self.get_content_items_choice(course) - for course in self.get_course_contexts() - } + 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_choice(self, course) -> Tuple[str, str]: - """Get `content_items` field choice. + def get_content_items_from_provider(self) -> List[Optional[dict]]: + """Get content items from a provider function. - Args: - course (CourseContext): Course object. + 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: - Tuple containing the choice value and name. + 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 - LTI Resource Link: - https://www.imsglobal.org/spec/lti-dl/v2p0#lti-resource-link + .. _LTI Deep Linking Specification - Content Item Types: + https://www.imsglobal.org/spec/lti-dl/v2p0#content-item-types """ - relative_url = reverse( - f'{app_config.name}:1.3:resource-link:launch-course', - kwargs={'course_id': course.learning_context.context_key}, - ) + if not (setting := configuration_helpers().get_value( + 'OLTITP_DEEP_LINKING_CONTENT_ITEMS_PROVIDER', + settings.OLTITP_DEEP_LINKING_CONTENT_ITEMS_PROVIDER, + )): + return [] - return ( - self.request.build_absolute_uri(relative_url), - course.learning_context.title, - ) + 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) - def get_course_contexts(self): - """Get CourseContext objects. + return content_items - Returns:self.cleaned_data - All CourseContext objects if COURSE_ACCESS_CONFIGURATION switch - is disabled or all CourseContext objects matching the IDs in - the CourseAccessConfiguration `allowed_course_ids` field. + 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}') - Raises: - CourseAccessConfiguration.DoesNotExist: If CourseAccessConfiguration - does not exist for this form `lti_tool` attribute. + 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 """ - if not COURSE_ACCESS_CONFIGURATION.is_enabled(): - return course_context().objects.all() + iss, aud, _sub, _pii = get_identity_claims(self.launch_data) - try: - course_access_config = CourseAccessConfiguration.objects.get( - lti_tool=self.lti_tool, + 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}, ) - except CourseAccessConfiguration.DoesNotExist as exc: - raise DeepLinkingException( - _(f'Course access configuration not found: {self.lti_tool.title}.'), - ) from exc - - return course_context().objects.filter( - learning_context__context_key__in=json.loads( - course_access_config.allowed_course_ids, - ), ) - def get_deep_link_resources(self) -> Set[Optional[DeepLinkResource]]: - """Get DeepLinkResource objects from this form `cleaned_data` attribute. + def clean(self) -> dict: + """Form clean. + + This method will transform all the JSON strings 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. Returns: - Set of DeepLinkResource objects or an empty set + A dictionary with cleaned form data. .. _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', []) - } + super().clean() + 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_title(content_item.get('title')) + deep_link_resource.set_url(content_item.get('url')) + deep_link_resources.append(deep_link_resource) + + self.cleaned_data['deep_link_resources'] = deep_link_resources + + return self.cleaned_data 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 e50d30c..506ae22 100644 --- a/openedx_lti_tool_plugin/deep_linking/tests/test_forms.py +++ b/openedx_lti_tool_plugin/deep_linking/tests/test_forms.py @@ -1,18 +1,20 @@ """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.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.models import CourseAccessConfiguration +from openedx_lti_tool_plugin.tests import AUD, ISS MODULE_PATH = f'{MODULE_PATH}.forms' -class DeepLinkingFormBaseTestCase(TestCase): +class DeepLinkingFormTestCase(TestCase): """DeepLinkingForm TestCase.""" def setUp(self): @@ -20,179 +22,267 @@ def setUp(self): super().setUp() self.form_class = DeepLinkingForm self.request = MagicMock() - self.lti_tool = MagicMock() - self.form_kwargs = {'request': self.request, 'lti_tool': self.lti_tool} - self.learning_context = MagicMock(context_key='random-course-key', title='Test') - self.course = MagicMock(learning_context=self.learning_context) + 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() -@patch.object(DeepLinkingForm, 'get_content_items_choices', return_value=[]) -class TestDeepLinkingFormInit(DeepLinkingFormBaseTestCase): - """Test DeepLinkingForm `__init__` method.""" +class TestDeepLinkingForm(DeepLinkingFormTestCase): + """Test DeepLinkingForm class.""" + def test_class_attributes(self): + """Test class attributes.""" + self.assertEqual( + self.form_class.CONTENT_ITEMS_SCHEMA, + { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'url': {'type': 'string'}, + 'title': {'type': 'string'}, + }, + 'additionalProperties': True, + }, + }, + ) + + @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(request=self.request, lti_tool=self.lti_tool) + """Test __init__ method.""" + form = self.form_class(**self.form_kwargs) self.assertEqual(form.request, self.request) - self.assertEqual(form.lti_tool, self.lti_tool) + 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.object(DeepLinkingForm, 'get_course_contexts') -@patch.object(DeepLinkingForm, 'get_content_items_choice') -@patch.object(DeepLinkingForm, '__init__', return_value=None) -class TestDeepLinkingFormGetContentItemsChoices(DeepLinkingFormBaseTestCase): - """Test DeepLinkingForm `get_content_items_choices` method.""" - - def test_get_content_items_choices( + @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, - init_mock: MagicMock, # pylint: disable=unused-argument - get_content_items_choice_mock: MagicMock, - get_course_contexts_mock: MagicMock, + 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_choices` method.""" - get_course_contexts_mock.return_value = [self.course] + """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_choices(), - {get_content_items_choice_mock.return_value}, + self.form_class(**self.form_kwargs).get_content_items(), + [ + { + 'url': build_content_item_url_mock.return_value, + 'title': self.course.title, + }, + ], ) - get_course_contexts_mock.assert_called_once_with() - get_content_items_choice_mock.assert_called_once_with(self.course) - - -@patch(f'{MODULE_PATH}.reverse') -@patch.object(DeepLinkingForm, 'get_content_items_choices') -class TestDeepLinkingFormGetContentItemsChoice(DeepLinkingFormBaseTestCase): - """Test DeepLinkingForm `get_content_items_choice` method.""" + 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) - def test_get_content_items_choice( + @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 `get_content_items_choice` method.""" + """Test build_content_item_url method.""" self.assertEqual( - self.form_class(**self.form_kwargs).get_content_items_choice(self.course), - ( - self.request.build_absolute_uri.return_value, - self.course.learning_context.title, - ), + 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.learning_context.context_key}, + kwargs={'course_id': self.course.course_id}, ) - self.request.build_absolute_uri.assert_called_once_with(reverse_mock()) + @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]} + + 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(f'{MODULE_PATH}.course_context') -@patch(f'{MODULE_PATH}.json.loads') -@patch.object(CourseAccessConfiguration.objects, 'get') -@patch(f'{MODULE_PATH}.COURSE_ACCESS_CONFIGURATION') -@patch.object(DeepLinkingForm, 'get_content_items_choices') -class TestDeepLinkingFormGetCourseContexts(DeepLinkingFormBaseTestCase): - """Test DeepLinkingForm `get_course_contexts` method.""" - def test_get_course_contexts( +@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, - get_content_items_choices_mock: MagicMock, # pylint: disable=unused-argument - course_access_configuration_switch_mock: MagicMock, - course_access_configuration_get_mock: MagicMock, - json_loads_mock: MagicMock, - course_context: MagicMock, + init_mock: MagicMock, # pylint: disable=unused-argument + json_dumps_mock: MagicMock, + get_content_items_mock: MagicMock, + get_content_items_from_provider_mock: MagicMock, ): - """Test `get_course_contexts` method.""" + """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_course_contexts(), - course_context.return_value.objects.filter.return_value, - ) - course_access_configuration_switch_mock.is_enabled.assert_called_once_with() - course_access_configuration_get_mock.assert_called_once_with(lti_tool=self.lti_tool) - course_context.assert_called_once_with() - json_loads_mock.assert_called_once_with( - course_access_configuration_get_mock().allowed_course_ids, - ) - course_context().objects.filter.assert_called_once_with( - learning_context__context_key__in=json_loads_mock() + 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_disabled_course_access_configuration_switch( + def test_with_get_content_items( self, - get_content_items_choices_mock: MagicMock, # pylint: disable=unused-argument - course_access_configuration_switch_mock: MagicMock, - course_access_configuration_get_mock: MagicMock, - json_loads_mock: MagicMock, - course_context: MagicMock, + 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 disabled `COURSE_ACCESS_CONFIGURATION` switch.""" - course_access_configuration_switch_mock.is_enabled.return_value = False + """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_course_contexts(), - course_context.return_value.objects.all.return_value, + self.form_class(**self.form_kwargs).get_content_items_choices(), + [(json_dumps_mock.return_value, self.title)], ) - course_access_configuration_switch_mock.is_enabled.assert_called_once_with() - course_context.assert_called_once_with() - course_context().objects.all.assert_called_once_with() - course_access_configuration_get_mock.assert_not_called() - json_loads_mock.assert_not_called() - course_context().objects.filter.assert_not_called() - - @patch(f'{MODULE_PATH}._') - def test_without_course_access_configuration( + 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, - gettext_mock: MagicMock, get_content_items_choices_mock: MagicMock, # pylint: disable=unused-argument - course_access_configuration_switch_mock: MagicMock, - course_access_configuration_get_mock: MagicMock, - json_loads_mock: MagicMock, - course_context: MagicMock, + validate_mock: MagicMock, + getattr_mock: MagicMock, + import_module_mock: MagicMock, + configuration_helpers_mock: MagicMock, ): - """Test without CourseAccessConfiguration instance.""" - course_access_configuration_get_mock.side_effect = CourseAccessConfiguration.DoesNotExist + """Test with setting value (happy path).""" + configuration_helpers_mock().get_value.return_value = self.setting_value - with self.assertRaises(DeepLinkingException) as ctxm: - self.form_class(**self.form_kwargs).get_course_contexts() - - course_access_configuration_switch_mock.is_enabled.assert_called_once_with() - course_access_configuration_get_mock.assert_called_once_with(lti_tool=self.lti_tool) - gettext_mock.assert_called_once_with( - f'Course access configuration not found: {self.lti_tool.title}.', + self.assertEqual( + self.form_class(**self.form_kwargs).get_content_items_from_provider(), + getattr_mock.return_value.return_value, + ) + 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, ) - self.assertEqual(str(gettext_mock()), str(ctxm.exception)) - course_context.assert_not_called() - course_context().objects.all.assert_not_called() - json_loads_mock.assert_not_called() - course_context().objects.filter.assert_not_called() + 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 = '' -@patch(f'{MODULE_PATH}.DeepLinkResource') -@patch.object(DeepLinkingForm, 'get_content_items_choices') -class TestDeepLinkingFormGetDeepLinkResources(DeepLinkingFormBaseTestCase): - """Test DeepLinkingForm `get_deep_link_resources` method.""" + 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() - def test_get_deep_link_resources( + @log_capture() + def test_with_exception( self, + log_mock: LogCaptureForDecorator, get_content_items_choices_mock: MagicMock, # pylint: disable=unused-argument - deep_link_resource_mock: MagicMock, + validate_mock: MagicMock, + getattr_mock: MagicMock, + import_module_mock: MagicMock, + configuration_helpers_mock: MagicMock, ): - """Test `get_deep_link_resources` method.""" - content_item = 'https://example.com' - form = self.form_class(**self.form_kwargs) - form.cleaned_data = {'content_items': [content_item]} + """Test with Exception.""" + import_module_mock.side_effect = Exception('example-error-message') + configuration_helpers_mock().get_value.return_value = self.setting_value self.assertEqual( - form.get_deep_link_resources(), - {deep_link_resource_mock.return_value.set_url.return_value}, + 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.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 f0632b6..a316d32 100644 --- a/openedx_lti_tool_plugin/deep_linking/tests/test_views.py +++ b/openedx_lti_tool_plugin/deep_linking/tests/test_views.py @@ -15,7 +15,6 @@ DeepLinkingView, validate_deep_linking_message, ) -from openedx_lti_tool_plugin.tests import AUD, ISS MODULE_PATH = f'{MODULE_PATH}.views' @@ -23,8 +22,8 @@ class TestValidateDeepLinkingMessage(TestCase): """Test validate_deep_linking_message function.""" - def test_validate_deep_linking_message(self: MagicMock): - """Test with LtiDeepLinkingRequest message.""" + def test_with_deep_linking_request_message(self: MagicMock): + """Test with deep linking request message (happy path).""" message = MagicMock() message.is_deep_link_launch.return_value = True @@ -37,7 +36,7 @@ def test_without_deep_linking_request_message( self: MagicMock, gettext_mock: MagicMock, ): - """Test without LtiDeepLinkingRequest message.""" + """Test without deep linking request message.""" message = MagicMock() message.is_deep_link_launch.return_value = False @@ -55,7 +54,7 @@ def test_without_deep_linking_request_message( @patch(f'{MODULE_PATH}.validate_deep_linking_message') @patch(f'{MODULE_PATH}.redirect') class TestDeepLinkingViewPost(TestCase): - """Test ResourceLinkLaunchView post method.""" + """Test ResourceLinkLaunchView.post method.""" def setUp(self): """Set up test fixtures.""" @@ -65,7 +64,7 @@ def setUp(self): self.url = reverse('1.3:deep-linking:root') self.request = self.factory.post(self.url) - def test_post( + def test_with_deep_linking_request( self, redirect_mock: MagicMock, validate_deep_linking_message_mock: MagicMock, @@ -73,7 +72,7 @@ def test_post( tool_storage_mock: MagicMock, tool_conf_mock: MagicMock, ): - """Test `post` method with LtiDeepLinkingRequest (happy path).""" + """Test with deep linking request (happy path).""" self.assertEqual( self.view_class.as_view()(self.request), redirect_mock.return_value, @@ -92,7 +91,7 @@ def test_post( ) @patch.object(DeepLinkingView, 'http_response_error') - def test_raises_lti_exception( + def test_with_lti_exception( self, http_response_error_mock: MagicMock, redirect_mock: MagicMock, @@ -101,7 +100,7 @@ def test_raises_lti_exception( tool_storage_mock: MagicMock, tool_conf_mock: MagicMock, ): - """Test raises LtiException.""" + """Test with LtiException.""" exception = LtiException('Error message') message_launch_mock.side_effect = exception @@ -119,7 +118,7 @@ def test_raises_lti_exception( http_response_error_mock.assert_called_once_with(exception) @patch.object(DeepLinkingView, 'http_response_error') - def test_raises_deep_linking_exception( + def test_with_deep_linking_exception( self, http_response_error_mock: MagicMock, redirect_mock: MagicMock, @@ -128,7 +127,7 @@ def test_raises_deep_linking_exception( tool_storage_mock: MagicMock, tool_conf_mock: MagicMock, ): - """Test raises DeepLinkingException.""" + """Test with DeepLinkingException.""" exception = DeepLinkingException('Error message') validate_deep_linking_message_mock.side_effect = exception @@ -147,20 +146,42 @@ def test_raises_deep_linking_exception( class TestDeepLinkingFormView(TestCase): - """Test DeepLinkingFormView.""" + """Test DeepLinkingFormView class.""" def test_class_attributes(self): """Test class attributes.""" self.assertEqual(DeepLinkingFormView.form_class, DeepLinkingForm) + @patch.object(DeepLinkingFormView, 'tool_config', new_callable=PropertyMock) + @patch.object(DeepLinkingFormView, 'tool_storage', new_callable=PropertyMock) + @patch(f'{MODULE_PATH}.DjangoMessageLaunch') + def test_get_message_from_cache( + self, + message_launch_mock: MagicMock, + tool_storage_mock: MagicMock, + tool_conf_mock: MagicMock, + ): + """Test get_message_from_cache method.""" + request = MagicMock() + launch_id = uuid4() + + self.assertEqual( + DeepLinkingFormView().get_message_from_cache(request, launch_id), + message_launch_mock.from_cache.return_value, + ) + message_launch_mock.from_cache.assert_called_once_with( + f'lti1p3-launch-{launch_id}', + request, + tool_conf_mock(), + launch_data_storage=tool_storage_mock(), + ) + @patch.object(DeepLinkingFormView, 'get_message_from_cache') @patch(f'{MODULE_PATH}.validate_deep_linking_message') -@patch(f'{MODULE_PATH}.get_identity_claims', return_value=(ISS, AUD, None, None)) -@patch.object(DeepLinkingFormView, 'tool_config', new_callable=PropertyMock) @patch.object(DeepLinkingFormView, 'form_class') class TestDeepLinkingFormViewGet(TestCase): - """Test DeepLinkingFormView get method.""" + """Test DeepLinkingFormView.get method.""" def setUp(self): """Set up test fixtures.""" @@ -172,16 +193,14 @@ def setUp(self): self.request = self.factory.get(self.url) @patch(f'{MODULE_PATH}.render') - def test_get( + def test_with_deep_linking_request( self, render_mock: MagicMock, form_class_mock: MagicMock, - tool_config_mock: MagicMock, - get_identity_claims_mock: MagicMock, validate_deep_linking_message_mock: MagicMock, get_message_from_cache_mock: MagicMock, ): - """Test `get` method with valid launch_id (happy path).""" + """Test with deep linking request (happy path).""" self.assertEqual( self.view_class.as_view()(self.request, self.launch_id), render_mock.return_value, @@ -189,11 +208,9 @@ def test_get( 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() - get_identity_claims_mock.assert_called_once_with(get_message_from_cache_mock().get_launch_data()) - tool_config_mock().get_lti_tool.assert_called_once_with(ISS, AUD) form_class_mock.assert_called_once_with( request=self.request, - lti_tool=tool_config_mock().get_lti_tool(), + launch_data=get_message_from_cache_mock().get_launch_data(), ) render_mock.assert_called_once_with( self.request, @@ -206,16 +223,14 @@ def test_get( ) @patch.object(DeepLinkingFormView, 'http_response_error') - def test_raises_lti_exception( + def test_with_lti_exception( self, http_response_error_mock: MagicMock, form_class_mock: MagicMock, - tool_config_mock: MagicMock, - get_identity_claims_mock: MagicMock, validate_deep_linking_message_mock: MagicMock, get_message_from_cache_mock: MagicMock, ): - """Test raises LtiException.""" + """Test with LtiException.""" exception = LtiException('Error message') get_message_from_cache_mock.side_effect = exception @@ -226,22 +241,18 @@ 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() get_message_from_cache_mock.return_value.get_launch_data.assert_not_called() - get_identity_claims_mock.assert_not_called() - tool_config_mock().get_lti_tool.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( + def test_with_deep_linking_exception( self, http_response_error_mock: MagicMock, form_class_mock: MagicMock, - tool_config_mock: MagicMock, - get_identity_claims_mock: MagicMock, validate_deep_linking_message_mock: MagicMock, get_message_from_cache_mock: MagicMock, ): - """Test raises DeepLinkingException.""" + """Test with DeepLinkingException.""" exception = DeepLinkingException('Error message') validate_deep_linking_message_mock.side_effect = exception @@ -252,19 +263,15 @@ 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()) get_message_from_cache_mock().get_launch_data.assert_not_called() - get_identity_claims_mock.assert_not_called() - tool_config_mock().get_lti_tool.assert_not_called() 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(f'{MODULE_PATH}.get_identity_claims', return_value=(ISS, AUD, None, None)) -@patch.object(DeepLinkingFormView, 'tool_config', new_callable=PropertyMock) @patch.object(DeepLinkingFormView, 'form_class') class TestDeepLinkingFormViewPost(TestCase): - """Test DeepLinkingFormView post method.""" + """Test DeepLinkingFormView.post method.""" def setUp(self): """Set up test fixtures.""" @@ -276,16 +283,23 @@ def setUp(self): self.request = self.factory.post(self.url) @patch(f'{MODULE_PATH}.HttpResponse') - def test_post( + def test_with_deep_linking_request( self, http_response_mock: MagicMock, form_class_mock: MagicMock, - tool_config_mock: MagicMock, - get_identity_claims_mock: MagicMock, validate_deep_linking_message_mock: MagicMock, get_message_from_cache_mock: MagicMock, ): - """Test `post` method with valid launch_id (happy path).""" + """Test with deep linking request (happy path).""" + form_class_mock.return_value.cleaned_data = { + 'deep_link_resources': [ + MagicMock( + url='http://example.com', + title='Test', + ) + ] + } + self.assertEqual( self.view_class.as_view()(self.request, self.launch_id), http_response_mock.return_value, @@ -293,18 +307,15 @@ def test_post( 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() - get_identity_claims_mock.assert_called_once_with(get_message_from_cache_mock().get_launch_data()) - tool_config_mock().get_lti_tool.assert_called_once_with(ISS, AUD) form_class_mock.assert_called_once_with( self.request.POST, request=self.request, - lti_tool=tool_config_mock().get_lti_tool(), + launch_data=get_message_from_cache_mock().get_launch_data(), ) form_class_mock().is_valid.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(), + form_class_mock().cleaned_data['deep_link_resources'], ) http_response_mock.assert_called_once_with( get_message_from_cache_mock().get_deep_link().output_response_form(), @@ -315,8 +326,6 @@ def test_with_invalid_form( self, http_response_error_mock: MagicMock, form_class_mock: MagicMock, - tool_config_mock: MagicMock, - get_identity_claims_mock: MagicMock, validate_deep_linking_message_mock: MagicMock, get_message_from_cache_mock: MagicMock, ): @@ -330,30 +339,25 @@ 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() - get_identity_claims_mock.assert_called_once_with(get_message_from_cache_mock().get_launch_data()) - tool_config_mock().get_lti_tool.assert_called_once_with(ISS, AUD) form_class_mock.assert_called_once_with( self.request.POST, request=self.request, - lti_tool=tool_config_mock().get_lti_tool(), + launch_data=get_message_from_cache_mock().get_launch_data(), ) form_class_mock().is_valid.assert_called_once_with() - 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( + def test_with_lti_exception( self, http_response_error_mock: MagicMock, form_class_mock: MagicMock, - tool_config_mock: MagicMock, - get_identity_claims_mock: MagicMock, validate_deep_linking_message_mock: MagicMock, get_message_from_cache_mock: MagicMock, ): - """Test raises LtiException.""" + """Test with LtiException.""" exception = LtiException('Error message') get_message_from_cache_mock.side_effect = exception @@ -364,26 +368,21 @@ 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() get_message_from_cache_mock.return_value.get_launch_data.assert_not_called() - get_identity_claims_mock.assert_not_called() - tool_config_mock().get_lti_tool.assert_not_called() form_class_mock.assert_not_called() form_class_mock().is_valid.assert_not_called() - form_class_mock().get_deep_link_resources.assert_not_called() get_message_from_cache_mock.return_value.get_deep_link.assert_not_called() get_message_from_cache_mock.return_value.get_deep_link().output_response_form.assert_not_called() http_response_error_mock.assert_called_once_with(exception) @patch.object(DeepLinkingFormView, 'http_response_error') - def test_raises_deep_linking_exception( + def test_with_deep_linking_exception( self, http_response_error_mock: MagicMock, form_class_mock: MagicMock, - tool_config_mock: MagicMock, - get_identity_claims_mock: MagicMock, validate_deep_linking_message_mock: MagicMock, get_message_from_cache_mock: MagicMock, ): - """Test raises DeepLinkingException.""" + """Test with DeepLinkingException.""" exception = DeepLinkingException('Error message') validate_deep_linking_message_mock.side_effect = exception @@ -394,39 +393,8 @@ 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()) get_message_from_cache_mock().get_launch_data.assert_not_called() - get_identity_claims_mock.assert_not_called() - tool_config_mock().get_lti_tool.assert_not_called() form_class_mock.assert_not_called() form_class_mock().is_valid.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_with(exception) - - -@patch.object(DeepLinkingFormView, 'tool_config', new_callable=PropertyMock) -@patch.object(DeepLinkingFormView, 'tool_storage', new_callable=PropertyMock) -@patch(f'{MODULE_PATH}.DjangoMessageLaunch') -class TestDeepLinkingFormViewGetMessageFromCache(TestCase): - """Test DeepLinkingFormView get_message_from_cache method.""" - - def test_get_message_from_cache( - self, - message_launch_mock: MagicMock, - tool_storage_mock: MagicMock, - tool_conf_mock: MagicMock, - ): - """Test `get_message_from_cache` method with valid launch_id (happy path).""" - request = MagicMock() - launch_id = uuid4() - - self.assertEqual( - DeepLinkingFormView().get_message_from_cache(request, launch_id), - message_launch_mock.from_cache.return_value, - ) - message_launch_mock.from_cache.assert_called_once_with( - f'lti1p3-launch-{launch_id}', - request, - tool_conf_mock(), - launch_data_storage=tool_storage_mock(), - ) diff --git a/openedx_lti_tool_plugin/deep_linking/views.py b/openedx_lti_tool_plugin/deep_linking/views.py index 1f770f7..76c9fd0 100644 --- a/openedx_lti_tool_plugin/deep_linking/views.py +++ b/openedx_lti_tool_plugin/deep_linking/views.py @@ -16,7 +16,6 @@ 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.utils import get_identity_claims from openedx_lti_tool_plugin.views import LtiToolBaseView @@ -129,8 +128,6 @@ def get( message = self.get_message_from_cache(request, launch_id) # Validate message. validate_deep_linking_message(message) - # Get identity claims from launch data. - iss, aud, _sub, _pii = get_identity_claims(message.get_launch_data()) # Render form template. return render( request, @@ -138,7 +135,7 @@ def get( { 'form': self.form_class( request=request, - lti_tool=self.tool_config.get_lti_tool(iss, aud), + launch_data=message.get_launch_data(), ), 'form_url': f'{app_config.name}:1.3:deep-linking:form', 'launch_id': launch_id, @@ -170,13 +167,11 @@ def post( message = self.get_message_from_cache(request, launch_id) # Validate message. validate_deep_linking_message(message) - # Get identity claims from launch data. - iss, aud, _sub, _pii = get_identity_claims(message.get_launch_data()) # Initialize form. form = self.form_class( request.POST, request=request, - lti_tool=self.tool_config.get_lti_tool(iss, aud), + launch_data=message.get_launch_data(), ) # Validate form. if not form.is_valid(): @@ -184,7 +179,7 @@ def post( # Render Deep Linking response. return HttpResponse( message.get_deep_link().output_response_form( - form.get_deep_link_resources(), + form.cleaned_data.get('deep_link_resources', []), ) ) except (LtiException, DeepLinkingException) as exc: diff --git a/openedx_lti_tool_plugin/models.py b/openedx_lti_tool_plugin/models.py index 1d750d5..f26debe 100644 --- a/openedx_lti_tool_plugin/models.py +++ b/openedx_lti_tool_plugin/models.py @@ -1,21 +1,24 @@ """Django Model.""" import json import uuid -from typing import TypeVar +from typing import Optional, TypeVar import shortuuid from django.contrib.auth import get_user_model from django.contrib.auth.models import AbstractBaseUser from django.core.exceptions import ValidationError from django.db import models, transaction -from django.db.models import Q +from django.db.models import Q, QuerySet from django.utils.translation import gettext_lazy as _ from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey +from pylti1p3.contrib.django import DjangoDbToolConf from pylti1p3.contrib.django.lti1p3_tool_config.models import LtiTool from openedx_lti_tool_plugin.apps import OpenEdxLtiToolPluginConfig as app_config +from openedx_lti_tool_plugin.edxapp_wrapper.learning_sequences import course_context from openedx_lti_tool_plugin.edxapp_wrapper.student_module import user_profile +from openedx_lti_tool_plugin.waffle import COURSE_ACCESS_CONFIGURATION UserT = TypeVar('UserT', bound=AbstractBaseUser) @@ -269,3 +272,63 @@ def is_course_id_allowed(self, course_id: str) -> bool: def __str__(self) -> str: """Get a string representation of this model instance.""" return f'' + + +class CourseContextManager(models.Manager): + """CourseContext Model Manager.""" + + def all_for_lti_tool(self, iss: str, aud: str) -> Optional[QuerySet]: + """Query all CourseContext objects available for an LtiTool. + + Args: + iss: Issuer claim. + aud: Audience claim. + + Returns: + All CourseContext objects if COURSE_ACCESS_CONFIGURATION + switch is disabled. + + All CourseContext objects with a Course ID that is in the + CourseAccessConfiguration.allowed_course_ids field. + + None if COURSE_ACCESS_CONFIGURATION switch is enabled and + no CourseAccessConfiguration can be found for the queried + LtiTool. + + """ + if not COURSE_ACCESS_CONFIGURATION.is_enabled(): + return self.all() + + try: + course_access_config = CourseAccessConfiguration.objects.get( + lti_tool=DjangoDbToolConf().get_lti_tool(iss, aud), + ) + except CourseAccessConfiguration.DoesNotExist: + return self.none() + + return self.filter( + learning_context__context_key__in=json.loads( + course_access_config.allowed_course_ids, + ), + ) + + +class CourseContext(course_context()): + """CourseContext Model.""" + + objects = CourseContextManager() + + class Meta: + """Model metadata options.""" + + proxy = True + + @property + def course_id(self): + """str: Course ID.""" + return self.learning_context.context_key + + @property + def title(self): + """str: Title.""" + return self.learning_context.title diff --git a/openedx_lti_tool_plugin/settings/common.py b/openedx_lti_tool_plugin/settings/common.py index 126025d..c8cd22b 100644 --- a/openedx_lti_tool_plugin/settings/common.py +++ b/openedx_lti_tool_plugin/settings/common.py @@ -40,8 +40,12 @@ def plugin_settings(settings: LazySettings): For more information please see: https://github.com/openedx/edx-django-utils/tree/master/edx_django_utils/plugins """ + # General settings settings.OLTITP_ENABLE_LTI_TOOL = False + # Deep linking settings + settings.OLTITP_DEEP_LINKING_CONTENT_ITEMS_PROVIDER = None + # Backends settings settings.OLTITP_CORE_SIGNALS_BACKEND = f'{BACKENDS_MODULE_PATH}.core_signals_module_o_v1' settings.OLTITP_MODULESTORE_BACKEND = f'{BACKENDS_MODULE_PATH}.modulestore_module_o_v1' diff --git a/openedx_lti_tool_plugin/settings/test.py b/openedx_lti_tool_plugin/settings/test.py index 975cfe1..d0fd3d8 100644 --- a/openedx_lti_tool_plugin/settings/test.py +++ b/openedx_lti_tool_plugin/settings/test.py @@ -45,6 +45,11 @@ }, ] + +MIGRATION_MODULES = { + 'openedx_lti_tool_plugin': None, +} + DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', @@ -56,14 +61,18 @@ AUTHENTICATION_BACKENDS = ['openedx_lti_tool_plugin.auth.LtiAuthenticationBackend'] - -# Plugin settings -OLTITP_ENABLE_LTI_TOOL = True +# Open edX LMS settings COURSE_ID_PATTERN = '(?P.*)' USAGE_KEY_PATTERN = '(?P.*)' LEARNING_MICROFRONTEND_URL = 'example.com' -# Backends for tests +# General settings +OLTITP_ENABLE_LTI_TOOL = True + +# Deep linking settings +OLTITP_DEEP_LINKING_CONTENT_ITEMS_PROVIDER = None + +# Backend settings OLTITP_TEST_BACKEND_MODULE_PATH = 'openedx_lti_tool_plugin.tests.backends_for_tests' OLTITP_CORE_SIGNALS_BACKEND = OLTITP_TEST_BACKEND_MODULE_PATH OLTITP_MODULESTORE_BACKEND = OLTITP_TEST_BACKEND_MODULE_PATH diff --git a/openedx_lti_tool_plugin/tests/backends_for_tests.py b/openedx_lti_tool_plugin/tests/backends_for_tests.py index 90c5bde..3c468ac 100644 --- a/openedx_lti_tool_plugin/tests/backends_for_tests.py +++ b/openedx_lti_tool_plugin/tests/backends_for_tests.py @@ -1,6 +1,8 @@ """Test backends for the openedx_lti_tool_plugin module.""" from unittest.mock import Mock +from django.db import models + def course_grade_changed_backend(): """Return COURSE_GRADE_CHANGED mock function.""" @@ -52,6 +54,10 @@ def set_logged_in_cookies_backend(*args: tuple, **kwargs: dict): return Mock() +class CourseContextTest(models.Model): + """CourseContext Test Model.""" + + def course_context_backend(): - """Return CourseContext mock function.""" - return Mock() + """Return CourseContext Test Model.""" + return CourseContextTest diff --git a/openedx_lti_tool_plugin/tests/test_models.py b/openedx_lti_tool_plugin/tests/test_models.py index 4afbe2a..c2801a9 100644 --- a/openedx_lti_tool_plugin/tests/test_models.py +++ b/openedx_lti_tool_plugin/tests/test_models.py @@ -14,7 +14,7 @@ from pylti1p3.contrib.django.lti1p3_tool_config.models import LtiTool, LtiToolKey from openedx_lti_tool_plugin.apps import OpenEdxLtiToolPluginConfig as app_config -from openedx_lti_tool_plugin.models import CourseAccessConfiguration, LtiProfile +from openedx_lti_tool_plugin.models import CourseAccessConfiguration, CourseContext, CourseContextManager, LtiProfile from openedx_lti_tool_plugin.tests import AUD, ISS, SUB MODULE_PATH = 'openedx_lti_tool_plugin.models' @@ -422,3 +422,132 @@ def test_str_method(self): str(self.access_configuration), f'', ) + + +@patch(f'{MODULE_PATH}.COURSE_ACCESS_CONFIGURATION') +@patch.object(CourseContextManager, 'all') +@patch(f'{MODULE_PATH}.DjangoDbToolConf') +@patch.object(CourseAccessConfiguration.objects, 'get') +@patch.object(CourseContextManager, 'none') +@patch(f'{MODULE_PATH}.json.loads') +@patch.object(CourseContextManager, 'filter') +class TestCourseContextManagerAllForLtiTool(TestCase): + """Test CourseContextManager.all_for_lti_tool method.""" + + def test_with_course_access_configuration( + self, + course_context_manager_filter_mock: MagicMock, + json_loads_mock: MagicMock, + course_context_manager_none_mock: MagicMock, + course_access_configuration_get_mock: MagicMock, + django_db_tool_conf_mock: MagicMock, + course_context_manager_all_mock: MagicMock, + course_access_configuration_switch_mock: MagicMock, + ): + """Test with CourseAccessConfiguration (happy path).""" + self.assertEqual( + CourseContext.objects.all_for_lti_tool(ISS, AUD), + course_context_manager_filter_mock.return_value, + ) + course_access_configuration_switch_mock.is_enabled.assert_called_once_with() + course_context_manager_all_mock.assert_not_called() + django_db_tool_conf_mock.assert_called_once_with() + django_db_tool_conf_mock().get_lti_tool.assert_called_once_with(ISS, AUD) + course_access_configuration_get_mock.assert_called_once_with( + lti_tool=django_db_tool_conf_mock().get_lti_tool(), + ) + course_context_manager_none_mock.assert_not_called() + json_loads_mock.assert_called_once_with( + course_access_configuration_get_mock().allowed_course_ids, + ) + course_context_manager_filter_mock.assert_called_once_with( + learning_context__context_key__in=json_loads_mock(), + ) + + def test_without_course_access_configuration( + self, + course_context_manager_filter_mock: MagicMock, + json_loads_mock: MagicMock, + course_context_manager_none_mock: MagicMock, + course_access_configuration_get_mock: MagicMock, + django_db_tool_conf_mock: MagicMock, + course_context_manager_all_mock: MagicMock, + course_access_configuration_switch_mock: MagicMock, + ): + """Test without CourseAccessConfiguration.""" + course_access_configuration_get_mock.side_effect = CourseAccessConfiguration.DoesNotExist + + self.assertEqual( + CourseContext.objects.all_for_lti_tool(ISS, AUD), + course_context_manager_none_mock.return_value, + ) + course_access_configuration_switch_mock.is_enabled.assert_called_once_with() + course_context_manager_all_mock.assert_not_called() + django_db_tool_conf_mock.assert_called_once_with() + django_db_tool_conf_mock().get_lti_tool.assert_called_once_with(ISS, AUD) + course_access_configuration_get_mock.assert_called_once_with( + lti_tool=django_db_tool_conf_mock().get_lti_tool(), + ) + course_context_manager_none_mock.assert_called_once_with() + json_loads_mock.assert_not_called() + course_context_manager_filter_mock.assert_not_called() + + def test_with_disabled_course_access_configuration_switch( + self, + course_context_manager_filter_mock: MagicMock, + json_loads_mock: MagicMock, + course_context_manager_none_mock: MagicMock, + course_access_configuration_get_mock: MagicMock, + django_db_tool_conf_mock: MagicMock, + course_context_manager_all_mock: MagicMock, + course_access_configuration_switch_mock: MagicMock, + ): + """Test with disabled COURSE_ACCESS_CONFIGURATION switch.""" + course_access_configuration_switch_mock.is_enabled.return_value = None + + self.assertEqual( + CourseContext.objects.all_for_lti_tool(ISS, AUD), + course_context_manager_all_mock.return_value, + ) + course_access_configuration_switch_mock.is_enabled.assert_called_once_with() + course_context_manager_all_mock.assert_called_once_with() + django_db_tool_conf_mock.assert_not_called() + django_db_tool_conf_mock().get_lti_tool.assert_not_called() + course_access_configuration_get_mock.assert_not_called() + course_context_manager_none_mock.assert_not_called() + json_loads_mock.assert_not_called() + course_context_manager_filter_mock.assert_not_called() + + +class TestCourseContext(TestCase): + """Test CourseContext class.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.context_key = 'example-content-key' + self.title = 'example-title' + self.learning_context = MagicMock( + context_key=self.context_key, + title=self.title, + ) + self.course_context = CourseContext() + self.course_context.learning_context = self.learning_context + + def test_meta_class_attributes(self): + """Test Meta class attributes.""" + self.assertTrue(self.course_context._meta.proxy) + + def test_course_id(self): + """Test course_id property.""" + self.assertEqual( + self.course_context.course_id, + self.learning_context.context_key, + ) + + def test_title(self): + """Test title property.""" + self.assertEqual( + self.course_context.title, + self.learning_context.title, + )