Commit 39b29dcb by Ned Batchelder

Merge pull request #1802 from edx/ned/merge-master-to-rc-2013-11-21-again

Merged master to rc/2013-11-21, so we can merge it back to master.
parents 3e3a57f5 c8a7b259
......@@ -9,6 +9,10 @@ LMS: Add feature for providing background grade report generation via Celery
instructor task, with reports uploaded to S3. Feature is visible on the beta
instructor dashboard. LMS-58
Blades: Added grading support for LTI module. LTI providers can now grade
student's work and send edX scores. OAuth1 based authentication
implemented. BLD-384.
LMS: Beta-tester status is now set on a per-course-run basis, rather than being
valid across all runs with the same course name. Old group membership will
still work across runs, but new beta-testers will only be added to a single
......@@ -39,7 +43,6 @@ LMS: Trap focus on the loading element when a user loads more threads
in the forum sidebar to improve accessibility.
LMS: Add error recovery when a user loads more threads in the forum sidebar.
>>>>>>> origin/master
LMS: Add a user-visible alert modal when a forums AJAX request fails.
......
......@@ -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
......
# -*- coding: utf-8 -*-
"""Dump username,unique_id_for_user pairs as CSV.
"""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 unique_id_for_user
from student.models import anonymous_id_for_user
class Command(BaseCommand):
......@@ -52,9 +50,17 @@ class Command(BaseCommand):
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",
"Per-Student anonymized user ID",
"Per-course anonymized user id"
))
for student in students:
csv_writer.writerow((student.id, unique_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)
......@@ -22,6 +22,9 @@ from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver, Signal
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
......@@ -39,6 +42,63 @@ log = logging.getLogger(__name__)
AUDIT_LOG = logging.getLogger("audit")
class AnonymousUserId(models.Model):
"""
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)
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):
"""
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 None
# 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 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:
return User.objects.get(anonymoususerid__anonymous_user_id=id)
except ObjectDoesNotExist:
return None
class UserStanding(models.Model):
"""
This table contains a student's account's status.
......@@ -147,12 +207,9 @@ 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()
# Setting course_id to '' makes it not affect the generated hash,
# and thus produce the old per-student anonymous id
return anonymous_id_for_user(user, '')
# TODO: Should be renamed to generic UserGroup, and possibly
......
......@@ -15,7 +15,7 @@ from django.conf import settings
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
......@@ -28,7 +28,7 @@ from courseware.tests.tests import TEST_DATA_MIXED_MODULESTORE
from mock import Mock, patch, sentinel
from textwrap import dedent
from student.models import unique_id_for_user, CourseEnrollment
from student.models import anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment, unique_id_for_user
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
......@@ -522,3 +522,37 @@ class PaidRegistrationTest(ModuleStoreTestCase):
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()
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.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)
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)
......@@ -38,7 +38,7 @@ XMODULES = [
"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(
......
......@@ -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
......
......@@ -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')
......@@ -403,6 +402,7 @@ class XModule(XModuleMixin, HTMLSnippet, XBlock): # pylint: disable=abstract-me
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`
......@@ -967,7 +967,7 @@ class ModuleSystem(ConfigurableFragmentWrapper, Runtime): # pylint: disable=abs
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.
......@@ -1052,6 +1052,8 @@ class ModuleSystem(ConfigurableFragmentWrapper, Runtime): # pylint: disable=abs
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)
......
**********************
Create a LTI Component
**********************
Description
===========
The LTI XModule is based on the `IMS Global Learning Tools Interoperability <http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html>`_ 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** [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.
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** [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 the grade for this particular LTI instance problem will be
counted towards student's total 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.
**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
......@@ -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::
......
......@@ -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
......
......@@ -2,27 +2,57 @@
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:
| 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
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
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
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
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:
| 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"
#pylint: disable=C0111
import os
from django.contrib.auth.models import User
from lettuce import world, step
from lettuce.django import django_url
......@@ -86,36 +87,41 @@ 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):
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 _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
}
metadata=metadata,
)
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 +144,20 @@ def create_course(course, metadata):
# This also ensures that the necessary templates are loaded
world.clear_courses()
weight = 0.1
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,18 +165,30 @@ 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',
)
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):
......@@ -170,6 +202,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')
......@@ -196,3 +229,41 @@ def check_lti_popup():
world.browser.switch_to_window(parent_window) # Switch to the main window again
@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 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()
assert iframe.is_text_present('LTI consumer (edX) responded with XML content')
......@@ -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',
......
from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
from uuid import uuid4
import textwrap
import urlparse
from oauthlib.oauth1.rfc5849 import signature
import oauthlib.oauth1
import hashlib
import base64
import mock
import sys
import requests
import textwrap
from logging import getLogger
logger = getLogger(__name__)
......@@ -13,6 +21,7 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
'''
protocol = "HTTP/1.0"
callback_url = None
def log_message(self, format, *args):
"""Log an arbitrary message."""
......@@ -23,24 +32,42 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
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.
'''
self.send_response(200, 'OK')
self.send_header('Content-type', 'html')
self.end_headers()
response_str = """<html><head><title>TEST TITLE</title></head>
<body>I have stored grades.</body></html>"""
self.wfile.write(response_str)
self._send_graded_result()
def do_POST(self):
'''
Handle a POST request from the client and sends response back.
'''
self._send_head()
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
# 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 = 'LTI consumer (edX) responded with XML content:<br>' + 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():
self.post_dict = self._post_dict()
correct_keys = [
'user_id',
'role',
......@@ -55,31 +82,41 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
'oauth_callback',
'lis_outcome_service_url',
'lis_result_sourcedid',
'launch_presentation_return_url'
'launch_presentation_return_url',
# 'lis_person_sourcedid', optional, not used now.
'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'}
if self.server.check_oauth_signature(params, post_dict['oauth_signature']):
params = {k: v for k, v in self.post_dict.items() if k != '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"
# 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"],
'sourcedId': self.post_dict['lis_result_sourcedid']
}
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()
......@@ -100,18 +137,91 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
# 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 = {}
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):
values = {
'textString': 0.5,
'sourcedId': self.server.grade_data['sourcedId'],
'imsx_messageIdentifier': uuid4().hex,
}
payload = textwrap.dedent("""
<?xml version = "1.0" encoding = "UTF-8"?>
<imsx_POXEnvelopeRequest xmlns="http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0">
<imsx_POXHeader>
<imsx_POXRequestHeaderInfo>
<imsx_version>V1.0</imsx_version>
<imsx_messageIdentifier>{imsx_messageIdentifier}</imsx_messageIdentifier> /
</imsx_POXRequestHeaderInfo>
</imsx_POXHeader>
<imsx_POXBody>
<replaceResultRequest>
<resultRecord>
<sourcedGUID>
<sourcedId>{sourcedId}</sourcedId>
</sourcedGUID>
<result>
<resultScore>
<language>en-us</language>
<textString>{textString}</textString>
</resultScore>
</result>
</resultRecord>
</replaceResultRequest>
</imsx_POXBody>
</imsx_POXEnvelopeRequest>
""")
data = payload.format(**values)
# temporarily changed to get for easy view in browser
# 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
headers = {'Content-Type': 'application/xml', 'X-Requested-With': 'XMLHttpRequest'}
headers['Authorization'] = self.oauth_sign(url, data)
response = requests.post(
url,
data=data,
headers=headers
)
self.server.grade_data['TC answer'] = response.content
return response
def _send_response(self, message):
'''
Send message back to the client
'''
response_str = """<html><head><title>TEST TITLE</title></head>
<body>
<div><h2>IFrame loaded</h2> \
<h3>Server response is:</h3>\
<h3 class="result">{}</h3></div>
</body></html>""".format(message)
if self.server.grade_data['callback_url']:
response_str = """<html><head><title>TEST TITLE</title></head>
<body>
<div><h2>Graded IFrame loaded</h2> \
<h3>Server response is:</h3>\
<h3 class="result">{}</h3></div>
<form action="{url}/grade" method="post">
<input type="submit" name="submit-button" value="Submit">
</form>
</body></html>""".format(message, url="http://%s:%s" % self.server.server_address)
else:
response_str = """<html><head><title>TEST TITLE</title></head>
<body>
<div><h2>IFrame loaded</h2> \
<h3>Server response is:</h3>\
<h3 class="result">{}</h3></div>
</body></html>""".format(message)
# Log the response
logger.debug("LTI: sent response {}".format(response_str))
......@@ -122,6 +232,34 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
'''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/x-www-form-urlencoded',
}
#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={u'oauth_body_hash': oauth_body_hash},
headers=headers
)
headers = headers['Authorization'] + ', oauth_body_hash="{}"'.format(oauth_body_hash)
return headers
class MockLTIServer(HTTPServer):
'''
......@@ -172,6 +310,5 @@ class MockLTIServer(HTTPServer):
request.uri = unicode(url)
request.http_method = u'POST'
request.signature = unicode(client_signature)
return signature.verify_hmac_sha1(request, client_secret)
"""
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()
"""
Test for Mock_LTI_Server
"""
import mock
from mock import Mock
import unittest
import threading
import textwrap
import urllib
import requests
from mock_lti_server import MockLTIServer
from nose.plugins.skip import SkipTest
class MockLTIServerTest(unittest.TestCase):
......@@ -19,14 +22,9 @@ 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'
server_host = 'localhost'
address = (server_host, server_port)
self.server = MockLTIServer(address)
self.server.oauth_settings = {
......@@ -45,12 +43,42 @@ class MockLTIServerTest(unittest.TestCase):
# 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': '',
'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': '',
'resource_link_id':'',
}
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('Wrong LTI signature' in response.content)
def test_success_response_launch_lti(self):
"""
Success lti launch.
"""
payload = {
'user_id': 'default_user_id',
'role': 'student',
'oauth_nonce': '',
......@@ -64,12 +92,16 @@ class MockLTIServerTest(unittest.TestCase):
'oauth_callback': 'about:blank',
'launch_presentation_return_url': '',
'lis_outcome_service_url': '',
'lis_result_sourcedid': ''
'lis_result_sourcedid': '',
'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)
......@@ -14,10 +14,11 @@ from .models import (
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
from xblock.fields import Scope
from xblock.fields import Scope, UserScope
log = logging.getLogger(__name__)
......@@ -226,10 +227,15 @@ class FieldDataCache(object):
if field_object is not None:
return field_object
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:
field_object, _ = StudentModule.objects.get_or_create(
course_id=self.course_id,
student=self.user,
student=User.objects.get(id=key.user_id),
module_state_key=key.block_scope_id.url(),
defaults={
'state': json.dumps({}),
......@@ -245,12 +251,12 @@ class FieldDataCache(object):
field_object, _ = XModuleStudentPrefsField.objects.get_or_create(
field_name=key.field_name,
module_type=key.block_scope_id,
student=self.user,
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=self.user,
student=User.objects.get(id=key.user_id),
)
cache_key = self._cache_key_from_kvs_key(key)
......@@ -347,7 +353,7 @@ class DjangoKeyValueStore(KeyValueStore):
# 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):
......
......@@ -14,7 +14,7 @@ from django.core.exceptions import PermissionDenied
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
......@@ -24,7 +24,7 @@ from lms.lib.xblock.field_data import LmsFieldData
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 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
......@@ -37,6 +37,7 @@ from xmodule.modulestore import Location
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.lti_module import LTIModule
log = logging.getLogger(__name__)
......@@ -285,15 +286,20 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours
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'
)
......@@ -361,6 +367,17 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours
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 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,
render_template=render_to_string,
......@@ -392,7 +409,7 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours
),
node_path=settings.NODE_PATH,
publish=publish,
anonymous_student_id=unique_id_for_user(user),
anonymous_student_id=anonymous_student_id,
course_id=course_id,
open_ended_grading_interface=open_ended_grading_interface,
s3_interface=s3_interface,
......@@ -401,6 +418,7 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours
# 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
......@@ -482,6 +500,14 @@ def xqueue_callback(request, course_id, userid, mod_id, dispatch):
return HttpResponse("")
@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, request.user)
def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None):
"""
Generic view for extensions. This is where AJAX calls go.
......@@ -497,15 +523,23 @@ 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
return _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, request.user)
def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user):
"""
Invoke an XBlock handler, either authenticated or not.
"""
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 not request.user.is_authenticated():
raise PermissionDenied
# Check submitted files
files = request.FILES or {}
error_msg = _check_files_limits(files)
......@@ -525,15 +559,14 @@ def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None):
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
course_id,
request.user,
user,
descriptor
)
instance = get_module(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)
......
......@@ -4,6 +4,9 @@ import oauthlib
from . import BaseTestXmodule
from collections import OrderedDict
import mock
import urllib
from xmodule.lti_module import LTIModule
from mock import Mock
class TestLTI(BaseTestXmodule):
......@@ -26,21 +29,33 @@ class TestLTI(BaseTestXmodule):
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))
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',
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': module_id,
u'lis_outcome_service_url': lis_outcome_service_url,
u'lis_result_sourcedid': sourcedId,
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
}
......@@ -70,14 +85,16 @@ class TestLTI(BaseTestXmodule):
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),
......
......@@ -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 @@ class TestStudentModuleStorage(TestCase):
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)
......
"""
Test for lms courseware app, module render unit
"""
from ddt import ddt, data
from mock import MagicMock, patch, Mock
import json
......@@ -11,10 +12,15 @@ from django.test import TestCase
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.lti_module import LTIDescriptor
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
......@@ -185,9 +191,11 @@ class TestHandleXBlockCallback(ModuleStoreTestCase, LoginEnrollmentTestCase):
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'
......@@ -513,3 +521,74 @@ class TestHtmlModifiers(ModuleStoreTestCase):
'Staff Debug',
result_fragment.content
)
PER_COURSE_ANONYMIZED_DESCRIPTORS = (LTIDescriptor, )
PER_STUDENT_ANONYMIZED_DESCRIPTORS = [
class_ for (name, class_) in XModuleDescriptor.load_classes()
if not issubclass(class_, PER_COURSE_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)
)
......@@ -58,11 +58,31 @@ 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
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 thirdparty:
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('/')
......@@ -86,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
......
......@@ -175,7 +175,9 @@ if settings.COURSEWARE_ENABLED:
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/xblock/(?P<usage_id>[^/]*)/handler/(?P<handler>[^/]*)(?:/(?P<suffix>.*))?$',
'courseware.module_render.handle_xblock_callback',
name='xblock_handler'),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/xblock/(?P<usage_id>[^/]*)/handler_noauth/(?P<handler>[^/]*)(?:/(?P<suffix>.*))?$',
'courseware.module_render.handle_xblock_callback_noauth',
name='xblock_handler_noauth'),
# Software Licenses
......
......@@ -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")
......
......@@ -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@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
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment