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.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
# -*- 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)
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Adding model 'AnonymousUserId'
|
||||
db.create_table('student_anonymoususerid', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
|
||||
('anonymous_user_id', self.gf('django.db.models.fields.CharField')(unique=True, max_length=16)),
|
||||
('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
|
||||
))
|
||||
db.send_create_signal('student', ['AnonymousUserId'])
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Deleting model 'AnonymousUserId'
|
||||
db.delete_table('student_anonymoususerid')
|
||||
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'student.anonymoususerid': {
|
||||
'Meta': {'object_name': 'AnonymousUserId'},
|
||||
'anonymous_user_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '16'}),
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'student.courseenrollment': {
|
||||
'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'student.courseenrollmentallowed': {
|
||||
'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'},
|
||||
'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
|
||||
},
|
||||
'student.pendingemailchange': {
|
||||
'Meta': {'object_name': 'PendingEmailChange'},
|
||||
'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
|
||||
},
|
||||
'student.pendingnamechange': {
|
||||
'Meta': {'object_name': 'PendingNameChange'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
|
||||
'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
|
||||
},
|
||||
'student.registration': {
|
||||
'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"},
|
||||
'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'})
|
||||
},
|
||||
'student.testcenterregistration': {
|
||||
'Meta': {'object_name': 'TestCenterRegistration'},
|
||||
'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
|
||||
'accommodation_request': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
|
||||
'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}),
|
||||
'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}),
|
||||
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'eligibility_appointment_date_first': ('django.db.models.fields.DateField', [], {'db_index': 'True'}),
|
||||
'eligibility_appointment_date_last': ('django.db.models.fields.DateField', [], {'db_index': 'True'}),
|
||||
'exam_series_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'testcenter_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['student.TestCenterUser']"}),
|
||||
'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
|
||||
'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
|
||||
'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
|
||||
},
|
||||
'student.testcenteruser': {
|
||||
'Meta': {'object_name': 'TestCenterUser'},
|
||||
'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
|
||||
'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
|
||||
'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
|
||||
'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
|
||||
'client_candidate_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}),
|
||||
'company_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '50', 'blank': 'True'}),
|
||||
'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
|
||||
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}),
|
||||
'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}),
|
||||
'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
|
||||
'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}),
|
||||
'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
|
||||
'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}),
|
||||
'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}),
|
||||
'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
|
||||
'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
|
||||
'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
|
||||
'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
|
||||
'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}),
|
||||
'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
|
||||
},
|
||||
'student.userprofile': {
|
||||
'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"},
|
||||
'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}),
|
||||
'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
|
||||
'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
|
||||
'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}),
|
||||
'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'})
|
||||
},
|
||||
'student.userstanding': {
|
||||
'Meta': {'object_name': 'UserStanding'},
|
||||
'account_status': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}),
|
||||
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'standing_last_changed_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'standing'", 'unique': 'True', 'to': "orm['auth.User']"})
|
||||
},
|
||||
'student.usertestgroup': {
|
||||
'Meta': {'object_name': 'UserTestGroup'},
|
||||
'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
|
||||
'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['student']
|
||||
@@ -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(
|
||||
|
||||
@@ -1,21 +1,60 @@
|
||||
"""
|
||||
Module that allows to insert LTI tools to page.
|
||||
Learning Tools Interoperability (LTI) module.
|
||||
|
||||
Protocol is oauth1, LTI version is 1.1.1:
|
||||
http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html
|
||||
|
||||
Resources
|
||||
---------
|
||||
|
||||
Theoretical background and detailed specifications of LTI can be found on:
|
||||
|
||||
http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html
|
||||
|
||||
This module is based on the version 1.1.1 of the LTI specifications by the
|
||||
IMS Global authority. For authentication, it uses OAuth1.
|
||||
|
||||
When responding back to the LTI tool provider, we must issue a correct
|
||||
response. Types of responses and their message payload is available at:
|
||||
|
||||
Table A1.2 Interpretation of the 'CodeMajor/severity' matrix.
|
||||
http://www.imsglobal.org/gws/gwsv1p0/imsgws_wsdlBindv1p0.html
|
||||
|
||||
A resource to test the LTI protocol (PHP realization):
|
||||
|
||||
http://www.imsglobal.org/developers/LTI/test/v1p1/lms.php
|
||||
|
||||
|
||||
What is supported:
|
||||
------------------
|
||||
|
||||
1.) Display of simple LTI in iframe or a new window.
|
||||
2.) Multiple LTI components on a single page.
|
||||
3.) The use of multiple LTI providers per course.
|
||||
4.) Use of advanced LTI component that provides back a grade.
|
||||
a.) The LTI provider sends back a grade to a specified URL.
|
||||
b.) Currently only action "update" is supported. "Read", and "delete"
|
||||
actions initially weren't required.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import oauthlib.oauth1
|
||||
from oauthlib.oauth1.rfc5849 import signature
|
||||
import hashlib
|
||||
import base64
|
||||
import urllib
|
||||
import textwrap
|
||||
from lxml import etree
|
||||
from webob import Response
|
||||
import mock
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
from xmodule.editing_module import MetadataOnlyEditingDescriptor
|
||||
from xmodule.raw_module import EmptyDataRawDescriptor
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.x_module import XModule, module_attr
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from pkg_resources import resource_string
|
||||
from xblock.core import String, Scope, List
|
||||
from xblock.fields import Boolean
|
||||
from xblock.core import String, Scope, List, XBlock
|
||||
from xblock.fields import Boolean, Float
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,8 +68,8 @@ class LTIFields(object):
|
||||
Fields to define and obtain LTI tool from provider are set here,
|
||||
except credentials, which should be set in course settings::
|
||||
|
||||
`lti_id` is id to connect tool with credentials in course settings.
|
||||
`launch_url` is launch url of tool.
|
||||
`lti_id` is id to connect tool with credentials in course settings. It should not contain :: (double semicolon)
|
||||
`launch_url` is launch URL of tool.
|
||||
`custom_parameters` are additional parameters to navigate to proper book and book page.
|
||||
|
||||
For example, for Vitalsource provider, `launch_url` should be
|
||||
@@ -40,7 +79,7 @@ class LTIFields(object):
|
||||
vbid=put_book_id_here
|
||||
book_location=page/put_page_number_here
|
||||
|
||||
Default non-empty url for `launch_url` is needed due to oauthlib demand (url scheme should be presented)::
|
||||
Default non-empty URL for `launch_url` is needed due to oauthlib demand (URL scheme should be presented)::
|
||||
|
||||
https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136
|
||||
"""
|
||||
@@ -48,13 +87,15 @@ class LTIFields(object):
|
||||
launch_url = String(help="URL of the tool", default='http://www.example.com', scope=Scope.settings)
|
||||
custom_parameters = List(help="Custom parameters (vbid, book_location, etc..)", scope=Scope.settings)
|
||||
open_in_a_new_page = Boolean(help="Should LTI be opened in new page?", default=True, scope=Scope.settings)
|
||||
graded = Boolean(help="Grades will be considered in overall score.", default=False, scope=Scope.settings)
|
||||
weight = Float(help="Weight for student grades.", default=1.0, scope=Scope.settings)
|
||||
|
||||
|
||||
class LTIModule(LTIFields, XModule):
|
||||
'''
|
||||
"""
|
||||
Module provides LTI integration to course.
|
||||
|
||||
Except usual xmodule structure it proceeds with oauth signing.
|
||||
Except usual Xmodule structure it proceeds with OAuth signing.
|
||||
How it works::
|
||||
|
||||
1. Get credentials from course settings.
|
||||
@@ -71,14 +112,14 @@ class LTIModule(LTIFields, XModule):
|
||||
role
|
||||
*+ all custom parameters*
|
||||
|
||||
These parameters should be encoded and signed by *oauth1* together with
|
||||
These parameters should be encoded and signed by *OAuth1* together with
|
||||
`launch_url` and *POST* request type.
|
||||
|
||||
3. Signing proceeds with client key/secret pair obtained from course settings.
|
||||
That pair should be obtained from LTI provider and set into course settings by course author.
|
||||
After that signature and other oauth data are generated.
|
||||
After that signature and other OAuth data are generated.
|
||||
|
||||
Oauth data which is generated after signing is usual::
|
||||
OAuth data which is generated after signing is usual::
|
||||
|
||||
oauth_callback
|
||||
oauth_nonce
|
||||
@@ -89,47 +130,47 @@ class LTIModule(LTIFields, XModule):
|
||||
|
||||
|
||||
4. All that data is passed to form and sent to LTI provider server by browser via
|
||||
autosubmit via javascript.
|
||||
autosubmit via JavaScript.
|
||||
|
||||
Form example::
|
||||
|
||||
<form
|
||||
action="${launch_url}"
|
||||
name="ltiLaunchForm-${element_id}"
|
||||
class="ltiLaunchForm"
|
||||
method="post"
|
||||
target="ltiLaunchFrame-${element_id}"
|
||||
encType="application/x-www-form-urlencoded"
|
||||
>
|
||||
<input name="launch_presentation_return_url" value="" />
|
||||
<input name="lis_outcome_service_url" value="" />
|
||||
<input name="lis_result_sourcedid" value="" />
|
||||
<input name="lti_message_type" value="basic-lti-launch-request" />
|
||||
<input name="lti_version" value="LTI-1p0" />
|
||||
<input name="oauth_callback" value="about:blank" />
|
||||
<input name="oauth_consumer_key" value="${oauth_consumer_key}" />
|
||||
<input name="oauth_nonce" value="${oauth_nonce}" />
|
||||
<input name="oauth_signature_method" value="HMAC-SHA1" />
|
||||
<input name="oauth_timestamp" value="${oauth_timestamp}" />
|
||||
<input name="oauth_version" value="1.0" />
|
||||
<input name="user_id" value="${user_id}" />
|
||||
<input name="role" value="student" />
|
||||
<input name="oauth_signature" value="${oauth_signature}" />
|
||||
action="${launch_url}"
|
||||
name="ltiLaunchForm-${element_id}"
|
||||
class="ltiLaunchForm"
|
||||
method="post"
|
||||
target="ltiLaunchFrame-${element_id}"
|
||||
encType="application/x-www-form-urlencoded"
|
||||
>
|
||||
<input name="launch_presentation_return_url" value="" />
|
||||
<input name="lis_outcome_service_url" value="" />
|
||||
<input name="lis_result_sourcedid" value="" />
|
||||
<input name="lti_message_type" value="basic-lti-launch-request" />
|
||||
<input name="lti_version" value="LTI-1p0" />
|
||||
<input name="oauth_callback" value="about:blank" />
|
||||
<input name="oauth_consumer_key" value="${oauth_consumer_key}" />
|
||||
<input name="oauth_nonce" value="${oauth_nonce}" />
|
||||
<input name="oauth_signature_method" value="HMAC-SHA1" />
|
||||
<input name="oauth_timestamp" value="${oauth_timestamp}" />
|
||||
<input name="oauth_version" value="1.0" />
|
||||
<input name="user_id" value="${user_id}" />
|
||||
<input name="role" value="student" />
|
||||
<input name="oauth_signature" value="${oauth_signature}" />
|
||||
|
||||
<input name="custom_1" value="${custom_param_1_value}" />
|
||||
<input name="custom_2" value="${custom_param_2_value}" />
|
||||
<input name="custom_..." value="${custom_param_..._value}" />
|
||||
<input name="custom_1" value="${custom_param_1_value}" />
|
||||
<input name="custom_2" value="${custom_param_2_value}" />
|
||||
<input name="custom_..." value="${custom_param_..._value}" />
|
||||
|
||||
<input type="submit" value="Press to Launch" />
|
||||
</form>
|
||||
<input type="submit" value="Press to Launch" />
|
||||
</form>
|
||||
|
||||
5. LTI provider has same secret key and it signs data string via *oauth1* and compares signatures.
|
||||
5. LTI provider has same secret key and it signs data string via *OAuth1* and compares signatures.
|
||||
|
||||
If signatures are correct, LTI provider redirects iframe source to LTI tool web page,
|
||||
and LTI tool is rendered to iframe inside course.
|
||||
|
||||
Otherwise error message from LTI provider is generated.
|
||||
'''
|
||||
"""
|
||||
|
||||
js = {'js': [resource_string(__name__, 'js/src/lti/lti.js')]}
|
||||
css = {'scss': [resource_string(__name__, 'css/lti/lti.scss')]}
|
||||
@@ -180,21 +221,7 @@ class LTIModule(LTIFields, XModule):
|
||||
"tool_consumer_instance_contact_email",
|
||||
]
|
||||
|
||||
# Obtains client_key and client_secret credentials from current course:
|
||||
course_id = self.course_id
|
||||
course_location = CourseDescriptor.id_to_location(course_id)
|
||||
course = self.descriptor.runtime.modulestore.get_item(course_location)
|
||||
client_key = client_secret = ''
|
||||
|
||||
for lti_passport in course.lti_passports:
|
||||
try:
|
||||
lti_id, key, secret = [i.strip() for i in lti_passport.split(':')]
|
||||
except ValueError:
|
||||
raise LTIError('Could not parse LTI passport: {0!r}. \
|
||||
Should be "id:key:secret" string.'.format(lti_passport))
|
||||
if lti_id == self.lti_id.strip():
|
||||
client_key, client_secret = key, secret
|
||||
break
|
||||
client_key, client_secret = self.get_client_key_secret()
|
||||
|
||||
# parsing custom parameters to dict
|
||||
custom_parameters = {}
|
||||
@@ -214,12 +241,12 @@ class LTIModule(LTIFields, XModule):
|
||||
input_fields = self.oauth_params(
|
||||
custom_parameters,
|
||||
client_key,
|
||||
client_secret
|
||||
client_secret,
|
||||
)
|
||||
context = {
|
||||
'input_fields': input_fields,
|
||||
|
||||
# these params do not participate in oauth signing
|
||||
# These parameters do not participate in OAuth signing.
|
||||
'launch_url': self.launch_url.strip(),
|
||||
'element_id': self.location.html_id(),
|
||||
'element_class': self.category,
|
||||
@@ -229,12 +256,57 @@ class LTIModule(LTIFields, XModule):
|
||||
|
||||
return self.system.render_template('lti.html', context)
|
||||
|
||||
def get_user_id(self):
|
||||
user_id = self.runtime.anonymous_student_id
|
||||
assert user_id is not None
|
||||
return unicode(urllib.quote(user_id))
|
||||
|
||||
def get_outcome_service_url(self):
|
||||
"""
|
||||
Return URL for storing grades.
|
||||
"""
|
||||
uri = 'http://{host}{path}'.format(
|
||||
host=self.system.hostname,
|
||||
path=self.runtime.handler_url(self, 'grade_handler', thirdparty=True).rstrip('/?')
|
||||
)
|
||||
return uri
|
||||
|
||||
def get_resource_link_id(self):
|
||||
"""
|
||||
This is an opaque unique identifier that the TC guarantees will be unique
|
||||
within the TC for every placement of the link.
|
||||
|
||||
If the tool / activity is placed multiple times in the same context,
|
||||
each of those placements will be distinct.
|
||||
|
||||
This value will also change if the item is exported from one system or
|
||||
context and imported into another system or context.
|
||||
|
||||
This parameter is required.
|
||||
"""
|
||||
return unicode(urllib.quote(self.id))
|
||||
|
||||
def get_lis_result_sourcedid(self):
|
||||
"""
|
||||
This field contains an identifier that indicates the LIS Result Identifier (if any)
|
||||
associated with this launch. This field identifies a unique row and column within the
|
||||
TC gradebook. This field is unique for every combination of context_id / resource_link_id / user_id.
|
||||
This value may change for a particular resource_link_id / user_id from one launch to the next.
|
||||
The TP should only retain the most recent value for this field for a particular resource_link_id / user_id.
|
||||
This field is generally optional, but is required for grading.
|
||||
|
||||
context_id is - is an opaque identifier that uniquely identifies the context that contains
|
||||
the link being launched.
|
||||
lti_id should be context_id by meaning.
|
||||
"""
|
||||
return u':'.join(urllib.quote(i) for i in (self.lti_id, self.get_resource_link_id(), self.get_user_id()))
|
||||
|
||||
|
||||
def oauth_params(self, custom_parameters, client_key, client_secret):
|
||||
"""
|
||||
Signs request and returns signature and oauth parameters.
|
||||
Signs request and returns signature and OAuth parameters.
|
||||
|
||||
`custom_paramters` is dict of parsed `custom_parameter` field
|
||||
|
||||
`client_key` and `client_secret` are LTI tool credentials.
|
||||
|
||||
Also *anonymous student id* is passed to template and therefore to LTI provider.
|
||||
@@ -245,22 +317,23 @@ class LTIModule(LTIFields, XModule):
|
||||
client_secret=unicode(client_secret)
|
||||
)
|
||||
|
||||
user_id = self.runtime.anonymous_student_id
|
||||
assert user_id is not None
|
||||
|
||||
# must have parameters for correct signing from LTI:
|
||||
# Must have parameters for correct signing from LTI:
|
||||
body = {
|
||||
u'user_id': user_id,
|
||||
u'user_id': self.get_user_id(),
|
||||
u'oauth_callback': u'about:blank',
|
||||
u'lis_outcome_service_url': '',
|
||||
u'lis_result_sourcedid': '',
|
||||
u'launch_presentation_return_url': '',
|
||||
u'lti_message_type': u'basic-lti-launch-request',
|
||||
u'lti_version': 'LTI-1p0',
|
||||
u'role': u'student'
|
||||
u'role': u'student',
|
||||
|
||||
# Parameters required for grading:
|
||||
u'resource_link_id': self.get_resource_link_id(),
|
||||
u'lis_outcome_service_url': self.get_outcome_service_url(),
|
||||
u'lis_result_sourcedid': self.get_lis_result_sourcedid(),
|
||||
|
||||
}
|
||||
|
||||
# appending custom parameter for signing
|
||||
# Appending custom parameter for signing.
|
||||
body.update(custom_parameters)
|
||||
|
||||
headers = {
|
||||
@@ -274,9 +347,9 @@ class LTIModule(LTIFields, XModule):
|
||||
http_method=u'POST',
|
||||
body=body,
|
||||
headers=headers)
|
||||
except ValueError: # scheme not in url
|
||||
#https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136
|
||||
#Stubbing headers for now:
|
||||
except ValueError: # Scheme not in url.
|
||||
# https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136
|
||||
# Stubbing headers for now:
|
||||
headers = {
|
||||
u'Content-Type': u'application/x-www-form-urlencoded',
|
||||
u'Authorization': u'OAuth oauth_nonce="80966668944732164491378916897", \
|
||||
@@ -284,7 +357,7 @@ oauth_timestamp="1378916897", oauth_version="1.0", oauth_signature_method="HMAC-
|
||||
oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
|
||||
|
||||
params = headers['Authorization']
|
||||
# parse headers to pass to template as part of context:
|
||||
# Parse headers to pass to template as part of context:
|
||||
params = dict([param.strip().replace('"', '').split('=') for param in params.split(',')])
|
||||
|
||||
params[u'oauth_nonce'] = params[u'OAuth oauth_nonce']
|
||||
@@ -297,13 +370,217 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
|
||||
# So we need to decode signature back:
|
||||
params[u'oauth_signature'] = urllib.unquote(params[u'oauth_signature']).decode('utf8')
|
||||
|
||||
# add lti parameters to oauth parameters for sending in form
|
||||
# Add LTI parameters to OAuth parameters for sending in form.
|
||||
params.update(body)
|
||||
return params
|
||||
|
||||
def max_score(self):
|
||||
return self.weight
|
||||
|
||||
class LTIModuleDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRawDescriptor):
|
||||
|
||||
@XBlock.handler
|
||||
def grade_handler(self, request, dispatch):
|
||||
"""
|
||||
This is called by courseware.module_render, to handle an AJAX call.
|
||||
|
||||
Used only for grading. Returns XML response.
|
||||
|
||||
Example of request body from LTI provider::
|
||||
|
||||
<?xml version = "1.0" encoding = "UTF-8"?>
|
||||
<imsx_POXEnvelopeRequest xmlns = "some_link (may be not required)">
|
||||
<imsx_POXHeader>
|
||||
<imsx_POXRequestHeaderInfo>
|
||||
<imsx_version>V1.0</imsx_version>
|
||||
<imsx_messageIdentifier>528243ba5241b</imsx_messageIdentifier>
|
||||
</imsx_POXRequestHeaderInfo>
|
||||
</imsx_POXHeader>
|
||||
<imsx_POXBody>
|
||||
<replaceResultRequest>
|
||||
<resultRecord>
|
||||
<sourcedGUID>
|
||||
<sourcedId>feb-123-456-2929::28883</sourcedId>
|
||||
</sourcedGUID>
|
||||
<result>
|
||||
<resultScore>
|
||||
<language>en-us</language>
|
||||
<textString>0.4</textString>
|
||||
</resultScore>
|
||||
</result>
|
||||
</resultRecord>
|
||||
</replaceResultRequest>
|
||||
</imsx_POXBody>
|
||||
</imsx_POXEnvelopeRequest>
|
||||
|
||||
Example of correct/incorrect answer XML body:: see response_xml_template.
|
||||
"""
|
||||
response_xml_template = textwrap.dedent("""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<imsx_POXEnvelopeResponse xmlns = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0">
|
||||
<imsx_POXHeader>
|
||||
<imsx_POXResponseHeaderInfo>
|
||||
<imsx_version>V1.0</imsx_version>
|
||||
<imsx_messageIdentifier>{imsx_messageIdentifier}</imsx_messageIdentifier>
|
||||
<imsx_statusInfo>
|
||||
<imsx_codeMajor>{imsx_codeMajor}</imsx_codeMajor>
|
||||
<imsx_severity>status</imsx_severity>
|
||||
<imsx_description>{imsx_description}</imsx_description>
|
||||
<imsx_messageRefIdentifier>
|
||||
</imsx_messageRefIdentifier>
|
||||
</imsx_statusInfo>
|
||||
</imsx_POXResponseHeaderInfo>
|
||||
</imsx_POXHeader>
|
||||
<imsx_POXBody>{response}</imsx_POXBody>
|
||||
</imsx_POXEnvelopeResponse>
|
||||
""")
|
||||
# Returns when `action` is unsupported.
|
||||
# Supported actions:
|
||||
# - replaceResultRequest.
|
||||
unsupported_values = {
|
||||
'imsx_codeMajor': 'unsupported',
|
||||
'imsx_description': 'Target does not support the requested operation.',
|
||||
'imsx_messageIdentifier': 'unknown',
|
||||
'response': ''
|
||||
}
|
||||
# Returns if:
|
||||
# - score is out of range;
|
||||
# - can't parse response from TP;
|
||||
# - can't verify OAuth signing or OAuth signing is incorrect.
|
||||
failure_values = {
|
||||
'imsx_codeMajor': 'failure',
|
||||
'imsx_description': 'The request has failed.',
|
||||
'imsx_messageIdentifier': 'unknown',
|
||||
'response': ''
|
||||
}
|
||||
|
||||
try:
|
||||
imsx_messageIdentifier, sourcedId, score, action = self.parse_grade_xml_body(request.body)
|
||||
except Exception:
|
||||
return Response(response_xml_template.format(**failure_values), content_type="application/xml")
|
||||
|
||||
# Verify OAuth signing.
|
||||
try:
|
||||
self.verify_oauth_body_sign(request)
|
||||
except (ValueError, LTIError):
|
||||
failure_values['imsx_messageIdentifier'] = escape(imsx_messageIdentifier)
|
||||
return Response(response_xml_template.format(**failure_values), content_type="application/xml")
|
||||
|
||||
|
||||
real_user = self.system.get_real_user(urllib.unquote(sourcedId.split(':')[-1]))
|
||||
if action == 'replaceResultRequest':
|
||||
self.system.publish(
|
||||
event={
|
||||
'event_name': 'grade',
|
||||
'value': score * self.max_score(),
|
||||
'max_value': self.max_score(),
|
||||
},
|
||||
custom_user=real_user
|
||||
)
|
||||
|
||||
values = {
|
||||
'imsx_codeMajor': 'success',
|
||||
'imsx_description': 'Score for {sourced_id} is now {score}'.format(sourced_id=sourcedId, score=score),
|
||||
'imsx_messageIdentifier': escape(imsx_messageIdentifier),
|
||||
'response': '<replaceResultResponse/>'
|
||||
}
|
||||
return Response(response_xml_template.format(**values), content_type="application/xml")
|
||||
|
||||
unsupported_values['imsx_messageIdentifier'] = escape(imsx_messageIdentifier)
|
||||
return Response(response_xml_template.format(**unsupported_values), content_type='application/xml')
|
||||
|
||||
|
||||
@classmethod
|
||||
def parse_grade_xml_body(cls, body):
|
||||
"""
|
||||
Parses XML from request.body and returns parsed data
|
||||
|
||||
XML body should contain nsmap with namespace, that is specified in LTI specs.
|
||||
|
||||
Returns tuple: imsx_messageIdentifier, sourcedId, score, action
|
||||
|
||||
Raises Exception if can't parse.
|
||||
"""
|
||||
lti_spec_namespace = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0"
|
||||
namespaces = {'def': lti_spec_namespace}
|
||||
|
||||
data = body.strip().encode('utf-8')
|
||||
parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8')
|
||||
root = etree.fromstring(data, parser=parser)
|
||||
|
||||
imsx_messageIdentifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text
|
||||
sourcedId = root.xpath("//def:sourcedId", namespaces=namespaces)[0].text
|
||||
score = root.xpath("//def:textString", namespaces=namespaces)[0].text
|
||||
action = root.xpath("//def:imsx_POXBody", namespaces=namespaces)[0].getchildren()[0].tag.replace('{'+lti_spec_namespace+'}', '')
|
||||
# Raise exception if score is not float or not in range 0.0-1.0 regarding spec.
|
||||
score = float(score)
|
||||
if not 0 <= score <= 1:
|
||||
raise LTIError
|
||||
|
||||
return imsx_messageIdentifier, sourcedId, score, action
|
||||
|
||||
def verify_oauth_body_sign(self, request):
|
||||
"""
|
||||
Verify grade request from LTI provider using OAuth body signing.
|
||||
|
||||
Uses http://oauth.googlecode.com/svn/spec/ext/body_hash/1.0/oauth-bodyhash.html::
|
||||
|
||||
This specification extends the OAuth signature to include integrity checks on HTTP request bodies
|
||||
with content types other than application/x-www-form-urlencoded.
|
||||
|
||||
Arguments:
|
||||
request: DjangoWebobRequest.
|
||||
|
||||
Raises:
|
||||
LTIError if request is incorrect.
|
||||
"""
|
||||
|
||||
client_key, client_secret = self.get_client_key_secret()
|
||||
|
||||
headers = {
|
||||
'Authorization':unicode(request.headers.get('Authorization')),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}
|
||||
|
||||
sha1 = hashlib.sha1()
|
||||
sha1.update(request.body)
|
||||
oauth_body_hash = base64.b64encode(sha1.hexdigest())
|
||||
|
||||
oauth_params = signature.collect_parameters(headers=headers, exclude_oauth_signature=False)
|
||||
oauth_headers =dict(oauth_params)
|
||||
oauth_signature = oauth_headers.pop('oauth_signature')
|
||||
|
||||
mock_request = mock.Mock(
|
||||
uri=unicode(urllib.unquote(request.url)),
|
||||
http_method=unicode(request.method),
|
||||
params=oauth_headers.items(),
|
||||
signature=oauth_signature
|
||||
)
|
||||
if (oauth_body_hash != oauth_headers.get('oauth_body_hash') or
|
||||
not signature.verify_hmac_sha1(mock_request, client_secret)):
|
||||
raise LTIError
|
||||
|
||||
def get_client_key_secret(self):
|
||||
"""
|
||||
Obtains client_key and client_secret credentials from current course.
|
||||
"""
|
||||
course_id = self.course_id
|
||||
course_location = CourseDescriptor.id_to_location(course_id)
|
||||
course = self.descriptor.runtime.modulestore.get_item(course_location)
|
||||
|
||||
for lti_passport in course.lti_passports:
|
||||
try:
|
||||
lti_id, key, secret = [i.strip() for i in lti_passport.split(':')]
|
||||
except ValueError:
|
||||
raise LTIError('Could not parse LTI passport: {0!r}. \
|
||||
Should be "id:key:secret" string.'.format(lti_passport))
|
||||
if lti_id == self.lti_id.strip():
|
||||
return key, secret
|
||||
return '', ''
|
||||
|
||||
class LTIDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRawDescriptor):
|
||||
"""
|
||||
Descriptor for LTI Xmodule.
|
||||
"""
|
||||
has_score = True
|
||||
module_class = LTIModule
|
||||
grade_handler = module_attr('grade_handler')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
251
common/lib/xmodule/xmodule/tests/test_lti_unit.py
Normal file
251
common/lib/xmodule/xmodule/tests/test_lti_unit.py
Normal file
@@ -0,0 +1,251 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Test for LTI Xmodule functional logic."""
|
||||
|
||||
from mock import Mock, patch, PropertyMock
|
||||
import textwrap
|
||||
from lxml import etree
|
||||
from webob.request import Request
|
||||
from copy import copy
|
||||
import urllib
|
||||
|
||||
from xmodule.lti_module import LTIDescriptor
|
||||
|
||||
from . import LogicTest
|
||||
|
||||
|
||||
class LTIModuleTest(LogicTest):
|
||||
"""Logic tests for LTI module."""
|
||||
descriptor_class = LTIDescriptor
|
||||
|
||||
def setUp(self):
|
||||
super(LTIModuleTest, self).setUp()
|
||||
self.environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'}
|
||||
self.request_body_xml_template = 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>{messageIdentifier}</imsx_messageIdentifier>
|
||||
</imsx_POXRequestHeaderInfo>
|
||||
</imsx_POXHeader>
|
||||
<imsx_POXBody>
|
||||
<{action}>
|
||||
<resultRecord>
|
||||
<sourcedGUID>
|
||||
<sourcedId>{sourcedId}</sourcedId>
|
||||
</sourcedGUID>
|
||||
<result>
|
||||
<resultScore>
|
||||
<language>en-us</language>
|
||||
<textString>{grade}</textString>
|
||||
</resultScore>
|
||||
</result>
|
||||
</resultRecord>
|
||||
</{action}>
|
||||
</imsx_POXBody>
|
||||
</imsx_POXEnvelopeRequest>
|
||||
""")
|
||||
self.system.get_real_user = Mock()
|
||||
self.xmodule.get_client_key_secret = Mock(return_value=('key', 'secret'))
|
||||
self.system.publish = Mock()
|
||||
|
||||
self.user_id = self.xmodule.runtime.anonymous_student_id
|
||||
self.lti_id = self.xmodule.lti_id
|
||||
self.module_id = '//MITx/999/lti/'
|
||||
|
||||
sourcedId = u':'.join(urllib.quote(i) for i in (self.lti_id, self.module_id, self.user_id))
|
||||
|
||||
self.DEFAULTS = {
|
||||
'sourcedId': sourcedId,
|
||||
'action': 'replaceResultRequest',
|
||||
'grade': '0.5',
|
||||
'messageIdentifier': '528243ba5241b',
|
||||
}
|
||||
|
||||
def get_request_body(self, params={}):
|
||||
data = copy(self.DEFAULTS)
|
||||
|
||||
data.update(params)
|
||||
return self.request_body_xml_template.format(**data)
|
||||
|
||||
def get_response_values(self, response):
|
||||
parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8')
|
||||
root = etree.fromstring(response.body.strip(), parser=parser)
|
||||
lti_spec_namespace = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0"
|
||||
namespaces = {'def': lti_spec_namespace}
|
||||
|
||||
code_major = root.xpath("//def:imsx_codeMajor", namespaces=namespaces)[0].text
|
||||
description = root.xpath("//def:imsx_description", namespaces=namespaces)[0].text
|
||||
messageIdentifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text
|
||||
imsx_POXBody = root.xpath("//def:imsx_POXBody", namespaces=namespaces)[0]
|
||||
|
||||
try:
|
||||
action = imsx_POXBody.getchildren()[0].tag.replace('{'+lti_spec_namespace+'}', '')
|
||||
except Exception:
|
||||
action = None
|
||||
|
||||
return {
|
||||
'code_major': code_major,
|
||||
'description': description,
|
||||
'messageIdentifier': messageIdentifier,
|
||||
'action': action
|
||||
}
|
||||
|
||||
def test_authorization_header_not_present(self):
|
||||
"""
|
||||
Request has no Authorization header.
|
||||
This is an unknown service request, i.e., it is not a part of the original service specification.
|
||||
"""
|
||||
request = Request(self.environ)
|
||||
request.body = self.get_request_body()
|
||||
response = self.xmodule.grade_handler(request, '')
|
||||
real_response = self.get_response_values(response)
|
||||
expected_response = {
|
||||
'action': None,
|
||||
'code_major': 'failure',
|
||||
'description': 'The request has failed.',
|
||||
'messageIdentifier': self.DEFAULTS['messageIdentifier'],
|
||||
}
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertDictEqual(expected_response, real_response)
|
||||
|
||||
def test_authorization_header_empty(self):
|
||||
"""
|
||||
Request Authorization header has no value.
|
||||
This is an unknown service request, i.e., it is not a part of the original service specification.
|
||||
"""
|
||||
request = Request(self.environ)
|
||||
request.authorization = "bad authorization header"
|
||||
request.body = self.get_request_body()
|
||||
response = self.xmodule.grade_handler(request, '')
|
||||
real_response = self.get_response_values(response)
|
||||
expected_response = {
|
||||
'action': None,
|
||||
'code_major': 'failure',
|
||||
'description': 'The request has failed.',
|
||||
'messageIdentifier': self.DEFAULTS['messageIdentifier'],
|
||||
}
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertDictEqual(expected_response, real_response)
|
||||
|
||||
def test_grade_not_in_range(self):
|
||||
"""
|
||||
Grade returned from Tool Provider is outside the range 0.0-1.0.
|
||||
"""
|
||||
self.xmodule.verify_oauth_body_sign = Mock()
|
||||
request = Request(self.environ)
|
||||
request.body = self.get_request_body(params={'grade': '10'})
|
||||
response = self.xmodule.grade_handler(request, '')
|
||||
real_response = self.get_response_values(response)
|
||||
expected_response = {
|
||||
'action': None,
|
||||
'code_major': 'failure',
|
||||
'description': 'The request has failed.',
|
||||
'messageIdentifier': 'unknown',
|
||||
}
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertDictEqual(expected_response, real_response)
|
||||
|
||||
def test_bad_grade_decimal(self):
|
||||
"""
|
||||
Grade returned from Tool Provider doesn't use a period as the decimal point.
|
||||
"""
|
||||
self.xmodule.verify_oauth_body_sign = Mock()
|
||||
request = Request(self.environ)
|
||||
request.body = self.get_request_body(params={'grade': '0,5'})
|
||||
response = self.xmodule.grade_handler(request, '')
|
||||
real_response = self.get_response_values(response)
|
||||
expected_response = {
|
||||
'action': None,
|
||||
'code_major': 'failure',
|
||||
'description': 'The request has failed.',
|
||||
'messageIdentifier': 'unknown',
|
||||
}
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertDictEqual(expected_response, real_response)
|
||||
|
||||
def test_unsupported_action(self):
|
||||
"""
|
||||
Action returned from Tool Provider isn't supported.
|
||||
`replaceResultRequest` is supported only.
|
||||
"""
|
||||
self.xmodule.verify_oauth_body_sign = Mock()
|
||||
request = Request(self.environ)
|
||||
request.body = self.get_request_body({'action': 'wrongAction'})
|
||||
response = self.xmodule.grade_handler(request, '')
|
||||
real_response = self.get_response_values(response)
|
||||
expected_response = {
|
||||
'action': None,
|
||||
'code_major': 'unsupported',
|
||||
'description': 'Target does not support the requested operation.',
|
||||
'messageIdentifier': self.DEFAULTS['messageIdentifier'],
|
||||
}
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertDictEqual(expected_response, real_response)
|
||||
|
||||
def test_good_request(self):
|
||||
"""
|
||||
Response from Tool Provider is correct.
|
||||
"""
|
||||
self.xmodule.verify_oauth_body_sign = Mock()
|
||||
request = Request(self.environ)
|
||||
request.body = self.get_request_body()
|
||||
response = self.xmodule.grade_handler(request, '')
|
||||
code_major, description, messageIdentifier, action = self.get_response_values(response)
|
||||
description_expected = 'Score for {sourcedId} is now {score}'.format(
|
||||
sourcedId=self.DEFAULTS['sourcedId'],
|
||||
score=self.DEFAULTS['grade'],
|
||||
)
|
||||
real_response = self.get_response_values(response)
|
||||
expected_response = {
|
||||
'action': 'replaceResultResponse',
|
||||
'code_major': 'success',
|
||||
'description': description_expected,
|
||||
'messageIdentifier': self.DEFAULTS['messageIdentifier'],
|
||||
}
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertDictEqual(expected_response, real_response)
|
||||
|
||||
def test_user_id(self):
|
||||
expected_user_id = unicode(urllib.quote(self.xmodule.runtime.anonymous_student_id))
|
||||
real_user_id = self.xmodule.get_user_id()
|
||||
self.assertEqual(real_user_id, expected_user_id)
|
||||
|
||||
def test_outcome_service_url(self):
|
||||
expected_outcome_service_url = 'http://{host}{path}'.format(
|
||||
host=self.xmodule.runtime.hostname,
|
||||
path=self.xmodule.runtime.handler_url(self.xmodule, 'grade_handler', thirdparty=True).rstrip('/?')
|
||||
)
|
||||
|
||||
real_outcome_service_url = self.xmodule.get_outcome_service_url()
|
||||
self.assertEqual(real_outcome_service_url, expected_outcome_service_url)
|
||||
|
||||
def test_resource_link_id(self):
|
||||
with patch('xmodule.lti_module.LTIModule.id', new_callable=PropertyMock) as mock_id:
|
||||
mock_id.return_value = self.module_id
|
||||
expected_resource_link_id = unicode(urllib.quote(self.module_id))
|
||||
real_resource_link_id = self.xmodule.get_resource_link_id()
|
||||
self.assertEqual(real_resource_link_id, expected_resource_link_id)
|
||||
|
||||
|
||||
def test_lis_result_sourcedid(self):
|
||||
with patch('xmodule.lti_module.LTIModule.id', new_callable=PropertyMock) as mock_id:
|
||||
mock_id.return_value = self.module_id
|
||||
expected_sourcedId = u':'.join(urllib.quote(i) for i in (self.lti_id, self.module_id, self.user_id))
|
||||
real_lis_result_sourcedid = self.xmodule.get_lis_result_sourcedid()
|
||||
self.assertEqual(real_lis_result_sourcedid, expected_sourcedId)
|
||||
|
||||
|
||||
def test_verify_oauth_body_sign(self):
|
||||
pass
|
||||
|
||||
def test_client_key_secret(self):
|
||||
pass
|
||||
|
||||
@@ -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)
|
||||
|
||||
97
docs/course_authors/source/create_lti.rst
Normal file
97
docs/course_authors/source/create_lti.rst
Normal file
@@ -0,0 +1,97 @@
|
||||
**********************
|
||||
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"
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pylint: disable=C0111
|
||||
|
||||
import os
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from lettuce import world, step
|
||||
from lettuce.django import django_url
|
||||
@@ -86,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',
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
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)
|
||||
|
||||
|
||||
25
lms/djangoapps/courseware/mock_lti_server/server_start.py
Normal file
25
lms/djangoapps/courseware/mock_lti_server/server_start.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
Mock LTI server for manual testing.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from mock_lti_server import MockLTIServer
|
||||
|
||||
server_port = 8034
|
||||
server_host = 'localhost'
|
||||
address = (server_host, server_port)
|
||||
|
||||
server = MockLTIServer(address)
|
||||
server.oauth_settings = {
|
||||
'client_key': 'test_client_key',
|
||||
'client_secret': 'test_client_secret',
|
||||
'lti_base': 'http://{}:{}/'.format(server_host, server_port),
|
||||
'lti_endpoint': 'correct_lti_endpoint'
|
||||
}
|
||||
server.server_host = server_host
|
||||
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print('^C received, shutting down server')
|
||||
server.socket.close()
|
||||
@@ -1,12 +1,15 @@
|
||||
"""
|
||||
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,12 @@ 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': '',
|
||||
@@ -64,12 +62,46 @@ 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':'',
|
||||
}
|
||||
|
||||
response_handle = urllib.urlopen(
|
||||
self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint'],
|
||||
urllib.urlencode(request)
|
||||
)
|
||||
response = response_handle.read()
|
||||
self.assertTrue('Wrong LTI signature' in response)
|
||||
uri = self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint']
|
||||
headers = {'referer': 'http://localhost:8000/'}
|
||||
response = requests.post(uri, data=payload, headers=headers)
|
||||
|
||||
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': '',
|
||||
'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':'',
|
||||
"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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -496,6 +522,17 @@ def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None):
|
||||
the location and course_id do not identify a valid module, the module is
|
||||
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)
|
||||
|
||||
@@ -503,9 +540,6 @@ def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None):
|
||||
if not Location.is_valid(location):
|
||||
raise Http404("Invalid location")
|
||||
|
||||
if not request.user.is_authenticated():
|
||||
raise PermissionDenied
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Test for lms courseware app, module render unit
|
||||
"""
|
||||
from ddt import ddt, data
|
||||
from mock import MagicMock, patch, Mock
|
||||
import json
|
||||
|
||||
@@ -11,10 +12,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
|
||||
|
||||
Reference in New Issue
Block a user