From 3aa8158e2f25af6324b049e7a795d306fe78ded6 Mon Sep 17 00:00:00 2001 From: Muhammad Tayayb Tahir Qureshi Date: Mon, 21 Oct 2024 16:49:23 +0500 Subject: [PATCH 01/34] feat: Extracting LTI xblock from edx-platform --- xblocks_contrib/lti/__init__.py | 2 +- xblocks_contrib/lti/lti.py | 970 +++++++++++++++++- xblocks_contrib/lti/lti_2_util.py | 370 +++++++ xblocks_contrib/lti/static/README.txt | 18 + xblocks_contrib/lti/static/css/lti.css | 56 +- xblocks_contrib/lti/static/js/src/lti.js | 64 +- xblocks_contrib/lti/templates/lti.html | 71 +- xblocks_contrib/lti/templates/lti_form.html | 38 + xblocks_contrib/lti/tests/test_lti20_unit.py | 387 +++++++ .../tests/{test_lti.py => test_lti_unit.py} | 0 10 files changed, 1888 insertions(+), 88 deletions(-) create mode 100644 xblocks_contrib/lti/lti_2_util.py create mode 100644 xblocks_contrib/lti/static/README.txt create mode 100644 xblocks_contrib/lti/templates/lti_form.html create mode 100644 xblocks_contrib/lti/tests/test_lti20_unit.py rename xblocks_contrib/lti/tests/{test_lti.py => test_lti_unit.py} (100%) diff --git a/xblocks_contrib/lti/__init__.py b/xblocks_contrib/lti/__init__.py index 094c184..030a9bb 100644 --- a/xblocks_contrib/lti/__init__.py +++ b/xblocks_contrib/lti/__init__.py @@ -1,5 +1,5 @@ """ -Init for the LTIBlock. +Learning Tools Interoperability (LTI) module. """ from .lti import LTIBlock diff --git a/xblocks_contrib/lti/lti.py b/xblocks_contrib/lti/lti.py index 5dbaf15..5c06f6d 100644 --- a/xblocks_contrib/lti/lti.py +++ b/xblocks_contrib/lti/lti.py @@ -1,81 +1,637 @@ -"""TO-DO: Write a description of what this XBlock is.""" +""" +THIS MODULE IS DEPRECATED IN FAVOR OF https://github.com/openedx/xblock-lti-consumer + +Learning Tools Interoperability (LTI) module. + + +Resources +--------- + +Theoretical background and detailed specifications of LTI can be found on: + + http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html + +This module is based on the version 1.1.1 of the LTI specifications by the +IMS Global authority. For authentication, it uses OAuth1. + +When responding back to the LTI tool provider, we must issue a correct +response. Types of responses and their message payload is available at: + + Table A1.2 Interpretation of the 'CodeMajor/severity' matrix. + http://www.imsglobal.org/gws/gwsv1p0/imsgws_wsdlBindv1p0.html + +A resource to test the LTI protocol (PHP realization): + + http://www.imsglobal.org/developers/LTI/test/v1p1/lms.php + +We have also begun to add support for LTI 1.2/2.0. We will keep this +docstring in synch with what support is available. The first LTI 2.0 +feature to be supported is the REST API results service, see specification +at +http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html + +What is supported: +------------------ + +1.) Display of simple LTI in iframe or a new window. +2.) Multiple LTI components on a single page. +3.) The use of multiple LTI providers per course. +4.) Use of advanced LTI component that provides back a grade. + A) LTI 1.1.1 XML endpoint + a.) The LTI provider sends back a grade to a specified URL. + b.) Currently only action "update" is supported. "Read", and "delete" + actions initially weren't required. + B) LTI 2.0 Result Service JSON REST endpoint + (http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html) + a.) Discovery of all such LTI http endpoints for a course. External tools GET from this discovery + endpoint and receive URLs for interacting with individual grading units. + (see lms/djangoapps/courseware/views/views.py:get_course_lti_endpoints) + b.) GET, PUT and DELETE in LTI Result JSON binding + (http://www.imsglobal.org/lti/ltiv2p0/mediatype/application/vnd/ims/lis/v2/result+json/index.html) + for a provider to synchronize grades into edx-platform. Reading, Setting, and Deleteing + Numeric grades between 0 and 1 and text + basic HTML feedback comments are supported, via + GET / PUT / DELETE HTTP methods respectively +""" from importlib.resources import files -from django.utils import translation +import base64 +import datetime +import hashlib +import logging +import textwrap +from xml.sax.saxutils import escape +from unittest import mock +from urllib import parse + +import nh3 +import oauthlib.oauth1 +from django.conf import settings +from lxml import etree +from oauthlib.oauth1.rfc5849 import signature +from pytz import UTC +from webob import Response from web_fragments.fragment import Fragment -from xblock.core import XBlock -from xblock.fields import Integer, Scope +from xblock.core import List, Scope, String, XBlock +from xblock.fields import Boolean, Float from xblock.utils.resources import ResourceLoader +from openedx.core.djangolib.markup import HTML, Text +from .lti_2_util import LTI20BlockMixin, LTIError + +from common.djangoapps.xblock_django.constants import ( + ATTR_KEY_ANONYMOUS_USER_ID, + ATTR_KEY_USER_ROLE, +) + resource_loader = ResourceLoader(__name__) +log = logging.getLogger(__name__) -# This Xblock is just to test the strucutre of xblocks-contrib -@XBlock.needs("i18n") -class LTIBlock(XBlock): +DOCS_ANCHOR_TAG_OPEN = ( + "" +) +BREAK_TAG = '
' + +# Make '_' a no-op so we can scrape strings. Using lambda instead of +# `django.utils.translation.ugettext_noop` because Django cannot be imported in this file +def noop(text): + return text + +_ = noop + +class LTIFields: """ - TO-DO: document what your XBlock does. + Fields to define and obtain LTI tool from provider are set here, + except credentials, which should be set in course settings:: + + `lti_id` is id to connect tool with credentials in course settings. It should not contain :: (double semicolon) + `launch_url` is launch URL of tool. + `custom_parameters` are additional parameters to navigate to proper book and book page. + + For example, for Vitalsource provider, `launch_url` should be + *https://bc-staging.vitalsource.com/books/book*, + and to get to proper book and book page, you should set custom parameters as:: + + vbid=put_book_id_here + book_location=page/put_page_number_here + + Default non-empty URL for `launch_url` is needed due to oauthlib demand (URL scheme should be presented):: + + https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136 """ + display_name = String( + display_name=_("Display Name"), + help=_( + "The display name for this component. " + "Analytics reports may also use the display name to identify this component." + ), + scope=Scope.settings, + default="LTI", + ) + lti_id = String( + display_name=_("LTI ID"), + help=Text(_( + "Enter the LTI ID for the external LTI provider. " + "This value must be the same LTI ID that you entered in the " + "LTI Passports setting on the Advanced Settings page." + "{break_tag}See {docs_anchor_open}the edX LTI documentation{anchor_close} for more details on this setting." + )).format( + break_tag=HTML(BREAK_TAG), + docs_anchor_open=HTML(DOCS_ANCHOR_TAG_OPEN), + anchor_close=HTML("
") + ), + default='', + scope=Scope.settings + ) + launch_url = String( + display_name=_("LTI URL"), + help=Text(_( + "Enter the URL of the external tool that this component launches. " + "This setting is only used when Hide External Tool is set to False." + "{break_tag}See {docs_anchor_open}the edX LTI documentation{anchor_close} for more details on this setting." + )).format( + break_tag=HTML(BREAK_TAG), + docs_anchor_open=HTML(DOCS_ANCHOR_TAG_OPEN), + anchor_close=HTML("") + ), + default='http://www.example.com', + scope=Scope.settings) + custom_parameters = List( + display_name=_("Custom Parameters"), + help=Text(_( + "Add the key/value pair for any custom parameters, such as the page your e-book should open to or " + "the background color for this component." + "{break_tag}See {docs_anchor_open}the edX LTI documentation{anchor_close} for more details on this setting." + )).format( + break_tag=HTML(BREAK_TAG), + docs_anchor_open=HTML(DOCS_ANCHOR_TAG_OPEN), + anchor_close=HTML("") + ), + scope=Scope.settings) + open_in_a_new_page = Boolean( + display_name=_("Open in New Page"), + help=_( + "Select True if you want students to click a link that opens the LTI tool in a new window. " + "Select False if you want the LTI content to open in an IFrame in the current page. " + "This setting is only used when Hide External Tool is set to False. " + ), + default=True, + scope=Scope.settings + ) + has_score = Boolean( + display_name=_("Scored"), + help=_( + "Select True if this component will receive a numerical score from the external LTI system." + ), + default=False, + scope=Scope.settings + ) + weight = Float( + display_name=_("Weight"), + help=_( + "Enter the number of points possible for this component. " + "The default value is 1.0. " + "This setting is only used when Scored is set to True." + ), + default=1.0, + scope=Scope.settings, + values={"min": 0}, + ) + module_score = Float( + help=_("The score kept in the xblock KVS -- duplicate of the published score in django DB"), + default=None, + scope=Scope.user_state + ) + score_comment = String( + help=_("Comment as returned from grader, LTI2.0 spec"), + default="", + scope=Scope.user_state + ) + hide_launch = Boolean( + display_name=_("Hide External Tool"), + help=_( + "Select True if you want to use this component as a placeholder for syncing with an external grading " + "system rather than launch an external tool. " + "This setting hides the Launch button and any IFrames for this component." + ), + default=False, + scope=Scope.settings + ) + + # Users will be presented with a message indicating that their e-mail/username would be sent to a third + # party application. When "Open in New Page" is not selected, the tool automatically appears without any user action. # lint-amnesty, pylint: disable=line-too-long + ask_to_send_username = Boolean( + display_name=_("Request user's username"), + # Translators: This is used to request the user's username for a third party service. + help=_("Select True to request the user's username."), + default=False, + scope=Scope.settings + ) + ask_to_send_email = Boolean( + display_name=_("Request user's email"), + # Translators: This is used to request the user's email for a third party service. + help=_("Select True to request the user's email address."), + default=False, + scope=Scope.settings + ) + + description = String( + display_name=_("LTI Application Information"), + help=_( + "Enter a description of the third party application. If requesting username and/or email, use this text box to inform users " # lint-amnesty, pylint: disable=line-too-long + "why their username and/or email will be forwarded to a third party application." + ), + default="", + scope=Scope.settings + ) - # Fields are defined on the class. You can access them in your code as - # self.. + button_text = String( + display_name=_("Button Text"), + help=_( + "Enter the text on the button used to launch the third party application." + ), + default="", + scope=Scope.settings + ) - # TO-DO: delete count, and define your own fields. - count = Integer( - default=0, - scope=Scope.user_state, - help="A simple counter, to show something happening", + accept_grades_past_due = Boolean( + display_name=_("Accept grades past deadline"), + help=_("Select True to allow third party systems to post grades past the deadline."), + default=True, + scope=Scope.settings ) + +@XBlock.needs("i18n") +@XBlock.needs("user") +@XBlock.needs("rebind_user") +class LTIBlock( + XBlock, + LTIFields, + LTI20BlockMixin, +): # pylint: disable=abstract-method + """ + THIS MODULE IS DEPRECATED IN FAVOR OF https://github.com/openedx/xblock-lti-consumer + + Module provides LTI integration to course. + + Except usual Xmodule structure it proceeds with OAuth signing. + How it works:: + + 1. Get credentials from course settings. + + 2. There is minimal set of parameters need to be signed (presented for Vitalsource):: + + user_id + oauth_callback + lis_outcome_service_url + lis_result_sourcedid + launch_presentation_return_url + lti_message_type + lti_version + roles + *+ all custom parameters* + + These parameters should be encoded and signed by *OAuth1* together with + `launch_url` and *POST* request type. + + 3. Signing proceeds with client key/secret pair obtained from course settings. + That pair should be obtained from LTI provider and set into course settings by course author. + After that signature and other OAuth data are generated. + + OAuth data which is generated after signing is usual:: + + oauth_callback + oauth_nonce + oauth_consumer_key + oauth_signature_method + oauth_timestamp + oauth_version + + + 4. All that data is passed to form and sent to LTI provider server by browser via + autosubmit via JavaScript. + + Form example:: + +
+ + + + + + + + + + + + + + + + + + + + +
+ + 5. LTI provider has same secret key and it signs data string via *OAuth1* and compares signatures. + + If signatures are correct, LTI provider redirects iframe source to LTI tool web page, + and LTI tool is rendered to iframe inside course. + + Otherwise error message from LTI provider is generated. + """ + # Indicates that this XBlock has been extracted from edx-platform. is_extracted = True - def resource_string(self, path): - """Handy helper for getting resources from our kit.""" - return files(__package__).joinpath(path).read_text(encoding="utf-8") + def max_score(self): + return self.weight if self.has_score else None + + def get_input_fields(self): # lint-amnesty, pylint: disable=missing-function-docstring + # LTI provides a list of default parameters that might be passed as + # part of the POST data. These parameters should not be prefixed. + # Likewise, The creator of an LTI link can add custom key/value parameters + # to a launch which are to be included with the launch of the LTI link. + # In this case, we will automatically add `custom_` prefix before this parameters. + # See http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html#_Toc316828520 + PARAMETERS = [ + "lti_message_type", + "lti_version", + "resource_link_title", + "resource_link_description", + "user_image", + "lis_person_name_given", + "lis_person_name_family", + "lis_person_name_full", + "lis_person_contact_email_primary", + "lis_person_sourcedid", + "role_scope_mentor", + "context_type", + "context_title", + "context_label", + "launch_presentation_locale", + "launch_presentation_document_target", + "launch_presentation_css_url", + "launch_presentation_width", + "launch_presentation_height", + "launch_presentation_return_url", + "tool_consumer_info_product_family_code", + "tool_consumer_info_version", + "tool_consumer_instance_guid", + "tool_consumer_instance_name", + "tool_consumer_instance_description", + "tool_consumer_instance_url", + "tool_consumer_instance_contact_email", + ] + + client_key, client_secret = self.get_client_key_secret() + + # parsing custom parameters to dict + custom_parameters = {} + + for custom_parameter in self.custom_parameters: + try: + param_name, param_value = [p.strip() for p in custom_parameter.split('=', 1)] + except ValueError: + _ = self.runtime.service(self, "i18n").ugettext + msg = _('Could not parse custom parameter: {custom_parameter}. Should be "x=y" string.').format( + custom_parameter=f"{custom_parameter!r}" + ) + raise LTIError(msg) # lint-amnesty, pylint: disable=raise-missing-from + + # LTI specs: 'custom_' should be prepended before each custom parameter, as pointed in link above. + if param_name not in PARAMETERS: + param_name = 'custom_' + param_name + + custom_parameters[str(param_name)] = str(param_value) + + return self.oauth_params( + custom_parameters, + client_key, + client_secret, + ) + + def get_context(self): + """ + Returns a context. + """ + # nh3 defaults for + # ALLOWED_TAGS are + # { + # 'a', 'abbr', 'acronym', 'area', 'article', 'aside', 'b', 'bdi', 'bdo', + # 'blockquote', 'br', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', + # 'data', 'dd', 'del', 'details', 'dfn', 'div', 'dl', 'dt', 'em', 'figcaption', + # 'figure', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', + # 'hr', 'i', 'img', 'ins', 'kbd', 'li', 'map', 'mark', 'nav', 'ol', 'p', 'pre', + # 'q', 'rp', 'rt', 'rtc', 'ruby', 's', 'samp', 'small', 'span', 'strike', + # 'strong', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'th', 'thead', + # 'time', 'tr', 'tt', 'u', 'ul', 'var', 'wbr' + # } + # + # ALLOWED_ATTRIBUTES are + # { + # 'a': {'href', 'hreflang'}, + # 'bdo': {'dir'}, + # 'blockquote': {'cite'}, + # 'col': {'charoff', 'char', 'align', 'span'}, + # 'colgroup': {'align', 'char', 'charoff', 'span'}, + # 'del': {'datetime', 'cite'}, + # 'hr': {'width', 'align', 'size'}, + # 'img': {'height', 'src', 'width', 'alt', 'align'}, + # 'ins': {'datetime', 'cite'}, + # 'ol': {'start'}, + # 'q': {'cite'}, + # 'table': {'align', 'char', 'charoff', 'summary'}, + # 'tbody': {'align', 'char', 'charoff'}, + # 'td': {'rowspan', 'headers', 'charoff', 'colspan', 'char', 'align'}, + # 'tfoot': {'align', 'char', 'charoff'}, + # 'th': {'rowspan', 'headers', 'charoff', 'colspan', 'scope', 'char', 'align'}, + # 'thead': {'charoff', 'char', 'align'}, + # 'tr': {'align', 'char', 'charoff'} + # } + # + # This lets all plaintext through. + sanitized_comment = nh3.clean(self.score_comment) + + return { + 'input_fields': self.get_input_fields(), + + # These parameters do not participate in OAuth signing. + 'launch_url': self.launch_url.strip(), + 'element_id': self.location.html_id(), + 'element_class': self.category, + 'open_in_a_new_page': self.open_in_a_new_page, + 'display_name': self.display_name, + 'form_url': self.runtime.handler_url(self, 'preview_handler').rstrip('/?'), + 'hide_launch': self.hide_launch, + 'has_score': self.has_score, + 'weight': self.weight, + 'module_score': self.module_score, + 'comment': sanitized_comment, + 'description': self.description, + 'ask_to_send_username': self.ask_to_send_username, + 'ask_to_send_email': self.ask_to_send_email, + 'button_text': self.button_text, + 'accept_grades_past_due': self.accept_grades_past_due, + } - # TO-DO: change this view to display your data your own way. def student_view(self, context=None): """ Create primary view of the LTIBlock, shown to students when viewing courses. """ - if context: - pass # TO-DO: do something based on the context. - frag = Fragment() frag.add_content( resource_loader.render_django_template( - "templates/lti.html", - { - "count": self.count, - }, - i18n_service=self.runtime.service(self, "i18n"), + "templates/lti.html", self.get_context() ) ) - frag.add_css(self.resource_string("static/css/lti.css")) - frag.add_javascript(self.resource_string("static/js/src/lti.js")) + frag.add_css(resource_loader.load_unicode("static/css/lti.css")) + frag.add_javascript(resource_loader.load_unicode("static/js/src/lti.js")) frag.initialize_js("LTIBlock") return frag - # TO-DO: change this handler to perform your own actions. You may need more - # than one handler, or you may not need any handlers at all. - @XBlock.json_handler - def increment_count(self, data, suffix=""): + @XBlock.handler + def preview_handler(self, _, __): + """ + This is called to get context with new oauth params to iframe. + """ + template = resource_loader.load_unicode("templates/lti_form.html").format(**self.get_context()) + return Response(template, content_type='text/html') + + @XBlock.handler + def grade_handler(self, request, suffix): # lint-amnesty, pylint: disable=unused-argument """ - Increments data. An example handler. + This is called by courseware.block_render, to handle an AJAX call. + + Used only for grading. Returns XML response. + + Example of request body from LTI provider:: + + + + + + V1.0 + 528243ba5241b + + + + + + + feb-123-456-2929::28883 + + + + en-us + 0.4 + + + + + + + + Example of correct/incorrect answer XML body:: see response_xml_template. """ - if suffix: - pass # TO-DO: Use the suffix when storing data. - # Just to show data coming in... - assert data["hello"] == "world" + response_xml_template = textwrap.dedent("""\ + + + + + V1.0 + {imsx_messageIdentifier} + + {imsx_codeMajor} + status + {imsx_description} + + + + + + {response} + + """) + # 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.accept_grades_past_due and self.is_past_due(): + failure_values['imsx_description'] = "Grade is past due" + return Response(response_xml_template.format(**failure_values), content_type="application/xml") + + try: + imsx_messageIdentifier, sourcedId, score, action = self.parse_grade_xml_body(request.body) + except Exception as e: # lint-amnesty, pylint: disable=broad-except + error_message = "Request body XML parsing error: " + escape(str(e)) + log.debug("[LTI]: " + error_message) # lint-amnesty, pylint: disable=logging-not-lazy + failure_values['imsx_description'] = error_message + return Response(response_xml_template.format(**failure_values), content_type="application/xml") + + # Verify OAuth signing. + try: + self.verify_oauth_body_sign(request) + except (ValueError, LTIError) as e: + failure_values['imsx_messageIdentifier'] = escape(imsx_messageIdentifier) + error_message = "OAuth verification error: " + escape(str(e)) + failure_values['imsx_description'] = error_message + log.debug("[LTI]: " + error_message) # lint-amnesty, pylint: disable=logging-not-lazy + return Response(response_xml_template.format(**failure_values), content_type="application/xml") + + real_user = self.runtime.service(self, 'user').get_user_by_anonymous_id(parse.unquote(sourcedId.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_messageIdentifier) + failure_values['imsx_description'] = "User not found." + return Response(response_xml_template.format(**failure_values), content_type="application/xml") + + if action == 'replaceResultRequest': + self.set_user_module_score(real_user, score, self.max_score()) - self.count += 1 - return {"count": self.count} + values = { + 'imsx_codeMajor': 'success', + 'imsx_description': f'Score for {sourcedId} is now {score}', + 'imsx_messageIdentifier': escape(imsx_messageIdentifier), + 'response': '' + } + log.debug("[LTI]: Grade is saved.") + return Response(response_xml_template.format(**values), content_type="application/xml") + + unsupported_values['imsx_messageIdentifier'] = escape(imsx_messageIdentifier) + log.debug("[LTI]: Incorrect action.") + return Response(response_xml_template.format(**unsupported_values), content_type='application/xml') - # TO-DO: change this to create the scenarios you'd like to see in the - # workbench while developing your XBlock. @staticmethod def workbench_scenarios(): """Create canned scenario for display in the workbench.""" @@ -96,9 +652,331 @@ def workbench_scenarios(): ), ] - @staticmethod - def get_dummy(): + def get_user_id(self): + """ + Returns the current user ID, URL-escaped so it is safe to use as a URL component. + """ + user_id = self.runtime.service(self, 'user').get_current_user().opt_attrs.get(ATTR_KEY_ANONYMOUS_USER_ID) + assert user_id is not None + return str(parse.quote(user_id)) + + def get_outcome_service_url(self, service_name="grade_handler"): + """ + Return URL for storing grades. + + To test LTI on sandbox we must use http scheme. + + While testing locally and on Jenkins, mock_lti_server use http.referer + to obtain scheme, so it is ok to have http(s) anyway. + + The scheme logic is handled in lms/lib/xblock/runtime.py + """ + return self.runtime.handler_url(self, service_name, thirdparty=True).rstrip('/?') + + def get_resource_link_id(self): + """ + This is an opaque unique identifier that the TC guarantees will be unique + within the TC for every placement of the link. + + If the tool / activity is placed multiple times in the same context, + each of those placements will be distinct. + + This value will also change if the item is exported from one system or + context and imported into another system or context. + + This parameter is required. + + Example: u'edx.org-i4x-2-3-lti-31de800015cf4afb973356dbe81496df' + + Hostname, edx.org, + makes resource_link_id change on import to another system. + + Last part of location, location.name - 31de800015cf4afb973356dbe81496df, + is random hash, updated by course_id, + this makes resource_link_id unique inside single course. + + First part of location is tag-org-course-category, i4x-2-3-lti. + + Location.name itself does not change on import to another course, + but org and course_id change. + + So together with org and course_id in a form of + i4x-2-3-lti-31de800015cf4afb973356dbe81496df this part of resource_link_id: + makes resource_link_id to be unique among courses inside same system. + """ + return str(parse.quote(f"{settings.LMS_BASE}-{self.location.html_id()}")) + + def get_lis_result_sourcedid(self): + """ + This field contains an identifier that indicates the LIS Result Identifier (if any) + associated with this launch. This field identifies a unique row and column within the + TC gradebook. This field is unique for every combination of context_id / resource_link_id / user_id. + This value may change for a particular resource_link_id / user_id from one launch to the next. + The TP should only retain the most recent value for this field for a particular resource_link_id / user_id. + This field is generally optional, but is required for grading. + """ + return "{context}:{resource_link}:{user_id}".format( + context=parse.quote(self.context_id), + resource_link=self.get_resource_link_id(), + user_id=self.get_user_id() + ) + + def get_course(self): + """ + Return course by course id. + """ + return self.runtime.modulestore.get_course(self.course_id) + + @property + def context_id(self): + """ + Return context_id. + + context_id is an opaque identifier that uniquely identifies the context (e.g., a course) + that contains the link being launched. + """ + return str(self.course_id) + + @property + def role(self): + """ + Get system user role and convert it to LTI role. + """ + roles = { + 'student': 'Student', + 'staff': 'Administrator', + 'instructor': 'Instructor', + } + user_role = self.runtime.service(self, 'user').get_current_user().opt_attrs.get(ATTR_KEY_USER_ROLE) + return roles.get(user_role, 'Student') + + def get_icon_class(self): + """ Returns the icon class """ + if self.graded and self.has_score: # pylint: disable=no-member + return 'problem' + return 'other' + + def oauth_params(self, custom_parameters, client_key, client_secret): + """ + Signs request and returns signature and OAuth parameters. + + `custom_paramters` is dict of parsed `custom_parameter` field + `client_key` and `client_secret` are LTI tool credentials. + + Also *anonymous student id* is passed to template and therefore to LTI provider. + """ + + client = oauthlib.oauth1.Client( + client_key=str(client_key), + client_secret=str(client_secret) + ) + + # Must have parameters for correct signing from LTI: + body = { + 'user_id': self.get_user_id(), + 'oauth_callback': 'about:blank', + 'launch_presentation_return_url': '', + 'lti_message_type': 'basic-lti-launch-request', + 'lti_version': 'LTI-1p0', + 'roles': self.role, + + # Parameters required for grading: + 'resource_link_id': self.get_resource_link_id(), + 'lis_result_sourcedid': self.get_lis_result_sourcedid(), + + 'context_id': self.context_id, + } + + if self.has_score: + body.update({ + 'lis_outcome_service_url': self.get_outcome_service_url() + }) + + self.user_email = "" # lint-amnesty, pylint: disable=attribute-defined-outside-init + self.user_username = "" # lint-amnesty, pylint: disable=attribute-defined-outside-init + + # Username and email can't be sent in studio mode, because the user object is not defined. + # To test functionality test in LMS + + real_user_object = self.runtime.service(self, 'user').get_user_by_anonymous_id() + try: + self.user_email = real_user_object.email # lint-amnesty, pylint: disable=attribute-defined-outside-init + except AttributeError: + self.user_email = "" # lint-amnesty, pylint: disable=attribute-defined-outside-init + try: + self.user_username = real_user_object.username # lint-amnesty, pylint: disable=attribute-defined-outside-init + except AttributeError: + self.user_username = "" # lint-amnesty, pylint: disable=attribute-defined-outside-init + + if self.ask_to_send_username and self.user_username: + body["lis_person_sourcedid"] = self.user_username + if self.ask_to_send_email and self.user_email: + body["lis_person_contact_email_primary"] = self.user_email + + # Appending custom parameter for signing. + body.update(custom_parameters) + + headers = { + # This is needed for body encoding: + 'Content-Type': 'application/x-www-form-urlencoded', + } + + try: + __, headers, __ = client.sign( + str(self.launch_url.strip()), + http_method='POST', + body=body, + headers=headers) + except ValueError: # Scheme not in url. + # https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136 + # Stubbing headers for now: + log.info( + "LTI block %s in course %s does not have oauth parameters correctly configured.", + self.location, + self.location.course_key, + ) + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': 'OAuth oauth_nonce="80966668944732164491378916897", \ +oauth_timestamp="1378916897", oauth_version="1.0", oauth_signature_method="HMAC-SHA1", \ +oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'} + + params = headers['Authorization'] + # Parse headers to pass to template as part of context: + params = dict([param.strip().replace('"', '').split('=') for param in params.split(',')]) + + params['oauth_nonce'] = params['OAuth oauth_nonce'] + del params['OAuth oauth_nonce'] + + # oauthlib encodes signature with + # 'Content-Type': 'application/x-www-form-urlencoded' + # so '='' becomes '%3D'. + # We send form via browser, so browser will encode it again, + # So we need to decode signature back: + params['oauth_signature'] = parse.unquote(params['oauth_signature']).encode('utf-8').decode('utf8') # lint-amnesty, pylint: disable=line-too-long + + # Add LTI parameters to OAuth parameters for sending in form. + params.update(body) + return params + + @classmethod + def parse_grade_xml_body(cls, body): + """ + Parses XML from request.body and returns parsed data + + XML body should contain nsmap with namespace, that is specified in LTI specs. + + Returns tuple: imsx_messageIdentifier, sourcedId, score, action + + Raises Exception if can't parse. + """ + lti_spec_namespace = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0" + namespaces = {'def': lti_spec_namespace} + + data = body.strip() + parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') + root = etree.fromstring(data, parser=parser) + + imsx_messageIdentifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text or '' + sourcedId = root.xpath("//def:sourcedId", namespaces=namespaces)[0].text + score = root.xpath("//def:textString", namespaces=namespaces)[0].text + action = root.xpath("//def:imsx_POXBody", namespaces=namespaces)[0].getchildren()[0].tag.replace('{' + lti_spec_namespace + '}', '') # lint-amnesty, pylint: disable=line-too-long + # Raise exception if score is not float or not in range 0.0-1.0 regarding spec. + score = float(score) + if not 0 <= score <= 1: + raise LTIError('score value outside the permitted range of 0-1.') + + return imsx_messageIdentifier, sourcedId, score, action + + def verify_oauth_body_sign(self, request, content_type='application/x-www-form-urlencoded'): + """ + Verify grade request from LTI provider using OAuth body signing. + + Uses http://oauth.googlecode.com/svn/spec/ext/body_hash/1.0/oauth-bodyhash.html:: + + This specification extends the OAuth signature to include integrity checks on HTTP request bodies + with content types other than application/x-www-form-urlencoded. + + Arguments: + request: DjangoWebobRequest. + + Raises: + LTIError if request is incorrect. + """ + + client_key, client_secret = self.get_client_key_secret() # lint-amnesty, pylint: disable=unused-variable + headers = { + 'Authorization': str(request.headers.get('Authorization')), + 'Content-Type': content_type, + } + + sha1 = hashlib.sha1() + sha1.update(request.body) + oauth_body_hash = base64.b64encode(sha1.digest()).decode('utf-8') + oauth_params = signature.collect_parameters(headers=headers, exclude_oauth_signature=False) + oauth_headers = dict(oauth_params) + oauth_signature = oauth_headers.pop('oauth_signature') + mock_request_lti_1 = mock.Mock( + uri=str(parse.unquote(self.get_outcome_service_url())), + http_method=str(request.method), + params=list(oauth_headers.items()), + signature=oauth_signature + ) + mock_request_lti_2 = mock.Mock( + uri=str(parse.unquote(request.url)), + http_method=str(request.method), + params=list(oauth_headers.items()), + signature=oauth_signature + ) + if oauth_body_hash != oauth_headers.get('oauth_body_hash'): + log.error( + "OAuth body hash verification failed, provided: {}, " + "calculated: {}, for url: {}, body is: {}".format( + oauth_headers.get('oauth_body_hash'), + oauth_body_hash, + self.get_outcome_service_url(), + request.body + ) + ) + raise LTIError("OAuth body hash verification is failed.") + + if (not signature.verify_hmac_sha1(mock_request_lti_1, client_secret) and not + signature.verify_hmac_sha1(mock_request_lti_2, client_secret)): + log.error("OAuth signature verification failed, for " + "headers:{} url:{} method:{}".format( + oauth_headers, + self.get_outcome_service_url(), + str(request.method) + )) + raise LTIError("OAuth signature verification has failed.") + + def get_client_key_secret(self): """ - Generate initial i18n with dummy method. + Obtains client_key and client_secret credentials from current course. """ - return translation.gettext_noop("Dummy") + course = self.get_course() + for lti_passport in course.lti_passports: + try: + lti_id, key, secret = [i.strip() for i in lti_passport.split(':')] + except ValueError: + _ = self.runtime.service(self, "i18n").ugettext + msg = _('Could not parse LTI passport: {lti_passport}. Should be "id:key:secret" string.').format( + lti_passport=f'{lti_passport!r}' + ) + raise LTIError(msg) # lint-amnesty, pylint: disable=raise-missing-from + + if lti_id == self.lti_id.strip(): + return key, secret + return '', '' + + def is_past_due(self): + """ + Is it now past this problem's due date, including grace period? + """ + due_date = self.due # pylint: disable=no-member + if self.graceperiod is not None and due_date: # pylint: disable=no-member + close_date = due_date + self.graceperiod # pylint: disable=no-member + else: + close_date = due_date + return close_date is not None and datetime.datetime.now(UTC) > close_date + diff --git a/xblocks_contrib/lti/lti_2_util.py b/xblocks_contrib/lti/lti_2_util.py new file mode 100644 index 0000000..11eba5e --- /dev/null +++ b/xblocks_contrib/lti/lti_2_util.py @@ -0,0 +1,370 @@ +""" +A mixin class for LTI 2.0 functionality. This is really just done to refactor the code to +keep the LTIBlock class from getting too big +""" + + +import base64 +import hashlib +import json +import logging +import re +from unittest import mock +from urllib import parse + +from django.conf import settings +from oauthlib.oauth1 import Client +from webob import Response +from xblock.core import XBlock + +from openedx.core.lib.grade_utils import round_away_from_zero + +log = logging.getLogger(__name__) + +LTI_2_0_REST_SUFFIX_PARSER = re.compile(r"^user/(?P\w+)", re.UNICODE) +LTI_2_0_JSON_CONTENT_TYPE = 'application/vnd.ims.lis.v2.result+json' + + +class LTIError(Exception): + """Error class for LTIBlock and LTI20BlockMixin""" + + +class LTI20BlockMixin: + """ + This class MUST be mixed into LTIBlock. It does not do anything on its own. It's just factored + out for modularity. + """ + + # LTI 2.0 Result Service Support + @XBlock.handler + def lti_2_0_result_rest_handler(self, request, suffix): + """ + Handler function for LTI 2.0 JSON/REST result service. + + See http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html + An example JSON object: + { + "@context" : "http://purl.imsglobal.org/ctx/lis/v2/Result", + "@type" : "Result", + "resultScore" : 0.83, + "comment" : "This is exceptional work." + } + For PUTs, the content type must be "application/vnd.ims.lis.v2.result+json". + We use the "suffix" parameter to parse out the user from the end of the URL. An example endpoint url is + http://localhost:8000/courses/org/num/run/xblock/i4x:;_;_org;_num;_lti;_GUID/handler_noauth/lti_2_0_result_rest_handler/user/ + so suffix is of the form "user/" + Failures result in 401, 404, or 500s without any body. Successes result in 200. Again see + http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html + (Note: this prevents good debug messages for the client, so we might want to change this, or the spec) + + Arguments: + request (xblock.django.request.DjangoWebobRequest): Request object for current HTTP request + suffix (unicode): request path after "lti_2_0_result_rest_handler/". expected to be "user/" + + Returns: + webob.response: response to this request. See above for details. + """ + if settings.DEBUG: + self._log_correct_authorization_header(request) + + if not self.accept_grades_past_due and self.is_past_due(): + return Response(status=404) # have to do 404 due to spec, but 400 is better, with error msg in body + + try: + anon_id = self.parse_lti_2_0_handler_suffix(suffix) + except LTIError: + return Response(status=404) # 404 because a part of the URL (denoting the anon user id) is invalid + try: + self.verify_lti_2_0_result_rest_headers(request, verify_content_type=True) + except LTIError: + return Response(status=401) # Unauthorized in this case. 401 is right + + real_user = self.runtime.service(self, 'user').get_user_by_anonymous_id(anon_id) + if not real_user: # that means we can't save to database, as we do not have real user id. + msg = f"[LTI]: Real user not found against anon_id: {anon_id}" + log.info(msg) + return Response(status=404) # have to do 404 due to spec, but 400 is better, with error msg in body + if request.method == "PUT": + return self._lti_2_0_result_put_handler(request, real_user) + elif request.method == "GET": + return self._lti_2_0_result_get_handler(request, real_user) + elif request.method == "DELETE": + return self._lti_2_0_result_del_handler(request, real_user) + else: + return Response(status=404) # have to do 404 due to spec, but 405 is better, with error msg in body + + def _log_correct_authorization_header(self, request): + """ + Helper function that logs proper HTTP Authorization header for a given request + + Used only in debug situations, this logs the correct Authorization header based on + the request header and body according to OAuth 1 Body signing + + Arguments: + request (xblock.django.request.DjangoWebobRequest): Request object to log Authorization header for + + Returns: + nothing + """ + sha1 = hashlib.sha1() + sha1.update(request.body) + oauth_body_hash = str(base64.b64encode(sha1.digest())) + log.debug(f"[LTI] oauth_body_hash = {oauth_body_hash}") + client_key, client_secret = self.get_client_key_secret() + client = Client(client_key, client_secret) + mock_request = mock.Mock( + uri=str(parse.unquote(request.url)), + headers=request.headers, + body="", + decoded_body="", + http_method=str(request.method), + ) + params = client.get_oauth_params(mock_request) + mock_request.oauth_params = params + mock_request.oauth_params.append(('oauth_body_hash', oauth_body_hash)) + sig = client.get_oauth_signature(mock_request) + mock_request.oauth_params.append(('oauth_signature', sig)) + + _, headers, _ = client._render(mock_request) # pylint: disable=protected-access + log.debug("\n\n#### COPY AND PASTE AUTHORIZATION HEADER ####\n{}\n####################################\n\n" + .format(headers['Authorization'])) + + def parse_lti_2_0_handler_suffix(self, suffix): + """ + Parser function for HTTP request path suffixes + + parses the suffix argument (the trailing parts of the URL) of the LTI2.0 REST handler. + must be of the form "user/". Returns anon_id if match found, otherwise raises LTIError + + Arguments: + suffix (unicode): suffix to parse + + Returns: + unicode: anon_id if match found + + Raises: + LTIError if suffix cannot be parsed or is not in its expected form + """ + if suffix: + match_obj = LTI_2_0_REST_SUFFIX_PARSER.match(suffix) + if match_obj: + return match_obj.group('anon_id') + # fall-through handles all error cases + msg = "No valid user id found in endpoint URL" + log.info(f"[LTI]: {msg}") + raise LTIError(msg) + + def _lti_2_0_result_get_handler(self, request, real_user): + """ + Helper request handler for GET requests to LTI 2.0 result endpoint + + GET handler for lti_2_0_result. Assumes all authorization has been checked. + + Arguments: + request (xblock.django.request.DjangoWebobRequest): Request object (unused) + real_user (django.contrib.auth.models.User): Actual user linked to anon_id in request path suffix + + Returns: + webob.response: response to this request, in JSON format with status 200 if success + """ + base_json_obj = { + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "@type": "Result" + } + self.runtime.service(self, 'rebind_user').rebind_noauth_module_to_user(self, real_user) + if self.module_score is None: # In this case, no score has been ever set + return Response(json.dumps(base_json_obj).encode('utf-8'), content_type=LTI_2_0_JSON_CONTENT_TYPE) + + # Fall through to returning grade and comment + base_json_obj['resultScore'] = round_away_from_zero(self.module_score, 2) + base_json_obj['comment'] = self.score_comment + return Response(json.dumps(base_json_obj).encode('utf-8'), content_type=LTI_2_0_JSON_CONTENT_TYPE) + + def _lti_2_0_result_del_handler(self, request, real_user): + """ + Helper request handler for DELETE requests to LTI 2.0 result endpoint + + DELETE handler for lti_2_0_result. Assumes all authorization has been checked. + + Arguments: + request (xblock.django.request.DjangoWebobRequest): Request object (unused) + real_user (django.contrib.auth.models.User): Actual user linked to anon_id in request path suffix + + Returns: + webob.response: response to this request. status 200 if success + """ + self.clear_user_module_score(real_user) + return Response(status=200) + + def _lti_2_0_result_put_handler(self, request, real_user): + """ + Helper request handler for PUT requests to LTI 2.0 result endpoint + + PUT handler for lti_2_0_result. Assumes all authorization has been checked. + + Arguments: + request (xblock.django.request.DjangoWebobRequest): Request object + real_user (django.contrib.auth.models.User): Actual user linked to anon_id in request path suffix + + Returns: + webob.response: response to this request. status 200 if success. 404 if body of PUT request is malformed + """ + try: + (score, comment) = self.parse_lti_2_0_result_json(request.body.decode('utf-8')) + except LTIError: + return Response(status=404) # have to do 404 due to spec, but 400 is better, with error msg in body + + # According to http://www.imsglobal.org/lti/ltiv2p0/ltiIMGv2p0.html#_Toc361225514 + # PUTting a JSON object with no "resultScore" field is equivalent to a DELETE. + if score is None: + self.clear_user_module_score(real_user) + return Response(status=200) + + # Fall-through record the score and the comment in the block + self.set_user_module_score(real_user, score, self.max_score(), comment) + return Response(status=200) + + def clear_user_module_score(self, user): + """ + Clears the module user state, including grades and comments, and also scoring in db's courseware_studentmodule + + Arguments: + user (django.contrib.auth.models.User): Actual user whose module state is to be cleared + + Returns: + nothing + """ + self.set_user_module_score(user, None, None, score_deleted=True) + + def set_user_module_score(self, user, score, max_score, comment="", score_deleted=False): + """ + Sets the module user state, including grades and comments, and also scoring in db's courseware_studentmodule + + Arguments: + user (django.contrib.auth.models.User): Actual user whose module state is to be set + score (float): user's numeric score to set. Must be in the range [0.0, 1.0] + max_score (float): max score that could have been achieved on this module + comment (unicode): comments provided by the grader as feedback to the student + + Returns: + nothing + """ + if score is not None and max_score is not None: + scaled_score = score * max_score + else: + scaled_score = None + + self.runtime.service(self, 'rebind_user').rebind_noauth_module_to_user(self, user) + + # have to publish for the progress page... + self.runtime.publish( + self, + 'grade', + { + 'value': scaled_score, + 'max_value': max_score, + 'user_id': user.id, + 'score_deleted': score_deleted, + }, + ) + self.module_score = scaled_score + self.score_comment = comment + + def verify_lti_2_0_result_rest_headers(self, request, verify_content_type=True): + """ + Helper method to validate LTI 2.0 REST result service HTTP headers. returns if correct, else raises LTIError + + Arguments: + request (xblock.django.request.DjangoWebobRequest): Request object + verify_content_type (bool): If true, verifies the content type of the request is that spec'ed by LTI 2.0 + + Returns: + nothing, but will only return if verification succeeds + + Raises: + LTIError if verification fails + """ + content_type = request.headers.get('Content-Type') + if verify_content_type and content_type != LTI_2_0_JSON_CONTENT_TYPE: + log.info(f"[LTI]: v2.0 result service -- bad Content-Type: {content_type}") + raise LTIError( + "For LTI 2.0 result service, Content-Type must be {}. Got {}".format(LTI_2_0_JSON_CONTENT_TYPE, + content_type)) + try: + self.verify_oauth_body_sign(request, content_type=LTI_2_0_JSON_CONTENT_TYPE) + except (ValueError, LTIError) as err: + log.info(f"[LTI]: v2.0 result service -- OAuth body verification failed: {str(err)}") + raise LTIError(str(err)) # lint-amnesty, pylint: disable=raise-missing-from + + def parse_lti_2_0_result_json(self, json_str): + """ + Helper method for verifying LTI 2.0 JSON object contained in the body of the request. + + The json_str must be loadable. It can either be an dict (object) or an array whose first element is an dict, + in which case that first dict is considered. + The dict must have the "@type" key with value equal to "Result", + "resultScore" key with value equal to a number [0, 1], + The "@context" key must be present, but we don't do anything with it. And the "comment" key may be + present, in which case it must be a string. + + Arguments: + json_str (unicode): The body of the LTI 2.0 results service request, which is a JSON string] + + Returns: + (float, str): (score, [optional]comment) if verification checks out + + Raises: + LTIError (with message) if verification fails + """ + try: + json_obj = json.loads(json_str) + except (ValueError, TypeError): + msg = f"Supplied JSON string in request body could not be decoded: {json_str}" + log.info(f"[LTI] {msg}") + raise LTIError(msg) # lint-amnesty, pylint: disable=raise-missing-from + + # the standard supports a list of objects, who knows why. It must contain at least 1 element, and the + # first element must be a dict + if not isinstance(json_obj, dict): + if isinstance(json_obj, list) and len(json_obj) >= 1 and isinstance(json_obj[0], dict): + json_obj = json_obj[0] + else: + msg = ("Supplied JSON string is a list that does not contain an object as the first element. {}" + .format(json_str)) + log.info(f"[LTI] {msg}") + raise LTIError(msg) + + # '@type' must be "Result" + result_type = json_obj.get("@type") + if result_type != "Result": + msg = f"JSON object does not contain correct @type attribute (should be 'Result', is {result_type})" + log.info(f"[LTI] {msg}") + raise LTIError(msg) + + # '@context' must be present as a key + REQUIRED_KEYS = ["@context"] # pylint: disable=invalid-name + for key in REQUIRED_KEYS: + if key not in json_obj: + msg = f"JSON object does not contain required key {key}" + log.info(f"[LTI] {msg}") + raise LTIError(msg) + + # 'resultScore' is not present. If this was a PUT this means it's actually a DELETE according + # to the LTI spec. We will indicate this by returning None as score, "" as comment. + # The actual delete will be handled by the caller + if "resultScore" not in json_obj: + return None, json_obj.get('comment', "") + + # if present, 'resultScore' must be a number between 0 and 1 inclusive + try: + score = float(json_obj.get('resultScore', "unconvertable")) # Check if float is present and the right type + if not 0 <= score <= 1: + msg = 'score value outside the permitted range of 0-1.' + log.info(f"[LTI] {msg}") + raise LTIError(msg) + except (TypeError, ValueError) as err: + msg = f"Could not convert resultScore to float: {str(err)}" + log.info(f"[LTI] {msg}") + raise LTIError(msg) # lint-amnesty, pylint: disable=raise-missing-from + + return score, json_obj.get('comment', "") diff --git a/xblocks_contrib/lti/static/README.txt b/xblocks_contrib/lti/static/README.txt new file mode 100644 index 0000000..127da5a --- /dev/null +++ b/xblocks_contrib/lti/static/README.txt @@ -0,0 +1,18 @@ +This static directory is for files that should be included in your kit as plain +static files. + +You can ask the runtime for a URL that will retrieve these files with: + + url = self.runtime.local_resource_url(self, "static/js/lib.js") + +The default implementation is very strict though, and will not serve files from +the static directory. It will serve files from a directory named "public". +Create a directory alongside this one named "public", and put files there. +Then you can get a url with code like this: + + url = self.runtime.local_resource_url(self, "public/js/lib.js") + +The sample code includes a function you can use to read the content of files +in the static directory, like this: + + frag.add_javascript(self.resource_string("static/js/my_block.js")) diff --git a/xblocks_contrib/lti/static/css/lti.css b/xblocks_contrib/lti/static/css/lti.css index 28e3b63..7e9b576 100644 --- a/xblocks_contrib/lti/static/css/lti.css +++ b/xblocks_contrib/lti/static/css/lti.css @@ -1,9 +1,57 @@ /* CSS for LTIBlock */ -.lti .count { - font-weight: bold; +@import url("https://fonts.googleapis.com/css?family=Open+Sans:300,400,400i,600,700"); + +h2.problem-header { + display: inline-block; +} + +div.problem-progress { + display: inline-block; + padding-left: 5px; + color: #666; + font-weight: 100; + font-size: 1em; +} + +div.lti { + margin: 0 auto; +} + +div.lti .wrapper-lti-link { + font-size: 14px; + background-color: #f6f6f6; + padding: 20px; +} + +div.lti .wrapper-lti-link .lti-link { + margin-bottom: 0; + text-align: right; +} + +div.lti .wrapper-lti-link .lti-link .link_lti_new_window { + font-size: 13px; + line-height: 20.72px; +} + +div.lti form.ltiLaunchForm { + display: none; +} + +div.lti iframe.ltiLaunchFrame { + width: 100%; + height: 800px; + display: block; + border: 0px; +} + +div.lti h4.problem-feedback-label { + font-weight: 100; + font-size: 1em; + font-family: "Source Sans", "Open Sans", Verdana, Geneva, sans-serif, sans-serif; } -.lti p { - cursor: pointer; +div.lti div.problem-feedback { + margin-top: 5px; + margin-bottom: 5px; } diff --git a/xblocks_contrib/lti/static/js/src/lti.js b/xblocks_contrib/lti/static/js/src/lti.js index 74e5e76..28d4d0e 100644 --- a/xblocks_contrib/lti/static/js/src/lti.js +++ b/xblocks_contrib/lti/static/js/src/lti.js @@ -1,38 +1,36 @@ - /* JavaScript for LTIBlock. */ -function LTIBlock(runtime, element) { - const updateCount = (result) => { - $('.count', element).text(result.count); - }; - const handlerUrl = runtime.handlerUrl(element, 'increment_count'); - - $('p', element).on('click', (eventObject) => { - $.ajax({ - type: 'POST', - url: handlerUrl, - contentType: 'application/json', - data: JSON.stringify({hello: 'world'}), - success: updateCount - }); - }); +(function() { + 'use strict'; - $(() => { - /* - Use `gettext` provided by django-statici18n for static translations - */ + /** + * This function will process all the attributes from the DOM element passed, taking all of + * the configuration attributes. It uses the request-username and request-email + * to prompt the user to decide if they want to share their personal information + * with the third party application connecting through LTI. + * @constructor + * @param {jQuery} element DOM element with the lti container. + */ + this.LTI = function(element) { + var dataAttrs = $(element).find('.lti').data(), + askToSendUsername = (dataAttrs.askToSendUsername === 'True'), + askToSendEmail = (dataAttrs.askToSendEmail === 'True'); - // eslint-disable-next-line no-undef - const dummyText = gettext('Hello World'); - - // Example usage of interpolation for translated strings - // eslint-disable-next-line no-undef - const message = StringUtils.interpolate( - gettext('You are enrolling in {courseName}'), - { - courseName: 'Rock & Roll 101' + // When the lti button is clicked, provide users the option to + // accept or reject sending their information to a third party + $(element).on('click', '.link_lti_new_window', function() { + if (askToSendUsername && askToSendEmail) { + // eslint-disable-next-line no-alert + return confirm(gettext('Click OK to have your username and e-mail address sent to a 3rd party application.\n\nClick Cancel to return to this page without sending your information.')); + } else if (askToSendUsername) { + // eslint-disable-next-line no-alert + return confirm(gettext('Click OK to have your username sent to a 3rd party application.\n\nClick Cancel to return to this page without sending your information.')); + } else if (askToSendEmail) { + // eslint-disable-next-line no-alert + return confirm(gettext('Click OK to have your e-mail address sent to a 3rd party application.\n\nClick Cancel to return to this page without sending your information.')); + } else { + return true; } - ); - console.log(message); // This is just for demonstration purposes - }); -} + }); + }; +}).call(this); diff --git a/xblocks_contrib/lti/templates/lti.html b/xblocks_contrib/lti/templates/lti.html index 8f9288f..73cc129 100644 --- a/xblocks_contrib/lti/templates/lti.html +++ b/xblocks_contrib/lti/templates/lti.html @@ -1,7 +1,70 @@ {% load i18n %} -
-

- LTIBlock: {% trans "count is now" %} {{ count }} {% trans "click me to increment." %} -

+

+ {# Translators: "External resource" means that this learning module is hosted on a platform external to the edX LMS #} + {{display_name}} ({% trans 'External resource' %}) +

+ +{% if has_score and weight %} +
+ {% if module_score is not None %} + {# Translators: "points" is the student's achieved score on this LTI unit, and "total_points" is the maximum number of points achievable. #} + {% trans "points" as points_trans %} + {% trans "{points} / {total_points} points" as score_trans %} + ({{ module_score }} / {{ weight }} {{ points_trans }}) + {% else %} + {# Translators: "total_points" is the maximum number of points achievable on this LTI unit #} + {% trans "total_points" as total_points_trans %} + ({{ weight }} {{ total_points_trans }} possible) + {% endif %} +
+{% endif %} + +
+ +{% if launch_url and launch_url != 'http://www.example.com' and not hide_launch %} + {% if open_in_a_new_page %} + + {% else %} + {# The result of the form submit will be rendered here. #} + + {% endif %} +{% elif not hide_launch %} +

+ {{ _('Please provide launch_url. Click "Edit", and fill in the required fields.') }} +

+{% endif %} + +{% if has_score and comment %} + + +{% endif %} +
diff --git a/xblocks_contrib/lti/templates/lti_form.html b/xblocks_contrib/lti/templates/lti_form.html new file mode 100644 index 0000000..7c72487 --- /dev/null +++ b/xblocks_contrib/lti/templates/lti_form.html @@ -0,0 +1,38 @@ +{% load i18n %} + + + + + + LTI + + + {% comment %} + This form will be hidden. + LTI block JavaScript will trigger a "submit" on the form, and the + result will be rendered instead. + {% endcomment %} + + + + diff --git a/xblocks_contrib/lti/tests/test_lti20_unit.py b/xblocks_contrib/lti/tests/test_lti20_unit.py new file mode 100644 index 0000000..6ade72e --- /dev/null +++ b/xblocks_contrib/lti/tests/test_lti20_unit.py @@ -0,0 +1,387 @@ +"""Tests for LTI Xmodule LTIv2.0 functional logic.""" + + +import datetime +import textwrap +import unittest +from unittest.mock import Mock + +from pytz import UTC +from xblock.field_data import DictFieldData + +from lti_2_util import LTIError +from lti import LTIBlock +from xmodule.tests.helpers import StubUserService + +from . import get_test_system + + +class LTI20RESTResultServiceTest(unittest.TestCase): + """Logic tests for LTI block. LTI2.0 REST ResultService""" + + USER_STANDIN = Mock() + USER_STANDIN.id = 999 + + def setUp(self): + super().setUp() + self.runtime = get_test_system(user=self.USER_STANDIN) + self.environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} + self.runtime.publish = Mock() + self.runtime._services['rebind_user'] = Mock() # pylint: disable=protected-access + + self.xblock = LTIBlock(self.runtime, DictFieldData({}), Mock()) + self.lti_id = self.xblock.lti_id + self.xblock.due = None + self.xblock.graceperiod = None + + def test_sanitize_get_context(self): + """Tests that the get_context function does basic sanitization""" + # get_context, unfortunately, requires a lot of mocking machinery + mocked_course = Mock(name='mocked_course', lti_passports=['lti_id:test_client:test_secret']) + modulestore = Mock(name='modulestore') + modulestore.get_course.return_value = mocked_course + self.xblock.runtime.modulestore = modulestore + self.xblock.lti_id = "lti_id" + + test_cases = ( # (before sanitize, after sanitize) + ("plaintext", "plaintext"), + ("a ", "a "), # drops scripts + ("bold 包", "bold 包"), # unicode, and tags pass through + ) + for case in test_cases: + self.xblock.score_comment = case[0] + assert case[1] == self.xblock.get_context()['comment'] + + def test_lti20_rest_bad_contenttype(self): + """ + Input with bad content type + """ + with self.assertRaisesRegex(LTIError, "Content-Type must be"): + request = Mock(headers={'Content-Type': 'Non-existent'}) + self.xblock.verify_lti_2_0_result_rest_headers(request) + + def test_lti20_rest_failed_oauth_body_verify(self): + """ + Input with bad oauth body hash verification + """ + err_msg = "OAuth body verification failed" + self.xblock.verify_oauth_body_sign = Mock(side_effect=LTIError(err_msg)) + with self.assertRaisesRegex(LTIError, err_msg): + request = Mock(headers={'Content-Type': 'application/vnd.ims.lis.v2.result+json'}) + self.xblock.verify_lti_2_0_result_rest_headers(request) + + def test_lti20_rest_good_headers(self): + """ + Input with good oauth body hash verification + """ + self.xblock.verify_oauth_body_sign = Mock(return_value=True) + + request = Mock(headers={'Content-Type': 'application/vnd.ims.lis.v2.result+json'}) + self.xblock.verify_lti_2_0_result_rest_headers(request) + # We just want the above call to complete without exceptions, and to have called verify_oauth_body_sign + assert self.xblock.verify_oauth_body_sign.called + + BAD_DISPATCH_INPUTS = [ + None, + "", + "abcd" + "notuser/abcd" + "user/" + "user//" + "user/gbere/" + "user/gbere/xsdf" + "user/ಠ益ಠ" # not alphanumeric + ] + + def test_lti20_rest_bad_dispatch(self): + """ + Test the error cases for the "dispatch" argument to the LTI 2.0 handler. Anything that doesn't + fit the form user/ + """ + for einput in self.BAD_DISPATCH_INPUTS: + with self.assertRaisesRegex(LTIError, "No valid user id found in endpoint URL"): + self.xblock.parse_lti_2_0_handler_suffix(einput) + + GOOD_DISPATCH_INPUTS = [ + ("user/abcd3", "abcd3"), + ("user/Äbcdè2", "Äbcdè2"), # unicode, just to make sure + ] + + def test_lti20_rest_good_dispatch(self): + """ + Test the good cases for the "dispatch" argument to the LTI 2.0 handler. Anything that does + fit the form user/ + """ + for ginput, expected in self.GOOD_DISPATCH_INPUTS: + assert self.xblock.parse_lti_2_0_handler_suffix(ginput) == expected + + BAD_JSON_INPUTS = [ + # (bad inputs, error message expected) + ([ + "kk", # ValueError + "{{}", # ValueError + "{}}", # ValueError + 3, # TypeError + {}, # TypeError + ], "Supplied JSON string in request body could not be decoded"), + ([ + "3", # valid json, not array or object + "[]", # valid json, array too small + "[3, {}]", # valid json, 1st element not an object + ], "Supplied JSON string is a list that does not contain an object as the first element"), + ([ + '{"@type": "NOTResult"}', # @type key must have value 'Result' + ], "JSON object does not contain correct @type attribute"), + ([ + # @context missing + '{"@type": "Result", "resultScore": 0.1}', + ], "JSON object does not contain required key"), + ([ + ''' + {"@type": "Result", + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "resultScore": 100}''' # score out of range + ], "score value outside the permitted range of 0-1."), + ([ + ''' + {"@type": "Result", + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "resultScore": "1b"}''', # score ValueError + ''' + {"@type": "Result", + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "resultScore": {}}''', # score TypeError + ], "Could not convert resultScore to float"), + ] + + def test_lti20_bad_json(self): + """ + Test that bad json_str to parse_lti_2_0_result_json inputs raise appropriate LTI Error + """ + for error_inputs, error_message in self.BAD_JSON_INPUTS: + for einput in error_inputs: + with self.assertRaisesRegex(LTIError, error_message): + self.xblock.parse_lti_2_0_result_json(einput) + + GOOD_JSON_INPUTS = [ + (''' + {"@type": "Result", + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "resultScore": 0.1}''', ""), # no comment means we expect "" + (''' + [{"@type": "Result", + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "@id": "anon_id:abcdef0123456789", + "resultScore": 0.1}]''', ""), # OK to have array of objects -- just take the first. @id is okay too + (''' + {"@type": "Result", + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "resultScore": 0.1, + "comment": "ಠ益ಠ"}''', "ಠ益ಠ"), # unicode comment + ] + + def test_lti20_good_json(self): + """ + Test the parsing of good comments + """ + for json_str, expected_comment in self.GOOD_JSON_INPUTS: + score, comment = self.xblock.parse_lti_2_0_result_json(json_str) + assert score == 0.1 + assert comment == expected_comment + + GOOD_JSON_PUT = textwrap.dedent(""" + {"@type": "Result", + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "@id": "anon_id:abcdef0123456789", + "resultScore": 0.1, + "comment": "ಠ益ಠ"} + """).encode('utf-8') + + GOOD_JSON_PUT_LIKE_DELETE = textwrap.dedent(""" + {"@type": "Result", + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "@id": "anon_id:abcdef0123456789", + "comment": "ಠ益ಠ"} + """).encode('utf-8') + + def get_signed_lti20_mock_request(self, body, method='PUT'): + """ + Example of signed from LTI 2.0 Provider. Signatures and hashes are example only and won't verify + """ + mock_request = Mock() + mock_request.headers = { + 'Content-Type': 'application/vnd.ims.lis.v2.result+json', + 'Authorization': ( + 'OAuth oauth_nonce="135685044251684026041377608307", ' + 'oauth_timestamp="1234567890", oauth_version="1.0", ' + 'oauth_signature_method="HMAC-SHA1", ' + 'oauth_consumer_key="test_client_key", ' + 'oauth_signature="my_signature%3D", ' + 'oauth_body_hash="gz+PeJZuF2//n9hNUnDj2v5kN70="' + ) + } + mock_request.url = 'http://testurl' + mock_request.http_method = method + mock_request.method = method + mock_request.body = body + return mock_request + + def setup_system_xblock_mocks_for_lti20_request_test(self): + """ + Helper fn to set up mocking for lti 2.0 request test + """ + self.xblock.max_score = Mock(return_value=1.0) + self.xblock.get_client_key_secret = Mock(return_value=('test_client_key', 'test_client_secret')) + self.xblock.verify_oauth_body_sign = Mock() + + def test_lti20_put_like_delete_success(self): + """ + The happy path for LTI 2.0 PUT that acts like a delete + """ + self.setup_system_xblock_mocks_for_lti20_request_test() + SCORE = 0.55 # pylint: disable=invalid-name + COMMENT = "ಠ益ಠ" # pylint: disable=invalid-name + self.xblock.module_score = SCORE + self.xblock.score_comment = COMMENT + mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT_LIKE_DELETE) + # Now call the handler + response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") + # Now assert there's no score + assert response.status_code == 200 + assert self.xblock.module_score is None + assert self.xblock.score_comment == '' + (_, evt_type, called_grade_obj), _ = self.runtime.publish.call_args # pylint: disable=unpacking-non-sequence + assert called_grade_obj ==\ + {'user_id': self.USER_STANDIN.id, 'value': None, 'max_value': None, 'score_deleted': True} + assert evt_type == 'grade' + + def test_lti20_delete_success(self): + """ + The happy path for LTI 2.0 DELETE + """ + self.setup_system_xblock_mocks_for_lti20_request_test() + SCORE = 0.55 # pylint: disable=invalid-name + COMMENT = "ಠ益ಠ" # pylint: disable=invalid-name + self.xblock.module_score = SCORE + self.xblock.score_comment = COMMENT + mock_request = self.get_signed_lti20_mock_request(b"", method='DELETE') + # Now call the handler + response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") + # Now assert there's no score + assert response.status_code == 200 + assert self.xblock.module_score is None + assert self.xblock.score_comment == '' + (_, evt_type, called_grade_obj), _ = self.runtime.publish.call_args # pylint: disable=unpacking-non-sequence + assert called_grade_obj ==\ + {'user_id': self.USER_STANDIN.id, 'value': None, 'max_value': None, 'score_deleted': True} + assert evt_type == 'grade' + + def test_lti20_put_set_score_success(self): + """ + The happy path for LTI 2.0 PUT that sets a score + """ + self.setup_system_xblock_mocks_for_lti20_request_test() + mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) + # Now call the handler + response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") + # Now assert + assert response.status_code == 200 + assert self.xblock.module_score == 0.1 + assert self.xblock.score_comment == 'ಠ益ಠ' + (_, evt_type, called_grade_obj), _ = self.runtime.publish.call_args # pylint: disable=unpacking-non-sequence + assert evt_type == 'grade' + assert called_grade_obj ==\ + {'user_id': self.USER_STANDIN.id, 'value': 0.1, 'max_value': 1.0, 'score_deleted': False} + + def test_lti20_get_no_score_success(self): + """ + The happy path for LTI 2.0 GET when there's no score + """ + self.setup_system_xblock_mocks_for_lti20_request_test() + mock_request = self.get_signed_lti20_mock_request(b"", method='GET') + # Now call the handler + response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") + # Now assert + assert response.status_code == 200 + assert response.json == {'@context': 'http://purl.imsglobal.org/ctx/lis/v2/Result', '@type': 'Result'} + + def test_lti20_get_with_score_success(self): + """ + The happy path for LTI 2.0 GET when there is a score + """ + self.setup_system_xblock_mocks_for_lti20_request_test() + SCORE = 0.55 # pylint: disable=invalid-name + COMMENT = "ಠ益ಠ" # pylint: disable=invalid-name + self.xblock.module_score = SCORE + self.xblock.score_comment = COMMENT + mock_request = self.get_signed_lti20_mock_request(b"", method='GET') + # Now call the handler + response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") + # Now assert + assert response.status_code == 200 + assert response.json ==\ + {'@context': 'http://purl.imsglobal.org/ctx/lis/v2/Result', + '@type': 'Result', 'resultScore': SCORE, 'comment': COMMENT} + + UNSUPPORTED_HTTP_METHODS = ["OPTIONS", "HEAD", "POST", "TRACE", "CONNECT"] + + def test_lti20_unsupported_method_error(self): + """ + Test we get a 404 when we don't GET or PUT + """ + self.setup_system_xblock_mocks_for_lti20_request_test() + mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) + for bad_method in self.UNSUPPORTED_HTTP_METHODS: + mock_request.method = bad_method + response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") + assert response.status_code == 404 + + def test_lti20_request_handler_bad_headers(self): + """ + Test that we get a 401 when header verification fails + """ + self.setup_system_xblock_mocks_for_lti20_request_test() + self.xblock.verify_lti_2_0_result_rest_headers = Mock(side_effect=LTIError()) + mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) + response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") + assert response.status_code == 401 + + def test_lti20_request_handler_bad_dispatch_user(self): + """ + Test that we get a 404 when there's no (or badly formatted) user specified in the url + """ + self.setup_system_xblock_mocks_for_lti20_request_test() + mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) + response = self.xblock.lti_2_0_result_rest_handler(mock_request, None) + assert response.status_code == 404 + + def test_lti20_request_handler_bad_json(self): + """ + Test that we get a 404 when json verification fails + """ + self.setup_system_xblock_mocks_for_lti20_request_test() + self.xblock.parse_lti_2_0_result_json = Mock(side_effect=LTIError()) + mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) + response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") + assert response.status_code == 404 + + def test_lti20_request_handler_bad_user(self): + """ + Test that we get a 404 when the supplied user does not exist + """ + self.setup_system_xblock_mocks_for_lti20_request_test() + self.runtime._services['user'] = StubUserService(user=None) # pylint: disable=protected-access + mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) + response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") + assert response.status_code == 404 + + def test_lti20_request_handler_grade_past_due(self): + """ + Test that we get a 404 when accept_grades_past_due is False and it is past due + """ + self.setup_system_xblock_mocks_for_lti20_request_test() + self.xblock.due = datetime.datetime.now(UTC) + self.xblock.accept_grades_past_due = False + mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) + response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") + assert response.status_code == 404 diff --git a/xblocks_contrib/lti/tests/test_lti.py b/xblocks_contrib/lti/tests/test_lti_unit.py similarity index 100% rename from xblocks_contrib/lti/tests/test_lti.py rename to xblocks_contrib/lti/tests/test_lti_unit.py From 17bc4941d5a8ecd98fbbac2565bb490f186b4e56 Mon Sep 17 00:00:00 2001 From: Muhammad Tayayb Tahir Qureshi Date: Thu, 31 Oct 2024 13:04:29 +0500 Subject: [PATCH 02/34] fix: fix LTI block --- xblocks_contrib/lti/lti.py | 235 +++++++++++--------- xblocks_contrib/lti/static/js/src/lti.js | 49 ++-- xblocks_contrib/lti/templates/lti.html | 158 ++++++++----- xblocks_contrib/lti/templates/lti_form.html | 2 +- 4 files changed, 242 insertions(+), 202 deletions(-) diff --git a/xblocks_contrib/lti/lti.py b/xblocks_contrib/lti/lti.py index 5c06f6d..1295c2f 100644 --- a/xblocks_contrib/lti/lti.py +++ b/xblocks_contrib/lti/lti.py @@ -74,7 +74,12 @@ from web_fragments.fragment import Fragment from xblock.core import List, Scope, String, XBlock from xblock.fields import Boolean, Float -from xblock.utils.resources import ResourceLoader +try: + from xblock.utils.resources import ResourceLoader + from xblock.utils.studio_editable import StudioEditableXBlockMixin +except ModuleNotFoundError: + from xblockutils.resources import ResourceLoader + from xblockutils.studio_editable import StudioEditableXBlockMixin from openedx.core.djangolib.markup import HTML, Text from .lti_2_util import LTI20BlockMixin, LTIError @@ -101,26 +106,116 @@ def noop(text): _ = noop -class LTIFields: +@XBlock.needs("i18n") +@XBlock.needs("user") +@XBlock.needs("rebind_user") +class LTIBlock( + LTI20BlockMixin, + StudioEditableXBlockMixin, + XBlock, +): # pylint: disable=abstract-method """ - Fields to define and obtain LTI tool from provider are set here, - except credentials, which should be set in course settings:: + THIS MODULE IS DEPRECATED IN FAVOR OF https://github.com/openedx/xblock-lti-consumer + + Module provides LTI integration to course. + + Except usual Xmodule structure it proceeds with OAuth signing. + How it works:: + + 1. Get credentials from course settings. + + 2. There is minimal set of parameters need to be signed (presented for Vitalsource):: + + user_id + oauth_callback + lis_outcome_service_url + lis_result_sourcedid + launch_presentation_return_url + lti_message_type + lti_version + roles + *+ all custom parameters* + + These parameters should be encoded and signed by *OAuth1* together with + `launch_url` and *POST* request type. + + 3. Signing proceeds with client key/secret pair obtained from course settings. + That pair should be obtained from LTI provider and set into course settings by course author. + After that signature and other OAuth data are generated. + + OAuth data which is generated after signing is usual:: - `lti_id` is id to connect tool with credentials in course settings. It should not contain :: (double semicolon) - `launch_url` is launch URL of tool. - `custom_parameters` are additional parameters to navigate to proper book and book page. + oauth_callback + oauth_nonce + oauth_consumer_key + oauth_signature_method + oauth_timestamp + oauth_version - For example, for Vitalsource provider, `launch_url` should be - *https://bc-staging.vitalsource.com/books/book*, - and to get to proper book and book page, you should set custom parameters as:: - vbid=put_book_id_here - book_location=page/put_page_number_here + 4. All that data is passed to form and sent to LTI provider server by browser via + autosubmit via JavaScript. - Default non-empty URL for `launch_url` is needed due to oauthlib demand (URL scheme should be presented):: + Form example:: - https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136 +
+ + + + + + + + + + + + + + + + + + + + +
+ + 5. LTI provider has same secret key and it signs data string via *OAuth1* and compares signatures. + + If signatures are correct, LTI provider redirects iframe source to LTI tool web page, + and LTI tool is rendered to iframe inside course. + + Otherwise error message from LTI provider is generated. """ + + # Indicates that this XBlock has been extracted from edx-platform. + is_extracted = True + + ###################################### + # LTI FIELDS # + ###################################### + # `lti_id` is id to connect tool with credentials in course settings. It should not contain :: (double semicolon) + # `launch_url` is launch URL of tool. + # `custom_parameters` are additional parameters to navigate to proper book and book page. + + # For example, for Vitalsource provider, `launch_url` should be + # *https://bc-staging.vitalsource.com/books/book*, + # and to get to proper book and book page, you should set custom parameters as:: + + # vbid=put_book_id_here + # book_location=page/put_page_number_here + + # Default non-empty URL for `launch_url` is needed due to oauthlib demand (URL scheme should be presented):: + + # https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136 display_name = String( display_name=_("Display Name"), help=_( @@ -130,6 +225,7 @@ class LTIFields: scope=Scope.settings, default="LTI", ) + lti_id = String( display_name=_("LTI ID"), help=Text(_( @@ -145,6 +241,7 @@ class LTIFields: default='', scope=Scope.settings ) + launch_url = String( display_name=_("LTI URL"), help=Text(_( @@ -158,6 +255,7 @@ class LTIFields: ), default='http://www.example.com', scope=Scope.settings) + custom_parameters = List( display_name=_("Custom Parameters"), help=Text(_( @@ -170,6 +268,7 @@ class LTIFields: anchor_close=HTML("") ), scope=Scope.settings) + open_in_a_new_page = Boolean( display_name=_("Open in New Page"), help=_( @@ -180,6 +279,7 @@ class LTIFields: default=True, scope=Scope.settings ) + has_score = Boolean( display_name=_("Scored"), help=_( @@ -188,6 +288,7 @@ class LTIFields: default=False, scope=Scope.settings ) + weight = Float( display_name=_("Weight"), help=_( @@ -199,16 +300,19 @@ class LTIFields: scope=Scope.settings, values={"min": 0}, ) + module_score = Float( help=_("The score kept in the xblock KVS -- duplicate of the published score in django DB"), default=None, scope=Scope.user_state ) + score_comment = String( help=_("Comment as returned from grader, LTI2.0 spec"), default="", scope=Scope.user_state ) + hide_launch = Boolean( display_name=_("Hide External Tool"), help=_( @@ -229,6 +333,7 @@ class LTIFields: default=False, scope=Scope.settings ) + ask_to_send_email = Boolean( display_name=_("Request user's email"), # Translators: This is used to request the user's email for a third party service. @@ -262,100 +367,12 @@ class LTIFields: default=True, scope=Scope.settings ) - - -@XBlock.needs("i18n") -@XBlock.needs("user") -@XBlock.needs("rebind_user") -class LTIBlock( - XBlock, - LTIFields, - LTI20BlockMixin, -): # pylint: disable=abstract-method - """ - THIS MODULE IS DEPRECATED IN FAVOR OF https://github.com/openedx/xblock-lti-consumer - - Module provides LTI integration to course. - - Except usual Xmodule structure it proceeds with OAuth signing. - How it works:: - - 1. Get credentials from course settings. - - 2. There is minimal set of parameters need to be signed (presented for Vitalsource):: - - user_id - oauth_callback - lis_outcome_service_url - lis_result_sourcedid - launch_presentation_return_url - lti_message_type - lti_version - roles - *+ all custom parameters* - - These parameters should be encoded and signed by *OAuth1* together with - `launch_url` and *POST* request type. - - 3. Signing proceeds with client key/secret pair obtained from course settings. - That pair should be obtained from LTI provider and set into course settings by course author. - After that signature and other OAuth data are generated. - - OAuth data which is generated after signing is usual:: - - oauth_callback - oauth_nonce - oauth_consumer_key - oauth_signature_method - oauth_timestamp - oauth_version - - - 4. All that data is passed to form and sent to LTI provider server by browser via - autosubmit via JavaScript. - - Form example:: - -
- - - - - - - - - - - - - - - - - - - - -
- - 5. LTI provider has same secret key and it signs data string via *OAuth1* and compares signatures. - - If signatures are correct, LTI provider redirects iframe source to LTI tool web page, - and LTI tool is rendered to iframe inside course. - - Otherwise error message from LTI provider is generated. - """ - - # Indicates that this XBlock has been extracted from edx-platform. - is_extracted = True + + editable_fields = ( + "accept_grades_past_due", "button_text", "custom_parameters", "display_name", + "hide_launch", "description", "lti_id", "launch_url", "open_in_a_new_page", + "ask_to_send_email", "ask_to_send_username", "has_score", "weight", + ) def max_score(self): return self.weight if self.has_score else None @@ -509,7 +526,7 @@ def preview_handler(self, _, __): """ This is called to get context with new oauth params to iframe. """ - template = resource_loader.load_unicode("templates/lti_form.html").format(**self.get_context()) + template = resource_loader.render_django_template("templates/lti_form.html", self.get_context()) return Response(template, content_type='text/html') @XBlock.handler diff --git a/xblocks_contrib/lti/static/js/src/lti.js b/xblocks_contrib/lti/static/js/src/lti.js index 28d4d0e..efbfa07 100644 --- a/xblocks_contrib/lti/static/js/src/lti.js +++ b/xblocks_contrib/lti/static/js/src/lti.js @@ -1,36 +1,23 @@ /* JavaScript for LTIBlock. */ -(function() { +function LTIBlock(element) { 'use strict'; - /** - * This function will process all the attributes from the DOM element passed, taking all of - * the configuration attributes. It uses the request-username and request-email - * to prompt the user to decide if they want to share their personal information - * with the third party application connecting through LTI. - * @constructor - * @param {jQuery} element DOM element with the lti container. - */ - this.LTI = function(element) { - var dataAttrs = $(element).find('.lti').data(), - askToSendUsername = (dataAttrs.askToSendUsername === 'True'), - askToSendEmail = (dataAttrs.askToSendEmail === 'True'); + const $lti = $(element).find('.lti'); + const askToSendUsername = $lti.data('ask-to-send-username') === 'True'; + const askToSendEmail = $lti.data('ask-to-send-email') === 'True'; - // When the lti button is clicked, provide users the option to - // accept or reject sending their information to a third party - $(element).on('click', '.link_lti_new_window', function() { - if (askToSendUsername && askToSendEmail) { - // eslint-disable-next-line no-alert - return confirm(gettext('Click OK to have your username and e-mail address sent to a 3rd party application.\n\nClick Cancel to return to this page without sending your information.')); - } else if (askToSendUsername) { - // eslint-disable-next-line no-alert - return confirm(gettext('Click OK to have your username sent to a 3rd party application.\n\nClick Cancel to return to this page without sending your information.')); - } else if (askToSendEmail) { - // eslint-disable-next-line no-alert - return confirm(gettext('Click OK to have your e-mail address sent to a 3rd party application.\n\nClick Cancel to return to this page without sending your information.')); - } else { - return true; - } - }); - }; -}).call(this); + // When the lti button is clicked, provide users the option to + // accept or reject sending their information to a third party + $(element).on('click', '.link_lti_new_window', function() { + if (askToSendUsername && askToSendEmail) { + return confirm('Click OK to have your username and e-mail address sent to a 3rd party application.\n\nClick Cancel to return to this page without sending your information.'); + } else if (askToSendUsername) { + return confirm('Click OK to have your username sent to a 3rd party application.\n\nClick Cancel to return to this page without sending your information.'); + } else if (askToSendEmail) { + return confirm('Click OK to have your e-mail address sent to a 3rd party application.\n\nClick Cancel to return to this page without sending your information.'); + } else { + return true; + } + }); +} diff --git a/xblocks_contrib/lti/templates/lti.html b/xblocks_contrib/lti/templates/lti.html index 73cc129..1f6ceef 100644 --- a/xblocks_contrib/lti/templates/lti.html +++ b/xblocks_contrib/lti/templates/lti.html @@ -1,70 +1,106 @@ {% load i18n %} -

- {# Translators: "External resource" means that this learning module is hosted on a platform external to the edX LMS #} - {{display_name}} ({% trans 'External resource' %}) -

+
+
-{% if has_score and weight %} -
- {% if module_score is not None %} - {# Translators: "points" is the student's achieved score on this LTI unit, and "total_points" is the maximum number of points achievable. #} - {% trans "points" as points_trans %} - {% trans "{points} / {total_points} points" as score_trans %} - ({{ module_score }} / {{ weight }} {{ points_trans }}) - {% else %} - {# Translators: "total_points" is the maximum number of points achievable on this LTI unit #} - {% trans "total_points" as total_points_trans %} - ({{ weight }} {{ total_points_trans }} possible) - {% endif %} -
-{% endif %} +
+ + + + + + + + + + + + + + + -
- -{% if launch_url and launch_url != 'http://www.example.com' and not hide_launch %} - {% if open_in_a_new_page %} - +

+ {# Translators: "External resource" means that this learning module is hosted on a platform external to the edX LMS #} + {{display_name}} ({% trans 'External resource' %}) +

+ + {% if has_score and weight %} +
+ {% if module_score is not None %} + {# Translators: "points" is the student's achieved score on this LTI unit, and "total_points" is the maximum number of points achievable. #} + {% trans "points" as points_trans %} + {% trans "{points} / {total_points} points" as score_trans %} + ({{ module_score }} / {{ weight }} {{ points_trans }}) + {% else %} + {# Translators: "total_points" is the maximum number of points achievable on this LTI unit #} + {% trans "total_points" as total_points_trans %} + ({{ weight }} {{ total_points_trans }} possible) {% endif %} -
- {% else %} - {# The result of the form submit will be rendered here. #} - {% endif %} -{% elif not hide_launch %} -

- {{ _('Please provide launch_url. Click "Edit", and fill in the required fields.') }} -

-{% endif %} - -{% if has_score and comment %} - - diff --git a/xblocks_contrib/lti/templates/lti_form.html b/xblocks_contrib/lti/templates/lti_form.html index 7c72487..5b7d12a 100644 --- a/xblocks_contrib/lti/templates/lti_form.html +++ b/xblocks_contrib/lti/templates/lti_form.html @@ -20,7 +20,7 @@ style="display:none;" > - {% for param_name, param_value in input_fields.items() %} + {% for param_name, param_value in input_fields.items %} {% endfor %} From 6f4740e8b9a1ff4137e00b136a276c77ca946925 Mon Sep 17 00:00:00 2001 From: Muhammad Tayayb Tahir Qureshi Date: Mon, 4 Nov 2024 11:42:56 +0500 Subject: [PATCH 03/34] test: add tests --- xblocks_contrib/lti/tests/helpers.py | 56 ++ xblocks_contrib/lti/tests/test_lti20_unit.py | 2 +- xblocks_contrib/lti/tests/test_lti_unit.py | 556 ++++++++++++++++++- 3 files changed, 594 insertions(+), 20 deletions(-) create mode 100644 xblocks_contrib/lti/tests/helpers.py diff --git a/xblocks_contrib/lti/tests/helpers.py b/xblocks_contrib/lti/tests/helpers.py new file mode 100644 index 0000000..e81e5ca --- /dev/null +++ b/xblocks_contrib/lti/tests/helpers.py @@ -0,0 +1,56 @@ +""" +Utility methods for unit tests. +""" + + +import pytest +from path import Path as path +from xblock.reference.user_service import UserService, XBlockUser + + +class StubUserService(UserService): + """ + Stub UserService for testing the sequence block. + """ + + def __init__(self, + user=None, + user_is_staff=False, + user_role=None, + anonymous_user_id=None, + deprecated_anonymous_user_id=None, + request_country_code=None, + **kwargs): + self.user = user + self.user_is_staff = user_is_staff + self.user_role = user_role + self.anonymous_user_id = anonymous_user_id + self.deprecated_anonymous_user_id = deprecated_anonymous_user_id + self.request_country_code = request_country_code + self._django_user = user + super().__init__(**kwargs) + + def get_current_user(self): + """ + Implements abstract method for getting the current user. + """ + user = XBlockUser() + if self.user and self.user.is_authenticated: + user.opt_attrs['edx-platform.anonymous_user_id'] = self.anonymous_user_id + user.opt_attrs['edx-platform.deprecated_anonymous_user_id'] = self.deprecated_anonymous_user_id + user.opt_attrs['edx-platform.request_country_code'] = self.request_country_code + user.opt_attrs['edx-platform.user_is_staff'] = self.user_is_staff + user.opt_attrs['edx-platform.user_id'] = self.user.id + user.opt_attrs['edx-platform.user_role'] = self.user_role + user.opt_attrs['edx-platform.username'] = self.user.username + else: + user.opt_attrs['edx-platform.username'] = 'anonymous' + user.opt_attrs['edx-platform.request_country_code'] = self.request_country_code + user.opt_attrs['edx-platform.is_authenticated'] = False + return user + + def get_user_by_anonymous_id(self, uid=None): # pylint: disable=unused-argument + """ + Return the original user passed into the service. + """ + return self.user diff --git a/xblocks_contrib/lti/tests/test_lti20_unit.py b/xblocks_contrib/lti/tests/test_lti20_unit.py index 6ade72e..1112f58 100644 --- a/xblocks_contrib/lti/tests/test_lti20_unit.py +++ b/xblocks_contrib/lti/tests/test_lti20_unit.py @@ -11,7 +11,7 @@ from lti_2_util import LTIError from lti import LTIBlock -from xmodule.tests.helpers import StubUserService +from .helpers import StubUserService from . import get_test_system diff --git a/xblocks_contrib/lti/tests/test_lti_unit.py b/xblocks_contrib/lti/tests/test_lti_unit.py index f4a4a53..6a300ec 100644 --- a/xblocks_contrib/lti/tests/test_lti_unit.py +++ b/xblocks_contrib/lti/tests/test_lti_unit.py @@ -1,26 +1,544 @@ -""" -Tests for LTIBlock -""" +"""Test for LTI Xmodule functional logic.""" -from django.test import TestCase + +import datetime +import textwrap +from copy import copy +from unittest.mock import Mock, PropertyMock, patch +from urllib import parse + + +import pytest +from django.conf import settings +from django.test import TestCase, override_settings +from lxml import etree +from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.locator import BlockUsageLocator +from pytz import UTC +from webob.request import Request +from xblock.field_data import DictFieldData from xblock.fields import ScopeIds -from xblock.test.toy_runtime import ToyRuntime -from xblocks_contrib import LTIBlock + +from common.djangoapps.xblock_django.constants import ATTR_KEY_ANONYMOUS_USER_ID +from xmodule.fields import Timedelta +from lti_2_util import LTIError +from lti import LTIBlock +from .helpers import StubUserService + +from . import get_test_system -class TestLTIBlock(TestCase): - """Tests for LTIBlock""" +@override_settings(LMS_BASE="edx.org") +class LTIBlockTest(TestCase): + """Logic tests for LTI block.""" - def test_my_student_view(self): - """Test the basic view loads.""" - scope_ids = ScopeIds("1", "2", "3", "4") - block = LTIBlock(ToyRuntime(), scope_ids=scope_ids) - frag = block.student_view() - as_dict = frag.to_dict() - content = as_dict["content"] - self.assertIn( - "LTIBlock: count is now", - content, - "XBlock did not render correct student view", + def setUp(self): + super().setUp() + self.environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} + self.request_body_xml_template = textwrap.dedent(""" + + + + + V1.0 + {messageIdentifier} + + + + <{action}> + + + {sourcedId} + + + + en-us + {grade} + + + + + + + """) + self.course_id = CourseKey.from_string('org/course/run') + self.runtime = get_test_system(self.course_id) + self.runtime.publish = Mock() + self.runtime._services['rebind_user'] = Mock() # pylint: disable=protected-access + + self.xblock = LTIBlock( + self.runtime, + DictFieldData({}), + ScopeIds(None, None, None, BlockUsageLocator(self.course_id, 'lti', 'name')) + ) + current_user = self.runtime.service(self.xblock, 'user').get_current_user() + self.user_id = current_user.opt_attrs.get(ATTR_KEY_ANONYMOUS_USER_ID) + self.lti_id = self.xblock.lti_id + + self.unquoted_resource_link_id = '{}-i4x-2-3-lti-31de800015cf4afb973356dbe81496df'.format( + settings.LMS_BASE ) + + sourced_id = ':'.join(parse.quote(i) for i in (self.lti_id, self.unquoted_resource_link_id, self.user_id)) # lint-amnesty, pylint: disable=line-too-long + + self.defaults = { + 'namespace': "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0", + 'sourcedId': sourced_id, + 'action': 'replaceResultRequest', + 'grade': 0.5, + 'messageIdentifier': '528243ba5241b', + } + + self.xblock.due = None + self.xblock.graceperiod = None + + def get_request_body(self, params=None): + """Fetches the body of a request specified by params""" + if params is None: + params = {} + data = copy(self.defaults) + + data.update(params) + return self.request_body_xml_template.format(**data).encode('utf-8') + + def get_response_values(self, response): + """Gets the values from the given response""" + parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') + root = etree.fromstring(response.body.strip(), parser=parser) + lti_spec_namespace = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0" + namespaces = {'def': lti_spec_namespace} + + code_major = root.xpath("//def:imsx_codeMajor", namespaces=namespaces)[0].text + description = root.xpath("//def:imsx_description", namespaces=namespaces)[0].text + message_identifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text + imsx_pox_body = root.xpath("//def:imsx_POXBody", namespaces=namespaces)[0] + + try: + action = imsx_pox_body.getchildren()[0].tag.replace('{' + lti_spec_namespace + '}', '') + except Exception: # pylint: disable=broad-except + action = None + + return { + 'code_major': code_major, + 'description': description, + 'messageIdentifier': message_identifier, + 'action': action + } + + @patch( + 'xmodule.lti_block.LTIBlock.get_client_key_secret', + return_value=('test_client_key', 'test_client_secret') + ) + def test_authorization_header_not_present(self, _get_key_secret): + """ + Request has no Authorization header. + + This is an unknown service request, i.e., it is not a part of the original service specification. + """ + request = Request(self.environ) + request.body = self.get_request_body() + response = self.xblock.grade_handler(request, '') + real_response = self.get_response_values(response) + expected_response = { + 'action': None, + 'code_major': 'failure', + 'description': 'OAuth verification error: Malformed authorization header', + 'messageIdentifier': self.defaults['messageIdentifier'], + } + + assert response.status_code == 200 + self.assertDictEqual(expected_response, real_response) + + @patch( + 'xmodule.lti_block.LTIBlock.get_client_key_secret', + return_value=('test_client_key', 'test_client_secret') + ) + def test_authorization_header_empty(self, _get_key_secret): + """ + Request Authorization header has no value. + + This is an unknown service request, i.e., it is not a part of the original service specification. + """ + request = Request(self.environ) + request.authorization = "bad authorization header" + request.body = self.get_request_body() + response = self.xblock.grade_handler(request, '') + real_response = self.get_response_values(response) + expected_response = { + 'action': None, + 'code_major': 'failure', + 'description': 'OAuth verification error: Malformed authorization header', + 'messageIdentifier': self.defaults['messageIdentifier'], + } + assert response.status_code == 200 + self.assertDictEqual(expected_response, real_response) + + def test_real_user_is_none(self): + """ + If we have no real user, we should send back failure response. + """ + self.runtime._services['user'] = StubUserService(user=None) # pylint: disable=protected-access + self.xblock.verify_oauth_body_sign = Mock() + self.xblock.has_score = True + request = Request(self.environ) + request.body = self.get_request_body() + response = self.xblock.grade_handler(request, '') + real_response = self.get_response_values(response) + expected_response = { + 'action': None, + 'code_major': 'failure', + 'description': 'User not found.', + 'messageIdentifier': self.defaults['messageIdentifier'], + } + assert response.status_code == 200 + self.assertDictEqual(expected_response, real_response) + + def test_grade_past_due(self): + """ + Should fail if we do not accept past due grades, and it is past due. + """ + self.xblock.accept_grades_past_due = False + self.xblock.due = datetime.datetime.now(UTC) + self.xblock.graceperiod = Timedelta().from_json("0 seconds") + request = Request(self.environ) + request.body = self.get_request_body() + response = self.xblock.grade_handler(request, '') + real_response = self.get_response_values(response) + expected_response = { + 'action': None, + 'code_major': 'failure', + 'description': 'Grade is past due', + 'messageIdentifier': 'unknown', + } + assert response.status_code == 200 + assert expected_response == real_response + + def test_grade_not_in_range(self): + """ + Grade returned from Tool Provider is outside the range 0.0-1.0. + """ + self.xblock.verify_oauth_body_sign = Mock() + request = Request(self.environ) + request.body = self.get_request_body(params={'grade': '10'}) + response = self.xblock.grade_handler(request, '') + real_response = self.get_response_values(response) + expected_response = { + 'action': None, + 'code_major': 'failure', + 'description': 'Request body XML parsing error: score value outside the permitted range of 0-1.', + 'messageIdentifier': 'unknown', + } + assert response.status_code == 200 + self.assertDictEqual(expected_response, real_response) + + def test_bad_grade_decimal(self): + """ + Grade returned from Tool Provider doesn't use a period as the decimal point. + """ + self.xblock.verify_oauth_body_sign = Mock() + request = Request(self.environ) + request.body = self.get_request_body(params={'grade': '0,5'}) + response = self.xblock.grade_handler(request, '') + real_response = self.get_response_values(response) + msg = "could not convert string to float: '0,5'" + expected_response = { + 'action': None, + 'code_major': 'failure', + 'description': f'Request body XML parsing error: {msg}', + 'messageIdentifier': 'unknown', + } + assert response.status_code == 200 + self.assertDictEqual(expected_response, real_response) + + def test_unsupported_action(self): + """ + Action returned from Tool Provider isn't supported. + `replaceResultRequest` is supported only. + """ + self.xblock.verify_oauth_body_sign = Mock() + request = Request(self.environ) + request.body = self.get_request_body({'action': 'wrongAction'}) + response = self.xblock.grade_handler(request, '') + real_response = self.get_response_values(response) + expected_response = { + 'action': None, + 'code_major': 'unsupported', + 'description': 'Target does not support the requested operation.', + 'messageIdentifier': self.defaults['messageIdentifier'], + } + assert response.status_code == 200 + self.assertDictEqual(expected_response, real_response) + + def test_good_request(self): + """ + Response from Tool Provider is correct. + """ + self.xblock.verify_oauth_body_sign = Mock() + self.xblock.has_score = True + request = Request(self.environ) + request.body = self.get_request_body() + response = self.xblock.grade_handler(request, '') + description_expected = 'Score for {sourcedId} is now {score}'.format( + sourcedId=self.defaults['sourcedId'], + score=self.defaults['grade'], + ) + real_response = self.get_response_values(response) + expected_response = { + 'action': 'replaceResultResponse', + 'code_major': 'success', + 'description': description_expected, + 'messageIdentifier': self.defaults['messageIdentifier'], + } + + assert response.status_code == 200 + self.assertDictEqual(expected_response, real_response) + assert self.xblock.module_score == float(self.defaults['grade']) + + def test_user_id(self): + expected_user_id = str(parse.quote(self.xblock.runtime.anonymous_student_id)) + real_user_id = self.xblock.get_user_id() + assert real_user_id == expected_user_id + + def test_outcome_service_url(self): + mock_url_prefix = 'https://hostname/' + test_service_name = "test_service" + + def mock_handler_url(block, handler_name, **kwargs): # pylint: disable=unused-argument + """Mock function for returning fully-qualified handler urls""" + return mock_url_prefix + handler_name + + self.xblock.runtime.handler_url = Mock(side_effect=mock_handler_url) + real_outcome_service_url = self.xblock.get_outcome_service_url(service_name=test_service_name) + assert real_outcome_service_url == (mock_url_prefix + test_service_name) + + def test_resource_link_id(self): + with patch('xmodule.lti_block.LTIBlock.location', new_callable=PropertyMock): + self.xblock.location.html_id = lambda: 'i4x-2-3-lti-31de800015cf4afb973356dbe81496df' + expected_resource_link_id = str(parse.quote(self.unquoted_resource_link_id)) + real_resource_link_id = self.xblock.get_resource_link_id() + assert real_resource_link_id == expected_resource_link_id + + def test_lis_result_sourcedid(self): + expected_sourced_id = ':'.join(parse.quote(i) for i in ( + str(self.course_id), + self.xblock.get_resource_link_id(), + self.user_id + )) + real_lis_result_sourcedid = self.xblock.get_lis_result_sourcedid() + assert real_lis_result_sourcedid == expected_sourced_id + + def test_client_key_secret(self): + """ + LTI block gets client key and secret provided. + """ + #this adds lti passports to system + mocked_course = Mock(lti_passports=['lti_id:test_client:test_secret']) + modulestore = Mock() + modulestore.get_course.return_value = mocked_course + runtime = Mock(modulestore=modulestore) + self.xblock.runtime = runtime + self.xblock.lti_id = "lti_id" + key, secret = self.xblock.get_client_key_secret() + expected = ('test_client', 'test_secret') + assert expected == (key, secret) + + def test_client_key_secret_not_provided(self): + """ + LTI block attempts to get client key and secret provided in cms. + + There are key and secret but not for specific LTI. + """ + + # this adds lti passports to system + mocked_course = Mock(lti_passports=['test_id:test_client:test_secret']) + modulestore = Mock() + modulestore.get_course.return_value = mocked_course + runtime = Mock(modulestore=modulestore) + self.xblock.runtime = runtime + # set another lti_id + self.xblock.lti_id = "another_lti_id" + key_secret = self.xblock.get_client_key_secret() + expected = ('', '') + assert expected == key_secret + + def test_bad_client_key_secret(self): + """ + LTI block attempts to get client key and secret provided in cms. + + There are key and secret provided in wrong format. + """ + # this adds lti passports to system + mocked_course = Mock(lti_passports=['test_id_test_client_test_secret']) + modulestore = Mock() + modulestore.get_course.return_value = mocked_course + runtime = Mock(modulestore=modulestore) + self.xblock.runtime = runtime + self.xblock.lti_id = 'lti_id' + with pytest.raises(LTIError): + self.xblock.get_client_key_secret() + + @patch('xmodule.lti_block.signature.verify_hmac_sha1', Mock(return_value=True)) + @patch( + 'xmodule.lti_block.LTIBlock.get_client_key_secret', + Mock(return_value=('test_client_key', 'test_client_secret')) + ) + def test_successful_verify_oauth_body_sign(self): + """ + Test if OAuth signing was successful. + """ + self.xblock.verify_oauth_body_sign(self.get_signed_grade_mock_request()) + + @patch('xmodule.lti_block.LTIBlock.get_outcome_service_url', Mock(return_value='https://testurl/')) + @patch('xmodule.lti_block.LTIBlock.get_client_key_secret', + Mock(return_value=('__consumer_key__', '__lti_secret__'))) + def test_failed_verify_oauth_body_sign_proxy_mangle_url(self): + """ + Oauth signing verify fail. + """ + request = self.get_signed_grade_mock_request_with_correct_signature() + self.xblock.verify_oauth_body_sign(request) + # we should verify against get_outcome_service_url not + # request url proxy and load balancer along the way may + # change url presented to the method + request.url = 'http://testurl/' + self.xblock.verify_oauth_body_sign(request) + + def get_signed_grade_mock_request_with_correct_signature(self): + """ + Generate a proper LTI request object + """ + mock_request = Mock() + mock_request.headers = { + 'X-Requested-With': 'XMLHttpRequest', + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': ( + 'OAuth realm="https://testurl/", oauth_body_hash="wwzA3s8gScKD1VpJ7jMt9b%2BMj9Q%3D",' + 'oauth_nonce="18821463", oauth_timestamp="1409321145", ' + 'oauth_consumer_key="__consumer_key__", oauth_signature_method="HMAC-SHA1", ' + 'oauth_version="1.0", oauth_signature="fHsE1hhIz76/msUoMR3Lyb7Aou4%3D"' + ) + } + mock_request.url = 'https://testurl' + mock_request.http_method = 'POST' + mock_request.method = mock_request.http_method + + mock_request.body = ( + b'\n' + b'' + b'V1.0' + b'edX_fix' + b'' + b'MITxLTI/MITxLTI/201x:localhost%3A8000-i4x-MITxLTI-MITxLTI-lti-3751833a214a4f66a0d18f63234207f2' + b':363979ef768ca171b50f9d1bfb322131' + b'en0.32' + b'' + ) + + return mock_request + + def test_wrong_xml_namespace(self): + """ + Test wrong XML Namespace. + + Tests that tool provider returned grade back with wrong XML Namespace. + """ + with pytest.raises(IndexError): + mocked_request = self.get_signed_grade_mock_request(namespace_lti_v1p1=False) + self.xblock.parse_grade_xml_body(mocked_request.body) + + def test_parse_grade_xml_body(self): + """ + Test XML request body parsing. + + Tests that xml body was parsed successfully. + """ + mocked_request = self.get_signed_grade_mock_request() + message_identifier, sourced_id, grade, action = self.xblock.parse_grade_xml_body(mocked_request.body) + assert self.defaults['messageIdentifier'] == message_identifier + assert self.defaults['sourcedId'] == sourced_id + assert self.defaults['grade'] == grade + assert self.defaults['action'] == action + + @patch('xmodule.lti_block.signature.verify_hmac_sha1', Mock(return_value=False)) + @patch( + 'xmodule.lti_block.LTIBlock.get_client_key_secret', + Mock(return_value=('test_client_key', 'test_client_secret')) + ) + def test_failed_verify_oauth_body_sign(self): + """ + Oauth signing verify fail. + """ + with pytest.raises(LTIError): + req = self.get_signed_grade_mock_request() + self.xblock.verify_oauth_body_sign(req) + + def get_signed_grade_mock_request(self, namespace_lti_v1p1=True): + """ + Example of signed request from LTI Provider. + + When `namespace_v1p0` is set to True then the default namespase from + LTI 1.1 will be used. Otherwise fake namespace will be added to XML. + """ + mock_request = Mock() + mock_request.headers = { + 'X-Requested-With': 'XMLHttpRequest', + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': 'OAuth oauth_nonce="135685044251684026041377608307", \ + oauth_timestamp="1234567890", oauth_version="1.0", \ + oauth_signature_method="HMAC-SHA1", \ + oauth_consumer_key="test_client_key", \ + oauth_signature="my_signature%3D", \ + oauth_body_hash="JEpIArlNCeV4ceXxric8gJQCnBw="' + } + mock_request.url = 'http://testurl' + mock_request.http_method = 'POST' + + params = {} + if not namespace_lti_v1p1: + params = { + 'namespace': "http://www.fakenamespace.com/fake" + } + mock_request.body = self.get_request_body(params) + + return mock_request + + def test_good_custom_params(self): + """ + Custom parameters are presented in right format. + """ + self.xblock.custom_parameters = ['test_custom_params=test_custom_param_value'] + self.xblock.get_client_key_secret = Mock(return_value=('test_client_key', 'test_client_secret')) + self.xblock.oauth_params = Mock() + self.xblock.get_input_fields() + self.xblock.oauth_params.assert_called_with( + {'custom_test_custom_params': 'test_custom_param_value'}, + 'test_client_key', 'test_client_secret' + ) + + def test_bad_custom_params(self): + """ + Custom parameters are presented in wrong format. + """ + bad_custom_params = ['test_custom_params: test_custom_param_value'] + self.xblock.custom_parameters = bad_custom_params + self.xblock.get_client_key_secret = Mock(return_value=('test_client_key', 'test_client_secret')) + self.xblock.oauth_params = Mock() + with pytest.raises(LTIError): + self.xblock.get_input_fields() + + def test_max_score(self): + self.xblock.weight = 100.0 + + assert not self.xblock.has_score + assert self.xblock.max_score() is None + + self.xblock.has_score = True + + assert self.xblock.max_score() == 100.0 + + def test_context_id(self): + """ + Tests that LTI parameter context_id is equal to course_id. + """ + assert str(self.course_id) == self.xblock.context_id From 9292c4e070968f95b44ff9fe63f1434f845e885e Mon Sep 17 00:00:00 2001 From: Muhammad Tayayb Tahir Qureshi Date: Wed, 13 Nov 2024 12:05:34 +0500 Subject: [PATCH 04/34] refactor: Remove dependencies from edx-platform in extracted LTI block - Defined constants for anonymous user ID and user role. - Replaced HTML/Text helpers with markupsafe. --- xblocks_contrib/lti/lti.py | 55 +++++++++++++++---------------- xblocks_contrib/lti/lti_2_util.py | 25 ++++++++++++-- 2 files changed, 49 insertions(+), 31 deletions(-) diff --git a/xblocks_contrib/lti/lti.py b/xblocks_contrib/lti/lti.py index 1295c2f..3ea3bb6 100644 --- a/xblocks_contrib/lti/lti.py +++ b/xblocks_contrib/lti/lti.py @@ -59,6 +59,7 @@ import datetime import hashlib import logging +import markupsafe import textwrap from xml.sax.saxutils import escape from unittest import mock @@ -81,13 +82,12 @@ from xblockutils.resources import ResourceLoader from xblockutils.studio_editable import StudioEditableXBlockMixin -from openedx.core.djangolib.markup import HTML, Text from .lti_2_util import LTI20BlockMixin, LTIError -from common.djangoapps.xblock_django.constants import ( - ATTR_KEY_ANONYMOUS_USER_ID, - ATTR_KEY_USER_ROLE, -) +# The anonymous user ID for the user in the course. +ATTR_KEY_ANONYMOUS_USER_ID = 'edx-platform.anonymous_user_id' +# The user's role in the course ('staff', 'instructor', or 'student'). +ATTR_KEY_USER_ROLE = 'edx-platform.user_role' resource_loader = ResourceLoader(__name__) @@ -228,15 +228,15 @@ class LTIBlock( lti_id = String( display_name=_("LTI ID"), - help=Text(_( + help=markupsafe.escape(_( "Enter the LTI ID for the external LTI provider. " "This value must be the same LTI ID that you entered in the " "LTI Passports setting on the Advanced Settings page." "{break_tag}See {docs_anchor_open}the edX LTI documentation{anchor_close} for more details on this setting." )).format( - break_tag=HTML(BREAK_TAG), - docs_anchor_open=HTML(DOCS_ANCHOR_TAG_OPEN), - anchor_close=HTML("") + break_tag=markupsafe.Markup(BREAK_TAG), + docs_anchor_open=markupsafe.Markup(DOCS_ANCHOR_TAG_OPEN), + anchor_close=markupsafe.Markup("") ), default='', scope=Scope.settings @@ -244,31 +244,31 @@ class LTIBlock( launch_url = String( display_name=_("LTI URL"), - help=Text(_( + help=markupsafe.escape(_( "Enter the URL of the external tool that this component launches. " "This setting is only used when Hide External Tool is set to False." "{break_tag}See {docs_anchor_open}the edX LTI documentation{anchor_close} for more details on this setting." )).format( - break_tag=HTML(BREAK_TAG), - docs_anchor_open=HTML(DOCS_ANCHOR_TAG_OPEN), - anchor_close=HTML("") + break_tag=markupsafe.Markup(BREAK_TAG), + docs_anchor_open=markupsafe.Markup(DOCS_ANCHOR_TAG_OPEN), + anchor_close=markupsafe.Markup("") ), default='http://www.example.com', scope=Scope.settings) - + custom_parameters = List( display_name=_("Custom Parameters"), - help=Text(_( + help=markupsafe.escape(_( "Add the key/value pair for any custom parameters, such as the page your e-book should open to or " "the background color for this component." "{break_tag}See {docs_anchor_open}the edX LTI documentation{anchor_close} for more details on this setting." )).format( - break_tag=HTML(BREAK_TAG), - docs_anchor_open=HTML(DOCS_ANCHOR_TAG_OPEN), - anchor_close=HTML("") + break_tag=markupsafe.Markup(BREAK_TAG), + docs_anchor_open=markupsafe.Markup(DOCS_ANCHOR_TAG_OPEN), + anchor_close=markupsafe.Markup("") ), scope=Scope.settings) - + open_in_a_new_page = Boolean( display_name=_("Open in New Page"), help=_( @@ -333,7 +333,7 @@ class LTIBlock( default=False, scope=Scope.settings ) - + ask_to_send_email = Boolean( display_name=_("Request user's email"), # Translators: This is used to request the user's email for a third party service. @@ -367,7 +367,7 @@ class LTIBlock( default=True, scope=Scope.settings ) - + editable_fields = ( "accept_grades_past_due", "button_text", "custom_parameters", "display_name", "hide_launch", "description", "lti_id", "launch_url", "open_in_a_new_page", @@ -959,12 +959,12 @@ def verify_oauth_body_sign(self, request, content_type='application/x-www-form-u if (not signature.verify_hmac_sha1(mock_request_lti_1, client_secret) and not signature.verify_hmac_sha1(mock_request_lti_2, client_secret)): - log.error("OAuth signature verification failed, for " - "headers:{} url:{} method:{}".format( - oauth_headers, - self.get_outcome_service_url(), - str(request.method) - )) + log.error( + "OAuth signature verification failed, for " + "headers:{} url:{} method:{}".format( + oauth_headers, self.get_outcome_service_url(), str(request.method) + ) + ) raise LTIError("OAuth signature verification has failed.") def get_client_key_secret(self): @@ -996,4 +996,3 @@ def is_past_due(self): else: close_date = due_date return close_date is not None and datetime.datetime.now(UTC) > close_date - diff --git a/xblocks_contrib/lti/lti_2_util.py b/xblocks_contrib/lti/lti_2_util.py index 11eba5e..f7d05a9 100644 --- a/xblocks_contrib/lti/lti_2_util.py +++ b/xblocks_contrib/lti/lti_2_util.py @@ -8,6 +8,7 @@ import hashlib import json import logging +import math import re from unittest import mock from urllib import parse @@ -17,8 +18,6 @@ from webob import Response from xblock.core import XBlock -from openedx.core.lib.grade_utils import round_away_from_zero - log = logging.getLogger(__name__) LTI_2_0_REST_SUFFIX_PARSER = re.compile(r"^user/(?P\w+)", re.UNICODE) @@ -154,6 +153,26 @@ def parse_lti_2_0_handler_suffix(self, suffix): log.info(f"[LTI]: {msg}") raise LTIError(msg) + def _round_away_from_zero(number, digits=0): + """ + Round numbers using the 'away from zero' strategy as opposed to the + 'Banker's rounding strategy.' The strategy refers to how we round when + a number is half way between two numbers. eg. 0.5, 1.5, etc. In python 3 + numbers round towards even. So 0.5 would round to 0 but 1.5 would round to 2. + + See here for more on floating point rounding strategies: + https://en.wikipedia.org/wiki/IEEE_754#Rounding_rules + + We want to continue to round away from zero so that student grades remain + consistent and don't suddenly change. + """ + p = 10.0 ** digits + + if number >= 0: + return float(math.floor((number * p) + 0.5)) / p + else: + return float(math.ceil((number * p) - 0.5)) / p + def _lti_2_0_result_get_handler(self, request, real_user): """ Helper request handler for GET requests to LTI 2.0 result endpoint @@ -176,7 +195,7 @@ def _lti_2_0_result_get_handler(self, request, real_user): return Response(json.dumps(base_json_obj).encode('utf-8'), content_type=LTI_2_0_JSON_CONTENT_TYPE) # Fall through to returning grade and comment - base_json_obj['resultScore'] = round_away_from_zero(self.module_score, 2) + base_json_obj['resultScore'] = self._round_away_from_zero(self.module_score, 2) base_json_obj['comment'] = self.score_comment return Response(json.dumps(base_json_obj).encode('utf-8'), content_type=LTI_2_0_JSON_CONTENT_TYPE) From 9154ed8d905e4b8cbd7b0efc3e92ef9119d28f77 Mon Sep 17 00:00:00 2001 From: Muhammad Tayayb Tahir Qureshi Date: Mon, 18 Nov 2024 13:03:32 +0500 Subject: [PATCH 05/34] test: update tests --- xblocks_contrib/lti/tests/__init__.py | 61 +++++++++++++++++++ xblocks_contrib/lti/tests/helpers.py | 58 +++++++++++++++++- ...{test_lti20_unit.py => test_lti_2_unit.py} | 4 +- xblocks_contrib/lti/tests/test_lti_unit.py | 9 +-- 4 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 xblocks_contrib/lti/tests/__init__.py rename xblocks_contrib/lti/tests/{test_lti20_unit.py => test_lti_2_unit.py} (99%) diff --git a/xblocks_contrib/lti/tests/__init__.py b/xblocks_contrib/lti/tests/__init__.py new file mode 100644 index 0000000..524024d --- /dev/null +++ b/xblocks_contrib/lti/tests/__init__.py @@ -0,0 +1,61 @@ +from opaque_keys.edx.keys import CourseKey + + +def get_test_system( + course_id=CourseKey.from_string('/'.join(['org', 'course', 'run'])), + user=None, + user_is_staff=False, + user_location=None, + render_template=None, + add_get_block_overrides=False +): + """ + Construct a test DescriptorSystem instance. + + By default, the descriptor system's render_template() method simply returns the repr of the + context it is passed. You can override this by passing in a different render_template argument. + """ + + id_manager = CourseLocationManager(course_id) + + descriptor_system = get_test_descriptor_system(id_reader=id_manager, id_generator=id_manager) + + if not user: + user = Mock(name='get_test_system.user', is_staff=False) + if not user_location: + user_location = Mock(name='get_test_system.user_location') + user_service = StubUserService( + user=user, + anonymous_user_id='student', + deprecated_anonymous_user_id='student', + user_is_staff=user_is_staff, + user_role='student', + request_country_code=user_location, + ) + + mako_service = StubMakoService(render_template=render_template) + + replace_url_service = StubReplaceURLService() + + def get_block(block): + """Mocks module_system get_block function""" + + prepare_block_runtime(block.runtime, add_overrides=add_get_block_overrides) + block.runtime.get_block_for_descriptor = get_block + block.bind_for_student(user.id) + + return block + + services = { + 'user': user_service, + 'mako': mako_service, + 'replace_urls': replace_url_service, + 'cache': CacheService(DoNothingCache()), + 'field-data': DictFieldData({}), + 'sandbox': SandboxService(contentstore, course_id), + } + + descriptor_system.get_block_for_descriptor = get_block # lint-amnesty, pylint: disable=attribute-defined-outside-init + descriptor_system._services.update(services) # lint-amnesty, pylint: disable=protected-access + + return descriptor_system \ No newline at end of file diff --git a/xblocks_contrib/lti/tests/helpers.py b/xblocks_contrib/lti/tests/helpers.py index e81e5ca..11745b3 100644 --- a/xblocks_contrib/lti/tests/helpers.py +++ b/xblocks_contrib/lti/tests/helpers.py @@ -2,12 +2,68 @@ Utility methods for unit tests. """ - +import datetime import pytest +import re from path import Path as path +from xblock.fields import JSONField from xblock.reference.user_service import UserService, XBlockUser +TIMEDELTA_REGEX = re.compile(r'^((?P\d+?) day(?:s?))?(\s)?((?P\d+?) hour(?:s?))?(\s)?((?P\d+?) minute(?:s)?)?(\s)?((?P\d+?) second(?:s)?)?$') # lint-amnesty, pylint: disable=line-too-long + + +class Timedelta(JSONField): # lint-amnesty, pylint: disable=missing-class-docstring + # Timedeltas are immutable, see http://docs.python.org/2/library/datetime.html#available-types + MUTABLE = False + + def from_json(self, time_str): # lint-amnesty, pylint: disable=arguments-differ + """ + time_str: A string with the following components: + day[s] (optional) + hour[s] (optional) + minute[s] (optional) + second[s] (optional) + + Returns a datetime.timedelta parsed from the string + """ + if time_str is None: + return None + + if isinstance(time_str, datetime.timedelta): + return time_str + + parts = TIMEDELTA_REGEX.match(time_str) + if not parts: + return + parts = parts.groupdict() + time_params = {} + for (name, param) in parts.items(): + if param: + time_params[name] = int(param) + return datetime.timedelta(**time_params) + + def to_json(self, value): + if value is None: + return None + + values = [] + for attr in ('days', 'hours', 'minutes', 'seconds'): + cur_value = getattr(value, attr, 0) + if cur_value > 0: + values.append("%d %s" % (cur_value, attr)) + return ' '.join(values) + + def enforce_type(self, value): + """ + Ensure that when set explicitly the Field is set to a timedelta + """ + if isinstance(value, datetime.timedelta) or value is None: + return value + + return self.from_json(value) + + class StubUserService(UserService): """ Stub UserService for testing the sequence block. diff --git a/xblocks_contrib/lti/tests/test_lti20_unit.py b/xblocks_contrib/lti/tests/test_lti_2_unit.py similarity index 99% rename from xblocks_contrib/lti/tests/test_lti20_unit.py rename to xblocks_contrib/lti/tests/test_lti_2_unit.py index 1112f58..7ede8ce 100644 --- a/xblocks_contrib/lti/tests/test_lti20_unit.py +++ b/xblocks_contrib/lti/tests/test_lti_2_unit.py @@ -9,8 +9,8 @@ from pytz import UTC from xblock.field_data import DictFieldData -from lti_2_util import LTIError -from lti import LTIBlock +from xblocks_contrib.lti.lti_2_util import LTIError +from xblocks_contrib.lti.lti import LTIBlock from .helpers import StubUserService from . import get_test_system diff --git a/xblocks_contrib/lti/tests/test_lti_unit.py b/xblocks_contrib/lti/tests/test_lti_unit.py index 6a300ec..95c5d69 100644 --- a/xblocks_contrib/lti/tests/test_lti_unit.py +++ b/xblocks_contrib/lti/tests/test_lti_unit.py @@ -7,7 +7,6 @@ from unittest.mock import Mock, PropertyMock, patch from urllib import parse - import pytest from django.conf import settings from django.test import TestCase, override_settings @@ -20,11 +19,9 @@ from xblock.fields import ScopeIds -from common.djangoapps.xblock_django.constants import ATTR_KEY_ANONYMOUS_USER_ID -from xmodule.fields import Timedelta -from lti_2_util import LTIError -from lti import LTIBlock -from .helpers import StubUserService +from xblocks_contrib.lti.lti_2_util import LTIError +from xblocks_contrib.lti.lti import LTIBlock, ATTR_KEY_ANONYMOUS_USER_ID +from .helpers import StubUserService, Timedelta from . import get_test_system From d0f5380d7f6e9ed11364c426ed57727334c79e47 Mon Sep 17 00:00:00 2001 From: Muhammad Tayayb Tahir Qureshi Date: Mon, 18 Nov 2024 14:40:37 +0500 Subject: [PATCH 06/34] chore: update requirements --- requirements/base.in | 3 +++ requirements/base.txt | 16 ++++++++++++++++ requirements/dev.txt | 19 +++++++++++++++++++ requirements/doc.txt | 11 +++++++++-- requirements/quality.txt | 17 +++++++++++++++++ requirements/test.txt | 16 ++++++++++++++++ 6 files changed, 80 insertions(+), 2 deletions(-) diff --git a/requirements/base.in b/requirements/base.in index 4ce83cb..ca1faca 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -5,3 +5,6 @@ django-statici18n edx-i18n-tools XBlock openedx-django-pyfs +nh3 +oauthlib +edx-opaque-keys diff --git a/requirements/base.txt b/requirements/base.txt index e9f3ab6..d7606ce 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -25,8 +25,12 @@ django-appconf==1.1.0 # via django-statici18n django-statici18n==2.6.0 # via -r requirements/base.in +dnspython==2.7.0 + # via pymongo edx-i18n-tools==1.6.3 # via -r requirements/base.in +edx-opaque-keys==2.11.0 + # via -r requirements/base.in fs==2.4.16 # via # fs-s3fs @@ -51,12 +55,20 @@ markupsafe==3.0.2 # via # mako # xblock +nh3==0.2.18 + # via -r requirements/base.in +oauthlib==3.2.2 + # via -r requirements/base.in openedx-django-pyfs==3.7.0 # via -r requirements/base.in path==16.16.0 # via edx-i18n-tools +pbr==6.1.0 + # via stevedore polib==1.2.0 # via edx-i18n-tools +pymongo==4.10.1 + # via edx-opaque-keys python-dateutil==2.9.0.post0 # via # botocore @@ -78,6 +90,10 @@ six==1.17.0 # python-dateutil sqlparse==0.5.3 # via django +stevedore==5.3.0 + # via edx-opaque-keys +typing-extensions==4.12.2 + # via edx-opaque-keys urllib3==2.2.3 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt diff --git a/requirements/dev.txt b/requirements/dev.txt index 37b6021..e2f4aaf 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -222,6 +222,14 @@ mdurl==0.1.2 # -r requirements/quality.txt # -r requirements/test.txt # markdown-it-py +nh3==0.2.18 + # via + # -r requirements/quality.txt + # -r requirements/test.txt +oauthlib==3.2.2 + # via + # -r requirements/quality.txt + # -r requirements/test.txt openedx-django-pyfs==3.7.0 # via # -r requirements/quality.txt @@ -296,6 +304,11 @@ pylint-plugin-utils==0.8.2 # -r requirements/quality.txt # pylint-celery # pylint-django +pymongo==4.10.1 + # via + # -r requirements/quality.txt + # -r requirements/test.txt + # edx-opaque-keys pypng==0.20220715.0 # via # -r requirements/quality.txt @@ -395,6 +408,7 @@ stevedore==5.4.0 # -r requirements/quality.txt # -r requirements/test.txt # code-annotations + # edx-opaque-keys text-unidecode==1.3 # via # -r requirements/quality.txt @@ -413,6 +427,11 @@ types-python-dateutil==2.9.0.20241206 # -r requirements/quality.txt # -r requirements/test.txt # arrow +typing-extensions==4.12.2 + # via + # -r requirements/quality.txt + # -r requirements/test.txt + # edx-opaque-keys urllib3==2.2.3 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt diff --git a/requirements/doc.txt b/requirements/doc.txt index 4cab958..7c7f033 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -51,8 +51,6 @@ certifi==2025.1.31 # via # -r requirements/test.txt # requests -cffi==1.17.1 - # via cryptography chardet==5.2.0 # via # -r requirements/test.txt @@ -102,6 +100,10 @@ django-appconf==1.1.0 # django-statici18n django-statici18n==2.6.0 # via -r requirements/test.txt +dnspython==2.7.0 + # via + # -r requirements/test.txt + # pymongo doc8==1.1.2 # via -r requirements/doc.in docutils==0.21.2 @@ -247,6 +249,10 @@ pygments==2.19.1 # readme-renderer # rich # sphinx +pymongo==4.10.1 + # via + # -r requirements/test.txt + # edx-opaque-keys pypng==0.20220715.0 # via # -r requirements/test.txt @@ -359,6 +365,7 @@ stevedore==5.4.0 # -r requirements/test.txt # code-annotations # doc8 + # edx-opaque-keys text-unidecode==1.3 # via # -r requirements/test.txt diff --git a/requirements/quality.txt b/requirements/quality.txt index 5a2af9a..06d38ea 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -96,6 +96,10 @@ django-appconf==1.1.0 # django-statici18n django-statici18n==2.6.0 # via -r requirements/test.txt +dnspython==2.7.0 + # via + # -r requirements/test.txt + # pymongo edx-i18n-tools==1.6.3 # via -r requirements/test.txt edx-lint==5.6.0 @@ -169,6 +173,10 @@ mdurl==0.1.2 # via # -r requirements/test.txt # markdown-it-py +nh3==0.2.18 + # via -r requirements/test.txt +oauthlib==3.2.2 + # via -r requirements/test.txt openedx-django-pyfs==3.7.0 # via -r requirements/test.txt packaging==24.2 @@ -222,6 +230,10 @@ pylint-plugin-utils==0.8.2 # via # pylint-celery # pylint-django +pymongo==4.10.1 + # via + # -r requirements/test.txt + # edx-opaque-keys pypng==0.20220715.0 # via # -r requirements/test.txt @@ -296,6 +308,7 @@ stevedore==5.4.0 # via # -r requirements/test.txt # code-annotations + # edx-opaque-keys text-unidecode==1.3 # via # -r requirements/test.txt @@ -308,6 +321,10 @@ types-python-dateutil==2.9.0.20241206 # via # -r requirements/test.txt # arrow +typing-extensions==4.12.2 + # via + # -r requirements/test.txt + # edx-opaque-keys urllib3==2.2.3 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt diff --git a/requirements/test.txt b/requirements/test.txt index f2a3a29..0b61601 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -63,6 +63,10 @@ django-appconf==1.1.0 # django-statici18n django-statici18n==2.6.0 # via -r requirements/base.txt +dnspython==2.7.0 + # via + # -r requirements/base.txt + # pymongo edx-i18n-tools==1.6.3 # via -r requirements/base.txt filelock==3.17.0 @@ -118,6 +122,10 @@ markupsafe==3.0.2 # xblock mdurl==0.1.2 # via markdown-it-py +nh3==0.2.18 + # via -r requirements/base.txt +oauthlib==3.2.2 + # via -r requirements/base.txt openedx-django-pyfs==3.7.0 # via -r requirements/base.txt packaging==24.2 @@ -145,6 +153,10 @@ polib==1.2.0 # edx-i18n-tools pygments==2.19.1 # via rich +pymongo==4.10.1 + # via + # -r requirements/base.txt + # edx-opaque-keys pypng==0.20220715.0 # via xblock-sdk pyproject-api==1.9.0 @@ -211,6 +223,10 @@ tox==4.24.1 # via -r requirements/test.in types-python-dateutil==2.9.0.20241206 # via arrow +typing-extensions==4.12.2 + # via + # -r requirements/base.txt + # edx-opaque-keys urllib3==2.2.3 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt From d09bed99744e6fd3af1d60a182d3cf08427bcbc9 Mon Sep 17 00:00:00 2001 From: Muhammad Tayayb Tahir Qureshi Date: Thu, 21 Nov 2024 15:02:38 +0500 Subject: [PATCH 07/34] fix: fix tests --- xblocks_contrib/lti/lti_2_util.py | 2 +- xblocks_contrib/lti/tests/__init__.py | 61 ------------------- xblocks_contrib/lti/tests/helpers.py | 63 +++++++++++++++++++- xblocks_contrib/lti/tests/test_lti_2_unit.py | 4 +- xblocks_contrib/lti/tests/test_lti_unit.py | 24 ++++---- 5 files changed, 75 insertions(+), 79 deletions(-) diff --git a/xblocks_contrib/lti/lti_2_util.py b/xblocks_contrib/lti/lti_2_util.py index f7d05a9..70823e8 100644 --- a/xblocks_contrib/lti/lti_2_util.py +++ b/xblocks_contrib/lti/lti_2_util.py @@ -153,7 +153,7 @@ def parse_lti_2_0_handler_suffix(self, suffix): log.info(f"[LTI]: {msg}") raise LTIError(msg) - def _round_away_from_zero(number, digits=0): + def _round_away_from_zero(self, number, digits=0): """ Round numbers using the 'away from zero' strategy as opposed to the 'Banker's rounding strategy.' The strategy refers to how we round when diff --git a/xblocks_contrib/lti/tests/__init__.py b/xblocks_contrib/lti/tests/__init__.py index 524024d..e69de29 100644 --- a/xblocks_contrib/lti/tests/__init__.py +++ b/xblocks_contrib/lti/tests/__init__.py @@ -1,61 +0,0 @@ -from opaque_keys.edx.keys import CourseKey - - -def get_test_system( - course_id=CourseKey.from_string('/'.join(['org', 'course', 'run'])), - user=None, - user_is_staff=False, - user_location=None, - render_template=None, - add_get_block_overrides=False -): - """ - Construct a test DescriptorSystem instance. - - By default, the descriptor system's render_template() method simply returns the repr of the - context it is passed. You can override this by passing in a different render_template argument. - """ - - id_manager = CourseLocationManager(course_id) - - descriptor_system = get_test_descriptor_system(id_reader=id_manager, id_generator=id_manager) - - if not user: - user = Mock(name='get_test_system.user', is_staff=False) - if not user_location: - user_location = Mock(name='get_test_system.user_location') - user_service = StubUserService( - user=user, - anonymous_user_id='student', - deprecated_anonymous_user_id='student', - user_is_staff=user_is_staff, - user_role='student', - request_country_code=user_location, - ) - - mako_service = StubMakoService(render_template=render_template) - - replace_url_service = StubReplaceURLService() - - def get_block(block): - """Mocks module_system get_block function""" - - prepare_block_runtime(block.runtime, add_overrides=add_get_block_overrides) - block.runtime.get_block_for_descriptor = get_block - block.bind_for_student(user.id) - - return block - - services = { - 'user': user_service, - 'mako': mako_service, - 'replace_urls': replace_url_service, - 'cache': CacheService(DoNothingCache()), - 'field-data': DictFieldData({}), - 'sandbox': SandboxService(contentstore, course_id), - } - - descriptor_system.get_block_for_descriptor = get_block # lint-amnesty, pylint: disable=attribute-defined-outside-init - descriptor_system._services.update(services) # lint-amnesty, pylint: disable=protected-access - - return descriptor_system \ No newline at end of file diff --git a/xblocks_contrib/lti/tests/helpers.py b/xblocks_contrib/lti/tests/helpers.py index 11745b3..d9d8dca 100644 --- a/xblocks_contrib/lti/tests/helpers.py +++ b/xblocks_contrib/lti/tests/helpers.py @@ -3,11 +3,13 @@ """ import datetime -import pytest import re -from path import Path as path +from unittest.mock import Mock +from opaque_keys.edx.keys import CourseKey from xblock.fields import JSONField from xblock.reference.user_service import UserService, XBlockUser +from xblock.reference.plugins import NO_CACHE_VALUE +from xblock.runtime import Runtime TIMEDELTA_REGEX = re.compile(r'^((?P\d+?) day(?:s?))?(\s)?((?P\d+?) hour(?:s?))?(\s)?((?P\d+?) minute(?:s)?)?(\s)?((?P\d+?) second(?:s)?)?$') # lint-amnesty, pylint: disable=line-too-long @@ -110,3 +112,60 @@ def get_user_by_anonymous_id(self, uid=None): # pylint: disable=unused-argument Return the original user passed into the service. """ return self.user + + +class MockRuntime(Runtime): + """A mock implementation of the Runtime class for testing purposes.""" + + def __init__(self, anonymous_student_id, services=None): + # id_reader and id_generator are required by Runtime. + super().__init__(id_reader=lambda: None, id_generator=lambda: None, services=services) + self.anonymous_student_id = anonymous_student_id + + def handler_url(self, block, handler_name, suffix="", thirdparty=False): + return f"/mock_url/{handler_name}" + + def local_resource_url(self, block, resource): + return f"/mock_resource_url/{resource}" + + def resource_url(self, resource): + return f"/mock_resource/{resource}" + + def publish(self, block, event_type, event_data): + pass + + +def get_test_system( + course_id=CourseKey.from_string("/".join(["org", "course", "run"])), + user=None, + user_is_staff=False, + user_location=None, +): + """Construct a minimal test system for the LTIBlockTest.""" + # course_id = course_id or CourseKey.from_string("org/course/run") + # user = user or Mock(id="student", is_staff=user_is_staff) + + if not user: + user = Mock(name='get_test_system.user', is_staff=False) + if not user_location: + user_location = Mock(name='get_test_system.user_location') + user_service = StubUserService( + user=user, + anonymous_user_id='student', + deprecated_anonymous_user_id='student', + user_is_staff=user_is_staff, + user_role='student', + request_country_code=user_location, + ) + runtime = MockRuntime( + anonymous_student_id="student", + services={ + "user": user_service, + } + ) + + # Add necessary mocks + runtime.publish = Mock(name="publish") + runtime._services["rebind_user"] = Mock(name="rebind_user") + + return runtime diff --git a/xblocks_contrib/lti/tests/test_lti_2_unit.py b/xblocks_contrib/lti/tests/test_lti_2_unit.py index 7ede8ce..b45e667 100644 --- a/xblocks_contrib/lti/tests/test_lti_2_unit.py +++ b/xblocks_contrib/lti/tests/test_lti_2_unit.py @@ -11,9 +11,7 @@ from xblocks_contrib.lti.lti_2_util import LTIError from xblocks_contrib.lti.lti import LTIBlock -from .helpers import StubUserService - -from . import get_test_system +from .helpers import StubUserService, get_test_system class LTI20RESTResultServiceTest(unittest.TestCase): diff --git a/xblocks_contrib/lti/tests/test_lti_unit.py b/xblocks_contrib/lti/tests/test_lti_unit.py index 95c5d69..4ac3a8f 100644 --- a/xblocks_contrib/lti/tests/test_lti_unit.py +++ b/xblocks_contrib/lti/tests/test_lti_unit.py @@ -20,10 +20,10 @@ from xblocks_contrib.lti.lti_2_util import LTIError -from xblocks_contrib.lti.lti import LTIBlock, ATTR_KEY_ANONYMOUS_USER_ID -from .helpers import StubUserService, Timedelta +from xblocks_contrib.lti.lti import LTIBlock +from .helpers import StubUserService, Timedelta, get_test_system -from . import get_test_system +ATTR_KEY_ANONYMOUS_USER_ID = 'edx-platform.anonymous_user_id' @override_settings(LMS_BASE="edx.org") @@ -124,7 +124,7 @@ def get_response_values(self, response): } @patch( - 'xmodule.lti_block.LTIBlock.get_client_key_secret', + 'xblocks_contrib.lti.lti.LTIBlock.get_client_key_secret', return_value=('test_client_key', 'test_client_secret') ) def test_authorization_header_not_present(self, _get_key_secret): @@ -148,7 +148,7 @@ def test_authorization_header_not_present(self, _get_key_secret): self.assertDictEqual(expected_response, real_response) @patch( - 'xmodule.lti_block.LTIBlock.get_client_key_secret', + 'xblocks_contrib.lti.lti.LTIBlock.get_client_key_secret', return_value=('test_client_key', 'test_client_secret') ) def test_authorization_header_empty(self, _get_key_secret): @@ -310,7 +310,7 @@ def mock_handler_url(block, handler_name, **kwargs): # pylint: disable=unused-a assert real_outcome_service_url == (mock_url_prefix + test_service_name) def test_resource_link_id(self): - with patch('xmodule.lti_block.LTIBlock.location', new_callable=PropertyMock): + with patch('xblocks_contrib.lti.lti.LTIBlock.location', new_callable=PropertyMock): self.xblock.location.html_id = lambda: 'i4x-2-3-lti-31de800015cf4afb973356dbe81496df' expected_resource_link_id = str(parse.quote(self.unquoted_resource_link_id)) real_resource_link_id = self.xblock.get_resource_link_id() @@ -375,9 +375,9 @@ def test_bad_client_key_secret(self): with pytest.raises(LTIError): self.xblock.get_client_key_secret() - @patch('xmodule.lti_block.signature.verify_hmac_sha1', Mock(return_value=True)) + @patch('xblocks_contrib.lti.lti.signature.verify_hmac_sha1', Mock(return_value=True)) @patch( - 'xmodule.lti_block.LTIBlock.get_client_key_secret', + 'xblocks_contrib.lti.lti.LTIBlock.get_client_key_secret', Mock(return_value=('test_client_key', 'test_client_secret')) ) def test_successful_verify_oauth_body_sign(self): @@ -386,8 +386,8 @@ def test_successful_verify_oauth_body_sign(self): """ self.xblock.verify_oauth_body_sign(self.get_signed_grade_mock_request()) - @patch('xmodule.lti_block.LTIBlock.get_outcome_service_url', Mock(return_value='https://testurl/')) - @patch('xmodule.lti_block.LTIBlock.get_client_key_secret', + @patch('xblocks_contrib.lti.lti.LTIBlock.get_outcome_service_url', Mock(return_value='https://testurl/')) + @patch('xblocks_contrib.lti.lti.LTIBlock.get_client_key_secret', Mock(return_value=('__consumer_key__', '__lti_secret__'))) def test_failed_verify_oauth_body_sign_proxy_mangle_url(self): """ @@ -457,9 +457,9 @@ def test_parse_grade_xml_body(self): assert self.defaults['grade'] == grade assert self.defaults['action'] == action - @patch('xmodule.lti_block.signature.verify_hmac_sha1', Mock(return_value=False)) + @patch('xblocks_contrib.lti.lti.signature.verify_hmac_sha1', Mock(return_value=False)) @patch( - 'xmodule.lti_block.LTIBlock.get_client_key_secret', + 'xblocks_contrib.lti.lti.LTIBlock.get_client_key_secret', Mock(return_value=('test_client_key', 'test_client_secret')) ) def test_failed_verify_oauth_body_sign(self): From c024f368ce7374c3585f91341f2a2dd30d3dc726 Mon Sep 17 00:00:00 2001 From: Muhammad Tayayb Tahir Qureshi Date: Mon, 25 Nov 2024 14:09:33 +0500 Subject: [PATCH 08/34] fix: fix --- xblocks_contrib/lti/lti.py | 21 ++ xblocks_contrib/lti/tests/helpers.py | 10 - ...{test_lti_2_unit.py => test_lti20_unit.py} | 126 ++++--- xblocks_contrib/lti/tests/test_lti_unit.py | 316 ++++++++++-------- 4 files changed, 284 insertions(+), 189 deletions(-) rename xblocks_contrib/lti/tests/{test_lti_2_unit.py => test_lti20_unit.py} (81%) diff --git a/xblocks_contrib/lti/lti.py b/xblocks_contrib/lti/lti.py index 3ea3bb6..48b8bbd 100644 --- a/xblocks_contrib/lti/lti.py +++ b/xblocks_contrib/lti/lti.py @@ -70,6 +70,7 @@ from django.conf import settings from lxml import etree from oauthlib.oauth1.rfc5849 import signature +from opaque_keys.edx.keys import UsageKey from pytz import UTC from webob import Response from web_fragments.fragment import Fragment @@ -374,6 +375,26 @@ class LTIBlock( "ask_to_send_email", "ask_to_send_username", "has_score", "weight", ) + @property + def course_id(self): + return self.location.course_key + + @property + def category(self): + return self.scope_ids.block_type + + @property + def location(self): + return self.scope_ids.usage_id + + @location.setter + def location(self, value): + assert isinstance(value, UsageKey) + self.scope_ids = self.scope_ids._replace( + def_id=value, # Note: assigning a UsageKey as def_id is OK in old mongo / import system but wrong in split + usage_id=value, + ) + def max_score(self): return self.weight if self.has_score else None diff --git a/xblocks_contrib/lti/tests/helpers.py b/xblocks_contrib/lti/tests/helpers.py index d9d8dca..a4863eb 100644 --- a/xblocks_contrib/lti/tests/helpers.py +++ b/xblocks_contrib/lti/tests/helpers.py @@ -139,23 +139,17 @@ def get_test_system( course_id=CourseKey.from_string("/".join(["org", "course", "run"])), user=None, user_is_staff=False, - user_location=None, ): """Construct a minimal test system for the LTIBlockTest.""" - # course_id = course_id or CourseKey.from_string("org/course/run") - # user = user or Mock(id="student", is_staff=user_is_staff) if not user: user = Mock(name='get_test_system.user', is_staff=False) - if not user_location: - user_location = Mock(name='get_test_system.user_location') user_service = StubUserService( user=user, anonymous_user_id='student', deprecated_anonymous_user_id='student', user_is_staff=user_is_staff, user_role='student', - request_country_code=user_location, ) runtime = MockRuntime( anonymous_student_id="student", @@ -164,8 +158,4 @@ def get_test_system( } ) - # Add necessary mocks - runtime.publish = Mock(name="publish") - runtime._services["rebind_user"] = Mock(name="rebind_user") - return runtime diff --git a/xblocks_contrib/lti/tests/test_lti_2_unit.py b/xblocks_contrib/lti/tests/test_lti20_unit.py similarity index 81% rename from xblocks_contrib/lti/tests/test_lti_2_unit.py rename to xblocks_contrib/lti/tests/test_lti20_unit.py index b45e667..85f9641 100644 --- a/xblocks_contrib/lti/tests/test_lti_2_unit.py +++ b/xblocks_contrib/lti/tests/test_lti20_unit.py @@ -1,12 +1,12 @@ """Tests for LTI Xmodule LTIv2.0 functional logic.""" - import datetime import textwrap -import unittest from unittest.mock import Mock from pytz import UTC +from django.conf import settings +from django.test import TestCase, override_settings from xblock.field_data import DictFieldData from xblocks_contrib.lti.lti_2_util import LTIError @@ -14,7 +14,8 @@ from .helpers import StubUserService, get_test_system -class LTI20RESTResultServiceTest(unittest.TestCase): +@override_settings(LMS_BASE="edx.org") +class LTI20RESTResultServiceTest(TestCase): """Logic tests for LTI block. LTI2.0 REST ResultService""" USER_STANDIN = Mock() @@ -23,20 +24,27 @@ class LTI20RESTResultServiceTest(unittest.TestCase): def setUp(self): super().setUp() self.runtime = get_test_system(user=self.USER_STANDIN) - self.environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} + self.environ = {"wsgi.url_scheme": "http", "REQUEST_METHOD": "POST"} self.runtime.publish = Mock() - self.runtime._services['rebind_user'] = Mock() # pylint: disable=protected-access + self.runtime._services["rebind_user"] = Mock() # pylint: disable=protected-access self.xblock = LTIBlock(self.runtime, DictFieldData({}), Mock()) self.lti_id = self.xblock.lti_id + + self.unquoted_resource_link_id = ( + "{}-i4x-2-3-lti-31de800015cf4afb973356dbe81496df".format(settings.LMS_BASE) + ) + self.xblock.due = None self.xblock.graceperiod = None def test_sanitize_get_context(self): """Tests that the get_context function does basic sanitization""" # get_context, unfortunately, requires a lot of mocking machinery - mocked_course = Mock(name='mocked_course', lti_passports=['lti_id:test_client:test_secret']) - modulestore = Mock(name='modulestore') + mocked_course = Mock( + name="mocked_course", lti_passports=["lti_id:test_client:test_secret"] + ) + modulestore = Mock(name="modulestore") modulestore.get_course.return_value = mocked_course self.xblock.runtime.modulestore = modulestore self.xblock.lti_id = "lti_id" @@ -48,14 +56,14 @@ def test_sanitize_get_context(self): ) for case in test_cases: self.xblock.score_comment = case[0] - assert case[1] == self.xblock.get_context()['comment'] + assert case[1] == self.xblock.get_context()["comment"] def test_lti20_rest_bad_contenttype(self): """ Input with bad content type """ with self.assertRaisesRegex(LTIError, "Content-Type must be"): - request = Mock(headers={'Content-Type': 'Non-existent'}) + request = Mock(headers={"Content-Type": "Non-existent"}) self.xblock.verify_lti_2_0_result_rest_headers(request) def test_lti20_rest_failed_oauth_body_verify(self): @@ -65,7 +73,9 @@ def test_lti20_rest_failed_oauth_body_verify(self): err_msg = "OAuth body verification failed" self.xblock.verify_oauth_body_sign = Mock(side_effect=LTIError(err_msg)) with self.assertRaisesRegex(LTIError, err_msg): - request = Mock(headers={'Content-Type': 'application/vnd.ims.lis.v2.result+json'}) + request = Mock( + headers={"Content-Type": "application/vnd.ims.lis.v2.result+json"} + ) self.xblock.verify_lti_2_0_result_rest_headers(request) def test_lti20_rest_good_headers(self): @@ -74,7 +84,9 @@ def test_lti20_rest_good_headers(self): """ self.xblock.verify_oauth_body_sign = Mock(return_value=True) - request = Mock(headers={'Content-Type': 'application/vnd.ims.lis.v2.result+json'}) + request = Mock( + headers={"Content-Type": "application/vnd.ims.lis.v2.result+json"} + ) self.xblock.verify_lti_2_0_result_rest_headers(request) # We just want the above call to complete without exceptions, and to have called verify_oauth_body_sign assert self.xblock.verify_oauth_body_sign.called @@ -88,7 +100,7 @@ def test_lti20_rest_good_headers(self): "user//" "user/gbere/" "user/gbere/xsdf" - "user/ಠ益ಠ" # not alphanumeric + "user/ಠ益ಠ", # not alphanumeric ] def test_lti20_rest_bad_dispatch(self): @@ -202,7 +214,7 @@ def test_lti20_good_json(self): "comment": "ಠ益ಠ"} """).encode('utf-8') - def get_signed_lti20_mock_request(self, body, method='PUT'): + def get_signed_lti20_mock_request(self, body, method="PUT"): """ Example of signed from LTI 2.0 Provider. Signatures and hashes are example only and won't verify """ @@ -216,9 +228,9 @@ def get_signed_lti20_mock_request(self, body, method='PUT'): 'oauth_consumer_key="test_client_key", ' 'oauth_signature="my_signature%3D", ' 'oauth_body_hash="gz+PeJZuF2//n9hNUnDj2v5kN70="' - ) + ), } - mock_request.url = 'http://testurl' + mock_request.url = "http://testurl" mock_request.http_method = method mock_request.method = method mock_request.body = body @@ -229,7 +241,9 @@ def setup_system_xblock_mocks_for_lti20_request_test(self): Helper fn to set up mocking for lti 2.0 request test """ self.xblock.max_score = Mock(return_value=1.0) - self.xblock.get_client_key_secret = Mock(return_value=('test_client_key', 'test_client_secret')) + self.xblock.get_client_key_secret = Mock( + return_value=("test_client_key", "test_client_secret") + ) self.xblock.verify_oauth_body_sign = Mock() def test_lti20_put_like_delete_success(self): @@ -241,17 +255,25 @@ def test_lti20_put_like_delete_success(self): COMMENT = "ಠ益ಠ" # pylint: disable=invalid-name self.xblock.module_score = SCORE self.xblock.score_comment = COMMENT - mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT_LIKE_DELETE) + mock_request = self.get_signed_lti20_mock_request( + self.GOOD_JSON_PUT_LIKE_DELETE + ) # Now call the handler response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") # Now assert there's no score assert response.status_code == 200 assert self.xblock.module_score is None - assert self.xblock.score_comment == '' - (_, evt_type, called_grade_obj), _ = self.runtime.publish.call_args # pylint: disable=unpacking-non-sequence - assert called_grade_obj ==\ - {'user_id': self.USER_STANDIN.id, 'value': None, 'max_value': None, 'score_deleted': True} - assert evt_type == 'grade' + assert self.xblock.score_comment == "" + (_, evt_type, called_grade_obj), _ = ( + self.runtime.publish.call_args + ) # pylint: disable=unpacking-non-sequence + assert called_grade_obj == { + "user_id": self.USER_STANDIN.id, + "value": None, + "max_value": None, + "score_deleted": True, + } + assert evt_type == "grade" def test_lti20_delete_success(self): """ @@ -262,17 +284,23 @@ def test_lti20_delete_success(self): COMMENT = "ಠ益ಠ" # pylint: disable=invalid-name self.xblock.module_score = SCORE self.xblock.score_comment = COMMENT - mock_request = self.get_signed_lti20_mock_request(b"", method='DELETE') + mock_request = self.get_signed_lti20_mock_request(b"", method="DELETE") # Now call the handler response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") # Now assert there's no score assert response.status_code == 200 assert self.xblock.module_score is None - assert self.xblock.score_comment == '' - (_, evt_type, called_grade_obj), _ = self.runtime.publish.call_args # pylint: disable=unpacking-non-sequence - assert called_grade_obj ==\ - {'user_id': self.USER_STANDIN.id, 'value': None, 'max_value': None, 'score_deleted': True} - assert evt_type == 'grade' + assert self.xblock.score_comment == "" + (_, evt_type, called_grade_obj), _ = ( + self.runtime.publish.call_args + ) # pylint: disable=unpacking-non-sequence + assert called_grade_obj == { + "user_id": self.USER_STANDIN.id, + "value": None, + "max_value": None, + "score_deleted": True, + } + assert evt_type == "grade" def test_lti20_put_set_score_success(self): """ @@ -285,23 +313,32 @@ def test_lti20_put_set_score_success(self): # Now assert assert response.status_code == 200 assert self.xblock.module_score == 0.1 - assert self.xblock.score_comment == 'ಠ益ಠ' - (_, evt_type, called_grade_obj), _ = self.runtime.publish.call_args # pylint: disable=unpacking-non-sequence - assert evt_type == 'grade' - assert called_grade_obj ==\ - {'user_id': self.USER_STANDIN.id, 'value': 0.1, 'max_value': 1.0, 'score_deleted': False} + assert self.xblock.score_comment == "ಠ益ಠ" + (_, evt_type, called_grade_obj), _ = ( + self.runtime.publish.call_args + ) # pylint: disable=unpacking-non-sequence + assert evt_type == "grade" + assert called_grade_obj == { + "user_id": self.USER_STANDIN.id, + "value": 0.1, + "max_value": 1.0, + "score_deleted": False, + } def test_lti20_get_no_score_success(self): """ The happy path for LTI 2.0 GET when there's no score """ self.setup_system_xblock_mocks_for_lti20_request_test() - mock_request = self.get_signed_lti20_mock_request(b"", method='GET') + mock_request = self.get_signed_lti20_mock_request(b"", method="GET") # Now call the handler response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") # Now assert assert response.status_code == 200 - assert response.json == {'@context': 'http://purl.imsglobal.org/ctx/lis/v2/Result', '@type': 'Result'} + assert response.json == { + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "@type": "Result", + } def test_lti20_get_with_score_success(self): """ @@ -312,14 +349,17 @@ def test_lti20_get_with_score_success(self): COMMENT = "ಠ益ಠ" # pylint: disable=invalid-name self.xblock.module_score = SCORE self.xblock.score_comment = COMMENT - mock_request = self.get_signed_lti20_mock_request(b"", method='GET') + mock_request = self.get_signed_lti20_mock_request(b"", method="GET") # Now call the handler response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") # Now assert assert response.status_code == 200 - assert response.json ==\ - {'@context': 'http://purl.imsglobal.org/ctx/lis/v2/Result', - '@type': 'Result', 'resultScore': SCORE, 'comment': COMMENT} + assert response.json == { + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "@type": "Result", + "resultScore": SCORE, + "comment": COMMENT, + } UNSUPPORTED_HTTP_METHODS = ["OPTIONS", "HEAD", "POST", "TRACE", "CONNECT"] @@ -331,7 +371,9 @@ def test_lti20_unsupported_method_error(self): mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) for bad_method in self.UNSUPPORTED_HTTP_METHODS: mock_request.method = bad_method - response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") + response = self.xblock.lti_2_0_result_rest_handler( + mock_request, "user/abcd" + ) assert response.status_code == 404 def test_lti20_request_handler_bad_headers(self): @@ -368,7 +410,9 @@ def test_lti20_request_handler_bad_user(self): Test that we get a 404 when the supplied user does not exist """ self.setup_system_xblock_mocks_for_lti20_request_test() - self.runtime._services['user'] = StubUserService(user=None) # pylint: disable=protected-access + self.runtime._services["user"] = StubUserService( + user=None + ) # pylint: disable=protected-access mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") assert response.status_code == 404 diff --git a/xblocks_contrib/lti/tests/test_lti_unit.py b/xblocks_contrib/lti/tests/test_lti_unit.py index 4ac3a8f..5ad3ea5 100644 --- a/xblocks_contrib/lti/tests/test_lti_unit.py +++ b/xblocks_contrib/lti/tests/test_lti_unit.py @@ -1,6 +1,5 @@ """Test for LTI Xmodule functional logic.""" - import datetime import textwrap from copy import copy @@ -23,7 +22,7 @@ from xblocks_contrib.lti.lti import LTIBlock from .helpers import StubUserService, Timedelta, get_test_system -ATTR_KEY_ANONYMOUS_USER_ID = 'edx-platform.anonymous_user_id' +ATTR_KEY_ANONYMOUS_USER_ID = "edx-platform.anonymous_user_id" @override_settings(LMS_BASE="edx.org") @@ -32,8 +31,9 @@ class LTIBlockTest(TestCase): def setUp(self): super().setUp() - self.environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} - self.request_body_xml_template = textwrap.dedent(""" + self.environ = {"wsgi.url_scheme": "http", "REQUEST_METHOD": "POST"} + self.request_body_xml_template = textwrap.dedent( + """ @@ -67,24 +67,29 @@ def setUp(self): self.xblock = LTIBlock( self.runtime, DictFieldData({}), - ScopeIds(None, None, None, BlockUsageLocator(self.course_id, 'lti', 'name')) + ScopeIds( + None, None, None, BlockUsageLocator(self.course_id, "lti", "name") + ), ) - current_user = self.runtime.service(self.xblock, 'user').get_current_user() + current_user = self.runtime.service(self.xblock, "user").get_current_user() self.user_id = current_user.opt_attrs.get(ATTR_KEY_ANONYMOUS_USER_ID) self.lti_id = self.xblock.lti_id - self.unquoted_resource_link_id = '{}-i4x-2-3-lti-31de800015cf4afb973356dbe81496df'.format( - settings.LMS_BASE + self.unquoted_resource_link_id = ( + "{}-i4x-2-3-lti-31de800015cf4afb973356dbe81496df".format(settings.LMS_BASE) ) - sourced_id = ':'.join(parse.quote(i) for i in (self.lti_id, self.unquoted_resource_link_id, self.user_id)) # lint-amnesty, pylint: disable=line-too-long + sourced_id = ":".join( + parse.quote(i) + for i in (self.lti_id, self.unquoted_resource_link_id, self.user_id) + ) # lint-amnesty, pylint: disable=line-too-long self.defaults = { - 'namespace': "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0", - 'sourcedId': sourced_id, - 'action': 'replaceResultRequest', - 'grade': 0.5, - 'messageIdentifier': '528243ba5241b', + "namespace": "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0", + "sourcedId": sourced_id, + "action": "replaceResultRequest", + "grade": 0.5, + "messageIdentifier": "528243ba5241b", } self.xblock.due = None @@ -97,35 +102,41 @@ def get_request_body(self, params=None): data = copy(self.defaults) data.update(params) - return self.request_body_xml_template.format(**data).encode('utf-8') + return self.request_body_xml_template.format(**data).encode("utf-8") def get_response_values(self, response): """Gets the values from the given response""" - parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') + parser = etree.XMLParser(ns_clean=True, recover=True, encoding="utf-8") root = etree.fromstring(response.body.strip(), parser=parser) lti_spec_namespace = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0" - namespaces = {'def': lti_spec_namespace} + namespaces = {"def": lti_spec_namespace} code_major = root.xpath("//def:imsx_codeMajor", namespaces=namespaces)[0].text - description = root.xpath("//def:imsx_description", namespaces=namespaces)[0].text - message_identifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text + description = root.xpath("//def:imsx_description", namespaces=namespaces)[ + 0 + ].text + message_identifier = root.xpath( + "//def:imsx_messageIdentifier", namespaces=namespaces + )[0].text imsx_pox_body = root.xpath("//def:imsx_POXBody", namespaces=namespaces)[0] try: - action = imsx_pox_body.getchildren()[0].tag.replace('{' + lti_spec_namespace + '}', '') + action = imsx_pox_body.getchildren()[0].tag.replace( + "{" + lti_spec_namespace + "}", "" + ) except Exception: # pylint: disable=broad-except action = None return { - 'code_major': code_major, - 'description': description, - 'messageIdentifier': message_identifier, - 'action': action + "code_major": code_major, + "description": description, + "messageIdentifier": message_identifier, + "action": action, } @patch( - 'xblocks_contrib.lti.lti.LTIBlock.get_client_key_secret', - return_value=('test_client_key', 'test_client_secret') + "xblocks_contrib.lti.lti.LTIBlock.get_client_key_secret", + return_value=("test_client_key", "test_client_secret"), ) def test_authorization_header_not_present(self, _get_key_secret): """ @@ -135,21 +146,21 @@ def test_authorization_header_not_present(self, _get_key_secret): """ request = Request(self.environ) request.body = self.get_request_body() - response = self.xblock.grade_handler(request, '') + response = self.xblock.grade_handler(request, "") real_response = self.get_response_values(response) expected_response = { - 'action': None, - 'code_major': 'failure', - 'description': 'OAuth verification error: Malformed authorization header', - 'messageIdentifier': self.defaults['messageIdentifier'], + "action": None, + "code_major": "failure", + "description": "OAuth verification error: Malformed authorization header", + "messageIdentifier": self.defaults["messageIdentifier"], } assert response.status_code == 200 self.assertDictEqual(expected_response, real_response) @patch( - 'xblocks_contrib.lti.lti.LTIBlock.get_client_key_secret', - return_value=('test_client_key', 'test_client_secret') + "xblocks_contrib.lti.lti.LTIBlock.get_client_key_secret", + return_value=("test_client_key", "test_client_secret"), ) def test_authorization_header_empty(self, _get_key_secret): """ @@ -160,13 +171,13 @@ def test_authorization_header_empty(self, _get_key_secret): request = Request(self.environ) request.authorization = "bad authorization header" request.body = self.get_request_body() - response = self.xblock.grade_handler(request, '') + response = self.xblock.grade_handler(request, "") real_response = self.get_response_values(response) expected_response = { - 'action': None, - 'code_major': 'failure', - 'description': 'OAuth verification error: Malformed authorization header', - 'messageIdentifier': self.defaults['messageIdentifier'], + "action": None, + "code_major": "failure", + "description": "OAuth verification error: Malformed authorization header", + "messageIdentifier": self.defaults["messageIdentifier"], } assert response.status_code == 200 self.assertDictEqual(expected_response, real_response) @@ -175,18 +186,20 @@ def test_real_user_is_none(self): """ If we have no real user, we should send back failure response. """ - self.runtime._services['user'] = StubUserService(user=None) # pylint: disable=protected-access + self.runtime._services["user"] = StubUserService( + user=None + ) # pylint: disable=protected-access self.xblock.verify_oauth_body_sign = Mock() self.xblock.has_score = True request = Request(self.environ) request.body = self.get_request_body() - response = self.xblock.grade_handler(request, '') + response = self.xblock.grade_handler(request, "") real_response = self.get_response_values(response) expected_response = { - 'action': None, - 'code_major': 'failure', - 'description': 'User not found.', - 'messageIdentifier': self.defaults['messageIdentifier'], + "action": None, + "code_major": "failure", + "description": "User not found.", + "messageIdentifier": self.defaults["messageIdentifier"], } assert response.status_code == 200 self.assertDictEqual(expected_response, real_response) @@ -200,13 +213,13 @@ def test_grade_past_due(self): self.xblock.graceperiod = Timedelta().from_json("0 seconds") request = Request(self.environ) request.body = self.get_request_body() - response = self.xblock.grade_handler(request, '') + response = self.xblock.grade_handler(request, "") real_response = self.get_response_values(response) expected_response = { - 'action': None, - 'code_major': 'failure', - 'description': 'Grade is past due', - 'messageIdentifier': 'unknown', + "action": None, + "code_major": "failure", + "description": "Grade is past due", + "messageIdentifier": "unknown", } assert response.status_code == 200 assert expected_response == real_response @@ -217,14 +230,14 @@ def test_grade_not_in_range(self): """ self.xblock.verify_oauth_body_sign = Mock() request = Request(self.environ) - request.body = self.get_request_body(params={'grade': '10'}) - response = self.xblock.grade_handler(request, '') + request.body = self.get_request_body(params={"grade": "10"}) + response = self.xblock.grade_handler(request, "") real_response = self.get_response_values(response) expected_response = { - 'action': None, - 'code_major': 'failure', - 'description': 'Request body XML parsing error: score value outside the permitted range of 0-1.', - 'messageIdentifier': 'unknown', + "action": None, + "code_major": "failure", + "description": "Request body XML parsing error: score value outside the permitted range of 0-1.", + "messageIdentifier": "unknown", } assert response.status_code == 200 self.assertDictEqual(expected_response, real_response) @@ -235,15 +248,15 @@ def test_bad_grade_decimal(self): """ self.xblock.verify_oauth_body_sign = Mock() request = Request(self.environ) - request.body = self.get_request_body(params={'grade': '0,5'}) - response = self.xblock.grade_handler(request, '') + request.body = self.get_request_body(params={"grade": "0,5"}) + response = self.xblock.grade_handler(request, "") real_response = self.get_response_values(response) msg = "could not convert string to float: '0,5'" expected_response = { - 'action': None, - 'code_major': 'failure', - 'description': f'Request body XML parsing error: {msg}', - 'messageIdentifier': 'unknown', + "action": None, + "code_major": "failure", + "description": f"Request body XML parsing error: {msg}", + "messageIdentifier": "unknown", } assert response.status_code == 200 self.assertDictEqual(expected_response, real_response) @@ -255,14 +268,14 @@ def test_unsupported_action(self): """ self.xblock.verify_oauth_body_sign = Mock() request = Request(self.environ) - request.body = self.get_request_body({'action': 'wrongAction'}) - response = self.xblock.grade_handler(request, '') + request.body = self.get_request_body({"action": "wrongAction"}) + response = self.xblock.grade_handler(request, "") real_response = self.get_response_values(response) expected_response = { - 'action': None, - 'code_major': 'unsupported', - 'description': 'Target does not support the requested operation.', - 'messageIdentifier': self.defaults['messageIdentifier'], + "action": None, + "code_major": "unsupported", + "description": "Target does not support the requested operation.", + "messageIdentifier": self.defaults["messageIdentifier"], } assert response.status_code == 200 self.assertDictEqual(expected_response, real_response) @@ -275,22 +288,22 @@ def test_good_request(self): self.xblock.has_score = True request = Request(self.environ) request.body = self.get_request_body() - response = self.xblock.grade_handler(request, '') - description_expected = 'Score for {sourcedId} is now {score}'.format( - sourcedId=self.defaults['sourcedId'], - score=self.defaults['grade'], + response = self.xblock.grade_handler(request, "") + description_expected = "Score for {sourcedId} is now {score}".format( + sourcedId=self.defaults["sourcedId"], + score=self.defaults["grade"], ) real_response = self.get_response_values(response) expected_response = { - 'action': 'replaceResultResponse', - 'code_major': 'success', - 'description': description_expected, - 'messageIdentifier': self.defaults['messageIdentifier'], + "action": "replaceResultResponse", + "code_major": "success", + "description": description_expected, + "messageIdentifier": self.defaults["messageIdentifier"], } assert response.status_code == 200 self.assertDictEqual(expected_response, real_response) - assert self.xblock.module_score == float(self.defaults['grade']) + assert self.xblock.module_score == float(self.defaults["grade"]) def test_user_id(self): expected_user_id = str(parse.quote(self.xblock.runtime.anonymous_student_id)) @@ -298,30 +311,41 @@ def test_user_id(self): assert real_user_id == expected_user_id def test_outcome_service_url(self): - mock_url_prefix = 'https://hostname/' + mock_url_prefix = "https://hostname/" test_service_name = "test_service" - def mock_handler_url(block, handler_name, **kwargs): # pylint: disable=unused-argument + def mock_handler_url( + block, handler_name, **kwargs + ): # pylint: disable=unused-argument """Mock function for returning fully-qualified handler urls""" return mock_url_prefix + handler_name self.xblock.runtime.handler_url = Mock(side_effect=mock_handler_url) - real_outcome_service_url = self.xblock.get_outcome_service_url(service_name=test_service_name) + real_outcome_service_url = self.xblock.get_outcome_service_url( + service_name=test_service_name + ) assert real_outcome_service_url == (mock_url_prefix + test_service_name) def test_resource_link_id(self): - with patch('xblocks_contrib.lti.lti.LTIBlock.location', new_callable=PropertyMock): - self.xblock.location.html_id = lambda: 'i4x-2-3-lti-31de800015cf4afb973356dbe81496df' + with patch( + "xblocks_contrib.lti.lti.LTIBlock.location", new_callable=PropertyMock + ): + self.xblock.location.html_id = ( + lambda: "i4x-2-3-lti-31de800015cf4afb973356dbe81496df" + ) expected_resource_link_id = str(parse.quote(self.unquoted_resource_link_id)) real_resource_link_id = self.xblock.get_resource_link_id() assert real_resource_link_id == expected_resource_link_id def test_lis_result_sourcedid(self): - expected_sourced_id = ':'.join(parse.quote(i) for i in ( - str(self.course_id), - self.xblock.get_resource_link_id(), - self.user_id - )) + expected_sourced_id = ":".join( + parse.quote(i) + for i in ( + str(self.course_id), + self.xblock.get_resource_link_id(), + self.user_id, + ) + ) real_lis_result_sourcedid = self.xblock.get_lis_result_sourcedid() assert real_lis_result_sourcedid == expected_sourced_id @@ -329,15 +353,15 @@ def test_client_key_secret(self): """ LTI block gets client key and secret provided. """ - #this adds lti passports to system - mocked_course = Mock(lti_passports=['lti_id:test_client:test_secret']) + # this adds lti passports to system + mocked_course = Mock(lti_passports=["lti_id:test_client:test_secret"]) modulestore = Mock() modulestore.get_course.return_value = mocked_course runtime = Mock(modulestore=modulestore) self.xblock.runtime = runtime self.xblock.lti_id = "lti_id" key, secret = self.xblock.get_client_key_secret() - expected = ('test_client', 'test_secret') + expected = ("test_client", "test_secret") assert expected == (key, secret) def test_client_key_secret_not_provided(self): @@ -348,7 +372,7 @@ def test_client_key_secret_not_provided(self): """ # this adds lti passports to system - mocked_course = Mock(lti_passports=['test_id:test_client:test_secret']) + mocked_course = Mock(lti_passports=["test_id:test_client:test_secret"]) modulestore = Mock() modulestore.get_course.return_value = mocked_course runtime = Mock(modulestore=modulestore) @@ -356,7 +380,7 @@ def test_client_key_secret_not_provided(self): # set another lti_id self.xblock.lti_id = "another_lti_id" key_secret = self.xblock.get_client_key_secret() - expected = ('', '') + expected = ("", "") assert expected == key_secret def test_bad_client_key_secret(self): @@ -366,19 +390,21 @@ def test_bad_client_key_secret(self): There are key and secret provided in wrong format. """ # this adds lti passports to system - mocked_course = Mock(lti_passports=['test_id_test_client_test_secret']) + mocked_course = Mock(lti_passports=["test_id_test_client_test_secret"]) modulestore = Mock() modulestore.get_course.return_value = mocked_course runtime = Mock(modulestore=modulestore) self.xblock.runtime = runtime - self.xblock.lti_id = 'lti_id' + self.xblock.lti_id = "lti_id" with pytest.raises(LTIError): self.xblock.get_client_key_secret() - @patch('xblocks_contrib.lti.lti.signature.verify_hmac_sha1', Mock(return_value=True)) @patch( - 'xblocks_contrib.lti.lti.LTIBlock.get_client_key_secret', - Mock(return_value=('test_client_key', 'test_client_secret')) + "xblocks_contrib.lti.lti.signature.verify_hmac_sha1", Mock(return_value=True) + ) + @patch( + "xblocks_contrib.lti.lti.LTIBlock.get_client_key_secret", + Mock(return_value=("test_client_key", "test_client_secret")), ) def test_successful_verify_oauth_body_sign(self): """ @@ -386,9 +412,14 @@ def test_successful_verify_oauth_body_sign(self): """ self.xblock.verify_oauth_body_sign(self.get_signed_grade_mock_request()) - @patch('xblocks_contrib.lti.lti.LTIBlock.get_outcome_service_url', Mock(return_value='https://testurl/')) - @patch('xblocks_contrib.lti.lti.LTIBlock.get_client_key_secret', - Mock(return_value=('__consumer_key__', '__lti_secret__'))) + @patch( + "xblocks_contrib.lti.lti.LTIBlock.get_outcome_service_url", + Mock(return_value="https://testurl/"), + ) + @patch( + "xblocks_contrib.lti.lti.LTIBlock.get_client_key_secret", + Mock(return_value=("__consumer_key__", "__lti_secret__")), + ) def test_failed_verify_oauth_body_sign_proxy_mangle_url(self): """ Oauth signing verify fail. @@ -398,7 +429,7 @@ def test_failed_verify_oauth_body_sign_proxy_mangle_url(self): # we should verify against get_outcome_service_url not # request url proxy and load balancer along the way may # change url presented to the method - request.url = 'http://testurl/' + request.url = "http://testurl/" self.xblock.verify_oauth_body_sign(request) def get_signed_grade_mock_request_with_correct_signature(self): @@ -407,29 +438,29 @@ def get_signed_grade_mock_request_with_correct_signature(self): """ mock_request = Mock() mock_request.headers = { - 'X-Requested-With': 'XMLHttpRequest', - 'Content-Type': 'application/x-www-form-urlencoded', - 'Authorization': ( + "X-Requested-With": "XMLHttpRequest", + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": ( 'OAuth realm="https://testurl/", oauth_body_hash="wwzA3s8gScKD1VpJ7jMt9b%2BMj9Q%3D",' 'oauth_nonce="18821463", oauth_timestamp="1409321145", ' 'oauth_consumer_key="__consumer_key__", oauth_signature_method="HMAC-SHA1", ' 'oauth_version="1.0", oauth_signature="fHsE1hhIz76/msUoMR3Lyb7Aou4%3D"' - ) + ), } - mock_request.url = 'https://testurl' - mock_request.http_method = 'POST' + mock_request.url = "https://testurl" + mock_request.http_method = "POST" mock_request.method = mock_request.http_method mock_request.body = ( - b'\n' + b"\n" b'' - b'V1.0' - b'edX_fix' - b'' - b'MITxLTI/MITxLTI/201x:localhost%3A8000-i4x-MITxLTI-MITxLTI-lti-3751833a214a4f66a0d18f63234207f2' - b':363979ef768ca171b50f9d1bfb322131' - b'en0.32' - b'' + b"V1.0" + b"edX_fix" + b"" + b"MITxLTI/MITxLTI/201x:localhost%3A8000-i4x-MITxLTI-MITxLTI-lti-3751833a214a4f66a0d18f63234207f2" + b":363979ef768ca171b50f9d1bfb322131" + b"en0.32" + b"" ) return mock_request @@ -441,7 +472,9 @@ def test_wrong_xml_namespace(self): Tests that tool provider returned grade back with wrong XML Namespace. """ with pytest.raises(IndexError): - mocked_request = self.get_signed_grade_mock_request(namespace_lti_v1p1=False) + mocked_request = self.get_signed_grade_mock_request( + namespace_lti_v1p1=False + ) self.xblock.parse_grade_xml_body(mocked_request.body) def test_parse_grade_xml_body(self): @@ -451,16 +484,20 @@ def test_parse_grade_xml_body(self): Tests that xml body was parsed successfully. """ mocked_request = self.get_signed_grade_mock_request() - message_identifier, sourced_id, grade, action = self.xblock.parse_grade_xml_body(mocked_request.body) - assert self.defaults['messageIdentifier'] == message_identifier - assert self.defaults['sourcedId'] == sourced_id - assert self.defaults['grade'] == grade - assert self.defaults['action'] == action + message_identifier, sourced_id, grade, action = ( + self.xblock.parse_grade_xml_body(mocked_request.body) + ) + assert self.defaults["messageIdentifier"] == message_identifier + assert self.defaults["sourcedId"] == sourced_id + assert self.defaults["grade"] == grade + assert self.defaults["action"] == action - @patch('xblocks_contrib.lti.lti.signature.verify_hmac_sha1', Mock(return_value=False)) @patch( - 'xblocks_contrib.lti.lti.LTIBlock.get_client_key_secret', - Mock(return_value=('test_client_key', 'test_client_secret')) + "xblocks_contrib.lti.lti.signature.verify_hmac_sha1", Mock(return_value=False) + ) + @patch( + "xblocks_contrib.lti.lti.LTIBlock.get_client_key_secret", + Mock(return_value=("test_client_key", "test_client_secret")), ) def test_failed_verify_oauth_body_sign(self): """ @@ -479,23 +516,21 @@ def get_signed_grade_mock_request(self, namespace_lti_v1p1=True): """ mock_request = Mock() mock_request.headers = { - 'X-Requested-With': 'XMLHttpRequest', - 'Content-Type': 'application/x-www-form-urlencoded', - 'Authorization': 'OAuth oauth_nonce="135685044251684026041377608307", \ + "X-Requested-With": "XMLHttpRequest", + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": 'OAuth oauth_nonce="135685044251684026041377608307", \ oauth_timestamp="1234567890", oauth_version="1.0", \ oauth_signature_method="HMAC-SHA1", \ oauth_consumer_key="test_client_key", \ oauth_signature="my_signature%3D", \ - oauth_body_hash="JEpIArlNCeV4ceXxric8gJQCnBw="' + oauth_body_hash="JEpIArlNCeV4ceXxric8gJQCnBw="', } - mock_request.url = 'http://testurl' - mock_request.http_method = 'POST' + mock_request.url = "http://testurl" + mock_request.http_method = "POST" params = {} if not namespace_lti_v1p1: - params = { - 'namespace': "http://www.fakenamespace.com/fake" - } + params = {"namespace": "http://www.fakenamespace.com/fake"} mock_request.body = self.get_request_body(params) return mock_request @@ -504,22 +539,27 @@ def test_good_custom_params(self): """ Custom parameters are presented in right format. """ - self.xblock.custom_parameters = ['test_custom_params=test_custom_param_value'] - self.xblock.get_client_key_secret = Mock(return_value=('test_client_key', 'test_client_secret')) + self.xblock.custom_parameters = ["test_custom_params=test_custom_param_value"] + self.xblock.get_client_key_secret = Mock( + return_value=("test_client_key", "test_client_secret") + ) self.xblock.oauth_params = Mock() self.xblock.get_input_fields() self.xblock.oauth_params.assert_called_with( - {'custom_test_custom_params': 'test_custom_param_value'}, - 'test_client_key', 'test_client_secret' + {"custom_test_custom_params": "test_custom_param_value"}, + "test_client_key", + "test_client_secret", ) def test_bad_custom_params(self): """ Custom parameters are presented in wrong format. """ - bad_custom_params = ['test_custom_params: test_custom_param_value'] + bad_custom_params = ["test_custom_params: test_custom_param_value"] self.xblock.custom_parameters = bad_custom_params - self.xblock.get_client_key_secret = Mock(return_value=('test_client_key', 'test_client_secret')) + self.xblock.get_client_key_secret = Mock( + return_value=("test_client_key", "test_client_secret") + ) self.xblock.oauth_params = Mock() with pytest.raises(LTIError): self.xblock.get_input_fields() From 347a420da787f2c29f333b06e265d3e98140ff45 Mon Sep 17 00:00:00 2001 From: Muhammad Tayayb Tahir Qureshi Date: Mon, 25 Nov 2024 14:59:38 +0500 Subject: [PATCH 09/34] chore: update requirements --- requirements/base.txt | 8 ++++---- requirements/dev.txt | 12 +++++++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/requirements/base.txt b/requirements/base.txt index d7606ce..5654272 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -8,9 +8,9 @@ appdirs==1.4.4 # via fs asgiref==3.8.1 # via django -boto3==1.36.21 +boto3==1.35.68 # via fs-s3fs -botocore==1.36.21 +botocore==1.35.68 # via # boto3 # s3transfer @@ -79,7 +79,7 @@ pyyaml==6.0.2 # via # edx-i18n-tools # xblock -s3transfer==0.11.2 +s3transfer==0.10.4 # via boto3 simplejson==3.20.1 # via xblock @@ -90,7 +90,7 @@ six==1.17.0 # python-dateutil sqlparse==0.5.3 # via django -stevedore==5.3.0 +stevedore==5.4.0 # via edx-opaque-keys typing-extensions==4.12.2 # via edx-opaque-keys diff --git a/requirements/dev.txt b/requirements/dev.txt index e2f4aaf..bbd4b76 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -29,12 +29,12 @@ binaryornot==0.4.4 # -r requirements/quality.txt # -r requirements/test.txt # cookiecutter -boto3==1.36.21 +boto3==1.35.68 # via # -r requirements/quality.txt # -r requirements/test.txt # fs-s3fs -botocore==1.36.21 +botocore==1.35.68 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -80,7 +80,7 @@ click-log==0.4.0 # via # -r requirements/quality.txt # edx-lint -code-annotations==2.2.0 +code-annotations==1.8.2 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -95,7 +95,7 @@ cookiecutter==2.6.0 # -r requirements/quality.txt # -r requirements/test.txt # xblock-sdk -coverage[toml]==7.6.12 +coverage[toml]==7.6.8 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -375,7 +375,7 @@ rich==13.9.4 # -r requirements/quality.txt # -r requirements/test.txt # cookiecutter -s3transfer==0.11.2 +s3transfer==0.10.4 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -403,6 +403,7 @@ sqlparse==0.5.3 # -r requirements/quality.txt # -r requirements/test.txt # django +stevedore==5.4.0 stevedore==5.4.0 # via # -r requirements/quality.txt @@ -456,6 +457,7 @@ webob==1.8.9 # -r requirements/test.txt # xblock # xblock-sdk +wheel==0.45.1 wheel==0.45.1 # via # -r requirements/pip-tools.txt From 3ee93d0acddb7f985547b82fb872adde10fc6bd7 Mon Sep 17 00:00:00 2001 From: Muhammad Tayayb Tahir Qureshi Date: Wed, 27 Nov 2024 12:50:59 +0500 Subject: [PATCH 10/34] chore: chore --- xblocks_contrib/lti/lti.py | 44 +++++++++++++++---------------- xblocks_contrib/lti/lti_2_util.py | 8 +++--- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/xblocks_contrib/lti/lti.py b/xblocks_contrib/lti/lti.py index 48b8bbd..1f916f7 100644 --- a/xblocks_contrib/lti/lti.py +++ b/xblocks_contrib/lti/lti.py @@ -42,7 +42,7 @@ b.) Currently only action "update" is supported. "Read", and "delete" actions initially weren't required. B) LTI 2.0 Result Service JSON REST endpoint - (http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html) + (http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html) a.) Discovery of all such LTI http endpoints for a course. External tools GET from this discovery endpoint and receive URLs for interacting with individual grading units. (see lms/djangoapps/courseware/views/views.py:get_course_lti_endpoints) @@ -561,27 +561,27 @@ def grade_handler(self, request, suffix): # lint-amnesty, pylint: disable=unuse - - - V1.0 - 528243ba5241b - - - - - - - feb-123-456-2929::28883 - - - - en-us - 0.4 - - - - - + + + V1.0 + 528243ba5241b + + + + + + + feb-123-456-2929::28883 + + + + en-us + 0.4 + + + + + Example of correct/incorrect answer XML body:: see response_xml_template. diff --git a/xblocks_contrib/lti/lti_2_util.py b/xblocks_contrib/lti/lti_2_util.py index 70823e8..b1de096 100644 --- a/xblocks_contrib/lti/lti_2_util.py +++ b/xblocks_contrib/lti/lti_2_util.py @@ -43,10 +43,10 @@ def lti_2_0_result_rest_handler(self, request, suffix): See http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html An example JSON object: { - "@context" : "http://purl.imsglobal.org/ctx/lis/v2/Result", - "@type" : "Result", - "resultScore" : 0.83, - "comment" : "This is exceptional work." + "@context" : "http://purl.imsglobal.org/ctx/lis/v2/Result", + "@type" : "Result", + "resultScore" : 0.83, + "comment" : "This is exceptional work." } For PUTs, the content type must be "application/vnd.ims.lis.v2.result+json". We use the "suffix" parameter to parse out the user from the end of the URL. An example endpoint url is From a09dffa0e6f8121426330d96419082710405c4eb Mon Sep 17 00:00:00 2001 From: Muhammad Tayyab Tahir Qureshi Date: Wed, 12 Feb 2025 14:58:15 +0500 Subject: [PATCH 11/34] chore: update get_test_system --- xblocks_contrib/lti/tests/helpers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/xblocks_contrib/lti/tests/helpers.py b/xblocks_contrib/lti/tests/helpers.py index a4863eb..7beebef 100644 --- a/xblocks_contrib/lti/tests/helpers.py +++ b/xblocks_contrib/lti/tests/helpers.py @@ -136,7 +136,6 @@ def publish(self, block, event_type, event_data): def get_test_system( - course_id=CourseKey.from_string("/".join(["org", "course", "run"])), user=None, user_is_staff=False, ): From 99acf3c6632404b1c8b265703ea63a297a18b134 Mon Sep 17 00:00:00 2001 From: Muhammad Tayyab Tahir Qureshi Date: Wed, 12 Feb 2025 14:59:22 +0500 Subject: [PATCH 12/34] chore: drop use of `location` attribute --- xblocks_contrib/lti/lti.py | 35 ++++++++--------------------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/xblocks_contrib/lti/lti.py b/xblocks_contrib/lti/lti.py index 1f916f7..ffbb7cc 100644 --- a/xblocks_contrib/lti/lti.py +++ b/xblocks_contrib/lti/lti.py @@ -61,6 +61,7 @@ import logging import markupsafe import textwrap +import uuid from xml.sax.saxutils import escape from unittest import mock from urllib import parse @@ -375,26 +376,6 @@ class LTIBlock( "ask_to_send_email", "ask_to_send_username", "has_score", "weight", ) - @property - def course_id(self): - return self.location.course_key - - @property - def category(self): - return self.scope_ids.block_type - - @property - def location(self): - return self.scope_ids.usage_id - - @location.setter - def location(self, value): - assert isinstance(value, UsageKey) - self.scope_ids = self.scope_ids._replace( - def_id=value, # Note: assigning a UsageKey as def_id is OK in old mongo / import system but wrong in split - usage_id=value, - ) - def max_score(self): return self.weight if self.has_score else None @@ -509,8 +490,8 @@ def get_context(self): # These parameters do not participate in OAuth signing. 'launch_url': self.launch_url.strip(), - 'element_id': self.location.html_id(), - 'element_class': self.category, + 'element_id': self.scope_ids.usage_id.html_id(), + 'element_class': self.scope_ids.block_type, 'open_in_a_new_page': self.open_in_a_new_page, 'display_name': self.display_name, 'form_url': self.runtime.handler_url(self, 'preview_handler').rstrip('/?'), @@ -742,7 +723,7 @@ def get_resource_link_id(self): i4x-2-3-lti-31de800015cf4afb973356dbe81496df this part of resource_link_id: makes resource_link_id to be unique among courses inside same system. """ - return str(parse.quote(f"{settings.LMS_BASE}-{self.location.html_id()}")) + return str(parse.quote(f"{settings.LMS_BASE}-{self.scope_ids.usage_id.html_id()}")) def get_lis_result_sourcedid(self): """ @@ -763,7 +744,7 @@ def get_course(self): """ Return course by course id. """ - return self.runtime.modulestore.get_course(self.course_id) + return self.runtime.modulestore.get_course(self.scope_ids.usage_id.course_key) @property def context_id(self): @@ -773,7 +754,7 @@ def context_id(self): context_id is an opaque identifier that uniquely identifies the context (e.g., a course) that contains the link being launched. """ - return str(self.course_id) + return str(self.scope_ids.usage_id.course_key) @property def role(self): @@ -870,8 +851,8 @@ def oauth_params(self, custom_parameters, client_key, client_secret): # Stubbing headers for now: log.info( "LTI block %s in course %s does not have oauth parameters correctly configured.", - self.location, - self.location.course_key, + self.scope_ids.usage_id, + self.scope_ids.usage_id.course_key, ) headers = { 'Content-Type': 'application/x-www-form-urlencoded', From 4448dc69c468b787478d8abd667021a635df748b Mon Sep 17 00:00:00 2001 From: Muhammad Tayyab Tahir Qureshi Date: Wed, 12 Feb 2025 15:59:22 +0500 Subject: [PATCH 13/34] chore: upgrade requirements --- requirements/base.txt | 11 ++++++----- requirements/dev.txt | 14 +++++++++----- requirements/doc.txt | 20 ++++++++++---------- requirements/quality.txt | 6 ++++-- requirements/test.txt | 15 +++++++++++---- 5 files changed, 40 insertions(+), 26 deletions(-) diff --git a/requirements/base.txt b/requirements/base.txt index 5654272..c13dd54 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -8,9 +8,9 @@ appdirs==1.4.4 # via fs asgiref==3.8.1 # via django -boto3==1.35.68 +boto3==1.36.18 # via fs-s3fs -botocore==1.35.68 +botocore==1.36.18 # via # boto3 # s3transfer @@ -42,6 +42,7 @@ jmespath==1.0.1 # via # boto3 # botocore +lxml[html-clean,html_clean]==5.3.1 lxml[html-clean,html_clean]==5.3.1 # via # edx-i18n-tools @@ -55,7 +56,7 @@ markupsafe==3.0.2 # via # mako # xblock -nh3==0.2.18 +nh3==0.2.20 # via -r requirements/base.in oauthlib==3.2.2 # via -r requirements/base.in @@ -63,11 +64,11 @@ openedx-django-pyfs==3.7.0 # via -r requirements/base.in path==16.16.0 # via edx-i18n-tools -pbr==6.1.0 +pbr==6.1.1 # via stevedore polib==1.2.0 # via edx-i18n-tools -pymongo==4.10.1 +pymongo==4.11.1 # via edx-opaque-keys python-dateutil==2.9.0.post0 # via diff --git a/requirements/dev.txt b/requirements/dev.txt index bbd4b76..ace5c2f 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -29,12 +29,12 @@ binaryornot==0.4.4 # -r requirements/quality.txt # -r requirements/test.txt # cookiecutter -boto3==1.35.68 +boto3==1.36.18 # via # -r requirements/quality.txt # -r requirements/test.txt # fs-s3fs -botocore==1.35.68 +botocore==1.36.18 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -95,7 +95,7 @@ cookiecutter==2.6.0 # -r requirements/quality.txt # -r requirements/test.txt # xblock-sdk -coverage[toml]==7.6.8 +coverage[toml]==7.6.12 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -137,6 +137,10 @@ edx-i18n-tools==1.6.3 # -r requirements/test.txt edx-lint==5.6.0 # via -r requirements/quality.txt +edx-opaque-keys==2.11.0 + # via + # -r requirements/quality.txt + # -r requirements/test.txt filelock==3.17.0 # via # -r requirements/quality.txt @@ -222,7 +226,7 @@ mdurl==0.1.2 # -r requirements/quality.txt # -r requirements/test.txt # markdown-it-py -nh3==0.2.18 +nh3==0.2.20 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -304,7 +308,7 @@ pylint-plugin-utils==0.8.2 # -r requirements/quality.txt # pylint-celery # pylint-django -pymongo==4.10.1 +pymongo==4.11.1 # via # -r requirements/quality.txt # -r requirements/test.txt diff --git a/requirements/doc.txt b/requirements/doc.txt index 7c7f033..d28f625 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -79,8 +79,6 @@ coverage[toml]==7.6.12 # via # -r requirements/test.txt # pytest-cov -cryptography==44.0.1 - # via secretstorage distlib==0.3.9 # via # -r requirements/test.txt @@ -115,6 +113,8 @@ docutils==0.21.2 # sphinx edx-i18n-tools==1.6.3 # via -r requirements/test.txt +edx-opaque-keys==2.11.0 + # via -r requirements/test.txt filelock==3.17.0 # via # -r requirements/test.txt @@ -151,10 +151,6 @@ jaraco-context==6.0.1 # via keyring jaraco-functools==4.1.0 # via keyring -jeepney==0.8.0 - # via - # keyring - # secretstorage jinja2==3.1.5 # via # -r requirements/test.txt @@ -202,7 +198,11 @@ more-itertools==10.6.0 # jaraco-classes # jaraco-functools nh3==0.2.20 - # via readme-renderer + # via + # -r requirements/test.txt + # readme-renderer +oauthlib==3.2.2 + # via -r requirements/test.txt openedx-django-pyfs==3.7.0 # via -r requirements/test.txt packaging==24.2 @@ -236,8 +236,6 @@ polib==1.2.0 # via # -r requirements/test.txt # edx-i18n-tools -pycparser==2.22 - # via cffi pydata-sphinx-theme==0.16.1 # via sphinx-book-theme pygments==2.19.1 @@ -249,7 +247,7 @@ pygments==2.19.1 # readme-renderer # rich # sphinx -pymongo==4.10.1 +pymongo==4.11.1 # via # -r requirements/test.txt # edx-opaque-keys @@ -380,7 +378,9 @@ types-python-dateutil==2.9.0.20241206 # arrow typing-extensions==4.12.2 # via + # -r requirements/test.txt # beautifulsoup4 + # edx-opaque-keys # pydata-sphinx-theme urllib3==2.2.3 # via diff --git a/requirements/quality.txt b/requirements/quality.txt index 06d38ea..8223a97 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -104,6 +104,8 @@ edx-i18n-tools==1.6.3 # via -r requirements/test.txt edx-lint==5.6.0 # via -r requirements/quality.in +edx-opaque-keys==2.11.0 + # via -r requirements/test.txt filelock==3.17.0 # via # -r requirements/test.txt @@ -173,7 +175,7 @@ mdurl==0.1.2 # via # -r requirements/test.txt # markdown-it-py -nh3==0.2.18 +nh3==0.2.20 # via -r requirements/test.txt oauthlib==3.2.2 # via -r requirements/test.txt @@ -230,7 +232,7 @@ pylint-plugin-utils==0.8.2 # via # pylint-celery # pylint-django -pymongo==4.10.1 +pymongo==4.11.1 # via # -r requirements/test.txt # edx-opaque-keys diff --git a/requirements/test.txt b/requirements/test.txt index 0b61601..5da7012 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -69,6 +69,8 @@ dnspython==2.7.0 # pymongo edx-i18n-tools==1.6.3 # via -r requirements/base.txt +edx-opaque-keys==2.11.0 + # via -r requirements/base.txt filelock==3.17.0 # via # tox @@ -122,7 +124,7 @@ markupsafe==3.0.2 # xblock mdurl==0.1.2 # via markdown-it-py -nh3==0.2.18 +nh3==0.2.20 # via -r requirements/base.txt oauthlib==3.2.2 # via -r requirements/base.txt @@ -138,7 +140,9 @@ path==16.16.0 # -r requirements/base.txt # edx-i18n-tools pbr==6.1.1 - # via stevedore + # via + # -r requirements/base.txt + # stevedore platformdirs==4.3.6 # via # tox @@ -153,7 +157,7 @@ polib==1.2.0 # edx-i18n-tools pygments==2.19.1 # via rich -pymongo==4.10.1 +pymongo==4.11.1 # via # -r requirements/base.txt # edx-opaque-keys @@ -216,7 +220,10 @@ sqlparse==0.5.3 # -r requirements/base.txt # django stevedore==5.4.0 - # via code-annotations + # via + # -r requirements/base.txt + # code-annotations + # edx-opaque-keys text-unidecode==1.3 # via python-slugify tox==4.24.1 From 29cc0a9d58249a8d8b530616a30562f061db5966 Mon Sep 17 00:00:00 2001 From: Muhammad Tayyab Tahir Qureshi Date: Wed, 12 Feb 2025 16:16:53 +0500 Subject: [PATCH 14/34] chore: add data package name to distinguish extracted block from the builtin one --- xblocks_contrib/lti/templates/lti.html | 1 + 1 file changed, 1 insertion(+) diff --git a/xblocks_contrib/lti/templates/lti.html b/xblocks_contrib/lti/templates/lti.html index 1f6ceef..958179d 100644 --- a/xblocks_contrib/lti/templates/lti.html +++ b/xblocks_contrib/lti/templates/lti.html @@ -6,6 +6,7 @@ class="lti" data-open_in_a_new_page="true" data-ajax_url="www.example.com/ajax_url" + data-package-name="xblocks-contrib" >
Date: Fri, 14 Feb 2025 15:37:57 +0500 Subject: [PATCH 15/34] chore: chore --- xblocks_contrib/lti/tests/test_lti_unit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xblocks_contrib/lti/tests/test_lti_unit.py b/xblocks_contrib/lti/tests/test_lti_unit.py index 5ad3ea5..f3040dc 100644 --- a/xblocks_contrib/lti/tests/test_lti_unit.py +++ b/xblocks_contrib/lti/tests/test_lti_unit.py @@ -60,7 +60,7 @@ def setUp(self): """) self.course_id = CourseKey.from_string('org/course/run') - self.runtime = get_test_system(self.course_id) + self.runtime = get_test_system() self.runtime.publish = Mock() self.runtime._services['rebind_user'] = Mock() # pylint: disable=protected-access From 7ddc414e8d9ee72fa7ac9eb662cf902a7242248d Mon Sep 17 00:00:00 2001 From: Muhammad Tayyab Tahir Qureshi Date: Fri, 14 Feb 2025 15:39:22 +0500 Subject: [PATCH 16/34] chore: revert use of `location` to fix test cases --- xblocks_contrib/lti/lti.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/xblocks_contrib/lti/lti.py b/xblocks_contrib/lti/lti.py index ffbb7cc..fbff00e 100644 --- a/xblocks_contrib/lti/lti.py +++ b/xblocks_contrib/lti/lti.py @@ -371,11 +371,23 @@ class LTIBlock( ) editable_fields = ( - "accept_grades_past_due", "button_text", "custom_parameters", "display_name", - "hide_launch", "description", "lti_id", "launch_url", "open_in_a_new_page", - "ask_to_send_email", "ask_to_send_username", "has_score", "weight", + "accept_grades_past_due", "ask_to_send_email", "ask_to_send_username", "button_text", + "custom_parameters", "description", "display_name", "has_score", "hide_launch", + "launch_url", "lti_id", "open_in_a_new_page", "weight", ) + @property + def location(self): + return self.scope_ids.usage_id + + @location.setter + def location(self, value): + assert isinstance(value, UsageKey) + self.scope_ids = self.scope_ids._replace( + def_id=value, # Note: assigning a UsageKey as def_id is OK in old mongo / import system but wrong in split + usage_id=value, + ) + def max_score(self): return self.weight if self.has_score else None @@ -490,7 +502,7 @@ def get_context(self): # These parameters do not participate in OAuth signing. 'launch_url': self.launch_url.strip(), - 'element_id': self.scope_ids.usage_id.html_id(), + 'element_id': self.location.html_id(), 'element_class': self.scope_ids.block_type, 'open_in_a_new_page': self.open_in_a_new_page, 'display_name': self.display_name, @@ -723,7 +735,7 @@ def get_resource_link_id(self): i4x-2-3-lti-31de800015cf4afb973356dbe81496df this part of resource_link_id: makes resource_link_id to be unique among courses inside same system. """ - return str(parse.quote(f"{settings.LMS_BASE}-{self.scope_ids.usage_id.html_id()}")) + return str(parse.quote(f"{settings.LMS_BASE}-{self.location.html_id()}")) def get_lis_result_sourcedid(self): """ @@ -744,7 +756,7 @@ def get_course(self): """ Return course by course id. """ - return self.runtime.modulestore.get_course(self.scope_ids.usage_id.course_key) + return self.runtime.modulestore.get_course(self.location.course_key) @property def context_id(self): @@ -754,7 +766,7 @@ def context_id(self): context_id is an opaque identifier that uniquely identifies the context (e.g., a course) that contains the link being launched. """ - return str(self.scope_ids.usage_id.course_key) + return str(self.location.course_key) @property def role(self): @@ -851,8 +863,8 @@ def oauth_params(self, custom_parameters, client_key, client_secret): # Stubbing headers for now: log.info( "LTI block %s in course %s does not have oauth parameters correctly configured.", - self.scope_ids.usage_id, - self.scope_ids.usage_id.course_key, + self.location, + self.location.course_key, ) headers = { 'Content-Type': 'application/x-www-form-urlencoded', From 2c16fac6b50a3a5e5f9e1e9f1d09d5f8d61f8c3e Mon Sep 17 00:00:00 2001 From: Muhammad Tayyab Tahir Qureshi Date: Mon, 17 Feb 2025 15:14:10 +0500 Subject: [PATCH 17/34] chore: create `LTIFields` class for the fields as in BuiltIn LTIBlock --- xblocks_contrib/lti/lti.py | 217 +++++++++++++++++++------------------ 1 file changed, 112 insertions(+), 105 deletions(-) diff --git a/xblocks_contrib/lti/lti.py b/xblocks_contrib/lti/lti.py index fbff00e..0067696 100644 --- a/xblocks_contrib/lti/lti.py +++ b/xblocks_contrib/lti/lti.py @@ -108,116 +108,26 @@ def noop(text): _ = noop -@XBlock.needs("i18n") -@XBlock.needs("user") -@XBlock.needs("rebind_user") -class LTIBlock( - LTI20BlockMixin, - StudioEditableXBlockMixin, - XBlock, -): # pylint: disable=abstract-method +class LTIFields: """ - THIS MODULE IS DEPRECATED IN FAVOR OF https://github.com/openedx/xblock-lti-consumer - - Module provides LTI integration to course. - - Except usual Xmodule structure it proceeds with OAuth signing. - How it works:: - - 1. Get credentials from course settings. - - 2. There is minimal set of parameters need to be signed (presented for Vitalsource):: - - user_id - oauth_callback - lis_outcome_service_url - lis_result_sourcedid - launch_presentation_return_url - lti_message_type - lti_version - roles - *+ all custom parameters* - - These parameters should be encoded and signed by *OAuth1* together with - `launch_url` and *POST* request type. - - 3. Signing proceeds with client key/secret pair obtained from course settings. - That pair should be obtained from LTI provider and set into course settings by course author. - After that signature and other OAuth data are generated. - - OAuth data which is generated after signing is usual:: - - oauth_callback - oauth_nonce - oauth_consumer_key - oauth_signature_method - oauth_timestamp - oauth_version - - - 4. All that data is passed to form and sent to LTI provider server by browser via - autosubmit via JavaScript. + Fields to define and obtain LTI tool from provider are set here, + except credentials, which should be set in course settings:: - Form example:: + `lti_id` is id to connect tool with credentials in course settings. It should not contain :: (double semicolon) + `launch_url` is launch URL of tool. + `custom_parameters` are additional parameters to navigate to proper book and book page. - - - - - - - - - - - - - - - + For example, for Vitalsource provider, `launch_url` should be + *https://bc-staging.vitalsource.com/books/book*, + and to get to proper book and book page, you should set custom parameters as:: - - - - - - + vbid=put_book_id_here + book_location=page/put_page_number_here - 5. LTI provider has same secret key and it signs data string via *OAuth1* and compares signatures. - - If signatures are correct, LTI provider redirects iframe source to LTI tool web page, - and LTI tool is rendered to iframe inside course. + Default non-empty URL for `launch_url` is needed due to oauthlib demand (URL scheme should be presented):: - Otherwise error message from LTI provider is generated. + https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136 """ - - # Indicates that this XBlock has been extracted from edx-platform. - is_extracted = True - - ###################################### - # LTI FIELDS # - ###################################### - # `lti_id` is id to connect tool with credentials in course settings. It should not contain :: (double semicolon) - # `launch_url` is launch URL of tool. - # `custom_parameters` are additional parameters to navigate to proper book and book page. - - # For example, for Vitalsource provider, `launch_url` should be - # *https://bc-staging.vitalsource.com/books/book*, - # and to get to proper book and book page, you should set custom parameters as:: - - # vbid=put_book_id_here - # book_location=page/put_page_number_here - - # Default non-empty URL for `launch_url` is needed due to oauthlib demand (URL scheme should be presented):: - - # https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136 display_name = String( display_name=_("Display Name"), help=_( @@ -256,7 +166,8 @@ class LTIBlock( anchor_close=markupsafe.Markup("") ), default='http://www.example.com', - scope=Scope.settings) + scope=Scope.settings + ) custom_parameters = List( display_name=_("Custom Parameters"), @@ -269,7 +180,8 @@ class LTIBlock( docs_anchor_open=markupsafe.Markup(DOCS_ANCHOR_TAG_OPEN), anchor_close=markupsafe.Markup("") ), - scope=Scope.settings) + scope=Scope.settings + ) open_in_a_new_page = Boolean( display_name=_("Open in New Page"), @@ -376,6 +288,101 @@ class LTIBlock( "launch_url", "lti_id", "open_in_a_new_page", "weight", ) + +@XBlock.needs("i18n") +@XBlock.needs("user") +@XBlock.needs("rebind_user") +class LTIBlock( + LTIFields, + LTI20BlockMixin, + StudioEditableXBlockMixin, + XBlock, +): # pylint: disable=abstract-method + """ + THIS MODULE IS DEPRECATED IN FAVOR OF https://github.com/openedx/xblock-lti-consumer + + Module provides LTI integration to course. + + Except usual Xmodule structure it proceeds with OAuth signing. + How it works:: + + 1. Get credentials from course settings. + + 2. There is minimal set of parameters need to be signed (presented for Vitalsource):: + + user_id + oauth_callback + lis_outcome_service_url + lis_result_sourcedid + launch_presentation_return_url + lti_message_type + lti_version + roles + *+ all custom parameters* + + These parameters should be encoded and signed by *OAuth1* together with + `launch_url` and *POST* request type. + + 3. Signing proceeds with client key/secret pair obtained from course settings. + That pair should be obtained from LTI provider and set into course settings by course author. + After that signature and other OAuth data are generated. + + OAuth data which is generated after signing is usual:: + + oauth_callback + oauth_nonce + oauth_consumer_key + oauth_signature_method + oauth_timestamp + oauth_version + + + 4. All that data is passed to form and sent to LTI provider server by browser via + autosubmit via JavaScript. + + Form example:: + +
+ + + + + + + + + + + + + + + + + + + + +
+ + 5. LTI provider has same secret key and it signs data string via *OAuth1* and compares signatures. + + If signatures are correct, LTI provider redirects iframe source to LTI tool web page, + and LTI tool is rendered to iframe inside course. + + Otherwise error message from LTI provider is generated. + """ + + # Indicates that this XBlock has been extracted from edx-platform. + is_extracted = True + @property def location(self): return self.scope_ids.usage_id From a60b5c4a01ca9f12c0ff447d2dd4ab14e8ffb55c Mon Sep 17 00:00:00 2001 From: Muhammad Tayyab Tahir Qureshi Date: Tue, 18 Feb 2025 11:08:59 +0500 Subject: [PATCH 18/34] chore: add `bind_for_student` method to fix test case --- xblocks_contrib/lti/lti.py | 40 +++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/xblocks_contrib/lti/lti.py b/xblocks_contrib/lti/lti.py index 0067696..4efbe1f 100644 --- a/xblocks_contrib/lti/lti.py +++ b/xblocks_contrib/lti/lti.py @@ -76,7 +76,7 @@ from webob import Response from web_fragments.fragment import Fragment from xblock.core import List, Scope, String, XBlock -from xblock.fields import Boolean, Float +from xblock.fields import Boolean, Float, UserScope try: from xblock.utils.resources import ResourceLoader from xblock.utils.studio_editable import StudioEditableXBlockMixin @@ -1017,3 +1017,41 @@ def is_past_due(self): else: close_date = due_date return close_date is not None and datetime.datetime.now(UTC) > close_date + + def bind_for_student(self, user_id, wrappers=None): + """ + Bind the XBlock to a specific student by user_id. + + Arguments: + user_id: The user_id to set in scope_ids. + wrappers: A list of functions to wrap the field data, if any. + """ + # If we're already bound to this user, skip re-binding + if self.scope_ids.user_id is not None and user_id == self.scope_ids.user_id: + return + + # Update scope_ids to the new user + self.scope_ids = self.scope_ids._replace(user_id=user_id) + + # Clear cached child data + self.clear_child_cache() + + # Clear cached field data + for field in self.fields.values(): + if hasattr(field.scope, 'user') and field.scope.user == UserScope.ONE: + field._del_cached_value(self) + if field in self._dirty_fields: + del self._dirty_fields[field] + + # Apply wrappers if provided + if wrappers: + wrapped_field_data = self.runtime.service(self, 'field-data-unbound') + for wrapper in wrappers: + wrapped_field_data = wrapper(wrapped_field_data) + self._bound_field_data = wrapped_field_data + + if getattr(self.runtime, "uses_deprecated_field_data", False): + self._field_data = wrapped_field_data + + # Optionally save the state if needed + self.save() From abdd4346fe736c4a51773a57296da4887ec5ffdb Mon Sep 17 00:00:00 2001 From: Muhammad Tayyab Tahir Qureshi Date: Thu, 20 Feb 2025 14:40:44 +0500 Subject: [PATCH 19/34] fix: fix - update selector in css to render iframe properly - update identifier in js to find LTI element --- xblocks_contrib/lti/static/css/lti.css | 16 ++++++++-------- xblocks_contrib/lti/static/js/src/lti.js | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/xblocks_contrib/lti/static/css/lti.css b/xblocks_contrib/lti/static/css/lti.css index 7e9b576..de0d207 100644 --- a/xblocks_contrib/lti/static/css/lti.css +++ b/xblocks_contrib/lti/static/css/lti.css @@ -14,44 +14,44 @@ div.problem-progress { font-size: 1em; } -div.lti { +div._lti_extracted { margin: 0 auto; } -div.lti .wrapper-lti-link { +div._lti_extracted .wrapper-lti-link { font-size: 14px; background-color: #f6f6f6; padding: 20px; } -div.lti .wrapper-lti-link .lti-link { +div._lti_extracted .wrapper-lti-link .lti-link { margin-bottom: 0; text-align: right; } -div.lti .wrapper-lti-link .lti-link .link_lti_new_window { +div._lti_extracted .wrapper-lti-link .lti-link .link_lti_new_window { font-size: 13px; line-height: 20.72px; } -div.lti form.ltiLaunchForm { +div._lti_extracted form.ltiLaunchForm { display: none; } -div.lti iframe.ltiLaunchFrame { +div._lti_extracted iframe.ltiLaunchFrame { width: 100%; height: 800px; display: block; border: 0px; } -div.lti h4.problem-feedback-label { +div._lti_extracted h4.problem-feedback-label { font-weight: 100; font-size: 1em; font-family: "Source Sans", "Open Sans", Verdana, Geneva, sans-serif, sans-serif; } -div.lti div.problem-feedback { +div._lti_extracted div.problem-feedback { margin-top: 5px; margin-bottom: 5px; } diff --git a/xblocks_contrib/lti/static/js/src/lti.js b/xblocks_contrib/lti/static/js/src/lti.js index efbfa07..01035f4 100644 --- a/xblocks_contrib/lti/static/js/src/lti.js +++ b/xblocks_contrib/lti/static/js/src/lti.js @@ -1,9 +1,9 @@ /* JavaScript for LTIBlock. */ -function LTIBlock(element) { +function LTIBlock(runtime, element) { 'use strict'; - const $lti = $(element).find('.lti'); + const $lti = $(element).find('._lti_extracted'); const askToSendUsername = $lti.data('ask-to-send-username') === 'True'; const askToSendEmail = $lti.data('ask-to-send-email') === 'True'; From 42987fb6b41f986bb6ccb1bfc5ac7234c486df9d Mon Sep 17 00:00:00 2001 From: Muhammad Tayyab Tahir Qureshi Date: Thu, 20 Feb 2025 14:43:37 +0500 Subject: [PATCH 20/34] chore: remove unnecessary form in HTML file --- xblocks_contrib/lti/templates/lti.html | 35 -------------------------- 1 file changed, 35 deletions(-) diff --git a/xblocks_contrib/lti/templates/lti.html b/xblocks_contrib/lti/templates/lti.html index 958179d..b30fb6a 100644 --- a/xblocks_contrib/lti/templates/lti.html +++ b/xblocks_contrib/lti/templates/lti.html @@ -1,41 +1,6 @@ {% load i18n %}
-
- -
- - - - - - - - - - - - - - - - - -
-

{# Translators: "External resource" means that this learning module is hosted on a platform external to the edX LMS #} {{display_name}} ({% trans 'External resource' %}) From 83250b65af05258d57bdd93d3cf160b7e11bb79e Mon Sep 17 00:00:00 2001 From: Muhammad Tayyab Tahir Qureshi Date: Thu, 20 Feb 2025 17:33:44 +0500 Subject: [PATCH 21/34] chore: upgrade requirements --- requirements/base.txt | 7 +++---- requirements/dev.txt | 17 ++++++++++------- requirements/doc.txt | 10 +++++----- requirements/quality.txt | 4 ++-- requirements/test.txt | 4 ++-- 5 files changed, 22 insertions(+), 20 deletions(-) diff --git a/requirements/base.txt b/requirements/base.txt index c13dd54..fd7d04e 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -8,9 +8,9 @@ appdirs==1.4.4 # via fs asgiref==3.8.1 # via django -boto3==1.36.18 +boto3==1.36.24 # via fs-s3fs -botocore==1.36.18 +botocore==1.36.24 # via # boto3 # s3transfer @@ -42,7 +42,6 @@ jmespath==1.0.1 # via # boto3 # botocore -lxml[html-clean,html_clean]==5.3.1 lxml[html-clean,html_clean]==5.3.1 # via # edx-i18n-tools @@ -80,7 +79,7 @@ pyyaml==6.0.2 # via # edx-i18n-tools # xblock -s3transfer==0.10.4 +s3transfer==0.11.2 # via boto3 simplejson==3.20.1 # via xblock diff --git a/requirements/dev.txt b/requirements/dev.txt index ace5c2f..fbe7f74 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -29,12 +29,12 @@ binaryornot==0.4.4 # -r requirements/quality.txt # -r requirements/test.txt # cookiecutter -boto3==1.36.18 +boto3==1.36.24 # via # -r requirements/quality.txt # -r requirements/test.txt # fs-s3fs -botocore==1.36.18 +botocore==1.36.24 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -80,7 +80,7 @@ click-log==0.4.0 # via # -r requirements/quality.txt # edx-lint -code-annotations==1.8.2 +code-annotations==2.2.0 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -100,7 +100,7 @@ coverage[toml]==7.6.12 # -r requirements/quality.txt # -r requirements/test.txt # pytest-cov -diff-cover==9.2.2 +diff-cover==9.2.3 # via -r requirements/dev.in dill==0.3.9 # via @@ -130,6 +130,11 @@ django-statici18n==2.6.0 # via # -r requirements/quality.txt # -r requirements/test.txt +dnspython==2.7.0 + # via + # -r requirements/quality.txt + # -r requirements/test.txt + # pymongo edx-i18n-tools==1.6.3 # via # -r requirements/dev.in @@ -379,7 +384,7 @@ rich==13.9.4 # -r requirements/quality.txt # -r requirements/test.txt # cookiecutter -s3transfer==0.10.4 +s3transfer==0.11.2 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -407,7 +412,6 @@ sqlparse==0.5.3 # -r requirements/quality.txt # -r requirements/test.txt # django -stevedore==5.4.0 stevedore==5.4.0 # via # -r requirements/quality.txt @@ -461,7 +465,6 @@ webob==1.8.9 # -r requirements/test.txt # xblock # xblock-sdk -wheel==0.45.1 wheel==0.45.1 # via # -r requirements/pip-tools.txt diff --git a/requirements/doc.txt b/requirements/doc.txt index d28f625..8167265 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -32,11 +32,11 @@ binaryornot==0.4.4 # via # -r requirements/test.txt # cookiecutter -boto3==1.36.21 +boto3==1.36.24 # via # -r requirements/test.txt # fs-s3fs -botocore==1.36.21 +botocore==1.36.24 # via # -r requirements/test.txt # boto3 @@ -314,12 +314,12 @@ rich==13.9.4 # -r requirements/test.txt # cookiecutter # twine +roman-numerals-py==3.0.0 + # via sphinx s3transfer==0.11.2 # via # -r requirements/test.txt # boto3 -secretstorage==3.3.3 - # via keyring simplejson==3.20.1 # via # -r requirements/test.txt @@ -335,7 +335,7 @@ snowballstemmer==2.2.0 # via sphinx soupsieve==2.6 # via beautifulsoup4 -sphinx==8.1.3 +sphinx==8.2.0 # via # -r requirements/doc.in # pydata-sphinx-theme diff --git a/requirements/quality.txt b/requirements/quality.txt index 8223a97..a18a0ce 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -24,11 +24,11 @@ binaryornot==0.4.4 # via # -r requirements/test.txt # cookiecutter -boto3==1.36.21 +boto3==1.36.24 # via # -r requirements/test.txt # fs-s3fs -botocore==1.36.21 +botocore==1.36.24 # via # -r requirements/test.txt # boto3 diff --git a/requirements/test.txt b/requirements/test.txt index 5da7012..25a4e3b 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -16,11 +16,11 @@ asgiref==3.8.1 # django binaryornot==0.4.4 # via cookiecutter -boto3==1.36.21 +boto3==1.36.24 # via # -r requirements/base.txt # fs-s3fs -botocore==1.36.21 +botocore==1.36.24 # via # -r requirements/base.txt # boto3 From 18f9b7aa7175495e1f59a3df0939ecd00d252a90 Mon Sep 17 00:00:00 2001 From: Muhammad Tayyab Tahir Qureshi Date: Mon, 24 Feb 2025 14:56:01 +0500 Subject: [PATCH 22/34] chore: add data package name to distinguish extracted block from the builtin one --- xblocks_contrib/lti/templates/lti.html | 1 + 1 file changed, 1 insertion(+) diff --git a/xblocks_contrib/lti/templates/lti.html b/xblocks_contrib/lti/templates/lti.html index b30fb6a..f761db3 100644 --- a/xblocks_contrib/lti/templates/lti.html +++ b/xblocks_contrib/lti/templates/lti.html @@ -26,6 +26,7 @@

class="{{ element_class }}" data-ask-to-send-username="{{ ask_to_send_username }}" data-ask-to-send-email="{{ ask_to_send_email }}" + data-package-name="xblocks-contrib" > {% if launch_url and launch_url != 'http://www.example.com' and not hide_launch %} From 0db139ab2c7cc9704f1907fda49badfa1cf229ec Mon Sep 17 00:00:00 2001 From: Muhammad Tayyab Tahir Qureshi Date: Wed, 5 Mar 2025 15:13:52 +0500 Subject: [PATCH 23/34] fix: change the LTI block identifier from `_lti_extracted` to `lti` --- xblocks_contrib/lti/static/css/lti.css | 16 ++++++++-------- xblocks_contrib/lti/static/js/src/lti.js | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/xblocks_contrib/lti/static/css/lti.css b/xblocks_contrib/lti/static/css/lti.css index de0d207..7e9b576 100644 --- a/xblocks_contrib/lti/static/css/lti.css +++ b/xblocks_contrib/lti/static/css/lti.css @@ -14,44 +14,44 @@ div.problem-progress { font-size: 1em; } -div._lti_extracted { +div.lti { margin: 0 auto; } -div._lti_extracted .wrapper-lti-link { +div.lti .wrapper-lti-link { font-size: 14px; background-color: #f6f6f6; padding: 20px; } -div._lti_extracted .wrapper-lti-link .lti-link { +div.lti .wrapper-lti-link .lti-link { margin-bottom: 0; text-align: right; } -div._lti_extracted .wrapper-lti-link .lti-link .link_lti_new_window { +div.lti .wrapper-lti-link .lti-link .link_lti_new_window { font-size: 13px; line-height: 20.72px; } -div._lti_extracted form.ltiLaunchForm { +div.lti form.ltiLaunchForm { display: none; } -div._lti_extracted iframe.ltiLaunchFrame { +div.lti iframe.ltiLaunchFrame { width: 100%; height: 800px; display: block; border: 0px; } -div._lti_extracted h4.problem-feedback-label { +div.lti h4.problem-feedback-label { font-weight: 100; font-size: 1em; font-family: "Source Sans", "Open Sans", Verdana, Geneva, sans-serif, sans-serif; } -div._lti_extracted div.problem-feedback { +div.lti div.problem-feedback { margin-top: 5px; margin-bottom: 5px; } diff --git a/xblocks_contrib/lti/static/js/src/lti.js b/xblocks_contrib/lti/static/js/src/lti.js index 01035f4..a9ab1fd 100644 --- a/xblocks_contrib/lti/static/js/src/lti.js +++ b/xblocks_contrib/lti/static/js/src/lti.js @@ -3,7 +3,7 @@ function LTIBlock(runtime, element) { 'use strict'; - const $lti = $(element).find('._lti_extracted'); + const $lti = $(element).find('.lti'); const askToSendUsername = $lti.data('ask-to-send-username') === 'True'; const askToSendEmail = $lti.data('ask-to-send-email') === 'True'; From c21f07a0a651f7351c6bcf426de8bb20d1de2c84 Mon Sep 17 00:00:00 2001 From: Muhammad Tayyab Tahir Qureshi Date: Fri, 4 Apr 2025 13:35:30 +0500 Subject: [PATCH 24/34] chore: chore --- xblocks_contrib/lti/lti.py | 38 -------------------------------------- 1 file changed, 38 deletions(-) diff --git a/xblocks_contrib/lti/lti.py b/xblocks_contrib/lti/lti.py index 4efbe1f..2629702 100644 --- a/xblocks_contrib/lti/lti.py +++ b/xblocks_contrib/lti/lti.py @@ -1017,41 +1017,3 @@ def is_past_due(self): else: close_date = due_date return close_date is not None and datetime.datetime.now(UTC) > close_date - - def bind_for_student(self, user_id, wrappers=None): - """ - Bind the XBlock to a specific student by user_id. - - Arguments: - user_id: The user_id to set in scope_ids. - wrappers: A list of functions to wrap the field data, if any. - """ - # If we're already bound to this user, skip re-binding - if self.scope_ids.user_id is not None and user_id == self.scope_ids.user_id: - return - - # Update scope_ids to the new user - self.scope_ids = self.scope_ids._replace(user_id=user_id) - - # Clear cached child data - self.clear_child_cache() - - # Clear cached field data - for field in self.fields.values(): - if hasattr(field.scope, 'user') and field.scope.user == UserScope.ONE: - field._del_cached_value(self) - if field in self._dirty_fields: - del self._dirty_fields[field] - - # Apply wrappers if provided - if wrappers: - wrapped_field_data = self.runtime.service(self, 'field-data-unbound') - for wrapper in wrappers: - wrapped_field_data = wrapper(wrapped_field_data) - self._bound_field_data = wrapped_field_data - - if getattr(self.runtime, "uses_deprecated_field_data", False): - self._field_data = wrapped_field_data - - # Optionally save the state if needed - self.save() From df29cead6d7f5f92a881f4223f350363e1c630d3 Mon Sep 17 00:00:00 2001 From: Muhammad Tayyab Tahir Qureshi Date: Thu, 17 Apr 2025 12:27:28 +0500 Subject: [PATCH 25/34] feat: add support of i18n --- xblocks_contrib/lti/lti.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/xblocks_contrib/lti/lti.py b/xblocks_contrib/lti/lti.py index 2629702..8a0d868 100644 --- a/xblocks_contrib/lti/lti.py +++ b/xblocks_contrib/lti/lti.py @@ -69,6 +69,7 @@ import nh3 import oauthlib.oauth1 from django.conf import settings +from django.utils.translation import gettext_noop as _ from lxml import etree from oauthlib.oauth1.rfc5849 import signature from opaque_keys.edx.keys import UsageKey @@ -101,13 +102,6 @@ ) BREAK_TAG = '
' -# Make '_' a no-op so we can scrape strings. Using lambda instead of -# `django.utils.translation.ugettext_noop` because Django cannot be imported in this file -def noop(text): - return text - -_ = noop - class LTIFields: """ Fields to define and obtain LTI tool from provider are set here, @@ -533,7 +527,8 @@ def student_view(self, context=None): frag = Fragment() frag.add_content( resource_loader.render_django_template( - "templates/lti.html", self.get_context() + "templates/lti.html", self.get_context(), + i18n_service=self.runtime.service(self, 'i18n') ) ) From f6f086a21e6d784f0a6a954dff96eeb3279190fd Mon Sep 17 00:00:00 2001 From: Muhammad Tayyab Tahir Qureshi Date: Thu, 17 Apr 2025 12:39:38 +0500 Subject: [PATCH 26/34] chore: upgrade requirements --- requirements/base.txt | 32 +++++++++---------- requirements/dev.txt | 68 ++++++++++++++++++++-------------------- requirements/doc.txt | 62 ++++++++++++++++++------------------ requirements/pip.txt | 2 +- requirements/quality.txt | 66 +++++++++++++++++++------------------- requirements/test.txt | 59 +++++++++++++++++----------------- 6 files changed, 145 insertions(+), 144 deletions(-) diff --git a/requirements/base.txt b/requirements/base.txt index 6020c86..d32df04 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -8,13 +8,13 @@ appdirs==1.4.4 # via fs asgiref==3.8.1 # via django -boto3==1.36.26 +boto3==1.37.35 # via fs-s3fs -botocore==1.36.26 +botocore==1.37.35 # via # boto3 # s3transfer -django==4.2.19 +django==4.2.20 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # django-appconf @@ -27,9 +27,9 @@ django-statici18n==2.6.0 # via -r requirements/base.in dnspython==2.7.0 # via pymongo -edx-i18n-tools==1.6.3 +edx-i18n-tools==1.7.0 # via -r requirements/base.in -edx-opaque-keys==2.11.0 +edx-opaque-keys==2.12.0 # via -r requirements/base.in fs==2.4.16 # via @@ -42,20 +42,20 @@ jmespath==1.0.1 # via # boto3 # botocore -lxml[html-clean,html_clean]==5.3.1 +lxml[html-clean,html_clean]==5.3.2 # via # edx-i18n-tools # lxml-html-clean # xblock -lxml-html-clean==0.4.1 +lxml-html-clean==0.4.2 # via lxml -mako==1.3.9 +mako==1.3.10 # via xblock markupsafe==3.0.2 # via # mako # xblock -nh3==0.2.20 +nh3==0.2.21 # via -r requirements/base.in oauthlib==3.2.2 # via -r requirements/base.in @@ -67,19 +67,19 @@ pbr==6.1.1 # via stevedore polib==1.2.0 # via edx-i18n-tools -pymongo==4.11.1 +pymongo==4.12.0 # via edx-opaque-keys python-dateutil==2.9.0.post0 # via # botocore # xblock -pytz==2025.1 +pytz==2025.2 # via xblock pyyaml==6.0.2 # via # edx-i18n-tools # xblock -s3transfer==0.11.2 +s3transfer==0.11.4 # via boto3 simplejson==3.20.1 # via xblock @@ -90,19 +90,19 @@ six==1.17.0 # python-dateutil sqlparse==0.5.3 # via django -stevedore==5.4.0 +stevedore==5.4.1 # via edx-opaque-keys -typing-extensions==4.12.2 +typing-extensions==4.13.2 # via edx-opaque-keys urllib3==2.2.3 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # botocore -web-fragments==2.2.0 +web-fragments==3.0.0 # via xblock webob==1.8.9 # via xblock -xblock==5.1.2 +xblock==5.2.0 # via -r requirements/base.in # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/dev.txt b/requirements/dev.txt index f1f0b95..08bcbad 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -19,7 +19,7 @@ asgiref==3.8.1 # -r requirements/quality.txt # -r requirements/test.txt # django -astroid==3.3.8 +astroid==3.3.9 # via # -r requirements/quality.txt # pylint @@ -29,12 +29,12 @@ binaryornot==0.4.4 # -r requirements/quality.txt # -r requirements/test.txt # cookiecutter -boto3==1.36.26 +boto3==1.37.35 # via # -r requirements/quality.txt # -r requirements/test.txt # fs-s3fs -botocore==1.36.26 +botocore==1.37.35 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -80,7 +80,7 @@ click-log==0.4.0 # via # -r requirements/quality.txt # edx-lint -code-annotations==2.2.0 +code-annotations==2.3.0 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -95,14 +95,14 @@ cookiecutter==2.6.0 # -r requirements/quality.txt # -r requirements/test.txt # xblock-sdk -coverage[toml]==7.6.12 +coverage[toml]==7.8.0 # via # -r requirements/quality.txt # -r requirements/test.txt # pytest-cov -diff-cover==9.2.3 +diff-cover==9.2.4 # via -r requirements/dev.in -dill==0.3.9 +dill==0.4.0 # via # -r requirements/quality.txt # pylint @@ -111,7 +111,7 @@ distlib==0.3.9 # -r requirements/quality.txt # -r requirements/test.txt # virtualenv -django==4.2.19 +django==4.2.20 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/quality.txt @@ -135,18 +135,18 @@ dnspython==2.7.0 # -r requirements/quality.txt # -r requirements/test.txt # pymongo -edx-i18n-tools==1.6.3 +edx-i18n-tools==1.7.0 # via # -r requirements/dev.in # -r requirements/quality.txt # -r requirements/test.txt edx-lint==5.6.0 # via -r requirements/quality.txt -edx-opaque-keys==2.11.0 +edx-opaque-keys==2.12.0 # via # -r requirements/quality.txt # -r requirements/test.txt -filelock==3.17.0 +filelock==3.18.0 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -170,16 +170,16 @@ idna==3.10 # -r requirements/quality.txt # -r requirements/test.txt # requests -iniconfig==2.0.0 +iniconfig==2.1.0 # via # -r requirements/quality.txt # -r requirements/test.txt # pytest -isort==6.0.0 +isort==6.0.1 # via # -r requirements/quality.txt # pylint -jinja2==3.1.5 +jinja2==3.1.6 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -192,7 +192,7 @@ jmespath==1.0.1 # -r requirements/test.txt # boto3 # botocore -lxml[html-clean]==5.3.1 +lxml[html-clean]==5.3.2 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -200,12 +200,12 @@ lxml[html-clean]==5.3.1 # lxml-html-clean # xblock # xblock-sdk -lxml-html-clean==0.4.1 +lxml-html-clean==0.4.2 # via # -r requirements/quality.txt # -r requirements/test.txt # lxml -mako==1.3.9 +mako==1.3.10 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -231,7 +231,7 @@ mdurl==0.1.2 # -r requirements/quality.txt # -r requirements/test.txt # markdown-it-py -nh3==0.2.20 +nh3==0.2.21 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -264,7 +264,7 @@ pbr==6.1.1 # stevedore pip-tools==7.4.1 # via -r requirements/pip-tools.txt -platformdirs==4.3.6 +platformdirs==4.3.7 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -283,7 +283,7 @@ polib==1.2.0 # -r requirements/quality.txt # -r requirements/test.txt # edx-i18n-tools -pycodestyle==2.12.1 +pycodestyle==2.13.0 # via -r requirements/quality.txt pydocstyle==6.3.0 # via -r requirements/quality.txt @@ -293,7 +293,7 @@ pygments==2.19.1 # -r requirements/test.txt # diff-cover # rich -pylint==3.3.4 +pylint==3.3.6 # via # -r requirements/quality.txt # edx-lint @@ -313,7 +313,7 @@ pylint-plugin-utils==0.8.2 # -r requirements/quality.txt # pylint-celery # pylint-django -pymongo==4.11.1 +pymongo==4.12.0 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -333,17 +333,17 @@ pyproject-hooks==1.2.0 # -r requirements/pip-tools.txt # build # pip-tools -pytest==8.3.4 +pytest==8.3.5 # via # -r requirements/quality.txt # -r requirements/test.txt # pytest-cov # pytest-django -pytest-cov==6.0.0 +pytest-cov==6.1.1 # via # -r requirements/quality.txt # -r requirements/test.txt -pytest-django==4.10.0 +pytest-django==4.11.1 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -360,7 +360,7 @@ python-slugify==8.0.4 # -r requirements/test.txt # code-annotations # cookiecutter -pytz==2025.1 +pytz==2025.2 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -379,12 +379,12 @@ requests==2.32.3 # -r requirements/test.txt # cookiecutter # xblock-sdk -rich==13.9.4 +rich==14.0.0 # via # -r requirements/quality.txt # -r requirements/test.txt # cookiecutter -s3transfer==0.11.2 +s3transfer==0.11.4 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -427,7 +427,7 @@ tomlkit==0.13.2 # via # -r requirements/quality.txt # pylint -tox==4.24.1 +tox==4.25.0 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -436,7 +436,7 @@ types-python-dateutil==2.9.0.20241206 # -r requirements/quality.txt # -r requirements/test.txt # arrow -typing-extensions==4.12.2 +typing-extensions==4.13.2 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -448,12 +448,12 @@ urllib3==2.2.3 # -r requirements/test.txt # botocore # requests -virtualenv==20.29.2 +virtualenv==20.30.0 # via # -r requirements/quality.txt # -r requirements/test.txt # tox -web-fragments==2.2.0 +web-fragments==3.0.0 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -469,12 +469,12 @@ wheel==0.45.1 # via # -r requirements/pip-tools.txt # pip-tools -xblock==5.1.2 +xblock==5.2.0 # via # -r requirements/quality.txt # -r requirements/test.txt # xblock-sdk -xblock-sdk==0.12.0 +xblock-sdk==0.13.0 # via # -r requirements/quality.txt # -r requirements/test.txt diff --git a/requirements/doc.txt b/requirements/doc.txt index 97f3bf4..bf9b590 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -26,17 +26,17 @@ babel==2.17.0 # sphinx backports-tarfile==1.2.0 # via jaraco-context -beautifulsoup4==4.13.3 +beautifulsoup4==4.13.4 # via pydata-sphinx-theme binaryornot==0.4.4 # via # -r requirements/test.txt # cookiecutter -boto3==1.36.26 +boto3==1.37.35 # via # -r requirements/test.txt # fs-s3fs -botocore==1.36.26 +botocore==1.37.35 # via # -r requirements/test.txt # boto3 @@ -65,7 +65,7 @@ click==8.1.8 # -r requirements/test.txt # code-annotations # cookiecutter -code-annotations==2.2.0 +code-annotations==2.3.0 # via -r requirements/test.txt colorama==0.4.6 # via @@ -75,7 +75,7 @@ cookiecutter==2.6.0 # via # -r requirements/test.txt # xblock-sdk -coverage[toml]==7.6.12 +coverage[toml]==7.8.0 # via # -r requirements/test.txt # pytest-cov @@ -83,7 +83,7 @@ distlib==0.3.9 # via # -r requirements/test.txt # virtualenv -django==4.2.19 +django==4.2.20 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt @@ -111,11 +111,11 @@ docutils==0.21.2 # readme-renderer # restructuredtext-lint # sphinx -edx-i18n-tools==1.6.3 +edx-i18n-tools==1.7.0 # via -r requirements/test.txt -edx-opaque-keys==2.11.0 +edx-opaque-keys==2.12.0 # via -r requirements/test.txt -filelock==3.17.0 +filelock==3.18.0 # via # -r requirements/test.txt # tox @@ -141,7 +141,7 @@ imagesize==1.4.1 # via sphinx importlib-metadata==8.6.1 # via keyring -iniconfig==2.0.0 +iniconfig==2.1.0 # via # -r requirements/test.txt # pytest @@ -151,7 +151,7 @@ jaraco-context==6.0.1 # via keyring jaraco-functools==4.1.0 # via keyring -jinja2==3.1.5 +jinja2==3.1.6 # via # -r requirements/test.txt # code-annotations @@ -164,18 +164,18 @@ jmespath==1.0.1 # botocore keyring==25.6.0 # via twine -lxml[html-clean]==5.3.1 +lxml[html-clean]==5.3.2 # via # -r requirements/test.txt # edx-i18n-tools # lxml-html-clean # xblock # xblock-sdk -lxml-html-clean==0.4.1 +lxml-html-clean==0.4.2 # via # -r requirements/test.txt # lxml -mako==1.3.9 +mako==1.3.10 # via # -r requirements/test.txt # xblock @@ -197,7 +197,7 @@ more-itertools==10.6.0 # via # jaraco-classes # jaraco-functools -nh3==0.2.20 +nh3==0.2.21 # via # -r requirements/test.txt # readme-renderer @@ -223,7 +223,7 @@ pbr==6.1.1 # via # -r requirements/test.txt # stevedore -platformdirs==4.3.6 +platformdirs==4.3.7 # via # -r requirements/test.txt # tox @@ -237,8 +237,6 @@ polib==1.2.0 # via # -r requirements/test.txt # edx-i18n-tools -pycparser==2.22 - # via cffi pydata-sphinx-theme==0.15.4 # via sphinx-book-theme pygments==2.19.1 @@ -250,7 +248,7 @@ pygments==2.19.1 # readme-renderer # rich # sphinx -pymongo==4.11.1 +pymongo==4.12.0 # via # -r requirements/test.txt # edx-opaque-keys @@ -264,14 +262,14 @@ pyproject-api==1.9.0 # tox pyproject-hooks==1.2.0 # via build -pytest==8.3.4 +pytest==8.3.5 # via # -r requirements/test.txt # pytest-cov # pytest-django -pytest-cov==6.0.0 +pytest-cov==6.1.1 # via -r requirements/test.txt -pytest-django==4.10.0 +pytest-django==4.11.1 # via -r requirements/test.txt python-dateutil==2.9.0.post0 # via @@ -284,7 +282,7 @@ python-slugify==8.0.4 # -r requirements/test.txt # code-annotations # cookiecutter -pytz==2025.1 +pytz==2025.2 # via # -r requirements/test.txt # xblock @@ -312,14 +310,14 @@ restructuredtext-lint==1.4.0 # via doc8 rfc3986==2.0.0 # via twine -rich==13.9.4 +rich==14.0.0 # via # -r requirements/test.txt # cookiecutter # twine roman-numerals-py==3.1.0 # via sphinx -s3transfer==0.11.2 +s3transfer==0.11.4 # via # -r requirements/test.txt # boto3 @@ -338,7 +336,7 @@ snowballstemmer==2.2.0 # via sphinx soupsieve==2.6 # via beautifulsoup4 -sphinx==8.2.1 +sphinx==8.2.3 # via # -r requirements/doc.in # pydata-sphinx-theme @@ -371,7 +369,7 @@ text-unidecode==1.3 # via # -r requirements/test.txt # python-slugify -tox==4.24.1 +tox==4.25.0 # via -r requirements/test.txt twine==6.1.0 # via -r requirements/doc.in @@ -379,7 +377,7 @@ types-python-dateutil==2.9.0.20241206 # via # -r requirements/test.txt # arrow -typing-extensions==4.12.2 +typing-extensions==4.13.2 # via # -r requirements/test.txt # beautifulsoup4 @@ -392,11 +390,11 @@ urllib3==2.2.3 # botocore # requests # twine -virtualenv==20.29.2 +virtualenv==20.30.0 # via # -r requirements/test.txt # tox -web-fragments==2.2.0 +web-fragments==3.0.0 # via # -r requirements/test.txt # xblock @@ -406,11 +404,11 @@ webob==1.8.9 # -r requirements/test.txt # xblock # xblock-sdk -xblock==5.1.2 +xblock==5.2.0 # via # -r requirements/test.txt # xblock-sdk -xblock-sdk==0.12.0 +xblock-sdk==0.13.0 # via -r requirements/test.txt zipp==3.21.0 # via importlib-metadata diff --git a/requirements/pip.txt b/requirements/pip.txt index e6c3348..e8191d7 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -12,5 +12,5 @@ pip==24.2 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/pip.in -setuptools==75.8.0 +setuptools==78.1.0 # via -r requirements/pip.in diff --git a/requirements/quality.txt b/requirements/quality.txt index d9b53cf..3f55741 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -16,7 +16,7 @@ asgiref==3.8.1 # via # -r requirements/test.txt # django -astroid==3.3.8 +astroid==3.3.9 # via # pylint # pylint-celery @@ -24,11 +24,11 @@ binaryornot==0.4.4 # via # -r requirements/test.txt # cookiecutter -boto3==1.36.26 +boto3==1.37.35 # via # -r requirements/test.txt # fs-s3fs -botocore==1.36.26 +botocore==1.37.35 # via # -r requirements/test.txt # boto3 @@ -59,7 +59,7 @@ click==8.1.8 # edx-lint click-log==0.4.0 # via edx-lint -code-annotations==2.2.0 +code-annotations==2.3.0 # via # -r requirements/test.txt # edx-lint @@ -71,17 +71,17 @@ cookiecutter==2.6.0 # via # -r requirements/test.txt # xblock-sdk -coverage[toml]==7.6.12 +coverage[toml]==7.8.0 # via # -r requirements/test.txt # pytest-cov -dill==0.3.9 +dill==0.4.0 # via pylint distlib==0.3.9 # via # -r requirements/test.txt # virtualenv -django==4.2.19 +django==4.2.20 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt @@ -100,13 +100,13 @@ dnspython==2.7.0 # via # -r requirements/test.txt # pymongo -edx-i18n-tools==1.6.3 +edx-i18n-tools==1.7.0 # via -r requirements/test.txt edx-lint==5.6.0 # via -r requirements/quality.in -edx-opaque-keys==2.11.0 +edx-opaque-keys==2.12.0 # via -r requirements/test.txt -filelock==3.17.0 +filelock==3.18.0 # via # -r requirements/test.txt # tox @@ -126,15 +126,15 @@ idna==3.10 # via # -r requirements/test.txt # requests -iniconfig==2.0.0 +iniconfig==2.1.0 # via # -r requirements/test.txt # pytest -isort==6.0.0 +isort==6.0.1 # via # -r requirements/quality.in # pylint -jinja2==3.1.5 +jinja2==3.1.6 # via # -r requirements/test.txt # code-annotations @@ -144,18 +144,18 @@ jmespath==1.0.1 # -r requirements/test.txt # boto3 # botocore -lxml[html-clean]==5.3.1 +lxml[html-clean]==5.3.2 # via # -r requirements/test.txt # edx-i18n-tools # lxml-html-clean # xblock # xblock-sdk -lxml-html-clean==0.4.1 +lxml-html-clean==0.4.2 # via # -r requirements/test.txt # lxml -mako==1.3.9 +mako==1.3.10 # via # -r requirements/test.txt # xblock @@ -175,7 +175,7 @@ mdurl==0.1.2 # via # -r requirements/test.txt # markdown-it-py -nh3==0.2.20 +nh3==0.2.21 # via -r requirements/test.txt oauthlib==3.2.2 # via -r requirements/test.txt @@ -195,7 +195,7 @@ pbr==6.1.1 # via # -r requirements/test.txt # stevedore -platformdirs==4.3.6 +platformdirs==4.3.7 # via # -r requirements/test.txt # pylint @@ -210,7 +210,7 @@ polib==1.2.0 # via # -r requirements/test.txt # edx-i18n-tools -pycodestyle==2.12.1 +pycodestyle==2.13.0 # via -r requirements/quality.in pydocstyle==6.3.0 # via -r requirements/quality.in @@ -218,7 +218,7 @@ pygments==2.19.1 # via # -r requirements/test.txt # rich -pylint==3.3.4 +pylint==3.3.6 # via # edx-lint # pylint-celery @@ -232,7 +232,7 @@ pylint-plugin-utils==0.8.2 # via # pylint-celery # pylint-django -pymongo==4.11.1 +pymongo==4.12.0 # via # -r requirements/test.txt # edx-opaque-keys @@ -244,14 +244,14 @@ pyproject-api==1.9.0 # via # -r requirements/test.txt # tox -pytest==8.3.4 +pytest==8.3.5 # via # -r requirements/test.txt # pytest-cov # pytest-django -pytest-cov==6.0.0 +pytest-cov==6.1.1 # via -r requirements/test.txt -pytest-django==4.10.0 +pytest-django==4.11.1 # via -r requirements/test.txt python-dateutil==2.9.0.post0 # via @@ -264,7 +264,7 @@ python-slugify==8.0.4 # -r requirements/test.txt # code-annotations # cookiecutter -pytz==2025.1 +pytz==2025.2 # via # -r requirements/test.txt # xblock @@ -280,11 +280,11 @@ requests==2.32.3 # -r requirements/test.txt # cookiecutter # xblock-sdk -rich==13.9.4 +rich==14.0.0 # via # -r requirements/test.txt # cookiecutter -s3transfer==0.11.2 +s3transfer==0.11.4 # via # -r requirements/test.txt # boto3 @@ -317,13 +317,13 @@ text-unidecode==1.3 # python-slugify tomlkit==0.13.2 # via pylint -tox==4.24.1 +tox==4.25.0 # via -r requirements/test.txt types-python-dateutil==2.9.0.20241206 # via # -r requirements/test.txt # arrow -typing-extensions==4.12.2 +typing-extensions==4.13.2 # via # -r requirements/test.txt # edx-opaque-keys @@ -333,11 +333,11 @@ urllib3==2.2.3 # -r requirements/test.txt # botocore # requests -virtualenv==20.29.2 +virtualenv==20.30.0 # via # -r requirements/test.txt # tox -web-fragments==2.2.0 +web-fragments==3.0.0 # via # -r requirements/test.txt # xblock @@ -347,11 +347,11 @@ webob==1.8.9 # -r requirements/test.txt # xblock # xblock-sdk -xblock==5.1.2 +xblock==5.2.0 # via # -r requirements/test.txt # xblock-sdk -xblock-sdk==0.12.0 +xblock-sdk==0.13.0 # via -r requirements/test.txt # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/test.txt b/requirements/test.txt index 121be26..1c7a290 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -16,11 +16,11 @@ asgiref==3.8.1 # django binaryornot==0.4.4 # via cookiecutter -boto3==1.36.26 +boto3==1.37.35 # via # -r requirements/base.txt # fs-s3fs -botocore==1.36.26 +botocore==1.37.35 # via # -r requirements/base.txt # boto3 @@ -39,13 +39,13 @@ click==8.1.8 # via # code-annotations # cookiecutter -code-annotations==2.2.0 +code-annotations==2.3.0 # via -r requirements/test.in colorama==0.4.6 # via tox cookiecutter==2.6.0 # via xblock-sdk -coverage[toml]==7.6.12 +coverage[toml]==7.8.0 # via pytest-cov distlib==0.3.9 # via virtualenv @@ -67,11 +67,11 @@ dnspython==2.7.0 # via # -r requirements/base.txt # pymongo -edx-i18n-tools==1.6.3 +edx-i18n-tools==1.7.0 # via -r requirements/base.txt -edx-opaque-keys==2.11.0 +edx-opaque-keys==2.12.0 # via -r requirements/base.txt -filelock==3.17.0 +filelock==3.18.0 # via # tox # virtualenv @@ -88,9 +88,9 @@ fs-s3fs==1.1.1 # xblock-sdk idna==3.10 # via requests -iniconfig==2.0.0 +iniconfig==2.1.0 # via pytest -jinja2==3.1.5 +jinja2==3.1.6 # via # code-annotations # cookiecutter @@ -99,18 +99,18 @@ jmespath==1.0.1 # -r requirements/base.txt # boto3 # botocore -lxml[html-clean]==5.3.1 +lxml[html-clean]==5.3.2 # via # -r requirements/base.txt # edx-i18n-tools # lxml-html-clean # xblock # xblock-sdk -lxml-html-clean==0.4.1 +lxml-html-clean==0.4.2 # via # -r requirements/base.txt # lxml -mako==1.3.9 +mako==1.3.10 # via # -r requirements/base.txt # xblock @@ -124,7 +124,7 @@ markupsafe==3.0.2 # xblock mdurl==0.1.2 # via markdown-it-py -nh3==0.2.20 +nh3==0.2.21 # via -r requirements/base.txt oauthlib==3.2.2 # via -r requirements/base.txt @@ -143,7 +143,7 @@ pbr==6.1.1 # via # -r requirements/base.txt # stevedore -platformdirs==4.3.6 +platformdirs==4.3.7 # via # tox # virtualenv @@ -157,7 +157,7 @@ polib==1.2.0 # edx-i18n-tools pygments==2.19.1 # via rich -pymongo==4.11.1 +pymongo==4.12.0 # via # -r requirements/base.txt # edx-opaque-keys @@ -165,13 +165,13 @@ pypng==0.20220715.0 # via xblock-sdk pyproject-api==1.9.0 # via tox -pytest==8.3.4 +pytest==8.3.5 # via # pytest-cov # pytest-django -pytest-cov==6.0.0 +pytest-cov==6.1.1 # via -r requirements/test.in -pytest-django==4.10.0 +pytest-django==4.11.1 # via -r requirements/test.in python-dateutil==2.9.0.post0 # via @@ -183,7 +183,7 @@ python-slugify==8.0.4 # via # code-annotations # cookiecutter -pytz==2025.1 +pytz==2025.2 # via # -r requirements/base.txt # xblock @@ -198,9 +198,9 @@ requests==2.32.3 # via # cookiecutter # xblock-sdk -rich==13.9.4 +rich==14.0.0 # via cookiecutter -s3transfer==0.11.2 +s3transfer==0.11.4 # via # -r requirements/base.txt # boto3 @@ -220,14 +220,17 @@ sqlparse==0.5.3 # -r requirements/base.txt # django stevedore==5.4.1 - # via code-annotations + # via + # -r requirements/base.txt + # code-annotations + # edx-opaque-keys text-unidecode==1.3 # via python-slugify -tox==4.24.1 +tox==4.25.0 # via -r requirements/test.in types-python-dateutil==2.9.0.20241206 # via arrow -typing-extensions==4.12.2 +typing-extensions==4.13.2 # via # -r requirements/base.txt # edx-opaque-keys @@ -237,9 +240,9 @@ urllib3==2.2.3 # -r requirements/base.txt # botocore # requests -virtualenv==20.29.2 +virtualenv==20.30.0 # via tox -web-fragments==2.2.0 +web-fragments==3.0.0 # via # -r requirements/base.txt # xblock @@ -249,11 +252,11 @@ webob==1.8.9 # -r requirements/base.txt # xblock # xblock-sdk -xblock==5.1.2 +xblock==5.2.0 # via # -r requirements/base.txt # xblock-sdk -xblock-sdk==0.12.0 +xblock-sdk==0.13.0 # via -r requirements/test.in # The following packages are considered to be unsafe in a requirements file: From e2e6e711f6febef88bd8e92fd502eb8051fc01b5 Mon Sep 17 00:00:00 2001 From: Muhammad Tayyab Tahir Qureshi Date: Thu, 17 Apr 2025 17:27:08 +0500 Subject: [PATCH 27/34] fix: fix pylint issues --- xblocks_contrib/lti/lti.py | 39 ++++++++------------ xblocks_contrib/lti/lti_2_util.py | 14 ++++--- xblocks_contrib/lti/tests/helpers.py | 12 +++--- xblocks_contrib/lti/tests/test_lti20_unit.py | 10 ++--- xblocks_contrib/lti/tests/test_lti_unit.py | 6 +-- 5 files changed, 35 insertions(+), 46 deletions(-) diff --git a/xblocks_contrib/lti/lti.py b/xblocks_contrib/lti/lti.py index 8a0d868..8af1161 100644 --- a/xblocks_contrib/lti/lti.py +++ b/xblocks_contrib/lti/lti.py @@ -53,19 +53,16 @@ GET / PUT / DELETE HTTP methods respectively """ -from importlib.resources import files - import base64 import datetime import hashlib import logging -import markupsafe import textwrap -import uuid from xml.sax.saxutils import escape from unittest import mock from urllib import parse +import markupsafe import nh3 import oauthlib.oauth1 from django.conf import settings @@ -77,7 +74,7 @@ from webob import Response from web_fragments.fragment import Fragment from xblock.core import List, Scope, String, XBlock -from xblock.fields import Boolean, Float, UserScope +from xblock.fields import Boolean, Float try: from xblock.utils.resources import ResourceLoader from xblock.utils.studio_editable import StudioEditableXBlockMixin @@ -291,7 +288,7 @@ class LTIBlock( LTI20BlockMixin, StudioEditableXBlockMixin, XBlock, -): # pylint: disable=abstract-method +): """ THIS MODULE IS DEPRECATED IN FAVOR OF https://github.com/openedx/xblock-lti-consumer @@ -520,7 +517,7 @@ def get_context(self): 'accept_grades_past_due': self.accept_grades_past_due, } - def student_view(self, context=None): + def student_view(self, _context=None): """ Create primary view of the LTIBlock, shown to students when viewing courses. """ @@ -785,7 +782,7 @@ def role(self): def get_icon_class(self): """ Returns the icon class """ - if self.graded and self.has_score: # pylint: disable=no-member + if self.graded and self.has_score: return 'problem' return 'other' @@ -886,7 +883,7 @@ def oauth_params(self, custom_parameters, client_key, client_secret): # so '='' becomes '%3D'. # We send form via browser, so browser will encode it again, # So we need to decode signature back: - params['oauth_signature'] = parse.unquote(params['oauth_signature']).encode('utf-8').decode('utf8') # lint-amnesty, pylint: disable=line-too-long + params['oauth_signature'] = parse.unquote(params['oauth_signature']).encode('utf-8').decode('utf8') # Add LTI parameters to OAuth parameters for sending in form. params.update(body) @@ -963,23 +960,19 @@ def verify_oauth_body_sign(self, request, content_type='application/x-www-form-u ) if oauth_body_hash != oauth_headers.get('oauth_body_hash'): log.error( - "OAuth body hash verification failed, provided: {}, " - "calculated: {}, for url: {}, body is: {}".format( - oauth_headers.get('oauth_body_hash'), - oauth_body_hash, - self.get_outcome_service_url(), - request.body - ) + "OAuth body hash verification failed, provided: %s, calculated: %s, for url: %s, body is: %s", + oauth_headers.get('oauth_body_hash'), + oauth_body_hash, + self.get_outcome_service_url(), + request.body ) raise LTIError("OAuth body hash verification is failed.") if (not signature.verify_hmac_sha1(mock_request_lti_1, client_secret) and not signature.verify_hmac_sha1(mock_request_lti_2, client_secret)): log.error( - "OAuth signature verification failed, for " - "headers:{} url:{} method:{}".format( - oauth_headers, self.get_outcome_service_url(), str(request.method) - ) + "OAuth signature verification failed, for headers: %s url: %s method: %s", + oauth_headers, self.get_outcome_service_url(), str(request.method) ) raise LTIError("OAuth signature verification has failed.") @@ -1006,9 +999,9 @@ def is_past_due(self): """ Is it now past this problem's due date, including grace period? """ - due_date = self.due # pylint: disable=no-member - if self.graceperiod is not None and due_date: # pylint: disable=no-member - close_date = due_date + self.graceperiod # pylint: disable=no-member + due_date = self.due + if self.graceperiod is not None and due_date: + close_date = due_date + self.graceperiod else: close_date = due_date return close_date is not None and datetime.datetime.now(UTC) > close_date diff --git a/xblocks_contrib/lti/lti_2_util.py b/xblocks_contrib/lti/lti_2_util.py index b1de096..23544e6 100644 --- a/xblocks_contrib/lti/lti_2_util.py +++ b/xblocks_contrib/lti/lti_2_util.py @@ -125,8 +125,10 @@ def _log_correct_authorization_header(self, request): mock_request.oauth_params.append(('oauth_signature', sig)) _, headers, _ = client._render(mock_request) # pylint: disable=protected-access - log.debug("\n\n#### COPY AND PASTE AUTHORIZATION HEADER ####\n{}\n####################################\n\n" - .format(headers['Authorization'])) + log.debug( + "\n\n#### COPY AND PASTE AUTHORIZATION HEADER ####\n%s\n####################################\n\n", + headers['Authorization'] + ) def parse_lti_2_0_handler_suffix(self, suffix): """ @@ -173,7 +175,7 @@ def _round_away_from_zero(self, number, digits=0): else: return float(math.ceil((number * p) - 0.5)) / p - def _lti_2_0_result_get_handler(self, request, real_user): + def _lti_2_0_result_get_handler(self, _request, real_user): """ Helper request handler for GET requests to LTI 2.0 result endpoint @@ -199,7 +201,7 @@ def _lti_2_0_result_get_handler(self, request, real_user): base_json_obj['comment'] = self.score_comment return Response(json.dumps(base_json_obj).encode('utf-8'), content_type=LTI_2_0_JSON_CONTENT_TYPE) - def _lti_2_0_result_del_handler(self, request, real_user): + def _lti_2_0_result_del_handler(self, _request, real_user): """ Helper request handler for DELETE requests to LTI 2.0 result endpoint @@ -240,7 +242,7 @@ def _lti_2_0_result_put_handler(self, request, real_user): return Response(status=200) # Fall-through record the score and the comment in the block - self.set_user_module_score(real_user, score, self.max_score(), comment) + self.set_user_module_score(real_user, score, self.max_score(), comment=comment) return Response(status=200) def clear_user_module_score(self, user): @@ -255,7 +257,7 @@ def clear_user_module_score(self, user): """ self.set_user_module_score(user, None, None, score_deleted=True) - def set_user_module_score(self, user, score, max_score, comment="", score_deleted=False): + def set_user_module_score(self, user, score, max_score, *, comment="", score_deleted=False): """ Sets the module user state, including grades and comments, and also scoring in db's courseware_studentmodule diff --git a/xblocks_contrib/lti/tests/helpers.py b/xblocks_contrib/lti/tests/helpers.py index 7beebef..11a1419 100644 --- a/xblocks_contrib/lti/tests/helpers.py +++ b/xblocks_contrib/lti/tests/helpers.py @@ -5,10 +5,8 @@ import datetime import re from unittest.mock import Mock -from opaque_keys.edx.keys import CourseKey from xblock.fields import JSONField from xblock.reference.user_service import UserService, XBlockUser -from xblock.reference.plugins import NO_CACHE_VALUE from xblock.runtime import Runtime @@ -19,7 +17,7 @@ class Timedelta(JSONField): # lint-amnesty, pylint: disable=missing-class-docst # Timedeltas are immutable, see http://docs.python.org/2/library/datetime.html#available-types MUTABLE = False - def from_json(self, time_str): # lint-amnesty, pylint: disable=arguments-differ + def from_json(self, time_str): # lint-amnesty, pylint: disable=arguments-renamed, inconsistent-return-statements """ time_str: A string with the following components: day[s] (optional) @@ -71,7 +69,7 @@ class StubUserService(UserService): Stub UserService for testing the sequence block. """ - def __init__(self, + def __init__(self, # pylint: disable=too-many-positional-arguments user=None, user_is_staff=False, user_role=None, @@ -114,7 +112,7 @@ def get_user_by_anonymous_id(self, uid=None): # pylint: disable=unused-argument return self.user -class MockRuntime(Runtime): +class MockRuntime(Runtime): #pylint: disable=abstract-method """A mock implementation of the Runtime class for testing purposes.""" def __init__(self, anonymous_student_id, services=None): @@ -122,10 +120,10 @@ def __init__(self, anonymous_student_id, services=None): super().__init__(id_reader=lambda: None, id_generator=lambda: None, services=services) self.anonymous_student_id = anonymous_student_id - def handler_url(self, block, handler_name, suffix="", thirdparty=False): + def handler_url(self, block, handler_name, suffix="", query="", thirdparty=False): #pylint: disable=too-many-positional-arguments return f"/mock_url/{handler_name}" - def local_resource_url(self, block, resource): + def local_resource_url(self, block, resource): #pylint: disable=arguments-renamed return f"/mock_resource_url/{resource}" def resource_url(self, resource): diff --git a/xblocks_contrib/lti/tests/test_lti20_unit.py b/xblocks_contrib/lti/tests/test_lti20_unit.py index 85f9641..51d2a02 100644 --- a/xblocks_contrib/lti/tests/test_lti20_unit.py +++ b/xblocks_contrib/lti/tests/test_lti20_unit.py @@ -266,7 +266,7 @@ def test_lti20_put_like_delete_success(self): assert self.xblock.score_comment == "" (_, evt_type, called_grade_obj), _ = ( self.runtime.publish.call_args - ) # pylint: disable=unpacking-non-sequence + ) assert called_grade_obj == { "user_id": self.USER_STANDIN.id, "value": None, @@ -293,7 +293,7 @@ def test_lti20_delete_success(self): assert self.xblock.score_comment == "" (_, evt_type, called_grade_obj), _ = ( self.runtime.publish.call_args - ) # pylint: disable=unpacking-non-sequence + ) assert called_grade_obj == { "user_id": self.USER_STANDIN.id, "value": None, @@ -316,7 +316,7 @@ def test_lti20_put_set_score_success(self): assert self.xblock.score_comment == "ಠ益ಠ" (_, evt_type, called_grade_obj), _ = ( self.runtime.publish.call_args - ) # pylint: disable=unpacking-non-sequence + ) assert evt_type == "grade" assert called_grade_obj == { "user_id": self.USER_STANDIN.id, @@ -410,9 +410,7 @@ def test_lti20_request_handler_bad_user(self): Test that we get a 404 when the supplied user does not exist """ self.setup_system_xblock_mocks_for_lti20_request_test() - self.runtime._services["user"] = StubUserService( - user=None - ) # pylint: disable=protected-access + self.runtime._services["user"] = StubUserService(user=None) # pylint: disable=protected-access mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") assert response.status_code == 404 diff --git a/xblocks_contrib/lti/tests/test_lti_unit.py b/xblocks_contrib/lti/tests/test_lti_unit.py index f3040dc..131daea 100644 --- a/xblocks_contrib/lti/tests/test_lti_unit.py +++ b/xblocks_contrib/lti/tests/test_lti_unit.py @@ -82,7 +82,7 @@ def setUp(self): sourced_id = ":".join( parse.quote(i) for i in (self.lti_id, self.unquoted_resource_link_id, self.user_id) - ) # lint-amnesty, pylint: disable=line-too-long + ) self.defaults = { "namespace": "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0", @@ -186,9 +186,7 @@ def test_real_user_is_none(self): """ If we have no real user, we should send back failure response. """ - self.runtime._services["user"] = StubUserService( - user=None - ) # pylint: disable=protected-access + self.runtime._services["user"] = StubUserService(user=None) # pylint: disable=protected-access self.xblock.verify_oauth_body_sign = Mock() self.xblock.has_score = True request = Request(self.environ) From 19a91d1916c79bafda2e82532c008644f7487baf Mon Sep 17 00:00:00 2001 From: Muhammad Tayyab Tahir Qureshi Date: Thu, 17 Apr 2025 18:02:14 +0500 Subject: [PATCH 28/34] fix: fix quality --- xblocks_contrib/lti/lti.py | 21 ++++++++++++-------- xblocks_contrib/lti/tests/helpers.py | 21 ++++++++++++++------ xblocks_contrib/lti/tests/test_lti20_unit.py | 5 +++-- xblocks_contrib/lti/tests/test_lti_unit.py | 4 ++-- 4 files changed, 33 insertions(+), 18 deletions(-) diff --git a/xblocks_contrib/lti/lti.py b/xblocks_contrib/lti/lti.py index 8af1161..a8924cf 100644 --- a/xblocks_contrib/lti/lti.py +++ b/xblocks_contrib/lti/lti.py @@ -58,9 +58,9 @@ import hashlib import logging import textwrap -from xml.sax.saxutils import escape from unittest import mock from urllib import parse +from xml.sax.saxutils import escape import markupsafe import nh3 @@ -71,10 +71,11 @@ from oauthlib.oauth1.rfc5849 import signature from opaque_keys.edx.keys import UsageKey from pytz import UTC -from webob import Response from web_fragments.fragment import Fragment +from webob import Response from xblock.core import List, Scope, String, XBlock from xblock.fields import Boolean, Float + try: from xblock.utils.resources import ResourceLoader from xblock.utils.studio_editable import StudioEditableXBlockMixin @@ -99,6 +100,7 @@ ) BREAK_TAG = '
' + class LTIFields: """ Fields to define and obtain LTI tool from provider are set here, @@ -230,7 +232,8 @@ class LTIFields: ) # Users will be presented with a message indicating that their e-mail/username would be sent to a third - # party application. When "Open in New Page" is not selected, the tool automatically appears without any user action. # lint-amnesty, pylint: disable=line-too-long + # party application. When "Open in New Page" is not selected, the tool automatically appears without any + # user action. ask_to_send_username = Boolean( display_name=_("Request user's username"), # Translators: This is used to request the user's username for a third party service. @@ -250,8 +253,8 @@ class LTIFields: description = String( display_name=_("LTI Application Information"), help=_( - "Enter a description of the third party application. If requesting username and/or email, use this text box to inform users " # lint-amnesty, pylint: disable=line-too-long - "why their username and/or email will be forwarded to a third party application." + "Enter a description of the third party application. If requesting username and/or email, use this text" + " box to inform users why their username and/or email will be forwarded to a third party application." ), default="", scope=Scope.settings @@ -275,7 +278,7 @@ class LTIFields: editable_fields = ( "accept_grades_past_due", "ask_to_send_email", "ask_to_send_username", "button_text", - "custom_parameters", "description", "display_name", "has_score", "hide_launch", + "custom_parameters", "description", "display_name", "has_score", "hide_launch", "launch_url", "lti_id", "open_in_a_new_page", "weight", ) @@ -834,7 +837,7 @@ def oauth_params(self, custom_parameters, client_key, client_secret): except AttributeError: self.user_email = "" # lint-amnesty, pylint: disable=attribute-defined-outside-init try: - self.user_username = real_user_object.username # lint-amnesty, pylint: disable=attribute-defined-outside-init + self.user_username = real_user_object.username # pylint: disable=attribute-defined-outside-init except AttributeError: self.user_username = "" # lint-amnesty, pylint: disable=attribute-defined-outside-init @@ -910,7 +913,9 @@ def parse_grade_xml_body(cls, body): imsx_messageIdentifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text or '' sourcedId = root.xpath("//def:sourcedId", namespaces=namespaces)[0].text score = root.xpath("//def:textString", namespaces=namespaces)[0].text - action = root.xpath("//def:imsx_POXBody", namespaces=namespaces)[0].getchildren()[0].tag.replace('{' + lti_spec_namespace + '}', '') # lint-amnesty, pylint: disable=line-too-long + action = root.xpath( + "//def:imsx_POXBody", namespaces=namespaces + )[0].getchildren()[0].tag.replace('{' + lti_spec_namespace + '}', '') # Raise exception if score is not float or not in range 0.0-1.0 regarding spec. score = float(score) if not 0 <= score <= 1: diff --git a/xblocks_contrib/lti/tests/helpers.py b/xblocks_contrib/lti/tests/helpers.py index 11a1419..23c7161 100644 --- a/xblocks_contrib/lti/tests/helpers.py +++ b/xblocks_contrib/lti/tests/helpers.py @@ -5,12 +5,19 @@ import datetime import re from unittest.mock import Mock + from xblock.fields import JSONField from xblock.reference.user_service import UserService, XBlockUser from xblock.runtime import Runtime - -TIMEDELTA_REGEX = re.compile(r'^((?P\d+?) day(?:s?))?(\s)?((?P\d+?) hour(?:s?))?(\s)?((?P\d+?) minute(?:s)?)?(\s)?((?P\d+?) second(?:s)?)?$') # lint-amnesty, pylint: disable=line-too-long +TIMEDELTA_REGEX = re.compile( + r'^' + r'((?P\d+?) day(?:s?))?(\s)?' + r'((?P\d+?) hour(?:s?))?(\s)?' + r'((?P\d+?) minute(?:s)?)?(\s)?' + r'((?P\d+?) second(?:s)?)?' + r'$' +) class Timedelta(JSONField): # lint-amnesty, pylint: disable=missing-class-docstring @@ -69,7 +76,7 @@ class StubUserService(UserService): Stub UserService for testing the sequence block. """ - def __init__(self, # pylint: disable=too-many-positional-arguments + def __init__(self, # pylint: disable=too-many-positional-arguments user=None, user_is_staff=False, user_role=None, @@ -112,7 +119,7 @@ def get_user_by_anonymous_id(self, uid=None): # pylint: disable=unused-argument return self.user -class MockRuntime(Runtime): #pylint: disable=abstract-method +class MockRuntime(Runtime): # pylint: disable=abstract-method """A mock implementation of the Runtime class for testing purposes.""" def __init__(self, anonymous_student_id, services=None): @@ -120,10 +127,12 @@ def __init__(self, anonymous_student_id, services=None): super().__init__(id_reader=lambda: None, id_generator=lambda: None, services=services) self.anonymous_student_id = anonymous_student_id - def handler_url(self, block, handler_name, suffix="", query="", thirdparty=False): #pylint: disable=too-many-positional-arguments + def handler_url( + self, block, handler_name, suffix="", query="", thirdparty=False + ): # pylint: disable=too-many-positional-arguments return f"/mock_url/{handler_name}" - def local_resource_url(self, block, resource): #pylint: disable=arguments-renamed + def local_resource_url(self, block, resource): # pylint: disable=arguments-renamed return f"/mock_resource_url/{resource}" def resource_url(self, resource): diff --git a/xblocks_contrib/lti/tests/test_lti20_unit.py b/xblocks_contrib/lti/tests/test_lti20_unit.py index 51d2a02..efad5cc 100644 --- a/xblocks_contrib/lti/tests/test_lti20_unit.py +++ b/xblocks_contrib/lti/tests/test_lti20_unit.py @@ -4,13 +4,14 @@ import textwrap from unittest.mock import Mock -from pytz import UTC from django.conf import settings from django.test import TestCase, override_settings +from pytz import UTC from xblock.field_data import DictFieldData -from xblocks_contrib.lti.lti_2_util import LTIError from xblocks_contrib.lti.lti import LTIBlock +from xblocks_contrib.lti.lti_2_util import LTIError + from .helpers import StubUserService, get_test_system diff --git a/xblocks_contrib/lti/tests/test_lti_unit.py b/xblocks_contrib/lti/tests/test_lti_unit.py index 131daea..cbefdda 100644 --- a/xblocks_contrib/lti/tests/test_lti_unit.py +++ b/xblocks_contrib/lti/tests/test_lti_unit.py @@ -17,9 +17,9 @@ from xblock.field_data import DictFieldData from xblock.fields import ScopeIds - -from xblocks_contrib.lti.lti_2_util import LTIError from xblocks_contrib.lti.lti import LTIBlock +from xblocks_contrib.lti.lti_2_util import LTIError + from .helpers import StubUserService, Timedelta, get_test_system ATTR_KEY_ANONYMOUS_USER_ID = "edx-platform.anonymous_user_id" From 95c901aebbd25f76369632faa0b3c9567fd19691 Mon Sep 17 00:00:00 2001 From: Muhammad Tayyab Tahir Qureshi Date: Fri, 18 Apr 2025 11:51:48 +0500 Subject: [PATCH 29/34] fix: fix docs errors --- xblocks_contrib/lti/lti.py | 201 +++++++++++++++--------------- xblocks_contrib/lti/lti_2_util.py | 2 + 2 files changed, 103 insertions(+), 100 deletions(-) diff --git a/xblocks_contrib/lti/lti.py b/xblocks_contrib/lti/lti.py index a8924cf..ca80b3e 100644 --- a/xblocks_contrib/lti/lti.py +++ b/xblocks_contrib/lti/lti.py @@ -36,7 +36,8 @@ 1.) Display of simple LTI in iframe or a new window. 2.) Multiple LTI components on a single page. 3.) The use of multiple LTI providers per course. -4.) Use of advanced LTI component that provides back a grade. +4.) Use of advanced LTI component that provides back a grade.:: + A) LTI 1.1.1 XML endpoint a.) The LTI provider sends back a grade to a specified URL. b.) Currently only action "update" is supported. "Read", and "delete" @@ -119,7 +120,7 @@ class LTIFields: Default non-empty URL for `launch_url` is needed due to oauthlib demand (URL scheme should be presented):: - https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136 + https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136 """ display_name = String( display_name=_("Display Name"), @@ -300,78 +301,78 @@ class LTIBlock( Except usual Xmodule structure it proceeds with OAuth signing. How it works:: - 1. Get credentials from course settings. - - 2. There is minimal set of parameters need to be signed (presented for Vitalsource):: - - user_id - oauth_callback - lis_outcome_service_url - lis_result_sourcedid - launch_presentation_return_url - lti_message_type - lti_version - roles - *+ all custom parameters* - - These parameters should be encoded and signed by *OAuth1* together with - `launch_url` and *POST* request type. - - 3. Signing proceeds with client key/secret pair obtained from course settings. - That pair should be obtained from LTI provider and set into course settings by course author. - After that signature and other OAuth data are generated. - - OAuth data which is generated after signing is usual:: - - oauth_callback - oauth_nonce - oauth_consumer_key - oauth_signature_method - oauth_timestamp - oauth_version - - - 4. All that data is passed to form and sent to LTI provider server by browser via - autosubmit via JavaScript. - - Form example:: - -
- - - - - - - - - - - - - - - - - - - - -
- - 5. LTI provider has same secret key and it signs data string via *OAuth1* and compares signatures. - - If signatures are correct, LTI provider redirects iframe source to LTI tool web page, - and LTI tool is rendered to iframe inside course. - - Otherwise error message from LTI provider is generated. + 1. Get credentials from course settings. + + 2. There is minimal set of parameters need to be signed (presented for Vitalsource):: + + user_id + oauth_callback + lis_outcome_service_url + lis_result_sourcedid + launch_presentation_return_url + lti_message_type + lti_version + roles + *+ all custom parameters* + + These parameters should be encoded and signed by *OAuth1* together with + `launch_url` and *POST* request type. + + 3. Signing proceeds with client key/secret pair obtained from course settings. + That pair should be obtained from LTI provider and set into course settings by course author. + After that signature and other OAuth data are generated. + + OAuth data which is generated after signing is usual:: + + oauth_callback + oauth_nonce + oauth_consumer_key + oauth_signature_method + oauth_timestamp + oauth_version + + + 4. All that data is passed to form and sent to LTI provider server by browser via + autosubmit via JavaScript. + + Form example:: + +
+ + + + + + + + + + + + + + + + + + + + +
+ + 5. LTI provider has same secret key and it signs data string via *OAuth1* and compares signatures. + + If signatures are correct, LTI provider redirects iframe source to LTI tool web page, + and LTI tool is rendered to iframe inside course. + + Otherwise error message from LTI provider is generated. """ # Indicates that this XBlock has been extracted from edx-platform. @@ -554,32 +555,32 @@ def grade_handler(self, request, suffix): # lint-amnesty, pylint: disable=unuse Example of request body from LTI provider:: - - - - - V1.0 - 528243ba5241b - - - - - - - feb-123-456-2929::28883 - - - - en-us - 0.4 - - - - - - - - Example of correct/incorrect answer XML body:: see response_xml_template. + + + + + V1.0 + 528243ba5241b + + + + + + + feb-123-456-2929::28883 + + + + en-us + 0.4 + + + + + + + + Example of correct/incorrect answer XML body:: see response_xml_template. """ response_xml_template = textwrap.dedent("""\ diff --git a/xblocks_contrib/lti/lti_2_util.py b/xblocks_contrib/lti/lti_2_util.py index 23544e6..ee6d0c4 100644 --- a/xblocks_contrib/lti/lti_2_util.py +++ b/xblocks_contrib/lti/lti_2_util.py @@ -42,11 +42,13 @@ def lti_2_0_result_rest_handler(self, request, suffix): See http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html An example JSON object: + { "@context" : "http://purl.imsglobal.org/ctx/lis/v2/Result", "@type" : "Result", "resultScore" : 0.83, "comment" : "This is exceptional work." + } For PUTs, the content type must be "application/vnd.ims.lis.v2.result+json". We use the "suffix" parameter to parse out the user from the end of the URL. An example endpoint url is From 9d6aa90ddb71477f10d4ea803b297b103006eeb1 Mon Sep 17 00:00:00 2001 From: "M. Tayyab Tahir Qureshi" <109274085+ttqureshi@users.noreply.github.com> Date: Wed, 7 May 2025 20:51:02 +0500 Subject: [PATCH 30/34] chore: update `init.py` Co-authored-by: Kyle McCormick --- xblocks_contrib/lti/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/xblocks_contrib/lti/__init__.py b/xblocks_contrib/lti/__init__.py index 030a9bb..23a5868 100644 --- a/xblocks_contrib/lti/__init__.py +++ b/xblocks_contrib/lti/__init__.py @@ -1,4 +1,6 @@ """ +THIS BLOCK IS DEPRECATED IN FAVOR OF https://github.com/openedx/xblock-lti-consumer + Learning Tools Interoperability (LTI) module. """ From 35acb49a83eb0d2b58a68a7359238ff539685cd2 Mon Sep 17 00:00:00 2001 From: Muhammad Tayyab Tahir Qureshi Date: Wed, 7 May 2025 21:01:07 +0500 Subject: [PATCH 31/34] chore: update README - set the status for `word_cloud`, `annotatable` and `lti` blocks --- README.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 07a5887..7e2d3f0 100644 --- a/README.rst +++ b/README.rst @@ -18,9 +18,9 @@ XBlocks Being Moved Here These are the XBlocks being moved here, and each of their statuses: * ``poll_question`` -- Placeholder -* ``word_cloud`` -- Placeholder -* ``annotatable`` -- Placeholder -* ``lti`` -- Placeholder +* ``word_cloud`` -- In Development +* ``annotatable`` -- In Development +* ``lti`` -- In Development * ``html`` -- Placeholder * ``discussion`` -- Placeholder * ``problem`` -- Placeholder From e74c2a8aa6ce6ecd55da8fb547f70b41710bfa1c Mon Sep 17 00:00:00 2001 From: Muhammad Tayyab Tahir Qureshi Date: Wed, 7 May 2025 21:05:20 +0500 Subject: [PATCH 32/34] chore: bump version and update `CHANGELOG` --- CHANGELOG.rst | 9 +++++++++ xblocks_contrib/__init__.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 479663d..21f7cfe 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,15 @@ Change Log Unreleased ********** +0.4.0 – 2025-05-7 +********************************************** + +Added +===== + +* Implemented the LTI XBlock, extracted from edx-platform. + + 0.3.0 – 2025-04-8 ********************************************** diff --git a/xblocks_contrib/__init__.py b/xblocks_contrib/__init__.py index 50aa37e..8d68499 100644 --- a/xblocks_contrib/__init__.py +++ b/xblocks_contrib/__init__.py @@ -11,4 +11,4 @@ from .video import VideoBlock from .word_cloud import WordCloudBlock -__version__ = "0.3.0" +__version__ = "0.4.0" From 8f657b6ec1198a927eda9e7a4949115a0d5b927d Mon Sep 17 00:00:00 2001 From: Muhammad Tayyab Tahir Qureshi Date: Wed, 7 May 2025 21:14:43 +0500 Subject: [PATCH 33/34] chore: update requiments --- requirements/base.txt | 20 ++++++++++---------- requirements/dev.txt | 28 ++++++++++++++-------------- requirements/doc.txt | 38 +++++++++++++++++--------------------- requirements/pip.txt | 2 +- requirements/quality.txt | 28 ++++++++++++++-------------- requirements/test.txt | 24 ++++++++++++------------ 6 files changed, 68 insertions(+), 72 deletions(-) diff --git a/requirements/base.txt b/requirements/base.txt index b283083..0f2f5cf 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -8,13 +8,13 @@ appdirs==1.4.4 # via fs asgiref==3.8.1 # via django -boto3==1.37.37 +boto3==1.38.10 # via fs-s3fs -botocore==1.37.37 +botocore==1.38.10 # via # boto3 # s3transfer -django==4.2.20 +django==4.2.21 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # django-appconf @@ -27,9 +27,9 @@ django-statici18n==2.6.0 # via -r requirements/base.in dnspython==2.7.0 # via pymongo -edx-i18n-tools==1.7.0 +edx-i18n-tools==1.9.0 # via -r requirements/base.in -edx-opaque-keys==2.12.0 +edx-opaque-keys==3.0.0 # via -r requirements/base.in fs==2.4.16 # via @@ -42,7 +42,7 @@ jmespath==1.0.1 # via # boto3 # botocore -lxml[html-clean,html_clean]==5.3.2 +lxml[html-clean,html_clean]==5.4.0 # via # edx-i18n-tools # lxml-html-clean @@ -59,7 +59,7 @@ nh3==0.2.21 # via -r requirements/base.in oauthlib==3.2.2 # via -r requirements/base.in -openedx-django-pyfs==3.7.0 +openedx-django-pyfs==3.8.0 # via -r requirements/base.in path==16.16.0 # via edx-i18n-tools @@ -67,7 +67,7 @@ pbr==6.1.1 # via stevedore polib==1.2.0 # via edx-i18n-tools -pymongo==4.12.0 +pymongo==4.12.1 # via edx-opaque-keys python-dateutil==2.9.0.post0 # via @@ -79,7 +79,7 @@ pyyaml==6.0.2 # via # edx-i18n-tools # xblock -s3transfer==0.11.5 +s3transfer==0.12.0 # via boto3 simplejson==3.20.1 # via xblock @@ -98,7 +98,7 @@ urllib3==2.2.3 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # botocore -web-fragments==3.0.0 +web-fragments==3.1.0 # via xblock webob==1.8.9 # via xblock diff --git a/requirements/dev.txt b/requirements/dev.txt index f4b7140..0d4a17e 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -29,12 +29,12 @@ binaryornot==0.4.4 # -r requirements/quality.txt # -r requirements/test.txt # cookiecutter -boto3==1.37.37 +boto3==1.38.10 # via # -r requirements/quality.txt # -r requirements/test.txt # fs-s3fs -botocore==1.37.37 +botocore==1.38.10 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -49,7 +49,7 @@ cachetools==5.5.2 # -r requirements/quality.txt # -r requirements/test.txt # tox -certifi==2025.1.31 +certifi==2025.4.26 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -61,7 +61,7 @@ chardet==5.2.0 # binaryornot # diff-cover # tox -charset-normalizer==3.4.1 +charset-normalizer==3.4.2 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -111,7 +111,7 @@ distlib==0.3.9 # -r requirements/quality.txt # -r requirements/test.txt # virtualenv -django==4.2.20 +django==4.2.21 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/quality.txt @@ -135,14 +135,14 @@ dnspython==2.7.0 # -r requirements/quality.txt # -r requirements/test.txt # pymongo -edx-i18n-tools==1.7.0 +edx-i18n-tools==1.9.0 # via # -r requirements/dev.in # -r requirements/quality.txt # -r requirements/test.txt edx-lint==5.6.0 # via -r requirements/quality.txt -edx-opaque-keys==2.12.0 +edx-opaque-keys==3.0.0 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -192,7 +192,7 @@ jmespath==1.0.1 # -r requirements/test.txt # boto3 # botocore -lxml[html-clean]==5.3.2 +lxml[html-clean]==5.4.0 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -239,7 +239,7 @@ oauthlib==3.2.2 # via # -r requirements/quality.txt # -r requirements/test.txt -openedx-django-pyfs==3.7.0 +openedx-django-pyfs==3.8.0 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -293,7 +293,7 @@ pygments==2.19.1 # -r requirements/test.txt # diff-cover # rich -pylint==3.3.6 +pylint==3.3.7 # via # -r requirements/quality.txt # edx-lint @@ -313,7 +313,7 @@ pylint-plugin-utils==0.8.2 # -r requirements/quality.txt # pylint-celery # pylint-django -pymongo==4.12.0 +pymongo==4.12.1 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -384,7 +384,7 @@ rich==14.0.0 # -r requirements/quality.txt # -r requirements/test.txt # cookiecutter -s3transfer==0.11.5 +s3transfer==0.12.0 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -448,12 +448,12 @@ urllib3==2.2.3 # -r requirements/test.txt # botocore # requests -virtualenv==20.30.0 +virtualenv==20.31.1 # via # -r requirements/quality.txt # -r requirements/test.txt # tox -web-fragments==3.0.0 +web-fragments==3.1.0 # via # -r requirements/quality.txt # -r requirements/test.txt diff --git a/requirements/doc.txt b/requirements/doc.txt index 7ad77f6..43f1d24 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -32,11 +32,11 @@ binaryornot==0.4.4 # via # -r requirements/test.txt # cookiecutter -boto3==1.37.37 +boto3==1.38.10 # via # -r requirements/test.txt # fs-s3fs -botocore==1.37.37 +botocore==1.38.10 # via # -r requirements/test.txt # boto3 @@ -47,7 +47,7 @@ cachetools==5.5.2 # via # -r requirements/test.txt # tox -certifi==2025.1.31 +certifi==2025.4.26 # via # -r requirements/test.txt # requests @@ -56,7 +56,7 @@ chardet==5.2.0 # -r requirements/test.txt # binaryornot # tox -charset-normalizer==3.4.1 +charset-normalizer==3.4.2 # via # -r requirements/test.txt # requests @@ -79,13 +79,11 @@ coverage[toml]==7.8.0 # via # -r requirements/test.txt # pytest-cov -cryptography==44.0.2 - # via secretstorage distlib==0.3.9 # via # -r requirements/test.txt # virtualenv -django==4.2.20 +django==4.2.21 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt @@ -113,9 +111,9 @@ docutils==0.21.2 # readme-renderer # restructuredtext-lint # sphinx -edx-i18n-tools==1.7.0 +edx-i18n-tools==1.9.0 # via -r requirements/test.txt -edx-opaque-keys==2.12.0 +edx-opaque-keys==3.0.0 # via -r requirements/test.txt filelock==3.18.0 # via @@ -141,7 +139,7 @@ idna==3.10 # requests imagesize==1.4.1 # via sphinx -importlib-metadata==8.6.1 +importlib-metadata==8.7.0 # via keyring iniconfig==2.1.0 # via @@ -153,10 +151,6 @@ jaraco-context==6.0.1 # via keyring jaraco-functools==4.1.0 # via keyring -jeepney==0.9.0 - # via - # keyring - # secretstorage jinja2==3.1.6 # via # -r requirements/test.txt @@ -170,7 +164,7 @@ jmespath==1.0.1 # botocore keyring==25.6.0 # via twine -lxml[html-clean]==5.3.2 +lxml[html-clean]==5.4.0 # via # -r requirements/test.txt # edx-i18n-tools @@ -199,15 +193,17 @@ mdurl==0.1.2 # via # -r requirements/test.txt # markdown-it-py -more-itertools==10.6.0 +more-itertools==10.7.0 # via # jaraco-classes # jaraco-functools nh3==0.2.21 + # via + # -r requirements/test.txt # readme-renderer oauthlib==3.2.2 # via -r requirements/test.txt -openedx-django-pyfs==3.7.0 +openedx-django-pyfs==3.8.0 # via -r requirements/test.txt packaging==25.0 # via @@ -252,7 +248,7 @@ pygments==2.19.1 # readme-renderer # rich # sphinx -pymongo==4.12.0 +pymongo==4.12.1 # via # -r requirements/test.txt # edx-opaque-keys @@ -321,7 +317,7 @@ rich==14.0.0 # twine roman-numerals-py==3.1.0 # via sphinx -s3transfer==0.11.5 +s3transfer==0.12.0 # via # -r requirements/test.txt # boto3 @@ -394,11 +390,11 @@ urllib3==2.2.3 # botocore # requests # twine -virtualenv==20.30.0 +virtualenv==20.31.1 # via # -r requirements/test.txt # tox -web-fragments==3.0.0 +web-fragments==3.1.0 # via # -r requirements/test.txt # xblock diff --git a/requirements/pip.txt b/requirements/pip.txt index f9945a2..a124796 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -12,5 +12,5 @@ pip==24.2 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/pip.in -setuptools==79.0.0 +setuptools==80.3.1 # via -r requirements/pip.in diff --git a/requirements/quality.txt b/requirements/quality.txt index a91ed21..fbb33a4 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -24,11 +24,11 @@ binaryornot==0.4.4 # via # -r requirements/test.txt # cookiecutter -boto3==1.37.37 +boto3==1.38.10 # via # -r requirements/test.txt # fs-s3fs -botocore==1.37.37 +botocore==1.38.10 # via # -r requirements/test.txt # boto3 @@ -37,7 +37,7 @@ cachetools==5.5.2 # via # -r requirements/test.txt # tox -certifi==2025.1.31 +certifi==2025.4.26 # via # -r requirements/test.txt # requests @@ -46,7 +46,7 @@ chardet==5.2.0 # -r requirements/test.txt # binaryornot # tox -charset-normalizer==3.4.1 +charset-normalizer==3.4.2 # via # -r requirements/test.txt # requests @@ -81,7 +81,7 @@ distlib==0.3.9 # via # -r requirements/test.txt # virtualenv -django==4.2.20 +django==4.2.21 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt @@ -100,11 +100,11 @@ dnspython==2.7.0 # via # -r requirements/test.txt # pymongo -edx-i18n-tools==1.7.0 +edx-i18n-tools==1.9.0 # via -r requirements/test.txt edx-lint==5.6.0 # via -r requirements/quality.in -edx-opaque-keys==2.12.0 +edx-opaque-keys==3.0.0 # via -r requirements/test.txt filelock==3.18.0 # via @@ -144,7 +144,7 @@ jmespath==1.0.1 # -r requirements/test.txt # boto3 # botocore -lxml[html-clean]==5.3.2 +lxml[html-clean]==5.4.0 # via # -r requirements/test.txt # edx-i18n-tools @@ -179,7 +179,7 @@ nh3==0.2.21 # via -r requirements/test.txt oauthlib==3.2.2 # via -r requirements/test.txt -openedx-django-pyfs==3.7.0 +openedx-django-pyfs==3.8.0 # via -r requirements/test.txt packaging==25.0 # via @@ -218,7 +218,7 @@ pygments==2.19.1 # via # -r requirements/test.txt # rich -pylint==3.3.6 +pylint==3.3.7 # via # edx-lint # pylint-celery @@ -232,7 +232,7 @@ pylint-plugin-utils==0.8.2 # via # pylint-celery # pylint-django -pymongo==4.12.0 +pymongo==4.12.1 # via # -r requirements/test.txt # edx-opaque-keys @@ -284,7 +284,7 @@ rich==14.0.0 # via # -r requirements/test.txt # cookiecutter -s3transfer==0.11.5 +s3transfer==0.12.0 # via # -r requirements/test.txt # boto3 @@ -333,11 +333,11 @@ urllib3==2.2.3 # -r requirements/test.txt # botocore # requests -virtualenv==20.30.0 +virtualenv==20.31.1 # via # -r requirements/test.txt # tox -web-fragments==3.0.0 +web-fragments==3.1.0 # via # -r requirements/test.txt # xblock diff --git a/requirements/test.txt b/requirements/test.txt index 298009a..610b5e0 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -16,24 +16,24 @@ asgiref==3.8.1 # django binaryornot==0.4.4 # via cookiecutter -boto3==1.37.37 +boto3==1.38.10 # via # -r requirements/base.txt # fs-s3fs -botocore==1.37.37 +botocore==1.38.10 # via # -r requirements/base.txt # boto3 # s3transfer cachetools==5.5.2 # via tox -certifi==2025.1.31 +certifi==2025.4.26 # via requests chardet==5.2.0 # via # binaryornot # tox -charset-normalizer==3.4.1 +charset-normalizer==3.4.2 # via requests click==8.1.8 # via @@ -67,9 +67,9 @@ dnspython==2.7.0 # via # -r requirements/base.txt # pymongo -edx-i18n-tools==1.7.0 +edx-i18n-tools==1.9.0 # via -r requirements/base.txt -edx-opaque-keys==2.12.0 +edx-opaque-keys==3.0.0 # via -r requirements/base.txt filelock==3.18.0 # via @@ -99,7 +99,7 @@ jmespath==1.0.1 # -r requirements/base.txt # boto3 # botocore -lxml[html-clean]==5.3.2 +lxml[html-clean]==5.4.0 # via # -r requirements/base.txt # edx-i18n-tools @@ -128,7 +128,7 @@ nh3==0.2.21 # via -r requirements/base.txt oauthlib==3.2.2 # via -r requirements/base.txt -openedx-django-pyfs==3.7.0 +openedx-django-pyfs==3.8.0 # via -r requirements/base.txt packaging==25.0 # via @@ -157,7 +157,7 @@ polib==1.2.0 # edx-i18n-tools pygments==2.19.1 # via rich -pymongo==4.12.0 +pymongo==4.12.1 # via # -r requirements/base.txt # edx-opaque-keys @@ -200,7 +200,7 @@ requests==2.32.3 # xblock-sdk rich==14.0.0 # via cookiecutter -s3transfer==0.11.5 +s3transfer==0.12.0 # via # -r requirements/base.txt # boto3 @@ -240,9 +240,9 @@ urllib3==2.2.3 # -r requirements/base.txt # botocore # requests -virtualenv==20.30.0 +virtualenv==20.31.1 # via tox -web-fragments==3.0.0 +web-fragments==3.1.0 # via # -r requirements/base.txt # xblock From 991726910e5025ba57a9c67a89cd48556766f678 Mon Sep 17 00:00:00 2001 From: Muhammad Tayyab Tahir Qureshi Date: Wed, 7 May 2025 23:11:06 +0500 Subject: [PATCH 34/34] chore: upgrade requirements --- requirements/doc.txt | 2 -- 1 file changed, 2 deletions(-) diff --git a/requirements/doc.txt b/requirements/doc.txt index 4b14395..43f1d24 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -79,8 +79,6 @@ coverage[toml]==7.8.0 # via # -r requirements/test.txt # pytest-cov -cryptography==44.0.3 - # via secretstorage distlib==0.3.9 # via # -r requirements/test.txt