Skip to content

Commit 3de9bcb

Browse files
kuipumuSquirrel18alexjmpb
authored
fix: CCX LTI configuration compatibility (#391)
* fix: CCX LTI configuration compatibility Co-authored-by: Squirrel18 <daniel.quiroga@edunext.co> Co-authored-by: alexjmpb <alexander.mendoza@edunext.co> * feat: Update version and changelog --------- Co-authored-by: Squirrel18 <daniel.quiroga@edunext.co> Co-authored-by: alexjmpb <alexander.mendoza@edunext.co>
1 parent 1d781f9 commit 3de9bcb

12 files changed

Lines changed: 195 additions & 2 deletions

File tree

CHANGELOG.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ Please See the `releases tab <https://github.com/openedx/xblock-lti-consumer/rel
1616
Unreleased
1717
~~~~~~~~~~
1818

19+
9.6.1 - 2023-06-28
20+
------------------
21+
* Fix CCX LTI configuration compatibility
22+
1923
9.6.0 - 2023-08-01
2024
------------------
2125
* Added support for Django 4.2

lti_consumer/__init__.py

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

7-
__version__ = '9.6.0'
7+
__version__ = '9.6.1'

lti_consumer/models.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from jsonfield import JSONField
1313
from Cryptodome.PublicKey import RSA
14+
from ccx_keys.locator import CCXBlockUsageLocator
1415
from opaque_keys.edx.django.models import CourseKeyField, UsageKeyField
1516
from opaque_keys.edx.keys import CourseKey
1617
from config_models.models import ConfigurationModel
@@ -29,6 +30,7 @@
2930
get_lti_deeplinking_response_url,
3031
get_lti_nrps_context_membership_url,
3132
choose_lti_1p3_redirect_uris,
33+
model_to_dict,
3234
)
3335

3436
log = logging.getLogger(__name__)
@@ -270,6 +272,42 @@ def clean(self):
270272
if consumer is None:
271273
raise ValidationError(_("Invalid LTI configuration."))
272274

275+
def sync_configurations(self):
276+
"""Syncronize main/children configurations.
277+
278+
This method will synchronize the field values of main/children configurations.
279+
On a configuration with a CCX location, it will copy the values from the main course configuration,
280+
otherwise, it will try to query any children configuration and update their fields using
281+
the current configuration values.
282+
"""
283+
EXCLUDED_FIELDS = ['id', 'config_id', 'location']
284+
285+
if isinstance(self.location, CCXBlockUsageLocator):
286+
# Query main configuration using main location.
287+
main_config = LtiConfiguration.objects.filter(location=self.location.to_block_locator()).first()
288+
# Copy fields from main configuration.
289+
for field in model_to_dict(main_config, EXCLUDED_FIELDS).items():
290+
setattr(self, field[0], field[1])
291+
else:
292+
try:
293+
# Query child CCX configurations using location block ID and CCX namespace.
294+
child_configs = LtiConfiguration.objects.filter(
295+
location__endswith=str(self.location).split('@')[-1],
296+
).filter(
297+
location__startswith=CCXBlockUsageLocator.CANONICAL_NAMESPACE,
298+
).exclude(id=self.pk)
299+
# Copy fields to child CCX configurations.
300+
child_configs.update(**model_to_dict(self, EXCLUDED_FIELDS))
301+
except IndexError:
302+
log.exception(
303+
f'Failed to query children CCX LTI configurations: '
304+
f'Failed to parse main LTI configuration location: {self.location}',
305+
)
306+
307+
def save(self, *args, **kwargs):
308+
self.sync_configurations()
309+
super().save(*args, **kwargs)
310+
273311
def _generate_lti_1p3_keys_if_missing(self):
274312
"""
275313
Generate LTI 1.3 RSA256 keys if missing.

lti_consumer/tests/unit/test_models.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"""
44
from contextlib import contextmanager
55
from datetime import datetime, timedelta
6-
from unittest.mock import patch
6+
from unittest.mock import patch, call
77

88
import ddt
99
from Cryptodome.PublicKey import RSA
@@ -12,6 +12,7 @@
1212
from django.utils import timezone
1313
from edx_django_utils.cache import RequestCache
1414
from jwkest.jwk import RSAKey
15+
from ccx_keys.locator import CCXBlockUsageLocator
1516
from opaque_keys.edx.locator import CourseLocator
1617

1718
from lti_consumer.lti_xblock import LtiConsumerXBlock
@@ -415,6 +416,80 @@ def test_get_redirect_uris_for_db_model_returns_expected(
415416

416417
assert self.lti_1p3_config_db.get_lti_1p3_redirect_uris() == expected
417418

419+
@patch.object(LtiConfiguration, 'sync_configurations')
420+
def test_save(self, sync_configurations_mock):
421+
"""Test save method."""
422+
self.assertEqual(self.lti_1p3_config.save(), None)
423+
sync_configurations_mock.assert_called_once_with()
424+
425+
@patch('lti_consumer.models.isinstance', return_value=True)
426+
@patch.object(LtiConfiguration.objects, 'filter')
427+
@patch('lti_consumer.models.model_to_dict')
428+
@patch('lti_consumer.models.setattr')
429+
def test_sync_configurations_with_ccx_location(
430+
self,
431+
setattr_mock,
432+
model_to_dict_mock,
433+
filter_mock,
434+
isinstance_mock,
435+
):
436+
"""
437+
Test sync_configurations method with CCX location.
438+
"""
439+
model_to_dict_mock.return_value = {'test': 'test'}
440+
self.lti_1p3_config.location = 'ccx-block-v1:course+test+2020+ccx@1+type@problem+block@test'
441+
442+
self.assertEqual(self.lti_1p3_config.sync_configurations(), None)
443+
isinstance_mock.assert_called_once_with(self.lti_1p3_config.location, CCXBlockUsageLocator)
444+
filter_mock.assert_has_calls([
445+
call(location=self.lti_1p3_config.location.to_block_locator()),
446+
call().first(),
447+
])
448+
model_to_dict_mock.assert_called_once_with(filter_mock.return_value.first(), ['id', 'config_id', 'location'])
449+
setattr_mock.assert_called_once_with(self.lti_1p3_config, 'test', 'test')
450+
451+
@patch('lti_consumer.models.isinstance', return_value=False)
452+
@patch.object(LtiConfiguration.objects, 'filter')
453+
@patch('lti_consumer.models.model_to_dict')
454+
def test_sync_configurations_with_location(
455+
self,
456+
model_to_dict_mock,
457+
filter_mock,
458+
isinstance_mock,
459+
):
460+
"""
461+
Test sync_configurations method with location.
462+
"""
463+
self.assertEqual(self.lti_1p3_config.sync_configurations(), None)
464+
isinstance_mock.assert_called_once_with(self.lti_1p3_config.location, CCXBlockUsageLocator)
465+
filter_mock.assert_has_calls([
466+
call(location__endswith=str(self.lti_1p3_config.location).split('@')[-1]),
467+
call().filter(location__startswith=CCXBlockUsageLocator.CANONICAL_NAMESPACE),
468+
call().filter().exclude(id=self.lti_1p3_config.pk),
469+
call().filter().exclude().update(**model_to_dict_mock),
470+
])
471+
model_to_dict_mock.assert_called_once_with(self.lti_1p3_config, ['id', 'config_id', 'location'])
472+
473+
@patch('lti_consumer.models.isinstance', return_value=False)
474+
@patch.object(LtiConfiguration.objects, 'filter', side_effect=IndexError())
475+
@patch('lti_consumer.models.log.exception')
476+
def test_sync_configurations_with_invalid_location(
477+
self,
478+
log_exception_mock,
479+
filter_mock,
480+
isinstance_mock,
481+
):
482+
"""
483+
Test sync_configurations method with invalid location.
484+
"""
485+
self.assertEqual(self.lti_1p3_config.sync_configurations(), None)
486+
isinstance_mock.assert_called_once_with(self.lti_1p3_config.location, CCXBlockUsageLocator)
487+
filter_mock.assert_called_once_with(location__endswith=str(self.lti_1p3_config.location).split('@')[-1])
488+
log_exception_mock.assert_called_once_with(
489+
f'Failed to query children CCX LTI configurations: '
490+
f'Failed to parse main LTI configuration location: {self.lti_1p3_config.location}'
491+
)
492+
418493

419494
class TestLtiAgsLineItemModel(TestCase):
420495
"""

lti_consumer/tests/unit/test_utils.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
get_lti_1p3_launch_data_cache_key,
1414
cache_lti_1p3_launch_data,
1515
get_data_from_cache,
16+
model_to_dict,
1617
)
1718

1819
LAUNCH_URL = "http://tool.launch"
@@ -137,3 +138,37 @@ def test_choose_lti_1p3_redirect_uri_returns_expected(self, launch_url, deep_lin
137138
)
138139

139140
assert result == expected
141+
142+
143+
class TestModelToDict(TestCase):
144+
"""
145+
Tests for the model_to_dict function.
146+
"""
147+
148+
def setUp(self):
149+
super().setUp()
150+
self.model_object = Mock()
151+
152+
@patch('lti_consumer.utils.copy.deepcopy', return_value={'test': 'test', '_test': 'test'})
153+
def test_with_exclude_argument(self, deepcopy_mock):
154+
"""
155+
Test model_to_dict function with exclude argument.
156+
"""
157+
self.assertEqual(model_to_dict(self.model_object, ['test']), {})
158+
deepcopy_mock.assert_called_once_with(self.model_object.__dict__)
159+
160+
@patch('lti_consumer.utils.copy.deepcopy', side_effect=AttributeError())
161+
def test_with_attribute_error(self, deepcopy_mock):
162+
"""
163+
Test model_to_dict function with AttributeError exception.
164+
"""
165+
self.assertEqual(model_to_dict(self.model_object), {})
166+
deepcopy_mock.assert_called_once_with(self.model_object.__dict__)
167+
168+
@patch('lti_consumer.utils.copy.deepcopy', side_effect=TypeError())
169+
def test_with_type_error(self, deepcopy_mock):
170+
"""
171+
Test model_to_dict function with TypeError exception.
172+
"""
173+
self.assertEqual(model_to_dict(self.model_object), {})
174+
deepcopy_mock.assert_called_once_with(self.model_object.__dict__)

lti_consumer/utils.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""
22
Utility functions for LTI Consumer block
33
"""
4+
import copy
45
import logging
56
from importlib import import_module
67
from urllib.parse import urlencode
@@ -332,3 +333,27 @@ def check_token_claim(token, claim_key, expected_value=None, invalid_claim_error
332333
if expected_value and claim_value != expected_value:
333334
msg = invalid_claim_error_msg if invalid_claim_error_msg else f"The claim {claim_key} value is invalid."
334335
raise InvalidClaimValue(msg)
336+
337+
338+
def model_to_dict(model_object, exclude=None):
339+
"""
340+
Get dictionary from model object.
341+
342+
This function will create a copy of a model object in a dictionary,
343+
with all private and excluded fields removed.
344+
"""
345+
if not exclude:
346+
exclude = []
347+
348+
try:
349+
# Copy model object fields.
350+
object_fields = copy.deepcopy(model_object.__dict__)
351+
352+
# Remove private and excluded fields.
353+
for key in list(object_fields):
354+
if key.startswith('_') or key in exclude:
355+
object_fields.pop(key, None)
356+
357+
return object_fields
358+
except (AttributeError, TypeError):
359+
return {}

requirements/base.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ xblock-utils
1313
pycryptodomex
1414
pyjwkest
1515
edx-opaque-keys[django]
16+
edx-ccx-keys # Opaque keys for Custom Courses.
1617
django-filter
1718
jsonfield
1819
django-config-models # Configuration models for Django allowing config management with auditing

requirements/base.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ django-waffle==4.0.0
5555
# via edx-django-utils
5656
djangorestframework==3.14.0
5757
# via django-config-models
58+
edx-ccx-keys==1.2.1
59+
# via -r requirements/base.in
5860
edx-django-utils==5.7.0
5961
# via django-config-models
6062
edx-opaque-keys[django]==2.4.0
@@ -138,6 +140,7 @@ simplejson==3.19.1
138140
six==1.16.0
139141
# via
140142
# bleach
143+
# edx-ccx-keys
141144
# fs
142145
# fs-s3fs
143146
# pyjwkest

requirements/ci.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ docutils==0.20.1
143143
# via
144144
# -r requirements/test.txt
145145
# readme-renderer
146+
edx-ccx-keys==1.2.1
147+
# via -r requirements/test.txt
146148
edx-django-utils==5.7.0
147149
# via
148150
# -r requirements/test.txt
@@ -412,6 +414,7 @@ six==1.16.0
412414
# -r requirements/test.txt
413415
# -r requirements/tox.txt
414416
# bleach
417+
# edx-ccx-keys
415418
# edx-lint
416419
# fs
417420
# fs-s3fs

requirements/dev.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ djangorestframework==3.14.0
7878
# via
7979
# -r requirements/base.txt
8080
# django-config-models
81+
edx-ccx-keys==1.2.1
82+
# via -r requirements/base.txt
8183
edx-django-utils==5.7.0
8284
# via
8385
# -r requirements/base.txt
@@ -202,6 +204,7 @@ six==1.16.0
202204
# via
203205
# -r requirements/base.txt
204206
# bleach
207+
# edx-ccx-keys
205208
# fs
206209
# fs-s3fs
207210
# pyjwkest

0 commit comments

Comments
 (0)