diff --git a/README.rst b/README.rst index 98caee18457157cd43cd53c4c9d17c5af08db2ec..cc9a2d3396da09ade14de1b25a7d076891053cbe 100644 --- a/README.rst +++ b/README.rst @@ -214,6 +214,53 @@ To configure parameter processors add the following snippet to your Ansible vari - 'customer_package.lti_processors:team_and_cohort' - 'example_package.lti_processors:extra_lti_params' +Dynamic LTI Custom Parameters +============================= + +This XBlock gives us the capability to attach static and dynamic custom parameters in the custom parameters field, +in the case we need to declare a dynamic custom parameter we must set the value of the parameter as a templated parameter +wrapped with the tags '${' and '}' just like the following example: + +.. code:: python + + ["static_param=static_value", "dynamic_custom_param=${templated_param_value}"] + +Defining a dynamic LTI Custom Parameter Processor +------------------------------------------------- + +The custom parameter processor is a function that expects an XBlock instance, and returns a ``string`` which should be the resolved value. +Exceptions must be handled by the processor itself. + +.. code:: python + + def get_course_name(xblock): + try: + course = CourseOverview.objects.get(id=xblock.course.id) + except CourseOverview.DoesNotExist: + log.error('Course does not exist.') + return '' + + return course.display_name + +Note. The processor function must return a ``string`` object. + +Configuring the LTI Dynamic Custom Parameters Settings +------------------------------------------------------ + +The setting LTI_CUSTOM_PARAM_TEMPLATES must be set in order to map the template value for the dynamic custom parameter +as the following example: + +.. code:: python + + LTI_CUSTOM_PARAM_TEMPLATES = { + 'templated_param_value': 'customer_package.module:func', + } + +* 'templated_param_value': custom parameter template name. +* 'customer_package.module:func': custom parameter processor path and function name. + + + LTI Advantage Features ====================== @@ -320,6 +367,11 @@ Changelog Please See the [releases tab](https://github.com/edx/xblock-lti-consumer/releases) for the complete changelog. +3.2.0 - 2022-01-18 +------------------- + +* Dynamic custom parameters support with the help of template parameter processors. + 3.1.2 - 2021-11-12 ------------------- diff --git a/lti_consumer/__init__.py b/lti_consumer/__init__.py index dc833d9ddcc8acf5cf6a8404c9a2cb3efeb7792c..0ddc23dac6db7e92c29851b3cf718e24dfd14ef5 100644 --- a/lti_consumer/__init__.py +++ b/lti_consumer/__init__.py @@ -4,4 +4,4 @@ Runtime will load the XBlock class from here. from .lti_xblock import LtiConsumerXBlock from .apps import LTIConsumerApp -__version__ = '3.1.2' +__version__ = '3.2.0' diff --git a/lti_consumer/lti_xblock.py b/lti_consumer/lti_xblock.py index 6d6cd54e9009d28a408a6a2a7e6c35562a70037f..4ca545f949eb4022db038b450ac27e65898a03d3 100644 --- a/lti_consumer/lti_xblock.py +++ b/lti_consumer/lti_xblock.py @@ -81,7 +81,7 @@ from .lti_1p3.exceptions import ( ) from .lti_1p3.constants import LTI_1P3_CONTEXT_TYPE from .outcomes import OutcomeService -from .utils import _ +from .utils import _, resolve_custom_parameter_template log = logging.getLogger(__name__) @@ -100,6 +100,7 @@ ROLE_MAP = { 'staff': 'Administrator', 'instructor': 'Instructor', } +CUSTOM_PARAMETER_TEMPLATE_TAGS = ('${', '}') def parse_handler_suffix(suffix): @@ -819,6 +820,10 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): if param_name not in LTI_PARAMETERS: param_name = 'custom_' + param_name + if (param_value.startswith(CUSTOM_PARAMETER_TEMPLATE_TAGS[0]) and + param_value.endswith(CUSTOM_PARAMETER_TEMPLATE_TAGS[1])): + param_value = resolve_custom_parameter_template(self, param_value) + custom_parameters[param_name] = param_value custom_parameters['custom_component_display_name'] = str(self.display_name) diff --git a/lti_consumer/tests/unit/test_lti_xblock.py b/lti_consumer/tests/unit/test_lti_xblock.py index 1a0eeae5b5403055f04f5aaa34ea010a71277846..d9bc4bf93e5b1f08c073e0388832586707fa4d90 100644 --- a/lti_consumer/tests/unit/test_lti_xblock.py +++ b/lti_consumer/tests/unit/test_lti_xblock.py @@ -3,12 +3,14 @@ Unit tests for LtiConsumerXBlock """ import json +import logging import urllib.parse from datetime import timedelta from unittest.mock import Mock, NonCallableMock, PropertyMock, patch import ddt from Cryptodome.PublicKey import RSA +from django.conf import settings as dj_settings from django.test.testcases import TestCase from django.utils import timezone from jwkest.jwk import RSAKey @@ -19,6 +21,7 @@ from lti_consumer.lti_1p3.tests.utils import create_jwt from lti_consumer.lti_xblock import LtiConsumerXBlock, parse_handler_suffix from lti_consumer.tests.unit import test_utils from lti_consumer.tests.unit.test_utils import FAKE_USER_ID, make_request, make_xblock +from lti_consumer.utils import resolve_custom_parameter_template HTML_PROBLEM_PROGRESS = '<div class="problem-progress">' HTML_ERROR_MESSAGE = '<h3 class="error_message">' @@ -301,6 +304,30 @@ class TestProperties(TestLtiConsumerXBlock): self.assertEqual(params, expected_params) + @patch('lti_consumer.lti_xblock.resolve_custom_parameter_template') + def test_templated_custom_parameters(self, mock_resolve_custom_parameter_template): + """ + Test `prefixed_custom_parameters` when a custom parameter with templated value has been provided. + """ + now = timezone.now() + one_day = timedelta(days=1) + self.xblock.due = now + self.xblock.graceperiod = one_day + self.xblock.custom_parameters = ['dynamic_param_1=${template_value}', 'param_2=false'] + mock_resolve_custom_parameter_template.return_value = 'resolved_template_value' + expected_params = { + 'custom_component_display_name': self.xblock.display_name, + 'custom_component_due_date': now.strftime('%Y-%m-%d %H:%M:%S'), + 'custom_component_graceperiod': str(one_day.total_seconds()), + 'custom_dynamic_param_1': 'resolved_template_value', + 'custom_param_2': 'false', + } + + params = self.xblock.prefixed_custom_parameters + + self.assertEqual(params, expected_params) + mock_resolve_custom_parameter_template.assert_called_once_with(self.xblock, '${template_value}') + def test_invalid_custom_parameter(self): """ Test `prefixed_custom_parameters` when a custom parameter has been configured with the wrong format @@ -1532,3 +1559,77 @@ class TestLti1p3AccessTokenEndpoint(TestLtiConsumerXBlock): response = self.xblock.lti_1p3_access_token(request) self.assertEqual(response.status_code, 200) + + +@patch('lti_consumer.utils.log') +@patch('lti_consumer.utils.import_module') +class TestDynamicCustomParametersResolver(TestLtiConsumerXBlock): + """ + Unit tests for lti_xblock utils resolve_custom_parameter_template method. + """ + + def setUp(self): + super().setUp() + + self.logger = logging.getLogger() + dj_settings.LTI_CUSTOM_PARAM_TEMPLATES = { + 'templated_param_value': 'customer_package.module:func', + } + self.mock_processor_module = Mock(func=Mock()) + + def test_successful_resolve_custom_parameter_template(self, mock_import_module, *_): + """ + Test a successful module import and execution. The template value to be resolved + should be replaced by the processor. + """ + + custom_parameter_template_value = '${templated_param_value}' + expected_resolved_value = 'resolved_value' + mock_import_module.return_value = self.mock_processor_module + self.mock_processor_module.func.return_value = expected_resolved_value + + resolved_value = resolve_custom_parameter_template(self.xblock, custom_parameter_template_value) + + mock_import_module.assert_called_once() + self.assertEqual(resolved_value, expected_resolved_value) + + def test_resolve_custom_parameter_template_with_invalid_data_type_returned(self, mock_import_module, mock_log): + """ + Test a successful module import and execution. The value returned by the processor should be a string object. + Otherwise, it should log an error. + """ + + custom_parameter_template_value = '${templated_param_value}' + mock_import_module.return_value = self.mock_processor_module + self.mock_processor_module.func.return_value = 1 + + resolved_value = resolve_custom_parameter_template(self.xblock, custom_parameter_template_value) + + self.assertEqual(resolved_value, custom_parameter_template_value) + assert mock_log.error.called + + def test_resolve_custom_parameter_template_with_invalid_module(self, mock_import_module, mock_log): + """ + Test a failed import with an undefined module. This should log an error. + """ + mock_import_module.side_effect = ModuleNotFoundError + custom_parameter_template_value = '${not_defined_parameter_template}' + + resolved_value = resolve_custom_parameter_template(self.xblock, custom_parameter_template_value) + + self.assertEqual(resolved_value, custom_parameter_template_value) + assert mock_log.error.called + + def test_lti_custom_param_templates_not_configured(self, mock_import_module, mock_log): + """ + Test the feature with LTI_CUSTOM_PARAM_TEMPLATES setting attribute not configured. + """ + custom_parameter_template_value = '${templated_param_value}' + + dj_settings.__delattr__('LTI_CUSTOM_PARAM_TEMPLATES') + + resolved_value = resolve_custom_parameter_template(self.xblock, custom_parameter_template_value) + + self.assertEqual(resolved_value, custom_parameter_template_value) + assert mock_log.error.called + mock_import_module.asser_not_called() diff --git a/lti_consumer/utils.py b/lti_consumer/utils.py index e81f177a31e48402c10c85447f80cac59d5a3491..52a63559dd4a508a0b65389200c99befc0224035 100644 --- a/lti_consumer/utils.py +++ b/lti_consumer/utils.py @@ -1,8 +1,13 @@ """ Utility functions for LTI Consumer block """ +import logging +from importlib import import_module + from django.conf import settings +log = logging.getLogger(__name__) + def _(text): """ @@ -113,3 +118,37 @@ def get_lti_nrps_context_membership_url(lti_config_id): lms_base=get_lms_base(), lti_config_id=str(lti_config_id), ) + + +def resolve_custom_parameter_template(xblock, template): + """ + Return the value processed according to the template processor. + The template processor must return a string object. + + :param xblock: LTI consumer xblock. + :param template: processor key. + """ + try: + module_name, func_name = settings.LTI_CUSTOM_PARAM_TEMPLATES.get( + template[2:len(template) - 1], + ':', + ).split(':', 1) + template_value = getattr( + import_module(module_name), + func_name, + )(xblock) + + if not isinstance(template_value, str): + log.error('The \'%s\' processor must return a string object.', func_name) + return template + except ValueError: + log.error( + 'Error while processing \'%s\' value. Reason: The template processor definition must be wrong.', + template, + ) + return template + except (AttributeError, ModuleNotFoundError) as ex: + log.error('Error while processing \'%s\' value. Reason: %s', template, str(ex)) + return template + + return template_value