diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index e0b6b4d76a75..ab708989e782 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -5,6 +5,10 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected.
+Blades: Added grading support for LTI module. LTI providers can now grade
+student's work and send edX scores. OAuth1 based authentication
+implemented. BLD-384.
+
Blades: Put 2nd "Hide output" button at top of test box & increase text size for
code response questions. BLD-126.
diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py
index 3b2ec85326c8..123d7fbadbdd 100644
--- a/cms/djangoapps/contentstore/views/preview.py
+++ b/cms/djangoapps/contentstore/views/preview.py
@@ -88,7 +88,7 @@ class PreviewModuleSystem(ModuleSystem): # pylint: disable=abstract-method
"""
An XModule ModuleSystem for use in Studio previews
"""
- def handler_url(self, block, handler_name, suffix='', query=''):
+ def handler_url(self, block, handler_name, suffix='', query='', thirdparty=False):
return handler_prefix(block, handler_name, suffix) + '?' + query
diff --git a/common/djangoapps/student/management/commands/anonymized_id_mapping.py b/common/djangoapps/student/management/commands/anonymized_id_mapping.py
index f1ed5bdef901..3c066121c59f 100644
--- a/common/djangoapps/student/management/commands/anonymized_id_mapping.py
+++ b/common/djangoapps/student/management/commands/anonymized_id_mapping.py
@@ -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 @@ def handle(self, *args, **options):
try:
with open(output_filename, 'wb') as output_file:
csv_writer = csv.writer(output_file)
- csv_writer.writerow(("User ID", "Anonymized user ID"))
+ csv_writer.writerow((
+ "User ID",
+ "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)
diff --git a/common/djangoapps/student/migrations/0029_add_lookup_table_between_user_and_anonymous_student_id.py b/common/djangoapps/student/migrations/0029_add_lookup_table_between_user_and_anonymous_student_id.py
new file mode 100644
index 000000000000..e086496bfcc3
--- /dev/null
+++ b/common/djangoapps/student/migrations/0029_add_lookup_table_between_user_and_anonymous_student_id.py
@@ -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']
\ No newline at end of file
diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py
index d7a918b5690e..0b62eee0b587 100644
--- a/common/djangoapps/student/models.py
+++ b/common/djangoapps/student/models.py
@@ -25,6 +25,7 @@
from django.dispatch import receiver
import django.dispatch
from django.forms import ModelForm, forms
+from django.core.exceptions import ObjectDoesNotExist
from course_modes.models import CourseMode
import lms.lib.comment_client as cc
@@ -42,6 +43,63 @@
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.
@@ -619,19 +677,6 @@ def get_testcenter_registration(user, course_id, exam_series_code):
get_testcenter_registration.__test__ = False
-def unique_id_for_user(user):
- """
- Return a unique id for a user, suitable for inserting into
- e.g. personalized survey links.
- """
- # include the secret key as a salt, and to make the ids unique across
- # different LMS installs.
- h = hashlib.md5()
- h.update(settings.SECRET_KEY)
- h.update(str(user.id))
- return h.hexdigest()
-
-
# TODO: Should be renamed to generic UserGroup, and possibly
# Given an optional field for type of group
class UserTestGroup(models.Model):
@@ -769,7 +814,7 @@ def update_enrollment(self, mode=None, is_active=None):
activation_changed = True
mode_changed = False
- # if mode is None, the call to update_enrollment didn't specify a new
+ # if mode is None, the call to update_enrollment didn't specify a new
# mode, so leave as-is
if self.mode != mode and mode is not None:
self.mode = mode
diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py
index f788c7fd3d55..399f42dcc654 100644
--- a/common/djangoapps/student/tests/tests.py
+++ b/common/djangoapps/student/tests/tests.py
@@ -15,7 +15,7 @@
from django.test import TestCase
from django.test.utils import override_settings
from django.test.client import RequestFactory
-from django.contrib.auth.models import User
+from django.contrib.auth.models import User, AnonymousUser
from django.contrib.auth.hashers import UNUSABLE_PASSWORD
from django.contrib.auth.tokens import default_token_generator
from django.utils.http import int_to_base36
@@ -28,7 +28,7 @@
from mock import Mock, patch, sentinel
from textwrap import dedent
-from student.models import unique_id_for_user, CourseEnrollment
+from student.models import anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment
from student.views import (process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper,
change_enrollment, complete_course_mode_info)
from student.tests.factories import UserFactory, CourseModeFactory
@@ -138,20 +138,28 @@ class CourseEndingTest(TestCase):
"""Test things related to course endings: certificates, surveys, etc"""
def test_process_survey_link(self):
- username = "fred"
- user = Mock(username=username)
- id = unique_id_for_user(user)
+ course_id = "test_id"
+ user = UserFactory()
+
link1 = "http://www.mysurvey.com"
- self.assertEqual(process_survey_link(link1, user), link1)
+ self.assertEqual(process_survey_link(link1, user, course_id), link1)
link2 = "http://www.mysurvey.com?unique={UNIQUE_ID}"
- link2_expected = "http://www.mysurvey.com?unique={UNIQUE_ID}".format(UNIQUE_ID=id)
- self.assertEqual(process_survey_link(link2, user), link2_expected)
+
+ expected_id = anonymous_id_for_user(user, course_id)
+ link2_expected = "http://www.mysurvey.com?unique={UNIQUE_ID}".format(UNIQUE_ID=expected_id)
+
+ self.assertEqual(process_survey_link(link2, user, course_id), link2_expected)
def test_cert_info(self):
- user = Mock(username="fred")
+ """
+ _cert_info() calls process_survey_link(), process_survey_link() calls anonymous_id_for_user()
+ anonymous_id_for_user() returns 'Anonymous' because user is not authenticated.
+ """
+ user = Mock(username="fred", id="test_user_id")
survey_url = "http://a_survey.com"
- course = Mock(end_of_course_survey_url=survey_url)
+
+ course = Mock(end_of_course_survey_url=survey_url, id="test_id")
self.assertEqual(_cert_info(user, course, None),
{'status': 'processing',
@@ -211,7 +219,7 @@ def test_cert_info(self):
})
# Test a course that doesn't have a survey specified
- course2 = Mock(end_of_course_survey_url=None)
+ course2 = Mock(end_of_course_survey_url=None, id="test_course_id2")
cert_status = {'status': 'notpassing', 'grade': '67',
'download_url': download_url}
self.assertEqual(_cert_info(user, course2, cert_status),
@@ -506,3 +514,37 @@ def test_change_enrollment_add_to_cart(self):
self.assertEqual(response.content, reverse('shoppingcart.views.show_cart'))
self.assertTrue(shoppingcart.models.PaidCourseRegistration.contained_in_order(
shoppingcart.models.Order.get_cart_for_user(self.user), self.course.id))
+
+
+@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
+class AnonymousLookupTable(TestCase):
+ """
+ Tests for anonymous_id_functions
+ """
+ # arbitrary constant
+ COURSE_SLUG = "100"
+ COURSE_NAME = "test_course"
+ COURSE_ORG = "EDX"
+
+ def setUp(self):
+ self.course = CourseFactory.create(org=self.COURSE_ORG, display_name=self.COURSE_NAME, number=self.COURSE_SLUG)
+ self.assertIsNotNone(self.course)
+ self.user = UserFactory()
+ 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)
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index 1702d7145e83..43f77c29c300 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -41,7 +41,7 @@
from student.models import (
Registration, UserProfile, TestCenterUser, TestCenterUserForm,
TestCenterRegistration, TestCenterRegistrationForm, PendingNameChange,
- PendingEmailChange, CourseEnrollment, unique_id_for_user,
+ PendingEmailChange, CourseEnrollment, anonymous_id_for_user,
get_testcenter_registration, CourseEnrollmentAllowed, UserStanding,
)
from student.forms import PasswordResetFormNoActive
@@ -149,12 +149,12 @@ def press(request):
return render_to_response('static_templates/press.html', {'articles': articles})
-def process_survey_link(survey_link, user):
+def process_survey_link(survey_link, user, course_id):
"""
If {UNIQUE_ID} appears in the link, replace it with a unique id for the user.
- Currently, this is sha1(user.username). Otherwise, return survey_link.
+ Currently, this is sha1() updated with secret_key, user.id and course.id. Otherwise, return survey_link.
"""
- return survey_link.format(UNIQUE_ID=unique_id_for_user(user))
+ return survey_link.format(UNIQUE_ID=anonymous_id_for_user(user, course_id))
def cert_info(user, course):
@@ -209,7 +209,7 @@ def _cert_info(user, course, cert_status):
course.end_of_course_survey_url is not None):
d.update({
'show_survey_button': True,
- 'survey_url': process_survey_link(course.end_of_course_survey_url, user)})
+ 'survey_url': process_survey_link(course.end_of_course_survey_url, user, course.id)})
else:
d['show_survey_button'] = False
@@ -296,7 +296,7 @@ def complete_course_mode_info(course_id, enrollment):
def dashboard(request):
user = request.user
- # Build our (course, enorllment) list for the user, but ignore any courses that no
+ # Build our (course, enorllment) list for the user, but ignore any courses that no
# longer exist (because the course IDs have changed). Still, we don't delete those
# enrollments, because it could have been a data push snafu.
course_enrollment_pairs = []
diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py
index c55ff4a34811..acb58aa39c55 100644
--- a/common/lib/xmodule/setup.py
+++ b/common/lib/xmodule/setup.py
@@ -39,7 +39,7 @@
"hidden = xmodule.hidden_module:HiddenDescriptor",
"raw = xmodule.raw_module:RawDescriptor",
"crowdsource_hinter = xmodule.crowdsource_hinter:CrowdsourceHinterDescriptor",
- "lti = xmodule.lti_module:LTIModuleDescriptor",
+ "lti = xmodule.lti_module:LTIDescriptor",
]
setup(
diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py
index 7fa30b48857d..12bca979dbac 100644
--- a/common/lib/xmodule/xmodule/lti_module.py
+++ b/common/lib/xmodule/xmodule/lti_module.py
@@ -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::
-
- 5. LTI provider has same secret key and it signs data string via *oauth1* and compares signatures.
+ action="${launch_url}"
+ name="ltiLaunchForm-${element_id}"
+ class="ltiLaunchForm"
+ method="post"
+ target="ltiLaunchFrame-${element_id}"
+ encType="application/x-www-form-urlencoded"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 5. LTI provider has same secret key and it signs data string via *OAuth1* and compares signatures.
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 @@ def get_html(self):
"tool_consumer_instance_contact_email",
]
- # Obtains client_key and client_secret credentials from current course:
- course_id = self.course_id
- course_location = CourseDescriptor.id_to_location(course_id)
- course = self.descriptor.runtime.modulestore.get_item(course_location)
- client_key = client_secret = ''
-
- for lti_passport in course.lti_passports:
- try:
- lti_id, key, secret = [i.strip() for i in lti_passport.split(':')]
- except ValueError:
- raise LTIError('Could not parse LTI passport: {0!r}. \
- Should be "id:key:secret" string.'.format(lti_passport))
- if lti_id == self.lti_id.strip():
- client_key, client_secret = key, secret
- break
+ client_key, client_secret = self.get_client_key_secret()
# parsing custom parameters to dict
custom_parameters = {}
@@ -214,12 +241,12 @@ def get_html(self):
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 @@ def get_html(self):
return self.system.render_template('lti.html', context)
+ def get_user_id(self):
+ user_id = self.runtime.anonymous_student_id
+ assert user_id is not None
+ return 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 @@ def oauth_params(self, custom_parameters, client_key, client_secret):
client_secret=unicode(client_secret)
)
- user_id = self.runtime.anonymous_student_id
- assert user_id is not None
-
- # must have parameters for correct signing from LTI:
+ # 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 @@ def oauth_params(self, custom_parameters, client_key, client_secret):
http_method=u'POST',
body=body,
headers=headers)
- except ValueError: # scheme not in url
- #https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136
- #Stubbing headers for now:
+ except ValueError: # Scheme not in url.
+ # https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136
+ # Stubbing headers for now:
headers = {
u'Content-Type': u'application/x-www-form-urlencoded',
u'Authorization': u'OAuth oauth_nonce="80966668944732164491378916897", \
@@ -284,7 +357,7 @@ def oauth_params(self, custom_parameters, client_key, client_secret):
oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
params = headers['Authorization']
- # parse headers to pass to template as part of context:
+ # Parse headers to pass to template as part of context:
params = dict([param.strip().replace('"', '').split('=') for param in params.split(',')])
params[u'oauth_nonce'] = params[u'OAuth oauth_nonce']
@@ -297,13 +370,217 @@ def oauth_params(self, custom_parameters, client_key, client_secret):
# So we need to decode signature back:
params[u'oauth_signature'] = urllib.unquote(params[u'oauth_signature']).decode('utf8')
- # add lti parameters to oauth parameters for sending in form
+ # Add LTI parameters to OAuth parameters for sending in form.
params.update(body)
return params
+ def max_score(self):
+ return self.weight
+
+
+ @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::
+
+
+
+
+
+ V1.0
+ 528243ba5241b
+
+
+
+
+
+
+ feb-123-456-2929::28883
+
+
+
+ en-us
+ 0.4
+
+
+
+
+
+
+
+ Example of correct/incorrect answer XML body:: see response_xml_template.
+ """
+ response_xml_template = textwrap.dedent("""
+
+
+
+
+ V1.0
+ {imsx_messageIdentifier}
+
+ {imsx_codeMajor}
+ status
+ {imsx_description}
+
+
+
+
+
+ {response}
+
+ """)
+ # Returns when `action` is unsupported.
+ # Supported actions:
+ # - replaceResultRequest.
+ unsupported_values = {
+ 'imsx_codeMajor': 'unsupported',
+ 'imsx_description': 'Target does not support the requested operation.',
+ 'imsx_messageIdentifier': 'unknown',
+ 'response': ''
+ }
+ # Returns if:
+ # - 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': ''
+ }
+ 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 LTIModuleDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRawDescriptor):
+class LTIDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRawDescriptor):
"""
Descriptor for LTI Xmodule.
"""
+ has_score = True
module_class = LTIModule
+ grade_handler = module_attr('grade_handler')
diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py
index 90c7f4500985..bfb5bd7f93e4 100644
--- a/common/lib/xmodule/xmodule/tests/__init__.py
+++ b/common/lib/xmodule/xmodule/tests/__init__.py
@@ -42,7 +42,7 @@ class TestModuleSystem(ModuleSystem): # pylint: disable=abstract-method
"""
ModuleSystem for testing
"""
- def handler_url(self, block, handler, suffix='', query=''):
+ def handler_url(self, block, handler, suffix='', query='', thirdparty=False):
return str(block.scope_ids.usage_id) + '/' + handler + '/' + suffix + '?' + query
diff --git a/common/lib/xmodule/xmodule/tests/test_lti_unit.py b/common/lib/xmodule/xmodule/tests/test_lti_unit.py
new file mode 100644
index 000000000000..b79bec16ccf1
--- /dev/null
+++ b/common/lib/xmodule/xmodule/tests/test_lti_unit.py
@@ -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("""
+
+
+
+
+ V1.0
+ {messageIdentifier}
+
+
+
+ <{action}>
+
+
+ {sourcedId}
+
+
+
+ en-us
+ {grade}
+
+
+
+ {action}>
+
+
+ """)
+ 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
+
diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py
index 8618aef9ae54..787af4a6cb06 100644
--- a/common/lib/xmodule/xmodule/x_module.py
+++ b/common/lib/xmodule/xmodule/x_module.py
@@ -371,7 +371,6 @@ class XModule(XModuleMixin, HTMLSnippet, XBlock): # pylint: disable=abstract-me
See the HTML module for a simple example.
"""
-
has_score = descriptor_attr('has_score')
_field_data_cache = descriptor_attr('_field_data_cache')
_field_data = descriptor_attr('_field_data')
@@ -402,6 +401,7 @@ def handle_ajax(self, _dispatch, _data):
data is a dictionary-like object with the content of the request"""
return u""
+ @XBlock.handler
def xmodule_handler(self, request, suffix=None):
"""
XBlock handler that wraps `handle_ajax`
@@ -962,7 +962,7 @@ def __init__(
anonymous_student_id='', course_id=None,
open_ended_grading_interface=None, s3_interface=None,
cache=None, can_execute_unsafe_code=None, replace_course_urls=None,
- replace_jump_to_id_urls=None, error_descriptor_class=None, **kwargs):
+ replace_jump_to_id_urls=None, error_descriptor_class=None, get_real_user=None, **kwargs):
"""
Create a closure around the system environment.
@@ -1047,6 +1047,8 @@ def __init__(
self.error_descriptor_class = error_descriptor_class
self.xmodule_instance = None
+ self.get_real_user = get_real_user
+
def get(self, attr):
""" provide uniform access to attributes (like etree)."""
return self.__dict__.get(attr)
diff --git a/docs/course_authors/source/create_lti.rst b/docs/course_authors/source/create_lti.rst
new file mode 100644
index 000000000000..00ccb71c6778
--- /dev/null
+++ b/docs/course_authors/source/create_lti.rst
@@ -0,0 +1,97 @@
+**********************
+Create a LTI Component
+**********************
+
+Description
+===========
+
+The LTI XModule is based on the `IMS Global Learning Tools Interoperability `_ Version 1.1.1 specifications.
+
+Enabling LTI
+============
+
+It is not available from the list of general components. To turn it on, add
+"lti" to the "advanced_modules" key on the Advanced Settings page.
+
+The module supports 2 modes of operation.
+
+1. Simple display of external LTI content
+2. Display of LTI content that will be graded by external provider
+
+In both cases, before an LTI component from an external provider can be
+included in a unit, the following pieces of information must be known/decided
+upon:
+
+**LTI id** [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
diff --git a/docs/course_authors/source/index.rst b/docs/course_authors/source/index.rst
index 5e17e39106be..1708770eece0 100755
--- a/docs/course_authors/source/index.rst
+++ b/docs/course_authors/source/index.rst
@@ -20,6 +20,7 @@ Contents
create_video
create_discussion
create_html_component
+ create_lti
create_problem
set_content_releasedates
establish_course_settings
@@ -34,13 +35,13 @@ Contents
checking_student_progress
change_log
-
-
-Appendices
+
+
+Appendices
==========
.. toctree::
diff --git a/docs/data/source/index.rst b/docs/data/source/index.rst
index d1efb4690257..7c1a1512f3ae 100644
--- a/docs/data/source/index.rst
+++ b/docs/data/source/index.rst
@@ -25,6 +25,7 @@ Specific Problem Types
course_data_formats/drag_and_drop/drag_and_drop_input.rst
course_data_formats/graphical_slider_tool/graphical_slider_tool.rst
course_data_formats/poll_module/poll_module.rst
+ course_data_formats/lti_module/lti.rst
course_data_formats/conditional_module/conditional_module.rst
course_data_formats/word_cloud/word_cloud.rst
course_data_formats/custom_response.rst
diff --git a/lms/djangoapps/courseware/features/lti.feature b/lms/djangoapps/courseware/features/lti.feature
index 8255e515b077..7f27a65b5a1b 100644
--- a/lms/djangoapps/courseware/features/lti.feature
+++ b/lms/djangoapps/courseware/features/lti.feature
@@ -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"
+
+
diff --git a/lms/djangoapps/courseware/features/lti.py b/lms/djangoapps/courseware/features/lti.py
index 58de82ded630..772877fa2a4b 100644
--- a/lms/djangoapps/courseware/features/lti.py
+++ b/lms/djangoapps/courseware/features/lti.py
@@ -1,6 +1,7 @@
#pylint: disable=C0111
import os
+
from django.contrib.auth.models import User
from lettuce import world, step
from lettuce.django import django_url
@@ -86,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')
+
diff --git a/lms/djangoapps/courseware/features/lti_setup.py b/lms/djangoapps/courseware/features/lti_setup.py
index 0a6c4590dd9e..e86aa78ed2e1 100644
--- a/lms/djangoapps/courseware/features/lti_setup.py
+++ b/lms/djangoapps/courseware/features/lti_setup.py
@@ -30,6 +30,7 @@ def setup_mock_lti_server():
server_thread.daemon = True
server_thread.start()
+ server.server_host = server_host
server.oauth_settings = {
'client_key': 'test_client_key',
'client_secret': 'test_client_secret',
diff --git a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py
index 83208d0102b9..31637892b3b3 100644
--- a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py
+++ b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py
@@ -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 @@ def log_message(self, format, *args):
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 = """TEST TITLE
+ I have stored grades."""
+
+ 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:
' + 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 @@ def do_POST(self):
'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 @@ def _post_dict(self):
# the correct fields, it won't find them,
# and will therefore send an error response
return {}
+ try:
+ cookie = self.headers.getheader('cookie')
+ self.server.cookie = {k.strip(): v[0] for k, v in urlparse.parse_qs(cookie).items()}
+ except:
+ self.server.cookie = {}
+ 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("""
+
+
+
+
+ V1.0
+ {imsx_messageIdentifier} /
+
+
+
+
+
+
+ {sourcedId}
+
+
+
+ en-us
+ {textString}
+
+
+
+
+
+
+ """)
+ 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 = """TEST TITLE
-
- IFrame loaded
\
- Server response is:
\
- {}
- """.format(message)
+
+ if self.server.grade_data['callback_url']:
+ response_str = """TEST TITLE
+
+ Graded IFrame loaded
\
+ Server response is:
\
+ {}
+
+
+ """.format(message, url="http://%s:%s" % self.server.server_address)
+ else:
+ response_str = """TEST TITLE
+
+ IFrame loaded
\
+ Server response is:
\
+ {}
+ """.format(message)
# Log the response
logger.debug("LTI: sent response {}".format(response_str))
@@ -122,6 +232,34 @@ def _is_correct_lti_request(self):
'''If url to LTI tool is correct.'''
return self.server.oauth_settings['lti_endpoint'] in self.path
+ def oauth_sign(self, url, body):
+ """
+ Signs request and returns signed body and headers.
+
+ """
+
+ client = oauthlib.oauth1.Client(
+ client_key=unicode(self.server.oauth_settings['client_key']),
+ client_secret=unicode(self.server.oauth_settings['client_secret'])
+ )
+ headers = {
+ # This is needed for body encoding:
+ 'Content-Type': 'application/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 @@ def check_oauth_signature(self, params, client_signature):
request.uri = unicode(url)
request.http_method = u'POST'
request.signature = unicode(client_signature)
-
return signature.verify_hmac_sha1(request, client_secret)
diff --git a/lms/djangoapps/courseware/mock_lti_server/server_start.py b/lms/djangoapps/courseware/mock_lti_server/server_start.py
new file mode 100644
index 000000000000..9f13e7982dbe
--- /dev/null
+++ b/lms/djangoapps/courseware/mock_lti_server/server_start.py
@@ -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()
diff --git a/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py b/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py
index 99650d5faae3..80dd3cc42f76 100644
--- a/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py
+++ b/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py
@@ -1,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,42 @@ def tearDown(self):
# Stop the server, freeing up the port
self.server.shutdown()
- def test_request(self):
+ def test_wrong_signature(self):
"""
Tests that LTI server processes request with right program
- path, and responses with incorrect signature.
+ path and responses with incorrect signature.
"""
- request = {
+ payload = {
+ 'user_id': 'default_user_id',
+ 'role': 'student',
+ 'oauth_nonce': '',
+ 'oauth_timestamp': '',
+ 'oauth_consumer_key': 'client_key',
+ 'lti_version': 'LTI-1p0',
+ 'oauth_signature_method': 'HMAC-SHA1',
+ 'oauth_version': '1.0',
+ 'oauth_signature': '',
+ 'lti_message_type': 'basic-lti-launch-request',
+ 'oauth_callback': 'about:blank',
+ 'launch_presentation_return_url': '',
+ 'lis_outcome_service_url': '',
+ 'lis_result_sourcedid': '',
+ 'resource_link_id':'',
+ }
+
+ uri = self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint']
+ headers = {'referer': 'http://localhost:8000/'}
+ response = requests.post(uri, data=payload, headers=headers)
+
+ self.assertTrue('Wrong LTI signature' in response.content)
+
+
+ def test_success_response_launch_lti(self):
+ """
+ Success lti launch.
+ """
+
+ payload = {
'user_id': 'default_user_id',
'role': 'student',
'oauth_nonce': '',
@@ -64,12 +92,16 @@ def test_request(self):
'oauth_callback': 'about:blank',
'launch_presentation_return_url': '',
'lis_outcome_service_url': '',
- 'lis_result_sourcedid': ''
+ 'lis_result_sourcedid': '',
+ 'resource_link_id':'',
+ "lis_outcome_service_url": '',
}
+ self.server.check_oauth_signature = Mock(return_value=True)
+
+ uri = self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint']
+ headers = {'referer': 'http://localhost:8000/'}
+
+ response = requests.post(uri, data=payload, headers=headers)
+
+ self.assertTrue('This is LTI tool. Success.' in response.content)
- response_handle = urllib.urlopen(
- self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint'],
- urllib.urlencode(request)
- )
- response = response_handle.read()
- self.assertTrue('Wrong LTI signature' in response)
diff --git a/lms/djangoapps/courseware/model_data.py b/lms/djangoapps/courseware/model_data.py
index da5d639bf035..fd7705affc64 100644
--- a/lms/djangoapps/courseware/model_data.py
+++ b/lms/djangoapps/courseware/model_data.py
@@ -14,10 +14,11 @@
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 @@ def find_or_create(self, key):
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 @@ def find_or_create(self, key):
field_object, _ = XModuleStudentPrefsField.objects.get_or_create(
field_name=key.field_name,
module_type=key.block_scope_id,
- student=self.user,
+ student=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 @@ def set_many(self, kv_dict):
# the list of successful saves
saved_fields.extend([field.field_name for field in field_objects[field_object]])
except DatabaseError:
- log.error('Error saving fields %r', field_objects[field_object])
+ log.exception('Error saving fields %r', field_objects[field_object])
raise KeyValueMultiSaveError(saved_fields)
def delete(self, key):
diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py
index 1539b883d34f..3b804c4ba96f 100644
--- a/lms/djangoapps/courseware/module_render.py
+++ b/lms/djangoapps/courseware/module_render.py
@@ -14,7 +14,7 @@
from django.core.urlresolvers import reverse
from django.http import Http404
from django.http import HttpResponse
-from django.views.decorators.csrf import csrf_exempt
+from django.views.decorators.csrf import csrf_exempt, csrf_protect
from capa.xqueue_interface import XQueueInterface
from courseware.access import has_access
@@ -24,7 +24,7 @@
from lms.lib.xblock.runtime import LmsModuleSystem, handler_prefix, unquote_slashes
from mitxmako.shortcuts import render_to_string
from psychometrics.psychoanalyze import make_psychometrics_data_update_handler
-from student.models import unique_id_for_user
+from student.models import 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.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 inner_get_module(descriptor):
position, wrap_xmodule_display, grade_bucket_type,
static_asset_path)
- def publish(event):
+ def publish(event, custom_user=None):
"""A function that allows XModules to publish events. This only supports grade changes right now."""
if event.get('event_name') != 'grade':
return
+ if custom_user:
+ user_id = custom_user.id
+ else:
+ user_id = user.id
+
# Construct the key for the module
key = KeyValueStore.Key(
scope=Scope.user_state,
- user_id=user.id,
+ user_id=user_id,
block_scope_id=descriptor.location,
field_name='grade'
)
@@ -361,6 +367,17 @@ def publish(event):
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 publish(event):
),
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 publish(event):
# 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)
diff --git a/lms/djangoapps/courseware/tests/test_lti.py b/lms/djangoapps/courseware/tests/test_lti_integration.py
similarity index 77%
rename from lms/djangoapps/courseware/tests/test_lti.py
rename to lms/djangoapps/courseware/tests/test_lti_integration.py
index a176384fd12a..9a70d217cce9 100644
--- a/lms/djangoapps/courseware/tests/test_lti.py
+++ b/lms/djangoapps/courseware/tests/test_lti_integration.py
@@ -4,6 +4,9 @@
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 @@ def setUp(self):
mocked_signature_after_sign = u'my_signature%3D'
mocked_decoded_signature = u'my_signature='
+ lti_id = self.item_module.lti_id
+ module_id = unicode(urllib.quote(self.item_module.id))
+ user_id = unicode(self.item_descriptor.xmodule_runtime.anonymous_student_id)
+
+ sourcedId = u':'.join(urllib.quote(i) for i in (lti_id, module_id, user_id))
+
+ 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 @@ def test_lti_constructor(self):
Makes sure that all parameters extracted.
"""
generated_context = self.item_module.render('student_view').content
+
expected_context = {
- 'input_fields': self.correct_headers,
'display_name': self.item_module.display_name,
- 'element_class': self.item_module.location.category,
+ 'input_fields': self.correct_headers,
+ 'element_class': self.item_module.category,
'element_id': self.item_module.location.html_id(),
'launch_url': 'http://www.example.com', # default value
'open_in_a_new_page': True,
}
+
self.assertEqual(
generated_context,
self.runtime.render_template('lti.html', expected_context),
diff --git a/lms/djangoapps/courseware/tests/test_model_data.py b/lms/djangoapps/courseware/tests/test_model_data.py
index bde57c15534c..2286506465be 100644
--- a/lms/djangoapps/courseware/tests/test_model_data.py
+++ b/lms/djangoapps/courseware/tests/test_model_data.py
@@ -40,11 +40,14 @@ def mock_descriptor(fields=[]):
location = partial(Location, 'i4x', 'edX', 'test_course', 'problem')
course_id = 'edX/test_course/test'
+# The user ids here are 1 because we make a student in the setUp functions, and
+# they get an id of 1. There's an assertion in setUp to ensure that assumption
+# is still true.
user_state_summary_key = partial(DjangoKeyValueStore.Key, Scope.user_state_summary, None, location('def_id'))
settings_key = partial(DjangoKeyValueStore.Key, Scope.settings, None, location('def_id'))
-user_state_key = partial(DjangoKeyValueStore.Key, Scope.user_state, 'user', location('def_id'))
-prefs_key = partial(DjangoKeyValueStore.Key, Scope.preferences, 'user', 'MockProblemModule')
-user_info_key = partial(DjangoKeyValueStore.Key, Scope.user_info, 'user', None)
+user_state_key = partial(DjangoKeyValueStore.Key, Scope.user_state, 1, location('def_id'))
+prefs_key = partial(DjangoKeyValueStore.Key, Scope.preferences, 1, 'MockProblemModule')
+user_info_key = partial(DjangoKeyValueStore.Key, Scope.user_info, 1, None)
class StudentModuleFactory(cmfStudentModuleFactory):
@@ -76,6 +79,7 @@ class TestStudentModuleStorage(TestCase):
def setUp(self):
student_module = StudentModuleFactory(state=json.dumps({'a_field': 'a_value', 'b_field': 'b_value'}))
self.user = student_module.student
+ self.assertEqual(self.user.id, 1) # check our assumption hard-coded in the key functions above.
self.field_data_cache = FieldDataCache([mock_descriptor([mock_field(Scope.user_state, 'a_field')])], course_id, self.user)
self.kvs = DjangoKeyValueStore(self.field_data_cache)
@@ -152,6 +156,7 @@ def test_set_many_failure(self):
class TestMissingStudentModule(TestCase):
def setUp(self):
self.user = UserFactory.create(username='user')
+ self.assertEqual(self.user.id, 1) # check our assumption hard-coded in the key functions above.
self.field_data_cache = FieldDataCache([mock_descriptor()], course_id, self.user)
self.kvs = DjangoKeyValueStore(self.field_data_cache)
diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py
index 3f4f3b673b2e..649ad3c8ad8e 100644
--- a/lms/djangoapps/courseware/tests/test_module_render.py
+++ b/lms/djangoapps/courseware/tests/test_module_render.py
@@ -1,6 +1,7 @@
"""
Test for lms courseware app, module render unit
"""
+from ddt import ddt, data
from mock import MagicMock, patch, Mock
import json
@@ -11,10 +12,15 @@
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 @@ def _mock_file(self, name='file', size=10):
return mock_file
def test_invalid_location(self):
+ request = self.request_factory.post('dummy_url', data={'position': 1})
+ request.user = self.mock_user
with self.assertRaises(Http404):
render.handle_xblock_callback(
- None,
+ request,
'dummy/course/id',
'invalid Location',
'dummy_handler'
@@ -513,3 +521,74 @@ def test_histogram(self):
'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)
+ )
diff --git a/lms/djangoapps/foldit/tests.py b/lms/djangoapps/foldit/tests.py
index c97cd2b59d36..5f2749c67509 100644
--- a/lms/djangoapps/foldit/tests.py
+++ b/lms/djangoapps/foldit/tests.py
@@ -8,7 +8,7 @@
from foldit.views import foldit_ops, verify_code
from foldit.models import PuzzleComplete, Score
-from student.models import unique_id_for_user
+from student.models import anonymous_id_for_user
from student.tests.factories import CourseEnrollmentFactory, UserFactory, UserProfileFactory
from datetime import datetime, timedelta
@@ -346,7 +346,7 @@ def test_SetPlayerPuzzlesComplete_level_complete(self):
self.set_puzzle_complete_response([13, 14, 15, 53524]))
is_complete = partial(
- PuzzleComplete.is_level_complete, unique_id_for_user(self.user))
+ PuzzleComplete.is_level_complete, anonymous_id_for_user(self.user, ''))
self.assertTrue(is_complete(1, 1))
self.assertTrue(is_complete(1, 3))
diff --git a/lms/djangoapps/foldit/views.py b/lms/djangoapps/foldit/views.py
index 91423bbf8876..281f154c15c5 100644
--- a/lms/djangoapps/foldit/views.py
+++ b/lms/djangoapps/foldit/views.py
@@ -8,7 +8,7 @@
from django.views.decorators.csrf import csrf_exempt
from foldit.models import Score, PuzzleComplete
-from student.models import unique_id_for_user
+from student.models import anonymous_id_for_user
import re
@@ -124,7 +124,9 @@ def save_scores(user, puzzle_scores):
try:
obj = Score.objects.get(
user=user,
- unique_user_id=unique_id_for_user(user),
+ # For historical reasons, the foldit module uses a per-student
+ # anonymized user id for storage
+ unique_user_id=anonymous_id_for_user(user, ''),
puzzle_id=puzzle_id,
score_version=score_version)
obj.current_score = current_score
@@ -133,7 +135,9 @@ def save_scores(user, puzzle_scores):
except Score.DoesNotExist:
obj = Score(
user=user,
- unique_user_id=unique_id_for_user(user),
+ # For historical reasons, the foldit module uses a per-student
+ # anonymized user id for storage
+ unique_user_id=anonymous_id_for_user(user, ''),
puzzle_id=puzzle_id,
current_score=current_score,
best_score=best_score,
@@ -160,7 +164,9 @@ def save_complete(user, puzzles_complete):
# create if not there
PuzzleComplete.objects.get_or_create(
user=user,
- unique_user_id=unique_id_for_user(user),
+ # For historical reasons, the foldit module uses a per-student
+ # anonymized user id for storage
+ unique_user_id=anonymous_id_for_user(user, ''),
puzzle_id=puzzle_id,
puzzle_set=puzzle_set,
puzzle_subset=puzzle_subset)
diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py
index 6744031c6f96..4547718af8c5 100644
--- a/lms/djangoapps/instructor/tests/test_api.py
+++ b/lms/djangoapps/instructor/tests/test_api.py
@@ -782,7 +782,7 @@ def test_get_anon_ids(self):
Test the CSV output for the anonymized user ids.
"""
url = reverse('get_anon_ids', kwargs={'course_id': self.course.id})
- with patch('instructor.views.api.unique_id_for_user') as mock_unique:
+ with patch('instructor.views.api.anonymous_id_for_user') as mock_unique:
mock_unique.return_value = '42'
response = self.client.get(url, {})
self.assertEqual(response['Content-Type'], 'text/csv')
diff --git a/lms/djangoapps/instructor/tests/test_legacy_anon_csv.py b/lms/djangoapps/instructor/tests/test_legacy_anon_csv.py
index b6312376ed2f..84d05d7f8dd9 100644
--- a/lms/djangoapps/instructor/tests/test_legacy_anon_csv.py
+++ b/lms/djangoapps/instructor/tests/test_legacy_anon_csv.py
@@ -58,7 +58,7 @@ def test_download_anon_csv(self):
course = self.toy
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
- with patch('instructor.views.legacy.unique_id_for_user') as mock_unique:
+ with patch('instructor.views.legacy.anonymous_id_for_user') as mock_unique:
mock_unique.return_value = 42
response = self.client.post(url, {'action': 'Download CSV of all student anonymized IDs'})
diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py
index 5596ba9c4140..47e45f28ac1b 100644
--- a/lms/djangoapps/instructor/views/api.py
+++ b/lms/djangoapps/instructor/views/api.py
@@ -28,7 +28,7 @@
FORUM_ROLE_COMMUNITY_TA)
from courseware.models import StudentModule
-from student.models import unique_id_for_user
+from student.models import anonymous_id_for_user
import instructor_task.api
from instructor_task.api_helper import AlreadyRunningError
from instructor_task.views import get_task_completion_info
@@ -448,7 +448,7 @@ def csv_response(filename, header, rows):
courseenrollment__course_id=course_id,
).order_by('id')
header = ['User ID', 'Anonymized user ID']
- rows = [[s.id, unique_id_for_user(s)] for s in students]
+ rows = [[s.id, anonymous_id_for_user(s, course_id)] for s in students]
return csv_response(course_id.replace('/', '-') + '-anon-ids.csv', header, rows)
diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py
index 98c18dc68bea..b42d6fe8b24f 100644
--- a/lms/djangoapps/instructor/views/legacy.py
+++ b/lms/djangoapps/instructor/views/legacy.py
@@ -52,7 +52,7 @@
from instructor_task.views import get_task_completion_info
from mitxmako.shortcuts import render_to_response, render_to_string
from psychometrics import psychoanalyze
-from student.models import CourseEnrollment, CourseEnrollmentAllowed, unique_id_for_user
+from student.models import CourseEnrollment, CourseEnrollmentAllowed, anonymous_id_for_user
from student.views import course_from_id
import track.views
from xblock.field_data import DictFieldData
@@ -547,7 +547,7 @@ def getdat(u):
).order_by('id')
datatable = {'header': ['User ID', 'Anonymized user ID']}
- datatable['data'] = [[s.id, unique_id_for_user(s)] for s in students]
+ datatable['data'] = [[s.id, anonymous_id_for_user(s, course_id)] for s in students]
return return_csv(course_id.replace('/', '-') + '-anon-ids.csv', datatable)
#----------------------------------------
diff --git a/lms/djangoapps/open_ended_grading/open_ended_notifications.py b/lms/djangoapps/open_ended_grading/open_ended_notifications.py
index c4580dd30436..f6700152edec 100644
--- a/lms/djangoapps/open_ended_grading/open_ended_notifications.py
+++ b/lms/djangoapps/open_ended_grading/open_ended_notifications.py
@@ -10,7 +10,7 @@
from courseware.access import has_access
from lms.lib.xblock.runtime import LmsModuleSystem
from mitxmako.shortcuts import render_to_string
-from student.models import unique_id_for_user
+from student.models import anonymous_id_for_user
from util.cache import cache
from .staff_grading_service import StaffGradingService
@@ -33,7 +33,7 @@ def staff_grading_notifications(course, user):
pending_grading = False
img_path = ""
course_id = course.id
- student_id = unique_id_for_user(user)
+ student_id = anonymous_id_for_user(user, course_id)
notification_type = "staff"
success, notification_dict = get_value_from_cache(student_id, course_id, notification_type)
@@ -74,7 +74,7 @@ def peer_grading_notifications(course, user):
pending_grading = False
img_path = ""
course_id = course.id
- student_id = unique_id_for_user(user)
+ student_id = anonymous_id_for_user(user, course_id)
notification_type = "peer"
success, notification_dict = get_value_from_cache(student_id, course_id, notification_type)
@@ -132,7 +132,7 @@ def combined_notifications(course, user):
)
#Initialize controller query service using our mock system
controller_qs = ControllerQueryService(settings.OPEN_ENDED_GRADING_INTERFACE, system)
- student_id = unique_id_for_user(user)
+ student_id = anonymous_id_for_user(user, course.id)
user_is_staff = has_access(user, course, 'staff')
course_id = course.id
notification_type = "combined"
diff --git a/lms/djangoapps/open_ended_grading/staff_grading_service.py b/lms/djangoapps/open_ended_grading/staff_grading_service.py
index 1322f3f06933..ef72fc7c3541 100644
--- a/lms/djangoapps/open_ended_grading/staff_grading_service.py
+++ b/lms/djangoapps/open_ended_grading/staff_grading_service.py
@@ -14,7 +14,7 @@
from courseware.access import has_access
from lms.lib.xblock.runtime import LmsModuleSystem
from mitxmako.shortcuts import render_to_string
-from student.models import unique_id_for_user
+from student.models import anonymous_id_for_user
from util.json_request import expect_json
from open_ended_grading.utils import does_location_exist
@@ -235,7 +235,7 @@ def get_next(request, course_id):
if len(missing) > 0:
return _err_response('Missing required keys {0}'.format(
', '.join(missing)))
- grader_id = unique_id_for_user(request.user)
+ grader_id = anonymous_id_for_user(request.user, course_id)
p = request.POST
location = p['location']
@@ -267,7 +267,7 @@ def get_problem_list(request, course_id):
"""
_check_access(request.user, course_id)
try:
- response = staff_grading_service().get_problem_list(course_id, unique_id_for_user(request.user))
+ response = staff_grading_service().get_problem_list(course_id, anonymous_id_for_user(request.user, course_id))
response = json.loads(response)
# If 'problem_list' is in the response, then we got a list of problems from the ORA server.
@@ -352,7 +352,7 @@ def save_grade(request, course_id):
return _err_response('Missing required keys {0}'.format(
', '.join(missing)))
- grader_id = unique_id_for_user(request.user)
+ grader_id = anonymous_id_for_user(request.user, course_id)
location = p['location']
diff --git a/lms/djangoapps/open_ended_grading/tests.py b/lms/djangoapps/open_ended_grading/tests.py
index ad6298d71a9f..eeea30d5ebb1 100644
--- a/lms/djangoapps/open_ended_grading/tests.py
+++ b/lms/djangoapps/open_ended_grading/tests.py
@@ -28,7 +28,7 @@
from lms.lib.xblock.runtime import LmsModuleSystem
from courseware.roles import CourseStaffRole
from mitxmako.shortcuts import render_to_string
-from student.models import unique_id_for_user
+from student.models import anonymous_id_for_user
from open_ended_grading import staff_grading_service, views, utils
@@ -455,7 +455,7 @@ def test_get_problem_list(self):
Mock the get_grading_status_list function using StudentProblemListMockQuery.
"""
# Initialize a StudentProblemList object.
- student_problem_list = utils.StudentProblemList(self.course.id, unique_id_for_user(self.user))
+ student_problem_list = utils.StudentProblemList(self.course.id, anonymous_id_for_user(self.user, self.course.id))
# Get the initial problem list from ORA.
success = student_problem_list.fetch_from_grading_service()
# Should be successful, and we should have three problems. See mock class for details.
diff --git a/lms/djangoapps/open_ended_grading/utils.py b/lms/djangoapps/open_ended_grading/utils.py
index bfde229e9c85..eb0573545284 100644
--- a/lms/djangoapps/open_ended_grading/utils.py
+++ b/lms/djangoapps/open_ended_grading/utils.py
@@ -89,7 +89,7 @@ class StudentProblemList(object):
def __init__(self, course_id, user_id):
"""
@param course_id: The id of a course object. Get using course.id.
- @param user_id: The anonymous id of the user, from the unique_id_for_user function.
+ @param user_id: The anonymous id of the user, from the anonymous_id_for_user function.
"""
self.course_id = course_id
self.user_id = user_id
diff --git a/lms/djangoapps/open_ended_grading/views.py b/lms/djangoapps/open_ended_grading/views.py
index e8002e0883f7..9ee6c55d924a 100644
--- a/lms/djangoapps/open_ended_grading/views.py
+++ b/lms/djangoapps/open_ended_grading/views.py
@@ -5,12 +5,12 @@
from mitxmako.shortcuts import render_to_response
from django.core.urlresolvers import reverse
-from student.models import unique_id_for_user
+from student.models import anonymous_id_for_user
from courseware.courses import get_course_with_access
from xmodule.open_ended_grading_classes.grading_service_module import GradingServiceError
import json
-from student.models import unique_id_for_user
+from student.models import anonymous_id_for_user
import open_ended_notifications
@@ -149,7 +149,7 @@ def student_problem_list(request, course_id):
course = get_course_with_access(request.user, course_id, 'load')
# The anonymous student id is needed for communication with ORA.
- student_id = unique_id_for_user(request.user)
+ student_id = anonymous_id_for_user(request.user, course_id)
base_course_url = reverse('courses')
error_text = ""
@@ -186,7 +186,7 @@ def flagged_problem_list(request, course_id):
Show a student problem list
'''
course = get_course_with_access(request.user, course_id, 'staff')
- student_id = unique_id_for_user(request.user)
+ student_id = anonymous_id_for_user(request.user, course_id)
# call problem list service
success = False
diff --git a/lms/lib/xblock/runtime.py b/lms/lib/xblock/runtime.py
index cecdd4d121da..4fa48be83a83 100644
--- a/lms/lib/xblock/runtime.py
+++ b/lms/lib/xblock/runtime.py
@@ -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
diff --git a/lms/urls.py b/lms/urls.py
index 4aec95d01fc1..6de00a741103 100644
--- a/lms/urls.py
+++ b/lms/urls.py
@@ -178,7 +178,9 @@
url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/xblock/(?P[^/]*)/handler/(?P[^/]*)(?:/(?P.*))?$',
'courseware.module_render.handle_xblock_callback',
name='xblock_handler'),
-
+ url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/xblock/(?P[^/]*)/handler_noauth/(?P[^/]*)(?:/(?P.*))?$',
+ 'courseware.module_render.handle_xblock_callback_noauth',
+ name='xblock_handler_noauth'),
# Software Licenses
diff --git a/rakelib/docs.rake b/rakelib/docs.rake
index 1617efa8afc9..09ecac0efbaa 100644
--- a/rakelib/docs.rake
+++ b/rakelib/docs.rake
@@ -32,7 +32,7 @@ task :showdocs, [:options] do |t, args|
elsif args.options == 'data'
path = "docs/data"
else
- path = "docs"
+ path = "docs/developers"
end
Launchy.open("#{path}/build/html/index.html")
diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt
index 26d12f2af93f..3f00a7c9159d 100644
--- a/requirements/edx/github.txt
+++ b/requirements/edx/github.txt
@@ -15,7 +15,7 @@
-e git+https://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk
# Our libraries:
--e git+https://github.com/edx/XBlock.git@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