Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 35 additions & 9 deletions src/lti/outcome_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,7 +23,8 @@
'lis_result_sourcedid',
'consumer_key',
'consumer_secret',
'post_request'
'post_request',
'needs_additional_review'
]


Expand All @@ -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):
# Initialize all our accessors to None
for attr in VALID_ATTRIBUTES:
setattr(self, attr, None)
Expand All @@ -51,20 +53,24 @@ 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'

@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.

Expand All @@ -75,18 +81,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()
Expand Down Expand Up @@ -140,10 +148,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,
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
Expand All @@ -164,6 +171,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

Expand Down Expand Up @@ -230,5 +247,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')
10 changes: 8 additions & 2 deletions tests/test_outcome_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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", '
Expand All @@ -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")