Skip to content

Commit 78cabcf

Browse files
Merge pull request #310 from openedx/mroytman/MST-1718-fix-basic-outcomes-result-service-user-id
Fix LTI 1.1 Basic Outcomes Service and LTI 2.0 Rsult Service to Support External User IDs
2 parents 418b1e3 + 3bbbdd5 commit 78cabcf

6 files changed

Lines changed: 112 additions & 31 deletions

File tree

CHANGELOG.rst

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,16 @@ Please See the [releases tab](https://github.com/openedx/xblock-lti-consumer/rel
1616
Unreleased
1717
~~~~~~~~~~
1818

19-
=======
19+
7.0.2 - 2022-11-29
20+
------------------
21+
* Fix the LTI 1.1 Outcome Results Service to be able to tie an outcome pass back to a user when the user ID is an
22+
`external_user_id`.
23+
* Fix the LTI 2.0 Result Service to be able to tie a result pass back to a user when the user ID is an
24+
`external_user_id`.
25+
* Update the `RESULT_SERVICE_SUFFIX_PARSER` regex string to be able to parse UUIDs to accommodate `external_user_ids`.
26+
* Add a `get_lti_1p1_user_from_user_id` method to the `LtiConsumerXBlock` to get the user object associated with a user
27+
ID.
28+
2029
7.0.1 - 2022-11-29
2130
------------------
2231

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__ = '7.0.1'
7+
__version__ = '7.0.2'

lti_consumer/lti_xblock.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@
9393
"/projects/open-edx-building-and-running-a-course/en/latest/exercises_tools/lti_component.html"
9494
"'>"
9595
)
96-
RESULT_SERVICE_SUFFIX_PARSER = re.compile(r"^user/(?P<anon_id>\w+)", re.UNICODE)
96+
RESULT_SERVICE_SUFFIX_PARSER = re.compile(r"^user/(?P<anon_id>[\w-]+)", re.UNICODE)
9797
LTI_1P1_ROLE_MAP = {
9898
'student': 'Student,Learner',
9999
'staff': 'Administrator',
@@ -835,7 +835,7 @@ def external_user_id(self):
835835

836836
def get_lti_1p1_user_id(self):
837837
"""
838-
Returns the user ID to send to an LTI tool during an LTI 1.1 launch. If the
838+
Returns the user ID to send to an LTI tool during an LTI 1.1/2.0 launch. If the
839839
enable_external_user_id_1p1_launches CourseWaffleFlag is enabled for the course, returns the external_user_id
840840
defined by the external_user_ids Djangoapp. Otherwise, returns the anonymous_user_id.
841841
@@ -849,6 +849,23 @@ def get_lti_1p1_user_id(self):
849849

850850
return self.anonymous_user_id
851851

852+
def get_lti_1p1_user_from_user_id(self, user_id):
853+
"""
854+
Returns the user object associated with a user_id. This is used in LTI 1.1/2.0 integrations for calls to the
855+
LTI 1.1 Basic Outcomes service and the LTI 2.0 Results service. Tool Providers may make calls to this library's
856+
endpoints with a user identifier. This function returns a user object associated with that user identifier.
857+
858+
The user identifier may be a course-anonymized user ID (i.e. the anonymous_user_id) or the global, consistent
859+
user ID (i.e. the external_user_id). This functions returns the correct User object.
860+
"""
861+
if external_user_id_1p1_launches_enabled(self.scope_ids.usage_id.context_key):
862+
try:
863+
return compat.get_user_from_external_user_id(user_id)
864+
except LtiError:
865+
return None
866+
else:
867+
return self.runtime.service(self, 'user').get_user_by_anonymous_id(user_id)
868+
852869
@property
853870
def resource_link_id(self):
854871
"""
@@ -1293,7 +1310,7 @@ def result_service_handler(self, request, suffix=''):
12931310
except LtiError:
12941311
return Response(status=401) # Unauthorized in this case. 401 is right
12951312

1296-
user = self.runtime.service(self, 'user').get_user_by_anonymous_id(anon_id)
1313+
user = self.get_lti_1p1_user_from_user_id(anon_id)
12971314
if not user: # that means we can't save to database, as we do not have real user id.
12981315
msg = _("[LTI]: Real user not found against anon_id: {}").format(anon_id)
12991316
log.info(msg)

lti_consumer/outcomes.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,9 @@ def handle_request(self, request):
183183
log.debug("[LTI]: %s", error_message)
184184
return response_xml_template.format(**failure_values)
185185

186-
anon_id = unquote(sourced_id.split(':')[-1])
187-
real_user = self.xblock.runtime.service(self, 'user').get_user_by_anonymous_id(anon_id)
186+
user_id = unquote(sourced_id.split(':')[-1])
187+
real_user = self.xblock.get_lti_1p1_user_from_user_id(user_id)
188+
188189
if not real_user: # that means we can't save to database, as we do not have real user id.
189190
failure_values['imsx_messageIdentifier'] = escape(imsx_message_identifier)
190191
failure_values['imsx_description'] = "User not found."

lti_consumer/tests/unit/test_lti_xblock.py

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -671,8 +671,8 @@ def test_unauthenticated_user(self):
671671

672672

673673
@ddt.ddt
674-
class TestGetLti1p1UserId(TestLtiConsumerXBlock):
675-
""" Unit tests for the get_lti_1p1_user_id method"""
674+
class TestLti1p1UserId(TestLtiConsumerXBlock):
675+
""" Unit tests for the get_lti_1p1_user_id and get_lti_1p1_user_from_user_id methods"""
676676
def setUp(self):
677677
super().setUp()
678678

@@ -698,6 +698,40 @@ def test_external_user_id_flag_enabled(self, external_user_id_1p1_launches_enabl
698698
external_user_id_1p1_launches_enabled.return_value = external_user_id_1p1_launches_enabled_value
699699
self.assertEqual(self.xblock.get_lti_1p1_user_id(), expected_value)
700700

701+
@patch('lti_consumer.lti_xblock.compat')
702+
@ddt.data(True, False)
703+
def test_get_lti_1p1_user_from_user_id(
704+
self,
705+
external_user_id_1p1_launches_enabled,
706+
compat_mock):
707+
708+
# Set the mock user objects for user objects associated with an anonymous_user_id and an external_user_id.
709+
mock_anonymous_user = Mock()
710+
mock_external_user = Mock()
711+
712+
self.xblock.runtime.service(self, 'user').get_user_by_anonymous_id = Mock(return_value=mock_anonymous_user)
713+
compat_mock.get_user_from_external_user_id.return_value = mock_external_user
714+
715+
with patch('lti_consumer.lti_xblock.external_user_id_1p1_launches_enabled') as \
716+
mock_external_user_id_1p1_launches_enabled:
717+
mock_external_user_id_1p1_launches_enabled.return_value = external_user_id_1p1_launches_enabled
718+
719+
user = self.xblock.get_lti_1p1_user_from_user_id('user_id')
720+
721+
if external_user_id_1p1_launches_enabled:
722+
self.assertEqual(user, mock_external_user)
723+
else:
724+
self.assertEqual(user, mock_anonymous_user)
725+
726+
@patch('lti_consumer.lti_xblock.external_user_id_1p1_launches_enabled')
727+
@patch('lti_consumer.lti_xblock.compat')
728+
def test_get_lti_1p1_user_from_user_id_lti_error(self, compat_mock, mock_external_user_id_1p1_launches_enabled):
729+
mock_external_user_id_1p1_launches_enabled.return_value = True
730+
compat_mock.get_user_from_external_user_id.side_effect = LtiError()
731+
732+
user = self.xblock.get_lti_1p1_user_from_user_id('user_id')
733+
self.assertEqual(user, None)
734+
701735

702736
class TestStudentView(TestLtiConsumerXBlock):
703737
"""
@@ -917,11 +951,15 @@ def setUp(self):
917951
super().setUp()
918952
self.lti_provider_key = 'test'
919953
self.lti_provider_secret = 'secret'
920-
self.xblock.runtime.get_real_user = Mock()
921954
self.xblock.accept_grades_past_due = True
922955
self.mock_lti_consumer = Mock()
923956
self.xblock._get_lti_consumer = Mock(return_value=self.mock_lti_consumer) # pylint: disable=protected-access
924957

958+
mock_user = Mock()
959+
mock_id = PropertyMock(return_value=1)
960+
type(mock_user).id = mock_id
961+
self.xblock.get_lti_1p1_user_from_user_id = Mock(return_value=mock_user)
962+
925963
@override_settings(DEBUG=True)
926964
@patch('lti_consumer.lti_xblock.log_authorization_header')
927965
@patch('lti_consumer.lti_xblock.LtiConsumerXBlock.lti_provider_key_secret')
@@ -998,11 +1036,8 @@ def test_bad_user_id(self, mock_parse_suffix):
9981036
Test 404 response returned when a user cannot be found
9991037
"""
10001038
mock_parse_suffix.return_value = FAKE_USER_ID
1001-
self.xblock.runtime.service = Mock(
1002-
return_value=Mock(
1003-
get_user_by_anonymous_id=Mock(return_value=None)
1004-
)
1005-
)
1039+
self.xblock.get_lti_1p1_user_from_user_id.return_value = None
1040+
10061041
response = self.xblock.result_service_handler(make_request('', 'GET'))
10071042

10081043
self.assertEqual(response.status_code, 404)
@@ -1327,6 +1362,15 @@ def test_suffix_match(self):
13271362
parsed = parse_handler_suffix(f"user/{FAKE_USER_ID}")
13281363
self.assertEqual(parsed, FAKE_USER_ID)
13291364

1365+
def test_suffix_match_uuid(self):
1366+
"""
1367+
Test `parse_handler_suffix` when `suffix` is a UUID. Note that we may send UUIDs as user IDs when the
1368+
lti_consumer.enable_external_user_id_1p1_launches CourseWaffleFlag is enabled, so we must be able to parse
1369+
UUID user IDs.
1370+
"""
1371+
parsed = parse_handler_suffix("user/2e9ec4fa-e1cc-4591-9f19-cf1e94454c21")
1372+
self.assertEqual(parsed, "2e9ec4fa-e1cc-4591-9f19-cf1e94454c21")
1373+
13301374

13311375
@ddt.ddt
13321376
class TestGetContext(TestLtiConsumerXBlock):

lti_consumer/tests/unit/test_outcomes.py

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55
import textwrap
66
import unittest
77
from copy import copy
8-
98
from unittest.mock import Mock, PropertyMock, patch
109

10+
import ddt
11+
1112
from lti_consumer.exceptions import LtiError
1213
from lti_consumer.outcomes import OutcomeService, parse_grade_xml_body
13-
from lti_consumer.tests.unit.test_lti_xblock import TestLtiConsumerXBlock
1414
from lti_consumer.tests.test_utils import make_request
15+
from lti_consumer.tests.unit.test_lti_xblock import TestLtiConsumerXBlock
1516

1617
REQUEST_BODY_TEMPLATE_VALID = textwrap.dedent("""
1718
<?xml version="1.0" encoding="UTF-8"?>
@@ -326,14 +327,25 @@ def test_string_with_unicode_chars(self):
326327
self.assertEqual(action, 'ţéšţ_action')
327328

328329

330+
@ddt.ddt
329331
class TestOutcomeService(TestLtiConsumerXBlock):
330332
"""
331333
Unit tests for OutcomeService
332334
"""
333335

334336
def setUp(self):
335337
super().setUp()
336-
self.outcome_servce = OutcomeService(self.xblock)
338+
self.outcome_service = OutcomeService(self.xblock)
339+
340+
# Set up user mock for LtiConsumerXBlock.get_lti_1p1_user_from_user_id method.
341+
self.mock_get_user_id_patcher = patch('lti_consumer.lti_xblock.LtiConsumerXBlock.get_lti_1p1_user_from_user_id')
342+
self.addCleanup(self.mock_get_user_id_patcher.stop)
343+
self.mock_get_user_id_patcher_enabled = self.mock_get_user_id_patcher.start()
344+
345+
mock_user = Mock()
346+
mock_id = PropertyMock(return_value=1)
347+
type(mock_user).id = mock_id
348+
self.mock_get_user_id_patcher_enabled.return_value = mock_user
337349

338350
@patch('lti_consumer.outcomes.verify_oauth_body_signature', Mock(return_value=True))
339351
@patch('lti_consumer.lti_xblock.LtiConsumerXBlock.lti_provider_key_secret', PropertyMock(return_value=('t', 's')))
@@ -343,6 +355,7 @@ def test_handle_replace_result_success(self):
343355
Test replace result request returns with success indicator
344356
"""
345357
request = make_request('')
358+
346359
values = {
347360
'code': 'success',
348361
'description': 'Score for is now 0.5',
@@ -351,7 +364,7 @@ def test_handle_replace_result_success(self):
351364
}
352365

353366
self.assertEqual(
354-
self.outcome_servce.handle_request(request).strip(),
367+
self.outcome_service.handle_request(request).strip(),
355368
RESPONSE_BODY_TEMPLATE.format(**values).strip()
356369
)
357370

@@ -362,7 +375,7 @@ def test_grade_past_due(self):
362375
"""
363376
request = make_request('')
364377
self.xblock.accept_grades_past_due = False
365-
response = self.outcome_servce.handle_request(request)
378+
response = self.outcome_service.handle_request(request)
366379

367380
self.assertIn('failure', response)
368381
self.assertIn('Grade is past due', response)
@@ -376,7 +389,7 @@ def test_lti_error_not_raises_type_error(self, mock_parse):
376389
request = make_request('test_string')
377390

378391
mock_parse.side_effect = LtiError
379-
response = self.outcome_servce.handle_request(request)
392+
response = self.outcome_service.handle_request(request)
380393
self.assertNotIn('TypeError', response)
381394
self.assertNotIn('a bytes-like object is required', response)
382395
self.assertIn('Request body XML parsing error', response)
@@ -389,7 +402,7 @@ def test_xml_parse_lti_error(self, mock_parse):
389402
request = make_request('')
390403

391404
mock_parse.side_effect = LtiError
392-
response = self.outcome_servce.handle_request(request)
405+
response = self.outcome_service.handle_request(request)
393406
self.assertIn('failure', response)
394407
self.assertIn('Request body XML parsing error', response)
395408

@@ -403,10 +416,10 @@ def test_invalid_signature(self, mock_verify):
403416
request = make_request('')
404417

405418
mock_verify.side_effect = ValueError
406-
self.assertIn('failure', self.outcome_servce.handle_request(request))
419+
self.assertIn('failure', self.outcome_service.handle_request(request))
407420

408421
mock_verify.side_effect = LtiError
409-
self.assertIn('failure', self.outcome_servce.handle_request(request))
422+
self.assertIn('failure', self.outcome_service.handle_request(request))
410423

411424
@patch('lti_consumer.outcomes.verify_oauth_body_signature', Mock(return_value=True))
412425
@patch('lti_consumer.lti_xblock.LtiConsumerXBlock.lti_provider_key_secret', PropertyMock(return_value=('t', 's')))
@@ -416,12 +429,9 @@ def test_user_not_found(self):
416429
Test user not found returns failure response
417430
"""
418431
request = make_request('')
419-
self.xblock.runtime.service = Mock(
420-
return_value=Mock(
421-
get_user_by_anonymous_id=Mock(return_value=None)
422-
)
423-
)
424-
response = self.outcome_servce.handle_request(request)
432+
433+
self.mock_get_user_id_patcher_enabled.return_value = None
434+
response = self.outcome_service.handle_request(request)
425435

426436
self.assertIn('failure', response)
427437
self.assertIn('User not found', response)
@@ -434,7 +444,7 @@ def test_unsupported_action(self):
434444
Test unsupported action returns unsupported response
435445
"""
436446
request = make_request('')
437-
response = self.outcome_servce.handle_request(request)
447+
response = self.outcome_service.handle_request(request)
438448

439449
self.assertIn('unsupported', response)
440450
self.assertIn('Target does not support the requested operation.', response)

0 commit comments

Comments
 (0)