Skip to content

Fix LTI 1.1 Basic Outcomes Service and LTI 2.0 Result Service to Support External User IDs - #310

Merged
michaelroytman merged 1 commit into
masterfrom
mroytman/MST-1718-fix-basic-outcomes-result-service-user-id
Nov 29, 2022
Merged

Fix LTI 1.1 Basic Outcomes Service and LTI 2.0 Result Service to Support External User IDs#310
michaelroytman merged 1 commit into
masterfrom
mroytman/MST-1718-fix-basic-outcomes-result-service-user-id

Conversation

@michaelroytman

@michaelroytman michaelroytman commented Nov 29, 2022

Copy link
Copy Markdown
Contributor

Description

In #307, we added the ability to send a stable, static user identifier (i.e. external user ID) to fix failed launches with the QwikLabs tool. This is because the QwikLabs tool did not work with the course-anonymized user IDs we used to send (i.e. anonymous user IDs). Inadvertently, this change broke the LTI 1.1 Basic Outcomes Service and the LTI 2.0 Result Service for courses that use the external user ID (i.e. they have the lti_consumer.enable_external_user_id_1p1_launches CourseWaffleFlag enabled). The Basic Outcomes Service and Result Service handle grade pass backs. Because we now have two ways to identify a user in LTI 1.1/2.0, we must update the Basic Outcomes Service and Result Service to support both.

Testing Instructions

Testing these is a little complex. I've tried to be as comprehensive as I can be with the instructions here, but please reach out if you have trouble.

LTI 1.1 Basic Outcomes Service

  • Set up devstack.

  • Create an LTI 1.1 component in Studio, following the instructions in the README.

  • Set up your preferred API testing tool (e.g. Postman) to send a POST request.

    • URL: <BASIC_OUTCOMES_URL>
    • Body: raw
      • Be sure to update the sourcedId XML component appropriately.
    <?xml version="1.0" encoding="UTF-8"?>
    <imsx_POXEnvelopeRequest xmlns="http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0">
    <imsx_POXHeader>
      <imsx_POXRequestHeaderInfo>
        <imsx_version>V1.0</imsx_version>
        <imsx_messageIdentifier>999999123</imsx_messageIdentifier>
      </imsx_POXRequestHeaderInfo>
    </imsx_POXHeader>
    <imsx_POXBody>
      <replaceResultRequest>
        <resultRecord>
          <sourcedGUID>
            <sourcedId>course-v1%3AedX%2B1717N1%2BY2022N1:localhost%3A18000-1bca781ee09347a6800ad29c346abc07:1bc0b578-6f17-4e32-917a-94dc63edddda</sourcedId>
          </sourcedGUID>
          <result>
            <resultScore>
              <language>en</language>
              <textString>0.92</textString>
            </resultScore>
          </result>
        </resultRecord>
      </replaceResultRequest>
    </imsx_POXBody>
    </imsx_POXEnvelopeRequest>
  • The basic_outcomes_url can be retrieved viewing the live LTI component in the LMS, if you’re using the SaLTIre tool, because it displays this value for you. If it does not, you can retrieve it by using the LTI rest endpoints view.

    • Go to https://<LMS_DOMAIN>/courses/<COURSE_ID>/lti_rest_endpoints/, find your component, and select the lti_1_1_result_service_xml_endpoint. Note that you should use http with devstack.
  • Open a Python shell and install the oauthlib Python module.

    • Alternatively, you can open a Django shell in the LMS/Studio devstack shells. It is installed there as a necessary dependency of xblock-lti-consumer.
  • Open a Python shell and enter the following commands. client_key and client_secret come from your LTI passport string. The ones below come from the SaLTIre testing tool, as described in the xblock-lti-consumer README file.

    >>> from oauthlib import oauth1
    >>> client_key="test"
    >>> client_secret="secret"
    >>> client = oauth1.Client(client_key=client_key, client_secret=client_secret)
    >>> basic_outcomes_url = "http://localhost:18000/courses/course-v1:edX+1717N1+Y2022N1/xblock/block-v1:edX+1717N1+Y2022N1+type@lti_consumer+block@1bca781ee09347a6800ad29c346abc07/handler_noauth/outcome_service_handler"
    >>> xml_body = """<?xml version="1.0" encoding="UTF-8"?>
    <imsx_POXEnvelopeResponse xmlns = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0">
        <imsx_POXHeader>
            <imsx_POXResponseHeaderInfo>
                <imsx_version>V1.0</imsx_version>
                <imsx_messageIdentifier>999999123</imsx_messageIdentifier>
                <imsx_statusInfo>
                    <imsx_codeMajor>failure</imsx_codeMajor>
                    <imsx_severity>status</imsx_severity>
                    <imsx_description>User not found.</imsx_description>
                    <imsx_messageRefIdentifier>
                    </imsx_messageRefIdentifier>
                </imsx_statusInfo>
            </imsx_POXResponseHeaderInfo>
        </imsx_POXHeader>
        <imsx_POXBody></imsx_POXBody>
    </imsx_POXEnvelopeResponse>"""
    >>> uri, headers, body = client.sign(basic_outcomes_url, http_method="POST", body=xml_body, headers={"Content-Type": "text/xml"})
    • If you have issues with the oauth_body_hash not matching, make sure that your xml_body matches the body variable in verify_oauth_body_signature. A breakpoint can be helpful.
  • headers should look something like this.

{'Content-Type': 'text/xml', 'Authorization': 'OAuth oauth_nonce="5609288327616222561669665375", oauth_timestamp="1669665375", oauth_version="1.0", oauth_signature_method="HMAC-SHA1", oauth_consumer_key="test", oauth_body_hash="vAVegN28HcixFW7OuHgfx0Ld%2Bdk%3D", oauth_signature="4Or9QJKG66jFHpZU6JeyNHcYdDk%3D"'}
  • Take the Authorization header and use it in your preferred API testing tool (e.g. Postman). For example, in Postman, you should have something like the following header mapping.
  • Note: If you’re switching between anonymous user IDs and external user IDs, you’ll need to update your XML in Postman and recompute the Authorization header using the instructions above.

LTI 2.0 Result Service

To send a Result Service request in devstack, follow these steps.

  • Set up devstack.

  • Create an LTI 1.1 component in Studio, following the instructions in the README.

  • Set up your preferred API testing tool (e.g. Postman) to send a PUT/GET request.

    • Set the request method to GET or PUT.
    • URL: <RESULT_SERVICE_URL>
    • Body: raw
      • Be sure to update the JSON appropriately.
      • NOTE: Do NOT set the content type to JSON. Leave it as raw, text.
    {
        "@context" : "http://purl.imsglobal.org/ctx/lis/v2/Result",
        "@type" : "Result",
        "resultScore" : 0.83,
        "comment" : "This is exceptional work."
    }
  • The result_service_url can be retrieved viewing the live LTI component in the LMS, if you’re using the SaLTIre tool, because it displays this value for you. If it does not, you can retrieve it by using the LTI rest endpoints view.

    • Go to https://<LMS_DOMAIN>/courses/<COURSE_ID>/lti_rest_endpoints/, find your component, and select the lti_2_0_result_service_json_endpoint. Note that you should use http with devstack.
    • If you’re making a PUT request, under Headers, set the Content Type header to application/vnd.ims.lis.v2.result+json.
  • Open a Python shell and install the oauthlib Python module.

    • Alternatively, you can open a Django shell in the LMS/Studio devstack shells. It is installed there as a necessary dependency of xblock-lti-consumer.
  • Open a Python shell and enter the following commands. client_key and client_secret come from your LTI passport string. The ones below come from the SaLTIre testing tool, as described in the xblock-lti-consumer README file.

    >>> from oauthlib import oauth1
    >>> client_key="test"
    >>> client_secret="secret"
    >>> client = oauth1.Client(client_key=client_key, client_secret=client_secret)
    >>> result_service_url = "http://localhost:18000/courses/course-v1:edX+1717N1+Y2022N1/xblock/block-v1:edX+1717N1+Y2022N1+type@lti_consumer+block@1bca781ee09347a6800ad29c346abc07/handler_noauth/result_service_handler/user/1bc0b578-6f17-4e32-917a-94dc63edddda"
    >>> json_body = """{
    >>>     "@context" : "http://purl.imsglobal.org/ctx/lis/v2/Result",
    >>>     "@type" : "Result",
    >>>     "resultScore" : 0.83,
    >>>     "comment": "This is exceptional work."
    >>> }"""
    >>> uri, headers, body = client.sign(result_service_url, http_method="PUT", body=json_body, headers={"Content-Type": "application/vnd.ims.lis.v2.result+json"})
    • If you have issues with the oauth_body_hash not matching, make sure that your xml_body matches the body variable in verify_oauth_body_signature. A breakpoint can be helpful.
      • Note: Be sure the json_body matches exactly what is in Postman, including whitespace characters (e.g. spaces versus tabs).
  • headers should look something like this.

{'Content-Type': 'text/xml', 'Authorization': 'OAuth oauth_nonce="5609288327616222561669665375", oauth_timestamp="1669665375", oauth_version="1.0", oauth_signature_method="HMAC-SHA1", oauth_consumer_key="test", oauth_body_hash="vAVegN28HcixFW7OuHgfx0Ld%2Bdk%3D", oauth_signature="4Or9QJKG66jFHpZU6JeyNHcYdDk%3D"'}
  • Take the Authorization header and use it in your preferred API testing tool (e.g. Postman). For example, in Postman, you should have something like the following header mapping.
Authorization: OAuth oauth_nonce="5609288327616222561669665375", oauth_timestamp="1669665375", oauth_version="1.0", oauth_signature_method="HMAC-SHA1", oauth_consumer_key="test", oauth_body_hash="vAVegN28HcixFW7OuHgfx0Ld%2Bdk%3D", oauth_signature="4Or9QJKG66jFHpZU6JeyNHcYdDk%3D"
  • Note: If you’re switching between anonymous user IDs and external user IDs, you’ll need to update your URL in Postman and recompute the Authorization header using the instructions above.

Manual Testing

I performed the following manual tests.

  • Tested LTI 1.1/2.0 component with lti_consumer.enable_external_user_id_1p1_launches flag on, sent Basic Outcomes Service request with external user ID
  • Tested LTI 1.1/2.0 component with lti_consumer.enable_external_user_id_1p1_launches flag off, sent Basic Outcomes Service request with anonymous user ID
  • Tested LTI 1.1/2.0 component with lti_consumer.enable_external_user_id_1p1_launches flag on, sent Result Service request with external user ID
  • Tested LTI 1.1/2.0 component with lti_consumer.enable_external_user_id_1p1_launches flag off, sent Result Service request with anonymous user ID

@michaelroytman
michaelroytman force-pushed the mroytman/MST-1718-fix-basic-outcomes-result-service-user-id branch from 4829b43 to 212455f Compare November 29, 2022 20:26
"'>"
)
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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds support for UUID user IDs. Otherwise, it lops off part of the user ID.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this must have been maddening to find

@codecov

codecov Bot commented Nov 29, 2022

Copy link
Copy Markdown

Codecov Report

Base: 97.78% // Head: 97.76% // Decreases project coverage by -0.02% ⚠️

Coverage data is based on head (212455f) compared to base (418b1e3).
Patch coverage: 96.15% of modified lines in pull request are covered.

❗ Current head 212455f differs from pull request most recent head 3bbbdd5. Consider uploading reports for the commit 3bbbdd5 to get more accurate results

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #310      +/-   ##
==========================================
- Coverage   97.78%   97.76%   -0.03%     
==========================================
  Files          72       72              
  Lines        5875     5910      +35     
==========================================
+ Hits         5745     5778      +33     
- Misses        130      132       +2     
Flag Coverage Δ
unittests 97.76% <96.15%> (-0.03%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Impacted Files Coverage Δ
lti_consumer/lti_xblock.py 95.27% <77.77%> (-0.39%) ⬇️
lti_consumer/outcomes.py 100.00% <100.00%> (ø)
lti_consumer/tests/unit/test_lti_xblock.py 99.14% <100.00%> (+0.01%) ⬆️
lti_consumer/tests/unit/test_outcomes.py 100.00% <100.00%> (ø)

Help us with your feedback. Take ten seconds to tell us how you rate us. Have a feature suggestion? Share it here.

☔ View full report at Codecov.
📢 Do you have feedback about the report comment? Let us know in this issue.

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):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is right, based on #249.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since the IDs are different formats we could detect that difference, but this seems cleaner

Comment thread CHANGELOG.rst Outdated
@@ -17,6 +17,15 @@ Unreleased
~~~~~~~~~~

=======

@ashultz0 ashultz0 Nov 29, 2022

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this set of ==== shouldn't be in the changelog, I left it in there when I was resolving conflicts

😞

Comment thread lti_consumer/outcomes.py
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice variable name change here

@ashultz0 ashultz0 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 shipit

after shipping please capture those test instructions into a doc ideally in the repo or at least in the wiki, they're very useful

@michaelroytman
michaelroytman force-pushed the mroytman/MST-1718-fix-basic-outcomes-result-service-user-id branch from 212455f to 403b452 Compare November 29, 2022 21:20
@michaelroytman michaelroytman changed the title Fix LTI 1.1 Basic Outcomes Service and LTI 2.0 Rsult Service to Support External User IDs Fix LTI 1.1 Basic Outcomes Service and LTI 2.0 Result Service to Support External User IDs Nov 29, 2022
… support external user IDs

In #307, we added the ability to send a stable, static user identifier (i.e. external user ID) to fix failed launches with the QwikLabs tool. This is because the QwikLabs tool did not work with the course-anonymized user IDs we used to send (i.e. anonymous user IDs). Inadvertently, this change broke the LTI 1.1 Basic Outcomes Service and the LTI 2.0 Result Service for courses that use the external user ID (i.e. they have the lti_consumer.enable_external_user_id_1p1_launches CourseWaffleFlag enabled). The Basic Outcomes Service and Result Service handle grade pass backs. Because we now have two ways to identify a user in LTI 1.1/2.0, we must update the Basic Outcomes Service and Result Service to support both. This commit fixes this bug.
@michaelroytman
michaelroytman force-pushed the mroytman/MST-1718-fix-basic-outcomes-result-service-user-id branch from 403b452 to 3bbbdd5 Compare November 29, 2022 21:31
@michaelroytman
michaelroytman merged commit 78cabcf into master Nov 29, 2022
@michaelroytman
michaelroytman deleted the mroytman/MST-1718-fix-basic-outcomes-result-service-user-id branch November 29, 2022 21:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants