diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7b70f257a648a3707325e0c802da8b78f900e291..b92ea47807970962c4006650bf9928e306a4958d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,7 +16,16 @@ Please See the [releases tab](https://github.com/openedx/xblock-lti-consumer/rel Unreleased ~~~~~~~~~~ -======= +7.0.2 - 2022-11-29 +------------------ +* 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 + `external_user_id`. +* 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 + `external_user_id`. +* Update the `RESULT_SERVICE_SUFFIX_PARSER` regex string to be able to parse UUIDs to accommodate `external_user_ids`. +* Add a `get_lti_1p1_user_from_user_id` method to the `LtiConsumerXBlock` to get the user object associated with a user + ID. + 7.0.1 - 2022-11-29 ------------------ diff --git a/lti_consumer/__init__.py b/lti_consumer/__init__.py index a920083c27989e8694e367c12fc09e9867109f9e..73e477f21d3a93b92318e291987d0659fae3146e 100644 --- a/lti_consumer/__init__.py +++ b/lti_consumer/__init__.py @@ -4,4 +4,4 @@ Runtime will load the XBlock class from here. from .apps import LTIConsumerApp from .lti_xblock import LtiConsumerXBlock -__version__ = '7.0.1' +__version__ = '7.0.2' diff --git a/lti_consumer/lti_xblock.py b/lti_consumer/lti_xblock.py index fb3e1d96cd1ffb52dcf3bc2099ea8e13997faf45..39e9dcdf573b0fce4923074b0a5a8a278e514786 100644 --- a/lti_consumer/lti_xblock.py +++ b/lti_consumer/lti_xblock.py @@ -93,7 +93,7 @@ DOCS_ANCHOR_TAG_OPEN = ( "/projects/open-edx-building-and-running-a-course/en/latest/exercises_tools/lti_component.html" "'>" ) -RESULT_SERVICE_SUFFIX_PARSER = re.compile(r"^user/(?P<anon_id>\w+)", re.UNICODE) +RESULT_SERVICE_SUFFIX_PARSER = re.compile(r"^user/(?P<anon_id>[\w-]+)", re.UNICODE) LTI_1P1_ROLE_MAP = { 'student': 'Student,Learner', 'staff': 'Administrator', @@ -835,7 +835,7 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): def get_lti_1p1_user_id(self): """ - Returns the user ID to send to an LTI tool during an LTI 1.1 launch. If the + Returns the user ID to send to an LTI tool during an LTI 1.1/2.0 launch. If the enable_external_user_id_1p1_launches CourseWaffleFlag is enabled for the course, returns the external_user_id defined by the external_user_ids Djangoapp. Otherwise, returns the anonymous_user_id. @@ -849,6 +849,23 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): return self.anonymous_user_id + def get_lti_1p1_user_from_user_id(self, user_id): + """ + Returns the user object associated with a user_id. This is used in LTI 1.1/2.0 integrations for calls to the + LTI 1.1 Basic Outcomes service and the LTI 2.0 Results service. Tool Providers may make calls to this library's + endpoints with a user identifier. This function returns a user object associated with that user identifier. + + The user identifier may be a course-anonymized user ID (i.e. the anonymous_user_id) or the global, consistent + user ID (i.e. the external_user_id). This functions returns the correct User object. + """ + if external_user_id_1p1_launches_enabled(self.scope_ids.usage_id.context_key): + try: + return compat.get_user_from_external_user_id(user_id) + except LtiError: + return None + else: + return self.runtime.service(self, 'user').get_user_by_anonymous_id(user_id) + @property def resource_link_id(self): """ @@ -1293,7 +1310,7 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): except LtiError: return Response(status=401) # Unauthorized in this case. 401 is right - user = self.runtime.service(self, 'user').get_user_by_anonymous_id(anon_id) + user = self.get_lti_1p1_user_from_user_id(anon_id) if not user: # that means we can't save to database, as we do not have real user id. msg = _("[LTI]: Real user not found against anon_id: {}").format(anon_id) log.info(msg) diff --git a/lti_consumer/outcomes.py b/lti_consumer/outcomes.py index b05ae0db0bfbb19be8e44557828919ae7de154b7..94112ec54bd6047bb7e5fe144cd3d83c7f6b9c40 100644 --- a/lti_consumer/outcomes.py +++ b/lti_consumer/outcomes.py @@ -183,8 +183,9 @@ class OutcomeService: log.debug("[LTI]: %s", error_message) return response_xml_template.format(**failure_values) - anon_id = unquote(sourced_id.split(':')[-1]) - real_user = self.xblock.runtime.service(self, 'user').get_user_by_anonymous_id(anon_id) + user_id = unquote(sourced_id.split(':')[-1]) + real_user = self.xblock.get_lti_1p1_user_from_user_id(user_id) + if not real_user: # that means we can't save to database, as we do not have real user id. failure_values['imsx_messageIdentifier'] = escape(imsx_message_identifier) failure_values['imsx_description'] = "User not found." diff --git a/lti_consumer/tests/unit/test_lti_xblock.py b/lti_consumer/tests/unit/test_lti_xblock.py index 0b8631cd1ab3ef0a92ff381395a86d24cd9417ee..106828571c1ac82dd8056fbbb35a5c7afbfa4815 100644 --- a/lti_consumer/tests/unit/test_lti_xblock.py +++ b/lti_consumer/tests/unit/test_lti_xblock.py @@ -671,8 +671,8 @@ class TestExtractRealUserData(TestLtiConsumerXBlock): @ddt.ddt -class TestGetLti1p1UserId(TestLtiConsumerXBlock): - """ Unit tests for the get_lti_1p1_user_id method""" +class TestLti1p1UserId(TestLtiConsumerXBlock): + """ Unit tests for the get_lti_1p1_user_id and get_lti_1p1_user_from_user_id methods""" def setUp(self): super().setUp() @@ -698,6 +698,40 @@ class TestGetLti1p1UserId(TestLtiConsumerXBlock): external_user_id_1p1_launches_enabled.return_value = external_user_id_1p1_launches_enabled_value self.assertEqual(self.xblock.get_lti_1p1_user_id(), expected_value) + @patch('lti_consumer.lti_xblock.compat') + @ddt.data(True, False) + def test_get_lti_1p1_user_from_user_id( + self, + external_user_id_1p1_launches_enabled, + compat_mock): + + # Set the mock user objects for user objects associated with an anonymous_user_id and an external_user_id. + mock_anonymous_user = Mock() + mock_external_user = Mock() + + self.xblock.runtime.service(self, 'user').get_user_by_anonymous_id = Mock(return_value=mock_anonymous_user) + compat_mock.get_user_from_external_user_id.return_value = mock_external_user + + with patch('lti_consumer.lti_xblock.external_user_id_1p1_launches_enabled') as \ + mock_external_user_id_1p1_launches_enabled: + mock_external_user_id_1p1_launches_enabled.return_value = external_user_id_1p1_launches_enabled + + user = self.xblock.get_lti_1p1_user_from_user_id('user_id') + + if external_user_id_1p1_launches_enabled: + self.assertEqual(user, mock_external_user) + else: + self.assertEqual(user, mock_anonymous_user) + + @patch('lti_consumer.lti_xblock.external_user_id_1p1_launches_enabled') + @patch('lti_consumer.lti_xblock.compat') + def test_get_lti_1p1_user_from_user_id_lti_error(self, compat_mock, mock_external_user_id_1p1_launches_enabled): + mock_external_user_id_1p1_launches_enabled.return_value = True + compat_mock.get_user_from_external_user_id.side_effect = LtiError() + + user = self.xblock.get_lti_1p1_user_from_user_id('user_id') + self.assertEqual(user, None) + class TestStudentView(TestLtiConsumerXBlock): """ @@ -917,11 +951,15 @@ class TestResultServiceHandler(TestLtiConsumerXBlock): super().setUp() self.lti_provider_key = 'test' self.lti_provider_secret = 'secret' - self.xblock.runtime.get_real_user = Mock() self.xblock.accept_grades_past_due = True self.mock_lti_consumer = Mock() self.xblock._get_lti_consumer = Mock(return_value=self.mock_lti_consumer) # pylint: disable=protected-access + mock_user = Mock() + mock_id = PropertyMock(return_value=1) + type(mock_user).id = mock_id + self.xblock.get_lti_1p1_user_from_user_id = Mock(return_value=mock_user) + @override_settings(DEBUG=True) @patch('lti_consumer.lti_xblock.log_authorization_header') @patch('lti_consumer.lti_xblock.LtiConsumerXBlock.lti_provider_key_secret') @@ -998,11 +1036,8 @@ class TestResultServiceHandler(TestLtiConsumerXBlock): Test 404 response returned when a user cannot be found """ mock_parse_suffix.return_value = FAKE_USER_ID - self.xblock.runtime.service = Mock( - return_value=Mock( - get_user_by_anonymous_id=Mock(return_value=None) - ) - ) + self.xblock.get_lti_1p1_user_from_user_id.return_value = None + response = self.xblock.result_service_handler(make_request('', 'GET')) self.assertEqual(response.status_code, 404) @@ -1327,6 +1362,15 @@ class TestParseSuffix(TestLtiConsumerXBlock): parsed = parse_handler_suffix(f"user/{FAKE_USER_ID}") self.assertEqual(parsed, FAKE_USER_ID) + def test_suffix_match_uuid(self): + """ + Test `parse_handler_suffix` when `suffix` is a UUID. Note that we may send UUIDs as user IDs when the + lti_consumer.enable_external_user_id_1p1_launches CourseWaffleFlag is enabled, so we must be able to parse + UUID user IDs. + """ + parsed = parse_handler_suffix("user/2e9ec4fa-e1cc-4591-9f19-cf1e94454c21") + self.assertEqual(parsed, "2e9ec4fa-e1cc-4591-9f19-cf1e94454c21") + @ddt.ddt class TestGetContext(TestLtiConsumerXBlock): diff --git a/lti_consumer/tests/unit/test_outcomes.py b/lti_consumer/tests/unit/test_outcomes.py index dcc1e5de3462402cd9d3baeb493c00517007fa19..8008f9f7d2fe90dc137691ad1ae966d6d826b76e 100644 --- a/lti_consumer/tests/unit/test_outcomes.py +++ b/lti_consumer/tests/unit/test_outcomes.py @@ -5,13 +5,14 @@ Unit tests for lti_consumer.outcomes module import textwrap import unittest from copy import copy - from unittest.mock import Mock, PropertyMock, patch +import ddt + from lti_consumer.exceptions import LtiError from lti_consumer.outcomes import OutcomeService, parse_grade_xml_body -from lti_consumer.tests.unit.test_lti_xblock import TestLtiConsumerXBlock from lti_consumer.tests.test_utils import make_request +from lti_consumer.tests.unit.test_lti_xblock import TestLtiConsumerXBlock REQUEST_BODY_TEMPLATE_VALID = textwrap.dedent(""" <?xml version="1.0" encoding="UTF-8"?> @@ -326,6 +327,7 @@ class TestParseGradeXmlBody(unittest.TestCase): self.assertEqual(action, 'ţéšţ_action') +@ddt.ddt class TestOutcomeService(TestLtiConsumerXBlock): """ Unit tests for OutcomeService @@ -333,7 +335,17 @@ class TestOutcomeService(TestLtiConsumerXBlock): def setUp(self): super().setUp() - self.outcome_servce = OutcomeService(self.xblock) + self.outcome_service = OutcomeService(self.xblock) + + # Set up user mock for LtiConsumerXBlock.get_lti_1p1_user_from_user_id method. + self.mock_get_user_id_patcher = patch('lti_consumer.lti_xblock.LtiConsumerXBlock.get_lti_1p1_user_from_user_id') + self.addCleanup(self.mock_get_user_id_patcher.stop) + self.mock_get_user_id_patcher_enabled = self.mock_get_user_id_patcher.start() + + mock_user = Mock() + mock_id = PropertyMock(return_value=1) + type(mock_user).id = mock_id + self.mock_get_user_id_patcher_enabled.return_value = mock_user @patch('lti_consumer.outcomes.verify_oauth_body_signature', Mock(return_value=True)) @patch('lti_consumer.lti_xblock.LtiConsumerXBlock.lti_provider_key_secret', PropertyMock(return_value=('t', 's'))) @@ -343,6 +355,7 @@ class TestOutcomeService(TestLtiConsumerXBlock): Test replace result request returns with success indicator """ request = make_request('') + values = { 'code': 'success', 'description': 'Score for is now 0.5', @@ -351,7 +364,7 @@ class TestOutcomeService(TestLtiConsumerXBlock): } self.assertEqual( - self.outcome_servce.handle_request(request).strip(), + self.outcome_service.handle_request(request).strip(), RESPONSE_BODY_TEMPLATE.format(**values).strip() ) @@ -362,7 +375,7 @@ class TestOutcomeService(TestLtiConsumerXBlock): """ request = make_request('') self.xblock.accept_grades_past_due = False - response = self.outcome_servce.handle_request(request) + response = self.outcome_service.handle_request(request) self.assertIn('failure', response) self.assertIn('Grade is past due', response) @@ -376,7 +389,7 @@ class TestOutcomeService(TestLtiConsumerXBlock): request = make_request('test_string') mock_parse.side_effect = LtiError - response = self.outcome_servce.handle_request(request) + response = self.outcome_service.handle_request(request) self.assertNotIn('TypeError', response) self.assertNotIn('a bytes-like object is required', response) self.assertIn('Request body XML parsing error', response) @@ -389,7 +402,7 @@ class TestOutcomeService(TestLtiConsumerXBlock): request = make_request('') mock_parse.side_effect = LtiError - response = self.outcome_servce.handle_request(request) + response = self.outcome_service.handle_request(request) self.assertIn('failure', response) self.assertIn('Request body XML parsing error', response) @@ -403,10 +416,10 @@ class TestOutcomeService(TestLtiConsumerXBlock): request = make_request('') mock_verify.side_effect = ValueError - self.assertIn('failure', self.outcome_servce.handle_request(request)) + self.assertIn('failure', self.outcome_service.handle_request(request)) mock_verify.side_effect = LtiError - self.assertIn('failure', self.outcome_servce.handle_request(request)) + self.assertIn('failure', self.outcome_service.handle_request(request)) @patch('lti_consumer.outcomes.verify_oauth_body_signature', Mock(return_value=True)) @patch('lti_consumer.lti_xblock.LtiConsumerXBlock.lti_provider_key_secret', PropertyMock(return_value=('t', 's'))) @@ -416,12 +429,9 @@ class TestOutcomeService(TestLtiConsumerXBlock): Test user not found returns failure response """ request = make_request('') - self.xblock.runtime.service = Mock( - return_value=Mock( - get_user_by_anonymous_id=Mock(return_value=None) - ) - ) - response = self.outcome_servce.handle_request(request) + + self.mock_get_user_id_patcher_enabled.return_value = None + response = self.outcome_service.handle_request(request) self.assertIn('failure', response) self.assertIn('User not found', response) @@ -434,7 +444,7 @@ class TestOutcomeService(TestLtiConsumerXBlock): Test unsupported action returns unsupported response """ request = make_request('') - response = self.outcome_servce.handle_request(request) + response = self.outcome_service.handle_request(request) self.assertIn('unsupported', response) self.assertIn('Target does not support the requested operation.', response)