Commit 6dac4b79 by Ned Batchelder

Merge pull request #1791 from cpennington/rebased-lti-graded

BLD-384: Add grading to LTI module
parents 32941969 11bbf4c1
...@@ -9,6 +9,10 @@ LMS: Add feature for providing background grade report generation via Celery ...@@ -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 task, with reports uploaded to S3. Feature is visible on the beta
instructor dashboard. LMS-58 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 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 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 course run. across runs, but new beta-testers will only be added to a single course run.
......
...@@ -115,7 +115,7 @@ class PreviewModuleSystem(ModuleSystem): # pylint: disable=abstract-method ...@@ -115,7 +115,7 @@ class PreviewModuleSystem(ModuleSystem): # pylint: disable=abstract-method
""" """
An XModule ModuleSystem for use in Studio previews 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 return handler_prefix(block, handler_name, suffix) + '?' + query
......
# -*- coding: utf-8 -*- # -*- 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 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 with a simple Django management command to generate a CSV mapping. To run, use
the following: the following:
rake django-admin[anonymized_id_mapping,x,y,z] ./manage.py lms anonymized_id_mapping COURSE_ID
"""
[Naturally, substitute the appropriate values for x, y, and z. (I.e.,
lms, dev, and MITx/6.002x/Circuits)]"""
import csv import csv
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.management.base import BaseCommand, CommandError 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): class Command(BaseCommand):
...@@ -52,9 +50,17 @@ class Command(BaseCommand): ...@@ -52,9 +50,17 @@ class Command(BaseCommand):
try: try:
with open(output_filename, 'wb') as output_file: with open(output_filename, 'wb') as output_file:
csv_writer = csv.writer(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: 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: except IOError:
raise CommandError("Error writing to file: %s" % output_filename) raise CommandError("Error writing to file: %s" % output_filename)
...@@ -25,6 +25,7 @@ from django.db.models.signals import post_save ...@@ -25,6 +25,7 @@ from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
import django.dispatch import django.dispatch
from django.forms import ModelForm, forms from django.forms import ModelForm, forms
from django.core.exceptions import ObjectDoesNotExist
from course_modes.models import CourseMode from course_modes.models import CourseMode
import lms.lib.comment_client as cc import lms.lib.comment_client as cc
...@@ -42,6 +43,63 @@ log = logging.getLogger(__name__) ...@@ -42,6 +43,63 @@ log = logging.getLogger(__name__)
AUDIT_LOG = logging.getLogger("audit") 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): class UserStanding(models.Model):
""" """
This table contains a student's account's status. This table contains a student's account's status.
...@@ -624,12 +682,9 @@ def unique_id_for_user(user): ...@@ -624,12 +682,9 @@ def unique_id_for_user(user):
Return a unique id for a user, suitable for inserting into Return a unique id for a user, suitable for inserting into
e.g. personalized survey links. e.g. personalized survey links.
""" """
# include the secret key as a salt, and to make the ids unique across # Setting course_id to '' makes it not affect the generated hash,
# different LMS installs. # and thus produce the old per-student anonymous id
h = hashlib.md5() return anonymous_id_for_user(user, '')
h.update(settings.SECRET_KEY)
h.update(str(user.id))
return h.hexdigest()
# TODO: Should be renamed to generic UserGroup, and possibly # TODO: Should be renamed to generic UserGroup, and possibly
......
...@@ -15,7 +15,7 @@ from django.conf import settings ...@@ -15,7 +15,7 @@ from django.conf import settings
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from django.test.client import RequestFactory 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.hashers import UNUSABLE_PASSWORD
from django.contrib.auth.tokens import default_token_generator from django.contrib.auth.tokens import default_token_generator
from django.utils.http import int_to_base36 from django.utils.http import int_to_base36
...@@ -28,7 +28,7 @@ from courseware.tests.tests import TEST_DATA_MIXED_MODULESTORE ...@@ -28,7 +28,7 @@ from courseware.tests.tests import TEST_DATA_MIXED_MODULESTORE
from mock import Mock, patch, sentinel from mock import Mock, patch, sentinel
from textwrap import dedent 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, from student.views import (process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper,
change_enrollment, complete_course_mode_info) change_enrollment, complete_course_mode_info)
from student.tests.factories import UserFactory, CourseModeFactory from student.tests.factories import UserFactory, CourseModeFactory
...@@ -501,3 +501,37 @@ class PaidRegistrationTest(ModuleStoreTestCase): ...@@ -501,3 +501,37 @@ class PaidRegistrationTest(ModuleStoreTestCase):
self.assertEqual(response.content, reverse('shoppingcart.views.show_cart')) self.assertEqual(response.content, reverse('shoppingcart.views.show_cart'))
self.assertTrue(shoppingcart.models.PaidCourseRegistration.contained_in_order( self.assertTrue(shoppingcart.models.PaidCourseRegistration.contained_in_order(
shoppingcart.models.Order.get_cart_for_user(self.user), self.course.id)) 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)
...@@ -39,7 +39,7 @@ XMODULES = [ ...@@ -39,7 +39,7 @@ XMODULES = [
"hidden = xmodule.hidden_module:HiddenDescriptor", "hidden = xmodule.hidden_module:HiddenDescriptor",
"raw = xmodule.raw_module:RawDescriptor", "raw = xmodule.raw_module:RawDescriptor",
"crowdsource_hinter = xmodule.crowdsource_hinter:CrowdsourceHinterDescriptor", "crowdsource_hinter = xmodule.crowdsource_hinter:CrowdsourceHinterDescriptor",
"lti = xmodule.lti_module:LTIModuleDescriptor", "lti = xmodule.lti_module:LTIDescriptor",
] ]
setup( setup(
......
...@@ -42,7 +42,7 @@ class TestModuleSystem(ModuleSystem): # pylint: disable=abstract-method ...@@ -42,7 +42,7 @@ class TestModuleSystem(ModuleSystem): # pylint: disable=abstract-method
""" """
ModuleSystem for testing 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 return str(block.scope_ids.usage_id) + '/' + handler + '/' + suffix + '?' + query
......
...@@ -371,7 +371,6 @@ class XModule(XModuleMixin, HTMLSnippet, XBlock): # pylint: disable=abstract-me ...@@ -371,7 +371,6 @@ class XModule(XModuleMixin, HTMLSnippet, XBlock): # pylint: disable=abstract-me
See the HTML module for a simple example. See the HTML module for a simple example.
""" """
has_score = descriptor_attr('has_score') has_score = descriptor_attr('has_score')
_field_data_cache = descriptor_attr('_field_data_cache') _field_data_cache = descriptor_attr('_field_data_cache')
_field_data = descriptor_attr('_field_data') _field_data = descriptor_attr('_field_data')
...@@ -403,6 +402,7 @@ class XModule(XModuleMixin, HTMLSnippet, XBlock): # pylint: disable=abstract-me ...@@ -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""" data is a dictionary-like object with the content of the request"""
return u"" return u""
@XBlock.handler
def xmodule_handler(self, request, suffix=None): def xmodule_handler(self, request, suffix=None):
""" """
XBlock handler that wraps `handle_ajax` XBlock handler that wraps `handle_ajax`
...@@ -967,7 +967,7 @@ class ModuleSystem(ConfigurableFragmentWrapper, Runtime): # pylint: disable=abs ...@@ -967,7 +967,7 @@ class ModuleSystem(ConfigurableFragmentWrapper, Runtime): # pylint: disable=abs
anonymous_student_id='', course_id=None, anonymous_student_id='', course_id=None,
open_ended_grading_interface=None, s3_interface=None, open_ended_grading_interface=None, s3_interface=None,
cache=None, can_execute_unsafe_code=None, replace_course_urls=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. Create a closure around the system environment.
...@@ -1052,6 +1052,8 @@ class ModuleSystem(ConfigurableFragmentWrapper, Runtime): # pylint: disable=abs ...@@ -1052,6 +1052,8 @@ class ModuleSystem(ConfigurableFragmentWrapper, Runtime): # pylint: disable=abs
self.error_descriptor_class = error_descriptor_class self.error_descriptor_class = error_descriptor_class
self.xmodule_instance = None self.xmodule_instance = None
self.get_real_user = get_real_user
def get(self, attr): def get(self, attr):
""" provide uniform access to attributes (like etree).""" """ provide uniform access to attributes (like etree)."""
return self.__dict__.get(attr) 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 ...@@ -20,6 +20,7 @@ Contents
create_video create_video
create_discussion create_discussion
create_html_component create_html_component
create_lti
create_problem create_problem
set_content_releasedates set_content_releasedates
establish_course_settings establish_course_settings
...@@ -34,13 +35,13 @@ Contents ...@@ -34,13 +35,13 @@ Contents
checking_student_progress checking_student_progress
change_log change_log
Appendices
Appendices
========== ==========
.. toctree:: .. toctree::
......
...@@ -25,6 +25,7 @@ Specific Problem Types ...@@ -25,6 +25,7 @@ Specific Problem Types
course_data_formats/drag_and_drop/drag_and_drop_input.rst course_data_formats/drag_and_drop/drag_and_drop_input.rst
course_data_formats/graphical_slider_tool/graphical_slider_tool.rst course_data_formats/graphical_slider_tool/graphical_slider_tool.rst
course_data_formats/poll_module/poll_module.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/conditional_module/conditional_module.rst
course_data_formats/word_cloud/word_cloud.rst course_data_formats/word_cloud/word_cloud.rst
course_data_formats/custom_response.rst course_data_formats/custom_response.rst
......
...@@ -2,27 +2,57 @@ ...@@ -2,27 +2,57 @@
Feature: LMS.LTI component Feature: LMS.LTI component
As a student, I want to view LTI component in LMS. As a student, I want to view LTI component in LMS.
#1
Scenario: LTI component in LMS with no launch_url is not rendered Scenario: LTI component in LMS with no launch_url is not rendered
Given the course has correct LTI credentials 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 Then I view the LTI and error is shown
#2
Scenario: LTI component in LMS with incorrect lti_id is rendered incorrectly Scenario: LTI component in LMS with incorrect lti_id is rendered incorrectly
Given the course has correct LTI credentials 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 Then I view the LTI but incorrect_signature warning is rendered
#3
Scenario: LTI component in LMS is rendered incorrectly Scenario: LTI component in LMS is rendered incorrectly
Given the course has incorrect LTI credentials 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 Then I view the LTI but incorrect_signature warning is rendered
#4
Scenario: LTI component in LMS is correctly rendered in new page Scenario: LTI component in LMS is correctly rendered in new page
Given the course has correct LTI credentials 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 Then I view the LTI and it is rendered in new page
#5
Scenario: LTI component in LMS is correctly rendered in iframe Scenario: LTI component in LMS is correctly rendered in iframe
Given the course has correct LTI credentials 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 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 #pylint: disable=C0111
import os import os
from django.contrib.auth.models import User from django.contrib.auth.models import User
from lettuce import world, step from lettuce import world, step
from lettuce.django import django_url from lettuce.django import django_url
...@@ -86,36 +87,41 @@ def set_incorrect_lti_passport(_step): ...@@ -86,36 +87,41 @@ def set_incorrect_lti_passport(_step):
} }
i_am_registered_for_the_course(coursenum, metadata) i_am_registered_for_the_course(coursenum, metadata)
@step('the course has an LTI component with (.*) fields(?:\:)?$') #, new_page is(.*), is_graded is(.*)
@step('the course has an LTI component with (.*) fields, new_page is(.*)$') def add_correct_lti_to_course(_step, fields):
def add_correct_lti_to_course(_step, fields, new_page):
category = 'lti' category = 'lti'
lti_id = 'correct_lti_id' metadata = {
launch_url = world.lti_server.oauth_settings['lti_base'] + world.lti_server.oauth_settings['lti_endpoint'] '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 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 elif fields.strip() == 'correct': # correct fields
pass pass
elif fields.strip() == 'no_launch_url': elif fields.strip() == 'no_launch_url':
launch_url = u'' metadata.update({
'launch_url': u''
})
else: # incorrect parameter else: # incorrect parameter
assert False assert False
if new_page.strip().lower() == 'false': if _step.hashes:
new_page = False metadata.update(_step.hashes[0])
else: # default is True
new_page = True
world.scenario_dict['LTI'] = world.ItemFactory.create( world.scenario_dict['LTI'] = world.ItemFactory.create(
parent_location=world.scenario_dict['SEQUENTIAL'].location, parent_location=world.scenario_dict['SEQUENTIAL'].location,
category=category, category=category,
display_name='LTI', display_name='LTI',
metadata={ metadata=metadata,
'lti_id': lti_id,
'launch_url': launch_url,
'open_in_a_new_page': new_page
}
) )
setattr(world.scenario_dict['LTI'], 'TEST_BASE_PATH', '{host}:{port}'.format(
host=world.browser.host,
port=world.browser.port,
))
course = world.scenario_dict["COURSE"] course = world.scenario_dict["COURSE"]
chapter_name = world.scenario_dict['SECTION'].display_name.replace( chapter_name = world.scenario_dict['SECTION'].display_name.replace(
" ", "_") " ", "_")
...@@ -138,6 +144,20 @@ def create_course(course, metadata): ...@@ -138,6 +144,20 @@ def create_course(course, metadata):
# This also ensures that the necessary templates are loaded # This also ensures that the necessary templates are loaded
world.clear_courses() 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 # Create the course
# We always use the same org and display name, # We always use the same org and display name,
# but vary the course identifier (e.g. 600x or 191x) # but vary the course identifier (e.g. 600x or 191x)
...@@ -145,18 +165,30 @@ def create_course(course, metadata): ...@@ -145,18 +165,30 @@ def create_course(course, metadata):
org='edx', org='edx',
number=course, number=course,
display_name='Test 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 # Add a section to the course to contain problems
world.scenario_dict['SECTION'] = world.ItemFactory.create( world.scenario_dict['SECTION'] = world.ItemFactory.create(
parent_location=world.scenario_dict['COURSE'].location, parent_location=world.scenario_dict['COURSE'].location,
display_name='Test Section' display_name='Test Section',
) )
world.scenario_dict['SEQUENTIAL'] = world.ItemFactory.create( world.scenario_dict['SEQUENTIAL'] = world.ItemFactory.create(
parent_location=world.scenario_dict['SECTION'].location, parent_location=world.scenario_dict['SECTION'].location,
category='sequential', category='sequential',
display_name='Test Section') display_name='Test Section',
metadata={'graded': True, 'format': 'Homework'})
def i_am_registered_for_the_course(course, metadata): def i_am_registered_for_the_course(course, metadata):
...@@ -170,6 +202,7 @@ 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. # If the user is not already enrolled, enroll the user.
CourseEnrollment.enroll(usr, course_id(course)) CourseEnrollment.enroll(usr, course_id(course))
world.add_to_course_staff('robot', world.scenario_dict['COURSE'].number)
world.log_in(username='robot', password='test') world.log_in(username='robot', password='test')
...@@ -196,3 +229,41 @@ def check_lti_popup(): ...@@ -196,3 +229,41 @@ def check_lti_popup():
world.browser.switch_to_window(parent_window) # Switch to the main window again 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(): ...@@ -30,6 +30,7 @@ def setup_mock_lti_server():
server_thread.daemon = True server_thread.daemon = True
server_thread.start() server_thread.start()
server.server_host = server_host
server.oauth_settings = { server.oauth_settings = {
'client_key': 'test_client_key', 'client_key': 'test_client_key',
'client_secret': 'test_client_secret', 'client_secret': 'test_client_secret',
......
from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
from uuid import uuid4
import textwrap
import urlparse import urlparse
from oauthlib.oauth1.rfc5849 import signature from oauthlib.oauth1.rfc5849 import signature
import oauthlib.oauth1
import hashlib
import base64
import mock import mock
import sys import sys
import requests
import textwrap
from logging import getLogger from logging import getLogger
logger = getLogger(__name__) logger = getLogger(__name__)
...@@ -13,6 +21,7 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler): ...@@ -13,6 +21,7 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
''' '''
protocol = "HTTP/1.0" protocol = "HTTP/1.0"
callback_url = None
def log_message(self, format, *args): def log_message(self, format, *args):
"""Log an arbitrary message.""" """Log an arbitrary message."""
...@@ -23,24 +32,42 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler): ...@@ -23,24 +32,42 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
self.log_date_time_string(), self.log_date_time_string(),
format % args)) format % args))
def do_HEAD(self): def do_GET(self):
self._send_head() '''
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): def do_POST(self):
''' '''
Handle a POST request from the client and sends response back. 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( logger.debug("LTI provider received POST request {} to path {}".format(
str(post_dict), str(self.post_dict),
self.path) self.path)
) # Log the request ) # Log the request
'''
# Respond only to requests with correct lti endpoint: # Respond to grade request
if self._is_correct_lti_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 = [ correct_keys = [
'user_id', 'user_id',
'role', 'role',
...@@ -55,31 +82,41 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler): ...@@ -55,31 +82,41 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
'oauth_callback', 'oauth_callback',
'lis_outcome_service_url', 'lis_outcome_service_url',
'lis_result_sourcedid', '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(self.post_dict.keys()):
if sorted(correct_keys) != sorted(post_dict.keys()):
status_message = "Incorrect LTI header" status_message = "Incorrect LTI header"
else: 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']): if self.server.check_oauth_signature(params, self.post_dict['oauth_signature']):
status_message = "This is LTI tool. Success." status_message = "This is LTI tool. Success."
else: else:
status_message = "Wrong LTI signature" 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: else:
status_message = "Invalid request URL" status_message = "Invalid request URL"
self._send_head()
self._send_response(status_message) self._send_response(status_message)
def _send_head(self): def _send_head(self):
''' '''
Send the response code and MIME headers Send the response code and MIME headers
''' '''
self.send_response(200)
'''
if self._is_correct_lti_request(): if self._is_correct_lti_request():
self.send_response(200) self.send_response(200)
else: else:
self.send_response(500) self.send_response(500)
'''
self.send_header('Content-type', 'text/html') self.send_header('Content-type', 'text/html')
self.end_headers() self.end_headers()
...@@ -100,18 +137,91 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler): ...@@ -100,18 +137,91 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
# the correct fields, it won't find them, # the correct fields, it won't find them,
# and will therefore send an error response # and will therefore send an error response
return {} 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 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): def _send_response(self, message):
''' '''
Send message back to the client Send message back to the client
''' '''
response_str = """<html><head><title>TEST TITLE</title></head>
<body> if self.server.grade_data['callback_url']:
<div><h2>IFrame loaded</h2> \ response_str = """<html><head><title>TEST TITLE</title></head>
<h3>Server response is:</h3>\ <body>
<h3 class="result">{}</h3></div> <div><h2>Graded IFrame loaded</h2> \
</body></html>""".format(message) <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 # Log the response
logger.debug("LTI: sent response {}".format(response_str)) logger.debug("LTI: sent response {}".format(response_str))
...@@ -122,6 +232,34 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler): ...@@ -122,6 +232,34 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
'''If url to LTI tool is correct.''' '''If url to LTI tool is correct.'''
return self.server.oauth_settings['lti_endpoint'] in self.path 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): class MockLTIServer(HTTPServer):
''' '''
...@@ -172,6 +310,5 @@ class MockLTIServer(HTTPServer): ...@@ -172,6 +310,5 @@ class MockLTIServer(HTTPServer):
request.uri = unicode(url) request.uri = unicode(url)
request.http_method = u'POST' request.http_method = u'POST'
request.signature = unicode(client_signature) request.signature = unicode(client_signature)
return signature.verify_hmac_sha1(request, client_secret) 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 Test for Mock_LTI_Server
""" """
import mock
from mock import Mock
import unittest import unittest
import threading import threading
import textwrap
import urllib import urllib
import requests
from mock_lti_server import MockLTIServer from mock_lti_server import MockLTIServer
from nose.plugins.skip import SkipTest
class MockLTIServerTest(unittest.TestCase): class MockLTIServerTest(unittest.TestCase):
...@@ -19,14 +22,9 @@ class MockLTIServerTest(unittest.TestCase): ...@@ -19,14 +22,9 @@ class MockLTIServerTest(unittest.TestCase):
def setUp(self): 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 # Create the server
server_port = 8034 server_port = 8034
server_host = '127.0.0.1' server_host = 'localhost'
address = (server_host, server_port) address = (server_host, server_port)
self.server = MockLTIServer(address) self.server = MockLTIServer(address)
self.server.oauth_settings = { self.server.oauth_settings = {
...@@ -45,12 +43,42 @@ class MockLTIServerTest(unittest.TestCase): ...@@ -45,12 +43,42 @@ class MockLTIServerTest(unittest.TestCase):
# Stop the server, freeing up the port # Stop the server, freeing up the port
self.server.shutdown() self.server.shutdown()
def test_request(self): def test_wrong_signature(self):
""" """
Tests that LTI server processes request with right program 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', 'user_id': 'default_user_id',
'role': 'student', 'role': 'student',
'oauth_nonce': '', 'oauth_nonce': '',
...@@ -64,12 +92,16 @@ class MockLTIServerTest(unittest.TestCase): ...@@ -64,12 +92,16 @@ class MockLTIServerTest(unittest.TestCase):
'oauth_callback': 'about:blank', 'oauth_callback': 'about:blank',
'launch_presentation_return_url': '', 'launch_presentation_return_url': '',
'lis_outcome_service_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 ( ...@@ -14,10 +14,11 @@ from .models import (
import logging import logging
from django.db import DatabaseError from django.db import DatabaseError
from django.contrib.auth.models import User
from xblock.runtime import KeyValueStore from xblock.runtime import KeyValueStore
from xblock.exceptions import KeyValueMultiSaveError, InvalidScopeError from xblock.exceptions import KeyValueMultiSaveError, InvalidScopeError
from xblock.fields import Scope from xblock.fields import Scope, UserScope
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -226,10 +227,15 @@ class FieldDataCache(object): ...@@ -226,10 +227,15 @@ class FieldDataCache(object):
if field_object is not None: if field_object is not None:
return field_object 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: if key.scope == Scope.user_state:
field_object, _ = StudentModule.objects.get_or_create( field_object, _ = StudentModule.objects.get_or_create(
course_id=self.course_id, course_id=self.course_id,
student=self.user, student=User.objects.get(id=key.user_id),
module_state_key=key.block_scope_id.url(), module_state_key=key.block_scope_id.url(),
defaults={ defaults={
'state': json.dumps({}), 'state': json.dumps({}),
...@@ -245,12 +251,12 @@ class FieldDataCache(object): ...@@ -245,12 +251,12 @@ class FieldDataCache(object):
field_object, _ = XModuleStudentPrefsField.objects.get_or_create( field_object, _ = XModuleStudentPrefsField.objects.get_or_create(
field_name=key.field_name, field_name=key.field_name,
module_type=key.block_scope_id, module_type=key.block_scope_id,
student=self.user, student=User.objects.get(id=key.user_id),
) )
elif key.scope == Scope.user_info: elif key.scope == Scope.user_info:
field_object, _ = XModuleStudentInfoField.objects.get_or_create( field_object, _ = XModuleStudentInfoField.objects.get_or_create(
field_name=key.field_name, 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) cache_key = self._cache_key_from_kvs_key(key)
...@@ -347,7 +353,7 @@ class DjangoKeyValueStore(KeyValueStore): ...@@ -347,7 +353,7 @@ class DjangoKeyValueStore(KeyValueStore):
# the list of successful saves # the list of successful saves
saved_fields.extend([field.field_name for field in field_objects[field_object]]) saved_fields.extend([field.field_name for field in field_objects[field_object]])
except DatabaseError: 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) raise KeyValueMultiSaveError(saved_fields)
def delete(self, key): def delete(self, key):
......
...@@ -14,7 +14,7 @@ from django.core.exceptions import PermissionDenied ...@@ -14,7 +14,7 @@ from django.core.exceptions import PermissionDenied
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import Http404 from django.http import Http404
from django.http import HttpResponse 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 capa.xqueue_interface import XQueueInterface
from courseware.access import has_access from courseware.access import has_access
...@@ -24,7 +24,7 @@ from lms.lib.xblock.field_data import LmsFieldData ...@@ -24,7 +24,7 @@ from lms.lib.xblock.field_data import LmsFieldData
from lms.lib.xblock.runtime import LmsModuleSystem, handler_prefix, unquote_slashes from lms.lib.xblock.runtime import LmsModuleSystem, handler_prefix, unquote_slashes
from mitxmako.shortcuts import render_to_string from mitxmako.shortcuts import render_to_string
from psychometrics.psychoanalyze import make_psychometrics_data_update_handler 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.json_request import JsonResponse
from util.sandboxing import can_execute_unsafe_code from util.sandboxing import can_execute_unsafe_code
from xblock.fields import Scope from xblock.fields import Scope
...@@ -37,6 +37,7 @@ from xmodule.modulestore import Location ...@@ -37,6 +37,7 @@ from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError 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_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__) log = logging.getLogger(__name__)
...@@ -285,15 +286,20 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours ...@@ -285,15 +286,20 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours
position, wrap_xmodule_display, grade_bucket_type, position, wrap_xmodule_display, grade_bucket_type,
static_asset_path) 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.""" """A function that allows XModules to publish events. This only supports grade changes right now."""
if event.get('event_name') != 'grade': if event.get('event_name') != 'grade':
return return
if custom_user:
user_id = custom_user.id
else:
user_id = user.id
# Construct the key for the module # Construct the key for the module
key = KeyValueStore.Key( key = KeyValueStore.Key(
scope=Scope.user_state, scope=Scope.user_state,
user_id=user.id, user_id=user_id,
block_scope_id=descriptor.location, block_scope_id=descriptor.location,
field_name='grade' field_name='grade'
) )
...@@ -361,6 +367,17 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours ...@@ -361,6 +367,17 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours
if has_access(user, descriptor, 'staff', course_id): if has_access(user, descriptor, 'staff', course_id):
block_wrappers.append(partial(add_histogram, user)) 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( system = LmsModuleSystem(
track_function=track_function, track_function=track_function,
render_template=render_to_string, render_template=render_to_string,
...@@ -392,7 +409,7 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours ...@@ -392,7 +409,7 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours
), ),
node_path=settings.NODE_PATH, node_path=settings.NODE_PATH,
publish=publish, publish=publish,
anonymous_student_id=unique_id_for_user(user), anonymous_student_id=anonymous_student_id,
course_id=course_id, course_id=course_id,
open_ended_grading_interface=open_ended_grading_interface, open_ended_grading_interface=open_ended_grading_interface,
s3_interface=s3_interface, s3_interface=s3_interface,
...@@ -401,6 +418,7 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours ...@@ -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) # 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 mixins=descriptor.runtime.mixologist._mixins, # pylint: disable=protected-access
wrappers=block_wrappers, wrappers=block_wrappers,
get_real_user=user_by_anonymous_id,
) )
# pass position specified in URL to module through ModuleSystem # pass position specified in URL to module through ModuleSystem
...@@ -482,6 +500,14 @@ def xqueue_callback(request, course_id, userid, mod_id, dispatch): ...@@ -482,6 +500,14 @@ def xqueue_callback(request, course_id, userid, mod_id, dispatch):
return HttpResponse("") 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): def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None):
""" """
Generic view for extensions. This is where AJAX calls go. 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): ...@@ -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 not accessible by the user, or the module raises NotFoundError. If the
module raises any other error, it will escape this function. 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) location = unquote_slashes(usage_id)
# Check parameters and fail fast if there's a problem # Check parameters and fail fast if there's a problem
if not Location.is_valid(location): if not Location.is_valid(location):
raise Http404("Invalid location") raise Http404("Invalid location")
if not request.user.is_authenticated():
raise PermissionDenied
# Check submitted files # Check submitted files
files = request.FILES or {} files = request.FILES or {}
error_msg = _check_files_limits(files) error_msg = _check_files_limits(files)
...@@ -525,15 +559,14 @@ def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None): ...@@ -525,15 +559,14 @@ def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None):
field_data_cache = FieldDataCache.cache_for_descriptor_descendents( field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
course_id, course_id,
request.user, user,
descriptor descriptor
) )
instance = get_module(user, request, location, field_data_cache, course_id, grade_bucket_type='ajax')
instance = get_module(request.user, request, location, field_data_cache, course_id, grade_bucket_type='ajax')
if instance is None: if instance is None:
# Either permissions just changed, or someone is trying to be clever # Either permissions just changed, or someone is trying to be clever
# and load something they shouldn't have access to. # 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 raise Http404
req = django_to_webob_request(request) req = django_to_webob_request(request)
......
...@@ -4,6 +4,9 @@ import oauthlib ...@@ -4,6 +4,9 @@ import oauthlib
from . import BaseTestXmodule from . import BaseTestXmodule
from collections import OrderedDict from collections import OrderedDict
import mock import mock
import urllib
from xmodule.lti_module import LTIModule
from mock import Mock
class TestLTI(BaseTestXmodule): class TestLTI(BaseTestXmodule):
...@@ -26,21 +29,33 @@ class TestLTI(BaseTestXmodule): ...@@ -26,21 +29,33 @@ class TestLTI(BaseTestXmodule):
mocked_signature_after_sign = u'my_signature%3D' mocked_signature_after_sign = u'my_signature%3D'
mocked_decoded_signature = u'my_signature=' 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 = { self.correct_headers = {
u'user_id': user_id,
u'oauth_callback': u'about:blank', u'oauth_callback': u'about:blank',
u'lis_outcome_service_url': '',
u'lis_result_sourcedid': '',
u'launch_presentation_return_url': '', u'launch_presentation_return_url': '',
u'lti_message_type': u'basic-lti-launch-request', u'lti_message_type': u'basic-lti-launch-request',
u'lti_version': 'LTI-1p0', 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_nonce': mocked_nonce,
u'oauth_timestamp': mocked_timestamp, u'oauth_timestamp': mocked_timestamp,
u'oauth_consumer_key': u'', u'oauth_consumer_key': u'',
u'oauth_signature_method': u'HMAC-SHA1', u'oauth_signature_method': u'HMAC-SHA1',
u'oauth_version': u'1.0', 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 u'oauth_signature': mocked_decoded_signature
} }
...@@ -70,14 +85,16 @@ class TestLTI(BaseTestXmodule): ...@@ -70,14 +85,16 @@ class TestLTI(BaseTestXmodule):
Makes sure that all parameters extracted. Makes sure that all parameters extracted.
""" """
generated_context = self.item_module.render('student_view').content generated_context = self.item_module.render('student_view').content
expected_context = { expected_context = {
'input_fields': self.correct_headers,
'display_name': self.item_module.display_name, '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(), 'element_id': self.item_module.location.html_id(),
'launch_url': 'http://www.example.com', # default value 'launch_url': 'http://www.example.com', # default value
'open_in_a_new_page': True, 'open_in_a_new_page': True,
} }
self.assertEqual( self.assertEqual(
generated_context, generated_context,
self.runtime.render_template('lti.html', expected_context), self.runtime.render_template('lti.html', expected_context),
......
...@@ -40,11 +40,14 @@ def mock_descriptor(fields=[]): ...@@ -40,11 +40,14 @@ def mock_descriptor(fields=[]):
location = partial(Location, 'i4x', 'edX', 'test_course', 'problem') location = partial(Location, 'i4x', 'edX', 'test_course', 'problem')
course_id = 'edX/test_course/test' 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')) 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')) settings_key = partial(DjangoKeyValueStore.Key, Scope.settings, None, location('def_id'))
user_state_key = partial(DjangoKeyValueStore.Key, Scope.user_state, 'user', location('def_id')) user_state_key = partial(DjangoKeyValueStore.Key, Scope.user_state, 1, location('def_id'))
prefs_key = partial(DjangoKeyValueStore.Key, Scope.preferences, 'user', 'MockProblemModule') prefs_key = partial(DjangoKeyValueStore.Key, Scope.preferences, 1, 'MockProblemModule')
user_info_key = partial(DjangoKeyValueStore.Key, Scope.user_info, 'user', None) user_info_key = partial(DjangoKeyValueStore.Key, Scope.user_info, 1, None)
class StudentModuleFactory(cmfStudentModuleFactory): class StudentModuleFactory(cmfStudentModuleFactory):
...@@ -76,6 +79,7 @@ class TestStudentModuleStorage(TestCase): ...@@ -76,6 +79,7 @@ class TestStudentModuleStorage(TestCase):
def setUp(self): def setUp(self):
student_module = StudentModuleFactory(state=json.dumps({'a_field': 'a_value', 'b_field': 'b_value'})) student_module = StudentModuleFactory(state=json.dumps({'a_field': 'a_value', 'b_field': 'b_value'}))
self.user = student_module.student 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.field_data_cache = FieldDataCache([mock_descriptor([mock_field(Scope.user_state, 'a_field')])], course_id, self.user)
self.kvs = DjangoKeyValueStore(self.field_data_cache) self.kvs = DjangoKeyValueStore(self.field_data_cache)
...@@ -152,6 +156,7 @@ class TestStudentModuleStorage(TestCase): ...@@ -152,6 +156,7 @@ class TestStudentModuleStorage(TestCase):
class TestMissingStudentModule(TestCase): class TestMissingStudentModule(TestCase):
def setUp(self): def setUp(self):
self.user = UserFactory.create(username='user') 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.field_data_cache = FieldDataCache([mock_descriptor()], course_id, self.user)
self.kvs = DjangoKeyValueStore(self.field_data_cache) self.kvs = DjangoKeyValueStore(self.field_data_cache)
......
""" """
Test for lms courseware app, module render unit Test for lms courseware app, module render unit
""" """
from ddt import ddt, data
from mock import MagicMock, patch, Mock from mock import MagicMock, patch, Mock
import json import json
...@@ -11,10 +12,15 @@ from django.test import TestCase ...@@ -11,10 +12,15 @@ from django.test import TestCase
from django.test.client import RequestFactory from django.test.client import RequestFactory
from django.test.utils import override_settings 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 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.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory
from xmodule.x_module import XModuleDescriptor
import courseware.module_render as render import courseware.module_render as render
from courseware.tests.tests import LoginEnrollmentTestCase from courseware.tests.tests import LoginEnrollmentTestCase
from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE
...@@ -185,9 +191,11 @@ class TestHandleXBlockCallback(ModuleStoreTestCase, LoginEnrollmentTestCase): ...@@ -185,9 +191,11 @@ class TestHandleXBlockCallback(ModuleStoreTestCase, LoginEnrollmentTestCase):
return mock_file return mock_file
def test_invalid_location(self): def test_invalid_location(self):
request = self.request_factory.post('dummy_url', data={'position': 1})
request.user = self.mock_user
with self.assertRaises(Http404): with self.assertRaises(Http404):
render.handle_xblock_callback( render.handle_xblock_callback(
None, request,
'dummy/course/id', 'dummy/course/id',
'invalid Location', 'invalid Location',
'dummy_handler' 'dummy_handler'
...@@ -513,3 +521,74 @@ class TestHtmlModifiers(ModuleStoreTestCase): ...@@ -513,3 +521,74 @@ class TestHtmlModifiers(ModuleStoreTestCase):
'Staff Debug', 'Staff Debug',
result_fragment.content 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): ...@@ -58,11 +58,31 @@ def unquote_slashes(text):
return re.sub(r'(;;|;_)', _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, 'course_id': course_id,
'usage_id': quote_slashes(str(block.scope_ids.usage_id)), 'usage_id': quote_slashes(str(block.scope_ids.usage_id)),
'handler': handler, 'handler': handler,
...@@ -72,8 +92,11 @@ def handler_url(course_id, block, handler, suffix='', query=''): ...@@ -72,8 +92,11 @@ def handler_url(course_id, block, handler, suffix='', query=''):
def handler_prefix(course_id, block): def handler_prefix(course_id, block):
""" """
Returns a prefix for use by the javascript handler_url function. 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.
The prefix is a valid handler url after the handler name is slash-appended
to it.
""" """
return handler_url(course_id, block, '').rstrip('/') return handler_url(course_id, block, '').rstrip('/')
...@@ -86,10 +109,11 @@ class LmsHandlerUrls(object): ...@@ -86,10 +109,11 @@ class LmsHandlerUrls(object):
This must be mixed in to a runtime that already accepts and stores This must be mixed in to a runtime that already accepts and stores
a course_id a course_id
""" """
# pylint: disable=unused-argument
def handler_url(self, block, handler_name, suffix='', query=''): # 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`""" """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 class LmsModuleSystem(LmsHandlerUrls, ModuleSystem): # pylint: disable=abstract-method
......
...@@ -178,7 +178,9 @@ if settings.COURSEWARE_ENABLED: ...@@ -178,7 +178,9 @@ if settings.COURSEWARE_ENABLED:
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/xblock/(?P<usage_id>[^/]*)/handler/(?P<handler>[^/]*)(?:/(?P<suffix>.*))?$', url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/xblock/(?P<usage_id>[^/]*)/handler/(?P<handler>[^/]*)(?:/(?P<suffix>.*))?$',
'courseware.module_render.handle_xblock_callback', 'courseware.module_render.handle_xblock_callback',
name='xblock_handler'), 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 # Software Licenses
......
...@@ -32,7 +32,7 @@ task :showdocs, [:options] do |t, args| ...@@ -32,7 +32,7 @@ task :showdocs, [:options] do |t, args|
elsif args.options == 'data' elsif args.options == 'data'
path = "docs/data" path = "docs/data"
else else
path = "docs" path = "docs/developers"
end end
Launchy.open("#{path}/build/html/index.html") Launchy.open("#{path}/build/html/index.html")
......
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
-e git+https://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk -e git+https://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk
# Our libraries: # 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/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/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 -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