Skip to content

Commit d3f7d9b

Browse files
authored
Merge pull request #215 from Pearson-Advance/ivanvgh/custom-params-template
feat: custom parameters template substitution.
2 parents 30a6147 + 7765f5b commit d3f7d9b

5 files changed

Lines changed: 199 additions & 2 deletions

File tree

README.rst

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,53 @@ To configure parameter processors add the following snippet to your Ansible vari
214214
- 'customer_package.lti_processors:team_and_cohort'
215215
- 'example_package.lti_processors:extra_lti_params'
216216
217+
Dynamic LTI Custom Parameters
218+
=============================
219+
220+
This XBlock gives us the capability to attach static and dynamic custom parameters in the custom parameters field,
221+
in the case we need to declare a dynamic custom parameter we must set the value of the parameter as a templated parameter
222+
wrapped with the tags '${' and '}' just like the following example:
223+
224+
.. code:: python
225+
226+
["static_param=static_value", "dynamic_custom_param=${templated_param_value}"]
227+
228+
Defining a dynamic LTI Custom Parameter Processor
229+
-------------------------------------------------
230+
231+
The custom parameter processor is a function that expects an XBlock instance, and returns a ``string`` which should be the resolved value.
232+
Exceptions must be handled by the processor itself.
233+
234+
.. code:: python
235+
236+
def get_course_name(xblock):
237+
try:
238+
course = CourseOverview.objects.get(id=xblock.course.id)
239+
except CourseOverview.DoesNotExist:
240+
log.error('Course does not exist.')
241+
return ''
242+
243+
return course.display_name
244+
245+
Note. The processor function must return a ``string`` object.
246+
247+
Configuring the LTI Dynamic Custom Parameters Settings
248+
------------------------------------------------------
249+
250+
The setting LTI_CUSTOM_PARAM_TEMPLATES must be set in order to map the template value for the dynamic custom parameter
251+
as the following example:
252+
253+
.. code:: python
254+
255+
LTI_CUSTOM_PARAM_TEMPLATES = {
256+
'templated_param_value': 'customer_package.module:func',
257+
}
258+
259+
* 'templated_param_value': custom parameter template name.
260+
* 'customer_package.module:func': custom parameter processor path and function name.
261+
262+
263+
217264
LTI Advantage Features
218265
======================
219266

@@ -320,6 +367,11 @@ Changelog
320367

321368
Please See the [releases tab](https://github.com/edx/xblock-lti-consumer/releases) for the complete changelog.
322369

370+
3.2.0 - 2022-01-18
371+
-------------------
372+
373+
* Dynamic custom parameters support with the help of template parameter processors.
374+
323375
3.1.2 - 2021-11-12
324376
-------------------
325377

lti_consumer/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
from .lti_xblock import LtiConsumerXBlock
55
from .apps import LTIConsumerApp
66

7-
__version__ = '3.1.2'
7+
__version__ = '3.2.0'

lti_consumer/lti_xblock.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@
8181
)
8282
from .lti_1p3.constants import LTI_1P3_CONTEXT_TYPE
8383
from .outcomes import OutcomeService
84-
from .utils import _
84+
from .utils import _, resolve_custom_parameter_template
8585

8686

8787
log = logging.getLogger(__name__)
@@ -100,6 +100,7 @@
100100
'staff': 'Administrator',
101101
'instructor': 'Instructor',
102102
}
103+
CUSTOM_PARAMETER_TEMPLATE_TAGS = ('${', '}')
103104

104105

105106
def parse_handler_suffix(suffix):
@@ -819,6 +820,10 @@ def prefixed_custom_parameters(self):
819820
if param_name not in LTI_PARAMETERS:
820821
param_name = 'custom_' + param_name
821822

823+
if (param_value.startswith(CUSTOM_PARAMETER_TEMPLATE_TAGS[0]) and
824+
param_value.endswith(CUSTOM_PARAMETER_TEMPLATE_TAGS[1])):
825+
param_value = resolve_custom_parameter_template(self, param_value)
826+
822827
custom_parameters[param_name] = param_value
823828

824829
custom_parameters['custom_component_display_name'] = str(self.display_name)

lti_consumer/tests/unit/test_lti_xblock.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
"""
44

55
import json
6+
import logging
67
import urllib.parse
78
from datetime import timedelta
89
from unittest.mock import Mock, NonCallableMock, PropertyMock, patch
910

1011
import ddt
1112
from Cryptodome.PublicKey import RSA
13+
from django.conf import settings as dj_settings
1214
from django.test.testcases import TestCase
1315
from django.utils import timezone
1416
from jwkest.jwk import RSAKey
@@ -19,6 +21,7 @@
1921
from lti_consumer.lti_xblock import LtiConsumerXBlock, parse_handler_suffix
2022
from lti_consumer.tests.unit import test_utils
2123
from lti_consumer.tests.unit.test_utils import FAKE_USER_ID, make_request, make_xblock
24+
from lti_consumer.utils import resolve_custom_parameter_template
2225

2326
HTML_PROBLEM_PROGRESS = '<div class="problem-progress">'
2427
HTML_ERROR_MESSAGE = '<h3 class="error_message">'
@@ -301,6 +304,30 @@ def test_prefixed_custom_parameters(self):
301304

302305
self.assertEqual(params, expected_params)
303306

307+
@patch('lti_consumer.lti_xblock.resolve_custom_parameter_template')
308+
def test_templated_custom_parameters(self, mock_resolve_custom_parameter_template):
309+
"""
310+
Test `prefixed_custom_parameters` when a custom parameter with templated value has been provided.
311+
"""
312+
now = timezone.now()
313+
one_day = timedelta(days=1)
314+
self.xblock.due = now
315+
self.xblock.graceperiod = one_day
316+
self.xblock.custom_parameters = ['dynamic_param_1=${template_value}', 'param_2=false']
317+
mock_resolve_custom_parameter_template.return_value = 'resolved_template_value'
318+
expected_params = {
319+
'custom_component_display_name': self.xblock.display_name,
320+
'custom_component_due_date': now.strftime('%Y-%m-%d %H:%M:%S'),
321+
'custom_component_graceperiod': str(one_day.total_seconds()),
322+
'custom_dynamic_param_1': 'resolved_template_value',
323+
'custom_param_2': 'false',
324+
}
325+
326+
params = self.xblock.prefixed_custom_parameters
327+
328+
self.assertEqual(params, expected_params)
329+
mock_resolve_custom_parameter_template.assert_called_once_with(self.xblock, '${template_value}')
330+
304331
def test_invalid_custom_parameter(self):
305332
"""
306333
Test `prefixed_custom_parameters` when a custom parameter has been configured with the wrong format
@@ -1532,3 +1559,77 @@ def test_access_token(self):
15321559

15331560
response = self.xblock.lti_1p3_access_token(request)
15341561
self.assertEqual(response.status_code, 200)
1562+
1563+
1564+
@patch('lti_consumer.utils.log')
1565+
@patch('lti_consumer.utils.import_module')
1566+
class TestDynamicCustomParametersResolver(TestLtiConsumerXBlock):
1567+
"""
1568+
Unit tests for lti_xblock utils resolve_custom_parameter_template method.
1569+
"""
1570+
1571+
def setUp(self):
1572+
super().setUp()
1573+
1574+
self.logger = logging.getLogger()
1575+
dj_settings.LTI_CUSTOM_PARAM_TEMPLATES = {
1576+
'templated_param_value': 'customer_package.module:func',
1577+
}
1578+
self.mock_processor_module = Mock(func=Mock())
1579+
1580+
def test_successful_resolve_custom_parameter_template(self, mock_import_module, *_):
1581+
"""
1582+
Test a successful module import and execution. The template value to be resolved
1583+
should be replaced by the processor.
1584+
"""
1585+
1586+
custom_parameter_template_value = '${templated_param_value}'
1587+
expected_resolved_value = 'resolved_value'
1588+
mock_import_module.return_value = self.mock_processor_module
1589+
self.mock_processor_module.func.return_value = expected_resolved_value
1590+
1591+
resolved_value = resolve_custom_parameter_template(self.xblock, custom_parameter_template_value)
1592+
1593+
mock_import_module.assert_called_once()
1594+
self.assertEqual(resolved_value, expected_resolved_value)
1595+
1596+
def test_resolve_custom_parameter_template_with_invalid_data_type_returned(self, mock_import_module, mock_log):
1597+
"""
1598+
Test a successful module import and execution. The value returned by the processor should be a string object.
1599+
Otherwise, it should log an error.
1600+
"""
1601+
1602+
custom_parameter_template_value = '${templated_param_value}'
1603+
mock_import_module.return_value = self.mock_processor_module
1604+
self.mock_processor_module.func.return_value = 1
1605+
1606+
resolved_value = resolve_custom_parameter_template(self.xblock, custom_parameter_template_value)
1607+
1608+
self.assertEqual(resolved_value, custom_parameter_template_value)
1609+
assert mock_log.error.called
1610+
1611+
def test_resolve_custom_parameter_template_with_invalid_module(self, mock_import_module, mock_log):
1612+
"""
1613+
Test a failed import with an undefined module. This should log an error.
1614+
"""
1615+
mock_import_module.side_effect = ModuleNotFoundError
1616+
custom_parameter_template_value = '${not_defined_parameter_template}'
1617+
1618+
resolved_value = resolve_custom_parameter_template(self.xblock, custom_parameter_template_value)
1619+
1620+
self.assertEqual(resolved_value, custom_parameter_template_value)
1621+
assert mock_log.error.called
1622+
1623+
def test_lti_custom_param_templates_not_configured(self, mock_import_module, mock_log):
1624+
"""
1625+
Test the feature with LTI_CUSTOM_PARAM_TEMPLATES setting attribute not configured.
1626+
"""
1627+
custom_parameter_template_value = '${templated_param_value}'
1628+
1629+
dj_settings.__delattr__('LTI_CUSTOM_PARAM_TEMPLATES')
1630+
1631+
resolved_value = resolve_custom_parameter_template(self.xblock, custom_parameter_template_value)
1632+
1633+
self.assertEqual(resolved_value, custom_parameter_template_value)
1634+
assert mock_log.error.called
1635+
mock_import_module.asser_not_called()

lti_consumer/utils.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
"""
22
Utility functions for LTI Consumer block
33
"""
4+
import logging
5+
from importlib import import_module
6+
47
from django.conf import settings
58

9+
log = logging.getLogger(__name__)
10+
611

712
def _(text):
813
"""
@@ -113,3 +118,37 @@ def get_lti_nrps_context_membership_url(lti_config_id):
113118
lms_base=get_lms_base(),
114119
lti_config_id=str(lti_config_id),
115120
)
121+
122+
123+
def resolve_custom_parameter_template(xblock, template):
124+
"""
125+
Return the value processed according to the template processor.
126+
The template processor must return a string object.
127+
128+
:param xblock: LTI consumer xblock.
129+
:param template: processor key.
130+
"""
131+
try:
132+
module_name, func_name = settings.LTI_CUSTOM_PARAM_TEMPLATES.get(
133+
template[2:len(template) - 1],
134+
':',
135+
).split(':', 1)
136+
template_value = getattr(
137+
import_module(module_name),
138+
func_name,
139+
)(xblock)
140+
141+
if not isinstance(template_value, str):
142+
log.error('The \'%s\' processor must return a string object.', func_name)
143+
return template
144+
except ValueError:
145+
log.error(
146+
'Error while processing \'%s\' value. Reason: The template processor definition must be wrong.',
147+
template,
148+
)
149+
return template
150+
except (AttributeError, ModuleNotFoundError) as ex:
151+
log.error('Error while processing \'%s\' value. Reason: %s', template, str(ex))
152+
return template
153+
154+
return template_value

0 commit comments

Comments
 (0)