""" This module adds support for the LTI Outcomes Management Service. For more details see: https://www.imsglobal.org/specs/ltiomv1p0 """ from __future__ import absolute_import, unicode_literals import logging from xml.sax.saxutils import escape import six.moves.urllib.error import six.moves.urllib.parse from six import text_type from lxml import etree from xblockutils.resources import ResourceLoader from .exceptions import LtiError from .oauth import verify_oauth_body_signature log = logging.getLogger(__name__) def parse_grade_xml_body(body): """ Parses values from the Outcome Service XML. XML body should contain nsmap with namespace, that is specified in LTI specs. Arguments: body (str): XML Outcome Service request body Returns: tuple: imsx_messageIdentifier, sourcedId, score, action Raises: LtiError if submitted score is outside the permitted range if the XML is missing required entities if there was a problem parsing the XML body """ lti_spec_namespace = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0" namespaces = {'def': lti_spec_namespace} if isinstance(body, text_type): data = body.strip().encode('utf-8') try: parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') # pylint: disable=no-member root = etree.fromstring(data, parser=parser) # pylint: disable=no-member except etree.XMLSyntaxError as ex: raise LtiError(str(ex) or 'Body is not valid XML') try: imsx_message_identifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text or '' except IndexError: raise LtiError('Failed to parse imsx_messageIdentifier from XML request body') try: body = root.xpath("//def:imsx_POXBody", namespaces=namespaces)[0] except IndexError: raise LtiError('Failed to parse imsx_POXBody from XML request body') try: action = body.getchildren()[0].tag.replace('{' + lti_spec_namespace + '}', '') except IndexError: raise LtiError('Failed to parse action from XML request body') try: sourced_id = root.xpath("//def:sourcedId", namespaces=namespaces)[0].text except IndexError: raise LtiError('Failed to parse sourcedId from XML request body') try: score = root.xpath("//def:textString", namespaces=namespaces)[0].text except IndexError: raise LtiError('Failed to parse score textString from XML request body') # Raise exception if score is not float or not in range 0.0-1.0 regarding spec. score = float(score) if not 0.0 <= score <= 1.0: raise LtiError('score value outside the permitted range of 0.0-1.0') return imsx_message_identifier, sourced_id, score, action class OutcomeService(object): # pylint: disable=bad-option-value, useless-object-inheritance """ Service for handling LTI Outcome Management Service requests. For more details see: https://www.imsglobal.org/specs/ltiomv1p0 """ def __init__(self, xblock): self.xblock = xblock def handle_request(self, request): """ Handler for Outcome Service requests. Parses and validates XML request body. Currently, only the replaceResultRequest action is supported. Example of request body from LTI provider:: <?xml version = "1.0" encoding = "UTF-8"?> <imsx_POXEnvelopeRequest xmlns = "some_link (may be not required)"> <imsx_POXHeader> <imsx_POXRequestHeaderInfo> <imsx_version>V1.0</imsx_version> <imsx_messageIdentifier>528243ba5241b</imsx_messageIdentifier> </imsx_POXRequestHeaderInfo> </imsx_POXHeader> <imsx_POXBody> <replaceResultRequest> <resultRecord> <sourcedGUID> <sourcedId>feb-123-456-2929::28883</sourcedId> </sourcedGUID> <result> <resultScore> <language>en-us</language> <textString>0.4</textString> </resultScore> </result> </resultRecord> </replaceResultRequest> </imsx_POXBody> </imsx_POXEnvelopeRequest> See /templates/xml/outcome_service_response.xml for the response body format. Arguments: request (xblock.django.request.DjangoWebobRequest): Request object for current HTTP request Returns: str: Outcome Service XML response """ resource_loader = ResourceLoader(__name__) response_xml_template = resource_loader.load_unicode('/templates/xml/outcome_service_response.xml') # Returns when `action` is unsupported. # Supported actions: # - replaceResultRequest. unsupported_values = { 'imsx_codeMajor': 'unsupported', 'imsx_description': 'Target does not support the requested operation.', 'imsx_messageIdentifier': 'unknown', 'response': '' } # Returns if: # - past due grades are not accepted and grade is past due # - score is out of range # - can't parse response from TP; # - can't verify OAuth signing or OAuth signing is incorrect. failure_values = { 'imsx_codeMajor': 'failure', 'imsx_description': 'The request has failed.', 'imsx_messageIdentifier': 'unknown', 'response': '' } if not self.xblock.accept_grades_past_due and self.xblock.is_past_due: failure_values['imsx_description'] = "Grade is past due" return response_xml_template.format(**failure_values) try: imsx_message_identifier, sourced_id, score, action = parse_grade_xml_body(request.body) except LtiError as ex: # pylint: disable=no-member body = escape(request.body) if request.body else '' error_message = "Request body XML parsing error: {} {}".format(str(ex), body) log.debug("[LTI]: %s", error_message) failure_values['imsx_description'] = error_message return response_xml_template.format(**failure_values) # Verify OAuth signing. __, secret = self.xblock.lti_provider_key_secret try: verify_oauth_body_signature(request, secret, self.xblock.outcome_service_url) except (ValueError, LtiError) as ex: failure_values['imsx_messageIdentifier'] = escape(imsx_message_identifier) error_message = "OAuth verification error: " + escape(str(ex)) failure_values['imsx_description'] = error_message log.debug("[LTI]: %s", error_message) return response_xml_template.format(**failure_values) real_user = self.xblock.runtime.get_real_user(six.moves.urllib.parse.unquote(sourced_id.split(':')[-1])) 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." return response_xml_template.format(**failure_values) if action == 'replaceResultRequest': self.xblock.set_user_module_score(real_user, score, self.xblock.max_score()) values = { 'imsx_codeMajor': 'success', 'imsx_description': 'Score for {sourced_id} is now {score}'.format(sourced_id=sourced_id, score=score), 'imsx_messageIdentifier': escape(imsx_message_identifier), 'response': '<replaceResultResponse/>' } log.debug(u"[LTI]: Grade is saved.") return response_xml_template.format(**values) unsupported_values['imsx_messageIdentifier'] = escape(imsx_message_identifier) log.debug(u"[LTI]: Incorrect action.") return response_xml_template.format(**unsupported_values)