From 06c77f07d3251c62dd161c25bba02dc67c790444 Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Thu, 31 Oct 2013 16:13:50 +0200 Subject: [PATCH 01/87] Add grade functionality for LTI (BLD-384) --- common/lib/xmodule/xmodule/lti_module.py | 138 ++++++++++++++++-- common/lib/xmodule/xmodule/tests/test_lti.py | 28 ++++ .../courseware/features/lti.feature | 21 ++- lms/djangoapps/courseware/features/lti.py | 61 +++++++- .../mock_lti_server/mock_lti_server.py | 63 ++++++-- .../mock_lti_server/test_mock_lti_server.py | 52 ++++++- lms/djangoapps/courseware/tests/test_lti.py | 65 +++++++++ 7 files changed, 392 insertions(+), 36 deletions(-) create mode 100644 common/lib/xmodule/xmodule/tests/test_lti.py diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 7fa30b48857d..e29359b6e09a 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -5,9 +5,12 @@ http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html """ +from uuid import uuid4 + import logging import oauthlib.oauth1 import urllib +import json from xmodule.editing_module import MetadataOnlyEditingDescriptor from xmodule.raw_module import EmptyDataRawDescriptor @@ -15,10 +18,12 @@ from xmodule.course_module import CourseDescriptor from pkg_resources import resource_string from xblock.core import String, Scope, List -from xblock.fields import Boolean +from xblock.fields import Boolean, Float log = logging.getLogger(__name__) +unicode = unicode # to disable pylint unicode warnings + class LTIError(Exception): pass @@ -48,6 +53,8 @@ class LTIFields(object): launch_url = String(help="URL of the tool", default='http://www.example.com', scope=Scope.settings) custom_parameters = List(help="Custom parameters (vbid, book_location, etc..)", scope=Scope.settings) open_in_a_new_page = Boolean(help="Should LTI be opened in new page?", default=True, scope=Scope.settings) + is_graded = Boolean(help="The LTI provider will grade student's results.", default=False, scope=Scope.settings) + weight = Float(help="Weight for student grades.", default=1.0, scope=Scope.settings) class LTIModule(LTIFields, XModule): @@ -135,6 +142,8 @@ class LTIModule(LTIFields, XModule): css = {'scss': [resource_string(__name__, 'css/lti/lti.scss')]} js_module_name = "LTI" + TEST_BASE_PATH = None + def get_html(self): """ Renders parameters to template. @@ -214,7 +223,7 @@ def get_html(self): input_fields = self.oauth_params( custom_parameters, client_key, - client_secret + client_secret, ) context = { 'input_fields': input_fields, @@ -229,6 +238,61 @@ def get_html(self): return self.system.render_template('lti.html', context) + def get_user_id(self): + user_id = self.runtime.anonymous_student_id + assert user_id is not None + return user_id + + def get_base_path(self): + if self.TEST_BASE_PATH: + return 'http://{host}{path}'.format( + host=self.TEST_BASE_PATH, + path=self.system.ajax_url, + ) + else: + return self.system.hostname + self.system.ajax_url + + def get_context_id(self): + # This is an opaque identifier that uniquely identifies the context that contains + # the link being launched. This parameter is recommended. + + # so context_id is lti_id + return self.lti_id + + 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. + + # This is unique id of edx platform. + return unicode(self.id) if self.is_graded else '' + + def get_lis_result_sourcedid(self): + if self.is_graded: + # 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 optional. + + # context_id is lti_id + return '{}::{}::{}'.format(self.lti_id, self.get_resource_link_id(), self.get_user_id()) + else: + return '' + + def get_lis_person_sourcedid(self): + # This field contains the LIS identifier for the user account that is performing this launch. + # The example syntax of "school:user" is not the required format - lis_person_sourcedid + # is simply a unique identifier (i.e., a normalized string). + # This field is optional and its content and meaning are defined by LIS. + school = 'EdX' + return '{}:{}:{}'.format(school, self.get_user_id(), uuid4().hex) if self.is_graded else '' + def oauth_params(self, custom_parameters, client_key, client_secret): """ Signs request and returns signature and oauth parameters. @@ -245,19 +309,24 @@ def oauth_params(self, custom_parameters, client_key, client_secret): client_secret=unicode(client_secret) ) - user_id = self.runtime.anonymous_student_id - assert user_id is not None - # must have parameters for correct signing from LTI: body = { - u'user_id': user_id, + u'user_id': self.get_user_id, u'oauth_callback': u'about:blank', - u'lis_outcome_service_url': '', - u'lis_result_sourcedid': '', u'launch_presentation_return_url': '', u'lti_message_type': u'basic-lti-launch-request', u'lti_version': 'LTI-1p0', - u'role': u'student' + u'role': u'student', + + # for grades, TODO: generate properly: + # required + u'resource_link_id': self.get_resource_link_id(), + u'lis_outcome_service_url': '{}/set'.format(self.get_base_path() if self.is_graded else ''), + + # optional fields + u'lis_result_sourcedid': self.get_lis_result_sourcedid(), + u'lis_person_sourcedid': self.get_lis_person_sourcedid(), + } # appending custom parameter for signing @@ -301,9 +370,60 @@ def oauth_params(self, custom_parameters, client_key, client_secret): params.update(body) return params + def get_score(self): + return { + 'score': 0.5, + 'total': self.get_maxscore() + } + + def get_maxscore(self): + return self.weight + + def handle_ajax(self, dispatch, data): + """ + This is called by courseware.module_render, to handle an AJAX call. + + Returns json in following format: + {'status_code': HTTP status code, 'content': use it only for returning + data for action `read`} + """ + # Investigate how will be applied changes to specific user_id + + action = dispatch.lower() + + if action == 'set': + if 'score' not in data.keys(): + return json.dumps({'status_code': 400}) + + self.system.publish({ + 'event_name': 'grade', + 'value': data['score'], + 'max_value': self.get_maxscore(), + }) + + return json.dumps({'status_code': 200}) + + elif action == 'read': + response = { + 'status_code': 200, + 'content': { + 'value': self.get_score(), + }, + } + + return json.dumps(response) + + elif action == 'delete': + return json.dumps({'status_code': 200}) + + return json.dumps({'status_code': 404}) + class LTIModuleDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRawDescriptor): """ Descriptor for LTI Xmodule. """ + has_score = True + graded = True + module_class = LTIModule diff --git a/common/lib/xmodule/xmodule/tests/test_lti.py b/common/lib/xmodule/xmodule/tests/test_lti.py new file mode 100644 index 000000000000..73a698c3a29c --- /dev/null +++ b/common/lib/xmodule/xmodule/tests/test_lti.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +"""Test for LTI Xmodule functional logic.""" +from xmodule.lti_module import LTIModuleDescriptor +from . import LogicTest + + +class LTIModuleTest(LogicTest): + """Logic tests for Poll Xmodule.""" + descriptor_class = LTIModuleDescriptor + + def test_handle_ajax(self): + # Make sure that ajax request works correctly. + + good_requests = {'set': {'score': 5}, 'read': {}, 'delete': {}} + bad_requests = {'set': {}, 'unknown_dispatch': {}} + + for dispatch, data in bad_requests.items(): + response = self.ajax_request(dispatch, data) + if dispatch == 'set': + self.assertEqual(response['status_code'], 400) + else: + self.assertEqual(response['status_code'], 404) + + for dispatch, data in good_requests.items(): + response = self.ajax_request(dispatch, data) + self.assertEqual(response['status_code'], 200) + if dispatch == 'read': + self.assertDictEqual(response['content']['value'], {'score': 0.5, 'total': 1.0}) diff --git a/lms/djangoapps/courseware/features/lti.feature b/lms/djangoapps/courseware/features/lti.feature index 8255e515b077..f02c60411391 100644 --- a/lms/djangoapps/courseware/features/lti.feature +++ b/lms/djangoapps/courseware/features/lti.feature @@ -2,27 +2,38 @@ Feature: LMS.LTI component As a student, I want to view LTI component in LMS. + #1 Scenario: LTI component in LMS with no launch_url is not rendered Given the course has correct LTI credentials - And the course has an LTI component with no_launch_url fields, new_page is false + And the course has an LTI component with no_launch_url fields, new_page is false, is_graded is false Then I view the LTI and error is shown + #2 Scenario: LTI component in LMS with incorrect lti_id is rendered incorrectly Given the course has correct LTI credentials - And the course has an LTI component with incorrect_lti_id fields, new_page is false + And the course has an LTI component with incorrect_lti_id fields, new_page is false, is_graded is false Then I view the LTI but incorrect_signature warning is rendered + #3 Scenario: LTI component in LMS is rendered incorrectly Given the course has incorrect LTI credentials - And the course has an LTI component with correct fields, new_page is false + And the course has an LTI component with correct fields, new_page is false, is_graded is false Then I view the LTI but incorrect_signature warning is rendered + #4 Scenario: LTI component in LMS is correctly rendered in new page Given the course has correct LTI credentials - And the course has an LTI component with correct fields, new_page is true + And the course has an LTI component with correct fields, new_page is true, is_graded is false Then I view the LTI and it is rendered in new page + #5 Scenario: LTI component in LMS is correctly rendered in iframe Given the course has correct LTI credentials - And the course has an LTI component with correct fields, new_page is false + And the course has an LTI component with correct fields, new_page is false, is_graded is false Then I view the LTI and it is rendered in iframe + + #6 + Scenario: Graded LTI component in LMS is correctly works + Given the course has correct LTI credentials + And the course has an LTI component with correct fields, new_page is false, is_graded is true + And I open gradebook diff --git a/lms/djangoapps/courseware/features/lti.py b/lms/djangoapps/courseware/features/lti.py index 58de82ded630..244b9f68b0e3 100644 --- a/lms/djangoapps/courseware/features/lti.py +++ b/lms/djangoapps/courseware/features/lti.py @@ -1,6 +1,7 @@ #pylint: disable=C0111 import os + from django.contrib.auth.models import User from lettuce import world, step from lettuce.django import django_url @@ -86,9 +87,8 @@ def set_incorrect_lti_passport(_step): } i_am_registered_for_the_course(coursenum, metadata) - -@step('the course has an LTI component with (.*) fields, new_page is(.*)$') -def add_correct_lti_to_course(_step, fields, new_page): +@step('the course has an LTI component with (.*) fields, new_page is(.*), is_graded is(.*)$') +def add_correct_lti_to_course(_step, fields, new_page, is_graded): category = 'lti' lti_id = 'correct_lti_id' launch_url = world.lti_server.oauth_settings['lti_base'] + world.lti_server.oauth_settings['lti_endpoint'] @@ -106,6 +106,11 @@ def add_correct_lti_to_course(_step, fields, new_page): else: # default is True new_page = True + if is_graded.strip().lower() == 'false': + is_graded = False + else: # default is True + is_graded = True + world.scenario_dict['LTI'] = world.ItemFactory.create( parent_location=world.scenario_dict['SEQUENTIAL'].location, category=category, @@ -113,9 +118,16 @@ def add_correct_lti_to_course(_step, fields, new_page): metadata={ 'lti_id': lti_id, 'launch_url': launch_url, - 'open_in_a_new_page': new_page + 'open_in_a_new_page': new_page, + 'is_graded': is_graded, } ) + + setattr(world.scenario_dict['LTI'], 'TEST_BASE_PATH', '{host}:{port}'.format( + host=world.browser.host, + port=world.browser.port, + )) + course = world.scenario_dict["COURSE"] chapter_name = world.scenario_dict['SECTION'].display_name.replace( " ", "_") @@ -138,6 +150,20 @@ def create_course(course, metadata): # This also ensures that the necessary templates are loaded world.clear_courses() + weight = 0.15 + grading_policy = { + "GRADER": [ + { + "type": "Homework", + "min_count": 1, + "drop_count": 0, + "short_label": "HW", + "weight": weight + }, + ] + } + metadata.update(grading_policy) + # Create the course # We always use the same org and display name, # but vary the course identifier (e.g. 600x or 191x) @@ -145,13 +171,25 @@ def create_course(course, metadata): org='edx', number=course, display_name='Test Course', - metadata=metadata + metadata=metadata, + grading_policy={ + "GRADER": [ + { + "type": "Homework", + "min_count": 1, + "drop_count": 0, + "short_label": "HW", + "weight": weight + }, + ] + }, ) # Add a section to the course to contain problems world.scenario_dict['SECTION'] = world.ItemFactory.create( parent_location=world.scenario_dict['COURSE'].location, - display_name='Test Section' + display_name='Test Section', + metadata={'graded': True, 'format': 'Homework'} ) world.scenario_dict['SEQUENTIAL'] = world.ItemFactory.create( parent_location=world.scenario_dict['SECTION'].location, @@ -170,6 +208,7 @@ def i_am_registered_for_the_course(course, metadata): # If the user is not already enrolled, enroll the user. CourseEnrollment.enroll(usr, course_id(course)) + world.add_to_course_staff('robot', world.scenario_dict['COURSE'].number) world.log_in(username='robot', password='test') @@ -195,4 +234,14 @@ def check_lti_popup(): world.browser.driver.close() # Close the pop-up window world.browser.switch_to_window(parent_window) # Switch to the main window again +@step('I open gradebook$') +def check_gradebook(_step): + location = world.scenario_dict['LTI'].location.html_id() + iframe_name = 'ltiLaunchFrame-' + location + with world.browser.get_iframe(iframe_name) as iframe: + iframe.find_by_css('a')[0].click() + + world.click_link('Instructor') + world.click_link('Gradebook') + assert world.is_css_present('.grade-table') diff --git a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py index 83208d0102b9..a9fc70d463cd 100644 --- a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py +++ b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py @@ -3,6 +3,7 @@ from oauthlib.oauth1.rfc5849 import signature import mock import sys +import requests from logging import getLogger logger = getLogger(__name__) @@ -13,6 +14,7 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler): ''' protocol = "HTTP/1.0" + callback_url = None def log_message(self, format, *args): """Log an arbitrary message.""" @@ -26,6 +28,24 @@ def log_message(self, format, *args): def do_HEAD(self): self._send_head() + def do_GET(self): + ''' + Handle a POST request from the client and sends response back. + ''' + self.send_response(200, 'OK') + self.send_header('Content-type', 'html') + self.end_headers() + + response_str = """TEST TITLE + Grade this LTI""" + + self.wfile.write(response_str) + + if MockLTIRequestHandler.callback_url: + self._send_graded_result(MockLTIRequestHandler.callback_url) + + + def do_POST(self): ''' Handle a POST request from the client and sends response back. @@ -55,7 +75,8 @@ def do_POST(self): 'oauth_callback', 'lis_outcome_service_url', 'lis_result_sourcedid', - 'launch_presentation_return_url' + 'launch_presentation_return_url', + 'lis_person_sourcedid', ] if sorted(correct_keys) != sorted(post_dict.keys()): @@ -66,10 +87,16 @@ def do_POST(self): status_message = "This is LTI tool. Success." else: status_message = "Wrong LTI signature" + + callback_url = post_dict["lis_outcome_service_url"] + if callback_url: + MockLTIRequestHandler.callback_url = callback_url + else: + callback_url = None else: status_message = "Invalid request URL" - self._send_response(status_message) + self._send_response(status_message, url=callback_url) def _send_head(self): ''' @@ -102,22 +129,40 @@ def _post_dict(self): return {} return post_dict - def _send_response(self, message): + def _send_graded_result(self, callback_url): + payload = {'score': 0.95} + + # temporarily changed to get for easy view in browser + response=requests.post(callback_url, data=payload) + assert response.status_code == 200 + + def _send_response(self, message, url=None): ''' Send message back to the client ''' - response_str = """TEST TITLE - -

IFrame loaded

\ -

Server response is:

\ -

{}

- """.format(message) + + if url is not None: + response_str = """TEST TITLE + +

Graded IFrame loaded

\ +

Server response is:

\ +

{}

+ Grade + """.format(message, url="http://%s:%s" % self.server.server_address) + else: + response_str = """TEST TITLE + +

IFrame loaded

\ +

Server response is:

\ +

{}

+ """.format(message) # Log the response logger.debug("LTI: sent response {}".format(response_str)) self.wfile.write(response_str) + def _is_correct_lti_request(self): '''If url to LTI tool is correct.''' return self.server.oauth_settings['lti_endpoint'] in self.path diff --git a/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py b/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py index 99650d5faae3..0ca2243060d5 100644 --- a/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py +++ b/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py @@ -5,8 +5,7 @@ import threading import urllib from mock_lti_server import MockLTIServer - -from nose.plugins.skip import SkipTest +import requests class MockLTIServerTest(unittest.TestCase): @@ -19,11 +18,6 @@ class MockLTIServerTest(unittest.TestCase): def setUp(self): - # This is a test of the test setup, - # so it does not need to run as part of the unit test suite - # You can re-enable it by commenting out the line below - # raise SkipTest - # Create the server server_port = 8034 server_host = '127.0.0.1' @@ -73,3 +67,47 @@ def test_request(self): ) response = response_handle.read() self.assertTrue('Wrong LTI signature' in response) + + def test_graded_request(self): + """ + Tests that LTI server processes a graded request. It should trigger + the callback URL provided. + """ + server_port = 8000 + server_host = '127.0.0.1' + callback_url = 'http://{}:{}/grade_lti'.format(server_host, server_port) + + request = { + 'user_id': 'default_user_id', + 'role': 'student', + 'oauth_nonce': '', + 'oauth_timestamp': '', + 'oauth_consumer_key': 'client_key', + 'lti_version': 'LTI-1p0', + 'oauth_signature_method': 'HMAC-SHA1', + 'oauth_version': '1.0', + 'oauth_signature': '', + 'lti_message_type': 'basic-lti-launch-request', + 'oauth_callback': 'about:blank', + 'launch_presentation_return_url': '', + 'lis_outcome_service_url': '', + 'lis_result_sourcedid': '', + + # TODO: Generate properly. + "lis_person_sourcedid": "857298237538593757", + + # TODO: Get course based callback URL. + "lis_outcome_service_url": callback_url, + } + + response_handle = urllib.urlopen( + self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint'], + urllib.urlencode(request) + ) + response = response_handle.read() + self.assertTrue('Wrong LTI signature' in response) + + # reading grading result back + response = requests.get(callback_url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, "Hello, Valera and Anton!") diff --git a/lms/djangoapps/courseware/tests/test_lti.py b/lms/djangoapps/courseware/tests/test_lti.py index a176384fd12a..735dc2c97945 100644 --- a/lms/djangoapps/courseware/tests/test_lti.py +++ b/lms/djangoapps/courseware/tests/test_lti.py @@ -5,6 +5,12 @@ from collections import OrderedDict import mock +import courseware.grades as grades +from xmodule.modulestore.django import modulestore +from student.tests.factories import UserFactory +from django.test.client import RequestFactory +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + class TestLTI(BaseTestXmodule): """ @@ -15,6 +21,28 @@ class TestLTI(BaseTestXmodule): of `oauthlib` library. """ CATEGORY = "lti" + grading_policy = None + + def set_up_course(self, **course_kwargs): + """ + Create a stock coursecourse with a specific due date. + + :param course_kwargs: All kwargs are passed to through to the :class:`CourseFactory` + """ + + course = CourseFactory(**course_kwargs) + chapter = ItemFactory(category='chapter', parent_location=course.location) # pylint: disable=no-member + section = self.section = ItemFactory( + category='sequential', + parent_location=chapter.location, + metadata={'graded': True, 'format': 'Homework'} + ) + vertical = ItemFactory(category='vertical', parent_location=section.location) + ItemFactory(category=self.CATEGORY, parent_location=vertical.location) + + course = modulestore().get_instance(course.id, course.location) # pylint: disable=no-member + + return course def setUp(self): """ @@ -82,3 +110,40 @@ def test_lti_constructor(self): generated_context, self.runtime.render_template('lti.html', expected_context), ) + + @mock.patch('xmodule.lti_module.LTIModule.get_score') + def test_lti_grading(self, get_score): + """ + Makes sure that LTI is graded. + """ + weight = 0.15 + grading_policy = { + "GRADER": [ + { + "type": "Homework", + "min_count": 1, + "drop_count": 0, + "short_label": "HW", + "weight": weight + }, + ] + } + mocked_score = {'score': 0.7, 'total': 1} + get_score.return_value = mocked_score + + course = self.set_up_course(grading_policy=grading_policy) + request_factory = RequestFactory() + user = UserFactory.create() + request = request_factory.get("foo") + request.user = user + + grade_summary = grades.grade(user, request, course) + actual_score = grade_summary['grade_breakdown'][0]['percent'] + + self.assertEqual( + actual_score, + mocked_score['score']*weight + ) + + + From 94d18002874bbe285c7db348b8a61b196a1e825d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Fri, 8 Nov 2013 15:54:10 +0200 Subject: [PATCH 02/87] Add lookup table to students module. --- ...e_between_user_and_anonymous_student_id.py | 192 ++++++++++++++++++ .../0030_add_index_for_anonymous_user_id.py | 187 +++++++++++++++++ common/djangoapps/student/models.py | 60 ++++-- common/lib/xmodule/xmodule/lti_module.py | 57 ++++-- lms/djangoapps/courseware/module_render.py | 9 +- 5 files changed, 472 insertions(+), 33 deletions(-) create mode 100644 common/djangoapps/student/migrations/0029_add_lookup_table_between_user_and_anonymous_student_id.py create mode 100644 common/djangoapps/student/migrations/0030_add_index_for_anonymous_user_id.py diff --git a/common/djangoapps/student/migrations/0029_add_lookup_table_between_user_and_anonymous_student_id.py b/common/djangoapps/student/migrations/0029_add_lookup_table_between_user_and_anonymous_student_id.py new file mode 100644 index 000000000000..384edae94b05 --- /dev/null +++ b/common/djangoapps/student/migrations/0029_add_lookup_table_between_user_and_anonymous_student_id.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'AnonymousUsers' + db.create_table('student_anonymoususers', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='anonymous', unique=True, to=orm['auth.User'])), + ('anonymous_user_id', self.gf('django.db.models.fields.CharField')(unique=True, max_length=16)), + )) + db.send_create_signal('student', ['AnonymousUsers']) + + + def backwards(self, orm): + # Deleting model 'AnonymousUsers' + db.delete_table('student_anonymoususers') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'student.anonymoususers': { + 'Meta': {'object_name': 'AnonymousUsers'}, + 'anonymous_user_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '16'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'anonymous'", 'unique': 'True', 'to': "orm['auth.User']"}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollmentallowed': { + 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'}, + 'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'student.pendingemailchange': { + 'Meta': {'object_name': 'PendingEmailChange'}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.pendingnamechange': { + 'Meta': {'object_name': 'PendingNameChange'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.registration': { + 'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.testcenterregistration': { + 'Meta': {'object_name': 'TestCenterRegistration'}, + 'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'accommodation_request': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), + 'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}), + 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'eligibility_appointment_date_first': ('django.db.models.fields.DateField', [], {'db_index': 'True'}), + 'eligibility_appointment_date_last': ('django.db.models.fields.DateField', [], {'db_index': 'True'}), + 'exam_series_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'testcenter_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['student.TestCenterUser']"}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}), + 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}) + }, + 'student.testcenteruser': { + 'Meta': {'object_name': 'TestCenterUser'}, + 'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}), + 'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), + 'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), + 'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), + 'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'client_candidate_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}), + 'company_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '50', 'blank': 'True'}), + 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}), + 'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}), + 'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}), + 'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}), + 'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}), + 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}), + 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}), + 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}) + }, + 'student.userprofile': { + 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, + 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}), + 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}), + 'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'student.userstanding': { + 'Meta': {'object_name': 'UserStanding'}, + 'account_status': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'standing_last_changed_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'standing'", 'unique': 'True', 'to': "orm['auth.User']"}) + }, + 'student.usertestgroup': { + 'Meta': {'object_name': 'UserTestGroup'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'}) + } + } + + complete_apps = ['student'] \ No newline at end of file diff --git a/common/djangoapps/student/migrations/0030_add_index_for_anonymous_user_id.py b/common/djangoapps/student/migrations/0030_add_index_for_anonymous_user_id.py new file mode 100644 index 000000000000..c855292999fe --- /dev/null +++ b/common/djangoapps/student/migrations/0030_add_index_for_anonymous_user_id.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding index on 'AnonymousUsers', fields ['anonymous_user_id'] + db.create_index('student_anonymoususers', ['anonymous_user_id']) + + + def backwards(self, orm): + # Removing index on 'AnonymousUsers', fields ['anonymous_user_id'] + db.delete_index('student_anonymoususers', ['anonymous_user_id']) + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'student.anonymoususers': { + 'Meta': {'object_name': 'AnonymousUsers'}, + 'anonymous_user_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '16', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'anonymous'", 'unique': 'True', 'to': "orm['auth.User']"}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollmentallowed': { + 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'}, + 'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'student.pendingemailchange': { + 'Meta': {'object_name': 'PendingEmailChange'}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.pendingnamechange': { + 'Meta': {'object_name': 'PendingNameChange'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.registration': { + 'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.testcenterregistration': { + 'Meta': {'object_name': 'TestCenterRegistration'}, + 'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'accommodation_request': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), + 'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}), + 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'eligibility_appointment_date_first': ('django.db.models.fields.DateField', [], {'db_index': 'True'}), + 'eligibility_appointment_date_last': ('django.db.models.fields.DateField', [], {'db_index': 'True'}), + 'exam_series_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'testcenter_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['student.TestCenterUser']"}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}), + 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}) + }, + 'student.testcenteruser': { + 'Meta': {'object_name': 'TestCenterUser'}, + 'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}), + 'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), + 'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), + 'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), + 'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'client_candidate_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}), + 'company_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '50', 'blank': 'True'}), + 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}), + 'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}), + 'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}), + 'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}), + 'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}), + 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}), + 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}), + 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}) + }, + 'student.userprofile': { + 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, + 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}), + 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}), + 'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'student.userstanding': { + 'Meta': {'object_name': 'UserStanding'}, + 'account_status': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'standing_last_changed_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'standing'", 'unique': 'True', 'to': "orm['auth.User']"}) + }, + 'student.usertestgroup': { + 'Meta': {'object_name': 'UserTestGroup'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'}) + } + } + + complete_apps = ['student'] \ No newline at end of file diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index d7a918b5690e..10ca1bce5485 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -25,6 +25,7 @@ from django.dispatch import receiver import django.dispatch from django.forms import ModelForm, forms +from django.core.exceptions import ObjectDoesNotExist from course_modes.models import CourseMode import lms.lib.comment_client as cc @@ -42,6 +43,52 @@ AUDIT_LOG = logging.getLogger("audit") +class AnonymousUsers(models.Model): + """ + This table contains user and anonymous_user_id + + Purpose of this table is to provide user by anonymous_user_id. + + We are generating anonymous_user_id using md5 algorithm, so resulting length will always be 16 bytes. + http://docs.python.org/2/library/md5.html#md5.digest_size + """ + user = models.ForeignKey(User, db_index=True, related_name='anonymous', unique=True) + anonymous_user_id = models.CharField(db_index=True, blank=False, unique=True, max_length=16) + + +def unique_id_for_user(user): + """ + Return a unique id for a user, suitable for inserting into + e.g. personalized survey links. + """ + def _unique_id_for_user(user): + # include the secret key as a salt, and to make the ids unique across different LMS installs. + h = hashlib.md5() + h.update(settings.SECRET_KEY) + h.update(str(user.id)) + return h.hexdigest() + + return AnonymousUsers.objects.get_or_create( + user=user, + anonymous_user_id=_unique_id_for_user(user) + )[0].anonymous_user_id + + +def user_by_anonymous_id(id): + """ + Return user by anonymous_user_id using AnonymousUsers lookup table. + + Do not raise `django.ObjectDoesNotExist` exception, + if there is no user for anonymous_student_id, + because this function will be used inside xmodule w/o django access. + """ + try: + obj = AnonymousUsers.objects.get(anonymous_user_id=id) + except ObjectDoesNotExist: + return None + return obj.user + + class UserStanding(models.Model): """ This table contains a student's account's status. @@ -619,19 +666,6 @@ def get_testcenter_registration(user, course_id, exam_series_code): get_testcenter_registration.__test__ = False -def unique_id_for_user(user): - """ - Return a unique id for a user, suitable for inserting into - e.g. personalized survey links. - """ - # include the secret key as a salt, and to make the ids unique across - # different LMS installs. - h = hashlib.md5() - h.update(settings.SECRET_KEY) - h.update(str(user.id)) - return h.hexdigest() - - # TODO: Should be renamed to generic UserGroup, and possibly # Given an optional field for type of group class UserTestGroup(models.Model): diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index e29359b6e09a..504cafdb152a 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -3,6 +3,9 @@ Protocol is oauth1, LTI version is 1.1.1: http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html + +play and test: +http://www.imsglobal.org/developers/LTI/test/v1p1/lms.php """ from uuid import uuid4 @@ -11,6 +14,7 @@ import oauthlib.oauth1 import urllib import json +import textwrap from xmodule.editing_module import MetadataOnlyEditingDescriptor from xmodule.raw_module import EmptyDataRawDescriptor @@ -387,37 +391,54 @@ def handle_ajax(self, dispatch, data): {'status_code': HTTP status code, 'content': use it only for returning data for action `read`} """ - # Investigate how will be applied changes to specific user_id - + # It can be case, that user is logged out, but TP have not yet submitted grade for him. + # for this case, TP sends back anonymous_id and we obtain user by anonymous user id. + # TODO: test and verify it! + import ipdb; ipdb.set_trace() + anonymous_id = data.get('anonymous_id', 'test_anonymous_id') action = dispatch.lower() - + # test $.post('/preview/modx/0/i4x://mitx/cs101/lti/80587c94f3cc455f8a63f660b7ff9315/set', {'score': 1}) + # $.post('http://localhost:8000/courses/blades/1/2013_Spring/courseware/caac519cf1ad4fba96a76a6f816faae9/f773addfbafa4059a0bcd5c33ce01901/set', {'score': 1}) if action == 'set': if 'score' not in data.keys(): return json.dumps({'status_code': 400}) - self.system.publish({ - 'event_name': 'grade', - 'value': data['score'], - 'max_value': self.get_maxscore(), - }) + self.system.publish( + event={ + 'event_name': 'grade', + 'value': data['score'], + 'max_value': self.get_maxscore(), + }, + custom_user=self.system.user + ) return json.dumps({'status_code': 200}) - elif action == 'read': + else: # return "unsupported" response response = { 'status_code': 200, - 'content': { - 'value': self.get_score(), - }, + 'content': textwrap.dedent(""" + + + + + V1.0 + 4560 + + unsupported + status + readPerson is not supported + 999999123 + readPerson + + + + + + """) } - return json.dumps(response) - elif action == 'delete': - return json.dumps({'status_code': 200}) - - return json.dumps({'status_code': 404}) - class LTIModuleDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRawDescriptor): """ diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 1539b883d34f..1d74d86c95fd 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -285,15 +285,20 @@ def inner_get_module(descriptor): position, wrap_xmodule_display, grade_bucket_type, static_asset_path) - def publish(event): + def publish(event, custom_user=None): """A function that allows XModules to publish events. This only supports grade changes right now.""" if event.get('event_name') != 'grade': return + if custom_user: + user_id = custom_user.id + else: + user_id = user.id + # Construct the key for the module key = KeyValueStore.Key( scope=Scope.user_state, - user_id=user.id, + user_id=user_id, block_scope_id=descriptor.location, field_name='grade' ) From 0fc3eebe27ba24f15144ef7ba4f5b16465357ea6 Mon Sep 17 00:00:00 2001 From: Oleg Marshev Date: Tue, 12 Nov 2013 12:22:03 +0200 Subject: [PATCH 03/87] Update test lti server and add script for manual run --- lms/djangoapps/courseware/features/lti.py | 2 ++ .../mock_lti_server/mock_lti_server.py | 29 +++++++++++++++---- .../mock_lti_server/server_start.py | 25 ++++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 lms/djangoapps/courseware/mock_lti_server/server_start.py diff --git a/lms/djangoapps/courseware/features/lti.py b/lms/djangoapps/courseware/features/lti.py index 244b9f68b0e3..e16a14d5f7ca 100644 --- a/lms/djangoapps/courseware/features/lti.py +++ b/lms/djangoapps/courseware/features/lti.py @@ -73,6 +73,7 @@ def set_correct_lti_passport(_step): world.lti_server.oauth_settings['client_secret'] )] } + import ipdb; ipdb.set_trace() i_am_registered_for_the_course(coursenum, metadata) @@ -236,6 +237,7 @@ def check_lti_popup(): @step('I open gradebook$') def check_gradebook(_step): + #import ipdb; ipdb.set_trace() location = world.scenario_dict['LTI'].location.html_id() iframe_name = 'ltiLaunchFrame-' + location with world.browser.get_iframe(iframe_name) as iframe: diff --git a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py index a9fc70d463cd..12b6df9e45c4 100644 --- a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py +++ b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py @@ -32,6 +32,8 @@ def do_GET(self): ''' Handle a POST request from the client and sends response back. ''' + post_dict = self._post_dict() + self.send_response(200, 'OK') self.send_header('Content-type', 'html') self.end_headers() @@ -77,17 +79,19 @@ def do_POST(self): 'lis_result_sourcedid', 'launch_presentation_return_url', 'lis_person_sourcedid', + 'resource_link_id', ] - if sorted(correct_keys) != sorted(post_dict.keys()): status_message = "Incorrect LTI header" else: params = {k: v for k, v in post_dict.items() if k != 'oauth_signature'} + ''' if self.server.check_oauth_signature(params, post_dict['oauth_signature']): status_message = "This is LTI tool. Success." else: status_message = "Wrong LTI signature" - + ''' + status_message = "This is LTI tool. Success." callback_url = post_dict["lis_outcome_service_url"] if callback_url: MockLTIRequestHandler.callback_url = callback_url @@ -95,8 +99,7 @@ def do_POST(self): callback_url = None else: status_message = "Invalid request URL" - - self._send_response(status_message, url=callback_url) + self._send_response(status_message, url=MockLTIRequestHandler.callback_url) def _send_head(self): ''' @@ -127,14 +130,28 @@ def _post_dict(self): # the correct fields, it won't find them, # and will therefore send an error response return {} + try: + cookie = self.headers.getheader('cookie') + self.server.cookie = {k.strip():v[0] for k,v in urlparse.parse_qs(cookie).items()} + except: + self.server.cookie = {} return post_dict def _send_graded_result(self, callback_url): payload = {'score': 0.95} # temporarily changed to get for easy view in browser - response=requests.post(callback_url, data=payload) - assert response.status_code == 200 + url = "http://localhost:8001" + callback_url + cookies = self.server.cookie + headers = {'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest', 'X-CSRFToken':'update_me'} + headers['X-CSRFToken'] = cookies.get('csrftoken') + response=requests.post( + url, + data=payload, + cookies=cookies, + headers=headers + ) + #assert response.status_code == 200 def _send_response(self, message, url=None): ''' diff --git a/lms/djangoapps/courseware/mock_lti_server/server_start.py b/lms/djangoapps/courseware/mock_lti_server/server_start.py new file mode 100644 index 000000000000..40efea41cced --- /dev/null +++ b/lms/djangoapps/courseware/mock_lti_server/server_start.py @@ -0,0 +1,25 @@ +import threading +from mock_lti_server import MockLTIServer +server_port = 8034 +server_host = '127.0.0.1' +address = (server_host, server_port) +server = MockLTIServer(address) +server.oauth_settings = { + 'client_key': 'test_client_key', + 'client_secret': 'test_client_secret', + 'lti_base': 'http://{}:{}/'.format(server_host, server_port), + 'lti_endpoint': 'correct_lti_endpoint' + } + +try: + server.serve_forever() +except KeyboardInterrupt: + print('^C received, shutting down server') + server.socket.close() + +''' +#Start the server in a separate daemon thread +server_thread = threading.Thread(target=server.serve_forever) +server_thread.daemon = True +server_thread.start() +''' \ No newline at end of file From 3960a0a6f2152e7d4a0762b1a47a25671d4d37e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Tue, 12 Nov 2013 15:13:10 +0200 Subject: [PATCH 04/87] Investigations. --- common/lib/xmodule/xmodule/lti_module.py | 8 ++-- common/lib/xmodule/xmodule/x_module.py | 4 +- .../mock_lti_server/mock_lti_server.py | 45 ++++++++++--------- lms/djangoapps/courseware/module_render.py | 3 +- 4 files changed, 34 insertions(+), 26 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 504cafdb152a..62568d300cbb 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -254,7 +254,8 @@ def get_base_path(self): path=self.system.ajax_url, ) else: - return self.system.hostname + self.system.ajax_url + # return self.system.hostname + self.system.ajax_url + return self.system.ajax_url def get_context_id(self): # This is an opaque identifier that uniquely identifies the context that contains @@ -315,7 +316,7 @@ def oauth_params(self, custom_parameters, client_key, client_secret): # must have parameters for correct signing from LTI: body = { - u'user_id': self.get_user_id, + u'user_id': self.get_user_id(), u'oauth_callback': u'about:blank', u'launch_presentation_return_url': '', u'lti_message_type': u'basic-lti-launch-request', @@ -394,7 +395,6 @@ def handle_ajax(self, dispatch, data): # It can be case, that user is logged out, but TP have not yet submitted grade for him. # for this case, TP sends back anonymous_id and we obtain user by anonymous user id. # TODO: test and verify it! - import ipdb; ipdb.set_trace() anonymous_id = data.get('anonymous_id', 'test_anonymous_id') action = dispatch.lower() # test $.post('/preview/modx/0/i4x://mitx/cs101/lti/80587c94f3cc455f8a63f660b7ff9315/set', {'score': 1}) @@ -409,7 +409,7 @@ def handle_ajax(self, dispatch, data): 'value': data['score'], 'max_value': self.get_maxscore(), }, - custom_user=self.system.user + custom_user=self.system.get_real_user(anonymous_id) ) return json.dumps({'status_code': 200}) diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 8618aef9ae54..9c65683418fe 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -962,7 +962,7 @@ def __init__( anonymous_student_id='', course_id=None, open_ended_grading_interface=None, s3_interface=None, cache=None, can_execute_unsafe_code=None, replace_course_urls=None, - replace_jump_to_id_urls=None, error_descriptor_class=None, **kwargs): + replace_jump_to_id_urls=None, error_descriptor_class=None, get_real_user=None, **kwargs): """ Create a closure around the system environment. @@ -1047,6 +1047,8 @@ def __init__( self.error_descriptor_class = error_descriptor_class self.xmodule_instance = None + self.get_real_user = get_real_user + def get(self, attr): """ provide uniform access to attributes (like etree).""" return self.__dict__.get(attr) diff --git a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py index 12b6df9e45c4..7e912950120f 100644 --- a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py +++ b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py @@ -32,19 +32,18 @@ def do_GET(self): ''' Handle a POST request from the client and sends response back. ''' - post_dict = self._post_dict() + self.post_dict = self._post_dict() self.send_response(200, 'OK') self.send_header('Content-type', 'html') self.end_headers() response_str = """TEST TITLE - Grade this LTI""" + I have stored grades.""" self.wfile.write(response_str) - if MockLTIRequestHandler.callback_url: - self._send_graded_result(MockLTIRequestHandler.callback_url) + self._send_graded_result() @@ -54,10 +53,10 @@ def do_POST(self): ''' self._send_head() - post_dict = self._post_dict() # Retrieve the POST data + self.post_dict = self._post_dict() # Retrieve the POST data logger.debug("LTI provider received POST request {} to path {}".format( - str(post_dict), + str(self.post_dict), self.path) ) # Log the request @@ -81,10 +80,10 @@ def do_POST(self): 'lis_person_sourcedid', 'resource_link_id', ] - if sorted(correct_keys) != sorted(post_dict.keys()): + if sorted(correct_keys) != sorted(self.post_dict.keys()): status_message = "Incorrect LTI header" else: - params = {k: v for k, v in post_dict.items() if k != 'oauth_signature'} + params = {k: v for k, v in self.post_dict.items() if k != 'oauth_signature'} ''' if self.server.check_oauth_signature(params, post_dict['oauth_signature']): status_message = "This is LTI tool. Success." @@ -92,14 +91,17 @@ def do_POST(self): status_message = "Wrong LTI signature" ''' status_message = "This is LTI tool. Success." - callback_url = post_dict["lis_outcome_service_url"] - if callback_url: - MockLTIRequestHandler.callback_url = callback_url - else: - callback_url = None + + # set data for grades + # what need to be stored as server data + self.server.grade_data = { + 'callback_url': self.post_dict["lis_outcome_service_url"], + 'user_id': self.post_dict['user_id'] + } + else: status_message = "Invalid request URL" - self._send_response(status_message, url=MockLTIRequestHandler.callback_url) + self._send_response(status_message) def _send_head(self): ''' @@ -137,28 +139,31 @@ def _post_dict(self): self.server.cookie = {} return post_dict - def _send_graded_result(self, callback_url): - payload = {'score': 0.95} + def _send_graded_result(self): + payload = { + 'score': 0.3, + 'anonymous_id': self.server.grade_data['user_id'] + } # temporarily changed to get for easy view in browser - url = "http://localhost:8001" + callback_url + url = "http://localhost:8000" + self.server.grade_data['callback_url'] cookies = self.server.cookie headers = {'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest', 'X-CSRFToken':'update_me'} headers['X-CSRFToken'] = cookies.get('csrftoken') response=requests.post( url, data=payload, - cookies=cookies, + cookies={k: v for k,v in cookies.items() if k in ['csrftoken', 'sessionid']}, headers=headers ) #assert response.status_code == 200 - def _send_response(self, message, url=None): + def _send_response(self, message): ''' Send message back to the client ''' - if url is not None: + if self.server.grade_data['callback_url']: response_str = """TEST TITLE

Graded IFrame loaded

\ diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 1d74d86c95fd..8490497cd407 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -24,7 +24,7 @@ from lms.lib.xblock.runtime import LmsModuleSystem, handler_prefix, unquote_slashes from mitxmako.shortcuts import render_to_string from psychometrics.psychoanalyze import make_psychometrics_data_update_handler -from student.models import unique_id_for_user +from student.models import unique_id_for_user, user_by_anonymous_id from util.json_request import JsonResponse from util.sandboxing import can_execute_unsafe_code from xblock.fields import Scope @@ -406,6 +406,7 @@ def publish(event, custom_user=None): # TODO: When we merge the descriptor and module systems, we can stop reaching into the mixologist (cpennington) mixins=descriptor.runtime.mixologist._mixins, # pylint: disable=protected-access wrappers=block_wrappers, + get_real_user=user_by_anonymous_id, ) # pass position specified in URL to module through ModuleSystem From 78306ce382c1c256d208785616c9bc1487ef38a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Tue, 12 Nov 2013 20:05:06 +0200 Subject: [PATCH 05/87] Add processing of XML request to LTI. --- common/lib/xmodule/xmodule/lti_module.py | 197 +++++++++++------- .../courseware/features/lti.feature | 3 +- lms/djangoapps/courseware/features/lti.py | 14 +- .../mock_lti_server/mock_lti_server.py | 86 ++++++-- 4 files changed, 207 insertions(+), 93 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 62568d300cbb..8da7969fff9e 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -13,8 +13,8 @@ import logging import oauthlib.oauth1 import urllib -import json import textwrap +from lxml import etree from xmodule.editing_module import MetadataOnlyEditingDescriptor from xmodule.raw_module import EmptyDataRawDescriptor @@ -265,38 +265,40 @@ def get_context_id(self): return self.lti_id 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. - - # This is unique id of edx platform. + """ + 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. + """ + # This is unique id of edx platform instance. Ned, it is true? return unicode(self.id) if self.is_graded else '' def get_lis_result_sourcedid(self): - if self.is_graded: - # 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 optional. - - # context_id is lti_id + """ + 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. + """ + if self.is_graded: # lti_id should be context_id by meaning. return '{}::{}::{}'.format(self.lti_id, self.get_resource_link_id(), self.get_user_id()) else: return '' - def get_lis_person_sourcedid(self): - # This field contains the LIS identifier for the user account that is performing this launch. - # The example syntax of "school:user" is not the required format - lis_person_sourcedid - # is simply a unique identifier (i.e., a normalized string). - # This field is optional and its content and meaning are defined by LIS. - school = 'EdX' - return '{}:{}:{}'.format(school, self.get_user_id(), uuid4().hex) if self.is_graded else '' + # Optional, do not use it for now. + # def get_lis_person_sourcedid(self): + # # This field contains the LIS identifier for the user account that is performing this launch. + # # The example syntax of "school:user" is not the required format - lis_person_sourcedid + # # is simply a unique identifier (i.e., a normalized string). + # # This field is optional and its content and meaning are defined by LIS. + # school = 'EdX' + # return '{}:{}:{}'.format(school, self.get_user_id(), uuid4().hex) if self.is_graded else '' def oauth_params(self, custom_parameters, client_key, client_secret): """ @@ -323,14 +325,11 @@ def oauth_params(self, custom_parameters, client_key, client_secret): u'lti_version': 'LTI-1p0', u'role': u'student', - # for grades, TODO: generate properly: - # required + # Parameters required for grading: u'resource_link_id': self.get_resource_link_id(), - u'lis_outcome_service_url': '{}/set'.format(self.get_base_path() if self.is_graded else ''), - - # optional fields + u'lis_outcome_service_url': '{}/replaceResult'.format(self.get_base_path() if self.is_graded else ''), u'lis_result_sourcedid': self.get_lis_result_sourcedid(), - u'lis_person_sourcedid': self.get_lis_person_sourcedid(), + # u'lis_person_sourcedid': self.get_lis_person_sourcedid(), # optional, do not use for now. } @@ -376,6 +375,9 @@ def oauth_params(self, custom_parameters, client_key, client_secret): return params def get_score(self): + """ + TODO read score + """ return { 'score': 0.5, 'total': self.get_maxscore() @@ -388,56 +390,111 @@ def handle_ajax(self, dispatch, data): """ This is called by courseware.module_render, to handle an AJAX call. + Used only for grading. + Returns json in following format: - {'status_code': HTTP status code, 'content': use it only for returning - data for action `read`} + {'status_code': HTTP status code, 'content': proper XML defined in LTI v.1.1} + + 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. + """ + + # verify oauth signing + + # Beware: xmlns is broken link, as it is from LTI spec page, where it is broken. + response_xml_template = textwrap.dedent(""" + + + + + V1.0 + {imsx_messageIdentifier} + + {imsx_codeMajor} + status + {imsx_description} + + + + + + {response} + + """) + + unsupported_values = { + 'imsx_codeMajor': 'unsupported', + 'imsx_description': 'Only replaceResult is supported', + 'imsx_messageIdentifier': 'unknown', + 'response': '' + } + + # what should come from LTI provider: + """ + data_example = textwrap.dedent(\""" + + + + + V1.0 + 528243ba5241b + + + + + + + feb-123-456-2929::28883 + + + + en-us + 0.4 + + + + + + + \""") """ - # It can be case, that user is logged out, but TP have not yet submitted grade for him. - # for this case, TP sends back anonymous_id and we obtain user by anonymous user id. - # TODO: test and verify it! - anonymous_id = data.get('anonymous_id', 'test_anonymous_id') + data = data.keys()[0] + '=' + data.values()[0] + data = data.strip().encode('utf-8') + parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') + try: # get data from request + root = etree.fromstring(data, parser=parser) + namespaces = {'def': root.nsmap.values()[0]} + imsx_messageIdentifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text + sourcedId = root.xpath("//def:sourcedId", namespaces=namespaces)[0].text + score = root.xpath("//def:textString", namespaces=namespaces)[0].text + except: + return response_xml_template.format(**unsupported_values), "application/xml" + action = dispatch.lower() - # test $.post('/preview/modx/0/i4x://mitx/cs101/lti/80587c94f3cc455f8a63f660b7ff9315/set', {'score': 1}) - # $.post('http://localhost:8000/courses/blades/1/2013_Spring/courseware/caac519cf1ad4fba96a76a6f816faae9/f773addfbafa4059a0bcd5c33ce01901/set', {'score': 1}) - if action == 'set': - if 'score' not in data.keys(): - return json.dumps({'status_code': 400}) + if action == 'replaceresult': self.system.publish( event={ 'event_name': 'grade', - 'value': data['score'], + 'value': score, 'max_value': self.get_maxscore(), }, - custom_user=self.system.get_real_user(anonymous_id) + custom_user=self.system.get_real_user(sourcedId.split('::')[-1]) ) - return json.dumps({'status_code': 200}) - - else: # return "unsupported" response - response = { - 'status_code': 200, - 'content': textwrap.dedent(""" - - - - - V1.0 - 4560 - - unsupported - status - readPerson is not supported - 999999123 - readPerson - - - - - - """) + values = { + 'imsx_codeMajor': 'success', + 'imsx_description': 'Score for {sourced_id} is now {score}'.format(sourced_id=sourcedId, score=score), + 'imsx_messageIdentifier': imsx_messageIdentifier, + 'response': '' } - return json.dumps(response) + return response_xml_template.format(**values), "application/xml" + + # return "unsupported" response + unsupported_values['imsx_messageIdentifier'] = imsx_messageIdentifier + return response_xml_template.format(**unsupported_values), "application/xml" class LTIModuleDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRawDescriptor): diff --git a/lms/djangoapps/courseware/features/lti.feature b/lms/djangoapps/courseware/features/lti.feature index f02c60411391..126ddf2b2b41 100644 --- a/lms/djangoapps/courseware/features/lti.feature +++ b/lms/djangoapps/courseware/features/lti.feature @@ -36,4 +36,5 @@ Feature: LMS.LTI component Scenario: Graded LTI component in LMS is correctly works Given the course has correct LTI credentials And the course has an LTI component with correct fields, new_page is false, is_graded is true - And I open gradebook + And I click on Grade link + Then I wiew result in Progress page diff --git a/lms/djangoapps/courseware/features/lti.py b/lms/djangoapps/courseware/features/lti.py index e16a14d5f7ca..63ee75ea64f3 100644 --- a/lms/djangoapps/courseware/features/lti.py +++ b/lms/djangoapps/courseware/features/lti.py @@ -73,7 +73,6 @@ def set_correct_lti_passport(_step): world.lti_server.oauth_settings['client_secret'] )] } - import ipdb; ipdb.set_trace() i_am_registered_for_the_course(coursenum, metadata) @@ -247,3 +246,16 @@ def check_gradebook(_step): world.click_link('Gradebook') assert world.is_css_present('.grade-table') +@step('I wiew result in Progress page$') +def check_progress(_step): + world.click_link('Progress') + assert world.browser.is_text_present('Practice Scores: 0.99/1') + +@step('I click on Grade link$') +def check_progress(_step): + location = world.scenario_dict['LTI'].location.html_id() + iframe_name = 'ltiLaunchFrame-' + location + with world.browser.get_iframe(iframe_name) as iframe: + world.click_link('Grade') + assert world.browser.is_text_present('I have stored grades.') + diff --git a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py index 7e912950120f..a8d703966b7c 100644 --- a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py +++ b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py @@ -1,9 +1,13 @@ from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler +from uuid import uuid4 +import textwrap import urlparse from oauthlib.oauth1.rfc5849 import signature import mock import sys import requests +import textwrap + from logging import getLogger logger = getLogger(__name__) @@ -30,9 +34,8 @@ def do_HEAD(self): def do_GET(self): ''' - Handle a POST request from the client and sends response back. + Handle a GET request from the client and sends response back. ''' - self.post_dict = self._post_dict() self.send_response(200, 'OK') self.send_header('Content-type', 'html') @@ -51,17 +54,20 @@ def do_POST(self): ''' Handle a POST request from the client and sends response back. ''' - self._send_head() - - self.post_dict = self._post_dict() # Retrieve the POST data + ''' logger.debug("LTI provider received POST request {} to path {}".format( str(self.post_dict), self.path) ) # Log the request - - # Respond only to requests with correct lti endpoint: - if self._is_correct_lti_request(): + ''' + # Respond to grade request + if 'grade' in self.path and self._send_graded_result().status_code == 200: + status_message = "I have stored grades." + self.server.grade_data['callback_url'] = None + # Respond to request with correct lti endpoint: + elif self._is_correct_lti_request(): + self.post_dict = self._post_dict() correct_keys = [ 'user_id', 'role', @@ -77,7 +83,7 @@ def do_POST(self): 'lis_outcome_service_url', 'lis_result_sourcedid', 'launch_presentation_return_url', - 'lis_person_sourcedid', + # 'lis_person_sourcedid', optional, not used now. 'resource_link_id', ] if sorted(correct_keys) != sorted(self.post_dict.keys()): @@ -98,20 +104,23 @@ def do_POST(self): 'callback_url': self.post_dict["lis_outcome_service_url"], 'user_id': self.post_dict['user_id'] } - else: status_message = "Invalid request URL" + + self._send_head() self._send_response(status_message) def _send_head(self): ''' Send the response code and MIME headers ''' + self.send_response(200) + ''' if self._is_correct_lti_request(): self.send_response(200) else: self.send_response(500) - + ''' self.send_header('Content-type', 'text/html') self.end_headers() @@ -137,26 +146,58 @@ def _post_dict(self): self.server.cookie = {k.strip():v[0] for k,v in urlparse.parse_qs(cookie).items()} except: self.server.cookie = {} + referer = urlparse.urlparse(self.headers.getheader('referer')) + self.server.referer_host = "{}://{}".format(referer.scheme, referer.netloc) return post_dict def _send_graded_result(self): - payload = { - 'score': 0.3, - 'anonymous_id': self.server.grade_data['user_id'] + + values = { + 'textString': 0.9, + 'sourcedId': self.server.grade_data['user_id'], + 'imsx_messageIdentifier': uuid4().hex, } + payload = textwrap.dedent(""" + + + + + V1.0 + {imsx_messageIdentifier} / + + + + + + + {sourcedId} + + + + en-us + {textString} + + + + + + + """) + data = payload.format(**values) # temporarily changed to get for easy view in browser - url = "http://localhost:8000" + self.server.grade_data['callback_url'] + url = self.server.referer_host + self.server.grade_data['callback_url'] cookies = self.server.cookie - headers = {'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest', 'X-CSRFToken':'update_me'} + headers = {'Content-Type': 'application/xml', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRFToken': 'update_me'} headers['X-CSRFToken'] = cookies.get('csrftoken') - response=requests.post( + response = requests.post( url, - data=payload, - cookies={k: v for k,v in cookies.items() if k in ['csrftoken', 'sessionid']}, + data=data, + cookies={k: v for k, v in cookies.items() if k in ['csrftoken', 'sessionid']}, headers=headers ) - #assert response.status_code == 200 + assert response.status_code == 200 + return response def _send_response(self, message): ''' @@ -169,7 +210,10 @@ def _send_response(self, message):

Graded IFrame loaded

\

Server response is:

\

{}

- Grade +
+ +
+ """.format(message, url="http://%s:%s" % self.server.server_address) else: response_str = """TEST TITLE From a4a2abebf1040be2179b6d6a37f59fcf1946b1f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Thu, 14 Nov 2013 17:48:05 +0200 Subject: [PATCH 06/87] Add course_id to lookup table. --- .../commands/anonymized_id_mapping.py | 2 +- ...e_between_user_and_anonymous_student_id.py | 6 +- .../0030_add_index_for_anonymous_user_id.py | 187 ------------------ common/djangoapps/student/models.py | 22 ++- common/djangoapps/student/tests/tests.py | 7 +- common/djangoapps/student/views.py | 8 +- lms/djangoapps/courseware/module_render.py | 2 +- lms/djangoapps/foldit/tests.py | 4 +- lms/djangoapps/foldit/views.py | 6 +- lms/djangoapps/instructor/views/api.py | 2 +- lms/djangoapps/instructor/views/legacy.py | 2 +- .../open_ended_notifications.py | 6 +- .../staff_grading_service.py | 6 +- lms/djangoapps/open_ended_grading/tests.py | 2 +- lms/djangoapps/open_ended_grading/views.py | 4 +- 15 files changed, 49 insertions(+), 217 deletions(-) delete mode 100644 common/djangoapps/student/migrations/0030_add_index_for_anonymous_user_id.py diff --git a/common/djangoapps/student/management/commands/anonymized_id_mapping.py b/common/djangoapps/student/management/commands/anonymized_id_mapping.py index f1ed5bdef901..bb32aea6e55d 100644 --- a/common/djangoapps/student/management/commands/anonymized_id_mapping.py +++ b/common/djangoapps/student/management/commands/anonymized_id_mapping.py @@ -54,7 +54,7 @@ def handle(self, *args, **options): csv_writer = csv.writer(output_file) csv_writer.writerow(("User ID", "Anonymized user ID")) for student in students: - csv_writer.writerow((student.id, unique_id_for_user(student))) + csv_writer.writerow((student.id, unique_id_for_user(student, course_id))) except IOError: raise CommandError("Error writing to file: %s" % output_filename) diff --git a/common/djangoapps/student/migrations/0029_add_lookup_table_between_user_and_anonymous_student_id.py b/common/djangoapps/student/migrations/0029_add_lookup_table_between_user_and_anonymous_student_id.py index 384edae94b05..9d14e18226b0 100644 --- a/common/djangoapps/student/migrations/0029_add_lookup_table_between_user_and_anonymous_student_id.py +++ b/common/djangoapps/student/migrations/0029_add_lookup_table_between_user_and_anonymous_student_id.py @@ -11,8 +11,9 @@ def forwards(self, orm): # Adding model 'AnonymousUsers' db.create_table('student_anonymoususers', ( ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='anonymous', unique=True, to=orm['auth.User'])), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='anonymous', to=orm['auth.User'])), ('anonymous_user_id', self.gf('django.db.models.fields.CharField')(unique=True, max_length=16)), + ('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), )) db.send_create_signal('student', ['AnonymousUsers']) @@ -62,8 +63,9 @@ def backwards(self, orm): 'student.anonymoususers': { 'Meta': {'object_name': 'AnonymousUsers'}, 'anonymous_user_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '16'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'anonymous'", 'unique': 'True', 'to': "orm['auth.User']"}) + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'anonymous'", 'to': "orm['auth.User']"}) }, 'student.courseenrollment': { 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, diff --git a/common/djangoapps/student/migrations/0030_add_index_for_anonymous_user_id.py b/common/djangoapps/student/migrations/0030_add_index_for_anonymous_user_id.py deleted file mode 100644 index c855292999fe..000000000000 --- a/common/djangoapps/student/migrations/0030_add_index_for_anonymous_user_id.py +++ /dev/null @@ -1,187 +0,0 @@ -# -*- coding: utf-8 -*- -import datetime -from south.db import db -from south.v2 import SchemaMigration -from django.db import models - - -class Migration(SchemaMigration): - - def forwards(self, orm): - # Adding index on 'AnonymousUsers', fields ['anonymous_user_id'] - db.create_index('student_anonymoususers', ['anonymous_user_id']) - - - def backwards(self, orm): - # Removing index on 'AnonymousUsers', fields ['anonymous_user_id'] - db.delete_index('student_anonymoususers', ['anonymous_user_id']) - - - models = { - 'auth.group': { - 'Meta': {'object_name': 'Group'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), - 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) - }, - 'auth.permission': { - 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, - 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - }, - 'auth.user': { - 'Meta': {'object_name': 'User'}, - 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), - 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), - 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), - 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) - }, - 'contenttypes.contenttype': { - 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, - 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) - }, - 'student.anonymoususers': { - 'Meta': {'object_name': 'AnonymousUsers'}, - 'anonymous_user_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '16', 'db_index': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'anonymous'", 'unique': 'True', 'to': "orm['auth.User']"}) - }, - 'student.courseenrollment': { - 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, - 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), - 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - }, - 'student.courseenrollmentallowed': { - 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'}, - 'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), - 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), - 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) - }, - 'student.pendingemailchange': { - 'Meta': {'object_name': 'PendingEmailChange'}, - 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), - 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) - }, - 'student.pendingnamechange': { - 'Meta': {'object_name': 'PendingNameChange'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), - 'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), - 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) - }, - 'student.registration': { - 'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"}, - 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'}) - }, - 'student.testcenterregistration': { - 'Meta': {'object_name': 'TestCenterRegistration'}, - 'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), - 'accommodation_request': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), - 'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), - 'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}), - 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), - 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), - 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), - 'eligibility_appointment_date_first': ('django.db.models.fields.DateField', [], {'db_index': 'True'}), - 'eligibility_appointment_date_last': ('django.db.models.fields.DateField', [], {'db_index': 'True'}), - 'exam_series_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), - 'testcenter_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['student.TestCenterUser']"}), - 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), - 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}), - 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), - 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), - 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}) - }, - 'student.testcenteruser': { - 'Meta': {'object_name': 'TestCenterUser'}, - 'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}), - 'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), - 'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), - 'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), - 'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), - 'client_candidate_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}), - 'company_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '50', 'blank': 'True'}), - 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), - 'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}), - 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), - 'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}), - 'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}), - 'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}), - 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), - 'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}), - 'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}), - 'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}), - 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), - 'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), - 'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), - 'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), - 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), - 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}), - 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), - 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}), - 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}) - }, - 'student.userprofile': { - 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, - 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}), - 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), - 'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), - 'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), - 'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), - 'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), - 'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), - 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}), - 'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) - }, - 'student.userstanding': { - 'Meta': {'object_name': 'UserStanding'}, - 'account_status': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}), - 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'standing_last_changed_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'standing'", 'unique': 'True', 'to': "orm['auth.User']"}) - }, - 'student.usertestgroup': { - 'Meta': {'object_name': 'UserTestGroup'}, - 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), - 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'}) - } - } - - complete_apps = ['student'] \ No newline at end of file diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 10ca1bce5485..a0cf50076630 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -45,18 +45,19 @@ class AnonymousUsers(models.Model): """ - This table contains user and anonymous_user_id + This table contains user, course_Id and anonymous_user_id Purpose of this table is to provide user by anonymous_user_id. We are generating anonymous_user_id using md5 algorithm, so resulting length will always be 16 bytes. http://docs.python.org/2/library/md5.html#md5.digest_size """ - user = models.ForeignKey(User, db_index=True, related_name='anonymous', unique=True) - anonymous_user_id = models.CharField(db_index=True, blank=False, unique=True, max_length=16) + user = models.ForeignKey(User, db_index=True, related_name='anonymous') + anonymous_user_id = models.CharField(unique=True, max_length=16) + course_id = models.CharField(db_index=True, max_length=255) -def unique_id_for_user(user): +def unique_id_for_user(user, course_id): """ Return a unique id for a user, suitable for inserting into e.g. personalized survey links. @@ -70,6 +71,7 @@ def _unique_id_for_user(user): return AnonymousUsers.objects.get_or_create( user=user, + course_id=course_id, anonymous_user_id=_unique_id_for_user(user) )[0].anonymous_user_id @@ -89,6 +91,18 @@ def user_by_anonymous_id(id): return obj.user +def unique_id_for_foldit_user(user): + """ + Return a unique id for a user, suitable for inserting into foldit module table + """ + # include the secret key as a salt, and to make the ids unique across + # different LMS installs. + h = hashlib.md5() + h.update(settings.SECRET_KEY) + h.update(str(user.id)) + return h.hexdigest() + + class UserStanding(models.Model): """ This table contains a student's account's status. diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index f788c7fd3d55..3745f28135bf 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -138,15 +138,18 @@ class CourseEndingTest(TestCase): """Test things related to course endings: certificates, surveys, etc""" def test_process_survey_link(self): + course = Mock(id="test_id") + username = "fred" user = Mock(username=username) id = unique_id_for_user(user) link1 = "http://www.mysurvey.com" - self.assertEqual(process_survey_link(link1, user), link1) + self.assertEqual(process_survey_link(link1, user, course.id), link1) link2 = "http://www.mysurvey.com?unique={UNIQUE_ID}" link2_expected = "http://www.mysurvey.com?unique={UNIQUE_ID}".format(UNIQUE_ID=id) - self.assertEqual(process_survey_link(link2, user), link2_expected) + + self.assertEqual(process_survey_link(link2, user, course.id), link2_expected) def test_cert_info(self): user = Mock(username="fred") diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 1702d7145e83..b2e5d7560593 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -149,12 +149,12 @@ def press(request): return render_to_response('static_templates/press.html', {'articles': articles}) -def process_survey_link(survey_link, user): +def process_survey_link(survey_link, user, course_id): """ If {UNIQUE_ID} appears in the link, replace it with a unique id for the user. Currently, this is sha1(user.username). Otherwise, return survey_link. """ - return survey_link.format(UNIQUE_ID=unique_id_for_user(user)) + return survey_link.format(UNIQUE_ID=unique_id_for_user(user, course_id)) def cert_info(user, course): @@ -209,7 +209,7 @@ def _cert_info(user, course, cert_status): course.end_of_course_survey_url is not None): d.update({ 'show_survey_button': True, - 'survey_url': process_survey_link(course.end_of_course_survey_url, user)}) + 'survey_url': process_survey_link(course.end_of_course_survey_url, user, course.id)}) else: d['show_survey_button'] = False @@ -296,7 +296,7 @@ def complete_course_mode_info(course_id, enrollment): def dashboard(request): user = request.user - # Build our (course, enorllment) list for the user, but ignore any courses that no + # Build our (course, enorllment) list for the user, but ignore any courses that no # longer exist (because the course IDs have changed). Still, we don't delete those # enrollments, because it could have been a data push snafu. course_enrollment_pairs = [] diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 8490497cd407..4fc356d4f86d 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -397,7 +397,7 @@ def publish(event, custom_user=None): ), node_path=settings.NODE_PATH, publish=publish, - anonymous_student_id=unique_id_for_user(user), + anonymous_student_id=unique_id_for_user(user, course_id), course_id=course_id, open_ended_grading_interface=open_ended_grading_interface, s3_interface=s3_interface, diff --git a/lms/djangoapps/foldit/tests.py b/lms/djangoapps/foldit/tests.py index c97cd2b59d36..7a6275638351 100644 --- a/lms/djangoapps/foldit/tests.py +++ b/lms/djangoapps/foldit/tests.py @@ -8,7 +8,7 @@ from foldit.views import foldit_ops, verify_code from foldit.models import PuzzleComplete, Score -from student.models import unique_id_for_user +from student.models import unique_id_for_foldit_user from student.tests.factories import CourseEnrollmentFactory, UserFactory, UserProfileFactory from datetime import datetime, timedelta @@ -346,7 +346,7 @@ def test_SetPlayerPuzzlesComplete_level_complete(self): self.set_puzzle_complete_response([13, 14, 15, 53524])) is_complete = partial( - PuzzleComplete.is_level_complete, unique_id_for_user(self.user)) + PuzzleComplete.is_level_complete, unique_id_for_foldit_user(self.user)) self.assertTrue(is_complete(1, 1)) self.assertTrue(is_complete(1, 3)) diff --git a/lms/djangoapps/foldit/views.py b/lms/djangoapps/foldit/views.py index 91423bbf8876..9517c24dd130 100644 --- a/lms/djangoapps/foldit/views.py +++ b/lms/djangoapps/foldit/views.py @@ -8,7 +8,7 @@ from django.views.decorators.csrf import csrf_exempt from foldit.models import Score, PuzzleComplete -from student.models import unique_id_for_user +from student.models import unique_id_for_foldit_user import re @@ -124,7 +124,7 @@ def save_scores(user, puzzle_scores): try: obj = Score.objects.get( user=user, - unique_user_id=unique_id_for_user(user), + unique_user_id=unique_id_for_foldit_user(user), puzzle_id=puzzle_id, score_version=score_version) obj.current_score = current_score @@ -133,7 +133,7 @@ def save_scores(user, puzzle_scores): except Score.DoesNotExist: obj = Score( user=user, - unique_user_id=unique_id_for_user(user), + unique_user_id=unique_id_for_foldit_user(user), puzzle_id=puzzle_id, current_score=current_score, best_score=best_score, diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 5596ba9c4140..468197341485 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -448,7 +448,7 @@ def csv_response(filename, header, rows): courseenrollment__course_id=course_id, ).order_by('id') header = ['User ID', 'Anonymized user ID'] - rows = [[s.id, unique_id_for_user(s)] for s in students] + rows = [[s.id, unique_id_for_user(s, course_id)] for s in students] return csv_response(course_id.replace('/', '-') + '-anon-ids.csv', header, rows) diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index 98c18dc68bea..0d15f9d3f36c 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -547,7 +547,7 @@ def getdat(u): ).order_by('id') datatable = {'header': ['User ID', 'Anonymized user ID']} - datatable['data'] = [[s.id, unique_id_for_user(s)] for s in students] + datatable['data'] = [[s.id, unique_id_for_user(s, course_id)] for s in students] return return_csv(course_id.replace('/', '-') + '-anon-ids.csv', datatable) #---------------------------------------- diff --git a/lms/djangoapps/open_ended_grading/open_ended_notifications.py b/lms/djangoapps/open_ended_grading/open_ended_notifications.py index c4580dd30436..8f1f3c3b2605 100644 --- a/lms/djangoapps/open_ended_grading/open_ended_notifications.py +++ b/lms/djangoapps/open_ended_grading/open_ended_notifications.py @@ -33,7 +33,7 @@ def staff_grading_notifications(course, user): pending_grading = False img_path = "" course_id = course.id - student_id = unique_id_for_user(user) + student_id = unique_id_for_user(user, course_id) notification_type = "staff" success, notification_dict = get_value_from_cache(student_id, course_id, notification_type) @@ -74,7 +74,7 @@ def peer_grading_notifications(course, user): pending_grading = False img_path = "" course_id = course.id - student_id = unique_id_for_user(user) + student_id = unique_id_for_user(user, course_id) notification_type = "peer" success, notification_dict = get_value_from_cache(student_id, course_id, notification_type) @@ -132,7 +132,7 @@ def combined_notifications(course, user): ) #Initialize controller query service using our mock system controller_qs = ControllerQueryService(settings.OPEN_ENDED_GRADING_INTERFACE, system) - student_id = unique_id_for_user(user) + student_id = unique_id_for_user(user, course.id) user_is_staff = has_access(user, course, 'staff') course_id = course.id notification_type = "combined" diff --git a/lms/djangoapps/open_ended_grading/staff_grading_service.py b/lms/djangoapps/open_ended_grading/staff_grading_service.py index 1322f3f06933..9d93b0e8d074 100644 --- a/lms/djangoapps/open_ended_grading/staff_grading_service.py +++ b/lms/djangoapps/open_ended_grading/staff_grading_service.py @@ -235,7 +235,7 @@ def get_next(request, course_id): if len(missing) > 0: return _err_response('Missing required keys {0}'.format( ', '.join(missing))) - grader_id = unique_id_for_user(request.user) + grader_id = unique_id_for_user(request.user, course_id) p = request.POST location = p['location'] @@ -267,7 +267,7 @@ def get_problem_list(request, course_id): """ _check_access(request.user, course_id) try: - response = staff_grading_service().get_problem_list(course_id, unique_id_for_user(request.user)) + response = staff_grading_service().get_problem_list(course_id, unique_id_for_user(request.user, course_id)) response = json.loads(response) # If 'problem_list' is in the response, then we got a list of problems from the ORA server. @@ -352,7 +352,7 @@ def save_grade(request, course_id): return _err_response('Missing required keys {0}'.format( ', '.join(missing))) - grader_id = unique_id_for_user(request.user) + grader_id = unique_id_for_user(request.user, course_id) location = p['location'] diff --git a/lms/djangoapps/open_ended_grading/tests.py b/lms/djangoapps/open_ended_grading/tests.py index ad6298d71a9f..0dc69e605f28 100644 --- a/lms/djangoapps/open_ended_grading/tests.py +++ b/lms/djangoapps/open_ended_grading/tests.py @@ -455,7 +455,7 @@ def test_get_problem_list(self): Mock the get_grading_status_list function using StudentProblemListMockQuery. """ # Initialize a StudentProblemList object. - student_problem_list = utils.StudentProblemList(self.course.id, unique_id_for_user(self.user)) + student_problem_list = utils.StudentProblemList(self.course.id, unique_id_for_user(self.user, self.course.id)) # Get the initial problem list from ORA. success = student_problem_list.fetch_from_grading_service() # Should be successful, and we should have three problems. See mock class for details. diff --git a/lms/djangoapps/open_ended_grading/views.py b/lms/djangoapps/open_ended_grading/views.py index e8002e0883f7..2d9512ed44f3 100644 --- a/lms/djangoapps/open_ended_grading/views.py +++ b/lms/djangoapps/open_ended_grading/views.py @@ -149,7 +149,7 @@ def student_problem_list(request, course_id): course = get_course_with_access(request.user, course_id, 'load') # The anonymous student id is needed for communication with ORA. - student_id = unique_id_for_user(request.user) + student_id = unique_id_for_user(request.user, course_id) base_course_url = reverse('courses') error_text = "" @@ -186,7 +186,7 @@ def flagged_problem_list(request, course_id): Show a student problem list ''' course = get_course_with_access(request.user, course_id, 'staff') - student_id = unique_id_for_user(request.user) + student_id = unique_id_for_user(request.user, course_id) # call problem list service success = False From f947690138854915b725a59338e21e7e4977b81a Mon Sep 17 00:00:00 2001 From: Oleg Marshev Date: Thu, 14 Nov 2013 17:50:43 +0200 Subject: [PATCH 07/87] Fix post dict and add click on submit button. --- common/djangoapps/student/models.py | 9 +++-- common/lib/xmodule/xmodule/lti_module.py | 25 +++++------- lms/djangoapps/courseware/features/lti.py | 8 ++-- .../mock_lti_server/mock_lti_server.py | 17 +++----- .../mock_lti_server/server_start.py | 2 +- lms/djangoapps/courseware/tests/test_lti.py | 40 ------------------- 6 files changed, 26 insertions(+), 75 deletions(-) diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index a0cf50076630..ecc4677ff239 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -59,20 +59,21 @@ class AnonymousUsers(models.Model): def unique_id_for_user(user, course_id): """ - Return a unique id for a user, suitable for inserting into - e.g. personalized survey links. + Return a unique id for a (user, course) pair, suitable for inserting into e.g. personalized survey links. """ - def _unique_id_for_user(user): + def _unique_id_for_user(user, course_id): # include the secret key as a salt, and to make the ids unique across different LMS installs. h = hashlib.md5() h.update(settings.SECRET_KEY) h.update(str(user.id)) + h.update(course_id) return h.hexdigest() + # import ipdb; ipdb.set_trace() return AnonymousUsers.objects.get_or_create( user=user, course_id=course_id, - anonymous_user_id=_unique_id_for_user(user) + anonymous_user_id=_unique_id_for_user(user, course_id) )[0].anonymous_user_id diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 8da7969fff9e..1fb526e2ed96 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -327,7 +327,7 @@ def oauth_params(self, custom_parameters, client_key, client_secret): # Parameters required for grading: u'resource_link_id': self.get_resource_link_id(), - u'lis_outcome_service_url': '{}/replaceResult'.format(self.get_base_path() if self.is_graded else ''), + u'lis_outcome_service_url': '{}/replaceResult'.format(self.get_base_path()) if self.is_graded else '', u'lis_result_sourcedid': self.get_lis_result_sourcedid(), # u'lis_person_sourcedid': self.get_lis_person_sourcedid(), # optional, do not use for now. @@ -374,15 +374,6 @@ def oauth_params(self, custom_parameters, client_key, client_secret): params.update(body) return params - def get_score(self): - """ - TODO read score - """ - return { - 'score': 0.5, - 'total': self.get_maxscore() - } - def get_maxscore(self): return self.weight @@ -465,16 +456,20 @@ def handle_ajax(self, dispatch, data): parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') try: # get data from request root = etree.fromstring(data, parser=parser) - namespaces = {'def': root.nsmap.values()[0]} - imsx_messageIdentifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text - sourcedId = root.xpath("//def:sourcedId", namespaces=namespaces)[0].text - score = root.xpath("//def:textString", namespaces=namespaces)[0].text + if root.nsmap: + namespaces = {'def': root.nsmap.values()[0]} + imsx_messageIdentifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text + sourcedId = root.xpath("//def:sourcedId", namespaces=namespaces)[0].text + score = root.xpath("//def:textString", namespaces=namespaces)[0].text + else: + imsx_messageIdentifier = root.xpath("//imsx_messageIdentifier")[0].text + sourcedId = root.xpath("//sourcedId")[0].text + score = root.xpath("//textString")[0].text except: return response_xml_template.format(**unsupported_values), "application/xml" action = dispatch.lower() if action == 'replaceresult': - self.system.publish( event={ 'event_name': 'grade', diff --git a/lms/djangoapps/courseware/features/lti.py b/lms/djangoapps/courseware/features/lti.py index 63ee75ea64f3..1e2338ca8ef7 100644 --- a/lms/djangoapps/courseware/features/lti.py +++ b/lms/djangoapps/courseware/features/lti.py @@ -236,7 +236,6 @@ def check_lti_popup(): @step('I open gradebook$') def check_gradebook(_step): - #import ipdb; ipdb.set_trace() location = world.scenario_dict['LTI'].location.html_id() iframe_name = 'ltiLaunchFrame-' + location with world.browser.get_iframe(iframe_name) as iframe: @@ -249,13 +248,14 @@ def check_gradebook(_step): @step('I wiew result in Progress page$') def check_progress(_step): world.click_link('Progress') - assert world.browser.is_text_present('Practice Scores: 0.99/1') + assert world.browser.is_text_present('Problem Scores: 0.99/1') @step('I click on Grade link$') def check_progress(_step): location = world.scenario_dict['LTI'].location.html_id() iframe_name = 'ltiLaunchFrame-' + location with world.browser.get_iframe(iframe_name) as iframe: - world.click_link('Grade') - assert world.browser.is_text_present('I have stored grades.') + iframe.find_by_name('submit-button').first.click() + # This test waits no matter how long the text will appear. Timeouts? + assert iframe.is_text_present('I have stored grades.') diff --git a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py index a8d703966b7c..d922229b5654 100644 --- a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py +++ b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py @@ -28,10 +28,10 @@ def log_message(self, format, *args): (self.client_address[0], self.log_date_time_string(), format % args)) - + ''' def do_HEAD(self): self._send_head() - + ''' def do_GET(self): ''' Handle a GET request from the client and sends response back. @@ -90,14 +90,10 @@ def do_POST(self): status_message = "Incorrect LTI header" else: params = {k: v for k, v in self.post_dict.items() if k != 'oauth_signature'} - ''' - if self.server.check_oauth_signature(params, post_dict['oauth_signature']): + if self.server.check_oauth_signature(params, self.post_dict['oauth_signature']): status_message = "This is LTI tool. Success." else: status_message = "Wrong LTI signature" - ''' - status_message = "This is LTI tool. Success." - # set data for grades # what need to be stored as server data self.server.grade_data = { @@ -110,6 +106,7 @@ def do_POST(self): self._send_head() self._send_response(status_message) + def _send_head(self): ''' Send the response code and MIME headers @@ -153,7 +150,7 @@ def _post_dict(self): def _send_graded_result(self): values = { - 'textString': 0.9, + 'textString': 0.99, 'sourcedId': self.server.grade_data['user_id'], 'imsx_messageIdentifier': uuid4().hex, } @@ -211,7 +208,7 @@ def _send_response(self, message):

Server response is:

\

{}

- +
""".format(message, url="http://%s:%s" % self.server.server_address) @@ -228,7 +225,6 @@ def _send_response(self, message): self.wfile.write(response_str) - def _is_correct_lti_request(self): '''If url to LTI tool is correct.''' return self.server.oauth_settings['lti_endpoint'] in self.path @@ -283,6 +279,5 @@ def check_oauth_signature(self, params, client_signature): request.uri = unicode(url) request.http_method = u'POST' request.signature = unicode(client_signature) - return signature.verify_hmac_sha1(request, client_secret) diff --git a/lms/djangoapps/courseware/mock_lti_server/server_start.py b/lms/djangoapps/courseware/mock_lti_server/server_start.py index 40efea41cced..e4b02794072b 100644 --- a/lms/djangoapps/courseware/mock_lti_server/server_start.py +++ b/lms/djangoapps/courseware/mock_lti_server/server_start.py @@ -1,7 +1,7 @@ import threading from mock_lti_server import MockLTIServer server_port = 8034 -server_host = '127.0.0.1' +server_host = 'localhost' address = (server_host, server_port) server = MockLTIServer(address) server.oauth_settings = { diff --git a/lms/djangoapps/courseware/tests/test_lti.py b/lms/djangoapps/courseware/tests/test_lti.py index 735dc2c97945..353fd4776037 100644 --- a/lms/djangoapps/courseware/tests/test_lti.py +++ b/lms/djangoapps/courseware/tests/test_lti.py @@ -5,10 +5,7 @@ from collections import OrderedDict import mock -import courseware.grades as grades from xmodule.modulestore.django import modulestore -from student.tests.factories import UserFactory -from django.test.client import RequestFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory @@ -110,40 +107,3 @@ def test_lti_constructor(self): generated_context, self.runtime.render_template('lti.html', expected_context), ) - - @mock.patch('xmodule.lti_module.LTIModule.get_score') - def test_lti_grading(self, get_score): - """ - Makes sure that LTI is graded. - """ - weight = 0.15 - grading_policy = { - "GRADER": [ - { - "type": "Homework", - "min_count": 1, - "drop_count": 0, - "short_label": "HW", - "weight": weight - }, - ] - } - mocked_score = {'score': 0.7, 'total': 1} - get_score.return_value = mocked_score - - course = self.set_up_course(grading_policy=grading_policy) - request_factory = RequestFactory() - user = UserFactory.create() - request = request_factory.get("foo") - request.user = user - - grade_summary = grades.grade(user, request, course) - actual_score = grade_summary['grade_breakdown'][0]['percent'] - - self.assertEqual( - actual_score, - mocked_score['score']*weight - ) - - - From 809cc09824a8485199c1a943fae7aa729259236e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Fri, 15 Nov 2013 13:42:38 +0200 Subject: [PATCH 08/87] Updates on lookup table and functions. --- common/djangoapps/student/models.py | 3 ++- common/djangoapps/student/tests/tests.py | 25 +++++++++++-------- .../mock_lti_server/test_mock_lti_server.py | 2 +- lms/djangoapps/foldit/tests.py | 4 +-- lms/djangoapps/foldit/views.py | 8 +++--- 5 files changed, 24 insertions(+), 18 deletions(-) diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index ecc4677ff239..2ce3d0e4a6f9 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -92,9 +92,10 @@ def user_by_anonymous_id(id): return obj.user -def unique_id_for_foldit_user(user): +def simple_unique_id_for_user(user): """ Return a unique id for a user, suitable for inserting into foldit module table + or into unit tests. """ # include the secret key as a salt, and to make the ids unique across # different LMS installs. diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index 3745f28135bf..8738c417ff8c 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -28,7 +28,7 @@ from mock import Mock, patch, sentinel from textwrap import dedent -from student.models import unique_id_for_user, CourseEnrollment +from student.models import unique_id_for_user, simple_unique_id_for_user, CourseEnrollment from student.views import (process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper, change_enrollment, complete_course_mode_info) from student.tests.factories import UserFactory, CourseModeFactory @@ -137,24 +137,29 @@ def test_reset_password_good_token(self, reset_confirm): class CourseEndingTest(TestCase): """Test things related to course endings: certificates, surveys, etc""" - def test_process_survey_link(self): + @patch('student.views.unique_id_for_user') + def test_process_survey_link(self, mock_unique_id_for_user): course = Mock(id="test_id") + user = Mock(username="fred", id="test_user_id") + mock_unique_id_for_user.return_value = simple_unique_id_for_user(user) - username = "fred" - user = Mock(username=username) - id = unique_id_for_user(user) link1 = "http://www.mysurvey.com" self.assertEqual(process_survey_link(link1, user, course.id), link1) link2 = "http://www.mysurvey.com?unique={UNIQUE_ID}" - link2_expected = "http://www.mysurvey.com?unique={UNIQUE_ID}".format(UNIQUE_ID=id) + link2_expected = "http://www.mysurvey.com?unique={UNIQUE_ID}".format(UNIQUE_ID=simple_unique_id_for_user(user)) self.assertEqual(process_survey_link(link2, user, course.id), link2_expected) - def test_cert_info(self): - user = Mock(username="fred") + # patching student.views.unique_id_for_user, not student.models.unique_id_for_user, + # look at http://www.voidspace.org.uk/python/mock/patch.html#where-to-patch for explanation. + @patch('student.views.unique_id_for_user') + def test_cert_info(self, mock_unique_id_for_user): + user = Mock(username="fred", id="test_user_id") survey_url = "http://a_survey.com" - course = Mock(end_of_course_survey_url=survey_url) + mock_unique_id_for_user.return_value = simple_unique_id_for_user(user) + + course = Mock(end_of_course_survey_url=survey_url, id="test_id") self.assertEqual(_cert_info(user, course, None), {'status': 'processing', @@ -214,7 +219,7 @@ def test_cert_info(self): }) # Test a course that doesn't have a survey specified - course2 = Mock(end_of_course_survey_url=None) + course2 = Mock(end_of_course_survey_url=None, id="test_course_id2") cert_status = {'status': 'notpassing', 'grade': '67', 'download_url': download_url} self.assertEqual(_cert_info(user, course2, cert_status), diff --git a/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py b/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py index 0ca2243060d5..2ffbfacf5236 100644 --- a/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py +++ b/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py @@ -74,7 +74,7 @@ def test_graded_request(self): the callback URL provided. """ server_port = 8000 - server_host = '127.0.0.1' + server_host = 'localhost' callback_url = 'http://{}:{}/grade_lti'.format(server_host, server_port) request = { diff --git a/lms/djangoapps/foldit/tests.py b/lms/djangoapps/foldit/tests.py index 7a6275638351..2708c5c073e3 100644 --- a/lms/djangoapps/foldit/tests.py +++ b/lms/djangoapps/foldit/tests.py @@ -8,7 +8,7 @@ from foldit.views import foldit_ops, verify_code from foldit.models import PuzzleComplete, Score -from student.models import unique_id_for_foldit_user +from student.models import simple_unique_id_for_user from student.tests.factories import CourseEnrollmentFactory, UserFactory, UserProfileFactory from datetime import datetime, timedelta @@ -346,7 +346,7 @@ def test_SetPlayerPuzzlesComplete_level_complete(self): self.set_puzzle_complete_response([13, 14, 15, 53524])) is_complete = partial( - PuzzleComplete.is_level_complete, unique_id_for_foldit_user(self.user)) + PuzzleComplete.is_level_complete, simple_unique_id_for_user(self.user)) self.assertTrue(is_complete(1, 1)) self.assertTrue(is_complete(1, 3)) diff --git a/lms/djangoapps/foldit/views.py b/lms/djangoapps/foldit/views.py index 9517c24dd130..65d7c6fd64da 100644 --- a/lms/djangoapps/foldit/views.py +++ b/lms/djangoapps/foldit/views.py @@ -8,7 +8,7 @@ from django.views.decorators.csrf import csrf_exempt from foldit.models import Score, PuzzleComplete -from student.models import unique_id_for_foldit_user +from student.models import simple_unique_id_for_user import re @@ -124,7 +124,7 @@ def save_scores(user, puzzle_scores): try: obj = Score.objects.get( user=user, - unique_user_id=unique_id_for_foldit_user(user), + unique_user_id=simple_unique_id_for_user(user), puzzle_id=puzzle_id, score_version=score_version) obj.current_score = current_score @@ -133,7 +133,7 @@ def save_scores(user, puzzle_scores): except Score.DoesNotExist: obj = Score( user=user, - unique_user_id=unique_id_for_foldit_user(user), + unique_user_id=simple_unique_id_for_user(user), puzzle_id=puzzle_id, current_score=current_score, best_score=best_score, @@ -160,7 +160,7 @@ def save_complete(user, puzzles_complete): # create if not there PuzzleComplete.objects.get_or_create( user=user, - unique_user_id=unique_id_for_user(user), + unique_user_id=simple_unique_id_for_user(user), puzzle_id=puzzle_id, puzzle_set=puzzle_set, puzzle_subset=puzzle_subset) From 35393bc1c8647780877065b6d7fd5a430e57331c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Fri, 15 Nov 2013 17:28:03 +0200 Subject: [PATCH 09/87] Add custom handler. --- common/lib/xmodule/xmodule/lti_module.py | 90 +++++++++++++++++++--- common/lib/xmodule/xmodule/x_module.py | 7 +- lms/djangoapps/courseware/module_render.py | 20 +++-- 3 files changed, 99 insertions(+), 18 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 1fb526e2ed96..d1e373dbf891 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -8,22 +8,22 @@ http://www.imsglobal.org/developers/LTI/test/v1p1/lms.php """ -from uuid import uuid4 - import logging import oauthlib.oauth1 import urllib import textwrap from lxml import etree +from webob import Response from xmodule.editing_module import MetadataOnlyEditingDescriptor from xmodule.raw_module import EmptyDataRawDescriptor -from xmodule.x_module import XModule +from xmodule.x_module import XModule, module_attr from xmodule.course_module import CourseDescriptor from pkg_resources import resource_string from xblock.core import String, Scope, List from xblock.fields import Boolean, Float + log = logging.getLogger(__name__) unicode = unicode # to disable pylint unicode warnings @@ -251,7 +251,7 @@ def get_base_path(self): if self.TEST_BASE_PATH: return 'http://{host}{path}'.format( host=self.TEST_BASE_PATH, - path=self.system.ajax_url, + path=self.system.ajax_url(third_party=True), ) else: # return self.system.hostname + self.system.ajax_url @@ -377,7 +377,7 @@ def oauth_params(self, custom_parameters, client_key, client_secret): def get_maxscore(self): return self.weight - def handle_ajax(self, dispatch, data): + def custom_handler(self, request, dispatch): """ This is called by courseware.module_render, to handle an AJAX call. @@ -451,8 +451,7 @@ def handle_ajax(self, dispatch, data): \""") """ - data = data.keys()[0] + '=' + data.values()[0] - data = data.strip().encode('utf-8') + data = request.body.strip().encode('utf-8') parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') try: # get data from request root = etree.fromstring(data, parser=parser) @@ -466,8 +465,9 @@ def handle_ajax(self, dispatch, data): sourcedId = root.xpath("//sourcedId")[0].text score = root.xpath("//textString")[0].text except: - return response_xml_template.format(**unsupported_values), "application/xml" + return Response(response_xml_template.format(**unsupported_values), content_type="application/xml") + real_user = self.system.get_real_user(sourcedId.split('::')[-1]) action = dispatch.lower() if action == 'replaceresult': self.system.publish( @@ -476,7 +476,7 @@ def handle_ajax(self, dispatch, data): 'value': score, 'max_value': self.get_maxscore(), }, - custom_user=self.system.get_real_user(sourcedId.split('::')[-1]) + custom_user=real_user ) values = { @@ -485,11 +485,11 @@ def handle_ajax(self, dispatch, data): 'imsx_messageIdentifier': imsx_messageIdentifier, 'response': '' } - return response_xml_template.format(**values), "application/xml" + return Response(response_xml_template.format(**values), content_type="application/xml") # return "unsupported" response unsupported_values['imsx_messageIdentifier'] = imsx_messageIdentifier - return response_xml_template.format(**unsupported_values), "application/xml" + return Response(response_xml_template.format(**unsupported_values), content_type='application/xml') class LTIModuleDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRawDescriptor): @@ -500,3 +500,71 @@ class LTIModuleDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRaw graded = True module_class = LTIModule + + custom_handler = module_attr('custom_handler') + + def authenticate(self, request, course): + # self course_id produces runtime assertion error + + # Obtains client_key and client_secret credentials from current course: + client_key = client_secret = '' + + for lti_passport in course.lti_passports: + try: + lti_id, key, secret = [i.strip() for i in lti_passport.split(':')] + except ValueError: + raise LTIError('Could not parse LTI passport: {0!r}. \ + Should be "id:key:secret" string.'.format(lti_passport)) + if lti_id == self.lti_id.strip(): + client_key, client_secret = key, secret + break + + # what should come from LTI provider: + """ + data_example = textwrap.dedent(\""" + + + + + V1.0 + 528243ba5241b + + + + + + + feb-123-456-2929::28883::5afe5d9bb03796557ee2614f5c9611fb + + + + en-us + 0.4 + + + + + + + \""") + """ + data = request.body.strip().encode('utf-8') + parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') + try: # get data from request + root = etree.fromstring(data, parser=parser) + if root.nsmap: + namespaces = {'def': root.nsmap.values()[0]} + imsx_messageIdentifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text + sourcedId = root.xpath("//def:sourcedId", namespaces=namespaces)[0].text + score = root.xpath("//def:textString", namespaces=namespaces)[0].text + else: + imsx_messageIdentifier = root.xpath("//imsx_messageIdentifier")[0].text + sourcedId = root.xpath("//sourcedId")[0].text + score = root.xpath("//textString")[0].text + except: + # return response_xml_template.format(**unsupported_values), "application/xml" + return None, False + + anonymous_user_id = sourcedId.split('::')[-1] + return anonymous_user_id, True + diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 9c65683418fe..ae3c44e173be 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -1064,11 +1064,14 @@ def __str__(self): return str(self.__dict__) @property - def ajax_url(self): + def ajax_url(self, third_party=False): """ The url prefix to be used by XModules to call into handle_ajax """ - return self.handler_url(self.xmodule_instance, 'xmodule_handler', '', '').rstrip('/?') + handler_name = 'xmodule_handler' + if third_party: + handler_name = 'custom_handler' + return self.handler_url(self.xmodule_instance, handler_name, '', '').rstrip('/?') class DoNothingCache(object): diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 4fc356d4f86d..4e9f37878c20 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -509,8 +509,9 @@ def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None): if not Location.is_valid(location): raise Http404("Invalid location") - if not request.user.is_authenticated(): - raise PermissionDenied + if handler != 'custom_handler': + if not request.user.is_authenticated(): + raise PermissionDenied # Check submitted files files = request.FILES or {} @@ -528,14 +529,23 @@ def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None): ) ) raise Http404 + # import ipdb; ipdb.set_trace() + custom_user = None + if handler == 'custom_handler': + from courseware.courses import get_course_by_id + course = get_course_by_id(course_id) + anonymous_user_id, status = descriptor.authenticate(request, course) + if not status: + raise PermissionDenied + from student.models import user_by_anonymous_id + custom_user = user_by_anonymous_id(anonymous_user_id) field_data_cache = FieldDataCache.cache_for_descriptor_descendents( course_id, - request.user, + custom_user or request.user, descriptor ) - - instance = get_module(request.user, request, location, field_data_cache, course_id, grade_bucket_type='ajax') + instance = get_module(custom_user or request.user, request, location, field_data_cache, course_id, grade_bucket_type='ajax') if instance is None: # Either permissions just changed, or someone is trying to be clever # and load something they shouldn't have access to. From 271cf513a13939a95693f0494813af0e4b4fc8ba Mon Sep 17 00:00:00 2001 From: Oleg Marshev Date: Mon, 18 Nov 2013 11:47:22 +0200 Subject: [PATCH 10/87] Add oauth sign. --- .../mock_lti_server/mock_lti_server.py | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py index d922229b5654..73d2f663c13a 100644 --- a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py +++ b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py @@ -3,6 +3,8 @@ import textwrap import urlparse from oauthlib.oauth1.rfc5849 import signature +import oauthlib.oauth1 +import hashlib import mock import sys import requests @@ -28,10 +30,7 @@ def log_message(self, format, *args): (self.client_address[0], self.log_date_time_string(), format % args)) - ''' - def do_HEAD(self): - self._send_head() - ''' + def do_GET(self): ''' Handle a GET request from the client and sends response back. @@ -187,11 +186,12 @@ def _send_graded_result(self): cookies = self.server.cookie headers = {'Content-Type': 'application/xml', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRFToken': 'update_me'} headers['X-CSRFToken'] = cookies.get('csrftoken') + signed_headers = self.oauth_sign(url, data) response = requests.post( url, data=data, cookies={k: v for k, v in cookies.items() if k in ['csrftoken', 'sessionid']}, - headers=headers + headers=signed_headers ) assert response.status_code == 200 return response @@ -229,6 +229,41 @@ def _is_correct_lti_request(self): '''If url to LTI tool is correct.''' return self.server.oauth_settings['lti_endpoint'] in self.path + def oauth_sign(self, url, body): + """ + Signs request and returns signed body and headers. + + """ + + client = oauthlib.oauth1.Client( + client_key=unicode(self.server.oauth_settings['client_key']), + client_secret=unicode(self.server.oauth_settings['client_secret']) + ) + headers = { + # This is needed for body encoding: + 'Content-Type': 'application/xml', + } + + try: + __, headers, __ = client.sign( + unicode(url.strip()), + http_method=u'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: + headers = { + u'Content-Type': u'application/x-www-form-urlencoded', + u'Authorization': u'OAuth oauth_nonce="80966668944732164491378916897", \ +oauth_timestamp="1378916897", oauth_version="1.0", oauth_signature_method="HMAC-SHA1", \ +oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'} + # Add oauth_body_hash to headers + sha1 = hashlib.sha1() + sha1.update(body) + headers['Authorization'] = headers['Authorization'] + ', oauth_body_hash="{}"'.format(sha1.hexdigest()) + return headers class MockLTIServer(HTTPServer): ''' From 1b6279db763b3369c9bbb6d3c7367265b0208a47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Mon, 18 Nov 2013 12:18:20 +0200 Subject: [PATCH 11/87] Updated mock_lti_server and removed odd unicode decalarion. --- common/lib/xmodule/xmodule/lti_module.py | 2 -- .../mock_lti_server/mock_lti_server.py | 35 ++++++++++--------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index d1e373dbf891..7f7e293ec39a 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -26,8 +26,6 @@ log = logging.getLogger(__name__) -unicode = unicode # to disable pylint unicode warnings - class LTIError(Exception): pass diff --git a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py index 73d2f663c13a..a22d5e2cbcb4 100644 --- a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py +++ b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py @@ -244,26 +244,27 @@ def oauth_sign(self, url, body): 'Content-Type': 'application/xml', } - try: - __, headers, __ = client.sign( - unicode(url.strip()), - http_method=u'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: - headers = { - u'Content-Type': u'application/x-www-form-urlencoded', - u'Authorization': u'OAuth oauth_nonce="80966668944732164491378916897", \ -oauth_timestamp="1378916897", oauth_version="1.0", oauth_signature_method="HMAC-SHA1", \ -oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'} # Add oauth_body_hash to headers sha1 = hashlib.sha1() sha1.update(body) - headers['Authorization'] = headers['Authorization'] + ', oauth_body_hash="{}"'.format(sha1.hexdigest()) - return headers + + __, headers, __ = client.sign( + unicode(url.strip()), + http_method=u'POST', + body={'oauth_body_hash': sha1.hexdigest()}, + headers=headers + ) + #headers example + """ + headers = { + u'Content-Type': u'application/x-www-form-urlencoded', + u'Authorization': u'OAuth oauth_nonce="80966668944732164491378916897", \ +oauth_timestamp="1378916897", oauth_version="1.0", oauth_signature_method="HMAC-SHA1", \ +oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'} + """ + + headers['Authorization'] = headers['Authorization'] + ', oauth_body_hash="{}"'.format() + return headers class MockLTIServer(HTTPServer): ''' From 08d7e9a07c400eb483ff19ba5a9736f8c231cfac Mon Sep 17 00:00:00 2001 From: Oleg Marshev Date: Mon, 18 Nov 2013 16:09:41 +0200 Subject: [PATCH 12/87] Fix oauth body hash. --- .../mock_lti_server/mock_lti_server.py | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py index a22d5e2cbcb4..2fbc7cff8610 100644 --- a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py +++ b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py @@ -5,6 +5,7 @@ from oauthlib.oauth1.rfc5849 import signature import oauthlib.oauth1 import hashlib +import base64 import mock import sys import requests @@ -149,7 +150,7 @@ def _post_dict(self): def _send_graded_result(self): values = { - 'textString': 0.99, + 'textString': 0.1, 'sourcedId': self.server.grade_data['user_id'], 'imsx_messageIdentifier': uuid4().hex, } @@ -186,12 +187,13 @@ def _send_graded_result(self): cookies = self.server.cookie headers = {'Content-Type': 'application/xml', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRFToken': 'update_me'} headers['X-CSRFToken'] = cookies.get('csrftoken') - signed_headers = self.oauth_sign(url, data) + headers['Authorization'] = self.oauth_sign(url, data) + response = requests.post( url, data=data, cookies={k: v for k, v in cookies.items() if k in ['csrftoken', 'sessionid']}, - headers=signed_headers + headers=headers ) assert response.status_code == 200 return response @@ -241,30 +243,22 @@ def oauth_sign(self, url, body): ) headers = { # This is needed for body encoding: - 'Content-Type': 'application/xml', + 'Content-Type': 'application/x-www-form-urlencoded', } - # Add oauth_body_hash to headers + #Calculate and encode body hash. See http://oauth.googlecode.com/svn/spec/ext/body_hash/1.0/oauth-bodyhash.html sha1 = hashlib.sha1() sha1.update(body) + oauth_body_hash = base64.b64encode(sha1.hexdigest()) __, headers, __ = client.sign( unicode(url.strip()), http_method=u'POST', - body={'oauth_body_hash': sha1.hexdigest()}, + body={u'oauth_body_hash': oauth_body_hash}, headers=headers ) - #headers example - """ - headers = { - u'Content-Type': u'application/x-www-form-urlencoded', - u'Authorization': u'OAuth oauth_nonce="80966668944732164491378916897", \ -oauth_timestamp="1378916897", oauth_version="1.0", oauth_signature_method="HMAC-SHA1", \ -oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'} - """ - - headers['Authorization'] = headers['Authorization'] + ', oauth_body_hash="{}"'.format() - return headers + headers = headers['Authorization'] + ', oauth_body_hash="{}"'.format(oauth_body_hash) + return headers class MockLTIServer(HTTPServer): ''' From 0c2da2f0f8b3aecc87c5dbb579f48728002c6456 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Mon, 18 Nov 2013 18:56:32 +0200 Subject: [PATCH 13/87] Add get_handler_url and update mocked server. --- common/lib/xmodule/xmodule/lti_module.py | 19 +++++++-------- common/lib/xmodule/xmodule/x_module.py | 11 +++++---- .../courseware/features/lti_setup.py | 1 + .../mock_lti_server/mock_lti_server.py | 23 ++++++++++++------- .../mock_lti_server/server_start.py | 16 ++++++------- 5 files changed, 39 insertions(+), 31 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 7f7e293ec39a..eee5b7566ca8 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -144,8 +144,6 @@ class LTIModule(LTIFields, XModule): css = {'scss': [resource_string(__name__, 'css/lti/lti.scss')]} js_module_name = "LTI" - TEST_BASE_PATH = None - def get_html(self): """ Renders parameters to template. @@ -245,15 +243,14 @@ def get_user_id(self): assert user_id is not None return user_id - def get_base_path(self): - if self.TEST_BASE_PATH: - return 'http://{host}{path}'.format( - host=self.TEST_BASE_PATH, - path=self.system.ajax_url(third_party=True), + def get_base_url(self): + """ + Return url for storing grades + """ + return 'http://{host}{path}'.format( + host=self.system.hostname, + path=self.system.get_handler_url('custom_handler'), ) - else: - # return self.system.hostname + self.system.ajax_url - return self.system.ajax_url def get_context_id(self): # This is an opaque identifier that uniquely identifies the context that contains @@ -325,7 +322,7 @@ def oauth_params(self, custom_parameters, client_key, client_secret): # Parameters required for grading: u'resource_link_id': self.get_resource_link_id(), - u'lis_outcome_service_url': '{}/replaceResult'.format(self.get_base_path()) if self.is_graded else '', + u'lis_outcome_service_url': '{}/replaceResult'.format(self.get_base_url()) if self.is_graded else '', u'lis_result_sourcedid': self.get_lis_result_sourcedid(), # u'lis_person_sourcedid': self.get_lis_person_sourcedid(), # optional, do not use for now. diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index ae3c44e173be..52decde05dc7 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -1064,13 +1064,16 @@ def __str__(self): return str(self.__dict__) @property - def ajax_url(self, third_party=False): + def ajax_url(self): """ The url prefix to be used by XModules to call into handle_ajax """ - handler_name = 'xmodule_handler' - if third_party: - handler_name = 'custom_handler' + return self.handler_url(self.xmodule_instance, 'xmodule_handler', '', '').rstrip('/?') + + def get_handler_url(self, handler_name): + """ + The url prefix to be used by XModules to call into handler_name + """ return self.handler_url(self.xmodule_instance, handler_name, '', '').rstrip('/?') diff --git a/lms/djangoapps/courseware/features/lti_setup.py b/lms/djangoapps/courseware/features/lti_setup.py index 0a6c4590dd9e..e86aa78ed2e1 100644 --- a/lms/djangoapps/courseware/features/lti_setup.py +++ b/lms/djangoapps/courseware/features/lti_setup.py @@ -30,6 +30,7 @@ def setup_mock_lti_server(): server_thread.daemon = True server_thread.start() + server.server_host = server_host server.oauth_settings = { 'client_key': 'test_client_key', 'client_secret': 'test_client_secret', diff --git a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py index 2fbc7cff8610..a1cdde5fd0b6 100644 --- a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py +++ b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py @@ -106,7 +106,6 @@ def do_POST(self): self._send_head() self._send_response(status_message) - def _send_head(self): ''' Send the response code and MIME headers @@ -140,11 +139,12 @@ def _post_dict(self): return {} try: cookie = self.headers.getheader('cookie') - self.server.cookie = {k.strip():v[0] for k,v in urlparse.parse_qs(cookie).items()} + self.server.cookie = {k.strip(): v[0] for k, v in urlparse.parse_qs(cookie).items()} except: self.server.cookie = {} referer = urlparse.urlparse(self.headers.getheader('referer')) self.server.referer_host = "{}://{}".format(referer.scheme, referer.netloc) + self.server.referer_netloc = referer.netloc return post_dict def _send_graded_result(self): @@ -183,19 +183,26 @@ def _send_graded_result(self): """) data = payload.format(**values) # temporarily changed to get for easy view in browser - url = self.server.referer_host + self.server.grade_data['callback_url'] - cookies = self.server.cookie - headers = {'Content-Type': 'application/xml', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRFToken': 'update_me'} - headers['X-CSRFToken'] = cookies.get('csrftoken') + # get relative part, because host name is different in a) manual tests b) acceptance tests c) demos + relative_url = urlparse.urlparse(self.server.grade_data['callback_url']).path + url = self.server.referer_host + relative_url + + cookies_to_send = {} + headers = {'Content-Type': 'application/xml', 'X-Requested-With': 'XMLHttpRequest'} + if self.server.server_host in self.server.referer_netloc: # request from localhost to localhost: + cookies = self.server.cookie + headers['X-CSRFToken'] = cookies.get('csrftoken') + cookies_to_send = {k: v for k, v in cookies.items() if k in ['csrftoken', 'sessionid']} + headers['Authorization'] = self.oauth_sign(url, data) response = requests.post( url, data=data, - cookies={k: v for k, v in cookies.items() if k in ['csrftoken', 'sessionid']}, + cookies=cookies_to_send, headers=headers ) - assert response.status_code == 200 + # assert response.status_code == 200 return response def _send_response(self, message): diff --git a/lms/djangoapps/courseware/mock_lti_server/server_start.py b/lms/djangoapps/courseware/mock_lti_server/server_start.py index e4b02794072b..9f13e7982dbe 100644 --- a/lms/djangoapps/courseware/mock_lti_server/server_start.py +++ b/lms/djangoapps/courseware/mock_lti_server/server_start.py @@ -1,25 +1,25 @@ +""" +Mock LTI server for manual testing. +""" + import threading from mock_lti_server import MockLTIServer + server_port = 8034 server_host = 'localhost' address = (server_host, server_port) + server = MockLTIServer(address) server.oauth_settings = { 'client_key': 'test_client_key', 'client_secret': 'test_client_secret', 'lti_base': 'http://{}:{}/'.format(server_host, server_port), 'lti_endpoint': 'correct_lti_endpoint' - } +} +server.server_host = server_host try: server.serve_forever() except KeyboardInterrupt: print('^C received, shutting down server') server.socket.close() - -''' -#Start the server in a separate daemon thread -server_thread = threading.Thread(target=server.serve_forever) -server_thread.daemon = True -server_thread.start() -''' \ No newline at end of file From 44874a7c2f1c3795c607564ede840677bcc2db60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Tue, 19 Nov 2013 13:05:34 +0200 Subject: [PATCH 14/87] Avoid csrf for OAuth signed request from LTI. --- common/djangoapps/track/middleware.py | 1 + .../mock_lti_server/mock_lti_server.py | 9 ++----- lms/djangoapps/courseware/module_render.py | 27 ++++++++++++------- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/common/djangoapps/track/middleware.py b/common/djangoapps/track/middleware.py index 54934c5f36a6..dff847e3318f 100644 --- a/common/djangoapps/track/middleware.py +++ b/common/djangoapps/track/middleware.py @@ -22,6 +22,7 @@ class TrackMiddleware(object): def process_request(self, request): try: + self.enter_request_context(request) if not self.should_process_request(request): diff --git a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py index a1cdde5fd0b6..477bd7773ca3 100644 --- a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py +++ b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py @@ -187,19 +187,13 @@ def _send_graded_result(self): relative_url = urlparse.urlparse(self.server.grade_data['callback_url']).path url = self.server.referer_host + relative_url - cookies_to_send = {} headers = {'Content-Type': 'application/xml', 'X-Requested-With': 'XMLHttpRequest'} - if self.server.server_host in self.server.referer_netloc: # request from localhost to localhost: - cookies = self.server.cookie - headers['X-CSRFToken'] = cookies.get('csrftoken') - cookies_to_send = {k: v for k, v in cookies.items() if k in ['csrftoken', 'sessionid']} headers['Authorization'] = self.oauth_sign(url, data) response = requests.post( url, data=data, - cookies=cookies_to_send, headers=headers ) # assert response.status_code == 200 @@ -263,10 +257,11 @@ def oauth_sign(self, url, body): http_method=u'POST', body={u'oauth_body_hash': oauth_body_hash}, headers=headers - ) + ) headers = headers['Authorization'] + ', oauth_body_hash="{}"'.format(oauth_body_hash) return headers + class MockLTIServer(HTTPServer): ''' A mock LTI provider server that responds diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 4e9f37878c20..a646647ebcc3 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -14,7 +14,7 @@ from django.core.urlresolvers import reverse from django.http import Http404 from django.http import HttpResponse -from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.csrf import csrf_exempt, csrf_protect from capa.xqueue_interface import XQueueInterface from courseware.access import has_access @@ -488,6 +488,7 @@ def xqueue_callback(request, course_id, userid, mod_id, dispatch): return HttpResponse("") +@csrf_exempt def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None): """ Generic view for extensions. This is where AJAX calls go. @@ -503,16 +504,22 @@ def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None): not accessible by the user, or the module raises NotFoundError. If the module raises any other error, it will escape this function. """ + + @csrf_protect + def get_protected_user(request): + return request.user + + if handler != 'custom_handler': + user = get_protected_user(request) + if not user.is_authenticated(): + raise PermissionDenied + location = unquote_slashes(usage_id) # Check parameters and fail fast if there's a problem if not Location.is_valid(location): raise Http404("Invalid location") - if handler != 'custom_handler': - if not request.user.is_authenticated(): - raise PermissionDenied - # Check submitted files files = request.FILES or {} error_msg = _check_files_limits(files) @@ -530,7 +537,7 @@ def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None): ) raise Http404 # import ipdb; ipdb.set_trace() - custom_user = None + if handler == 'custom_handler': from courseware.courses import get_course_by_id course = get_course_by_id(course_id) @@ -538,18 +545,18 @@ def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None): if not status: raise PermissionDenied from student.models import user_by_anonymous_id - custom_user = user_by_anonymous_id(anonymous_user_id) + user = user_by_anonymous_id(anonymous_user_id) field_data_cache = FieldDataCache.cache_for_descriptor_descendents( course_id, - custom_user or request.user, + user, descriptor ) - instance = get_module(custom_user or request.user, request, location, field_data_cache, course_id, grade_bucket_type='ajax') + instance = get_module(user, request, location, field_data_cache, course_id, grade_bucket_type='ajax') if instance is None: # Either permissions just changed, or someone is trying to be clever # and load something they shouldn't have access to. - log.debug("No module %s for user %s -- access denied?", location, request.user) + log.debug("No module %s for user %s -- access denied?", location, user) raise Http404 req = django_to_webob_request(request) From e02cb2651ffdaa26fad684a49579a6193365cfd1 Mon Sep 17 00:00:00 2001 From: Oleg Marshev Date: Tue, 19 Nov 2013 18:03:19 +0200 Subject: [PATCH 15/87] OAuth for lti. Working. --- common/lib/xmodule/xmodule/lti_module.py | 43 ++++++++++++++++++- .../mock_lti_server/mock_lti_server.py | 2 +- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index eee5b7566ca8..708827761c9e 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -10,10 +10,15 @@ import logging import oauthlib.oauth1 +from oauthlib.oauth1.rfc5849 import signature +import hashlib +import base64 import urllib import textwrap from lxml import etree from webob import Response +import mock +import re from xmodule.editing_module import MetadataOnlyEditingDescriptor from xmodule.raw_module import EmptyDataRawDescriptor @@ -252,6 +257,17 @@ def get_base_url(self): path=self.system.get_handler_url('custom_handler'), ) + def get_outcome_service_url(self): + """ + Return url for storing grades + """ + uri = 'http://{host}{path}'.format( + host=self.system.hostname, + path=self.system.get_handler_url('custom_handler'), + ) + return '{}/replaceResult'.format(uri) if self.is_graded else '' + + def get_context_id(self): # This is an opaque identifier that uniquely identifies the context that contains # the link being launched. This parameter is recommended. @@ -322,7 +338,7 @@ def oauth_params(self, custom_parameters, client_key, client_secret): # Parameters required for grading: u'resource_link_id': self.get_resource_link_id(), - u'lis_outcome_service_url': '{}/replaceResult'.format(self.get_base_url()) if self.is_graded else '', + u'lis_outcome_service_url': self.get_outcome_service_url(), u'lis_result_sourcedid': self.get_lis_result_sourcedid(), # u'lis_person_sourcedid': self.get_lis_person_sourcedid(), # optional, do not use for now. @@ -543,6 +559,31 @@ def authenticate(self, request, course): \""") """ + import ipdb; ipdb.set_trace() + #verify oauth signing + mock_request = mock.Mock() + headers = { + 'Authorization':unicode(request.META['HTTP_AUTHORIZATION']), + 'Content-Type': 'application/x-www-form-urlencoded', + } + + sha1 = hashlib.sha1() + sha1.update(request.body) + oauth_body_hash = base64.b64encode(sha1.hexdigest()) + + mock_request.params = signature.collect_parameters( + body = {u'oauth_body_hash': unicode(oauth_body_hash)}, + headers=headers + ) + + mock_request.uri = request.META['HTTP_HOST'] + request.META['PATH_INFO'] + mock_request.http_method = unicode(request.META['REQUEST_METHOD']) + + client_signature = re.search(r'(?<=oauth_signature=")[^"]*', request.META['HTTP_AUTHORIZATION']).group(0) + mock_request.signature = unicode(client_signature) + result = signature.verify_hmac_sha1(mock_request, client_secret) + + data = request.body.strip().encode('utf-8') parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') try: # get data from request diff --git a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py index 477bd7773ca3..c21130e7f691 100644 --- a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py +++ b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py @@ -150,7 +150,7 @@ def _post_dict(self): def _send_graded_result(self): values = { - 'textString': 0.1, + 'textString': 0.5, 'sourcedId': self.server.grade_data['user_id'], 'imsx_messageIdentifier': uuid4().hex, } From 694a8d36ea5ee997f9d8c0563ab3c156453f392d Mon Sep 17 00:00:00 2001 From: Oleg Marshev Date: Wed, 20 Nov 2013 12:47:49 +0200 Subject: [PATCH 16/87] Add body hash compare. Still working on. --- common/lib/xmodule/xmodule/lti_module.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 708827761c9e..004d68fc79da 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -567,14 +567,22 @@ def authenticate(self, request, course): 'Content-Type': 'application/x-www-form-urlencoded', } + #get request body and calculate body hash sha1 = hashlib.sha1() sha1.update(request.body) oauth_body_hash = base64.b64encode(sha1.hexdigest()) + + mock_request.params = signature.collect_parameters( - body = {u'oauth_body_hash': unicode(oauth_body_hash)}, + #body = {u'oauth_body_hash': unicode(oauth_body_hash)}, headers=headers ) + #compare hash from request body and body hash from Authorization header + if oauth_body_hash == mock_request.params[0][1]: + #bodies are identical + else: + #bodies are different mock_request.uri = request.META['HTTP_HOST'] + request.META['PATH_INFO'] mock_request.http_method = unicode(request.META['REQUEST_METHOD']) From 1a2cb55a52a6c63f1a37d713e7ea4ffea46e2731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Wed, 20 Nov 2013 13:06:47 +0200 Subject: [PATCH 17/87] Fix. --- common/lib/xmodule/xmodule/lti_module.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 004d68fc79da..80407ad8b391 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -577,12 +577,13 @@ def authenticate(self, request, course): mock_request.params = signature.collect_parameters( #body = {u'oauth_body_hash': unicode(oauth_body_hash)}, headers=headers - ) + ) + + oauth_headers = {i[0]:i[1] for i in mock_request.params} + #compare hash from request body and body hash from Authorization header - if oauth_body_hash == mock_request.params[0][1]: - #bodies are identical - else: - #bodies are different + if oauth_body_hash != oauth_headers.get('oauth_body_hash') + return None, False mock_request.uri = request.META['HTTP_HOST'] + request.META['PATH_INFO'] mock_request.http_method = unicode(request.META['REQUEST_METHOD']) From 2f09f219d55b088ef1931b4a247c2f007b155c38 Mon Sep 17 00:00:00 2001 From: Oleg Marshev Date: Wed, 20 Nov 2013 14:03:27 +0200 Subject: [PATCH 18/87] Fix. --- common/lib/xmodule/xmodule/lti_module.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 80407ad8b391..68155ca31155 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -562,8 +562,14 @@ def authenticate(self, request, course): import ipdb; ipdb.set_trace() #verify oauth signing mock_request = mock.Mock() + + try: + authorization_header = request.META['HTTP_AUTHORIZATION'] + except KeyError: + return None, False + headers = { - 'Authorization':unicode(request.META['HTTP_AUTHORIZATION']), + 'Authorization':unicode(authorization_header), 'Content-Type': 'application/x-www-form-urlencoded', } @@ -572,8 +578,6 @@ def authenticate(self, request, course): sha1.update(request.body) oauth_body_hash = base64.b64encode(sha1.hexdigest()) - - mock_request.params = signature.collect_parameters( #body = {u'oauth_body_hash': unicode(oauth_body_hash)}, headers=headers @@ -582,13 +586,16 @@ def authenticate(self, request, course): oauth_headers = {i[0]:i[1] for i in mock_request.params} #compare hash from request body and body hash from Authorization header - if oauth_body_hash != oauth_headers.get('oauth_body_hash') + if oauth_body_hash != oauth_headers.get('oauth_body_hash'): return None, False mock_request.uri = request.META['HTTP_HOST'] + request.META['PATH_INFO'] mock_request.http_method = unicode(request.META['REQUEST_METHOD']) - client_signature = re.search(r'(?<=oauth_signature=")[^"]*', request.META['HTTP_AUTHORIZATION']).group(0) + oauth_params = signature.collect_parameters(headers=headers, exclude_oauth_signature=False) + oauth_headers = {i[0]:i[1] for i in oauth_params} + client_signature = oauth_headers.get('oauth_signature') + mock_request.signature = unicode(client_signature) result = signature.verify_hmac_sha1(mock_request, client_secret) From 1fdb1f7a70fbded36b8e0ccf69526bf938730b04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Wed, 20 Nov 2013 14:52:38 +0200 Subject: [PATCH 19/87] Implement oauth signing. --- common/lib/xmodule/xmodule/lti_module.py | 54 +++++++++---------- .../mock_lti_server/mock_lti_server.py | 1 - 2 files changed, 26 insertions(+), 29 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 68155ca31155..e751dcb3d14c 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -559,7 +559,7 @@ def authenticate(self, request, course): \""") """ - import ipdb; ipdb.set_trace() + #verify oauth signing mock_request = mock.Mock() @@ -578,10 +578,7 @@ def authenticate(self, request, course): sha1.update(request.body) oauth_body_hash = base64.b64encode(sha1.hexdigest()) - mock_request.params = signature.collect_parameters( - #body = {u'oauth_body_hash': unicode(oauth_body_hash)}, - headers=headers - ) + mock_request.params = signature.collect_parameters(headers=headers) oauth_headers = {i[0]:i[1] for i in mock_request.params} @@ -589,34 +586,35 @@ def authenticate(self, request, course): if oauth_body_hash != oauth_headers.get('oauth_body_hash'): return None, False - mock_request.uri = request.META['HTTP_HOST'] + request.META['PATH_INFO'] - mock_request.http_method = unicode(request.META['REQUEST_METHOD']) + mock_request.uri = unicode(request.build_absolute_uri()) + mock_request.http_method = unicode(request.method) oauth_params = signature.collect_parameters(headers=headers, exclude_oauth_signature=False) oauth_headers = {i[0]:i[1] for i in oauth_params} client_signature = oauth_headers.get('oauth_signature') - mock_request.signature = unicode(client_signature) - result = signature.verify_hmac_sha1(mock_request, client_secret) - - data = request.body.strip().encode('utf-8') - parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') - try: # get data from request - root = etree.fromstring(data, parser=parser) - if root.nsmap: - namespaces = {'def': root.nsmap.values()[0]} - imsx_messageIdentifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text - sourcedId = root.xpath("//def:sourcedId", namespaces=namespaces)[0].text - score = root.xpath("//def:textString", namespaces=namespaces)[0].text - else: - imsx_messageIdentifier = root.xpath("//imsx_messageIdentifier")[0].text - sourcedId = root.xpath("//sourcedId")[0].text - score = root.xpath("//textString")[0].text - except: - # return response_xml_template.format(**unsupported_values), "application/xml" + correct_request = signature.verify_hmac_sha1(mock_request, client_secret) + if correct_request: + data = request.body.strip().encode('utf-8') + parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') + try: # get data from request + root = etree.fromstring(data, parser=parser) + if root.nsmap: + namespaces = {'def': root.nsmap.values()[0]} + imsx_messageIdentifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text + sourcedId = root.xpath("//def:sourcedId", namespaces=namespaces)[0].text + score = root.xpath("//def:textString", namespaces=namespaces)[0].text + else: + imsx_messageIdentifier = root.xpath("//imsx_messageIdentifier")[0].text + sourcedId = root.xpath("//sourcedId")[0].text + score = root.xpath("//textString")[0].text + except: + # return response_xml_template.format(**unsupported_values), "application/xml" + return None, False + + anonymous_user_id = sourcedId.split('::')[-1] + return anonymous_user_id, True + else: return None, False - anonymous_user_id = sourcedId.split('::')[-1] - return anonymous_user_id, True - diff --git a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py index c21130e7f691..6e2ca18121d3 100644 --- a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py +++ b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py @@ -251,7 +251,6 @@ def oauth_sign(self, url, body): sha1 = hashlib.sha1() sha1.update(body) oauth_body_hash = base64.b64encode(sha1.hexdigest()) - __, headers, __ = client.sign( unicode(url.strip()), http_method=u'POST', From cae7017c7f206a9e8d4aee7d58261e20d6dfb8d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Thu, 21 Nov 2013 10:08:36 +0200 Subject: [PATCH 20/87] Make resource_link_id mandatory. --- common/lib/xmodule/xmodule/lti_module.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index e751dcb3d14c..ca3bb5905c73 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -285,8 +285,7 @@ def get_resource_link_id(self): context and imported into another system or context. This parameter is required. """ - # This is unique id of edx platform instance. Ned, it is true? - return unicode(self.id) if self.is_graded else '' + return unicode(self.id) def get_lis_result_sourcedid(self): """ From b8a42da889bbeb1425104377dcdb109e86d509f9 Mon Sep 17 00:00:00 2001 From: Oleg Marshev Date: Thu, 21 Nov 2013 18:12:11 +0200 Subject: [PATCH 21/87] Add test. --- common/lib/xmodule/xmodule/tests/test_lti.py | 47 +++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/tests/test_lti.py b/common/lib/xmodule/xmodule/tests/test_lti.py index 73a698c3a29c..50e025659c11 100644 --- a/common/lib/xmodule/xmodule/tests/test_lti.py +++ b/common/lib/xmodule/xmodule/tests/test_lti.py @@ -1,11 +1,13 @@ # -*- coding: utf-8 -*- """Test for LTI Xmodule functional logic.""" +from mock import Mock +import textwrap from xmodule.lti_module import LTIModuleDescriptor from . import LogicTest class LTIModuleTest(LogicTest): - """Logic tests for Poll Xmodule.""" + """Logic tests for LTI module.""" descriptor_class = LTIModuleDescriptor def test_handle_ajax(self): @@ -26,3 +28,46 @@ def test_handle_ajax(self): self.assertEqual(response['status_code'], 200) if dispatch == 'read': self.assertDictEqual(response['content']['value'], {'score': 0.5, 'total': 1.0}) + + def test_custom_handler(self): + self.system.get_real_user = Mock() + self.system.publish = Mock() + + dispatch = 'replaceresult' + + xml_template = textwrap.dedent(""" + + + + + V1.0 + 528243ba5241b + + + + + + + feb-123-456-2929::28883 + + + + en-us + {score} + + + + + + + """) + + + mock_request = Mock() + good_values = {'score': '0.5'} + mock_request.body = xml_template.format(**good_values) + + result = self.xmodule.custom_handler(mock_request, dispatch) + self.assertTrue(result.status_code == 200) + + From 59200bad9b2ccad3ac478d13f7818a099e84dd53 Mon Sep 17 00:00:00 2001 From: Oleg Marshev Date: Fri, 22 Nov 2013 16:48:40 +0200 Subject: [PATCH 22/87] Add tests. --- common/lib/xmodule/xmodule/tests/test_lti.py | 138 +++++++++++++++++-- 1 file changed, 128 insertions(+), 10 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_lti.py b/common/lib/xmodule/xmodule/tests/test_lti.py index 50e025659c11..58a28960c46f 100644 --- a/common/lib/xmodule/xmodule/tests/test_lti.py +++ b/common/lib/xmodule/xmodule/tests/test_lti.py @@ -2,6 +2,8 @@ """Test for LTI Xmodule functional logic.""" from mock import Mock import textwrap +from lxml import etree +import unittest from xmodule.lti_module import LTIModuleDescriptor from . import LogicTest @@ -10,6 +12,33 @@ class LTIModuleTest(LogicTest): """Logic tests for LTI module.""" descriptor_class = LTIModuleDescriptor + xml_template = textwrap.dedent(""" + + + + + V1.0 + 528243ba5241b + + + + + + + feb-123-456-2929::28883 + + + + en-us + {grade} + + + + + + + """) + def test_handle_ajax(self): # Make sure that ajax request works correctly. @@ -29,31 +58,101 @@ def test_handle_ajax(self): if dispatch == 'read': self.assertDictEqual(response['content']['value'], {'score': 0.5, 'total': 1.0}) - def test_custom_handler(self): + def test_valid_xml(self): + """ + Valid XML returned from Tool Provider. + """ + self.system.get_real_user = Mock() + self.system.publish = Mock() + + dispatch = 'replaceresult' + + mock_request = Mock() + + good_value = {'grade': '0.5'} + mock_request.body = self.xml_template.format(**good_value) + result = self.xmodule.custom_handler(mock_request, dispatch) + + parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') + root = etree.fromstring(result.body.strip(), parser=parser) + namespaces = {'def': root.nsmap.values()[0]} + + code_major = root.xpath("//def:imsx_codeMajor", namespaces=namespaces)[0].text + + self.assertEqual(code_major, 'success', code_major) + + + def test_bad_grade_range(self): + """ + Grade returned from Tool Provider is outside the range 0.0-1.0. + """ self.system.get_real_user = Mock() self.system.publish = Mock() dispatch = 'replaceresult' + + mock_request = Mock() + bad_value = {'grade': '100'} + mock_request.body = self.xml_template.format(**bad_value) + result = self.xmodule.custom_handler(mock_request, dispatch) + + parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') + root = etree.fromstring(result.body.strip(), parser=parser) + namespaces = {'def': root.nsmap.values()[0]} + code_major = root.xpath("//def:imsx_codeMajor", namespaces=namespaces)[0].text + + self.assertEqual(code_major, 'failure', code_major) + + def test_bad_grade_decimal(self): + """ + Grade returned from Tool Provider doesn't use a period as the decimal point. + """ + self.system.get_real_user = Mock() + self.system.publish = Mock() + + dispatch = 'replaceresult' + + mock_request = Mock() + bad_value = {'grade': '0,5'} + mock_request.body = self.xml_template.format(**bad_value) + result = self.xmodule.custom_handler(mock_request, dispatch) + + parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') + root = etree.fromstring(result.body.strip(), parser=parser) + namespaces = {'def': root.nsmap.values()[0]} + code_major = root.xpath("//def:imsx_codeMajor", namespaces=namespaces)[0].text + + self.assertEqual(code_major, 'failure', code_major) + + def test_incomplete_response_body(self): + """ + Response body from Tool Provider doesn't contain messageIdentifier and sourcedId and textString. + """ + self.system.get_real_user = Mock() + self.system.publish = Mock() + + dispatch = 'replaceresult' + xml_template = textwrap.dedent(""" V1.0 - 528243ba5241b + "here must be messageIdentifier" - feb-123-456-2929::28883 + "here must be sourcedId" en-us - {score} + "here must be textString" @@ -61,13 +160,32 @@ def test_custom_handler(self): """) - - - mock_request = Mock() - good_values = {'score': '0.5'} - mock_request.body = xml_template.format(**good_values) + mock_request = Mock() + mock_request.body = xml_template result = self.xmodule.custom_handler(mock_request, dispatch) - self.assertTrue(result.status_code == 200) + + parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') + root = etree.fromstring(result.body.strip(), parser=parser) + namespaces = {'def': root.nsmap.values()[0]} + code_major = root.xpath("//def:imsx_codeMajor", namespaces=namespaces)[0].text + + self.assertEqual(code_major, 'unsupported', code_major) + + @unittest.skip("skipped because not completed") + def test_authorization_header_not_present(self): + """ + Authorization header not provided in request. + """ + mock_request = Mock() + mock_request.META = None + + mock_course = Mock() + import ipdb; ipdb.set_trace() + descriptor = LTIModuleDescriptor() + + result = descriptor.authenticate(mock_request, mock_course) + + From 117b3c65b92887ad1933add86957f9bdc8fa30ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Sat, 23 Nov 2013 21:03:29 +0200 Subject: [PATCH 23/87] Various fixes to LTI XML grade processing. --- common/djangoapps/track/middleware.py | 1 - common/lib/xmodule/xmodule/lti_module.py | 237 +++++++----------- .../mock_lti_server/mock_lti_server.py | 6 +- 3 files changed, 89 insertions(+), 155 deletions(-) diff --git a/common/djangoapps/track/middleware.py b/common/djangoapps/track/middleware.py index dff847e3318f..54934c5f36a6 100644 --- a/common/djangoapps/track/middleware.py +++ b/common/djangoapps/track/middleware.py @@ -22,7 +22,6 @@ class TrackMiddleware(object): def process_request(self, request): try: - self.enter_request_context(request) if not self.should_process_request(request): diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index ca3bb5905c73..fddd31d2992a 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -41,7 +41,7 @@ class LTIFields(object): 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. + `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. @@ -60,7 +60,7 @@ class LTIFields(object): launch_url = String(help="URL of the tool", default='http://www.example.com', scope=Scope.settings) custom_parameters = List(help="Custom parameters (vbid, book_location, etc..)", scope=Scope.settings) open_in_a_new_page = Boolean(help="Should LTI be opened in new page?", default=True, scope=Scope.settings) - is_graded = Boolean(help="The LTI provider will grade student's results.", default=False, scope=Scope.settings) + graded = Boolean(help="Grades will be considered in overall score.", default=False, scope=Scope.settings) weight = Float(help="Weight for student grades.", default=1.0, scope=Scope.settings) @@ -248,15 +248,6 @@ def get_user_id(self): assert user_id is not None return user_id - def get_base_url(self): - """ - Return url for storing grades - """ - return 'http://{host}{path}'.format( - host=self.system.hostname, - path=self.system.get_handler_url('custom_handler'), - ) - def get_outcome_service_url(self): """ Return url for storing grades @@ -265,15 +256,7 @@ def get_outcome_service_url(self): host=self.system.hostname, path=self.system.get_handler_url('custom_handler'), ) - return '{}/replaceResult'.format(uri) if self.is_graded else '' - - - def get_context_id(self): - # This is an opaque identifier that uniquely identifies the context that contains - # the link being launched. This parameter is recommended. - - # so context_id is lti_id - return self.lti_id + return '{}/replaceResult'.format(uri) def get_resource_link_id(self): """ @@ -295,27 +278,19 @@ def get_lis_result_sourcedid(self): 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. + + context_id is - is an opaque identifier that uniquely identifies the context that contains + the link being launched. + lti_id should be context_id by meaning """ - if self.is_graded: # lti_id should be context_id by meaning. - return '{}::{}::{}'.format(self.lti_id, self.get_resource_link_id(), self.get_user_id()) - else: - return '' + return ':'.join(urllib.quote(i) for i in (self.lti_id, self.get_resource_link_id(), self.get_user_id())) - # Optional, do not use it for now. - # def get_lis_person_sourcedid(self): - # # This field contains the LIS identifier for the user account that is performing this launch. - # # The example syntax of "school:user" is not the required format - lis_person_sourcedid - # # is simply a unique identifier (i.e., a normalized string). - # # This field is optional and its content and meaning are defined by LIS. - # school = 'EdX' - # return '{}:{}:{}'.format(school, self.get_user_id(), uuid4().hex) if self.is_graded else '' 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. @@ -339,7 +314,6 @@ def oauth_params(self, custom_parameters, client_key, client_secret): u'resource_link_id': self.get_resource_link_id(), u'lis_outcome_service_url': self.get_outcome_service_url(), u'lis_result_sourcedid': self.get_lis_result_sourcedid(), - # u'lis_person_sourcedid': self.get_lis_person_sourcedid(), # optional, do not use for now. } @@ -384,27 +358,46 @@ def oauth_params(self, custom_parameters, client_key, client_secret): params.update(body) return params - def get_maxscore(self): + def max_score(self): return self.weight def custom_handler(self, request, dispatch): """ This is called by courseware.module_render, to handle an AJAX call. - Used only for grading. - - Returns json in following format: - {'status_code': HTTP status code, 'content': proper XML defined in LTI v.1.1} - - 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. + Used only for grading. Returns XML reponse + + 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 + + TODO: add test for xml escaping """ - - # verify oauth signing - - # Beware: xmlns is broken link, as it is from LTI spec page, where it is broken. response_xml_template = textwrap.dedent(""" @@ -424,67 +417,24 @@ def custom_handler(self, request, dispatch): {response} """) - unsupported_values = { 'imsx_codeMajor': 'unsupported', 'imsx_description': 'Only replaceResult is supported', 'imsx_messageIdentifier': 'unknown', 'response': '' } - - # what should come from LTI provider: - """ - data_example = textwrap.dedent(\""" - - - - - V1.0 - 528243ba5241b - - - - - - - feb-123-456-2929::28883 - - - - en-us - 0.4 - - - - - - - \""") - """ - data = request.body.strip().encode('utf-8') - parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') - try: # get data from request - root = etree.fromstring(data, parser=parser) - if root.nsmap: - namespaces = {'def': root.nsmap.values()[0]} - imsx_messageIdentifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text - sourcedId = root.xpath("//def:sourcedId", namespaces=namespaces)[0].text - score = root.xpath("//def:textString", namespaces=namespaces)[0].text - else: - imsx_messageIdentifier = root.xpath("//imsx_messageIdentifier")[0].text - sourcedId = root.xpath("//sourcedId")[0].text - score = root.xpath("//textString")[0].text + try: + imsx_messageIdentifier, sourcedId, score, action = self.descriptor.parse_grade_xml_body(request.body) except: return Response(response_xml_template.format(**unsupported_values), content_type="application/xml") - real_user = self.system.get_real_user(sourcedId.split('::')[-1]) - action = dispatch.lower() - if action == 'replaceresult': + real_user = self.system.get_real_user(urllib.unquote(sourcedId.split(':')[-1])) + if action == 'replaceResultRequest': self.system.publish( event={ 'event_name': 'grade', - 'value': score, - 'max_value': self.get_maxscore(), + 'value': float(score) * self.max_score(), + 'max_value': self.max_score(), }, custom_user=real_user ) @@ -497,7 +447,6 @@ def custom_handler(self, request, dispatch): } return Response(response_xml_template.format(**values), content_type="application/xml") - # return "unsupported" response unsupported_values['imsx_messageIdentifier'] = imsx_messageIdentifier return Response(response_xml_template.format(**unsupported_values), content_type='application/xml') @@ -507,18 +456,45 @@ class LTIModuleDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRaw Descriptor for LTI Xmodule. """ has_score = True - graded = True - module_class = LTIModule - custom_handler = module_attr('custom_handler') + @classmethod + def parse_grade_xml_body(cls, body): + """ + Parses XML from request.body and returns parsed data + + XML body should contain nsmap (see specs). + + Returns tuple: imsx_messageIdentifier, sourcedId, score, action + + Raises Exception if can't parse. + """ + data = body.strip().encode('utf-8') + parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') + # get data from request, + root = etree.fromstring(data, parser=parser) + namespaces = {'def': root.nsmap.values()[0]} + imsx_messageIdentifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text + 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('{'+root.nsmap.values()[0]+'}', '') + return imsx_messageIdentifier, sourcedId, score, action + + def authenticate(self, request, course): + """ + Verify request 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. + """ # self course_id produces runtime assertion error # Obtains client_key and client_secret credentials from current course: client_key = client_secret = '' - for lti_passport in course.lti_passports: try: lti_id, key, secret = [i.strip() for i in lti_passport.split(':')] @@ -529,36 +505,6 @@ def authenticate(self, request, course): client_key, client_secret = key, secret break - # what should come from LTI provider: - """ - data_example = textwrap.dedent(\""" - - - - - V1.0 - 528243ba5241b - - - - - - - feb-123-456-2929::28883::5afe5d9bb03796557ee2614f5c9611fb - - - - en-us - 0.4 - - - - - - - \""") - """ - #verify oauth signing mock_request = mock.Mock() @@ -579,7 +525,7 @@ def authenticate(self, request, course): mock_request.params = signature.collect_parameters(headers=headers) - oauth_headers = {i[0]:i[1] for i in mock_request.params} + oauth_headers = dict(mock_request.params) #compare hash from request body and body hash from Authorization header if oauth_body_hash != oauth_headers.get('oauth_body_hash'): @@ -589,31 +535,20 @@ def authenticate(self, request, course): mock_request.http_method = unicode(request.method) oauth_params = signature.collect_parameters(headers=headers, exclude_oauth_signature=False) - oauth_headers = {i[0]:i[1] for i in oauth_params} + oauth_headers =dict(oauth_params) client_signature = oauth_headers.get('oauth_signature') mock_request.signature = unicode(client_signature) correct_request = signature.verify_hmac_sha1(mock_request, client_secret) if correct_request: - data = request.body.strip().encode('utf-8') - parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') - try: # get data from request - root = etree.fromstring(data, parser=parser) - if root.nsmap: - namespaces = {'def': root.nsmap.values()[0]} - imsx_messageIdentifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text - sourcedId = root.xpath("//def:sourcedId", namespaces=namespaces)[0].text - score = root.xpath("//def:textString", namespaces=namespaces)[0].text - else: - imsx_messageIdentifier = root.xpath("//imsx_messageIdentifier")[0].text - sourcedId = root.xpath("//sourcedId")[0].text - score = root.xpath("//textString")[0].text - except: - # return response_xml_template.format(**unsupported_values), "application/xml" + try: + __, sourcedId, __, __ = self.parse_grade_xml_body(request.body) + anonymous_user_id = urllib.unquote(sourcedId.split(':')[-1]) + except Exception: + # should return response_xml_template.format(**unsupported_values), "application/xml" return None, False - - anonymous_user_id = sourcedId.split('::')[-1] return anonymous_user_id, True else: + # should return response_xml_template.format(**unsupported_values), "application/xml" return None, False diff --git a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py index 6e2ca18121d3..f7748e07fd2d 100644 --- a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py +++ b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py @@ -98,7 +98,7 @@ def do_POST(self): # what need to be stored as server data self.server.grade_data = { 'callback_url': self.post_dict["lis_outcome_service_url"], - 'user_id': self.post_dict['user_id'] + 'sourcedId': self.post_dict['lis_result_sourcedid'] } else: status_message = "Invalid request URL" @@ -151,13 +151,13 @@ def _send_graded_result(self): values = { 'textString': 0.5, - 'sourcedId': self.server.grade_data['user_id'], + 'sourcedId': self.server.grade_data['sourcedId'], 'imsx_messageIdentifier': uuid4().hex, } payload = textwrap.dedent(""" - + V1.0 From a7ecfc8227ea49f1d641e6ef0889de5ed70e3aea Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Sat, 23 Nov 2013 20:24:31 -0500 Subject: [PATCH 24/87] Cherry-picked 9131eb825dddb40c1669dc7d04072724ac11d8e0. --- common/lib/xmodule/xmodule/x_module.py | 2 +- lms/djangoapps/courseware/module_render.py | 22 ++++++++----- .../courseware/tests/test_module_render.py | 4 ++- lms/lib/xblock/runtime.py | 31 ++++++++++++++++--- lms/urls.py | 4 ++- requirements/edx/github.txt | 2 +- 6 files changed, 50 insertions(+), 15 deletions(-) diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 52decde05dc7..a5dc223b092e 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -371,7 +371,6 @@ class XModule(XModuleMixin, HTMLSnippet, XBlock): # pylint: disable=abstract-me See the HTML module for a simple example. """ - has_score = descriptor_attr('has_score') _field_data_cache = descriptor_attr('_field_data_cache') _field_data = descriptor_attr('_field_data') @@ -402,6 +401,7 @@ def handle_ajax(self, _dispatch, _data): data is a dictionary-like object with the content of the request""" return u"" + @XBlock.handler def xmodule_handler(self, request, suffix=None): """ XBlock handler that wraps `handle_ajax` diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index a646647ebcc3..ab777577664f 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -489,6 +489,13 @@ def xqueue_callback(request, course_id, userid, mod_id, dispatch): @csrf_exempt +def handle_xblock_callback_noauth(request, course_id, usage_id, handler, suffix=None): + """ + Entry point for unauthenticated XBlock handlers. + """ + return _invoke_xblock_handler(request, course_id, usage_id, handler, suffix) + + def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None): """ Generic view for extensions. This is where AJAX calls go. @@ -504,16 +511,17 @@ def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None): not accessible by the user, or the module raises NotFoundError. If the module raises any other error, it will escape this function. """ + if not request.user.is_authenticated(): + raise PermissionDenied - @csrf_protect - def get_protected_user(request): - return request.user + return _invoke_xblock_handler(request, course_id, usage_id, handler, suffix) - if handler != 'custom_handler': - user = get_protected_user(request) - if not user.is_authenticated(): - raise PermissionDenied +def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix): + """ + Invoke an XBlock handler, either authenticated or not. + + """ location = unquote_slashes(usage_id) # Check parameters and fail fast if there's a problem diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index 3f4f3b673b2e..38f42d1f49bc 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -185,9 +185,11 @@ def _mock_file(self, name='file', size=10): return mock_file def test_invalid_location(self): + request = self.request_factory.post('dummy_url', data={'position': 1}) + request.user = self.mock_user with self.assertRaises(Http404): render.handle_xblock_callback( - None, + request, 'dummy/course/id', 'invalid Location', 'dummy_handler' diff --git a/lms/lib/xblock/runtime.py b/lms/lib/xblock/runtime.py index cecdd4d121da..7d2ac245325a 100644 --- a/lms/lib/xblock/runtime.py +++ b/lms/lib/xblock/runtime.py @@ -60,9 +60,29 @@ def unquote_slashes(text): def handler_url(course_id, block, handler, suffix='', query=''): """ - Return an xblock handler url for the specified course, block and handler + Return an XBlock handler url for the specified course, block and handler. + + If handler is an empty string, this function is being used to create a + prefix of the general URL, which is assumed to be followed by handler name + and suffix. + + If handler is specified, then it is checked for being a valid handler + function, and ValueError is raised if not. + """ - return reverse('xblock_handler', kwargs={ + view_name = 'xblock_handler' + if handler: + # Be sure this is really a handler. + func = getattr(block, handler, None) + if not func: + raise ValueError("{!r} is not a function name".format(handler)) + if not getattr(func, "_is_xblock_handler", False): + raise ValueError("{!r} is not a handler name".format(handler)) + + if getattr(func, "_is_unauthenticated", False): + view_name = 'xblock_handler_noauth' + + return reverse(view_name, kwargs={ 'course_id': course_id, 'usage_id': quote_slashes(str(block.scope_ids.usage_id)), 'handler': handler, @@ -72,8 +92,11 @@ def handler_url(course_id, block, handler, suffix='', query=''): def handler_prefix(course_id, block): """ - Returns a prefix for use by the javascript handler_url function. - The prefix is a valid handler url the handler name is appended to it. + Returns a prefix for use by the Javascript handler_url function. + + The prefix is a valid handler url after the handler name is slash-appended + to it. + """ return handler_url(course_id, block, '').rstrip('/') diff --git a/lms/urls.py b/lms/urls.py index 4aec95d01fc1..6de00a741103 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -178,7 +178,9 @@ url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/xblock/(?P[^/]*)/handler/(?P[^/]*)(?:/(?P.*))?$', 'courseware.module_render.handle_xblock_callback', name='xblock_handler'), - + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/xblock/(?P[^/]*)/handler_noauth/(?P[^/]*)(?:/(?P.*))?$', + 'courseware.module_render.handle_xblock_callback_noauth', + name='xblock_handler_noauth'), # Software Licenses diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 26d12f2af93f..8f5d4a189a62 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -15,7 +15,7 @@ -e git+https://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk # Our libraries: --e git+https://github.com/edx/XBlock.git@d6d2fc91#egg=XBlock +-e git+https://github.com/edx/XBlock.git@9a5dbfc76b657c2c05535ce344e3006d4d134830#egg=XBlock -e git+https://github.com/edx/codejail.git@0a1b468#egg=codejail -e git+https://github.com/edx/diff-cover.git@v0.2.6#egg=diff_cover -e git+https://github.com/edx/js-test-tool.git@v0.1.4#egg=js_test_tool From e99c080f51e164a5dd1571ec2922bee54ce37e82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Sun, 24 Nov 2013 17:15:36 +0200 Subject: [PATCH 25/87] =?UTF-8?q?Small=20fixes=20and=20rename=20of=20uniqu?= =?UTF-8?q?e=5Fid=5Ffor=5Fuse=D0=B3.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../commands/anonymized_id_mapping.py | 6 ++-- common/djangoapps/student/models.py | 13 ++++---- common/djangoapps/student/tests/tests.py | 18 +++++------ common/djangoapps/student/views.py | 4 +-- common/lib/xmodule/xmodule/lti_module.py | 16 ++++++---- common/lib/xmodule/xmodule/x_module.py | 10 +++---- lms/djangoapps/courseware/module_render.py | 30 +++++++++---------- lms/djangoapps/foldit/tests.py | 4 +-- lms/djangoapps/foldit/views.py | 8 ++--- lms/djangoapps/instructor/tests/test_api.py | 2 +- .../instructor/tests/test_legacy_anon_csv.py | 2 +- lms/djangoapps/instructor/views/api.py | 4 +-- lms/djangoapps/instructor/views/legacy.py | 4 +-- .../open_ended_notifications.py | 8 ++--- .../staff_grading_service.py | 8 ++--- lms/djangoapps/open_ended_grading/tests.py | 4 +-- lms/djangoapps/open_ended_grading/utils.py | 2 +- lms/djangoapps/open_ended_grading/views.py | 8 ++--- 18 files changed, 78 insertions(+), 73 deletions(-) diff --git a/common/djangoapps/student/management/commands/anonymized_id_mapping.py b/common/djangoapps/student/management/commands/anonymized_id_mapping.py index bb32aea6e55d..8983d5a3ec15 100644 --- a/common/djangoapps/student/management/commands/anonymized_id_mapping.py +++ b/common/djangoapps/student/management/commands/anonymized_id_mapping.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""Dump username,unique_id_for_user pairs as CSV. +"""Dump username,anonymous_id_for_user pairs as CSV. Give instructors easy access to the mapping from anonymized IDs to user IDs with a simple Django management command to generate a CSV mapping. To run, use @@ -15,7 +15,7 @@ from django.contrib.auth.models import User from django.core.management.base import BaseCommand, CommandError -from student.models import unique_id_for_user +from student.models import anonymous_id_for_user class Command(BaseCommand): @@ -54,7 +54,7 @@ def handle(self, *args, **options): csv_writer = csv.writer(output_file) csv_writer.writerow(("User ID", "Anonymized user ID")) for student in students: - csv_writer.writerow((student.id, unique_id_for_user(student, course_id))) + csv_writer.writerow((student.id, anonymous_id_for_user(student, course_id))) except IOError: raise CommandError("Error writing to file: %s" % output_filename) diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 2ce3d0e4a6f9..49d57bcd8dfb 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -57,11 +57,11 @@ class AnonymousUsers(models.Model): course_id = models.CharField(db_index=True, max_length=255) -def unique_id_for_user(user, course_id): +def anonymous_id_for_user(user, course_id): """ Return a unique id for a (user, course) pair, suitable for inserting into e.g. personalized survey links. """ - def _unique_id_for_user(user, course_id): + def _anonymous_id_for_user(user, course_id): # include the secret key as a salt, and to make the ids unique across different LMS installs. h = hashlib.md5() h.update(settings.SECRET_KEY) @@ -69,11 +69,14 @@ def _unique_id_for_user(user, course_id): h.update(course_id) return h.hexdigest() + if user.is_anonymous(): + return 'Anonymous' + # import ipdb; ipdb.set_trace() return AnonymousUsers.objects.get_or_create( user=user, course_id=course_id, - anonymous_user_id=_unique_id_for_user(user, course_id) + anonymous_user_id=_anonymous_id_for_user(user, course_id) )[0].anonymous_user_id @@ -92,7 +95,7 @@ def user_by_anonymous_id(id): return obj.user -def simple_unique_id_for_user(user): +def simple_anonymous_id_for_user(user): """ Return a unique id for a user, suitable for inserting into foldit module table or into unit tests. @@ -819,7 +822,7 @@ def update_enrollment(self, mode=None, is_active=None): activation_changed = True mode_changed = False - # if mode is None, the call to update_enrollment didn't specify a new + # if mode is None, the call to update_enrollment didn't specify a new # mode, so leave as-is if self.mode != mode and mode is not None: self.mode = mode diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index 8738c417ff8c..ba0e858897ab 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -28,7 +28,7 @@ from mock import Mock, patch, sentinel from textwrap import dedent -from student.models import unique_id_for_user, simple_unique_id_for_user, CourseEnrollment +from student.models import anonymous_id_for_user, simple_anonymous_id_for_user, CourseEnrollment from student.views import (process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper, change_enrollment, complete_course_mode_info) from student.tests.factories import UserFactory, CourseModeFactory @@ -137,27 +137,27 @@ def test_reset_password_good_token(self, reset_confirm): class CourseEndingTest(TestCase): """Test things related to course endings: certificates, surveys, etc""" - @patch('student.views.unique_id_for_user') - def test_process_survey_link(self, mock_unique_id_for_user): + @patch('student.views.anonymous_id_for_user') + def test_process_survey_link(self, mock_anonymous_id_for_user): course = Mock(id="test_id") user = Mock(username="fred", id="test_user_id") - mock_unique_id_for_user.return_value = simple_unique_id_for_user(user) + mock_anonymous_id_for_user.return_value = simple_anonymous_id_for_user(user) link1 = "http://www.mysurvey.com" self.assertEqual(process_survey_link(link1, user, course.id), link1) link2 = "http://www.mysurvey.com?unique={UNIQUE_ID}" - link2_expected = "http://www.mysurvey.com?unique={UNIQUE_ID}".format(UNIQUE_ID=simple_unique_id_for_user(user)) + link2_expected = "http://www.mysurvey.com?unique={UNIQUE_ID}".format(UNIQUE_ID=simple_anonymous_id_for_user(user)) self.assertEqual(process_survey_link(link2, user, course.id), link2_expected) - # patching student.views.unique_id_for_user, not student.models.unique_id_for_user, + # patching student.views.anonymous_id_for_user, not student.models.anonymous_id_for_user, # look at http://www.voidspace.org.uk/python/mock/patch.html#where-to-patch for explanation. - @patch('student.views.unique_id_for_user') - def test_cert_info(self, mock_unique_id_for_user): + @patch('student.views.anonymous_id_for_user') + def test_cert_info(self, mock_anonymous_id_for_user): user = Mock(username="fred", id="test_user_id") survey_url = "http://a_survey.com" - mock_unique_id_for_user.return_value = simple_unique_id_for_user(user) + mock_anonymous_id_for_user.return_value = simple_anonymous_id_for_user(user) course = Mock(end_of_course_survey_url=survey_url, id="test_id") diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index b2e5d7560593..ccb6695fd044 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -41,7 +41,7 @@ from student.models import ( Registration, UserProfile, TestCenterUser, TestCenterUserForm, TestCenterRegistration, TestCenterRegistrationForm, PendingNameChange, - PendingEmailChange, CourseEnrollment, unique_id_for_user, + PendingEmailChange, CourseEnrollment, anonymous_id_for_user, get_testcenter_registration, CourseEnrollmentAllowed, UserStanding, ) from student.forms import PasswordResetFormNoActive @@ -154,7 +154,7 @@ def process_survey_link(survey_link, user, course_id): If {UNIQUE_ID} appears in the link, replace it with a unique id for the user. Currently, this is sha1(user.username). Otherwise, return survey_link. """ - return survey_link.format(UNIQUE_ID=unique_id_for_user(user, course_id)) + return survey_link.format(UNIQUE_ID=anonymous_id_for_user(user, course_id)) def cert_info(user, course): diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index fddd31d2992a..e235ee17d73a 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -25,7 +25,7 @@ from xmodule.x_module import XModule, module_attr from xmodule.course_module import CourseDescriptor from pkg_resources import resource_string -from xblock.core import String, Scope, List +from xblock.core import String, Scope, List, XBlock from xblock.fields import Boolean, Float @@ -254,9 +254,10 @@ def get_outcome_service_url(self): """ uri = 'http://{host}{path}'.format( host=self.system.hostname, - path=self.system.get_handler_url('custom_handler'), + # path=self.system.get_handler_url('custom_handler'), + path=self.runtime.handler_url(self, 'grade_handler').rstrip('/?') ) - return '{}/replaceResult'.format(uri) + return uri def get_resource_link_id(self): """ @@ -361,7 +362,10 @@ def oauth_params(self, custom_parameters, client_key, client_secret): def max_score(self): return self.weight - def custom_handler(self, request, dispatch): + + @XBlock.handler + @XBlock.unauthenticated + def grade_handler(self, request, dispatch): """ This is called by courseware.module_render, to handle an AJAX call. @@ -424,7 +428,7 @@ def custom_handler(self, request, dispatch): 'response': '' } try: - imsx_messageIdentifier, sourcedId, score, action = self.descriptor.parse_grade_xml_body(request.body) + imsx_messageIdentifier, sourcedId, score, action = self.descriptor.parse_grade_xml_body(request.body) except: return Response(response_xml_template.format(**unsupported_values), content_type="application/xml") @@ -457,7 +461,7 @@ class LTIModuleDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRaw """ has_score = True module_class = LTIModule - custom_handler = module_attr('custom_handler') + grade_handler = module_attr('grade_handler') @classmethod def parse_grade_xml_body(cls, body): diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index a5dc223b092e..fdd9d160775d 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -1070,11 +1070,11 @@ def ajax_url(self): """ return self.handler_url(self.xmodule_instance, 'xmodule_handler', '', '').rstrip('/?') - def get_handler_url(self, handler_name): - """ - The url prefix to be used by XModules to call into handler_name - """ - return self.handler_url(self.xmodule_instance, handler_name, '', '').rstrip('/?') + # def get_handler_url(self, handler_name): + # """ + # The url prefix to be used by XModules to call into handler_name + # """ + # return self.handler_url(self.xmodule_instance, handler_name, '', '').rstrip('/?') class DoNothingCache(object): diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index ab777577664f..ed5e8b8e351f 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -24,7 +24,7 @@ from lms.lib.xblock.runtime import LmsModuleSystem, handler_prefix, unquote_slashes from mitxmako.shortcuts import render_to_string from psychometrics.psychoanalyze import make_psychometrics_data_update_handler -from student.models import unique_id_for_user, user_by_anonymous_id +from student.models import anonymous_id_for_user, user_by_anonymous_id from util.json_request import JsonResponse from util.sandboxing import can_execute_unsafe_code from xblock.fields import Scope @@ -302,7 +302,7 @@ def publish(event, custom_user=None): block_scope_id=descriptor.location, field_name='grade' ) - + import ipdb; ipdb.set_trace() student_module = field_data_cache.find_or_create(key) # Update the grades student_module.grade = event.get('value') @@ -397,7 +397,7 @@ def publish(event, custom_user=None): ), node_path=settings.NODE_PATH, publish=publish, - anonymous_student_id=unique_id_for_user(user, course_id), + anonymous_student_id=anonymous_id_for_user(user, course_id), course_id=course_id, open_ended_grading_interface=open_ended_grading_interface, s3_interface=s3_interface, @@ -493,7 +493,7 @@ def handle_xblock_callback_noauth(request, course_id, usage_id, handler, suffix= """ Entry point for unauthenticated XBlock handlers. """ - return _invoke_xblock_handler(request, course_id, usage_id, handler, suffix) + return _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, request.user) def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None): @@ -514,10 +514,10 @@ def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None): if not request.user.is_authenticated(): raise PermissionDenied - return _invoke_xblock_handler(request, course_id, usage_id, handler, suffix) + return _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, request.user) -def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix): +def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user): """ Invoke an XBlock handler, either authenticated or not. @@ -544,16 +544,14 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix): ) ) raise Http404 - # import ipdb; ipdb.set_trace() - - if handler == 'custom_handler': - from courseware.courses import get_course_by_id - course = get_course_by_id(course_id) - anonymous_user_id, status = descriptor.authenticate(request, course) - if not status: - raise PermissionDenied - from student.models import user_by_anonymous_id - user = user_by_anonymous_id(anonymous_user_id) + + from courseware.courses import get_course_by_id + course = get_course_by_id(course_id) + anonymous_user_id, status = descriptor.authenticate(request, course) + if not status: + raise PermissionDenied + from student.models import user_by_anonymous_id + user = user_by_anonymous_id(anonymous_user_id) field_data_cache = FieldDataCache.cache_for_descriptor_descendents( course_id, diff --git a/lms/djangoapps/foldit/tests.py b/lms/djangoapps/foldit/tests.py index 2708c5c073e3..34a95f101f70 100644 --- a/lms/djangoapps/foldit/tests.py +++ b/lms/djangoapps/foldit/tests.py @@ -8,7 +8,7 @@ from foldit.views import foldit_ops, verify_code from foldit.models import PuzzleComplete, Score -from student.models import simple_unique_id_for_user +from student.models import simple_anonymous_id_for_user from student.tests.factories import CourseEnrollmentFactory, UserFactory, UserProfileFactory from datetime import datetime, timedelta @@ -346,7 +346,7 @@ def test_SetPlayerPuzzlesComplete_level_complete(self): self.set_puzzle_complete_response([13, 14, 15, 53524])) is_complete = partial( - PuzzleComplete.is_level_complete, simple_unique_id_for_user(self.user)) + PuzzleComplete.is_level_complete, simple_anonymous_id_for_user(self.user)) self.assertTrue(is_complete(1, 1)) self.assertTrue(is_complete(1, 3)) diff --git a/lms/djangoapps/foldit/views.py b/lms/djangoapps/foldit/views.py index 65d7c6fd64da..d7727ea041c4 100644 --- a/lms/djangoapps/foldit/views.py +++ b/lms/djangoapps/foldit/views.py @@ -8,7 +8,7 @@ from django.views.decorators.csrf import csrf_exempt from foldit.models import Score, PuzzleComplete -from student.models import simple_unique_id_for_user +from student.models import simple_anonymous_id_for_user import re @@ -124,7 +124,7 @@ def save_scores(user, puzzle_scores): try: obj = Score.objects.get( user=user, - unique_user_id=simple_unique_id_for_user(user), + unique_user_id=simple_anonymous_id_for_user(user), puzzle_id=puzzle_id, score_version=score_version) obj.current_score = current_score @@ -133,7 +133,7 @@ def save_scores(user, puzzle_scores): except Score.DoesNotExist: obj = Score( user=user, - unique_user_id=simple_unique_id_for_user(user), + unique_user_id=simple_anonymous_id_for_user(user), puzzle_id=puzzle_id, current_score=current_score, best_score=best_score, @@ -160,7 +160,7 @@ def save_complete(user, puzzles_complete): # create if not there PuzzleComplete.objects.get_or_create( user=user, - unique_user_id=simple_unique_id_for_user(user), + unique_user_id=simple_anonymous_id_for_user(user), puzzle_id=puzzle_id, puzzle_set=puzzle_set, puzzle_subset=puzzle_subset) diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 6744031c6f96..4547718af8c5 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -782,7 +782,7 @@ def test_get_anon_ids(self): Test the CSV output for the anonymized user ids. """ url = reverse('get_anon_ids', kwargs={'course_id': self.course.id}) - with patch('instructor.views.api.unique_id_for_user') as mock_unique: + with patch('instructor.views.api.anonymous_id_for_user') as mock_unique: mock_unique.return_value = '42' response = self.client.get(url, {}) self.assertEqual(response['Content-Type'], 'text/csv') diff --git a/lms/djangoapps/instructor/tests/test_legacy_anon_csv.py b/lms/djangoapps/instructor/tests/test_legacy_anon_csv.py index b6312376ed2f..84d05d7f8dd9 100644 --- a/lms/djangoapps/instructor/tests/test_legacy_anon_csv.py +++ b/lms/djangoapps/instructor/tests/test_legacy_anon_csv.py @@ -58,7 +58,7 @@ def test_download_anon_csv(self): course = self.toy url = reverse('instructor_dashboard', kwargs={'course_id': course.id}) - with patch('instructor.views.legacy.unique_id_for_user') as mock_unique: + with patch('instructor.views.legacy.anonymous_id_for_user') as mock_unique: mock_unique.return_value = 42 response = self.client.post(url, {'action': 'Download CSV of all student anonymized IDs'}) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 468197341485..47e45f28ac1b 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -28,7 +28,7 @@ FORUM_ROLE_COMMUNITY_TA) from courseware.models import StudentModule -from student.models import unique_id_for_user +from student.models import anonymous_id_for_user import instructor_task.api from instructor_task.api_helper import AlreadyRunningError from instructor_task.views import get_task_completion_info @@ -448,7 +448,7 @@ def csv_response(filename, header, rows): courseenrollment__course_id=course_id, ).order_by('id') header = ['User ID', 'Anonymized user ID'] - rows = [[s.id, unique_id_for_user(s, course_id)] for s in students] + rows = [[s.id, anonymous_id_for_user(s, course_id)] for s in students] return csv_response(course_id.replace('/', '-') + '-anon-ids.csv', header, rows) diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index 0d15f9d3f36c..b42d6fe8b24f 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -52,7 +52,7 @@ from instructor_task.views import get_task_completion_info from mitxmako.shortcuts import render_to_response, render_to_string from psychometrics import psychoanalyze -from student.models import CourseEnrollment, CourseEnrollmentAllowed, unique_id_for_user +from student.models import CourseEnrollment, CourseEnrollmentAllowed, anonymous_id_for_user from student.views import course_from_id import track.views from xblock.field_data import DictFieldData @@ -547,7 +547,7 @@ def getdat(u): ).order_by('id') datatable = {'header': ['User ID', 'Anonymized user ID']} - datatable['data'] = [[s.id, unique_id_for_user(s, course_id)] for s in students] + datatable['data'] = [[s.id, anonymous_id_for_user(s, course_id)] for s in students] return return_csv(course_id.replace('/', '-') + '-anon-ids.csv', datatable) #---------------------------------------- diff --git a/lms/djangoapps/open_ended_grading/open_ended_notifications.py b/lms/djangoapps/open_ended_grading/open_ended_notifications.py index 8f1f3c3b2605..f6700152edec 100644 --- a/lms/djangoapps/open_ended_grading/open_ended_notifications.py +++ b/lms/djangoapps/open_ended_grading/open_ended_notifications.py @@ -10,7 +10,7 @@ from courseware.access import has_access from lms.lib.xblock.runtime import LmsModuleSystem from mitxmako.shortcuts import render_to_string -from student.models import unique_id_for_user +from student.models import anonymous_id_for_user from util.cache import cache from .staff_grading_service import StaffGradingService @@ -33,7 +33,7 @@ def staff_grading_notifications(course, user): pending_grading = False img_path = "" course_id = course.id - student_id = unique_id_for_user(user, course_id) + student_id = anonymous_id_for_user(user, course_id) notification_type = "staff" success, notification_dict = get_value_from_cache(student_id, course_id, notification_type) @@ -74,7 +74,7 @@ def peer_grading_notifications(course, user): pending_grading = False img_path = "" course_id = course.id - student_id = unique_id_for_user(user, course_id) + student_id = anonymous_id_for_user(user, course_id) notification_type = "peer" success, notification_dict = get_value_from_cache(student_id, course_id, notification_type) @@ -132,7 +132,7 @@ def combined_notifications(course, user): ) #Initialize controller query service using our mock system controller_qs = ControllerQueryService(settings.OPEN_ENDED_GRADING_INTERFACE, system) - student_id = unique_id_for_user(user, course.id) + student_id = anonymous_id_for_user(user, course.id) user_is_staff = has_access(user, course, 'staff') course_id = course.id notification_type = "combined" diff --git a/lms/djangoapps/open_ended_grading/staff_grading_service.py b/lms/djangoapps/open_ended_grading/staff_grading_service.py index 9d93b0e8d074..ef72fc7c3541 100644 --- a/lms/djangoapps/open_ended_grading/staff_grading_service.py +++ b/lms/djangoapps/open_ended_grading/staff_grading_service.py @@ -14,7 +14,7 @@ from courseware.access import has_access from lms.lib.xblock.runtime import LmsModuleSystem from mitxmako.shortcuts import render_to_string -from student.models import unique_id_for_user +from student.models import anonymous_id_for_user from util.json_request import expect_json from open_ended_grading.utils import does_location_exist @@ -235,7 +235,7 @@ def get_next(request, course_id): if len(missing) > 0: return _err_response('Missing required keys {0}'.format( ', '.join(missing))) - grader_id = unique_id_for_user(request.user, course_id) + grader_id = anonymous_id_for_user(request.user, course_id) p = request.POST location = p['location'] @@ -267,7 +267,7 @@ def get_problem_list(request, course_id): """ _check_access(request.user, course_id) try: - response = staff_grading_service().get_problem_list(course_id, unique_id_for_user(request.user, course_id)) + response = staff_grading_service().get_problem_list(course_id, anonymous_id_for_user(request.user, course_id)) response = json.loads(response) # If 'problem_list' is in the response, then we got a list of problems from the ORA server. @@ -352,7 +352,7 @@ def save_grade(request, course_id): return _err_response('Missing required keys {0}'.format( ', '.join(missing))) - grader_id = unique_id_for_user(request.user, course_id) + grader_id = anonymous_id_for_user(request.user, course_id) location = p['location'] diff --git a/lms/djangoapps/open_ended_grading/tests.py b/lms/djangoapps/open_ended_grading/tests.py index 0dc69e605f28..eeea30d5ebb1 100644 --- a/lms/djangoapps/open_ended_grading/tests.py +++ b/lms/djangoapps/open_ended_grading/tests.py @@ -28,7 +28,7 @@ from lms.lib.xblock.runtime import LmsModuleSystem from courseware.roles import CourseStaffRole from mitxmako.shortcuts import render_to_string -from student.models import unique_id_for_user +from student.models import anonymous_id_for_user from open_ended_grading import staff_grading_service, views, utils @@ -455,7 +455,7 @@ def test_get_problem_list(self): Mock the get_grading_status_list function using StudentProblemListMockQuery. """ # Initialize a StudentProblemList object. - student_problem_list = utils.StudentProblemList(self.course.id, unique_id_for_user(self.user, self.course.id)) + student_problem_list = utils.StudentProblemList(self.course.id, anonymous_id_for_user(self.user, self.course.id)) # Get the initial problem list from ORA. success = student_problem_list.fetch_from_grading_service() # Should be successful, and we should have three problems. See mock class for details. diff --git a/lms/djangoapps/open_ended_grading/utils.py b/lms/djangoapps/open_ended_grading/utils.py index bfde229e9c85..eb0573545284 100644 --- a/lms/djangoapps/open_ended_grading/utils.py +++ b/lms/djangoapps/open_ended_grading/utils.py @@ -89,7 +89,7 @@ class StudentProblemList(object): def __init__(self, course_id, user_id): """ @param course_id: The id of a course object. Get using course.id. - @param user_id: The anonymous id of the user, from the unique_id_for_user function. + @param user_id: The anonymous id of the user, from the anonymous_id_for_user function. """ self.course_id = course_id self.user_id = user_id diff --git a/lms/djangoapps/open_ended_grading/views.py b/lms/djangoapps/open_ended_grading/views.py index 2d9512ed44f3..9ee6c55d924a 100644 --- a/lms/djangoapps/open_ended_grading/views.py +++ b/lms/djangoapps/open_ended_grading/views.py @@ -5,12 +5,12 @@ from mitxmako.shortcuts import render_to_response from django.core.urlresolvers import reverse -from student.models import unique_id_for_user +from student.models import anonymous_id_for_user from courseware.courses import get_course_with_access from xmodule.open_ended_grading_classes.grading_service_module import GradingServiceError import json -from student.models import unique_id_for_user +from student.models import anonymous_id_for_user import open_ended_notifications @@ -149,7 +149,7 @@ def student_problem_list(request, course_id): course = get_course_with_access(request.user, course_id, 'load') # The anonymous student id is needed for communication with ORA. - student_id = unique_id_for_user(request.user, course_id) + student_id = anonymous_id_for_user(request.user, course_id) base_course_url = reverse('courses') error_text = "" @@ -186,7 +186,7 @@ def flagged_problem_list(request, course_id): Show a student problem list ''' course = get_course_with_access(request.user, course_id, 'staff') - student_id = unique_id_for_user(request.user, course_id) + student_id = anonymous_id_for_user(request.user, course_id) # call problem list service success = False From cdbba285867db40705801e60713e85abd4a643d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Sun, 24 Nov 2013 18:20:03 +0200 Subject: [PATCH 26/87] Changes self.user to key.user_id. --- lms/djangoapps/courseware/model_data.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/courseware/model_data.py b/lms/djangoapps/courseware/model_data.py index da5d639bf035..2a0bd947d052 100644 --- a/lms/djangoapps/courseware/model_data.py +++ b/lms/djangoapps/courseware/model_data.py @@ -226,10 +226,13 @@ def find_or_create(self, key): if field_object is not None: return field_object + if not self.user.is_anonymous(): + assert key.user_id == self.user.id + if key.scope == Scope.user_state: field_object, _ = StudentModule.objects.get_or_create( course_id=self.course_id, - student=self.user, + student=key.user_id, module_state_key=key.block_scope_id.url(), defaults={ 'state': json.dumps({}), @@ -245,12 +248,12 @@ def find_or_create(self, key): field_object, _ = XModuleStudentPrefsField.objects.get_or_create( field_name=key.field_name, module_type=key.block_scope_id, - student=self.user, + student=key.user_id, ) elif key.scope == Scope.user_info: field_object, _ = XModuleStudentInfoField.objects.get_or_create( field_name=key.field_name, - student=self.user, + student=key.user_id, ) cache_key = self._cache_key_from_kvs_key(key) From be1a2c9d0fe106ee007792b5ea951c790b56baa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Sun, 24 Nov 2013 19:00:11 +0200 Subject: [PATCH 27/87] Simplify LTI code. --- common/lib/xmodule/xmodule/lti_module.py | 92 +++++++++------------- lms/djangoapps/courseware/module_render.py | 9 --- 2 files changed, 39 insertions(+), 62 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index e235ee17d73a..927b9b824e6e 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -399,8 +399,6 @@ def grade_handler(self, request, dispatch): Example of correct/incorrect answer xml body:: see response_xml_template - - TODO: add test for xml escaping """ response_xml_template = textwrap.dedent(""" @@ -428,10 +426,17 @@ def grade_handler(self, request, dispatch): 'response': '' } try: - imsx_messageIdentifier, sourcedId, score, action = self.descriptor.parse_grade_xml_body(request.body) - except: + imsx_messageIdentifier, sourcedId, score, action = self.parse_grade_xml_body(request.body) + except Exception: return Response(response_xml_template.format(**unsupported_values), content_type="application/xml") + # verify oauth signing + try: + self.verify_oauth_body_sign(request) + except LTIError: + return Response(response_xml_template.format(**unsupported_values), content_type="application/xml") + + real_user = self.system.get_real_user(urllib.unquote(sourcedId.split(':')[-1])) if action == 'replaceResultRequest': self.system.publish( @@ -455,14 +460,6 @@ def grade_handler(self, request, dispatch): return Response(response_xml_template.format(**unsupported_values), content_type='application/xml') -class LTIModuleDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRawDescriptor): - """ - Descriptor for LTI Xmodule. - """ - has_score = True - module_class = LTIModule - grade_handler = module_attr('grade_handler') - @classmethod def parse_grade_xml_body(cls, body): """ @@ -476,7 +473,6 @@ def parse_grade_xml_body(cls, body): """ data = body.strip().encode('utf-8') parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') - # get data from request, root = etree.fromstring(data, parser=parser) namespaces = {'def': root.nsmap.values()[0]} imsx_messageIdentifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text @@ -485,74 +481,64 @@ def parse_grade_xml_body(cls, body): action = root.xpath("//def:imsx_POXBody", namespaces=namespaces)[0].getchildren()[0].tag.replace('{'+root.nsmap.values()[0]+'}', '') return imsx_messageIdentifier, sourcedId, score, action - - def authenticate(self, request, course): + def verify_oauth_body_sign(self, request): """ - Verify request using OAuth body signing. + 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. + + Args: + request: DjangoWebobRequest. + + Raises: LTIError if request is incorrect. """ - # self course_id produces runtime assertion error + # this part will be removed as Inheritance PR will be meged + from courseware.courses import get_course_by_id + course = get_course_by_id(self.course_id) # Obtains client_key and client_secret credentials from current course: client_key = client_secret = '' for lti_passport in course.lti_passports: try: lti_id, key, secret = [i.strip() for i in lti_passport.split(':')] - except ValueError: + except ValueError: # do log here instead of raising exception raise LTIError('Could not parse LTI passport: {0!r}. \ Should be "id:key:secret" string.'.format(lti_passport)) if lti_id == self.lti_id.strip(): client_key, client_secret = key, secret break - #verify oauth signing - mock_request = mock.Mock() - - try: - authorization_header = request.META['HTTP_AUTHORIZATION'] - except KeyError: - return None, False - headers = { - 'Authorization':unicode(authorization_header), + 'Authorization':unicode(request.headers.get('Authorization')), 'Content-Type': 'application/x-www-form-urlencoded', } - #get request body and calculate body hash sha1 = hashlib.sha1() sha1.update(request.body) oauth_body_hash = base64.b64encode(sha1.hexdigest()) - mock_request.params = signature.collect_parameters(headers=headers) - - oauth_headers = dict(mock_request.params) - - #compare hash from request body and body hash from Authorization header - if oauth_body_hash != oauth_headers.get('oauth_body_hash'): - return None, False - - mock_request.uri = unicode(request.build_absolute_uri()) - mock_request.http_method = unicode(request.method) - oauth_params = signature.collect_parameters(headers=headers, exclude_oauth_signature=False) oauth_headers =dict(oauth_params) - client_signature = oauth_headers.get('oauth_signature') - mock_request.signature = unicode(client_signature) + oauth_signature = oauth_headers.pop('oauth_signature') - correct_request = signature.verify_hmac_sha1(mock_request, client_secret) - if correct_request: - try: - __, sourcedId, __, __ = self.parse_grade_xml_body(request.body) - anonymous_user_id = urllib.unquote(sourcedId.split(':')[-1]) - except Exception: - # should return response_xml_template.format(**unsupported_values), "application/xml" - return None, False - return anonymous_user_id, True - else: - # should return response_xml_template.format(**unsupported_values), "application/xml" - return None, False + mock_request = mock.Mock( + uri=unicode(request.url), + http_method=unicode(request.method), + params=oauth_headers.items(), + signature=oauth_signature + ) + if (oauth_body_hash != oauth_headers.get('oauth_body_hash') or + not signature.verify_hmac_sha1(mock_request, client_secret)): + raise LTIError + +class LTIModuleDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRawDescriptor): + """ + Descriptor for LTI Xmodule. + """ + has_score = True + module_class = LTIModule + grade_handler = module_attr('grade_handler') diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index ed5e8b8e351f..21fbe1a58214 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -302,7 +302,6 @@ def publish(event, custom_user=None): block_scope_id=descriptor.location, field_name='grade' ) - import ipdb; ipdb.set_trace() student_module = field_data_cache.find_or_create(key) # Update the grades student_module.grade = event.get('value') @@ -545,14 +544,6 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user): ) raise Http404 - from courseware.courses import get_course_by_id - course = get_course_by_id(course_id) - anonymous_user_id, status = descriptor.authenticate(request, course) - if not status: - raise PermissionDenied - from student.models import user_by_anonymous_id - user = user_by_anonymous_id(anonymous_user_id) - field_data_cache = FieldDataCache.cache_for_descriptor_descendents( course_id, user, From 1ece16307975c5d1d58cd66c3bcc9a544d1bf077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Sun, 24 Nov 2013 19:01:35 +0200 Subject: [PATCH 28/87] Removed get_handler_url from x_module. --- common/lib/xmodule/xmodule/x_module.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index fdd9d160775d..787af4a6cb06 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -1070,12 +1070,6 @@ def ajax_url(self): """ return self.handler_url(self.xmodule_instance, 'xmodule_handler', '', '').rstrip('/?') - # def get_handler_url(self, handler_name): - # """ - # The url prefix to be used by XModules to call into handler_name - # """ - # return self.handler_url(self.xmodule_instance, handler_name, '', '').rstrip('/?') - class DoNothingCache(object): """A duck-compatible object to use in ModuleSystem when there's no cache.""" From 402816779211785d7a39d3b59b1ea9b55cd92c62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Sun, 24 Nov 2013 19:10:36 +0200 Subject: [PATCH 29/87] Fix comments and clean-up. --- common/djangoapps/student/models.py | 3 ++- common/djangoapps/student/views.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 49d57bcd8dfb..c65a82b660bc 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -69,10 +69,11 @@ def _anonymous_id_for_user(user, course_id): h.update(course_id) return h.hexdigest() + # This part is for ability to get xblock instance in noauth xblock handlers, + # where user is anauthenticated. if user.is_anonymous(): return 'Anonymous' - # import ipdb; ipdb.set_trace() return AnonymousUsers.objects.get_or_create( user=user, course_id=course_id, diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index ccb6695fd044..43f77c29c300 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -152,7 +152,7 @@ def press(request): def process_survey_link(survey_link, user, course_id): """ If {UNIQUE_ID} appears in the link, replace it with a unique id for the user. - Currently, this is sha1(user.username). Otherwise, return survey_link. + Currently, this is sha1() updated with secret_key, user.id and course.id. Otherwise, return survey_link. """ return survey_link.format(UNIQUE_ID=anonymous_id_for_user(user, course_id)) From b6285cb474db28350881f1067f3a70a7131c61ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Mon, 25 Nov 2013 11:43:53 +0200 Subject: [PATCH 30/87] Change hash of xblock repo. --- requirements/edx/github.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 8f5d4a189a62..ce94e3b28ec2 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -15,7 +15,7 @@ -e git+https://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk # Our libraries: --e git+https://github.com/edx/XBlock.git@9a5dbfc76b657c2c05535ce344e3006d4d134830#egg=XBlock +-e git+https://github.com/edx/XBlock.git@7e387ce17e4421434fce549fca2c3ceb2bc7#egg=XBlock -e git+https://github.com/edx/codejail.git@0a1b468#egg=codejail -e git+https://github.com/edx/diff-cover.git@v0.2.6#egg=diff_cover -e git+https://github.com/edx/js-test-tool.git@v0.1.4#egg=js_test_tool From edec45a269aa52b202869ae3adf9bf290940030c Mon Sep 17 00:00:00 2001 From: polesye Date: Mon, 25 Nov 2013 12:18:54 +0200 Subject: [PATCH 31/87] Add url.quote. --- common/lib/xmodule/xmodule/lti_module.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 927b9b824e6e..15091b8a1758 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -246,7 +246,7 @@ def get_html(self): def get_user_id(self): user_id = self.runtime.anonymous_student_id assert user_id is not None - return user_id + return unicode(urllib.quote(user_id)) def get_outcome_service_url(self): """ @@ -269,7 +269,7 @@ def get_resource_link_id(self): context and imported into another system or context. This parameter is required. """ - return unicode(self.id) + return unicode(urllib.quote(self.id)) def get_lis_result_sourcedid(self): """ @@ -284,7 +284,7 @@ def get_lis_result_sourcedid(self): the link being launched. lti_id should be context_id by meaning """ - return ':'.join(urllib.quote(i) for i in (self.lti_id, self.get_resource_link_id(), self.get_user_id())) + return u':'.join(urllib.quote(i) for i in (self.lti_id, self.get_resource_link_id(), self.get_user_id())) def oauth_params(self, custom_parameters, client_key, client_secret): From 81de246f1aea0037c667ba41afab54b182a42b17 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Sat, 23 Nov 2013 20:24:31 -0500 Subject: [PATCH 32/87] cherry-picked 5bfd2976961ac0dfd7220ff994157a68fe44b2b4. --- requirements/edx/github.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index ce94e3b28ec2..dc36112e2387 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -15,7 +15,7 @@ -e git+https://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk # Our libraries: --e git+https://github.com/edx/XBlock.git@7e387ce17e4421434fce549fca2c3ceb2bc7#egg=XBlock +-e git+https://github.com/edx/XBlock.git@7e387ce17e4421434fce549fca2c3ceb2bc769ee#egg=XBlock -e git+https://github.com/edx/codejail.git@0a1b468#egg=codejail -e git+https://github.com/edx/diff-cover.git@v0.2.6#egg=diff_cover -e git+https://github.com/edx/js-test-tool.git@v0.1.4#egg=js_test_tool From d9c9eeeee76965c65eb03c2524cf942d4af6dfa2 Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Mon, 25 Nov 2013 12:35:00 +0200 Subject: [PATCH 33/87] URL resource for CodeMajor/Severity Interpretation Matrix error/status messages. --- common/lib/xmodule/xmodule/lti_module.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 15091b8a1758..14ead676577d 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -4,6 +4,9 @@ Protocol is oauth1, LTI version is 1.1.1: http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html +CodeMajor/Severity Interpretation Matrix +http://www.imsglobal.org/es/esv1p0/imscommon_infov1p0.html#1589069 + play and test: http://www.imsglobal.org/developers/LTI/test/v1p1/lms.php """ From 3dc4df0016f179d5dd9d9c8d747aefd707b02d68 Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Mon, 25 Nov 2013 12:44:06 +0200 Subject: [PATCH 34/87] Minor corrections to code comments. --- common/lib/xmodule/xmodule/lti_module.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 14ead676577d..9908d914e04d 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -71,7 +71,7 @@ class LTIModule(LTIFields, XModule): ''' Module provides LTI integration to course. - Except usual xmodule structure it proceeds with oauth signing. + Except usual Xmodule structure it proceeds with OAuth signing. How it works:: 1. Get credentials from course settings. @@ -88,14 +88,14 @@ class LTIModule(LTIFields, XModule): role *+ all custom parameters* - These parameters should be encoded and signed by *oauth1* together with + 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. + After that signature and other OAuth data are generated. - Oauth data which is generated after signing is usual:: + OAuth data which is generated after signing is usual:: oauth_callback oauth_nonce @@ -106,7 +106,7 @@ class LTIModule(LTIFields, XModule): 4. All that data is passed to form and sent to LTI provider server by browser via - autosubmit via javascript. + autosubmit via JavaScript. Form example:: @@ -140,7 +140,7 @@ class LTIModule(LTIFields, XModule): - 5. LTI provider has same secret key and it signs data string via *oauth1* and compares signatures. + 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. @@ -292,7 +292,7 @@ def get_lis_result_sourcedid(self): def oauth_params(self, custom_parameters, client_key, client_secret): """ - Signs request and returns signature and oauth parameters. + 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. @@ -372,7 +372,7 @@ def grade_handler(self, request, dispatch): """ This is called by courseware.module_render, to handle an AJAX call. - Used only for grading. Returns XML reponse + Used only for grading. Returns XML response. Example of request body from LTI provider:: @@ -401,7 +401,7 @@ def grade_handler(self, request, dispatch): - Example of correct/incorrect answer xml body:: see response_xml_template + Example of correct/incorrect answer XML body:: see response_xml_template """ response_xml_template = textwrap.dedent(""" From d7f14c26e61ad62c42279cc52d0e8b6266e7a035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Mon, 25 Nov 2013 13:00:37 +0200 Subject: [PATCH 35/87] Fixes user in model_data and unquote url from webob request. --- common/lib/xmodule/xmodule/lti_module.py | 2 +- lms/djangoapps/courseware/model_data.py | 7 ++++--- lms/djangoapps/courseware/module_render.py | 1 + 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 15091b8a1758..b9cdeea0e1f1 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -525,7 +525,7 @@ def verify_oauth_body_sign(self, request): oauth_signature = oauth_headers.pop('oauth_signature') mock_request = mock.Mock( - uri=unicode(request.url), + uri=unicode(urllib.unquote(request.url)), http_method=unicode(request.method), params=oauth_headers.items(), signature=oauth_signature diff --git a/lms/djangoapps/courseware/model_data.py b/lms/djangoapps/courseware/model_data.py index 2a0bd947d052..0a914c114fba 100644 --- a/lms/djangoapps/courseware/model_data.py +++ b/lms/djangoapps/courseware/model_data.py @@ -14,6 +14,7 @@ import logging from django.db import DatabaseError +from django.contrib.auth.models import User from xblock.runtime import KeyValueStore from xblock.exceptions import KeyValueMultiSaveError, InvalidScopeError @@ -232,7 +233,7 @@ def find_or_create(self, key): if key.scope == Scope.user_state: field_object, _ = StudentModule.objects.get_or_create( course_id=self.course_id, - student=key.user_id, + student=User.objects.get(id=key.user_id), module_state_key=key.block_scope_id.url(), defaults={ 'state': json.dumps({}), @@ -248,12 +249,12 @@ def find_or_create(self, key): field_object, _ = XModuleStudentPrefsField.objects.get_or_create( field_name=key.field_name, module_type=key.block_scope_id, - student=key.user_id, + student=User.objects.get(id=key.user_id), ) elif key.scope == Scope.user_info: field_object, _ = XModuleStudentInfoField.objects.get_or_create( field_name=key.field_name, - student=key.user_id, + student=User.objects.get(id=key.user_id), ) cache_key = self._cache_key_from_kvs_key(key) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 21fbe1a58214..e3ab265e5454 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -302,6 +302,7 @@ def publish(event, custom_user=None): block_scope_id=descriptor.location, field_name='grade' ) + student_module = field_data_cache.find_or_create(key) # Update the grades student_module.grade = event.get('value') From 8fdbcdb878cde7fa84e774ebfe6c37991276bde4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Mon, 25 Nov 2013 13:20:58 +0200 Subject: [PATCH 36/87] Mock LTI server return TC response body. --- lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py index f7748e07fd2d..bbe8f3d779c9 100644 --- a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py +++ b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py @@ -63,7 +63,7 @@ def do_POST(self): ''' # Respond to grade request if 'grade' in self.path and self._send_graded_result().status_code == 200: - status_message = "I have stored grades." + status_message = 'LTI consumer (edX) responsed with XML content:
' + self.server.grade_data['TC answer'] self.server.grade_data['callback_url'] = None # Respond to request with correct lti endpoint: elif self._is_correct_lti_request(): @@ -196,7 +196,7 @@ def _send_graded_result(self): data=data, headers=headers ) - # assert response.status_code == 200 + self.server.grade_data['TC answer'] = response.content return response def _send_response(self, message): From 0b8d42fa9ef929c928d25746d9ba9d1641d1a3b0 Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Mon, 25 Nov 2013 13:25:24 +0200 Subject: [PATCH 37/87] Fine tuning responses. --- common/lib/xmodule/xmodule/lti_module.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 86de6c0cb41e..afbf1360895e 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -423,8 +423,8 @@ def grade_handler(self, request, dispatch): """) unsupported_values = { - 'imsx_codeMajor': 'unsupported', - 'imsx_description': 'Only replaceResult is supported', + 'imsx_codeMajor': 'Unsupported', + 'imsx_description': 'Target does not support the requested operation.', 'imsx_messageIdentifier': 'unknown', 'response': '' } @@ -452,7 +452,7 @@ def grade_handler(self, request, dispatch): ) values = { - 'imsx_codeMajor': 'success', + 'imsx_codeMajor': 'Success', 'imsx_description': 'Score for {sourced_id} is now {score}'.format(sourced_id=sourcedId, score=score), 'imsx_messageIdentifier': imsx_messageIdentifier, 'response': '' From 45370a92330ff5a24ba4372d050723b28302a447 Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Mon, 25 Nov 2013 13:51:36 +0200 Subject: [PATCH 38/87] Adding initial documentation for LTI module. --- .../source/course_data_formats/lti_module/lti.rst | 11 +++++++++++ docs/data/source/index.rst | 1 + 2 files changed, 12 insertions(+) create mode 100644 docs/data/source/course_data_formats/lti_module/lti.rst diff --git a/docs/data/source/course_data_formats/lti_module/lti.rst b/docs/data/source/course_data_formats/lti_module/lti.rst new file mode 100644 index 000000000000..55c77a2a28b8 --- /dev/null +++ b/docs/data/source/course_data_formats/lti_module/lti.rst @@ -0,0 +1,11 @@ +********************************************** +Xml format of LTI module [xmodule] +********************************************** + +.. module:: lti_module + +Format description +================== + +The LTI XModule is based on the IMS Global Learning Tools Interoperability +Version 1.1.1 specifications. diff --git a/docs/data/source/index.rst b/docs/data/source/index.rst index d1efb4690257..7c1a1512f3ae 100644 --- a/docs/data/source/index.rst +++ b/docs/data/source/index.rst @@ -25,6 +25,7 @@ Specific Problem Types course_data_formats/drag_and_drop/drag_and_drop_input.rst course_data_formats/graphical_slider_tool/graphical_slider_tool.rst course_data_formats/poll_module/poll_module.rst + course_data_formats/lti_module/lti.rst course_data_formats/conditional_module/conditional_module.rst course_data_formats/word_cloud/word_cloud.rst course_data_formats/custom_response.rst From 6b104ea1f9b040bdfd8eab3991cc272c28cf376e Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Mon, 25 Nov 2013 13:56:02 +0200 Subject: [PATCH 39/87] Added an entry to CHANGELOG.rst file. --- CHANGELOG.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e0b6b4d76a75..5b0ea22d78cc 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,9 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +Blades: Added grading support for LTI module. LTI providers can now grade +student's work and send edX scores. OAuth1 based authentication implemented. + Blades: Put 2nd "Hide output" button at top of test box & increase text size for code response questions. BLD-126. From c1d5b85b39139e82077e6de3319920579c735067 Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Mon, 25 Nov 2013 14:08:21 +0200 Subject: [PATCH 40/87] Included Jira issue in changelog. --- CHANGELOG.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5b0ea22d78cc..ab708989e782 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,7 +6,8 @@ in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. Blades: Added grading support for LTI module. LTI providers can now grade -student's work and send edX scores. OAuth1 based authentication implemented. +student's work and send edX scores. OAuth1 based authentication +implemented. BLD-384. Blades: Put 2nd "Hide output" button at top of test box & increase text size for code response questions. BLD-126. From c1bcc2d0d032919de4cadb9fedc2c611cab42211 Mon Sep 17 00:00:00 2001 From: polesye Date: Mon, 25 Nov 2013 16:10:16 +0200 Subject: [PATCH 41/87] Add integration test. --- .../courseware/features/lti.feature | 34 +++++-- lms/djangoapps/courseware/features/lti.py | 88 ++++++++++--------- 2 files changed, 74 insertions(+), 48 deletions(-) diff --git a/lms/djangoapps/courseware/features/lti.feature b/lms/djangoapps/courseware/features/lti.feature index 126ddf2b2b41..7f27a65b5a1b 100644 --- a/lms/djangoapps/courseware/features/lti.feature +++ b/lms/djangoapps/courseware/features/lti.feature @@ -5,36 +5,54 @@ Feature: LMS.LTI component #1 Scenario: LTI component in LMS with no launch_url is not rendered Given the course has correct LTI credentials - And the course has an LTI component with no_launch_url fields, new_page is false, is_graded is false + And the course has an LTI component with no_launch_url fields: + | open_in_a_new_page | + | False | Then I view the LTI and error is shown #2 Scenario: LTI component in LMS with incorrect lti_id is rendered incorrectly Given the course has correct LTI credentials - And the course has an LTI component with incorrect_lti_id fields, new_page is false, is_graded is false + And the course has an LTI component with incorrect_lti_id fields: + | open_in_a_new_page | + | False | Then I view the LTI but incorrect_signature warning is rendered #3 Scenario: LTI component in LMS is rendered incorrectly Given the course has incorrect LTI credentials - And the course has an LTI component with correct fields, new_page is false, is_graded is false + And the course has an LTI component with correct fields: + | open_in_a_new_page | + | False | Then I view the LTI but incorrect_signature warning is rendered #4 Scenario: LTI component in LMS is correctly rendered in new page Given the course has correct LTI credentials - And the course has an LTI component with correct fields, new_page is true, is_graded is false + And the course has an LTI component with correct fields Then I view the LTI and it is rendered in new page #5 Scenario: LTI component in LMS is correctly rendered in iframe Given the course has correct LTI credentials - And the course has an LTI component with correct fields, new_page is false, is_graded is false + And the course has an LTI component with correct fields: + | open_in_a_new_page | + | False | Then I view the LTI and it is rendered in iframe #6 Scenario: Graded LTI component in LMS is correctly works Given the course has correct LTI credentials - And the course has an LTI component with correct fields, new_page is false, is_graded is true - And I click on Grade link - Then I wiew result in Progress page + And the course has an LTI component with correct fields: + | open_in_a_new_page | weight | is_graded | + | False | 10 | True | + And I submit answer to LTI question + And I click on the "Progress" tab + Then I see text "Problem Scores: 5/10" + And I see graph with total progress "5%" + Then I click on the "Instructor" tab + And I click on the "Gradebook" tab + And I see in the gradebook table that "HW" is "50" + And I see in the gradebook table that "Total" is "5" + + diff --git a/lms/djangoapps/courseware/features/lti.py b/lms/djangoapps/courseware/features/lti.py index 1e2338ca8ef7..06d2ff87fdda 100644 --- a/lms/djangoapps/courseware/features/lti.py +++ b/lms/djangoapps/courseware/features/lti.py @@ -87,40 +87,34 @@ def set_incorrect_lti_passport(_step): } i_am_registered_for_the_course(coursenum, metadata) -@step('the course has an LTI component with (.*) fields, new_page is(.*), is_graded is(.*)$') -def add_correct_lti_to_course(_step, fields, new_page, is_graded): +@step('the course has an LTI component with (.*) fields(?:\:)?$') #, new_page is(.*), is_graded is(.*) +def add_correct_lti_to_course(_step, fields): category = 'lti' - lti_id = 'correct_lti_id' - launch_url = world.lti_server.oauth_settings['lti_base'] + world.lti_server.oauth_settings['lti_endpoint'] + metadata = { + 'lti_id': 'correct_lti_id', + 'launch_url': world.lti_server.oauth_settings['lti_base'] + world.lti_server.oauth_settings['lti_endpoint'], + } if fields.strip() == 'incorrect_lti_id': # incorrect fields - lti_id = 'incorrect_lti_id' + metadata.update({ + 'lti_id': 'incorrect_lti_id' + }) elif fields.strip() == 'correct': # correct fields pass elif fields.strip() == 'no_launch_url': - launch_url = u'' + metadata.update({ + 'launch_url': u'' + }) else: # incorrect parameter assert False - if new_page.strip().lower() == 'false': - new_page = False - else: # default is True - new_page = True - - if is_graded.strip().lower() == 'false': - is_graded = False - else: # default is True - is_graded = True + if _step.hashes: + metadata.update(_step.hashes[0]) world.scenario_dict['LTI'] = world.ItemFactory.create( parent_location=world.scenario_dict['SEQUENTIAL'].location, category=category, display_name='LTI', - metadata={ - 'lti_id': lti_id, - 'launch_url': launch_url, - 'open_in_a_new_page': new_page, - 'is_graded': is_graded, - } + metadata=metadata, ) setattr(world.scenario_dict['LTI'], 'TEST_BASE_PATH', '{host}:{port}'.format( @@ -150,7 +144,7 @@ def create_course(course, metadata): # This also ensures that the necessary templates are loaded world.clear_courses() - weight = 0.15 + weight = 0.1 grading_policy = { "GRADER": [ { @@ -189,12 +183,12 @@ def create_course(course, metadata): world.scenario_dict['SECTION'] = world.ItemFactory.create( parent_location=world.scenario_dict['COURSE'].location, display_name='Test Section', - metadata={'graded': True, 'format': 'Homework'} ) world.scenario_dict['SEQUENTIAL'] = world.ItemFactory.create( parent_location=world.scenario_dict['SECTION'].location, category='sequential', - display_name='Test Section') + display_name='Test Section', + metadata={'graded': True, 'format': 'Homework'}) def i_am_registered_for_the_course(course, metadata): @@ -234,28 +228,42 @@ def check_lti_popup(): world.browser.driver.close() # Close the pop-up window world.browser.switch_to_window(parent_window) # Switch to the main window again -@step('I open gradebook$') -def check_gradebook(_step): - location = world.scenario_dict['LTI'].location.html_id() - iframe_name = 'ltiLaunchFrame-' + location - with world.browser.get_iframe(iframe_name) as iframe: - iframe.find_by_css('a')[0].click() - world.click_link('Instructor') - world.click_link('Gradebook') - assert world.is_css_present('.grade-table') +@step('I see text "([^"]*)"$') +def check_progress(_step, text): + assert world.browser.is_text_present(text) + + +@step('I see graph with total progress "([^"]*)"$') +def see_graph(_step, progress): + SELECTOR = 'grade-detail-graph' + node = world.browser.find_by_xpath('//div[@id="{parent}"]//div[text()="{progress}"]'.format( + parent=SELECTOR, + progress=progress, + )) + + assert node + + +@step('I see in the gradebook table that "([^"]*)" is "([^"]*)"$') +def see_value_in_the_gradebook(_step, label, text): + TABLE_SELECTOR = '.grade-table' + index = 0 + table_headers = world.css_find('{0} thead th'.format(TABLE_SELECTOR)) + + for i, element in enumerate(table_headers): + if element.text.strip() == label: + index = i + break; + + assert world.css_has_text('{0} tbody td'.format(TABLE_SELECTOR), text, index=index) -@step('I wiew result in Progress page$') -def check_progress(_step): - world.click_link('Progress') - assert world.browser.is_text_present('Problem Scores: 0.99/1') -@step('I click on Grade link$') -def check_progress(_step): +@step('I submit answer to LTI question$') +def click_grade(_step): location = world.scenario_dict['LTI'].location.html_id() iframe_name = 'ltiLaunchFrame-' + location with world.browser.get_iframe(iframe_name) as iframe: iframe.find_by_name('submit-button').first.click() - # This test waits no matter how long the text will appear. Timeouts? assert iframe.is_text_present('I have stored grades.') From 975e8631b607460aa59c2fb73b43ba4f783cbaed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Mon, 25 Nov 2013 16:30:18 +0200 Subject: [PATCH 42/87] Add unique together to anonymous lookup table. --- common/djangoapps/student/models.py | 1 + lms/djangoapps/courseware/model_data.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index c65a82b660bc..11e9b0160a89 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -55,6 +55,7 @@ class AnonymousUsers(models.Model): user = models.ForeignKey(User, db_index=True, related_name='anonymous') anonymous_user_id = models.CharField(unique=True, max_length=16) course_id = models.CharField(db_index=True, max_length=255) + unique_together = (user, course_id) def anonymous_id_for_user(user, course_id): diff --git a/lms/djangoapps/courseware/model_data.py b/lms/djangoapps/courseware/model_data.py index 0a914c114fba..9ed063c8b20d 100644 --- a/lms/djangoapps/courseware/model_data.py +++ b/lms/djangoapps/courseware/model_data.py @@ -351,7 +351,7 @@ def set_many(self, kv_dict): # the list of successful saves saved_fields.extend([field.field_name for field in field_objects[field_object]]) except DatabaseError: - log.error('Error saving fields %r', field_objects[field_object]) + log.exception('Error saving fields %r', field_objects[field_object]) raise KeyValueMultiSaveError(saved_fields) def delete(self, key): From 03daa648b26cadf16fe30282399283e61cfe34cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Mon, 25 Nov 2013 16:34:25 +0200 Subject: [PATCH 43/87] Use default in get_or_create in lookup table. --- common/djangoapps/student/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 11e9b0160a89..e36cfe63cf7a 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -76,9 +76,9 @@ def _anonymous_id_for_user(user, course_id): return 'Anonymous' return AnonymousUsers.objects.get_or_create( + defaults={'anonymous_user_id': _anonymous_id_for_user(user, course_id)}, user=user, - course_id=course_id, - anonymous_user_id=_anonymous_id_for_user(user, course_id) + course_id=course_id )[0].anonymous_user_id From 0b667130331500c3ee0a5cb38618121039539965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Mon, 25 Nov 2013 16:57:23 +0200 Subject: [PATCH 44/87] Remove obsolete mocking. --- common/djangoapps/student/tests/tests.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index ba0e858897ab..71e7b9e3e98d 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -28,7 +28,7 @@ from mock import Mock, patch, sentinel from textwrap import dedent -from student.models import anonymous_id_for_user, simple_anonymous_id_for_user, CourseEnrollment +from student.models import anonymous_id_for_user, CourseEnrollment from student.views import (process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper, change_enrollment, complete_course_mode_info) from student.tests.factories import UserFactory, CourseModeFactory @@ -137,27 +137,27 @@ def test_reset_password_good_token(self, reset_confirm): class CourseEndingTest(TestCase): """Test things related to course endings: certificates, surveys, etc""" - @patch('student.views.anonymous_id_for_user') - def test_process_survey_link(self, mock_anonymous_id_for_user): + def test_process_survey_link(self): course = Mock(id="test_id") user = Mock(username="fred", id="test_user_id") - mock_anonymous_id_for_user.return_value = simple_anonymous_id_for_user(user) link1 = "http://www.mysurvey.com" self.assertEqual(process_survey_link(link1, user, course.id), link1) link2 = "http://www.mysurvey.com?unique={UNIQUE_ID}" - link2_expected = "http://www.mysurvey.com?unique={UNIQUE_ID}".format(UNIQUE_ID=simple_anonymous_id_for_user(user)) + + # anonymous_id_for_user returns 'Anonymous' because user is not authenticated. + link2_expected = "http://www.mysurvey.com?unique={UNIQUE_ID}".format(UNIQUE_ID='Anonymous') self.assertEqual(process_survey_link(link2, user, course.id), link2_expected) - # patching student.views.anonymous_id_for_user, not student.models.anonymous_id_for_user, - # look at http://www.voidspace.org.uk/python/mock/patch.html#where-to-patch for explanation. - @patch('student.views.anonymous_id_for_user') - def test_cert_info(self, mock_anonymous_id_for_user): + def test_cert_info(self): + """ + _cert_info() calls process_survey_link(), process_survey_link() calls anonymous_id_for_user() + anonymous_id_for_user() returns 'Anonymous' because user is not authenticated. + """ user = Mock(username="fred", id="test_user_id") survey_url = "http://a_survey.com" - mock_anonymous_id_for_user.return_value = simple_anonymous_id_for_user(user) course = Mock(end_of_course_survey_url=survey_url, id="test_id") From d2bfec3ae421000a3545d77c1f57c25e41e5d78d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Mon, 25 Nov 2013 17:01:00 +0200 Subject: [PATCH 45/87] Make anonymous_id_for_user use simple_anonymous_id_for_user. --- common/djangoapps/student/models.py | 38 ++++++++++------------------- 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index e36cfe63cf7a..bd300800053a 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -57,31 +57,32 @@ class AnonymousUsers(models.Model): course_id = models.CharField(db_index=True, max_length=255) unique_together = (user, course_id) +def simple_anonymous_id_for_user(user, course_id=''): + """ + Return a unique id for a user, suitable for inserting into foldit module table + or into unit tests. + """ + # include the secret key as a salt, and to make the ids unique across different LMS installs. + h = hashlib.md5() + h.update(settings.SECRET_KEY) + h.update(str(user.id)) + h.update(course_id) + return h.hexdigest() def anonymous_id_for_user(user, course_id): """ Return a unique id for a (user, course) pair, suitable for inserting into e.g. personalized survey links. """ - def _anonymous_id_for_user(user, course_id): - # include the secret key as a salt, and to make the ids unique across different LMS installs. - h = hashlib.md5() - h.update(settings.SECRET_KEY) - h.update(str(user.id)) - h.update(course_id) - return h.hexdigest() - - # This part is for ability to get xblock instance in noauth xblock handlers, - # where user is anauthenticated. + # This part is for ability to get xblock instance in xblock_noauth handlers, where user is unauthenticated. if user.is_anonymous(): return 'Anonymous' return AnonymousUsers.objects.get_or_create( - defaults={'anonymous_user_id': _anonymous_id_for_user(user, course_id)}, + defaults={'anonymous_user_id': simple_anonymous_id_for_user(user, course_id)}, user=user, course_id=course_id )[0].anonymous_user_id - def user_by_anonymous_id(id): """ Return user by anonymous_user_id using AnonymousUsers lookup table. @@ -97,19 +98,6 @@ def user_by_anonymous_id(id): return obj.user -def simple_anonymous_id_for_user(user): - """ - Return a unique id for a user, suitable for inserting into foldit module table - or into unit tests. - """ - # include the secret key as a salt, and to make the ids unique across - # different LMS installs. - h = hashlib.md5() - h.update(settings.SECRET_KEY) - h.update(str(user.id)) - return h.hexdigest() - - class UserStanding(models.Model): """ This table contains a student's account's status. From 0aa0406b24880cbda035cf8fd89d20a948e19a64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Mon, 25 Nov 2013 17:04:14 +0200 Subject: [PATCH 46/87] Make export have simple_anonymous_id for historical reasons. --- .../student/management/commands/anonymized_id_mapping.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/djangoapps/student/management/commands/anonymized_id_mapping.py b/common/djangoapps/student/management/commands/anonymized_id_mapping.py index 8983d5a3ec15..df8bc8f2ba54 100644 --- a/common/djangoapps/student/management/commands/anonymized_id_mapping.py +++ b/common/djangoapps/student/management/commands/anonymized_id_mapping.py @@ -15,7 +15,7 @@ from django.contrib.auth.models import User from django.core.management.base import BaseCommand, CommandError -from student.models import anonymous_id_for_user +from student.models import anonymous_id_for_user, simple_anonymous_id_for_user class Command(BaseCommand): @@ -54,7 +54,7 @@ def handle(self, *args, **options): csv_writer = csv.writer(output_file) csv_writer.writerow(("User ID", "Anonymized user ID")) for student in students: - csv_writer.writerow((student.id, anonymous_id_for_user(student, course_id))) + csv_writer.writerow((student.id, anonymous_id_for_user(student, course_id), simple_anonymous_id_for_user(student))) except IOError: raise CommandError("Error writing to file: %s" % output_filename) From 709f2d7afad5f725d2cf5be741d5fa5c797b6f7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Mon, 25 Nov 2013 17:06:57 +0200 Subject: [PATCH 47/87] Update docstrings. --- .../student/management/commands/anonymized_id_mapping.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/common/djangoapps/student/management/commands/anonymized_id_mapping.py b/common/djangoapps/student/management/commands/anonymized_id_mapping.py index df8bc8f2ba54..02f35f76a451 100644 --- a/common/djangoapps/student/management/commands/anonymized_id_mapping.py +++ b/common/djangoapps/student/management/commands/anonymized_id_mapping.py @@ -1,5 +1,8 @@ # -*- coding: utf-8 -*- -"""Dump username,anonymous_id_for_user pairs as CSV. +"""Dump username, anonymous_id_for_user, simple_anonymous_id_for_user triples as CSV. + +Dumping of simple_anonymous_id_for_user in addition to anonymous_id_for_user is to +enable people that use it, to have a way to correlate w/ the historical data. Give instructors easy access to the mapping from anonymized IDs to user IDs with a simple Django management command to generate a CSV mapping. To run, use From 53cb96967fabe6554b0c2934ddb40f22951de5da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Mon, 25 Nov 2013 17:11:05 +0200 Subject: [PATCH 48/87] Update header in csv. --- .../student/management/commands/anonymized_id_mapping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/djangoapps/student/management/commands/anonymized_id_mapping.py b/common/djangoapps/student/management/commands/anonymized_id_mapping.py index 02f35f76a451..f82b140789aa 100644 --- a/common/djangoapps/student/management/commands/anonymized_id_mapping.py +++ b/common/djangoapps/student/management/commands/anonymized_id_mapping.py @@ -55,7 +55,7 @@ def handle(self, *args, **options): try: with open(output_filename, 'wb') as output_file: csv_writer = csv.writer(output_file) - csv_writer.writerow(("User ID", "Anonymized user ID")) + csv_writer.writerow(("User ID", "Anonymized user ID", "Simple anonymized user ID")) for student in students: csv_writer.writerow((student.id, anonymous_id_for_user(student, course_id), simple_anonymous_id_for_user(student))) except IOError: From 089d2bcfc9fa4b5d9f08ea0549ac2dbf8211584d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Mon, 25 Nov 2013 17:16:29 +0200 Subject: [PATCH 49/87] Add escaping for imsx_messageIdentifier. --- common/lib/xmodule/xmodule/lti_module.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index afbf1360895e..39903cbffafa 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -22,6 +22,7 @@ from webob import Response import mock import re +from xml.sax.saxutils import escape from xmodule.editing_module import MetadataOnlyEditingDescriptor from xmodule.raw_module import EmptyDataRawDescriptor @@ -454,12 +455,12 @@ def grade_handler(self, request, dispatch): values = { 'imsx_codeMajor': 'Success', 'imsx_description': 'Score for {sourced_id} is now {score}'.format(sourced_id=sourcedId, score=score), - 'imsx_messageIdentifier': imsx_messageIdentifier, + 'imsx_messageIdentifier': escape(imsx_messageIdentifier), 'response': '' } return Response(response_xml_template.format(**values), content_type="application/xml") - unsupported_values['imsx_messageIdentifier'] = imsx_messageIdentifier + unsupported_values['imsx_messageIdentifier'] = escape(imsx_messageIdentifier) return Response(response_xml_template.format(**unsupported_values), content_type='application/xml') From c6bfa98835dade466593c3a6c574221ca71bb9ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Mon, 25 Nov 2013 17:19:29 +0200 Subject: [PATCH 50/87] LTI grade XML namespace is hardcoded. --- common/lib/xmodule/xmodule/lti_module.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 39903cbffafa..7a19b352b9dc 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -469,20 +469,24 @@ def parse_grade_xml_body(cls, body): """ Parses XML from request.body and returns parsed data - XML body should contain nsmap (see specs). + 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().encode('utf-8') parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') root = etree.fromstring(data, parser=parser) - namespaces = {'def': root.nsmap.values()[0]} + imsx_messageIdentifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text 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('{'+root.nsmap.values()[0]+'}', '') + action = root.xpath("//def:imsx_POXBody", namespaces=namespaces)[0].getchildren()[0].tag.replace('{'+lti_spec_namespace+'}', '') + return imsx_messageIdentifier, sourcedId, score, action def verify_oauth_body_sign(self, request): From 69e8645658422eea10cc3df401307f589cda823f Mon Sep 17 00:00:00 2001 From: Oleg Marshev Date: Mon, 25 Nov 2013 17:46:09 +0200 Subject: [PATCH 51/87] Add messages. --- common/lib/xmodule/xmodule/lti_module.py | 38 ++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 7a19b352b9dc..f31d05d82473 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -424,13 +424,21 @@ def grade_handler(self, request, dispatch): """) unsupported_values = { - 'imsx_codeMajor': 'Unsupported', + 'imsx_codeMajor': 'unsupported', 'imsx_description': 'Target does not support the requested operation.', 'imsx_messageIdentifier': 'unknown', 'response': '' } + failure_values = { + 'imsx_codeMajor': 'failure', + 'imsx_description': 'The request has failed.', + 'imsx_messageIdentifier': 'unknown', + 'response': '' + } try: imsx_messageIdentifier, sourcedId, score, action = self.parse_grade_xml_body(request.body) + except (ValueError, LTIError): + return Response(response_xml_template.format(**failure_values), content_type="application/xml") except Exception: return Response(response_xml_template.format(**unsupported_values), content_type="application/xml") @@ -446,7 +454,7 @@ def grade_handler(self, request, dispatch): self.system.publish( event={ 'event_name': 'grade', - 'value': float(score) * self.max_score(), + 'value': score * self.max_score(), 'max_value': self.max_score(), }, custom_user=real_user @@ -486,6 +494,10 @@ def parse_grade_xml_body(cls, body): 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+'}', '') + #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 return imsx_messageIdentifier, sourcedId, score, action @@ -503,6 +515,8 @@ def verify_oauth_body_sign(self, request): Raises: LTIError if request is incorrect. """ + + ''' # this part will be removed as Inheritance PR will be meged from courseware.courses import get_course_by_id course = get_course_by_id(self.course_id) @@ -518,6 +532,9 @@ def verify_oauth_body_sign(self, request): if lti_id == self.lti_id.strip(): client_key, client_secret = key, secret break + ''' + + client_key, client_secret = self.get_client_key_secret() headers = { 'Authorization':unicode(request.headers.get('Authorization')), @@ -542,6 +559,23 @@ def verify_oauth_body_sign(self, request): not signature.verify_hmac_sha1(mock_request, client_secret)): raise LTIError + def get_client_key_secret(self): + """ + Obtains client_key and client_secret credentials from current course. + """ + course_id = self.course_id + course_location = CourseDescriptor.id_to_location(course_id) + course = self.descriptor.runtime.modulestore.get_item(course_location) + + for lti_passport in course.lti_passports: + try: + lti_id, key, secret = [i.strip() for i in lti_passport.split(':')] + except ValueError: + raise LTIError('Could not parse LTI passport: {0!r}. \ + Should be "id:key:secret" string.'.format(lti_passport)) + if lti_id == self.lti_id.strip(): + return key, secret + class LTIModuleDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRawDescriptor): """ From 10efe0cd1f97eabf91d7bdfde8f4475e2537f9b3 Mon Sep 17 00:00:00 2001 From: Oleg Marshev Date: Mon, 25 Nov 2013 17:50:01 +0200 Subject: [PATCH 52/87] Clean. --- common/lib/xmodule/xmodule/lti_module.py | 34 +----------------------- 1 file changed, 1 insertion(+), 33 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index f31d05d82473..27cdd3dafe57 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -198,21 +198,7 @@ def get_html(self): "tool_consumer_instance_contact_email", ] - # Obtains client_key and client_secret credentials from current course: - course_id = self.course_id - course_location = CourseDescriptor.id_to_location(course_id) - course = self.descriptor.runtime.modulestore.get_item(course_location) - client_key = client_secret = '' - - for lti_passport in course.lti_passports: - try: - lti_id, key, secret = [i.strip() for i in lti_passport.split(':')] - except ValueError: - raise LTIError('Could not parse LTI passport: {0!r}. \ - Should be "id:key:secret" string.'.format(lti_passport)) - if lti_id == self.lti_id.strip(): - client_key, client_secret = key, secret - break + client_key, client_secret = self.get_client_key_secret() # parsing custom parameters to dict custom_parameters = {} @@ -515,24 +501,6 @@ def verify_oauth_body_sign(self, request): Raises: LTIError if request is incorrect. """ - - ''' - # this part will be removed as Inheritance PR will be meged - from courseware.courses import get_course_by_id - course = get_course_by_id(self.course_id) - - # Obtains client_key and client_secret credentials from current course: - client_key = client_secret = '' - for lti_passport in course.lti_passports: - try: - lti_id, key, secret = [i.strip() for i in lti_passport.split(':')] - except ValueError: # do log here instead of raising exception - raise LTIError('Could not parse LTI passport: {0!r}. \ - Should be "id:key:secret" string.'.format(lti_passport)) - if lti_id == self.lti_id.strip(): - client_key, client_secret = key, secret - break - ''' client_key, client_secret = self.get_client_key_secret() From 9885b52c285d58f2c1b2f657675a6d1a020664cb Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Mon, 25 Nov 2013 16:30:16 +0200 Subject: [PATCH 53/87] Updated docs. --- docs/data/source/course_data_formats/lti_module/lti.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/data/source/course_data_formats/lti_module/lti.rst b/docs/data/source/course_data_formats/lti_module/lti.rst index 55c77a2a28b8..2d6e881d2d24 100644 --- a/docs/data/source/course_data_formats/lti_module/lti.rst +++ b/docs/data/source/course_data_formats/lti_module/lti.rst @@ -1,11 +1,11 @@ ********************************************** -Xml format of LTI module [xmodule] +LTI module [xmodule] ********************************************** .. module:: lti_module -Format description -================== +Description +=========== The LTI XModule is based on the IMS Global Learning Tools Interoperability Version 1.1.1 specifications. From e4f091c7ebb43c3b9532fbb101c2da758c03e3e9 Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Mon, 25 Nov 2013 17:59:30 +0200 Subject: [PATCH 54/87] Updated LTI docs. --- .../course_data_formats/lti_module/lti.rst | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/docs/data/source/course_data_formats/lti_module/lti.rst b/docs/data/source/course_data_formats/lti_module/lti.rst index 2d6e881d2d24..8213f6ea391b 100644 --- a/docs/data/source/course_data_formats/lti_module/lti.rst +++ b/docs/data/source/course_data_formats/lti_module/lti.rst @@ -9,3 +9,79 @@ Description The LTI XModule is based on the IMS Global Learning Tools Interoperability Version 1.1.1 specifications. + +Enabling LTI +============ + +It is not available from the list of general components. To turn it on, add +"lti" to the "advanced_modules" key on the Advanced Settings page. + +The module supports 2 modes of operation. + +1.) Simple display of external LTI content +2.) display of LTI content that will be graded by external provider + +In both cases, before an LTI component from an external provider can be +included in a unit, the following pieces of information must be known/decided +upon: + +- LTI id: Internal string representing the external LTI provider. Can be anything. +- Client key: Used for OAuth authentication. Issued by external LTI provider. +- Client secret: Used for OAuth authentication. Issued by external LTI provider. + +LTI id is necessary to differentiate between multiple available external LTI +providers that are added to an edX course. + +The three fields above must be entered in "lti_passports" field in the format: + +[ +"{lti_id}:{client_key}:{client_secret}" +] + +Multiple external LTI providers are separated by commas: + +[ +"{lti_id_1}:{client_key_1}:{client_secret_1}", +"{lti_id_2}:{client_key_2}:{client_secret_2}", +"{lti_id_3}:{client_key_3}:{client_secret_3}" +] + +Adding LTI to a unit +==================== + +After LTI has been enabled, and an external provider has been registered, an +instance of it can be added to a unit. + +LTI will be available from the Advanced Component category. After adding an LTI +component to a unit, it can be configured by Editing it's settings (the Edit +dialog). The following settings are available: + +- Display Name [string]: Title of the new LTI component instance + +- custom_parameters: With the "+ Add" button, multiple custom parameters can be +added. Basically, each individual external LTI provider can have a separate +format custom parameters. For example: + +key=value + +- graded [boolean]: Whether or not this particular LTI instance problem will be +graded by the external LTI provider. + +- launch_url [string]: If `rgaded` above is set to `true`, then this must be +the URL that will be passed to the external LTI provider for it to respond with +a grade. + +- lti_id [string]: Internal string representing the external LTI provider that +will be used to display content. The same as was entered on the Advanced +Settings page. + +- open_in_a_new_page [boolean]: If set to `true`, a link will be present for the student +to click. When the link is clicked, a new window will open with the external +LTI content. If set to `false`, the external LTI content will be loaded in the +page in an iframe. + +- weight [float]: If the problem will be graded by an external LTI provider, +the raw grade will be in the range [0.0, 1.0]. In order to change this range, +set the `weight`. The grade that will be stored is calculated by the formula: + +stored_grade = raw_grade * weight From a36f4c2997a00766432c345a3a1e5fadf39e277a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Mon, 25 Nov 2013 18:14:43 +0200 Subject: [PATCH 55/87] Add tests for anonymous lookup table. --- common/djangoapps/student/tests/tests.py | 37 +++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index 71e7b9e3e98d..6d76ecfc8774 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -28,7 +28,7 @@ from mock import Mock, patch, sentinel from textwrap import dedent -from student.models import anonymous_id_for_user, CourseEnrollment +from student.models import anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment from student.views import (process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper, change_enrollment, complete_course_mode_info) from student.tests.factories import UserFactory, CourseModeFactory @@ -514,3 +514,38 @@ def test_change_enrollment_add_to_cart(self): self.assertEqual(response.content, reverse('shoppingcart.views.show_cart')) self.assertTrue(shoppingcart.models.PaidCourseRegistration.contained_in_order( shoppingcart.models.Order.get_cart_for_user(self.user), self.course.id)) + + +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) +class AnonymousLookupTable(TestCase): + """ + Tests for anonymous_id_functions + """ + # arbitrary constant + COURSE_SLUG = "100" + COURSE_NAME = "test_course" + COURSE_ORG = "EDX" + + def setUp(self): + self.course = CourseFactory.create(org=self.COURSE_ORG, display_name=self.COURSE_NAME, number=self.COURSE_SLUG) + self.assertIsNotNone(self.course) + self.user = UserFactory.create(username="jack", email="jack@fake.edx.org") + CourseModeFactory.create( + course_id=self.course.id, + mode_slug='honor', + mode_display_name='Honor Code', + ) + patcher = patch('student.models.server_track') + self.mock_server_track = patcher.start() + self.addCleanup(patcher.stop) + + def test_for_unregistered_user(self ): # same path as for logged out user + self.user.is_anonymous = lambda : True + self.assertEqual('Anonymous', anonymous_id_for_user(self.user, self.course.id)) + self.assertIsNone(user_by_anonymous_id('Anonymous')) + + def test_roundtrip_for_logged_user(self): + enrollment = CourseEnrollment.enroll(self.user, self.course.id) + anonymous_id = anonymous_id_for_user(self.user, self.course.id) + real_user = user_by_anonymous_id(anonymous_id) + self.assertEqual(self.user, real_user) From 68a7eafc7fbe6031b3d87c013ca5b29ac7388e56 Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Mon, 25 Nov 2013 18:50:32 +0200 Subject: [PATCH 56/87] Adding docs for LTI. --- common/lib/xmodule/xmodule/lti_module.py | 4 +- .../course_data_formats/lti_module/lti.rst | 76 ++++++++++--------- 2 files changed, 43 insertions(+), 37 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 27cdd3dafe57..e614246f82aa 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -4,8 +4,8 @@ Protocol is oauth1, LTI version is 1.1.1: http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html -CodeMajor/Severity Interpretation Matrix -http://www.imsglobal.org/es/esv1p0/imscommon_infov1p0.html#1589069 +Table A1.2 Interpretation of the 'CodeMajor/severity' matrix. +http://www.imsglobal.org/gws/gwsv1p0/imsgws_wsdlBindv1p0.html play and test: http://www.imsglobal.org/developers/LTI/test/v1p1/lms.php diff --git a/docs/data/source/course_data_formats/lti_module/lti.rst b/docs/data/source/course_data_formats/lti_module/lti.rst index 8213f6ea391b..78d8a5b4c8dd 100644 --- a/docs/data/source/course_data_formats/lti_module/lti.rst +++ b/docs/data/source/course_data_formats/lti_module/lti.rst @@ -7,8 +7,7 @@ LTI module [xmodule] Description =========== -The LTI XModule is based on the IMS Global Learning Tools Interoperability -Version 1.1.1 specifications. +The LTI XModule is based on the `IMS Global Learning Tools Interoperability `_ Version 1.1.1 specifications. Enabling LTI ============ @@ -18,8 +17,8 @@ It is not available from the list of general components. To turn it on, add The module supports 2 modes of operation. -1.) Simple display of external LTI content -2.) display of LTI content that will be graded by external provider +1. Simple display of external LTI content +2. display of LTI content that will be graded by external provider In both cases, before an LTI component from an external provider can be included in a unit, the following pieces of information must be known/decided @@ -32,19 +31,19 @@ upon: LTI id is necessary to differentiate between multiple available external LTI providers that are added to an edX course. -The three fields above must be entered in "lti_passports" field in the format: +The three fields above must be entered in "lti_passports" field in the format:: -[ -"{lti_id}:{client_key}:{client_secret}" -] + [ + "{lti_id}:{client_key}:{client_secret}" + ] -Multiple external LTI providers are separated by commas: +Multiple external LTI providers are separated by commas:: -[ -"{lti_id_1}:{client_key_1}:{client_secret_1}", -"{lti_id_2}:{client_key_2}:{client_secret_2}", -"{lti_id_3}:{client_key_3}:{client_secret_3}" -] + [ + "{lti_id_1}:{client_key_1}:{client_secret_1}", + "{lti_id_2}:{client_key_2}:{client_secret_2}", + "{lti_id_3}:{client_key_3}:{client_secret_3}" + ] Adding LTI to a unit ==================== @@ -56,32 +55,39 @@ LTI will be available from the Advanced Component category. After adding an LTI component to a unit, it can be configured by Editing it's settings (the Edit dialog). The following settings are available: -- Display Name [string]: Title of the new LTI component instance +*Display Name* [string] + Title of the new LTI component instance -- custom_parameters: With the "+ Add" button, multiple custom parameters can be -added. Basically, each individual external LTI provider can have a separate -format custom parameters. For example: +*custom_parameters* [string] + With the "+ Add" button, multiple custom parameters can be + added. Basically, each individual external LTI provider can have a separate + format custom parameters. For example:: -key=value + key=value -- graded [boolean]: Whether or not this particular LTI instance problem will be -graded by the external LTI provider. +*graded* [boolean] + Whether or not this particular LTI instance problem will be + graded by the external LTI provider. -- launch_url [string]: If `rgaded` above is set to `true`, then this must be -the URL that will be passed to the external LTI provider for it to respond with -a grade. +*launch_url* [string] + If `rgaded` above is set to `true`, then this must be + the URL that will be passed to the external LTI provider for it to respond with + a grade. -- lti_id [string]: Internal string representing the external LTI provider that -will be used to display content. The same as was entered on the Advanced -Settings page. +*lti_id* [string] + Internal string representing the external LTI provider that + will be used to display content. The same as was entered on the Advanced + Settings page. -- open_in_a_new_page [boolean]: If set to `true`, a link will be present for the student -to click. When the link is clicked, a new window will open with the external -LTI content. If set to `false`, the external LTI content will be loaded in the -page in an iframe. +*open_in_a_new_page* [boolean] + If set to `true`, a link will be present for the student + to click. When the link is clicked, a new window will open with the external + LTI content. If set to `false`, the external LTI content will be loaded in the + page in an iframe. -- weight [float]: If the problem will be graded by an external LTI provider, -the raw grade will be in the range [0.0, 1.0]. In order to change this range, -set the `weight`. The grade that will be stored is calculated by the formula: +*weight* [float] + If the problem will be graded by an external LTI provider, + the raw grade will be in the range [0.0, 1.0]. In order to change this range, + set the `weight`. The grade that will be stored is calculated by the formula:: -stored_grade = raw_grade * weight + stored_grade = raw_grade * weight From 39e461c5429a9f65de298ee5d8f9e75e52be0891 Mon Sep 17 00:00:00 2001 From: Oleg Marshev Date: Mon, 25 Nov 2013 18:50:47 +0200 Subject: [PATCH 57/87] Fix messages. --- common/lib/xmodule/xmodule/lti_module.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index e614246f82aa..2cc0f6e4f2b7 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -423,16 +423,14 @@ def grade_handler(self, request, dispatch): } try: imsx_messageIdentifier, sourcedId, score, action = self.parse_grade_xml_body(request.body) - except (ValueError, LTIError): - return Response(response_xml_template.format(**failure_values), content_type="application/xml") except Exception: - return Response(response_xml_template.format(**unsupported_values), content_type="application/xml") + return Response(response_xml_template.format(**failure_values), content_type="application/xml") # verify oauth signing try: self.verify_oauth_body_sign(request) - except LTIError: - return Response(response_xml_template.format(**unsupported_values), content_type="application/xml") + except (ValueError, LTIError): + return Response(response_xml_template.format(**failure_values), content_type="application/xml") real_user = self.system.get_real_user(urllib.unquote(sourcedId.split(':')[-1])) From da51a52c8fba58ae86800fdc6cc3d0593a0b4ad6 Mon Sep 17 00:00:00 2001 From: Oleg Marshev Date: Mon, 25 Nov 2013 18:58:17 +0200 Subject: [PATCH 58/87] Add unittests. --- common/lib/xmodule/xmodule/tests/test_lti.py | 189 +++++++++---------- 1 file changed, 85 insertions(+), 104 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_lti.py b/common/lib/xmodule/xmodule/tests/test_lti.py index 58a28960c46f..193afbccf9a7 100644 --- a/common/lib/xmodule/xmodule/tests/test_lti.py +++ b/common/lib/xmodule/xmodule/tests/test_lti.py @@ -14,7 +14,7 @@ class LTIModuleTest(LogicTest): xml_template = textwrap.dedent(""" - + V1.0 @@ -39,153 +39,134 @@ class LTIModuleTest(LogicTest): """) - def test_handle_ajax(self): - # Make sure that ajax request works correctly. + def test_authorization_header_not_present(self): + """ + Request has no Authorization header. + This is an unknown service request, i.e., it is not a part of the original service specification. + """ + self.system.get_real_user = Mock() + self.xmodule.get_client_key_secret = Mock(return_value=('key', 'secret')) - good_requests = {'set': {'score': 5}, 'read': {}, 'delete': {}} - bad_requests = {'set': {}, 'unknown_dispatch': {}} + self.system.publish = Mock() + + from webob.request import Request + environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} + + incorrect_request = Request(environ) + + good_value = {'grade': '0.5'} + incorrect_request.body = self.xml_template.format(**good_value) + dispatch = 'test' + response = self.xmodule.grade_handler(incorrect_request, dispatch) - for dispatch, data in bad_requests.items(): - response = self.ajax_request(dispatch, data) - if dispatch == 'set': - self.assertEqual(response['status_code'], 400) - else: - self.assertEqual(response['status_code'], 404) + code_major = self.get_code_major(response) - for dispatch, data in good_requests.items(): - response = self.ajax_request(dispatch, data) - self.assertEqual(response['status_code'], 200) - if dispatch == 'read': - self.assertDictEqual(response['content']['value'], {'score': 0.5, 'total': 1.0}) + self.assertEqual(response.status_code, 200) + self.assertEqual(code_major, 'unsupported', code_major) - def test_valid_xml(self): + def test_authorization_header_empty(self): """ - Valid XML returned from Tool Provider. + Request Authorization header has no value. + This is an unknown service request, i.e., it is not a part of the original service specification. """ self.system.get_real_user = Mock() + self.xmodule.get_client_key_secret = Mock(return_value=('key', 'secret')) + self.system.publish = Mock() - dispatch = 'replaceresult' - - mock_request = Mock() - - good_value = {'grade': '0.5'} - mock_request.body = self.xml_template.format(**good_value) - result = self.xmodule.custom_handler(mock_request, dispatch) + from webob.request import Request + environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} - parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') - root = etree.fromstring(result.body.strip(), parser=parser) - namespaces = {'def': root.nsmap.values()[0]} + incorrect_request = Request(environ) + incorrect_request.authorization = "bad authorization header" - code_major = root.xpath("//def:imsx_codeMajor", namespaces=namespaces)[0].text + good_value = {'grade': '0.5'} + incorrect_request.body = self.xml_template.format(**good_value) + dispatch = 'test' + response = self.xmodule.grade_handler(incorrect_request, dispatch) - self.assertEqual(code_major, 'success', code_major) + code_major = self.get_code_major(response) + self.assertEqual(response.status_code, 200) + self.assertEqual(code_major, 'unsupported', code_major) def test_bad_grade_range(self): """ Grade returned from Tool Provider is outside the range 0.0-1.0. """ + self.xmodule.verify_oauth_body_sign = Mock() self.system.get_real_user = Mock() - self.system.publish = Mock() - dispatch = 'replaceresult' - - mock_request = Mock() - - bad_value = {'grade': '100'} - mock_request.body = self.xml_template.format(**bad_value) - result = self.xmodule.custom_handler(mock_request, dispatch) + from webob.request import Request + environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} - parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') - root = etree.fromstring(result.body.strip(), parser=parser) - namespaces = {'def': root.nsmap.values()[0]} - code_major = root.xpath("//def:imsx_codeMajor", namespaces=namespaces)[0].text + incorrect_request = Request(environ) + #grade not in range + bad_value = {'grade': '10'} + incorrect_request.body = self.xml_template.format(**bad_value) + dispatch = 'test' + + response = self.xmodule.grade_handler(incorrect_request, dispatch) + + code_major = self.get_code_major(response) + + self.assertEqual(response.status_code, 200) self.assertEqual(code_major, 'failure', code_major) def test_bad_grade_decimal(self): """ Grade returned from Tool Provider doesn't use a period as the decimal point. """ + self.xmodule.verify_oauth_body_sign = Mock() self.system.get_real_user = Mock() - self.system.publish = Mock() - dispatch = 'replaceresult' - - mock_request = Mock() + from webob.request import Request + environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} + + incorrect_request = Request(environ) + bad_value = {'grade': '0,5'} - mock_request.body = self.xml_template.format(**bad_value) - result = self.xmodule.custom_handler(mock_request, dispatch) + + incorrect_request.body = self.xml_template.format(**bad_value) + dispatch = 'test' + response = self.xmodule.grade_handler(incorrect_request, dispatch) + code_major = self.get_code_major(response) + + self.assertEqual(response.status_code, 200) + self.assertEqual(code_major, 'failure', code_major) + + def get_code_major(self, response): parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') - root = etree.fromstring(result.body.strip(), parser=parser) + root = etree.fromstring(response.body.strip(), parser=parser) namespaces = {'def': root.nsmap.values()[0]} code_major = root.xpath("//def:imsx_codeMajor", namespaces=namespaces)[0].text + return code_major - self.assertEqual(code_major, 'failure', code_major) - - def test_incomplete_response_body(self): - """ - Response body from Tool Provider doesn't contain messageIdentifier and sourcedId and textString. - """ + @unittest.skip("not implemented") + def test_good_request(self): + self.system.get_real_user = Mock() + self.xmodule.get_client_key_secret = Mock(return_value=('test_key', 'test_secret')) + self.system.publish = Mock() - dispatch = 'replaceresult' + from webob.request import Request + environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} - xml_template = textwrap.dedent(""" - - - - - V1.0 - "here must be messageIdentifier" - - - - - - - "here must be sourcedId" - - - - en-us - "here must be textString" - - - - - - - """) + incorrect_request = Request(environ) + incorrect_request.authorization = "bad authorization header" - mock_request = Mock() - mock_request.body = xml_template - result = self.xmodule.custom_handler(mock_request, dispatch) + good_value = {'grade': '0.5'} + incorrect_request.body = self.xml_template.format(**good_value) + dispatch = 'test' + response = self.xmodule.grade_handler(incorrect_request, dispatch) - parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') - root = etree.fromstring(result.body.strip(), parser=parser) - namespaces = {'def': root.nsmap.values()[0]} - code_major = root.xpath("//def:imsx_codeMajor", namespaces=namespaces)[0].text + code_major = self.get_code_major(response) + self.assertEqual(response.status_code, 200) self.assertEqual(code_major, 'unsupported', code_major) - @unittest.skip("skipped because not completed") - def test_authorization_header_not_present(self): - """ - Authorization header not provided in request. - """ - mock_request = Mock() - mock_request.META = None - - mock_course = Mock() - import ipdb; ipdb.set_trace() - descriptor = LTIModuleDescriptor() - - result = descriptor.authenticate(mock_request, mock_course) - - From c72107f5ff7ac5a9f44b7a8cb610b45909656777 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Mon, 25 Nov 2013 19:08:38 +0200 Subject: [PATCH 59/87] Fix return value in get_clien_key_secret --- common/lib/xmodule/xmodule/lti_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 2cc0f6e4f2b7..b8790b4cd0d1 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -541,7 +541,7 @@ def get_client_key_secret(self): Should be "id:key:secret" string.'.format(lti_passport)) if lti_id == self.lti_id.strip(): return key, secret - + return '', '' class LTIModuleDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRawDescriptor): """ From b173e483823b815779448ef9faf023eed22d7669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Mon, 25 Nov 2013 19:24:57 +0200 Subject: [PATCH 60/87] Refactor LTI unittests. --- common/lib/xmodule/xmodule/tests/test_lti.py | 73 +++++++------------- 1 file changed, 24 insertions(+), 49 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_lti.py b/common/lib/xmodule/xmodule/tests/test_lti.py index 193afbccf9a7..d35dbed0bd95 100644 --- a/common/lib/xmodule/xmodule/tests/test_lti.py +++ b/common/lib/xmodule/xmodule/tests/test_lti.py @@ -1,10 +1,14 @@ # -*- coding: utf-8 -*- """Test for LTI Xmodule functional logic.""" + from mock import Mock import textwrap from lxml import etree import unittest +from webob.request import Request + from xmodule.lti_module import LTIModuleDescriptor + from . import LogicTest @@ -12,7 +16,10 @@ class LTIModuleTest(LogicTest): """Logic tests for LTI module.""" descriptor_class = LTIModuleDescriptor - xml_template = textwrap.dedent(""" + def setUp(self): + super(TestAssetIndex, self).setUp() + self.environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} + self.xml_template = textwrap.dedent(""" @@ -38,29 +45,27 @@ class LTIModuleTest(LogicTest): """) + self.system.get_real_user = Mock() + self.xmodule.get_client_key_secret = Mock(return_value=('key', 'secret')) + self.system.publish = Mock() + + def get_code_major(self, response): + parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') + root = etree.fromstring(response.body.strip(), parser=parser) + namespaces = {'def': root.nsmap.values()[0]} + code_major = root.xpath("//def:imsx_codeMajor", namespaces=namespaces)[0].text + return code_major def test_authorization_header_not_present(self): """ Request has no Authorization header. This is an unknown service request, i.e., it is not a part of the original service specification. """ - self.system.get_real_user = Mock() - self.xmodule.get_client_key_secret = Mock(return_value=('key', 'secret')) - - self.system.publish = Mock() - - from webob.request import Request - environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} - - incorrect_request = Request(environ) - + incorrect_request = Request(self.environ) good_value = {'grade': '0.5'} incorrect_request.body = self.xml_template.format(**good_value) - dispatch = 'test' - response = self.xmodule.grade_handler(incorrect_request, dispatch) - + response = self.xmodule.grade_handler(incorrect_request, '') code_major = self.get_code_major(response) - self.assertEqual(response.status_code, 200) self.assertEqual(code_major, 'unsupported', code_major) @@ -69,21 +74,12 @@ def test_authorization_header_empty(self): Request Authorization header has no value. This is an unknown service request, i.e., it is not a part of the original service specification. """ - self.system.get_real_user = Mock() - self.xmodule.get_client_key_secret = Mock(return_value=('key', 'secret')) - - self.system.publish = Mock() - - from webob.request import Request - environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} - - incorrect_request = Request(environ) + incorrect_request = Request(self.environ) incorrect_request.authorization = "bad authorization header" good_value = {'grade': '0.5'} incorrect_request.body = self.xml_template.format(**good_value) - dispatch = 'test' - response = self.xmodule.grade_handler(incorrect_request, dispatch) + response = self.xmodule.grade_handler(incorrect_request, '') code_major = self.get_code_major(response) @@ -119,43 +115,22 @@ def test_bad_grade_decimal(self): Grade returned from Tool Provider doesn't use a period as the decimal point. """ self.xmodule.verify_oauth_body_sign = Mock() - self.system.get_real_user = Mock() - - from webob.request import Request - environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} - - incorrect_request = Request(environ) + incorrect_request = Request(self.environ) bad_value = {'grade': '0,5'} - incorrect_request.body = self.xml_template.format(**bad_value) dispatch = 'test' response = self.xmodule.grade_handler(incorrect_request, dispatch) - code_major = self.get_code_major(response) self.assertEqual(response.status_code, 200) self.assertEqual(code_major, 'failure', code_major) - def get_code_major(self, response): - parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') - root = etree.fromstring(response.body.strip(), parser=parser) - namespaces = {'def': root.nsmap.values()[0]} - code_major = root.xpath("//def:imsx_codeMajor", namespaces=namespaces)[0].text - return code_major @unittest.skip("not implemented") def test_good_request(self): - self.system.get_real_user = Mock() - self.xmodule.get_client_key_secret = Mock(return_value=('test_key', 'test_secret')) - - self.system.publish = Mock() - - from webob.request import Request - environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} - - incorrect_request = Request(environ) + incorrect_request = Request(self.environ) incorrect_request.authorization = "bad authorization header" good_value = {'grade': '0.5'} From 406d811af611db5d0ecc6d12f4cd10f75301c707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Mon, 25 Nov 2013 19:28:55 +0200 Subject: [PATCH 61/87] Cleanup in LTI tests. --- common/lib/xmodule/xmodule/tests/test_lti.py | 46 +++++--------------- 1 file changed, 12 insertions(+), 34 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_lti.py b/common/lib/xmodule/xmodule/tests/test_lti.py index d35dbed0bd95..4bf6b87aec6d 100644 --- a/common/lib/xmodule/xmodule/tests/test_lti.py +++ b/common/lib/xmodule/xmodule/tests/test_lti.py @@ -19,7 +19,7 @@ class LTIModuleTest(LogicTest): def setUp(self): super(TestAssetIndex, self).setUp() self.environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} - self.xml_template = textwrap.dedent(""" + self.requet_body_xml_template = textwrap.dedent(""" @@ -63,7 +63,7 @@ def test_authorization_header_not_present(self): """ incorrect_request = Request(self.environ) good_value = {'grade': '0.5'} - incorrect_request.body = self.xml_template.format(**good_value) + incorrect_request.body = self.requet_body_xml_template.format(**good_value) response = self.xmodule.grade_handler(incorrect_request, '') code_major = self.get_code_major(response) self.assertEqual(response.status_code, 200) @@ -76,38 +76,24 @@ def test_authorization_header_empty(self): """ incorrect_request = Request(self.environ) incorrect_request.authorization = "bad authorization header" - good_value = {'grade': '0.5'} - incorrect_request.body = self.xml_template.format(**good_value) + incorrect_request.body = self.requet_body_xml_template.format(**good_value) response = self.xmodule.grade_handler(incorrect_request, '') - code_major = self.get_code_major(response) - self.assertEqual(response.status_code, 200) self.assertEqual(code_major, 'unsupported', code_major) - def test_bad_grade_range(self): + def test_grade_not_in_range(self): """ Grade returned from Tool Provider is outside the range 0.0-1.0. """ self.xmodule.verify_oauth_body_sign = Mock() - self.system.get_real_user = Mock() - - from webob.request import Request - environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} - - incorrect_request = Request(environ) - - #grade not in range - bad_value = {'grade': '10'} + incorrect_request = Request(self.environ) + bad_value = {'grade': '10'} #grade not in range incorrect_request.body = self.xml_template.format(**bad_value) - dispatch = 'test' - - response = self.xmodule.grade_handler(incorrect_request, dispatch) - - code_major = self.get_code_major(response) - + response = self.xmodule.grade_handler(incorrect_request, '') self.assertEqual(response.status_code, 200) + code_major = self.get_code_major(response) self.assertEqual(code_major, 'failure', code_major) def test_bad_grade_decimal(self): @@ -115,31 +101,23 @@ def test_bad_grade_decimal(self): Grade returned from Tool Provider doesn't use a period as the decimal point. """ self.xmodule.verify_oauth_body_sign = Mock() - incorrect_request = Request(self.environ) bad_value = {'grade': '0,5'} - incorrect_request.body = self.xml_template.format(**bad_value) - dispatch = 'test' - response = self.xmodule.grade_handler(incorrect_request, dispatch) + incorrect_request.body = self.requet_body_xml_template.format(**bad_value) + response = self.xmodule.grade_handler(incorrect_request, '') code_major = self.get_code_major(response) - self.assertEqual(response.status_code, 200) self.assertEqual(code_major, 'failure', code_major) - @unittest.skip("not implemented") def test_good_request(self): self.system.get_real_user = Mock() incorrect_request = Request(self.environ) incorrect_request.authorization = "bad authorization header" - good_value = {'grade': '0.5'} - incorrect_request.body = self.xml_template.format(**good_value) - dispatch = 'test' - response = self.xmodule.grade_handler(incorrect_request, dispatch) - + incorrect_request.body = self.requet_body_xml_template.format(**good_value) + response = self.xmodule.grade_handler(incorrect_request, '') code_major = self.get_code_major(response) - self.assertEqual(response.status_code, 200) self.assertEqual(code_major, 'unsupported', code_major) From 8568eb22078f9699b3607cec613bab37077e6335 Mon Sep 17 00:00:00 2001 From: polesye Date: Mon, 25 Nov 2013 21:53:08 +0200 Subject: [PATCH 62/87] Fix lti tests. --- common/lib/xmodule/xmodule/tests/test_lti.py | 67 +++++++++++++------- 1 file changed, 43 insertions(+), 24 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_lti.py b/common/lib/xmodule/xmodule/tests/test_lti.py index 4bf6b87aec6d..3743f34e2f1a 100644 --- a/common/lib/xmodule/xmodule/tests/test_lti.py +++ b/common/lib/xmodule/xmodule/tests/test_lti.py @@ -17,9 +17,9 @@ class LTIModuleTest(LogicTest): descriptor_class = LTIModuleDescriptor def setUp(self): - super(TestAssetIndex, self).setUp() - self.environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} - self.requet_body_xml_template = textwrap.dedent(""" + super(LTIModuleTest, self).setUp() + self.environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} + self.requet_body_xml_template = textwrap.dedent(""" @@ -29,7 +29,7 @@ def setUp(self): - + <{action}> feb-123-456-2929::28883 @@ -41,14 +41,23 @@ def setUp(self): - + - """) + """) self.system.get_real_user = Mock() self.xmodule.get_client_key_secret = Mock(return_value=('key', 'secret')) self.system.publish = Mock() + def get_request_body(self, params={}): + data = { + 'action': 'replaceResultRequest', + 'grade': '0.5', + } + + data.update(params) + return self.requet_body_xml_template.format(**data) + def get_code_major(self, response): parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') root = etree.fromstring(response.body.strip(), parser=parser) @@ -62,12 +71,11 @@ def test_authorization_header_not_present(self): This is an unknown service request, i.e., it is not a part of the original service specification. """ incorrect_request = Request(self.environ) - good_value = {'grade': '0.5'} - incorrect_request.body = self.requet_body_xml_template.format(**good_value) + incorrect_request.body = self.get_request_body() response = self.xmodule.grade_handler(incorrect_request, '') code_major = self.get_code_major(response) self.assertEqual(response.status_code, 200) - self.assertEqual(code_major, 'unsupported', code_major) + self.assertEqual(code_major, 'failure') def test_authorization_header_empty(self): """ @@ -76,12 +84,11 @@ def test_authorization_header_empty(self): """ incorrect_request = Request(self.environ) incorrect_request.authorization = "bad authorization header" - good_value = {'grade': '0.5'} - incorrect_request.body = self.requet_body_xml_template.format(**good_value) + incorrect_request.body = self.get_request_body() response = self.xmodule.grade_handler(incorrect_request, '') code_major = self.get_code_major(response) self.assertEqual(response.status_code, 200) - self.assertEqual(code_major, 'unsupported', code_major) + self.assertEqual(code_major, 'failure') def test_grade_not_in_range(self): """ @@ -89,12 +96,11 @@ def test_grade_not_in_range(self): """ self.xmodule.verify_oauth_body_sign = Mock() incorrect_request = Request(self.environ) - bad_value = {'grade': '10'} #grade not in range - incorrect_request.body = self.xml_template.format(**bad_value) + incorrect_request.body = self.get_request_body(params={'grade': '10'}) response = self.xmodule.grade_handler(incorrect_request, '') self.assertEqual(response.status_code, 200) code_major = self.get_code_major(response) - self.assertEqual(code_major, 'failure', code_major) + self.assertEqual(code_major, 'failure') def test_bad_grade_decimal(self): """ @@ -102,24 +108,37 @@ def test_bad_grade_decimal(self): """ self.xmodule.verify_oauth_body_sign = Mock() incorrect_request = Request(self.environ) - bad_value = {'grade': '0,5'} - incorrect_request.body = self.requet_body_xml_template.format(**bad_value) + incorrect_request.body = self.get_request_body(params={'grade': '0,5'}) response = self.xmodule.grade_handler(incorrect_request, '') code_major = self.get_code_major(response) self.assertEqual(response.status_code, 200) - self.assertEqual(code_major, 'failure', code_major) + self.assertEqual(code_major, 'failure') + + def test_unsupported_action(self): + """ + Action returned from Tool Provider isn't supported. + `replaceResultRequest` is supported only. + """ + self.xmodule.verify_oauth_body_sign = Mock() + incorrect_request = Request(self.environ) + incorrect_request.body = self.get_request_body({'action': 'wrongAction'}) + response = self.xmodule.grade_handler(incorrect_request, '') + code_major = self.get_code_major(response) + self.assertEqual(response.status_code, 200) + self.assertEqual(code_major, 'unsupported') - @unittest.skip("not implemented") def test_good_request(self): - self.system.get_real_user = Mock() + """ + Response from Tool Provider is correct. + """ + self.xmodule.verify_oauth_body_sign = Mock() incorrect_request = Request(self.environ) - incorrect_request.authorization = "bad authorization header" - good_value = {'grade': '0.5'} - incorrect_request.body = self.requet_body_xml_template.format(**good_value) + # incorrect_request.authorization = "bad authorization header" + incorrect_request.body = self.get_request_body() response = self.xmodule.grade_handler(incorrect_request, '') code_major = self.get_code_major(response) self.assertEqual(response.status_code, 200) - self.assertEqual(code_major, 'unsupported', code_major) + self.assertEqual(code_major, 'Success') From 38b30f3e470afc99ffaf4fa78dd108336e353bfb Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Mon, 25 Nov 2013 15:53:00 -0500 Subject: [PATCH 63/87] Tighten the user id assertion, and fix tests. --- lms/djangoapps/courseware/model_data.py | 6 ++++-- lms/djangoapps/courseware/tests/test_model_data.py | 11 ++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/lms/djangoapps/courseware/model_data.py b/lms/djangoapps/courseware/model_data.py index 9ed063c8b20d..fd7705affc64 100644 --- a/lms/djangoapps/courseware/model_data.py +++ b/lms/djangoapps/courseware/model_data.py @@ -18,7 +18,7 @@ from xblock.runtime import KeyValueStore from xblock.exceptions import KeyValueMultiSaveError, InvalidScopeError -from xblock.fields import Scope +from xblock.fields import Scope, UserScope log = logging.getLogger(__name__) @@ -227,7 +227,9 @@ def find_or_create(self, key): if field_object is not None: return field_object - if not self.user.is_anonymous(): + if key.scope.user == UserScope.ONE and not self.user.is_anonymous(): + # If we're getting user data, we expect that the key matches the + # user we were constructed for. assert key.user_id == self.user.id if key.scope == Scope.user_state: diff --git a/lms/djangoapps/courseware/tests/test_model_data.py b/lms/djangoapps/courseware/tests/test_model_data.py index bde57c15534c..2286506465be 100644 --- a/lms/djangoapps/courseware/tests/test_model_data.py +++ b/lms/djangoapps/courseware/tests/test_model_data.py @@ -40,11 +40,14 @@ def mock_descriptor(fields=[]): location = partial(Location, 'i4x', 'edX', 'test_course', 'problem') course_id = 'edX/test_course/test' +# The user ids here are 1 because we make a student in the setUp functions, and +# they get an id of 1. There's an assertion in setUp to ensure that assumption +# is still true. user_state_summary_key = partial(DjangoKeyValueStore.Key, Scope.user_state_summary, None, location('def_id')) settings_key = partial(DjangoKeyValueStore.Key, Scope.settings, None, location('def_id')) -user_state_key = partial(DjangoKeyValueStore.Key, Scope.user_state, 'user', location('def_id')) -prefs_key = partial(DjangoKeyValueStore.Key, Scope.preferences, 'user', 'MockProblemModule') -user_info_key = partial(DjangoKeyValueStore.Key, Scope.user_info, 'user', None) +user_state_key = partial(DjangoKeyValueStore.Key, Scope.user_state, 1, location('def_id')) +prefs_key = partial(DjangoKeyValueStore.Key, Scope.preferences, 1, 'MockProblemModule') +user_info_key = partial(DjangoKeyValueStore.Key, Scope.user_info, 1, None) class StudentModuleFactory(cmfStudentModuleFactory): @@ -76,6 +79,7 @@ class TestStudentModuleStorage(TestCase): def setUp(self): student_module = StudentModuleFactory(state=json.dumps({'a_field': 'a_value', 'b_field': 'b_value'})) self.user = student_module.student + self.assertEqual(self.user.id, 1) # check our assumption hard-coded in the key functions above. self.field_data_cache = FieldDataCache([mock_descriptor([mock_field(Scope.user_state, 'a_field')])], course_id, self.user) self.kvs = DjangoKeyValueStore(self.field_data_cache) @@ -152,6 +156,7 @@ def test_set_many_failure(self): class TestMissingStudentModule(TestCase): def setUp(self): self.user = UserFactory.create(username='user') + self.assertEqual(self.user.id, 1) # check our assumption hard-coded in the key functions above. self.field_data_cache = FieldDataCache([mock_descriptor()], course_id, self.user) self.kvs = DjangoKeyValueStore(self.field_data_cache) From 1e576f235e678a3571efd2b9f8a1960872b36054 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Mon, 25 Nov 2013 14:20:09 -0500 Subject: [PATCH 64/87] Cleanup the AnonymousUserId model and associated functions --- .../commands/anonymized_id_mapping.py | 25 ++++++----- ...e_between_user_and_anonymous_student_id.py | 18 ++++---- common/djangoapps/student/models.py | 42 ++++++++++--------- lms/djangoapps/foldit/tests.py | 4 +- lms/djangoapps/foldit/views.py | 14 +++++-- 5 files changed, 57 insertions(+), 46 deletions(-) diff --git a/common/djangoapps/student/management/commands/anonymized_id_mapping.py b/common/djangoapps/student/management/commands/anonymized_id_mapping.py index f82b140789aa..3c066121c59f 100644 --- a/common/djangoapps/student/management/commands/anonymized_id_mapping.py +++ b/common/djangoapps/student/management/commands/anonymized_id_mapping.py @@ -1,24 +1,19 @@ # -*- coding: utf-8 -*- -"""Dump username, anonymous_id_for_user, simple_anonymous_id_for_user triples as CSV. - -Dumping of simple_anonymous_id_for_user in addition to anonymous_id_for_user is to -enable people that use it, to have a way to correlate w/ the historical data. +"""Dump username, per-student anonymous id, and per-course anonymous id triples as CSV. Give instructors easy access to the mapping from anonymized IDs to user IDs with a simple Django management command to generate a CSV mapping. To run, use the following: -rake django-admin[anonymized_id_mapping,x,y,z] - -[Naturally, substitute the appropriate values for x, y, and z. (I.e., - lms, dev, and MITx/6.002x/Circuits)]""" +./manage.py lms anonymized_id_mapping COURSE_ID +""" import csv from django.contrib.auth.models import User from django.core.management.base import BaseCommand, CommandError -from student.models import anonymous_id_for_user, simple_anonymous_id_for_user +from student.models import anonymous_id_for_user class Command(BaseCommand): @@ -55,9 +50,17 @@ def handle(self, *args, **options): try: with open(output_filename, 'wb') as output_file: csv_writer = csv.writer(output_file) - csv_writer.writerow(("User ID", "Anonymized user ID", "Simple anonymized user ID")) + csv_writer.writerow(( + "User ID", + "Per-Student anonymized user ID", + "Per-course anonymized user id" + )) for student in students: - csv_writer.writerow((student.id, anonymous_id_for_user(student, course_id), simple_anonymous_id_for_user(student))) + csv_writer.writerow(( + student.id, + anonymous_id_for_user(student, ''), + anonymous_id_for_user(student, course_id) + )) except IOError: raise CommandError("Error writing to file: %s" % output_filename) diff --git a/common/djangoapps/student/migrations/0029_add_lookup_table_between_user_and_anonymous_student_id.py b/common/djangoapps/student/migrations/0029_add_lookup_table_between_user_and_anonymous_student_id.py index 9d14e18226b0..e086496bfcc3 100644 --- a/common/djangoapps/student/migrations/0029_add_lookup_table_between_user_and_anonymous_student_id.py +++ b/common/djangoapps/student/migrations/0029_add_lookup_table_between_user_and_anonymous_student_id.py @@ -8,19 +8,19 @@ class Migration(SchemaMigration): def forwards(self, orm): - # Adding model 'AnonymousUsers' - db.create_table('student_anonymoususers', ( + # Adding model 'AnonymousUserId' + db.create_table('student_anonymoususerid', ( ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='anonymous', to=orm['auth.User'])), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), ('anonymous_user_id', self.gf('django.db.models.fields.CharField')(unique=True, max_length=16)), ('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), )) - db.send_create_signal('student', ['AnonymousUsers']) + db.send_create_signal('student', ['AnonymousUserId']) def backwards(self, orm): - # Deleting model 'AnonymousUsers' - db.delete_table('student_anonymoususers') + # Deleting model 'AnonymousUserId' + db.delete_table('student_anonymoususerid') models = { @@ -60,12 +60,12 @@ def backwards(self, orm): 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) }, - 'student.anonymoususers': { - 'Meta': {'object_name': 'AnonymousUsers'}, + 'student.anonymoususerid': { + 'Meta': {'object_name': 'AnonymousUserId'}, 'anonymous_user_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '16'}), 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'anonymous'", 'to': "orm['auth.User']"}) + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) }, 'student.courseenrollment': { 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index bd300800053a..0b62eee0b587 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -43,7 +43,7 @@ AUDIT_LOG = logging.getLogger("audit") -class AnonymousUsers(models.Model): +class AnonymousUserId(models.Model): """ This table contains user, course_Id and anonymous_user_id @@ -52,50 +52,52 @@ class AnonymousUsers(models.Model): We are generating anonymous_user_id using md5 algorithm, so resulting length will always be 16 bytes. http://docs.python.org/2/library/md5.html#md5.digest_size """ - user = models.ForeignKey(User, db_index=True, related_name='anonymous') + user = models.ForeignKey(User, db_index=True) anonymous_user_id = models.CharField(unique=True, max_length=16) course_id = models.CharField(db_index=True, max_length=255) unique_together = (user, course_id) -def simple_anonymous_id_for_user(user, course_id=''): - """ - Return a unique id for a user, suitable for inserting into foldit module table - or into unit tests. - """ - # include the secret key as a salt, and to make the ids unique across different LMS installs. - h = hashlib.md5() - h.update(settings.SECRET_KEY) - h.update(str(user.id)) - h.update(course_id) - return h.hexdigest() def anonymous_id_for_user(user, course_id): """ - Return a unique id for a (user, course) pair, suitable for inserting into e.g. personalized survey links. + Return a unique id for a (user, course) pair, suitable for inserting + into e.g. personalized survey links. + + If user is an `AnonymousUser`, returns `None` """ # This part is for ability to get xblock instance in xblock_noauth handlers, where user is unauthenticated. if user.is_anonymous(): - return 'Anonymous' + return None - return AnonymousUsers.objects.get_or_create( - defaults={'anonymous_user_id': simple_anonymous_id_for_user(user, course_id)}, + # include the secret key as a salt, and to make the ids unique across different LMS installs. + hasher = hashlib.md5() + hasher.update(settings.SECRET_KEY) + hasher.update(str(user.id)) + hasher.update(course_id) + + return AnonymousUserId.objects.get_or_create( + defaults={'anonymous_user_id': hasher.hexdigest()}, user=user, course_id=course_id )[0].anonymous_user_id + def user_by_anonymous_id(id): """ - Return user by anonymous_user_id using AnonymousUsers lookup table. + Return user by anonymous_user_id using AnonymousUserId lookup table. Do not raise `django.ObjectDoesNotExist` exception, if there is no user for anonymous_student_id, because this function will be used inside xmodule w/o django access. """ + + if id is None: + return None + try: - obj = AnonymousUsers.objects.get(anonymous_user_id=id) + return User.objects.get(anonymoususerid__anonymous_user_id=id) except ObjectDoesNotExist: return None - return obj.user class UserStanding(models.Model): diff --git a/lms/djangoapps/foldit/tests.py b/lms/djangoapps/foldit/tests.py index 34a95f101f70..5f2749c67509 100644 --- a/lms/djangoapps/foldit/tests.py +++ b/lms/djangoapps/foldit/tests.py @@ -8,7 +8,7 @@ from foldit.views import foldit_ops, verify_code from foldit.models import PuzzleComplete, Score -from student.models import simple_anonymous_id_for_user +from student.models import anonymous_id_for_user from student.tests.factories import CourseEnrollmentFactory, UserFactory, UserProfileFactory from datetime import datetime, timedelta @@ -346,7 +346,7 @@ def test_SetPlayerPuzzlesComplete_level_complete(self): self.set_puzzle_complete_response([13, 14, 15, 53524])) is_complete = partial( - PuzzleComplete.is_level_complete, simple_anonymous_id_for_user(self.user)) + PuzzleComplete.is_level_complete, anonymous_id_for_user(self.user, '')) self.assertTrue(is_complete(1, 1)) self.assertTrue(is_complete(1, 3)) diff --git a/lms/djangoapps/foldit/views.py b/lms/djangoapps/foldit/views.py index d7727ea041c4..281f154c15c5 100644 --- a/lms/djangoapps/foldit/views.py +++ b/lms/djangoapps/foldit/views.py @@ -8,7 +8,7 @@ from django.views.decorators.csrf import csrf_exempt from foldit.models import Score, PuzzleComplete -from student.models import simple_anonymous_id_for_user +from student.models import anonymous_id_for_user import re @@ -124,7 +124,9 @@ def save_scores(user, puzzle_scores): try: obj = Score.objects.get( user=user, - unique_user_id=simple_anonymous_id_for_user(user), + # For historical reasons, the foldit module uses a per-student + # anonymized user id for storage + unique_user_id=anonymous_id_for_user(user, ''), puzzle_id=puzzle_id, score_version=score_version) obj.current_score = current_score @@ -133,7 +135,9 @@ def save_scores(user, puzzle_scores): except Score.DoesNotExist: obj = Score( user=user, - unique_user_id=simple_anonymous_id_for_user(user), + # For historical reasons, the foldit module uses a per-student + # anonymized user id for storage + unique_user_id=anonymous_id_for_user(user, ''), puzzle_id=puzzle_id, current_score=current_score, best_score=best_score, @@ -160,7 +164,9 @@ def save_complete(user, puzzles_complete): # create if not there PuzzleComplete.objects.get_or_create( user=user, - unique_user_id=simple_anonymous_id_for_user(user), + # For historical reasons, the foldit module uses a per-student + # anonymized user id for storage + unique_user_id=anonymous_id_for_user(user, ''), puzzle_id=puzzle_id, puzzle_set=puzzle_set, puzzle_subset=puzzle_subset) From 38c44e15bf6f5b2bdf0198450d4b5e505f07a474 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Mon, 25 Nov 2013 14:37:26 -0500 Subject: [PATCH 65/87] Use the per-student anonymized id for the subset of modules known to persist those ids --- lms/djangoapps/courseware/module_render.py | 20 ++++- .../courseware/tests/test_module_render.py | 90 ++++++++++++++++++- 2 files changed, 106 insertions(+), 4 deletions(-) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index e3ab265e5454..1f0455cee4fb 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -37,6 +37,10 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule_modifiers import replace_course_urls, replace_jump_to_id_urls, replace_static_urls, add_histogram, wrap_xblock +from xmodule.capa_module import CapaModule +from xmodule.combined_open_ended_module import CombinedOpenEndedModule +from xmodule.html_module import HtmlModule +from xmodule.foldit_module import FolditModule log = logging.getLogger(__name__) @@ -366,6 +370,18 @@ def publish(event, custom_user=None): if has_access(user, descriptor, 'staff', course_id): block_wrappers.append(partial(add_histogram, user)) + # These modules store data using the anonymous_student_id as a key. + # To prevent loss of data, we will continue to provide these modules + # with the per-student anonymized id (as we have in the past), + # while giving all other modules a per-course anonymized id + if issubclass( + getattr(descriptor, 'module_class', None), + (CapaModule, HtmlModule, CombinedOpenEndedModule, FolditModule) + ): + anonymous_student_id = anonymous_id_for_user(user, '') + else: + anonymous_student_id = anonymous_id_for_user(user, course_id) + system = LmsModuleSystem( track_function=track_function, render_template=render_to_string, @@ -397,7 +413,7 @@ def publish(event, custom_user=None): ), node_path=settings.NODE_PATH, publish=publish, - anonymous_student_id=anonymous_id_for_user(user, course_id), + anonymous_student_id=anonymous_student_id, course_id=course_id, open_ended_grading_interface=open_ended_grading_interface, s3_interface=s3_interface, @@ -459,7 +475,7 @@ def xqueue_callback(request, course_id, userid, mod_id, dispatch): # Test xqueue package, which we expect to be: # xpackage = {'xqueue_header': json.dumps({'lms_key':'secretkey',...}), - # 'xqueue_body' : 'Message from grader'} + # 'xqueue_body' : 'Messa/ge from grader'} for key in ['xqueue_header', 'xqueue_body']: if key not in data: raise Http404 diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index 38f42d1f49bc..40f99de9b41c 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -1,6 +1,7 @@ """ Test for lms courseware app, module render unit """ +from ddt import ddt, data from mock import MagicMock, patch, Mock import json @@ -11,10 +12,18 @@ from django.test.client import RequestFactory from django.test.utils import override_settings -from xmodule.modulestore.django import modulestore +from xblock.field_data import FieldData +from xblock.runtime import Runtime +from xblock.fields import ScopeIds +from xmodule.capa_module import CapaDescriptor +from xmodule.combined_open_ended_module import CombinedOpenEndedDescriptor +from xmodule.foldit_module import FolditDescriptor +from xmodule.html_module import HtmlDescriptor from xmodule.modulestore import Location -from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory +from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory +from xmodule.x_module import XModuleDescriptor import courseware.module_render as render from courseware.tests.tests import LoginEnrollmentTestCase from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE @@ -515,3 +524,80 @@ def test_histogram(self): 'Staff Debug', result_fragment.content ) + + +PER_STUDENT_ANONYMIZED_DESCRIPTORS = ( + CapaDescriptor, + HtmlDescriptor, + FolditDescriptor, + CombinedOpenEndedDescriptor +) + +PER_COURSE_ANONYMIZED_DESCRIPTORS = [ + class_ for (name, class_) in XModuleDescriptor.load_classes() + if not issubclass(class_, PER_STUDENT_ANONYMIZED_DESCRIPTORS) +] + + +@ddt +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) +class TestAnonymousStudentId(ModuleStoreTestCase, LoginEnrollmentTestCase): + """ + Test that anonymous_student_id is set correctly across a variety of XBlock types + """ + + def setUp(self): + self.user = UserFactory() + + @patch('courseware.module_render.has_access', Mock(return_value=True)) + def _get_anonymous_id(self, course_id, xblock_class): + location = Location('dummy_org', 'dummy_course', 'dummy_category', 'dummy_name') + descriptor = Mock( + spec=xblock_class, + _field_data=Mock(spec=FieldData), + location=location, + static_asset_path=None, + runtime=Mock( + spec=Runtime, + resources_fs=None, + mixologist=Mock(_mixins=()) + ), + scope_ids=Mock(spec=ScopeIds), + ) + if hasattr(xblock_class, 'module_class'): + descriptor.module_class = xblock_class.module_class + + return render.get_module_for_descriptor_internal( + self.user, + descriptor, + Mock(spec=FieldDataCache), + course_id, + Mock(), # Track Function + Mock(), # XQueue Callback Url Prefix + ).xmodule_runtime.anonymous_student_id + + @data(*PER_STUDENT_ANONYMIZED_DESCRIPTORS) + def test_per_student_anonymized_id(self, descriptor_class): + for course_id in ('MITx/6.00x/2012_Fall', 'MITx/6.00x/2013_Spring'): + self.assertEquals( + # This value is set by observation, so that later changes to the student + # id computation don't break old data + '5afe5d9bb03796557ee2614f5c9611fb', + self._get_anonymous_id(course_id, descriptor_class) + ) + + @data(*PER_COURSE_ANONYMIZED_DESCRIPTORS) + def test_per_course_anonymized_id(self, descriptor_class): + self.assertEquals( + # This value is set by observation, so that later changes to the student + # id computation don't break old data + 'e3b0b940318df9c14be59acb08e78af5', + self._get_anonymous_id('MITx/6.00x/2012_Fall', descriptor_class) + ) + + self.assertEquals( + # This value is set by observation, so that later changes to the student + # id computation don't break old data + 'f82b5416c9f54b5ce33989511bb5ef2e', + self._get_anonymous_id('MITx/6.00x/2013_Spring', descriptor_class) + ) From 1b1a40f4ee98ff9baf5ef359427790380df6baeb Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Mon, 25 Nov 2013 16:53:22 -0500 Subject: [PATCH 66/87] Fix tests --- common/djangoapps/student/tests/tests.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index 6d76ecfc8774..399f42dcc654 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -15,7 +15,7 @@ from django.test import TestCase from django.test.utils import override_settings from django.test.client import RequestFactory -from django.contrib.auth.models import User +from django.contrib.auth.models import User, AnonymousUser from django.contrib.auth.hashers import UNUSABLE_PASSWORD from django.contrib.auth.tokens import default_token_generator from django.utils.http import int_to_base36 @@ -138,18 +138,18 @@ class CourseEndingTest(TestCase): """Test things related to course endings: certificates, surveys, etc""" def test_process_survey_link(self): - course = Mock(id="test_id") - user = Mock(username="fred", id="test_user_id") + course_id = "test_id" + user = UserFactory() link1 = "http://www.mysurvey.com" - self.assertEqual(process_survey_link(link1, user, course.id), link1) + self.assertEqual(process_survey_link(link1, user, course_id), link1) link2 = "http://www.mysurvey.com?unique={UNIQUE_ID}" - # anonymous_id_for_user returns 'Anonymous' because user is not authenticated. - link2_expected = "http://www.mysurvey.com?unique={UNIQUE_ID}".format(UNIQUE_ID='Anonymous') + expected_id = anonymous_id_for_user(user, course_id) + link2_expected = "http://www.mysurvey.com?unique={UNIQUE_ID}".format(UNIQUE_ID=expected_id) - self.assertEqual(process_survey_link(link2, user, course.id), link2_expected) + self.assertEqual(process_survey_link(link2, user, course_id), link2_expected) def test_cert_info(self): """ @@ -529,7 +529,7 @@ class AnonymousLookupTable(TestCase): def setUp(self): self.course = CourseFactory.create(org=self.COURSE_ORG, display_name=self.COURSE_NAME, number=self.COURSE_SLUG) self.assertIsNotNone(self.course) - self.user = UserFactory.create(username="jack", email="jack@fake.edx.org") + self.user = UserFactory() CourseModeFactory.create( course_id=self.course.id, mode_slug='honor', @@ -539,10 +539,9 @@ def setUp(self): self.mock_server_track = patcher.start() self.addCleanup(patcher.stop) - def test_for_unregistered_user(self ): # same path as for logged out user - self.user.is_anonymous = lambda : True - self.assertEqual('Anonymous', anonymous_id_for_user(self.user, self.course.id)) - self.assertIsNone(user_by_anonymous_id('Anonymous')) + def test_for_unregistered_user(self): # same path as for logged out user + self.assertEqual(None, anonymous_id_for_user(AnonymousUser(), self.course.id)) + self.assertIsNone(user_by_anonymous_id(None)) def test_roundtrip_for_logged_user(self): enrollment = CourseEnrollment.enroll(self.user, self.course.id) From ac7597b7a304c93d28e5177815ae4891d8b4a070 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Mon, 25 Nov 2013 17:17:53 -0500 Subject: [PATCH 67/87] Adapt to new way of doing thirdparty handlers. --- cms/djangoapps/contentstore/views/preview.py | 2 +- common/lib/xmodule/xmodule/lti_module.py | 4 +--- common/lib/xmodule/xmodule/tests/__init__.py | 2 +- lms/lib/xblock/runtime.py | 13 +++++++------ requirements/edx/github.txt | 2 +- 5 files changed, 11 insertions(+), 12 deletions(-) diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index 3b2ec85326c8..123d7fbadbdd 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -88,7 +88,7 @@ class PreviewModuleSystem(ModuleSystem): # pylint: disable=abstract-method """ An XModule ModuleSystem for use in Studio previews """ - def handler_url(self, block, handler_name, suffix='', query=''): + def handler_url(self, block, handler_name, suffix='', query='', thirdparty=False): return handler_prefix(block, handler_name, suffix) + '?' + query diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index b8790b4cd0d1..fa06a60e8b19 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -244,8 +244,7 @@ def get_outcome_service_url(self): """ uri = 'http://{host}{path}'.format( host=self.system.hostname, - # path=self.system.get_handler_url('custom_handler'), - path=self.runtime.handler_url(self, 'grade_handler').rstrip('/?') + path=self.runtime.handler_url(self, 'grade_handler', thirdparty=True).rstrip('/?') ) return uri @@ -354,7 +353,6 @@ def max_score(self): @XBlock.handler - @XBlock.unauthenticated def grade_handler(self, request, dispatch): """ This is called by courseware.module_render, to handle an AJAX call. diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py index 90c7f4500985..bfb5bd7f93e4 100644 --- a/common/lib/xmodule/xmodule/tests/__init__.py +++ b/common/lib/xmodule/xmodule/tests/__init__.py @@ -42,7 +42,7 @@ class TestModuleSystem(ModuleSystem): # pylint: disable=abstract-method """ ModuleSystem for testing """ - def handler_url(self, block, handler, suffix='', query=''): + def handler_url(self, block, handler, suffix='', query='', thirdparty=False): return str(block.scope_ids.usage_id) + '/' + handler + '/' + suffix + '?' + query diff --git a/lms/lib/xblock/runtime.py b/lms/lib/xblock/runtime.py index 7d2ac245325a..4fa48be83a83 100644 --- a/lms/lib/xblock/runtime.py +++ b/lms/lib/xblock/runtime.py @@ -58,7 +58,7 @@ def unquote_slashes(text): return re.sub(r'(;;|;_)', _unquote_slashes, text) -def handler_url(course_id, block, handler, suffix='', query=''): +def handler_url(course_id, block, handler, suffix='', query='', thirdparty=False): """ Return an XBlock handler url for the specified course, block and handler. @@ -79,8 +79,8 @@ def handler_url(course_id, block, handler, suffix='', query=''): if not getattr(func, "_is_xblock_handler", False): raise ValueError("{!r} is not a handler name".format(handler)) - if getattr(func, "_is_unauthenticated", False): - view_name = 'xblock_handler_noauth' + if thirdparty: + view_name = 'xblock_handler_noauth' return reverse(view_name, kwargs={ 'course_id': course_id, @@ -109,10 +109,11 @@ class LmsHandlerUrls(object): This must be mixed in to a runtime that already accepts and stores a course_id """ - - def handler_url(self, block, handler_name, suffix='', query=''): # pylint: disable=unused-argument + # pylint: disable=unused-argument + # pylint: disable=no-member + def handler_url(self, block, handler_name, suffix='', query='', thirdparty=False): """See :method:`xblock.runtime:Runtime.handler_url`""" - return handler_url(self.course_id, block, handler_name, suffix='', query='') # pylint: disable=no-member + return handler_url(self.course_id, block, handler_name, suffix='', query='', thirdparty=thirdparty) class LmsModuleSystem(LmsHandlerUrls, ModuleSystem): # pylint: disable=abstract-method diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index dc36112e2387..3f00a7c9159d 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -15,7 +15,7 @@ -e git+https://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk # Our libraries: --e git+https://github.com/edx/XBlock.git@7e387ce17e4421434fce549fca2c3ceb2bc769ee#egg=XBlock +-e git+https://github.com/edx/XBlock.git@341d162f353289cfd3974a4f4f9354ce81ab60db#egg=XBlock -e git+https://github.com/edx/codejail.git@0a1b468#egg=codejail -e git+https://github.com/edx/diff-cover.git@v0.2.6#egg=diff_cover -e git+https://github.com/edx/js-test-tool.git@v0.1.4#egg=js_test_tool From 1d19cb9d6d9c90e45dacf4f842e3d8219cd5c4ac Mon Sep 17 00:00:00 2001 From: polesye Date: Tue, 26 Nov 2013 10:35:40 +0200 Subject: [PATCH 68/87] Fix acceptance test. --- lms/djangoapps/courseware/features/lti.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/courseware/features/lti.py b/lms/djangoapps/courseware/features/lti.py index 06d2ff87fdda..74a1d6672793 100644 --- a/lms/djangoapps/courseware/features/lti.py +++ b/lms/djangoapps/courseware/features/lti.py @@ -265,5 +265,5 @@ def click_grade(_step): iframe_name = 'ltiLaunchFrame-' + location with world.browser.get_iframe(iframe_name) as iframe: iframe.find_by_name('submit-button').first.click() - assert iframe.is_text_present('I have stored grades.') + assert iframe.is_text_present('LTI consumer (edX) responsed with XML content') From 871c00cf439e7bc1cc015bb140cff5e80fb101f3 Mon Sep 17 00:00:00 2001 From: polesye Date: Tue, 26 Nov 2013 10:47:23 +0200 Subject: [PATCH 69/87] Fix test. --- common/lib/xmodule/xmodule/tests/test_lti.py | 1 - lms/djangoapps/courseware/tests/test_lti.py | 41 +++++--------------- 2 files changed, 10 insertions(+), 32 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_lti.py b/common/lib/xmodule/xmodule/tests/test_lti.py index 3743f34e2f1a..8f77057e2b52 100644 --- a/common/lib/xmodule/xmodule/tests/test_lti.py +++ b/common/lib/xmodule/xmodule/tests/test_lti.py @@ -133,7 +133,6 @@ def test_good_request(self): """ self.xmodule.verify_oauth_body_sign = Mock() incorrect_request = Request(self.environ) - # incorrect_request.authorization = "bad authorization header" incorrect_request.body = self.get_request_body() response = self.xmodule.grade_handler(incorrect_request, '') code_major = self.get_code_major(response) diff --git a/lms/djangoapps/courseware/tests/test_lti.py b/lms/djangoapps/courseware/tests/test_lti.py index 353fd4776037..2835976b88c7 100644 --- a/lms/djangoapps/courseware/tests/test_lti.py +++ b/lms/djangoapps/courseware/tests/test_lti.py @@ -5,9 +5,6 @@ from collections import OrderedDict import mock -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory - class TestLTI(BaseTestXmodule): """ @@ -18,28 +15,6 @@ class TestLTI(BaseTestXmodule): of `oauthlib` library. """ CATEGORY = "lti" - grading_policy = None - - def set_up_course(self, **course_kwargs): - """ - Create a stock coursecourse with a specific due date. - - :param course_kwargs: All kwargs are passed to through to the :class:`CourseFactory` - """ - - course = CourseFactory(**course_kwargs) - chapter = ItemFactory(category='chapter', parent_location=course.location) # pylint: disable=no-member - section = self.section = ItemFactory( - category='sequential', - parent_location=chapter.location, - metadata={'graded': True, 'format': 'Homework'} - ) - vertical = ItemFactory(category='vertical', parent_location=section.location) - ItemFactory(category=self.CATEGORY, parent_location=vertical.location) - - course = modulestore().get_instance(course.id, course.location) # pylint: disable=no-member - - return course def setUp(self): """ @@ -52,20 +27,22 @@ def setUp(self): mocked_decoded_signature = u'my_signature=' self.correct_headers = { + u'user_id': unicode(self.item_descriptor.xmodule_runtime.anonymous_student_id), u'oauth_callback': u'about:blank', - u'lis_outcome_service_url': '', - u'lis_result_sourcedid': '', u'launch_presentation_return_url': '', u'lti_message_type': u'basic-lti-launch-request', u'lti_version': 'LTI-1p0', + u'role': u'student', + + u'resource_link_id': u'i4x%3A//MITx/999/lti/lti_3', + u'lis_outcome_service_url': 'http://edx.orgi4x://MITx/999/lti/lti_3/grade_handler', + u'lis_result_sourcedid': u':i4x%253A//MITx/999/lti/lti_3:student', u'oauth_nonce': mocked_nonce, u'oauth_timestamp': mocked_timestamp, u'oauth_consumer_key': u'', u'oauth_signature_method': u'HMAC-SHA1', u'oauth_version': u'1.0', - u'user_id': self.item_descriptor.xmodule_runtime.anonymous_student_id, - u'role': u'student', u'oauth_signature': mocked_decoded_signature } @@ -95,14 +72,16 @@ def test_lti_constructor(self): Makes sure that all parameters extracted. """ generated_context = self.item_module.render('student_view').content + expected_context = { - 'input_fields': self.correct_headers, 'display_name': self.item_module.display_name, - 'element_class': self.item_module.location.category, + 'input_fields': self.correct_headers, + 'element_class': self.item_module.category, 'element_id': self.item_module.location.html_id(), 'launch_url': 'http://www.example.com', # default value 'open_in_a_new_page': True, } + self.assertEqual( generated_context, self.runtime.render_template('lti.html', expected_context), From 7592ca69e4167dec0f28d42b37db887029e8b52f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Tue, 26 Nov 2013 11:34:04 +0200 Subject: [PATCH 70/87] Fix typo. --- lms/djangoapps/courseware/module_render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 1f0455cee4fb..f41aadf2e1f0 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -475,7 +475,7 @@ def xqueue_callback(request, course_id, userid, mod_id, dispatch): # Test xqueue package, which we expect to be: # xpackage = {'xqueue_header': json.dumps({'lms_key':'secretkey',...}), - # 'xqueue_body' : 'Messa/ge from grader'} + # 'xqueue_body' : 'Message from grader'} for key in ['xqueue_header', 'xqueue_body']: if key not in data: raise Http404 From 8dcfd90862de03dc24a8ad093982fb50b1e1d919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Tue, 26 Nov 2013 12:50:17 +0200 Subject: [PATCH 71/87] Fix default path in docs. --- rakelib/docs.rake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rakelib/docs.rake b/rakelib/docs.rake index 1617efa8afc9..09ecac0efbaa 100644 --- a/rakelib/docs.rake +++ b/rakelib/docs.rake @@ -32,7 +32,7 @@ task :showdocs, [:options] do |t, args| elsif args.options == 'data' path = "docs/data" else - path = "docs" + path = "docs/developers" end Launchy.open("#{path}/build/html/index.html") From 0424f764f9012799169a665a49055a19231eb39a Mon Sep 17 00:00:00 2001 From: polesye Date: Tue, 26 Nov 2013 13:32:40 +0200 Subject: [PATCH 72/87] Fix tests. --- common/lib/xmodule/xmodule/lti_module.py | 11 +- .../{test_lti.py => test_lti_integration.py} | 115 ++++++++++++++---- .../tests/{test_lti.py => test_lti_unit.py} | 13 +- 3 files changed, 114 insertions(+), 25 deletions(-) rename common/lib/xmodule/xmodule/tests/{test_lti.py => test_lti_integration.py} (56%) rename lms/djangoapps/courseware/tests/{test_lti.py => test_lti_unit.py} (88%) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index fa06a60e8b19..a334cccac6de 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -407,18 +407,26 @@ def grade_handler(self, request, dispatch): {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: + # - 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': '' } + try: imsx_messageIdentifier, sourcedId, score, action = self.parse_grade_xml_body(request.body) except Exception: @@ -428,6 +436,7 @@ def grade_handler(self, request, dispatch): try: self.verify_oauth_body_sign(request) except (ValueError, LTIError): + failure_values['imsx_messageIdentifier'] = escape(imsx_messageIdentifier) return Response(response_xml_template.format(**failure_values), content_type="application/xml") @@ -443,7 +452,7 @@ def grade_handler(self, request, dispatch): ) values = { - 'imsx_codeMajor': 'Success', + 'imsx_codeMajor': 'success', 'imsx_description': 'Score for {sourced_id} is now {score}'.format(sourced_id=sourcedId, score=score), 'imsx_messageIdentifier': escape(imsx_messageIdentifier), 'response': '' diff --git a/common/lib/xmodule/xmodule/tests/test_lti.py b/common/lib/xmodule/xmodule/tests/test_lti_integration.py similarity index 56% rename from common/lib/xmodule/xmodule/tests/test_lti.py rename to common/lib/xmodule/xmodule/tests/test_lti_integration.py index 8f77057e2b52..9adc2e8cb9da 100644 --- a/common/lib/xmodule/xmodule/tests/test_lti.py +++ b/common/lib/xmodule/xmodule/tests/test_lti_integration.py @@ -6,6 +6,8 @@ from lxml import etree import unittest from webob.request import Request +from copy import copy +import urllib from xmodule.lti_module import LTIModuleDescriptor @@ -19,20 +21,20 @@ class LTIModuleTest(LogicTest): def setUp(self): super(LTIModuleTest, self).setUp() self.environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} - self.requet_body_xml_template = textwrap.dedent(""" + self.request_body_xml_template = textwrap.dedent(""" V1.0 - 528243ba5241b + {messageIdentifier} <{action}> - feb-123-456-2929::28883 + {sourcedId} @@ -49,21 +51,46 @@ def setUp(self): self.xmodule.get_client_key_secret = Mock(return_value=('key', 'secret')) self.system.publish = Mock() - def get_request_body(self, params={}): - data = { + user_id = self.xmodule.runtime.anonymous_student_id + lti_id = self.xmodule.lti_id + module_id = '//MITx/999/lti/' + + sourcedId = u':'.join(urllib.quote(i) for i in (lti_id, module_id, user_id)) + + self.DEFAULTS = { + 'sourcedId': sourcedId, 'action': 'replaceResultRequest', 'grade': '0.5', + 'messageIdentifier': '528243ba5241b', } + def get_request_body(self, params={}): + data = copy(self.DEFAULTS) + data.update(params) - return self.requet_body_xml_template.format(**data) + return self.request_body_xml_template.format(**data) - def get_code_major(self, response): + def get_response_values(self, response): parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') root = etree.fromstring(response.body.strip(), parser=parser) namespaces = {'def': root.nsmap.values()[0]} + code_major = root.xpath("//def:imsx_codeMajor", namespaces=namespaces)[0].text - return code_major + description = root.xpath("//def:imsx_description", namespaces=namespaces)[0].text + messageIdentifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text + imsx_POXBody = root.xpath("//def:imsx_POXBody", namespaces=namespaces)[0] + + try: + action = imsx_POXBody.getchildren()[0].tag.replace('{'+root.nsmap.values()[0]+'}', '') + except Exception: + action = None + + return { + 'code_major': code_major, + 'description': description, + 'messageIdentifier': messageIdentifier, + 'action': action + } def test_authorization_header_not_present(self): """ @@ -73,9 +100,16 @@ def test_authorization_header_not_present(self): incorrect_request = Request(self.environ) incorrect_request.body = self.get_request_body() response = self.xmodule.grade_handler(incorrect_request, '') - code_major = self.get_code_major(response) + real_repsonse = self.get_response_values(response) + expected_response = { + 'action': None, + 'code_major': 'failure', + 'description': 'The request has failed.', + 'messageIdentifier': self.DEFAULTS['messageIdentifier'], + } + self.assertEqual(response.status_code, 200) - self.assertEqual(code_major, 'failure') + self.assertDictEqual(expected_response, real_repsonse) def test_authorization_header_empty(self): """ @@ -86,9 +120,16 @@ def test_authorization_header_empty(self): incorrect_request.authorization = "bad authorization header" incorrect_request.body = self.get_request_body() response = self.xmodule.grade_handler(incorrect_request, '') - code_major = self.get_code_major(response) + real_repsonse = self.get_response_values(response) + expected_response = { + 'action': None, + 'code_major': 'failure', + 'description': 'The request has failed.', + 'messageIdentifier': self.DEFAULTS['messageIdentifier'], + } + self.assertEqual(response.status_code, 200) - self.assertEqual(code_major, 'failure') + self.assertDictEqual(expected_response, real_repsonse) def test_grade_not_in_range(self): """ @@ -98,9 +139,16 @@ def test_grade_not_in_range(self): incorrect_request = Request(self.environ) incorrect_request.body = self.get_request_body(params={'grade': '10'}) response = self.xmodule.grade_handler(incorrect_request, '') + real_repsonse = self.get_response_values(response) + expected_response = { + 'action': None, + 'code_major': 'failure', + 'description': 'The request has failed.', + 'messageIdentifier': 'unknown', + } + self.assertEqual(response.status_code, 200) - code_major = self.get_code_major(response) - self.assertEqual(code_major, 'failure') + self.assertDictEqual(expected_response, real_repsonse) def test_bad_grade_decimal(self): """ @@ -110,9 +158,16 @@ def test_bad_grade_decimal(self): incorrect_request = Request(self.environ) incorrect_request.body = self.get_request_body(params={'grade': '0,5'}) response = self.xmodule.grade_handler(incorrect_request, '') - code_major = self.get_code_major(response) + real_repsonse = self.get_response_values(response) + expected_response = { + 'action': None, + 'code_major': 'failure', + 'description': 'The request has failed.', + 'messageIdentifier': 'unknown', + } + self.assertEqual(response.status_code, 200) - self.assertEqual(code_major, 'failure') + self.assertDictEqual(expected_response, real_repsonse) def test_unsupported_action(self): """ @@ -123,9 +178,16 @@ def test_unsupported_action(self): incorrect_request = Request(self.environ) incorrect_request.body = self.get_request_body({'action': 'wrongAction'}) response = self.xmodule.grade_handler(incorrect_request, '') - code_major = self.get_code_major(response) + real_repsonse = self.get_response_values(response) + expected_response = { + 'action': None, + 'code_major': 'unsupported', + 'description': 'Target does not support the requested operation.', + 'messageIdentifier': self.DEFAULTS['messageIdentifier'], + } + self.assertEqual(response.status_code, 200) - self.assertEqual(code_major, 'unsupported') + self.assertDictEqual(expected_response, real_repsonse) def test_good_request(self): """ @@ -135,9 +197,20 @@ def test_good_request(self): incorrect_request = Request(self.environ) incorrect_request.body = self.get_request_body() response = self.xmodule.grade_handler(incorrect_request, '') - code_major = self.get_code_major(response) - self.assertEqual(response.status_code, 200) - self.assertEqual(code_major, 'Success') + code_major, description, messageIdentifier, action = self.get_response_values(response) + description_expected = 'Score for {sourcedId} is now {score}'.format( + sourcedId=self.DEFAULTS['sourcedId'], + score=self.DEFAULTS['grade'], + ) + real_repsonse = self.get_response_values(response) + expected_response = { + 'action': 'replaceResultResponse', + 'code_major': 'success', + 'description': description_expected, + 'messageIdentifier': self.DEFAULTS['messageIdentifier'], + } + self.assertEqual(response.status_code, 200) + self.assertDictEqual(expected_response, real_repsonse) diff --git a/lms/djangoapps/courseware/tests/test_lti.py b/lms/djangoapps/courseware/tests/test_lti_unit.py similarity index 88% rename from lms/djangoapps/courseware/tests/test_lti.py rename to lms/djangoapps/courseware/tests/test_lti_unit.py index 2835976b88c7..2c7f96ed5512 100644 --- a/lms/djangoapps/courseware/tests/test_lti.py +++ b/lms/djangoapps/courseware/tests/test_lti_unit.py @@ -4,6 +4,7 @@ from . import BaseTestXmodule from collections import OrderedDict import mock +import urllib class TestLTI(BaseTestXmodule): @@ -26,17 +27,23 @@ def setUp(self): mocked_signature_after_sign = u'my_signature%3D' mocked_decoded_signature = u'my_signature=' + lti_id = self.item_module.lti_id + module_id = unicode(urllib.quote(self.item_module.id)) + user_id = unicode(self.item_descriptor.xmodule_runtime.anonymous_student_id) + + sourcedId = u':'.join(urllib.quote(i) for i in (lti_id, module_id, user_id)) + self.correct_headers = { - u'user_id': unicode(self.item_descriptor.xmodule_runtime.anonymous_student_id), + u'user_id': user_id, u'oauth_callback': u'about:blank', u'launch_presentation_return_url': '', u'lti_message_type': u'basic-lti-launch-request', u'lti_version': 'LTI-1p0', u'role': u'student', - u'resource_link_id': u'i4x%3A//MITx/999/lti/lti_3', + u'resource_link_id': module_id, u'lis_outcome_service_url': 'http://edx.orgi4x://MITx/999/lti/lti_3/grade_handler', - u'lis_result_sourcedid': u':i4x%253A//MITx/999/lti/lti_3:student', + u'lis_result_sourcedid': sourcedId, u'oauth_nonce': mocked_nonce, u'oauth_timestamp': mocked_timestamp, From 7beefd582802ee9ab0848f31a1b7bbce262818c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Tue, 26 Nov 2013 14:57:59 +0200 Subject: [PATCH 73/87] Fix unittest. --- lms/djangoapps/courseware/tests/test_lti_unit.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/courseware/tests/test_lti_unit.py b/lms/djangoapps/courseware/tests/test_lti_unit.py index 2c7f96ed5512..e690146b905c 100644 --- a/lms/djangoapps/courseware/tests/test_lti_unit.py +++ b/lms/djangoapps/courseware/tests/test_lti_unit.py @@ -33,6 +33,10 @@ def setUp(self): sourcedId = u':'.join(urllib.quote(i) for i in (lti_id, module_id, user_id)) + lis_outcome_service_url = 'http://{host}{path}'.format( + host=self.item_descriptor.xmodule_runtime.hostname, + path=self.item_descriptor.xmodule_runtime.handler_url(self.item_module, 'grade_handler', thirdparty=True).rstrip('/?') + ) self.correct_headers = { u'user_id': user_id, u'oauth_callback': u'about:blank', @@ -42,7 +46,7 @@ def setUp(self): u'role': u'student', u'resource_link_id': module_id, - u'lis_outcome_service_url': 'http://edx.orgi4x://MITx/999/lti/lti_3/grade_handler', + u'lis_outcome_service_url': lis_outcome_service_url, u'lis_result_sourcedid': sourcedId, u'oauth_nonce': mocked_nonce, From 657f9d614bd6d6fad9b0112d3cab298e729d9d04 Mon Sep 17 00:00:00 2001 From: polesye Date: Tue, 26 Nov 2013 15:23:23 +0200 Subject: [PATCH 74/87] Rename unittests. --- .../tests/{test_lti_integration.py => test_lti_unit.py} | 6 +++--- .../tests/{test_lti_unit.py => test_lti_integration.py} | 0 2 files changed, 3 insertions(+), 3 deletions(-) rename common/lib/xmodule/xmodule/tests/{test_lti_integration.py => test_lti_unit.py} (98%) rename lms/djangoapps/courseware/tests/{test_lti_unit.py => test_lti_integration.py} (100%) diff --git a/common/lib/xmodule/xmodule/tests/test_lti_integration.py b/common/lib/xmodule/xmodule/tests/test_lti_unit.py similarity index 98% rename from common/lib/xmodule/xmodule/tests/test_lti_integration.py rename to common/lib/xmodule/xmodule/tests/test_lti_unit.py index 9adc2e8cb9da..8702b829b024 100644 --- a/common/lib/xmodule/xmodule/tests/test_lti_integration.py +++ b/common/lib/xmodule/xmodule/tests/test_lti_unit.py @@ -4,7 +4,6 @@ from mock import Mock import textwrap from lxml import etree -import unittest from webob.request import Request from copy import copy import urllib @@ -73,7 +72,8 @@ def get_request_body(self, params={}): def get_response_values(self, response): parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') root = etree.fromstring(response.body.strip(), parser=parser) - namespaces = {'def': root.nsmap.values()[0]} + 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 @@ -81,7 +81,7 @@ def get_response_values(self, response): imsx_POXBody = root.xpath("//def:imsx_POXBody", namespaces=namespaces)[0] try: - action = imsx_POXBody.getchildren()[0].tag.replace('{'+root.nsmap.values()[0]+'}', '') + action = imsx_POXBody.getchildren()[0].tag.replace('{'+lti_spec_namespace+'}', '') except Exception: action = None diff --git a/lms/djangoapps/courseware/tests/test_lti_unit.py b/lms/djangoapps/courseware/tests/test_lti_integration.py similarity index 100% rename from lms/djangoapps/courseware/tests/test_lti_unit.py rename to lms/djangoapps/courseware/tests/test_lti_integration.py From 03e0da89f7ac5d896126145f4d080c24953de114 Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Tue, 26 Nov 2013 15:27:13 +0200 Subject: [PATCH 75/87] Moved LTI docs to course_authors section. Corrected several things in rst file. --- .../source/create_lti.rst} | 40 ++++++++++--------- docs/course_authors/source/index.rst | 7 ++-- 2 files changed, 26 insertions(+), 21 deletions(-) rename docs/{data/source/course_data_formats/lti_module/lti.rst => course_authors/source/create_lti.rst} (77%) diff --git a/docs/data/source/course_data_formats/lti_module/lti.rst b/docs/course_authors/source/create_lti.rst similarity index 77% rename from docs/data/source/course_data_formats/lti_module/lti.rst rename to docs/course_authors/source/create_lti.rst index 78d8a5b4c8dd..00ccb71c6778 100644 --- a/docs/data/source/course_data_formats/lti_module/lti.rst +++ b/docs/course_authors/source/create_lti.rst @@ -1,8 +1,6 @@ -********************************************** -LTI module [xmodule] -********************************************** - -.. module:: lti_module +********************** +Create a LTI Component +********************** Description =========== @@ -18,15 +16,21 @@ It is not available from the list of general components. To turn it on, add The module supports 2 modes of operation. 1. Simple display of external LTI content -2. display of LTI content that will be graded by external provider +2. Display of LTI content that will be graded by external provider In both cases, before an LTI component from an external provider can be included in a unit, the following pieces of information must be known/decided upon: -- LTI id: Internal string representing the external LTI provider. Can be anything. -- Client key: Used for OAuth authentication. Issued by external LTI provider. -- Client secret: Used for OAuth authentication. Issued by external LTI provider. +**LTI id** [string] + Internal string representing the external LTI provider. Can contain multi- + case alphanumeric characters, and underscore. + +**Client key** [string] + Used for OAuth authentication. Issued by external LTI provider. + +**Client secret** [string] + Used for OAuth authentication. Issued by external LTI provider. LTI id is necessary to differentiate between multiple available external LTI providers that are added to an edX course. @@ -55,37 +59,37 @@ LTI will be available from the Advanced Component category. After adding an LTI component to a unit, it can be configured by Editing it's settings (the Edit dialog). The following settings are available: -*Display Name* [string] +**Display Name** [string] Title of the new LTI component instance -*custom_parameters* [string] +**custom_parameters** [string] With the "+ Add" button, multiple custom parameters can be added. Basically, each individual external LTI provider can have a separate format custom parameters. For example:: key=value -*graded* [boolean] - Whether or not this particular LTI instance problem will be - graded by the external LTI provider. +**graded** [boolean] + Whether or not the grade for this particular LTI instance problem will be + counted towards student's total grade. -*launch_url* [string] +**launch_url** [string] If `rgaded` above is set to `true`, then this must be the URL that will be passed to the external LTI provider for it to respond with a grade. -*lti_id* [string] +**lti_id** [string] Internal string representing the external LTI provider that will be used to display content. The same as was entered on the Advanced Settings page. -*open_in_a_new_page* [boolean] +**open_in_a_new_page** [boolean] If set to `true`, a link will be present for the student to click. When the link is clicked, a new window will open with the external LTI content. If set to `false`, the external LTI content will be loaded in the page in an iframe. -*weight* [float] +**weight** [float] If the problem will be graded by an external LTI provider, the raw grade will be in the range [0.0, 1.0]. In order to change this range, set the `weight`. The grade that will be stored is calculated by the formula:: diff --git a/docs/course_authors/source/index.rst b/docs/course_authors/source/index.rst index 5e17e39106be..1708770eece0 100755 --- a/docs/course_authors/source/index.rst +++ b/docs/course_authors/source/index.rst @@ -20,6 +20,7 @@ Contents create_video create_discussion create_html_component + create_lti create_problem set_content_releasedates establish_course_settings @@ -34,13 +35,13 @@ Contents checking_student_progress change_log - - -Appendices + + +Appendices ========== .. toctree:: From 2f12eadd514ee1729c9dabb86adcbe07ab07befd Mon Sep 17 00:00:00 2001 From: Oleg Marshev Date: Tue, 26 Nov 2013 16:20:38 +0200 Subject: [PATCH 76/87] Fix. --- .../mock_lti_server/test_mock_lti_server.py | 66 +++++++++---------- 1 file changed, 30 insertions(+), 36 deletions(-) diff --git a/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py b/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py index 2ffbfacf5236..80dd3cc42f76 100644 --- a/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py +++ b/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py @@ -1,11 +1,15 @@ """ Test for Mock_LTI_Server """ +import mock +from mock import Mock import unittest import threading +import textwrap import urllib -from mock_lti_server import MockLTIServer import requests +from mock_lti_server import MockLTIServer + class MockLTIServerTest(unittest.TestCase): @@ -20,7 +24,7 @@ def setUp(self): # Create the server server_port = 8034 - server_host = '127.0.0.1' + server_host = 'localhost' address = (server_host, server_port) self.server = MockLTIServer(address) self.server.oauth_settings = { @@ -39,12 +43,12 @@ def tearDown(self): # Stop the server, freeing up the port self.server.shutdown() - def test_request(self): + def test_wrong_signature(self): """ Tests that LTI server processes request with right program - path, and responses with incorrect signature. + path and responses with incorrect signature. """ - request = { + payload = { 'user_id': 'default_user_id', 'role': 'student', 'oauth_nonce': '', @@ -58,26 +62,23 @@ def test_request(self): 'oauth_callback': 'about:blank', 'launch_presentation_return_url': '', 'lis_outcome_service_url': '', - 'lis_result_sourcedid': '' + 'lis_result_sourcedid': '', + 'resource_link_id':'', } - response_handle = urllib.urlopen( - self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint'], - urllib.urlencode(request) - ) - response = response_handle.read() - self.assertTrue('Wrong LTI signature' in response) + uri = self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint'] + headers = {'referer': 'http://localhost:8000/'} + response = requests.post(uri, data=payload, headers=headers) - def test_graded_request(self): + self.assertTrue('Wrong LTI signature' in response.content) + + + def test_success_response_launch_lti(self): """ - Tests that LTI server processes a graded request. It should trigger - the callback URL provided. + Success lti launch. """ - server_port = 8000 - server_host = 'localhost' - callback_url = 'http://{}:{}/grade_lti'.format(server_host, server_port) - request = { + payload = { 'user_id': 'default_user_id', 'role': 'student', 'oauth_nonce': '', @@ -92,22 +93,15 @@ def test_graded_request(self): 'launch_presentation_return_url': '', 'lis_outcome_service_url': '', 'lis_result_sourcedid': '', - - # TODO: Generate properly. - "lis_person_sourcedid": "857298237538593757", - - # TODO: Get course based callback URL. - "lis_outcome_service_url": callback_url, + 'resource_link_id':'', + "lis_outcome_service_url": '', } + self.server.check_oauth_signature = Mock(return_value=True) + + uri = self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint'] + headers = {'referer': 'http://localhost:8000/'} + + response = requests.post(uri, data=payload, headers=headers) + + self.assertTrue('This is LTI tool. Success.' in response.content) - response_handle = urllib.urlopen( - self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint'], - urllib.urlencode(request) - ) - response = response_handle.read() - self.assertTrue('Wrong LTI signature' in response) - - # reading grading result back - response = requests.get(callback_url) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.content, "Hello, Valera and Anton!") From f1fcc8a09ce9103525bdc0d1bbca387fec2c0214 Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Tue, 26 Nov 2013 16:31:40 +0200 Subject: [PATCH 77/87] Updated Python doc strings for LTI. --- common/lib/xmodule/xmodule/lti_module.py | 108 ++++++++++++++--------- 1 file changed, 66 insertions(+), 42 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index a334cccac6de..bac31e75a71b 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -1,14 +1,38 @@ """ -Module that allows to insert LTI tools to page. +Learning Tools Interoperability (LTI) module. -Protocol is oauth1, LTI version is 1.1.1: -http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html -Table A1.2 Interpretation of the 'CodeMajor/severity' matrix. -http://www.imsglobal.org/gws/gwsv1p0/imsgws_wsdlBindv1p0.html +Resources +--------- -play and test: -http://www.imsglobal.org/developers/LTI/test/v1p1/lms.php +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 + + +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 advance LTI component that provides back a grade. + 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. """ import logging @@ -46,7 +70,7 @@ class LTIFields(object): 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. + `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 @@ -56,7 +80,7 @@ class LTIFields(object): 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):: + 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 """ @@ -69,7 +93,7 @@ class LTIFields(object): class LTIModule(LTIFields, XModule): - ''' + """ Module provides LTI integration to course. Except usual Xmodule structure it proceeds with OAuth signing. @@ -96,7 +120,7 @@ class LTIModule(LTIFields, XModule): 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 data which is generated after signing is usual:: oauth_callback oauth_nonce @@ -112,34 +136,34 @@ class LTIModule(LTIFields, XModule): Form example::
- - - - - - - - - - - - - - - - - - - - -
+ action="${launch_url}" + name="ltiLaunchForm-${element_id}" + class="ltiLaunchForm" + method="post" + target="ltiLaunchFrame-${element_id}" + encType="application/x-www-form-urlencoded" + > + + + + + + + + + + + + + + + + + + + + + 5. LTI provider has same secret key and it signs data string via *OAuth1* and compares signatures. @@ -147,7 +171,7 @@ class LTIModule(LTIFields, XModule): and LTI tool is rendered to iframe inside course. Otherwise error message from LTI provider is generated. - ''' + """ js = {'js': [resource_string(__name__, 'js/src/lti/lti.js')]} css = {'scss': [resource_string(__name__, 'css/lti/lti.scss')]} @@ -223,7 +247,7 @@ def get_html(self): context = { 'input_fields': input_fields, - # these params do not participate in oauth signing + # these params do not participate in OAuth signing 'launch_url': self.launch_url.strip(), 'element_id': self.location.html_id(), 'element_class': self.category, @@ -240,7 +264,7 @@ def get_user_id(self): def get_outcome_service_url(self): """ - Return url for storing grades + Return URL for storing grades """ uri = 'http://{host}{path}'.format( host=self.system.hostname, From 6d5dee57768c595a10918a4b20faf42742a6f01d Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Tue, 26 Nov 2013 16:42:28 +0200 Subject: [PATCH 78/87] Tidy up LTI docs in PY. --- common/lib/xmodule/xmodule/lti_module.py | 42 +++++++++++++----------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index bac31e75a71b..aca65d19fe44 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -29,7 +29,7 @@ 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 advance LTI component that provides back a grade. +4.) Use of advanced LTI component that provides back a grade. 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. @@ -247,7 +247,7 @@ def get_html(self): context = { 'input_fields': input_fields, - # these params do not participate in OAuth signing + # These parameters do not participate in OAuth signing. 'launch_url': self.launch_url.strip(), 'element_id': self.location.html_id(), 'element_class': self.category, @@ -264,7 +264,7 @@ def get_user_id(self): def get_outcome_service_url(self): """ - Return URL for storing grades + Return URL for storing grades. """ uri = 'http://{host}{path}'.format( host=self.system.hostname, @@ -276,10 +276,13 @@ 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. """ return unicode(urllib.quote(self.id)) @@ -295,7 +298,7 @@ def get_lis_result_sourcedid(self): context_id is - is an opaque identifier that uniquely identifies the context that contains the link being launched. - lti_id should be context_id by meaning + lti_id should be context_id by meaning. """ return u':'.join(urllib.quote(i) for i in (self.lti_id, self.get_resource_link_id(), self.get_user_id())) @@ -315,7 +318,7 @@ def oauth_params(self, custom_parameters, client_key, client_secret): client_secret=unicode(client_secret) ) - # must have parameters for correct signing from LTI: + # Must have parameters for correct signing from LTI: body = { u'user_id': self.get_user_id(), u'oauth_callback': u'about:blank', @@ -331,7 +334,7 @@ def oauth_params(self, custom_parameters, client_key, client_secret): } - # appending custom parameter for signing + # Appending custom parameter for signing. body.update(custom_parameters) headers = { @@ -345,9 +348,9 @@ def oauth_params(self, custom_parameters, client_key, client_secret): http_method=u'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: + except ValueError: # Scheme not in url. + # https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136 + # Stubbing headers for now: headers = { u'Content-Type': u'application/x-www-form-urlencoded', u'Authorization': u'OAuth oauth_nonce="80966668944732164491378916897", \ @@ -355,7 +358,7 @@ def oauth_params(self, custom_parameters, client_key, client_secret): oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'} params = headers['Authorization'] - # parse headers to pass to template as part of context: + # Parse headers to pass to template as part of context: params = dict([param.strip().replace('"', '').split('=') for param in params.split(',')]) params[u'oauth_nonce'] = params[u'OAuth oauth_nonce'] @@ -368,7 +371,7 @@ def oauth_params(self, custom_parameters, client_key, client_secret): # So we need to decode signature back: params[u'oauth_signature'] = urllib.unquote(params[u'oauth_signature']).decode('utf8') - # add lti parameters to oauth parameters for sending in form + # Add LTI parameters to OAuth parameters for sending in form. params.update(body) return params @@ -410,7 +413,7 @@ def grade_handler(self, request, dispatch):
- Example of correct/incorrect answer XML body:: see response_xml_template + Example of correct/incorrect answer XML body:: see response_xml_template. """ response_xml_template = textwrap.dedent(""" @@ -443,7 +446,7 @@ def grade_handler(self, request, dispatch): # Returns if: # - score is out of range; # - can't parse response from TP; - # - can't verify oauth signing or oauth signing is incorrect. + # - can't verify OAuth signing or OAuth signing is incorrect. failure_values = { 'imsx_codeMajor': 'failure', 'imsx_description': 'The request has failed.', @@ -456,7 +459,7 @@ def grade_handler(self, request, dispatch): except Exception: return Response(response_xml_template.format(**failure_values), content_type="application/xml") - # verify oauth signing + # Verify OAuth signing. try: self.verify_oauth_body_sign(request) except (ValueError, LTIError): @@ -509,7 +512,7 @@ def parse_grade_xml_body(cls, body): 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+'}', '') - #Raise exception if score is not float or not in range 0.0-1.0 regarding spec. + # 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 @@ -525,10 +528,11 @@ def verify_oauth_body_sign(self, request): This specification extends the OAuth signature to include integrity checks on HTTP request bodies with content types other than application/x-www-form-urlencoded. - Args: - request: DjangoWebobRequest. + Arguments: + request: DjangoWebobRequest. - Raises: LTIError if request is incorrect. + Raises: + LTIError if request is incorrect. """ client_key, client_secret = self.get_client_key_secret() @@ -558,7 +562,7 @@ def verify_oauth_body_sign(self, request): def get_client_key_secret(self): """ - Obtains client_key and client_secret credentials from current course. + Obtains client_key and client_secret credentials from current course. """ course_id = self.course_id course_location = CourseDescriptor.id_to_location(course_id) From 42bef78f2ee25e0fed99ce813d5c7cb770075379 Mon Sep 17 00:00:00 2001 From: polesye Date: Tue, 26 Nov 2013 16:54:40 +0200 Subject: [PATCH 79/87] Work in progress. --- .../xmodule/xmodule/tests/test_lti_unit.py | 21 ++++++++++++++++++- .../courseware/tests/test_lti_integration.py | 21 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/tests/test_lti_unit.py b/common/lib/xmodule/xmodule/tests/test_lti_unit.py index 8702b829b024..9b6b0fd212b1 100644 --- a/common/lib/xmodule/xmodule/tests/test_lti_unit.py +++ b/common/lib/xmodule/xmodule/tests/test_lti_unit.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """Test for LTI Xmodule functional logic.""" -from mock import Mock +from mock import Mock, patch import textwrap from lxml import etree from webob.request import Request @@ -213,4 +213,23 @@ def test_good_request(self): self.assertEqual(response.status_code, 200) self.assertDictEqual(expected_response, real_repsonse) + def test_user_id(self): + expected_user_id = unicode(urllib.quote(self.xmodule.runtime.anonymous_student_id)) + real_user_id = self.xmodule.get_user_id() + self.assertEqual(real_user_id, expected_user_id) + + def test_outcome_service_url(self): + expected_outcome_service_url = 'http://{host}{path}'.format( + host=self.xmodule.runtime.hostname, + path=self.xmodule.runtime.handler_url(self.xmodule, 'grade_handler', thirdparty=True).rstrip('/?') + ) + + real_outcome_service_url = self.xmodule.get_outcome_service_url() + self.assertEqual(real_outcome_service_url, expected_outcome_service_url) + + def test_verify_oauth_body_sign(self): + pass + + def test_client_key_secret(self): + pass diff --git a/lms/djangoapps/courseware/tests/test_lti_integration.py b/lms/djangoapps/courseware/tests/test_lti_integration.py index e690146b905c..5fda91871629 100644 --- a/lms/djangoapps/courseware/tests/test_lti_integration.py +++ b/lms/djangoapps/courseware/tests/test_lti_integration.py @@ -5,6 +5,8 @@ from collections import OrderedDict import mock import urllib +from xmodule.lti_module import LTIModule +from mock import Mock class TestLTI(BaseTestXmodule): @@ -97,3 +99,22 @@ def test_lti_constructor(self): generated_context, self.runtime.render_template('lti.html', expected_context), ) + + + def test_resource_link_id(self): + module = LTIModule(self.item_descriptor, self.runtime, self.item_descriptor._field_data, Mock()) + expected_resource_link_id = unicode(self.item_descriptor.xmodule_runtime.anonymous_student_id) + real_resource_link_id = module.get_resource_link_id() + + self.assertEqual(real_resource_link_id, expected_resource_link_id) + + + def test_lis_result_sourcedid(self): + user_id = self.item_descriptor.xmodule_runtime.anonymous_student_id + lti_id = self.item_module.lti_id + module_id = 'test' + expected_sourcedId = u':'.join(urllib.quote(i) for i in (lti_id, module_id, user_id)) + + real_lis_result_sourcedid = self.item_module.get_lis_result_sourcedid() + self.assertEqual(real_lis_result_sourcedid, expected_sourcedId) + From 22e579f5faed80a7b0a9bd09c50201695fbf9f82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Tue, 26 Nov 2013 18:00:00 +0200 Subject: [PATCH 80/87] Add two tests for LTI module functions. --- .../xmodule/xmodule/tests/test_lti_unit.py | 90 +++++++++++-------- .../courseware/tests/test_lti_integration.py | 19 ---- 2 files changed, 53 insertions(+), 56 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_lti_unit.py b/common/lib/xmodule/xmodule/tests/test_lti_unit.py index 9b6b0fd212b1..18a658b7fdc1 100644 --- a/common/lib/xmodule/xmodule/tests/test_lti_unit.py +++ b/common/lib/xmodule/xmodule/tests/test_lti_unit.py @@ -1,14 +1,14 @@ # -*- coding: utf-8 -*- """Test for LTI Xmodule functional logic.""" -from mock import Mock, patch +from mock import Mock, patch, PropertyMock import textwrap from lxml import etree from webob.request import Request from copy import copy import urllib -from xmodule.lti_module import LTIModuleDescriptor +from xmodule.lti_module import LTIModuleDescriptor, LTIModule from . import LogicTest @@ -50,11 +50,11 @@ def setUp(self): self.xmodule.get_client_key_secret = Mock(return_value=('key', 'secret')) self.system.publish = Mock() - user_id = self.xmodule.runtime.anonymous_student_id - lti_id = self.xmodule.lti_id - module_id = '//MITx/999/lti/' + self.user_id = self.xmodule.runtime.anonymous_student_id + self.lti_id = self.xmodule.lti_id + self.module_id = '//MITx/999/lti/' - sourcedId = u':'.join(urllib.quote(i) for i in (lti_id, module_id, user_id)) + sourcedId = u':'.join(urllib.quote(i) for i in (self.lti_id, self.module_id, self.user_id)) self.DEFAULTS = { 'sourcedId': sourcedId, @@ -97,10 +97,10 @@ def test_authorization_header_not_present(self): Request has no Authorization header. This is an unknown service request, i.e., it is not a part of the original service specification. """ - incorrect_request = Request(self.environ) - incorrect_request.body = self.get_request_body() - response = self.xmodule.grade_handler(incorrect_request, '') - real_repsonse = self.get_response_values(response) + request = Request(self.environ) + request.body = self.get_request_body() + response = self.xmodule.grade_handler(request, '') + real_response = self.get_response_values(response) expected_response = { 'action': None, 'code_major': 'failure', @@ -109,18 +109,18 @@ def test_authorization_header_not_present(self): } self.assertEqual(response.status_code, 200) - self.assertDictEqual(expected_response, real_repsonse) + self.assertDictEqual(expected_response, real_response) def test_authorization_header_empty(self): """ Request Authorization header has no value. This is an unknown service request, i.e., it is not a part of the original service specification. """ - incorrect_request = Request(self.environ) - incorrect_request.authorization = "bad authorization header" - incorrect_request.body = self.get_request_body() - response = self.xmodule.grade_handler(incorrect_request, '') - real_repsonse = self.get_response_values(response) + request = Request(self.environ) + request.authorization = "bad authorization header" + request.body = self.get_request_body() + response = self.xmodule.grade_handler(request, '') + real_response = self.get_response_values(response) expected_response = { 'action': None, 'code_major': 'failure', @@ -129,17 +129,17 @@ def test_authorization_header_empty(self): } self.assertEqual(response.status_code, 200) - self.assertDictEqual(expected_response, real_repsonse) + self.assertDictEqual(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.xmodule.verify_oauth_body_sign = Mock() - incorrect_request = Request(self.environ) - incorrect_request.body = self.get_request_body(params={'grade': '10'}) - response = self.xmodule.grade_handler(incorrect_request, '') - real_repsonse = self.get_response_values(response) + request = Request(self.environ) + request.body = self.get_request_body(params={'grade': '10'}) + response = self.xmodule.grade_handler(request, '') + real_response = self.get_response_values(response) expected_response = { 'action': None, 'code_major': 'failure', @@ -148,17 +148,17 @@ def test_grade_not_in_range(self): } self.assertEqual(response.status_code, 200) - self.assertDictEqual(expected_response, real_repsonse) + 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.xmodule.verify_oauth_body_sign = Mock() - incorrect_request = Request(self.environ) - incorrect_request.body = self.get_request_body(params={'grade': '0,5'}) - response = self.xmodule.grade_handler(incorrect_request, '') - real_repsonse = self.get_response_values(response) + request = Request(self.environ) + request.body = self.get_request_body(params={'grade': '0,5'}) + response = self.xmodule.grade_handler(request, '') + real_response = self.get_response_values(response) expected_response = { 'action': None, 'code_major': 'failure', @@ -167,7 +167,7 @@ def test_bad_grade_decimal(self): } self.assertEqual(response.status_code, 200) - self.assertDictEqual(expected_response, real_repsonse) + self.assertDictEqual(expected_response, real_response) def test_unsupported_action(self): """ @@ -175,10 +175,10 @@ def test_unsupported_action(self): `replaceResultRequest` is supported only. """ self.xmodule.verify_oauth_body_sign = Mock() - incorrect_request = Request(self.environ) - incorrect_request.body = self.get_request_body({'action': 'wrongAction'}) - response = self.xmodule.grade_handler(incorrect_request, '') - real_repsonse = self.get_response_values(response) + request = Request(self.environ) + request.body = self.get_request_body({'action': 'wrongAction'}) + response = self.xmodule.grade_handler(request, '') + real_response = self.get_response_values(response) expected_response = { 'action': None, 'code_major': 'unsupported', @@ -187,22 +187,22 @@ def test_unsupported_action(self): } self.assertEqual(response.status_code, 200) - self.assertDictEqual(expected_response, real_repsonse) + self.assertDictEqual(expected_response, real_response) def test_good_request(self): """ Response from Tool Provider is correct. """ self.xmodule.verify_oauth_body_sign = Mock() - incorrect_request = Request(self.environ) - incorrect_request.body = self.get_request_body() - response = self.xmodule.grade_handler(incorrect_request, '') + request = Request(self.environ) + request.body = self.get_request_body() + response = self.xmodule.grade_handler(request, '') code_major, description, messageIdentifier, action = self.get_response_values(response) description_expected = 'Score for {sourcedId} is now {score}'.format( sourcedId=self.DEFAULTS['sourcedId'], score=self.DEFAULTS['grade'], ) - real_repsonse = self.get_response_values(response) + real_response = self.get_response_values(response) expected_response = { 'action': 'replaceResultResponse', 'code_major': 'success', @@ -211,7 +211,7 @@ def test_good_request(self): } self.assertEqual(response.status_code, 200) - self.assertDictEqual(expected_response, real_repsonse) + self.assertDictEqual(expected_response, real_response) def test_user_id(self): expected_user_id = unicode(urllib.quote(self.xmodule.runtime.anonymous_student_id)) @@ -227,6 +227,22 @@ def test_outcome_service_url(self): real_outcome_service_url = self.xmodule.get_outcome_service_url() self.assertEqual(real_outcome_service_url, expected_outcome_service_url) + def test_resource_link_id(self): + with patch('xmodule.lti_module.LTIModule.id', new_callable=PropertyMock) as mock_id: + mock_id.return_value = self.module_id + expected_resource_link_id = unicode(urllib.quote(self.module_id)) + real_resource_link_id = self.xmodule.get_resource_link_id() + self.assertEqual(real_resource_link_id, expected_resource_link_id) + + + def test_lis_result_sourcedid(self): + with patch('xmodule.lti_module.LTIModule.id', new_callable=PropertyMock) as mock_id: + mock_id.return_value = self.module_id + expected_sourcedId = u':'.join(urllib.quote(i) for i in (self.lti_id, self.module_id, self.user_id)) + real_lis_result_sourcedid = self.xmodule.get_lis_result_sourcedid() + self.assertEqual(real_lis_result_sourcedid, expected_sourcedId) + + def test_verify_oauth_body_sign(self): pass diff --git a/lms/djangoapps/courseware/tests/test_lti_integration.py b/lms/djangoapps/courseware/tests/test_lti_integration.py index 5fda91871629..9a70d217cce9 100644 --- a/lms/djangoapps/courseware/tests/test_lti_integration.py +++ b/lms/djangoapps/courseware/tests/test_lti_integration.py @@ -99,22 +99,3 @@ def test_lti_constructor(self): generated_context, self.runtime.render_template('lti.html', expected_context), ) - - - def test_resource_link_id(self): - module = LTIModule(self.item_descriptor, self.runtime, self.item_descriptor._field_data, Mock()) - expected_resource_link_id = unicode(self.item_descriptor.xmodule_runtime.anonymous_student_id) - real_resource_link_id = module.get_resource_link_id() - - self.assertEqual(real_resource_link_id, expected_resource_link_id) - - - def test_lis_result_sourcedid(self): - user_id = self.item_descriptor.xmodule_runtime.anonymous_student_id - lti_id = self.item_module.lti_id - module_id = 'test' - expected_sourcedId = u':'.join(urllib.quote(i) for i in (lti_id, module_id, user_id)) - - real_lis_result_sourcedid = self.item_module.get_lis_result_sourcedid() - self.assertEqual(real_lis_result_sourcedid, expected_sourcedId) - From ca6c76f1b838f43ab67bbf322b9000646009aa1e Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 26 Nov 2013 09:14:10 -0500 Subject: [PATCH 81/87] Fix typo --- lms/djangoapps/courseware/features/lti.py | 2 +- lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/courseware/features/lti.py b/lms/djangoapps/courseware/features/lti.py index 74a1d6672793..772877fa2a4b 100644 --- a/lms/djangoapps/courseware/features/lti.py +++ b/lms/djangoapps/courseware/features/lti.py @@ -265,5 +265,5 @@ def click_grade(_step): iframe_name = 'ltiLaunchFrame-' + location with world.browser.get_iframe(iframe_name) as iframe: iframe.find_by_name('submit-button').first.click() - assert iframe.is_text_present('LTI consumer (edX) responsed with XML content') + assert iframe.is_text_present('LTI consumer (edX) responded with XML content') diff --git a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py index bbe8f3d779c9..31637892b3b3 100644 --- a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py +++ b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py @@ -63,7 +63,7 @@ def do_POST(self): ''' # Respond to grade request if 'grade' in self.path and self._send_graded_result().status_code == 200: - status_message = 'LTI consumer (edX) responsed with XML content:
' + self.server.grade_data['TC answer'] + status_message = 'LTI consumer (edX) responded with XML content:
' + self.server.grade_data['TC answer'] self.server.grade_data['callback_url'] = None # Respond to request with correct lti endpoint: elif self._is_correct_lti_request(): From 2fcbd20421245e22eefb169f93a98d97bcabd0a4 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 26 Nov 2013 11:19:25 -0500 Subject: [PATCH 82/87] Remove unused import --- common/lib/xmodule/xmodule/lti_module.py | 1 - 1 file changed, 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index aca65d19fe44..eda004b9ba46 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -45,7 +45,6 @@ from lxml import etree from webob import Response import mock -import re from xml.sax.saxutils import escape from xmodule.editing_module import MetadataOnlyEditingDescriptor From 78ba08557b794b95019d07d854a3f4c98c4c2d7d Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 26 Nov 2013 11:58:17 -0500 Subject: [PATCH 83/87] Rename the LTIModuleDescriptor to LTIDescriptor to follow convention --- common/lib/xmodule/setup.py | 2 +- common/lib/xmodule/xmodule/lti_module.py | 2 +- common/lib/xmodule/xmodule/tests/test_lti_unit.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index c55ff4a34811..acb58aa39c55 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -39,7 +39,7 @@ "hidden = xmodule.hidden_module:HiddenDescriptor", "raw = xmodule.raw_module:RawDescriptor", "crowdsource_hinter = xmodule.crowdsource_hinter:CrowdsourceHinterDescriptor", - "lti = xmodule.lti_module:LTIModuleDescriptor", + "lti = xmodule.lti_module:LTIDescriptor", ] setup( diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index eda004b9ba46..12bca979dbac 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -577,7 +577,7 @@ def get_client_key_secret(self): return key, secret return '', '' -class LTIModuleDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRawDescriptor): +class LTIDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRawDescriptor): """ Descriptor for LTI Xmodule. """ diff --git a/common/lib/xmodule/xmodule/tests/test_lti_unit.py b/common/lib/xmodule/xmodule/tests/test_lti_unit.py index 18a658b7fdc1..b79bec16ccf1 100644 --- a/common/lib/xmodule/xmodule/tests/test_lti_unit.py +++ b/common/lib/xmodule/xmodule/tests/test_lti_unit.py @@ -8,14 +8,14 @@ from copy import copy import urllib -from xmodule.lti_module import LTIModuleDescriptor, LTIModule +from xmodule.lti_module import LTIDescriptor from . import LogicTest class LTIModuleTest(LogicTest): """Logic tests for LTI module.""" - descriptor_class = LTIModuleDescriptor + descriptor_class = LTIDescriptor def setUp(self): super(LTIModuleTest, self).setUp() From 2836cbf1acb3e0b95f7d2540ed80646da3c2299b Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 26 Nov 2013 11:58:45 -0500 Subject: [PATCH 84/87] Make per-course anonymized ids specific to LTIModule, with the intention of expanding later, to reduce release risk --- lms/djangoapps/courseware/module_render.py | 17 ++++++++--------- .../courseware/tests/test_module_render.py | 17 ++++------------- 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index f41aadf2e1f0..dfa8dbef6465 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -371,16 +371,15 @@ def publish(event, custom_user=None): block_wrappers.append(partial(add_histogram, user)) # These modules store data using the anonymous_student_id as a key. - # To prevent loss of data, we will continue to provide these modules - # with the per-student anonymized id (as we have in the past), - # while giving all other modules a per-course anonymized id - if issubclass( - getattr(descriptor, 'module_class', None), - (CapaModule, HtmlModule, CombinedOpenEndedModule, FolditModule) - ): - anonymous_student_id = anonymous_id_for_user(user, '') - else: + # To prevent loss of data, we will continue to provide old modules with + # the per-student anonymized id (as we have in the past), + # while giving selected modules a per-course anonymized id. + # As we have the time to manually test more modules, we can add to the list + # of modules that get the per-course anonymized id. + if issubclass(getattr(descriptor, 'module_class', None), [LtiModule]): anonymous_student_id = anonymous_id_for_user(user, course_id) + else: + anonymous_student_id = anonymous_id_for_user(user, '') system = LmsModuleSystem( track_function=track_function, diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index 40f99de9b41c..cc4144600887 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -15,10 +15,7 @@ from xblock.field_data import FieldData from xblock.runtime import Runtime from xblock.fields import ScopeIds -from xmodule.capa_module import CapaDescriptor -from xmodule.combined_open_ended_module import CombinedOpenEndedDescriptor -from xmodule.foldit_module import FolditDescriptor -from xmodule.html_module import HtmlDescriptor +from xmodule.lti_module import LTIDescriptor from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase @@ -525,17 +522,11 @@ def test_histogram(self): result_fragment.content ) +PER_COURSE_ANONYMIZED_DESCRIPTORS = [LTIDescriptor] -PER_STUDENT_ANONYMIZED_DESCRIPTORS = ( - CapaDescriptor, - HtmlDescriptor, - FolditDescriptor, - CombinedOpenEndedDescriptor -) - -PER_COURSE_ANONYMIZED_DESCRIPTORS = [ +PER_STUDENT_ANONYMIZED_DESCRIPTORS = [ class_ for (name, class_) in XModuleDescriptor.load_classes() - if not issubclass(class_, PER_STUDENT_ANONYMIZED_DESCRIPTORS) + if not issubclass(class_, PER_COURSE_ANONYMIZED_DESCRIPTORS) ] From 563500eef83d791f49953c9c6e6f9ef2cad6d210 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 26 Nov 2013 12:14:41 -0500 Subject: [PATCH 85/87] Fix LTIModule imports --- lms/djangoapps/courseware/module_render.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index dfa8dbef6465..25f6904e4a4b 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -37,10 +37,7 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule_modifiers import replace_course_urls, replace_jump_to_id_urls, replace_static_urls, add_histogram, wrap_xblock -from xmodule.capa_module import CapaModule -from xmodule.combined_open_ended_module import CombinedOpenEndedModule -from xmodule.html_module import HtmlModule -from xmodule.foldit_module import FolditModule +from xmodule.lti_module import LTIModule log = logging.getLogger(__name__) @@ -376,7 +373,7 @@ def publish(event, custom_user=None): # while giving selected modules a per-course anonymized id. # As we have the time to manually test more modules, we can add to the list # of modules that get the per-course anonymized id. - if issubclass(getattr(descriptor, 'module_class', None), [LtiModule]): + if issubclass(getattr(descriptor, 'module_class', None), [LTIModule]): anonymous_student_id = anonymous_id_for_user(user, course_id) else: anonymous_student_id = anonymous_id_for_user(user, '') From 1d3aec7979327d462931eb696cf05483c1e1bbb6 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 26 Nov 2013 12:33:17 -0500 Subject: [PATCH 86/87] Don't use a list for issubclass --- lms/djangoapps/courseware/module_render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 25f6904e4a4b..3b804c4ba96f 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -373,7 +373,7 @@ def publish(event, custom_user=None): # while giving selected modules a per-course anonymized id. # As we have the time to manually test more modules, we can add to the list # of modules that get the per-course anonymized id. - if issubclass(getattr(descriptor, 'module_class', None), [LTIModule]): + if issubclass(getattr(descriptor, 'module_class', None), LTIModule): anonymous_student_id = anonymous_id_for_user(user, course_id) else: anonymous_student_id = anonymous_id_for_user(user, '') From 52e01d2043e7a653802c8f0d5045b6aeb6bfb88e Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 26 Nov 2013 12:48:02 -0500 Subject: [PATCH 87/87] Don't use a list for issubclass, again --- lms/djangoapps/courseware/tests/test_module_render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index cc4144600887..649ad3c8ad8e 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -522,7 +522,7 @@ def test_histogram(self): result_fragment.content ) -PER_COURSE_ANONYMIZED_DESCRIPTORS = [LTIDescriptor] +PER_COURSE_ANONYMIZED_DESCRIPTORS = (LTIDescriptor, ) PER_STUDENT_ANONYMIZED_DESCRIPTORS = [ class_ for (name, class_) in XModuleDescriptor.load_classes()