diff --git a/src/lti/outcome_request.py b/src/lti/outcome_request.py index 124d755..33c8bad 100644 --- a/src/lti/outcome_request.py +++ b/src/lti/outcome_request.py @@ -4,6 +4,7 @@ import requests from requests_oauthlib import OAuth1 from requests_oauthlib.oauth1_auth import SIGNATURE_TYPE_AUTH_HEADER +from requests.structures import CaseInsensitiveDict from .outcome_response import OutcomeResponse from .utils import InvalidLTIConfigError @@ -22,7 +23,8 @@ 'lis_result_sourcedid', 'consumer_key', 'consumer_secret', - 'post_request' + 'post_request', + 'needs_additional_review' ] @@ -37,7 +39,7 @@ class OutcomeRequest(object): they each use it differently. The TP will use it to POST an OAuth-signed request to the TC. A TC will use it to parse such a request from a TP. ''' - def __init__(self, opts=defaultdict(lambda: None)): + def __init__(self, opts=defaultdict(lambda: None), headers=None, requests_session=None): # Initialize all our accessors to None for attr in VALID_ATTRIBUTES: setattr(self, attr, None) @@ -51,20 +53,25 @@ def __init__(self, opts=defaultdict(lambda: None)): "Invalid outcome request option: {}".format(key) ) + self.headers = CaseInsensitiveDict(headers or {}) + if "Content-Type" not in self.headers: + self.headers['Content-type'] = 'application/xml' + self.requests_session = requests_session or requests.Session() + @staticmethod - def from_post_request(post_request): + def from_post_request(post_request, headers=None): ''' Convenience method for creating a new OutcomeRequest from a request object. post_request is assumed to be a Django HttpRequest object ''' - request = OutcomeRequest() + request = OutcomeRequest(headers=headers) request.post_request = post_request request.process_xml(post_request.body) return request - def post_replace_result(self, score, result_data=None): + def post_replace_result(self, score, result_data=None, needs_additional_review=False): ''' POSTs the given score to the Tool Consumer with a replaceResult. @@ -75,18 +82,20 @@ def post_replace_result(self, score, result_data=None): 'text' : str text 'url' : str url + 'ltiLaunchUrl' : str url ''' self.operation = REPLACE_REQUEST self.score = score self.result_data = result_data + self.needs_additional_review = needs_additional_review if result_data is not None: if len(result_data) > 1: error_msg = ('Dictionary result_data can only have one entry. ' '{0} entries were found.'.format(len(result_data))) raise InvalidLTIConfigError(error_msg) - elif 'text' not in result_data and 'url' not in result_data: + elif 'text' not in result_data and 'url' not in result_data and 'ltiLaunchUrl' not in result_data: error_msg = ('Dictionary result_data can only have the key ' - '"text" or the key "url".') + '"text" or the key "url" or the key "ltiLaunchUrl".') raise InvalidLTIConfigError(error_msg) else: return self.post_outcome_request() @@ -140,10 +149,9 @@ def post_outcome_request(self, **kwargs): signature_type=SIGNATURE_TYPE_AUTH_HEADER, force_include_body=True, **kwargs) - headers = {'Content-type': 'application/xml'} - resp = requests.post(self.lis_outcome_service_url, auth=header_oauth, + resp = self.requests_session.post(self.lis_outcome_service_url, auth=header_oauth, data=self.generate_request_xml(), - headers=headers) + headers=self.headers) outcome_resp = OutcomeResponse.from_post_response(resp, resp.content) self.outcome_response = outcome_resp return self.outcome_response @@ -164,6 +172,16 @@ def process_xml(self, xml): sourcedGUID.sourcedId self.score = str(result.resultRecord.result. resultScore.textString) + + if len(resultData := result.find('resultRecord/result/resultData', root.nsmap)): + if r := resultData.find('text', root.nsmap): + self.result_data = {'text': result} + elif r := resultData.find('url', root.nsmap): + self.result_data = {'url': result} + elif r := resultData.find('ltiLaunchUrl', root.nsmap): + self.result_data = {'ltiLaunchUrl': r} + + self.needs_additional_review = result.find('submissionDetails/needsAdditionalReview', root.nsmap) is not None except: pass @@ -230,5 +248,14 @@ def generate_request_xml(self): elif 'url' in self.result_data: resultDataURL = etree.SubElement(resultData, 'url') resultDataURL.text = self.result_data['url'] + elif 'ltiLaunchUrl' in self.result_data: + resultDataLaunchURL = etree.SubElement(resultData, 'ltiLaunchUrl') + resultDataLaunchURL.text = self.result_data['ltiLaunchUrl'] + + # Canvas needsAdditionalReview extension: + # https://github.com/instructure/canvas-lms/blob/master/doc/api/assignment_tools.md#submission-needs-additional-review + if self.needs_additional_review: + submissionDetails = etree.SubElement(request, 'submissionDetails') + etree.SubElement(submissionDetails, 'needsAdditionalReview') return etree.tostring(root, xml_declaration=True, encoding='utf-8') diff --git a/tests/test_outcome_request.py b/tests/test_outcome_request.py index ca115b7..ef5cd11 100644 --- a/tests/test_outcome_request.py +++ b/tests/test_outcome_request.py @@ -111,7 +111,8 @@ def test_has_required_attributes(self): self.assertTrue(request.has_required_attributes()) def test_post_outcome_request(self): - request = OutcomeRequest() + request_headers = {"User-Agent": "unit-test"} + request = OutcomeRequest(headers=request_headers) self.assertRaises(InvalidLTIConfigError, request.post_outcome_request) request.consumer_key = 'consumer' request.consumer_secret = 'secret' @@ -126,6 +127,8 @@ def test_post_outcome_request(self): self.assertIsInstance(resp, OutcomeResponse) request = resp.post_response.request self.assertTrue('authorization' in request.headers) + self.assertEqual(request.headers.get('user-agent'), b"unit-test") + self.assertEqual(request.headers.get('content-type'), b"application/xml") auth_header = unquote(request.headers['authorization'].decode('utf-8')) correct = ('OAuth ' 'oauth_nonce="my_nonce", oauth_timestamp="1234567890", ' @@ -141,8 +144,11 @@ def test_from_post_request(self): data=REPLACE_RESULT_XML, content_type='application/xml' ) - request = OutcomeRequest.from_post_request(post_request) + request_headers = {"User-Agent": "post-request", "Content-Type": "text/xml"} + request = OutcomeRequest.from_post_request(post_request, request_headers) self.assertEqual(request.operation, 'replaceResult') self.assertEqual(request.lis_result_sourcedid, '261-154-728-17-784') self.assertEqual(request.message_identifier, '123456789') self.assertEqual(request.score, '5') + self.assertEqual(request.headers.get('User-Agent'), "post-request") + self.assertEqual(request.headers.get('Content-Type'), "text/xml")