diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index e2152d987259..d9720032e79b 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -62,6 +62,7 @@ 'lti', 'concept', 'openassessment', # edx-ora2 + 'image_explorer', ] + OPEN_ENDED_COMPONENT_TYPES + NOTE_COMPONENT_TYPES ADVANCED_COMPONENT_CATEGORY = 'advanced' diff --git a/cms/envs/aws.py b/cms/envs/aws.py index b2de92a738f9..67fc78e64460 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -274,3 +274,6 @@ ##### X-Frame-Options response header settings ##### X_FRAME_OPTIONS = ENV_TOKENS.get('X_FRAME_OPTIONS', X_FRAME_OPTIONS) + +##### ADVANCED_SECURITY_CONFIG ##### +ADVANCED_SECURITY_CONFIG = ENV_TOKENS.get('ADVANCED_SECURITY_CONFIG', {}) diff --git a/cms/envs/common.py b/cms/envs/common.py index 434f534a275f..1d11372c0247 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -96,6 +96,9 @@ # Prevent concurrent logins per user 'PREVENT_CONCURRENT_LOGINS': False, + + # Turn off Advanced Security by default + 'ADVANCED_SECURITY': False, } ENABLE_JASMINE = False @@ -566,6 +569,7 @@ 'openassessment.xblock' ) + for app_name in OPTIONAL_APPS: # First attempt to only find the module rather than actually importing it, # to avoid circular references - only try to import if it can't be found @@ -578,3 +582,7 @@ except ImportError: continue INSTALLED_APPS += (app_name,) + +### ADVANCED_SECURITY_CONFIG +# Empty by default +ADVANCED_SECURITY_CONFIG = {} diff --git a/cms/lib/xblock/mixin.py b/cms/lib/xblock/mixin.py index e4c9610a2b79..f46a95ff07cd 100644 --- a/cms/lib/xblock/mixin.py +++ b/cms/lib/xblock/mixin.py @@ -3,6 +3,7 @@ """ import datetime +import time from xblock.fields import Scope, Field, Integer, XBlockMixin @@ -20,6 +21,15 @@ def to_json(self, value): return list(value.timetuple()) + def enforce_type(self, value): + if isinstance(value, datetime.datetime) or value is None: + return value + + if isinstance(value, tuple, time.struct_time): + return self.from_json(DateTuple) + + raise TypeError("Value should be datetime, a timetuple or None, not {}".format(type(value))) + class CmsBlockMixin(XBlockMixin): """ diff --git a/common/djangoapps/student/migrations/0032_auto__add_loginfailures.py b/common/djangoapps/student/migrations/0032_auto__add_loginfailures.py index c39d2595be3d..70919c419849 100644 --- a/common/djangoapps/student/migrations/0032_auto__add_loginfailures.py +++ b/common/djangoapps/student/migrations/0032_auto__add_loginfailures.py @@ -113,6 +113,8 @@ def backwards(self, orm): }, 'student.userprofile': { 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, + 'city': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'null': 'True', 'blank': 'True'}), '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'}), @@ -144,4 +146,4 @@ def backwards(self, orm): } } - complete_apps = ['student'] \ No newline at end of file + complete_apps = ['student'] diff --git a/common/djangoapps/student/migrations/0033_auto__add_passwordhistory.py b/common/djangoapps/student/migrations/0033_auto__add_passwordhistory.py new file mode 100644 index 000000000000..f46fd07894a5 --- /dev/null +++ b/common/djangoapps/student/migrations/0033_auto__add_passwordhistory.py @@ -0,0 +1,156 @@ +# -*- 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 'PasswordHistory' + db.create_table('student_passwordhistory', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('password', self.gf('django.db.models.fields.CharField')(max_length=128)), + ('time_set', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime.now)), + )) + db.send_create_signal('student', ['PasswordHistory']) + + + def backwards(self, orm): + # Deleting model 'PasswordHistory' + db.delete_table('student_passwordhistory') + + + 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': '32'}), + '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.loginfailures': { + 'Meta': {'object_name': 'LoginFailures'}, + 'failure_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'lockout_until': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.passwordhistory': { + 'Meta': {'object_name': 'PasswordHistory'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'time_set': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + '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.userprofile': { + 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, + 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'city': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'null': 'True', 'blank': '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 c4fc45950daf..dd7d2a2334d7 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -20,14 +20,15 @@ from collections import defaultdict from django.conf import settings +from django.utils import timezone from django.contrib.auth.models import User +from django.contrib.auth.hashers import make_password from django.contrib.auth.signals import user_logged_in, user_logged_out from django.db import models, IntegrityError from django.db.models import Count from django.db.models.signals import post_save from django.dispatch import receiver, Signal import django.dispatch -from django.forms import ModelForm, forms from django.core.exceptions import ObjectDoesNotExist from django.utils.translation import ugettext_noop from django_countries import CountryField @@ -312,6 +313,187 @@ class PendingEmailChange(models.Model): EVENT_NAME_ENROLLMENT_DEACTIVATED = 'edx.course.enrollment.deactivated' +class PasswordHistory(models.Model): + """ + This model will keep track of past passwords that a user has used + as well as providing contraints (e.g. can't reuse passwords) + """ + user = models.ForeignKey(User) + password = models.CharField(max_length=128) + time_set = models.DateTimeField(default=timezone.now) + + def create(self, user): + """ + This will copy over the current password, if any of the configuration has been turned on + """ + + if not (PasswordHistory.is_student_password_reuse_restricted() or + PasswordHistory.is_staff_password_reuse_restricted() or + PasswordHistory.is_password_reset_frequency_restricted() or + PasswordHistory.is_staff_forced_password_reset_enabled() or + PasswordHistory.is_student_forced_password_reset_enabled()): + + return + + self.user = user + self.password = user.password + self.save() + + @classmethod + def is_student_password_reuse_restricted(cls): + """ + Returns whether the configuration which limits password reuse has been turned on + """ + return settings.FEATURES['ADVANCED_SECURITY'] and \ + settings.ADVANCED_SECURITY_CONFIG.get( + 'MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE', 0 + ) > 0 + + @classmethod + def is_staff_password_reuse_restricted(cls): + """ + Returns whether the configuration which limits password reuse has been turned on + """ + return settings.FEATURES['ADVANCED_SECURITY'] and \ + settings.ADVANCED_SECURITY_CONFIG.get( + 'MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE', 0 + ) > 0 + + @classmethod + def is_password_reset_frequency_restricted(cls): + """ + Returns whether the configuration which limits the password reset frequency has been turned on + """ + return settings.FEATURES['ADVANCED_SECURITY'] and \ + settings.ADVANCED_SECURITY_CONFIG.get( + 'MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS', None + ) + + @classmethod + def is_staff_forced_password_reset_enabled(cls): + """ + Returns whether the configuration which forces password resets to occur has been turned on + """ + return settings.FEATURES['ADVANCED_SECURITY'] and \ + settings.ADVANCED_SECURITY_CONFIG.get( + 'MIN_DAYS_FOR_STAFF_ACCOUNTS_PASSWORD_RESETS', None + ) + + @classmethod + def is_student_forced_password_reset_enabled(cls): + """ + Returns whether the configuration which forces password resets to occur has been turned on + """ + return settings.FEATURES['ADVANCED_SECURITY'] and \ + settings.ADVANCED_SECURITY_CONFIG.get( + 'MIN_DAYS_FOR_STUDENT_ACCOUNTS_PASSWORD_RESETS', None + ) + + @classmethod + def should_user_reset_password_now(cls, user): + """ + Returns whether a password has 'expired' and should be reset. Note there are two different + expiry policies for staff and students + """ + if not settings.FEATURES['ADVANCED_SECURITY']: + return False + + days_before_password_reset = None + if user.is_staff: + if cls.is_staff_forced_password_reset_enabled(): + days_before_password_reset = \ + settings.ADVANCED_SECURITY_CONFIG['MIN_DAYS_FOR_STAFF_ACCOUNTS_PASSWORD_RESETS'] + elif cls.is_student_forced_password_reset_enabled(): + days_before_password_reset = \ + settings.ADVANCED_SECURITY_CONFIG['MIN_DAYS_FOR_STUDENT_ACCOUNTS_PASSWORD_RESETS'] + + if days_before_password_reset: + history = PasswordHistory.objects.filter(user=user).order_by('-time_set') + time_last_reset = None + + if history: + # first element should be the last time we reset password + time_last_reset = history[0].time_set + else: + # no history, then let's take the date the user joined + time_last_reset = user.date_joined + + now = timezone.now() + + delta = now - time_last_reset + + return delta.days >= days_before_password_reset + + return False + + @classmethod + def is_password_reset_too_soon(cls, user): + """ + Verifies that the password is not getting reset too frequently + """ + if not cls.is_password_reset_frequency_restricted(): + return False + + history = PasswordHistory.objects.filter(user=user).order_by('-time_set') + + if not history: + return False + + now = timezone.now() + + delta = now - history[0].time_set + + return delta.days < settings.ADVANCED_SECURITY_CONFIG['MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS'] + + @classmethod + def is_allowable_password_reuse(cls, user, new_password): + """ + Verifies that the password adheres to the reuse policies + """ + if not settings.FEATURES['ADVANCED_SECURITY']: + return True + + min_diff_passwords_required = 0 + if user.is_staff: + if cls.is_staff_password_reuse_restricted(): + min_diff_passwords_required = \ + settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE'] + elif cls.is_student_password_reuse_restricted(): + min_diff_passwords_required = \ + settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE'] + + history = PasswordHistory.objects.filter(user=user).order_by('-time_set') + + reuse_distance = 0 + + for entry in history: + # did we reach the minimum amount of intermediate different passwords? + if reuse_distance >= min_diff_passwords_required: + return True + + # be sure to re-use the same salt + # NOTE, how the salt is serialized in the password field is dependent on the algorithm + # in pbkdf2_sha256 [LMS] it's the 3rd element, in sha1 [unit tests] it's the 2nd element + hash_elements = entry.password.split('$') + algorithm = hash_elements[0] + if algorithm == 'pbkdf2_sha256': + hashed_password = make_password(new_password, hash_elements[2]) + elif algorithm == 'sha1': + hashed_password = make_password(new_password, hash_elements[1]) + else: + # This means we got something unexpected. We don't want to throw an exception, but + # log as an error and basically allow any password reuse + AUDIT_LOG.error('Unknown password hashing algorithm "{0}" found in existing password hash, password reuse policy will not be enforced!!!'.format(algorithm)) + return True + + if entry.password != hashed_password: + reuse_distance += 1 + else: + return False + + return True + + class LoginFailures(models.Model): """ This model will keep track of failed login attempts diff --git a/common/djangoapps/student/tests/test_password_history.py b/common/djangoapps/student/tests/test_password_history.py new file mode 100644 index 000000000000..efa1e370ed07 --- /dev/null +++ b/common/djangoapps/student/tests/test_password_history.py @@ -0,0 +1,205 @@ +# -*- coding: utf-8 -*- +""" +This test file will verify proper password history enforcement +""" +from django.test import TestCase +from django.utils import timezone +from mock import patch +from student.tests.factories import UserFactory, AdminFactory + +from student.models import PasswordHistory +from freezegun import freeze_time +from datetime import timedelta + +from django.test.utils import override_settings + + +@patch.dict("django.conf.settings.FEATURES", {'ADVANCED_SECURITY': True}) +class TestPasswordHistory(TestCase): + """ + All the tests that assert proper behavior regarding password history + """ + + def _change_password(self, user, password): + """ + Helper method to change password on user and record in the PasswordHistory + """ + user.set_password(password) + user.save() + history = PasswordHistory() + history.create(user) + + def _user_factory_with_history(self, is_staff=False, set_initial_history=True): + """ + Helper method to generate either an Admin or a User + """ + if is_staff: + user = AdminFactory() + else: + user = UserFactory() + + user.date_joined = timezone.now() + + if set_initial_history: + history = PasswordHistory() + history.create(user) + + return user + + @patch.dict("django.conf.settings.FEATURES", {'ADVANCED_SECURITY': False}) + def test_disabled_feature(self): + """ + Test that behavior is normal when this feature is not turned on + """ + user = UserFactory() + staff = AdminFactory() + + # if feature is disabled user can keep reusing same password + self.assertTrue(PasswordHistory.is_allowable_password_reuse(user, "test")) + self.assertTrue(PasswordHistory.is_allowable_password_reuse(staff, "test")) + + self.assertFalse(PasswordHistory.should_user_reset_password_now(user)) + self.assertFalse(PasswordHistory.should_user_reset_password_now(staff)) + + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE': 2}) + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE': 1}) + def test_accounts_password_reuse(self): + """ + Assert against the password reuse policy + """ + user = self._user_factory_with_history() + staff = self._user_factory_with_history(is_staff=True) + + # students need to user at least one different passwords before reuse + self.assertFalse(PasswordHistory.is_allowable_password_reuse(user, "test")) + self.assertTrue(PasswordHistory.is_allowable_password_reuse(user, "different")) + self._change_password(user, "different") + + self.assertTrue(PasswordHistory.is_allowable_password_reuse(user, "test")) + + # staff needs to use at least two different passwords before reuse + self.assertFalse(PasswordHistory.is_allowable_password_reuse(staff, "test")) + self.assertTrue(PasswordHistory.is_allowable_password_reuse(staff, "different")) + self._change_password(staff, "different") + + self.assertFalse(PasswordHistory.is_allowable_password_reuse(staff, "test")) + self.assertFalse(PasswordHistory.is_allowable_password_reuse(staff, "different")) + self.assertTrue(PasswordHistory.is_allowable_password_reuse(staff, "third")) + self._change_password(staff, "third") + + self.assertTrue(PasswordHistory.is_allowable_password_reuse(staff, "test")) + + @override_settings(PASSWORD_HASHERS=('django.contrib.auth.hashers.PBKDF2PasswordHasher')) + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE': 2}) + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE': 1}) + def test_pbkdf2_sha256_password_reuse(self): + """ + Assert against the password reuse policy but using the normal Django PBKDF2 + """ + user = self._user_factory_with_history() + staff = self._user_factory_with_history(is_staff=True) + + # students need to user at least one different passwords before reuse + self.assertFalse(PasswordHistory.is_allowable_password_reuse(user, "test")) + self.assertTrue(PasswordHistory.is_allowable_password_reuse(user, "different")) + self._change_password(user, "different") + + self.assertTrue(PasswordHistory.is_allowable_password_reuse(user, "test")) + + # staff needs to use at least two different passwords before reuse + self.assertFalse(PasswordHistory.is_allowable_password_reuse(staff, "test")) + self.assertTrue(PasswordHistory.is_allowable_password_reuse(staff, "different")) + self._change_password(staff, "different") + + self.assertFalse(PasswordHistory.is_allowable_password_reuse(staff, "test")) + self.assertFalse(PasswordHistory.is_allowable_password_reuse(staff, "different")) + self.assertTrue(PasswordHistory.is_allowable_password_reuse(staff, "third")) + self._change_password(staff, "third") + + self.assertTrue(PasswordHistory.is_allowable_password_reuse(staff, "test")) + + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DAYS_FOR_STAFF_ACCOUNTS_PASSWORD_RESETS': 1}) + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DAYS_FOR_STUDENT_ACCOUNTS_PASSWORD_RESETS': 5}) + def test_forced_password_change(self): + """ + Assert when passwords must be reset + """ + student = self._user_factory_with_history() + staff = self._user_factory_with_history(is_staff=True) + grandfathered_student = self._user_factory_with_history(set_initial_history=False) + + self.assertFalse(PasswordHistory.should_user_reset_password_now(student)) + self.assertFalse(PasswordHistory.should_user_reset_password_now(staff)) + self.assertFalse(PasswordHistory.should_user_reset_password_now(grandfathered_student)) + + staff_reset_time = timezone.now() + timedelta(days=1) + with freeze_time(staff_reset_time): + self.assertFalse(PasswordHistory.should_user_reset_password_now(student)) + self.assertFalse(PasswordHistory.should_user_reset_password_now(grandfathered_student)) + self.assertTrue(PasswordHistory.should_user_reset_password_now(staff)) + + self._change_password(staff, 'Different') + self.assertFalse(PasswordHistory.should_user_reset_password_now(staff)) + + student_reset_time = timezone.now() + timedelta(days=5) + + with freeze_time(student_reset_time): + self.assertTrue(PasswordHistory.should_user_reset_password_now(student)) + self.assertTrue(PasswordHistory.should_user_reset_password_now(grandfathered_student)) + self.assertTrue(PasswordHistory.should_user_reset_password_now(staff)) + + self._change_password(student, 'Different') + self.assertFalse(PasswordHistory.should_user_reset_password_now(student)) + + self._change_password(grandfathered_student, 'Different') + self.assertFalse(PasswordHistory.should_user_reset_password_now(grandfathered_student)) + + self._change_password(staff, 'Different') + self.assertFalse(PasswordHistory.should_user_reset_password_now(staff)) + + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DAYS_FOR_STAFF_ACCOUNTS_PASSWORD_RESETS': None}) + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DAYS_FOR_STUDENT_ACCOUNTS_PASSWORD_RESETS': None}) + def test_no_forced_password_change(self): + """ + Assert that if we skip configuration, then user will never have to force reset password + """ + student = self._user_factory_with_history() + staff = self._user_factory_with_history(is_staff=True) + + # also create a user who doesn't have any history + grandfathered_student = UserFactory() + grandfathered_student.date_joined = timezone.now() + + self.assertFalse(PasswordHistory.should_user_reset_password_now(student)) + self.assertFalse(PasswordHistory.should_user_reset_password_now(staff)) + self.assertFalse(PasswordHistory.should_user_reset_password_now(grandfathered_student)) + + staff_reset_time = timezone.now() + timedelta(days=100) + with freeze_time(staff_reset_time): + self.assertFalse(PasswordHistory.should_user_reset_password_now(student)) + self.assertFalse(PasswordHistory.should_user_reset_password_now(grandfathered_student)) + self.assertFalse(PasswordHistory.should_user_reset_password_now(staff)) + + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS': 1}) + def test_too_frequent_password_resets(self): + """ + Assert that a user should not be able to password reset too frequently + """ + student = self._user_factory_with_history() + grandfathered_student = self._user_factory_with_history(set_initial_history=False) + + self.assertTrue(PasswordHistory.is_password_reset_too_soon(student)) + self.assertFalse(PasswordHistory.is_password_reset_too_soon(grandfathered_student)) + + staff_reset_time = timezone.now() + timedelta(days=100) + with freeze_time(staff_reset_time): + self.assertFalse(PasswordHistory.is_password_reset_too_soon(student)) + + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS': None}) + def test_disabled_too_frequent_password_resets(self): + """ + Verify properly default behavior when feature is disabled + """ + student = self._user_factory_with_history() + + self.assertFalse(PasswordHistory.is_password_reset_too_soon(student)) diff --git a/common/djangoapps/student/tests/test_password_policy.py b/common/djangoapps/student/tests/test_password_policy.py index 647288ad0f02..a649c535449b 100644 --- a/common/djangoapps/student/tests/test_password_policy.py +++ b/common/djangoapps/student/tests/test_password_policy.py @@ -87,7 +87,7 @@ def test_password_not_enough_lowercase(self): ) @patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", {'LOWER': 3}) - def test_password_not_enough_lowercase(self): + def test_password_enough_lowercase(self): self.url_params['password'] = 'ThisShouldPass' response = self.client.post(self.url, self.url_params) self.assertEqual(response.status_code, 200) diff --git a/common/djangoapps/student/tests/test_reset_password.py b/common/djangoapps/student/tests/test_reset_password.py new file mode 100644 index 000000000000..d0f35bc9c7c3 --- /dev/null +++ b/common/djangoapps/student/tests/test_reset_password.py @@ -0,0 +1,158 @@ +""" +Test the various password reset flows +""" +import json +import re +import unittest + +from django.core.cache import cache +from django.conf import settings +from django.test import TestCase +from django.test.client import RequestFactory +from django.contrib.auth.models import User +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 + +from mock import Mock, patch +from textwrap import dedent + +from student.views import password_reset, password_reset_confirm_wrapper +from student.tests.factories import UserFactory +from student.tests.test_email import mock_render_to_string + + +class ResetPasswordTests(TestCase): + """ Tests that clicking reset password sends email, and doesn't activate the user + """ + request_factory = RequestFactory() + + def setUp(self): + self.user = UserFactory.create() + self.user.is_active = False + self.user.save() + self.token = default_token_generator.make_token(self.user) + self.uidb36 = int_to_base36(self.user.id) + + self.user_bad_passwd = UserFactory.create() + self.user_bad_passwd.is_active = False + self.user_bad_passwd.password = UNUSABLE_PASSWORD + self.user_bad_passwd.save() + + @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) + def test_user_bad_password_reset(self): + """Tests password reset behavior for user with password marked UNUSABLE_PASSWORD""" + + bad_pwd_req = self.request_factory.post('/password_reset/', {'email': self.user_bad_passwd.email}) + bad_pwd_resp = password_reset(bad_pwd_req) + # If they've got an unusable password, we return a successful response code + self.assertEquals(bad_pwd_resp.status_code, 200) + obj = json.loads(bad_pwd_resp.content) + self.assertEquals(obj, { + 'success': True, + 'value': "('registration/password_reset_done.html', [])", + }) + + @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) + def test_nonexist_email_password_reset(self): + """Now test the exception cases with of reset_password called with invalid email.""" + + bad_email_req = self.request_factory.post('/password_reset/', {'email': self.user.email + "makeItFail"}) + bad_email_resp = password_reset(bad_email_req) + # Note: even if the email is bad, we return a successful response code + # This prevents someone potentially trying to "brute-force" find out which + # emails are and aren't registered with edX + self.assertEquals(bad_email_resp.status_code, 200) + obj = json.loads(bad_email_resp.content) + self.assertEquals(obj, { + 'success': True, + 'value': "('registration/password_reset_done.html', [])", + }) + + @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) + def test_password_reset_ratelimited(self): + """ Try (and fail) resetting password 30 times in a row on an non-existant email address """ + cache.clear() + + for i in xrange(30): + good_req = self.request_factory.post('/password_reset/', { + 'email': 'thisdoesnotexist{0}@foo.com'.format(i) + }) + good_resp = password_reset(good_req) + self.assertEquals(good_resp.status_code, 200) + + # then the rate limiter should kick in and give a HttpForbidden response + bad_req = self.request_factory.post('/password_reset/', {'email': 'thisdoesnotexist@foo.com'}) + bad_resp = password_reset(bad_req) + self.assertEquals(bad_resp.status_code, 403) + + cache.clear() + + @unittest.skipIf( + settings.FEATURES.get('DISABLE_RESET_EMAIL_TEST', False), + dedent(""" + Skipping Test because CMS has not provided necessary templates for password reset. + If LMS tests print this message, that needs to be fixed. + """) + ) + @patch('django.core.mail.send_mail') + @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) + def test_reset_password_email(self, send_email): + """Tests contents of reset password email, and that user is not active""" + + good_req = self.request_factory.post('/password_reset/', {'email': self.user.email}) + good_resp = password_reset(good_req) + self.assertEquals(good_resp.status_code, 200) + obj = json.loads(good_resp.content) + self.assertEquals(obj, { + 'success': True, + 'value': "('registration/password_reset_done.html', [])", + }) + + (subject, msg, from_addr, to_addrs) = send_email.call_args[0] + self.assertIn("Password reset", subject) + self.assertIn("You're receiving this e-mail because you requested a password reset", msg) + self.assertEquals(from_addr, settings.DEFAULT_FROM_EMAIL) + self.assertEquals(len(to_addrs), 1) + self.assertIn(self.user.email, to_addrs) + + #test that the user is not active + self.user = User.objects.get(pk=self.user.pk) + self.assertFalse(self.user.is_active) + re.search(r'password_reset_confirm/(?P[0-9A-Za-z]+)-(?P.+)/', msg).groupdict() + + @patch('student.views.password_reset_confirm') + def test_reset_password_bad_token(self, reset_confirm): + """Tests bad token and uidb36 in password reset""" + + bad_reset_req = self.request_factory.get('/password_reset_confirm/NO-OP/') + password_reset_confirm_wrapper(bad_reset_req, 'NO', 'OP') + confirm_kwargs = reset_confirm.call_args[1] + self.assertEquals(confirm_kwargs['uidb36'], 'NO') + self.assertEquals(confirm_kwargs['token'], 'OP') + self.user = User.objects.get(pk=self.user.pk) + self.assertFalse(self.user.is_active) + + @patch('student.views.password_reset_confirm') + def test_reset_password_good_token(self, reset_confirm): + """Tests good token and uidb36 in password reset""" + + good_reset_req = self.request_factory.get('/password_reset_confirm/{0}-{1}/'.format(self.uidb36, self.token)) + password_reset_confirm_wrapper(good_reset_req, self.uidb36, self.token) + confirm_kwargs = reset_confirm.call_args[1] + self.assertEquals(confirm_kwargs['uidb36'], self.uidb36) + self.assertEquals(confirm_kwargs['token'], self.token) + self.user = User.objects.get(pk=self.user.pk) + self.assertTrue(self.user.is_active) + + @patch('student.views.password_reset_confirm') + def test_reset_password_with_reused_password(self, reset_confirm): + """Tests good token and uidb36 in password reset""" + + good_reset_req = self.request_factory.get('/password_reset_confirm/{0}-{1}/'.format(self.uidb36, self.token)) + password_reset_confirm_wrapper(good_reset_req, self.uidb36, self.token) + confirm_kwargs = reset_confirm.call_args[1] + self.assertEquals(confirm_kwargs['uidb36'], self.uidb36) + self.assertEquals(confirm_kwargs['token'], self.token) + self.user = User.objects.get(pk=self.user.pk) + self.assertTrue(self.user.is_active) diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index f7035db51cf7..48d8bb642eb2 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -5,21 +5,15 @@ Replace this with more appropriate tests for your application. """ import logging -import json -import re import unittest from datetime import datetime, timedelta import pytz -from django.core.cache import cache from django.conf import settings from django.test import TestCase from django.test.utils import override_settings from django.test.client import RequestFactory from django.contrib.auth.models import User, 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 from django.core.urlresolvers import reverse from django.http import HttpResponse @@ -28,13 +22,11 @@ from courseware.tests.tests import TEST_DATA_MIXED_MODULESTORE from mock import Mock, patch, sentinel -from textwrap import dedent from student.models import anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment, unique_id_for_user -from student.views import (process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper, - change_enrollment, complete_course_mode_info, token, course_from_id) +from student.views import (process_survey_link, _cert_info, + change_enrollment, complete_course_mode_info, token) from student.tests.factories import UserFactory, CourseModeFactory -from student.tests.test_email import mock_render_to_string import shoppingcart @@ -44,127 +36,6 @@ log = logging.getLogger(__name__) -class ResetPasswordTests(TestCase): - """ Tests that clicking reset password sends email, and doesn't activate the user - """ - request_factory = RequestFactory() - - def setUp(self): - self.user = UserFactory.create() - self.user.is_active = False - self.user.save() - self.token = default_token_generator.make_token(self.user) - self.uidb36 = int_to_base36(self.user.id) - - self.user_bad_passwd = UserFactory.create() - self.user_bad_passwd.is_active = False - self.user_bad_passwd.password = UNUSABLE_PASSWORD - self.user_bad_passwd.save() - - @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) - def test_user_bad_password_reset(self): - """Tests password reset behavior for user with password marked UNUSABLE_PASSWORD""" - - bad_pwd_req = self.request_factory.post('/password_reset/', {'email': self.user_bad_passwd.email}) - bad_pwd_resp = password_reset(bad_pwd_req) - # If they've got an unusable password, we return a successful response code - self.assertEquals(bad_pwd_resp.status_code, 200) - obj = json.loads(bad_pwd_resp.content) - self.assertEquals(obj, { - 'success': True, - 'value': "('registration/password_reset_done.html', [])", - }) - - @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) - def test_nonexist_email_password_reset(self): - """Now test the exception cases with of reset_password called with invalid email.""" - - bad_email_req = self.request_factory.post('/password_reset/', {'email': self.user.email+"makeItFail"}) - bad_email_resp = password_reset(bad_email_req) - # Note: even if the email is bad, we return a successful response code - # This prevents someone potentially trying to "brute-force" find out which emails are and aren't registered with edX - self.assertEquals(bad_email_resp.status_code, 200) - obj = json.loads(bad_email_resp.content) - self.assertEquals(obj, { - 'success': True, - 'value': "('registration/password_reset_done.html', [])", - }) - - @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) - def test_password_reset_ratelimited(self): - """ Try (and fail) resetting password 30 times in a row on an non-existant email address """ - cache.clear() - - for i in xrange(30): - good_req = self.request_factory.post('/password_reset/', {'email': 'thisdoesnotexist@foo.com'}) - good_resp = password_reset(good_req) - self.assertEquals(good_resp.status_code, 200) - - # then the rate limiter should kick in and give a HttpForbidden response - bad_req = self.request_factory.post('/password_reset/', {'email': 'thisdoesnotexist@foo.com'}) - bad_resp = password_reset(bad_req) - self.assertEquals(bad_resp.status_code, 403) - - cache.clear() - - @unittest.skipIf( - settings.FEATURES.get('DISABLE_RESET_EMAIL_TEST', False), - dedent(""" - Skipping Test because CMS has not provided necessary templates for password reset. - If LMS tests print this message, that needs to be fixed. - """) - ) - @patch('django.core.mail.send_mail') - @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) - def test_reset_password_email(self, send_email): - """Tests contents of reset password email, and that user is not active""" - - good_req = self.request_factory.post('/password_reset/', {'email': self.user.email}) - good_resp = password_reset(good_req) - self.assertEquals(good_resp.status_code, 200) - obj = json.loads(good_resp.content) - self.assertEquals(obj, { - 'success': True, - 'value': "('registration/password_reset_done.html', [])", - }) - - ((subject, msg, from_addr, to_addrs), sm_kwargs) = send_email.call_args - self.assertIn("Password reset", subject) - self.assertIn("You're receiving this e-mail because you requested a password reset", msg) - self.assertEquals(from_addr, settings.DEFAULT_FROM_EMAIL) - self.assertEquals(len(to_addrs), 1) - self.assertIn(self.user.email, to_addrs) - - #test that the user is not active - self.user = User.objects.get(pk=self.user.pk) - self.assertFalse(self.user.is_active) - reset_match = re.search(r'password_reset_confirm/(?P[0-9A-Za-z]+)-(?P.+)/', msg).groupdict() - - @patch('student.views.password_reset_confirm') - def test_reset_password_bad_token(self, reset_confirm): - """Tests bad token and uidb36 in password reset""" - - bad_reset_req = self.request_factory.get('/password_reset_confirm/NO-OP/') - password_reset_confirm_wrapper(bad_reset_req, 'NO', 'OP') - (confirm_args, confirm_kwargs) = reset_confirm.call_args - self.assertEquals(confirm_kwargs['uidb36'], 'NO') - self.assertEquals(confirm_kwargs['token'], 'OP') - self.user = User.objects.get(pk=self.user.pk) - self.assertFalse(self.user.is_active) - - @patch('student.views.password_reset_confirm') - def test_reset_password_good_token(self, reset_confirm): - """Tests good token and uidb36 in password reset""" - - good_reset_req = self.request_factory.get('/password_reset_confirm/{0}-{1}/'.format(self.uidb36, self.token)) - password_reset_confirm_wrapper(good_reset_req, self.uidb36, self.token) - (confirm_args, confirm_kwargs) = reset_confirm.call_args - self.assertEquals(confirm_kwargs['uidb36'], self.uidb36) - self.assertEquals(confirm_kwargs['token'], self.token) - self.user = User.objects.get(pk=self.user.pk) - self.assertTrue(self.user.is_active) - - class CourseEndingTest(TestCase): """Test things related to course endings: certificates, surveys, etc""" diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 54f3e2bfb3db..d41115a8b8d0 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -30,6 +30,8 @@ from django.utils.translation import ugettext as _, get_language from django.views.decorators.http import require_POST, require_GET +from django.template.response import TemplateResponse + from ratelimitbackend.exceptions import RateLimitException from edxmako.shortcuts import render_to_response, render_to_string @@ -39,7 +41,7 @@ Registration, UserProfile, PendingNameChange, PendingEmailChange, CourseEnrollment, unique_id_for_user, CourseEnrollmentAllowed, UserStanding, LoginFailures, - create_comments_service_user + create_comments_service_user, PasswordHistory ) from student.forms import PasswordResetFormNoActive from student.firebase_token_generator import create_token @@ -747,6 +749,15 @@ def login_user(request, error=""): "value": _('This account has been temporarily locked due to excessive login failures. Try again later.'), }) # TODO: this should be status code 429 # pylint: disable=fixme + # see if the user must reset his/her password due to any policy settings + if PasswordHistory.should_user_reset_password_now(user_found_by_email_lookup): + return JsonResponse({ + "success": False, + "value": _('Your password has expired due to password policy on this account. You must ' + 'reset your password before you can log in again. Please click the ' + 'Forgot Password" link on this page to reset your password before logging in again.'), + }) # TODO: this should be status code 403 # pylint: disable=fixme + # if the user doesn't exist, we want to set the username to an invalid # username so that authentication is guaranteed to fail and we can take # advantage of the ratelimited backend @@ -971,6 +982,7 @@ def _do_create_account(post_vars): is_active=False) user.set_password(post_vars['password']) registration = Registration() + # TODO: Rearrange so that if part of the process fails, the whole process fails. # Right now, we can have e.g. no registration e-mail sent out and a zombie account try: @@ -990,6 +1002,11 @@ def _do_create_account(post_vars): else: raise + # add this account creation to password history + # NOTE, this will be a NOP unless the feature has been turned on in configuration + password_history_entry = PasswordHistory() + password_history_entry.create(user) + registration.register(user) profile = UserProfile(user=user) @@ -1419,12 +1436,71 @@ def password_reset_confirm_wrapper( user.save() except (ValueError, User.DoesNotExist): pass - # we also want to pass settings.PLATFORM_NAME in as extra_context - extra_context = {"platform_name": settings.PLATFORM_NAME} - return password_reset_confirm( - request, uidb36=uidb36, token=token, extra_context=extra_context - ) + # tie in password strength enforcement as an optional level of + # security protection + err_msg = None + + if request.method == 'POST': + password = request.POST['new_password1'] + if settings.FEATURES.get('ENFORCE_PASSWORD_POLICY', False): + try: + validate_password_length(password) + validate_password_complexity(password) + validate_password_dictionary(password) + except ValidationError, err: + err_msg = _('Password: ') + '; '.join(err.messages) + + # also, check the password reuse policy + if not PasswordHistory.is_allowable_password_reuse(user, password): + if user.is_staff: + num_distinct = settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE'] + else: + num_distinct = settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE'] + err_msg = _("You are re-using a password that you have used recently. You must " + "have {0} distinct password(s) before reusing a previous password.").format(num_distinct) + + # also, check to see if passwords are getting reset too frequent + if PasswordHistory.is_password_reset_too_soon(user): + num_days = settings.ADVANCED_SECURITY_CONFIG['MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS'] + err_msg = _("You are resetting passwords too frequently. Due to security policies, " + "{0} day(s) must elapse between password resets").format(num_days) + + if err_msg: + # We have an password reset attempt which violates some security policy, use the + # existing Django template to communicate this back to the user + context = { + 'validlink': True, + 'form': None, + 'title': _('Password reset unsuccessful'), + 'err_msg': err_msg, + } + return TemplateResponse(request, 'registration/password_reset_confirm.html', context) + else: + # we also want to pass settings.PLATFORM_NAME in as extra_context + extra_context = {"platform_name": settings.PLATFORM_NAME} + + if request.method == 'POST': + # remember what the old password hash is before we call down + old_password_hash = user.password + + result = password_reset_confirm( + request, uidb36=uidb36, token=token, extra_context=extra_context + ) + + # get the updated user + updated_user = User.objects.get(id=uid_int) + + # did the password hash change, if so record it in the PasswordHistory + if updated_user.password != old_password_hash: + entry = PasswordHistory() + entry.create(updated_user) + + return result + else: + return password_reset_confirm( + request, uidb36=uidb36, token=token, extra_context=extra_context + ) def reactivation_email_for_user(user): diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index bcf9c78cfb86..ba622f8c7f33 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -347,7 +347,10 @@ def get_hints(self, student_answers, new_cmap, old_cmap): hintmode = hintgroup.get('mode', 'always') for hintpart in hintgroup.findall('hintpart'): if hintpart.get('on') in hints_to_show: - hint_text = hintpart.find('text').text + if hintpart.find('text') is not None: + hint_text = hintpart.find('text').text + elif hintpart.text: + hint_text = hintpart.text # make the hint appear after the last answer box in this # response aid = self.answer_ids[-1] @@ -718,9 +721,14 @@ def get_answers(self): @registry.register class MultipleChoiceResponse(LoncapaResponse): + """ + MultipleChoiceResponse is a specialized ChoiceResponse type responsible + for handling all aspects of MCQ behavior, including hinting + """ # TODO: handle direction and randomize tags = ['multiplechoiceresponse'] + hint_tag = 'choicehint' max_inputfields = 1 allowed_inputfields = ['choicegroup'] correct_choices = None @@ -774,6 +782,29 @@ def get_score(self, student_answers): def get_answers(self): return {self.answer_id: self.correct_choices} + def check_string(self, expected, given): + """ + Attempt to match the given value with what the caller expects + For now we ignore the case -- at some point we might add a switch + We're also a bit lenient in terms of matching at the present time + """ + flags = re.IGNORECASE + regexp = re.compile('^' + '|'.join(expected) + '$', flags=flags | re.UNICODE) + result = re.search(regexp, given) + return bool(result) + + def check_hint_condition(self, hxml_set, student_answers): + # stolen from StringResponse.check_hint_condition + given = student_answers[self.answer_id].strip() + hints_to_show = [] + for hxml in hxml_set: + name = hxml.get('name') + hinted_answer = contextualize_text(hxml.get('answer'), self.context).strip() + + if self.check_string([hinted_answer], given): + hints_to_show.append(name) + return hints_to_show + @registry.register class TrueFalseResponse(MultipleChoiceResponse): diff --git a/common/lib/capa/capa/templates/choicegroup.html b/common/lib/capa/capa/templates/choicegroup.html index c91cfb0c5787..49ec2d4f7fa7 100644 --- a/common/lib/capa/capa/templates/choicegroup.html +++ b/common/lib/capa/capa/templates/choicegroup.html @@ -1,4 +1,64 @@ +<% + fieldset_width = 775 + fieldset_message_width = 0 + if (not msg is UNDEFINED) and (len(msg) > 0): + fieldset_width = 480 + fieldset_message_width = 295 + +%>
+ +
% if input_type == 'checkbox' or not value: % endif
+
+
@@ -43,22 +105,39 @@ % elif input_type != 'radio' and choice_id in value: checked="true" % endif - % if input_type != 'radio': - aria-multiselectable="true" - % endif /> ${choice_description} % if input_type == 'radio' and ( (isinstance(value, basestring) and (choice_id == value)) or (not isinstance(value, basestring) and choice_id in value) ): - % if status in ('correct', 'incorrect') and not show_correctness=='never': - ${choice_description|h} - ${status_display} + <% + if status == 'correct': + correctness = 'correct' + elif status == 'incorrect': + correctness = 'incorrect' + else: + correctness = None + %> + % if correctness and not show_correctness=='never': + Status: ${correctness} % endif % endif % endfor
- +
+ ## Message/hint display block -- empty/invisible unless msg is populated +
0): + id="fieldset_message_${id}" + % endif + > +
X
+ % if (not msg is UNDEFINED) and (len(msg) > 0): + ${msg} + % endif +
+
% if show_correctness == "never" and (value or status not in ['unsubmitted']):
${submitted_message}
%endif diff --git a/common/lib/capa/capa/tests/response_xml_factory.py b/common/lib/capa/capa/tests/response_xml_factory.py index 26c60db77d71..9c85f8283c0b 100644 --- a/common/lib/capa/capa/tests/response_xml_factory.py +++ b/common/lib/capa/capa/tests/response_xml_factory.py @@ -58,6 +58,7 @@ def build_xml(self, **kwargs): script = kwargs.get('script', None) num_responses = kwargs.get('num_responses', 1) num_inputs = kwargs.get('num_inputs', 1) + hints = kwargs.get('hints', None) # The root is root = etree.Element("problem") @@ -83,6 +84,12 @@ def build_xml(self, **kwargs): if not (None == input_element): response_element.append(input_element) + # Add hintgroup, if specified + if hints is not None and hasattr(self, 'create_hintgroup_element'): + hintgroup_element = self.create_hintgroup_element(**kwargs) + if hintgroup_element is not None: + response_element.append(hintgroup_element) + # The problem has an explanation of the solution if explanation_text: explanation = etree.SubElement(root, "solution") @@ -161,6 +168,28 @@ def choicegroup_input_xml(**kwargs): return group_element + @staticmethod + def hintgroup_input_xml(**kwargs): + """ Create a XML element""" + + # Gather the troops + choice_names = kwargs.get("choice_names") + hints = kwargs.get("hints") + + # Build the child tree + group_element = etree.Element("hintgroup") + for (choice_name, hint) in zip(choice_names, hints): + choicehint_element = etree.SubElement(group_element, "choicehint") + choicehint_answer = "choice_" + choice_name + choicehint_element.set("answer", choicehint_answer) + choicehint_name = choice_name + "_hint" + choicehint_element.set("name", choicehint_name) + hintpart_element = etree.SubElement(group_element, "hintpart") + hintpart_element.set("on", choicehint_name) + hintpart_element.text = hint + + return group_element + class NumericalResponseXMLFactory(ResponseXMLFactory): """ Factory for producing XML trees """ @@ -618,7 +647,14 @@ def create_response_element(self, **kwargs): def create_input_element(self, **kwargs): """ Create the element""" kwargs['choice_type'] = 'multiple' - return ResponseXMLFactory.choicegroup_input_xml(**kwargs) + choice_group = ResponseXMLFactory.choicegroup_input_xml(**kwargs) + return choice_group + + def create_hintgroup_element(self, **kwargs): + """ Create the element""" + + hintgroup_element = ResponseXMLFactory.hintgroup_input_xml(**kwargs) + return hintgroup_element class TrueFalseResponseXMLFactory(ResponseXMLFactory): diff --git a/common/lib/capa/capa/tests/test_input_templates.py b/common/lib/capa/capa/tests/test_input_templates.py index 6933a48b99c3..eacd8047a4a9 100644 --- a/common/lib/capa/capa/tests/test_input_templates.py +++ b/common/lib/capa/capa/tests/test_input_templates.py @@ -258,6 +258,36 @@ def test_option_marked_incorrect(self): xpath = "//div[@class='indicator_container']/span" self.assert_no_xpath(xml, xpath, self.context) + + def test_option_marked_incorrect_with_feedback(self): + """ + Test conditions under which a particular option + (not the entire problem) is marked incorrect, with feedback. + """ + conditions = [ + {'input_type': 'radio', 'value': '2'}, + {'input_type': 'radio', 'value': ['2']}] + + self.context['status'] = 'incorrect' + self.context['msg'] = "This is the feedback" + + for test_conditions in conditions: + self.context.update(test_conditions) + xml = self.render_to_xml(self.context) + + # Should include a choicegroup_incorrect class + xpath = "//label[@class='choicegroup_incorrect']" + self.assert_has_xpath(xml, xpath, self.context) + + # Should include a fieldset_message type + xpath = "//div[@id='fieldset_message_" + self.context['id'] + "']" + self.assert_has_xpath(xml, xpath, self.context) + + + # Should include a fieldset_message_title type + xpath = "//span[@id='fieldset_message_title_" + self.context['id'] + "']" + self.assert_has_xpath(xml, xpath, self.context) + def test_never_show_correctness(self): """ Test conditions under which we tell the template to diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index 770f6959ee3a..365cf734e6dd 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -94,6 +94,15 @@ def test_named_multiple_choice_grade(self): self.assert_grade(problem, 'choice_foil_3', 'incorrect') + def test_named_multiple_choice_grade_with_hint(self): + problem = self.build_problem(choices=[False], + choice_names=["foil_1"], + hints=["h1"]) + + # Ensure that we get the expected hint + self.assert_grade(problem, 'choice_foil_1', 'incorrect', 'h1') + + class TrueFalseResponseTest(ResponseTest): from capa.tests.response_xml_factory import TrueFalseResponseXMLFactory xml_factory_class = TrueFalseResponseXMLFactory diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index 58d81709674f..800eefb8dc29 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -42,6 +42,7 @@ "raw = xmodule.raw_module:RawDescriptor", "crowdsource_hinter = xmodule.crowdsource_hinter:CrowdsourceHinterDescriptor", "lti = xmodule.lti_module:LTIDescriptor", + "image_explorer = xmodule.image_explorer:ImageExplorerDescriptor", ] setup( diff --git a/common/lib/xmodule/xmodule/css/image_explorer/display.scss b/common/lib/xmodule/xmodule/css/image_explorer/display.scss new file mode 100644 index 000000000000..9a6243b63c13 --- /dev/null +++ b/common/lib/xmodule/xmodule/css/image_explorer/display.scss @@ -0,0 +1,74 @@ +.image-explorer-description { + padding-top: 10px; + padding-bottom: 10px; +} + +.image-explorer-wrapper { + position: relative; +} + +.image-explorer-hotspot-reveal { + background-color: rgba(0, 101, 189, 0.80); + color: #000000; + left: 100%; + position: absolute; + top: 25%; + width: 300px; + z-index: 1; + display: none; +} + +.image-explorer-hotspot-content-wrapper { + margin: 10px 20px 10px 10px; +} + +.image-explorer-hotspot-reveal.active { + display: block; + opacity: 1; +} + +.image-explorer-hotspot-reveal-header { + font-family: "Open Sans Regular"; + font-size: 16px; +} + +.image-explorer-wrapper a.image-explorer-hotspot { + background: url("../images/image-explorer-hotspot-sprite.png") no-repeat scroll 0 0 rgba(0, 0, 0, 0); + width: 41px; + height: 41px; + display: block; + text-decoration: none; + -webkit-transition: all 0.0s linear 0s; + -moz-transition: all 0.0s linear 0s; + transition: all 0.0s linear 0s; +} + +.image-explorer-wrapper a.image-explorer-hotspot:hover { + background-position: 0px -43px +} + +a.image-explorer-hotspot .image-explorer-hotspot-reveal .image-explorer-hotspot-reveal-header p { + margin: 10px 0px 10px 20px; +} + +.image-explorer-hotspot-reveal-body { + font-family: "Open Sans Regular"; + font-size: 14px; +} + +a.image-explorer-hotspot .image-explorer-hotspot-reveal .image-explorer-hotspot-reveal-body ul { + list-style-type: disc; +} + +a.image-explorer-hotspot .image-explorer-hotspot-reveal .image-explorer-hotspot-reveal-body ul li { + margin: 0 0 24px 17px; +} + +.image-explorer-close-reveal { + background: url("../images/image-explorer-reveal-close.png") no-repeat scroll 0 0 rgba(0, 0, 0, 0); + float: right; + width: 20px; + height: 20px; + margin-right: 5px; + margin-top: 5px; +} diff --git a/common/lib/xmodule/xmodule/fields.py b/common/lib/xmodule/xmodule/fields.py index 74d7a61b7dfd..8692f2014060 100644 --- a/common/lib/xmodule/xmodule/fields.py +++ b/common/lib/xmodule/xmodule/fields.py @@ -68,10 +68,7 @@ def to_json(self, value): """ if value is None: return None - if isinstance(value, time.struct_time): - # struct_times are always utc - return time.strftime('%Y-%m-%dT%H:%M:%SZ', value) - elif isinstance(value, datetime.datetime): + if isinstance(value, datetime.datetime): if value.tzinfo is None or value.utcoffset().total_seconds() == 0: # isoformat adds +00:00 rather than Z return value.strftime('%Y-%m-%dT%H:%M:%SZ') @@ -80,6 +77,8 @@ def to_json(self, value): else: raise TypeError("Cannot convert {!r} to json".format(value)) + enforce_type = from_json + TIMEDELTA_REGEX = re.compile(r'^((?P\d+?) day(?:s?))?(\s)?((?P\d+?) hour(?:s?))?(\s)?((?P\d+?) minute(?:s)?)?(\s)?((?P\d+?) second(?:s)?)?$') @@ -117,6 +116,15 @@ def to_json(self, value): values.append("%d %s" % (cur_value, attr)) return ' '.join(values) + def enforce_type(self, value): + """ + Ensure that when set explicitly the Field is set to a timedelta + """ + if isinstance(value, datetime.timedelta) or value is None: + return value + + return self.from_json(value) + class RelativeTime(Field): """ @@ -219,3 +227,12 @@ def timedelta_to_string(self, value): if len(stringified) == 7: stringified = '0' + stringified return stringified + + def enforce_type(self, value): + """ + Ensure that when set explicitly the Field is set to a timedelta + """ + if isinstance(value, datetime.timedelta) or value is None: + return None + + return self.from_json(value) diff --git a/common/lib/xmodule/xmodule/image_explorer.py b/common/lib/xmodule/xmodule/image_explorer.py new file mode 100644 index 000000000000..1a327cc582fa --- /dev/null +++ b/common/lib/xmodule/xmodule/image_explorer.py @@ -0,0 +1,177 @@ +from xmodule.x_module import XModule +from xmodule.raw_module import RawDescriptor +from lxml import etree +from xml.etree import ElementTree as ET + +from xblock.fields import Scope, String +import textwrap +from pkg_resources import resource_string + +class AttrDict(dict): + def __init__(self, *args, **kwargs): + super(AttrDict, self).__init__(*args, **kwargs) + self.__dict__ = self + +class ImageExplorerFields(object): + display_name = String( + display_name="Display Name", + help="This name appears in the horizontal navigation at the top of the page.", + scope=Scope.settings, + default="Image Explorer" + ) + data = String(help="XML contents to display for this module", scope=Scope.content, default=textwrap.dedent("""\ + + + +

+ Enjoy using the Image Explorer. Click around the MIT Dome and see what you find! +

+
+ + + +
+

+ This is where many pranks take place. Below are some of the highlights: +

+
+ +
    +
  • Once there was a police car up here
  • +
  • Also there was a Fire Truck put up there
  • +
+ +
+
+ + +
+

+ Watch the Red Line subway go around the dome +

+
+ +
+
+
+
+ """)) + + + +class ImageExplorerModule(ImageExplorerFields, XModule): + """ + The xModule to render the Image Explorer + """ + css = { + 'scss': [resource_string(__name__, 'css/image_explorer/display.scss')], + } + + js = { + 'coffee': [resource_string(__name__, 'js/src/image_explorer/display.coffee')], + } + + js_module_name = "ImageExplorer" + + def __init__(self, *args, **kwargs): + super(ImageExplorerModule, self).__init__(*args, **kwargs) + + xmltree = etree.fromstring(self.data) + + self.description = self._get_description(xmltree) + self.hotspots = self._get_hotspots(xmltree) + self.background = self._get_background(xmltree) + + def get_html(self): + """ + Implementation of the XModule API entry point + """ + + context = { + 'title': self.display_name_with_default, + 'description_html': self.description, + 'hotspots': self.hotspots, + 'background': self.background, + } + + return self.system.render_template('image_explorer.html', context) + + def _get_background(self, xmltree): + """ + Parse the XML to get the information about the background image + """ + background = xmltree.find('background') + return AttrDict({ + 'src': background.get('src'), + 'width': background.get('width'), + 'height': background.get('height') + }) + + def _inner_content(self, tag): + """ + Helper met + """ + if tag is not None: + return u''.join(ET.tostring(e) for e in tag) + return None + + def _get_description(self, xmltree): + """ + Parse the XML to get the description information + """ + description = xmltree.find('description') + if description is not None: + return self._inner_content(description) + return None + + def _get_hotspots(self, xmltree): + """ + Parse the XML to get the hotspot information + """ + hotspots_element= xmltree.find('hotspots') + hotspot_elements = hotspots_element.findall('hotspot') + hotspots = [] + for hotspot_element in hotspot_elements: + feedback_element = hotspot_element.find('feedback') + + feedback = AttrDict() + feedback.width = feedback_element.get('width') + feedback.height = feedback_element.get('height') + feedback.header = self._inner_content(feedback_element.find('header')) + + feedback.body = None + body_element = feedback_element.find('body') + if body_element is not None: + feedback.type = 'text' + feedback.body = self._inner_content(body_element) + + feedback.youtube = None + youtube_element = feedback_element.find('youtube') + if youtube_element is not None: + feedback.type = 'youtube' + feedback.youtube = AttrDict() + feedback.youtube.video_id = youtube_element.get('video_id') + feedback.youtube.width = youtube_element.get('width') + feedback.youtube.height = youtube_element.get('height') + + hotspot = AttrDict() + hotspot.feedback = feedback + hotspot.x = hotspot_element.get('x') + hotspot.y = hotspot_element.get('y') + + hotspots.append(hotspot) + + return hotspots + + +class ImageExplorerDescriptor(ImageExplorerFields, RawDescriptor): + """ Descriptor for custom tags. Loads the template when created.""" + module_class = ImageExplorerModule + template_dir_name = 'image_explorer' + + def export_to_file(self): + """ + Custom tags are special: since they're already pointers, we don't want + to export them in a file with yet another layer of indirection. + """ + return False diff --git a/common/lib/xmodule/xmodule/js/fixtures/image_explorer.html b/common/lib/xmodule/xmodule/js/fixtures/image_explorer.html new file mode 100644 index 000000000000..2036975b3535 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/fixtures/image_explorer.html @@ -0,0 +1 @@ +
diff --git a/common/lib/xmodule/xmodule/js/spec/image_explorer/display_spec.coffee b/common/lib/xmodule/xmodule/js/spec/image_explorer/display_spec.coffee new file mode 100644 index 000000000000..38a64d0a93af --- /dev/null +++ b/common/lib/xmodule/xmodule/js/spec/image_explorer/display_spec.coffee @@ -0,0 +1,37 @@ +describe 'ImageExplorer', -> + beforeEach -> + loadFixtures 'image_explorer.html' + describe 'constructor', -> + el = $('.xblock-student_view.xmodule_ImageExplorerModule') + beforeEach -> + @image_explorer = new ImageExplorer(el) + @image_explorer.close_hotspots() + + it 'no hotspot should be visible on load', -> + # disable test for now as we figure out the Jasmine/css relationship + return + visible_counter = 0 + $('.image-explorer-hotspot-reveal').each( + (index, el)-> + if $(el).css('display') == 'block' + visible_counter = visible_counter + 1 + ) + expect(visible_counter).toBe(0) + + it 'clicking on a hotspot will reveal an overlay and hide all others', -> + # disable test for now as we figure out the Jasmine/css relationship + return + $('.image-explorer-hotspot').each( + (index, el)-> + el.click() + reveal = $(el).find('.image-explorer-hotspot-reveal') + expect(reveal.css('display')).toBe('block'); + + visible_counter = 0 + $('.image-explorer-hotspot-reveal').each( + (index, el)-> + if $(el).css('display') == 'block' + visible_counter = visible_counter + 1 + ) + expect(visible_counter).toBe(1) + ) diff --git a/common/lib/xmodule/xmodule/js/src/image_explorer/display.coffee b/common/lib/xmodule/xmodule/js/src/image_explorer/display.coffee new file mode 100644 index 000000000000..e642ee50e193 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/image_explorer/display.coffee @@ -0,0 +1,49 @@ +class @ImageExplorer + # The client side code for ImageExplorer xModule + + constructor: (element) -> + @el = $(element).find('.image-explorer-xmodule-wrapper') + @bind() + + $: (selector) -> + $(selector, @el) + + bind: => + @$('.image-explorer-hotspot').click @hotspot_clicked + @$('.image-explorer-close-reveal').click @hotspot_closed_clicked + @el.click @close_hotspots + + close_hotspots: => + @$('.image-explorer-hotspot-reveal').css('display', 'none') + return + + hotspot_clicked: (eventObj) => + eventObj.preventDefault() + eventObj.stopPropagation() + + @close_hotspots() + target = @$(eventObj.currentTarget) + target_position_left = target.position().left + hotspot_image_width = target.outerWidth() + + reveal = target.find('.image-explorer-hotspot-reveal') + + # see the width of the hotspot to show, see if it goes too far to the right + # if so then have the reveal go to the left of the hotspot icon + reveal_width = reveal.outerWidth() + parent_wrapper = reveal.parents('.image-explorer-hotspot') + image_element = parent_wrapper.siblings('.image-explorer-background') + image_width = image_element.outerWidth() + + if (target_position_left + reveal_width > image_width) and (target_position_left - reveal_width - hotspot_image_width > 0) + reveal.css('margin-left', '-' + (reveal_width + hotspot_image_width) + 'px') + + # show the reveal + reveal.css('display', 'block') + return + + hotspot_closed_clicked: (eventObj) => + eventObj.preventDefault() + eventObj.stopPropagation() + @close_hotspots() + return diff --git a/common/static/images/image-explorer-hotspot-sprite.png b/common/static/images/image-explorer-hotspot-sprite.png new file mode 100644 index 000000000000..8a800536f540 Binary files /dev/null and b/common/static/images/image-explorer-hotspot-sprite.png differ diff --git a/common/static/images/image-explorer-reveal-close.png b/common/static/images/image-explorer-reveal-close.png new file mode 100644 index 000000000000..2e2489cc3eb8 Binary files /dev/null and b/common/static/images/image-explorer-reveal-close.png differ diff --git a/lms/djangoapps/api_manager/__init__.py b/lms/djangoapps/api_manager/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/api_manager/courses_urls.py b/lms/djangoapps/api_manager/courses_urls.py new file mode 100644 index 000000000000..17f5b1fb686e --- /dev/null +++ b/lms/djangoapps/api_manager/courses_urls.py @@ -0,0 +1,22 @@ +""" +Courses API URI specification +The order of the URIs really matters here, due to the slash characters present in the identifiers +""" +from django.conf.urls import patterns, url + +urlpatterns = patterns( + 'api_manager.courses_views', + url(r'/*$^', 'courses_list'), + url(r'^(?P[^/]+/[^/]+/[^/]+)/modules/(?P[a-zA-Z0-9/_:]+)/submodules/*$', 'modules_list'), + url(r'^(?P[^/]+/[^/]+/[^/]+)/modules/(?P[a-zA-Z0-9/_:]+)$', 'modules_detail'), + url(r'^(?P[^/]+/[^/]+/[^/]+)/modules/*$', 'modules_list'), + url(r'^(?P[^/]+/[^/]+/[^/]+)/groups/(?P[0-9]+)$', 'courses_groups_detail'), + url(r'^(?P[^/]+/[^/]+/[^/]+)/groups/*$', 'courses_groups_list'), + url(r'^(?P[^/]+/[^/]+/[^/]+)/overview$', 'course_overview'), + url(r'^(?P[^/]+/[^/]+/[^/]+)/updates$', 'course_updates'), + url(r'^(?P[^/]+/[^/]+/[^/]+)/static_tabs/(?P[a-zA-Z0-9/_:]+)$', 'static_tab_detail'), + url(r'^(?P[^/]+/[^/]+/[^/]+)/static_tabs$', 'static_tabs_list'), + url(r'^(?P[^/]+/[^/]+/[^/]+)/users$', 'course_users_list'), + url(r'^(?P[^/]+/[^/]+/[^/]+)/tree/(?P[0-9]+)$', 'course_tree'), + url(r'^(?P[^/]+/[^/]+/[^/]+)$', 'courses_detail'), +) diff --git a/lms/djangoapps/api_manager/courses_views.py b/lms/djangoapps/api_manager/courses_views.py new file mode 100644 index 000000000000..6160abba4e1b --- /dev/null +++ b/lms/djangoapps/api_manager/courses_views.py @@ -0,0 +1,655 @@ +""" API implementation for course-oriented interactions. """ + +from django.contrib.auth.models import Group, User +from django.core.exceptions import ObjectDoesNotExist +from lxml import etree +from StringIO import StringIO +from collections import OrderedDict +import logging + +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.response import Response + +from api_manager.permissions import ApiKeyHeaderPermission +from api_manager.models import CourseGroupRelationship +from xmodule.modulestore.django import modulestore +from xmodule.modulestore import Location, InvalidLocationError + +from courseware.courses import get_course_about_section, get_course_info_section, get_course_by_id +from courseware.views import get_static_tab_contents +from student.models import CourseEnrollment, CourseEnrollmentAllowed + +log = logging.getLogger(__name__) + +def _generate_base_uri(request): + """ + Constructs the protocol:host:path component of the resource uri + """ + protocol = 'http' + if request.is_secure(): + protocol = protocol + 's' + resource_uri = '{}://{}{}'.format( + protocol, + request.get_host(), + request.path + ) + return resource_uri + + +def _get_module_submodules(module, submodule_type=None): + """ + Parses the provided module looking for child modules + Matches on submodule type (category) when specified + """ + submodules = [] + if hasattr(module, 'children'): + child_modules = module.get_children() + for child_module in child_modules: + if submodule_type: + if getattr(child_module, 'category') == submodule_type: + submodules.append(child_module) + else: + submodules.append(child_module) + return submodules + + +def _serialize_module(request, course_id, module): + """ + Loads the specified module data into the response dict + This should probably evolve to use DRF serializers + """ + data = {} + + if getattr(module, 'id') == course_id: + module_id = module.id + else: + module_id = module.location.url() + data['id'] = module_id + + if hasattr(module, 'display_name'): + data['name'] = module.display_name + + data['category'] = module.location.category + + protocol = 'http' + if request.is_secure(): + protocol = protocol + 's' + module_uri = '{}://{}/api/courses/{}'.format( + protocol, + request.get_host(), + course_id.encode('utf-8') + ) + + # Some things we do only if the module is a course + if (course_id == module_id): + data['number'] = module.location.course + data['org'] = module.location.org + + # Other things we do only if the module is not a course + else: + module_uri = '{}/modules/{}'.format(module_uri, module_id) + data['uri'] = module_uri + + return data + + +def _serialize_module_submodules(request, course_id, submodules): + """ + Loads the specified module submodule data into the response dict + This should probably evolve to use DRF serializers + """ + data = [] + if submodules: + for submodule in submodules: + submodule_data = _serialize_module( + request, + course_id, + submodule + ) + data.append(submodule_data) + return data + + +@api_view(['GET']) +@permission_classes((ApiKeyHeaderPermission,)) +def modules_list(request, course_id, module_id=None): + """ + GET retrieves the list of submodules for a given module + We don't know where in the module hierarchy we are -- could even be the top + """ + if module_id is None: + module_id = course_id + response_data = [] + submodule_type = request.QUERY_PARAMS.get('type', None) + store = modulestore() + if course_id != module_id: + try: + module = store.get_instance(course_id, Location(module_id)) + except InvalidLocationError: + module = None + else: + module = store.get_course(course_id) + if module: + submodules = _get_module_submodules(module, submodule_type) + response_data = _serialize_module_submodules( + request, + course_id, + submodules + ) + status_code = status.HTTP_200_OK + else: + status_code = status.HTTP_404_NOT_FOUND + return Response(response_data, status=status_code) + + +@api_view(['GET']) +@permission_classes((ApiKeyHeaderPermission,)) +def modules_detail(request, course_id, module_id): + """ + GET retrieves an existing module from the system + """ + store = modulestore() + response_data = {} + submodule_type = request.QUERY_PARAMS.get('type', None) + if course_id != module_id: + try: + module = store.get_instance(course_id, Location(module_id)) + except InvalidLocationError: + module = None + else: + module = store.get_course(course_id) + if module: + response_data = _serialize_module( + request, + course_id, + module + ) + submodules = _get_module_submodules(module, submodule_type) + response_data['modules'] = _serialize_module_submodules( + request, + course_id, + submodules + ) + status_code = status.HTTP_200_OK + else: + status_code = status.HTTP_404_NOT_FOUND + return Response(response_data, status=status_code) + + +@api_view(['GET']) +@permission_classes((ApiKeyHeaderPermission,)) +def courses_list(request): + """ + GET returns the list of available courses + """ + response_data = [] + store = modulestore() + course_descriptors = store.get_courses() + for course_descriptor in course_descriptors: + course_data = _serialize_module( + request, + course_descriptor.id, + course_descriptor + ) + response_data.append(course_data) + return Response(response_data, status=status.HTTP_200_OK) + + +@api_view(['GET']) +@permission_classes((ApiKeyHeaderPermission,)) +def courses_detail(request, course_id): + """ + GET retrieves an existing course from the system + """ + response_data = {} + store = modulestore() + try: + course_descriptor = store.get_course(course_id) + except ValueError: + course_descriptor = None + if course_descriptor: + response_data = _serialize_module( + request, + course_descriptor.id, + course_descriptor + ) + submodules = _get_module_submodules(course_descriptor, None) + response_data['modules'] = _serialize_module_submodules( + request, + course_id, + submodules + ) + status_code = status.HTTP_200_OK + else: + status_code = status.HTTP_404_NOT_FOUND + return Response(response_data, status=status_code) + + +@api_view(['GET']) +@permission_classes((ApiKeyHeaderPermission,)) +def course_tree(request, course_id, depth): + """ + GET retrieves an existing course from the system and returns summary information about the submodules + to the specified depth + """ + response_data = {} + + depth_int = int(depth) + + # note, passing in depth=N optimizes the number of round trips to the database + course_descriptor = get_course_by_id(course_id, depth=depth_int) + if not course_descriptor: + return Response({}, status.HTTP_404_NOT_FOUND) + + def _serialize_node_with_children(descriptor, depth): + data = _serialize_module( + request, + course_descriptor.id, + descriptor + ) + + if depth > 0: + data['modules'] = [] + for child in descriptor.get_children(): + data['modules'].append(_serialize_node_with_children(child, depth-1)) + + return data + + response_data = _serialize_node_with_children(course_descriptor, depth_int) + return Response(response_data) + + +@api_view(['POST']) +@permission_classes((ApiKeyHeaderPermission,)) +def courses_groups_list(request, course_id): + """ + POST creates a new course-group relationship in the system + """ + response_data = {} + group_id = request.DATA['group_id'] + base_uri = _generate_base_uri(request) + store = modulestore() + try: + existing_course = store.get_course(course_id) + except ValueError: + existing_course = None + try: + existing_group = Group.objects.get(id=group_id) + except ObjectDoesNotExist: + existing_group = None + if existing_course and existing_group: + try: + existing_relationship = CourseGroupRelationship.objects.get(course_id=course_id, group=existing_group) + except ObjectDoesNotExist: + existing_relationship = None + if existing_relationship is None: + CourseGroupRelationship.objects.create(course_id=course_id, group=existing_group) + response_data['course_id'] = str(existing_course.id) + response_data['group_id'] = str(existing_group.id) + response_data['uri'] = '{}/{}'.format(base_uri, existing_group.id) + response_status = status.HTTP_201_CREATED + else: + response_data['message'] = "Relationship already exists." + response_status = status.HTTP_409_CONFLICT + else: + response_status = status.HTTP_404_NOT_FOUND + return Response(response_data, status=response_status) + + +@api_view(['GET', 'DELETE']) +@permission_classes((ApiKeyHeaderPermission,)) +def courses_groups_detail(request, course_id, group_id): + """ + GET retrieves an existing course-group relationship from the system + DELETE removes/inactivates/etc. an existing course-group relationship + """ + if request.method == 'GET': + response_data = {} + base_uri = _generate_base_uri(request) + response_data['uri'] = base_uri + response_data['course_id'] = course_id + response_data['group_id'] = group_id + store = modulestore() + try: + existing_course = store.get_course(course_id) + except ValueError: + existing_course = None + try: + existing_group = Group.objects.get(id=group_id) + except ObjectDoesNotExist: + existing_group = None + if existing_course and existing_group: + try: + existing_relationship = CourseGroupRelationship.objects.get(course_id=course_id, group=existing_group) + except ObjectDoesNotExist: + existing_relationship = None + if existing_relationship: + response_status = status.HTTP_200_OK + else: + response_status = status.HTTP_404_NOT_FOUND + else: + response_status = status.HTTP_404_NOT_FOUND + return Response(response_data, status=response_status) + elif request.method == 'DELETE': + try: + existing_group = Group.objects.get(id=group_id) + existing_relationship = CourseGroupRelationship.objects.get(course_id=course_id, group=existing_group).delete() + except ObjectDoesNotExist: + pass + return Response({}, status=status.HTTP_204_NO_CONTENT) + + +def _inner_content(tag): + """ + Helper method + """ + inner_content = None + if tag is not None: + inner_content = tag.text if tag.text else u'' + inner_content += u''.join(etree.tostring(e) for e in tag) + inner_content += tag.tail if tag.tail else u'' + + return inner_content + + +def _parse_overview_html(html): + """ + Helper method to break up the course about HTML into components + """ + result = {} + + parser = etree.HTMLParser() + tree = etree.parse(StringIO(html), parser) + + sections = tree.findall('/body/section') + + result = [] + for section in sections: + section_class = section.get('class') + if section_class: + section_data = OrderedDict() + section_data['class'] = section_class + + articles = section.findall('article') + if articles: + section_data['articles'] = [] + for article in articles: + article_class = article.get('class') + if article_class: + article_data = OrderedDict() + article_data['class'] = article_class + + if article_class == "teacher": + + name_element = article.find('h3') + if name_element is not None: + article_data['name'] = name_element.text + + image_element = article.find("./div[@class='teacher-image']/img") + if image_element is not None: + article_data['image_src'] = image_element.get('src') + + bios = article.findall('p') + bio_html = '' + for bio in bios: + bio_html += etree.tostring(bio) + + if bio_html: + article_data['bio'] = bio_html + else: + article_data['body'] = _inner_content(article) + + section_data['articles'].append(article_data) + else: + section_data['body'] = _inner_content(section) + + result.append(section_data) + + return result + + +@api_view(['GET']) +@permission_classes((ApiKeyHeaderPermission,)) +def course_overview(request, course_id): + """ + GET retrieves the course overview module, which - in MongoDB - is stored with the following + naming convention {"_id.org":"i4x", "_id.course":, "_id.category":"about", "_id.name":"overview"} + """ + store = modulestore() + response_data = OrderedDict() + + try: + course_module = store.get_course(course_id) + if not course_module: + return Response({}, status=status.HTTP_404_NOT_FOUND) + + content = get_course_about_section(course_module, 'overview') + + if request.GET.get('parse') and request.GET.get('parse') in ['True', 'true']: + try: + response_data['sections'] = _parse_overview_html(content) + except: + log.exception( + u"Error prasing course overview. Content = {0}".format( + content + )) + return Response({'err': 'could_not_parse'}, status=status.HTTP_409_CONFLICT) + else: + response_data['overview_html'] = content + + except InvalidLocationError: + return Response({}, status=status.HTTP_404_NOT_FOUND) + + return Response(response_data) + + +def _parse_updates_html(html): + """ + Helper method to break up the course updates HTML into components + """ + result = {} + + parser = etree.HTMLParser() + tree = etree.parse(StringIO(html), parser) + + # get all of the individual postings + postings = tree.findall('/body/ol/li') + + result = [] + for posting in postings: + posting_data = {} + posting_date_element = posting.find('h2') + if posting_date_element is not None: + posting_data['date'] = posting_date_element.text + + content = u'' + for el in posting: + # note, we can't delete or skip over the date element in + # the HTML tree because there might be some tailing content + if el != posting_date_element: + content += etree.tostring(el) + else: + content += el.tail if el.tail else u'' + + posting_data['content'] = content.strip() + result.append(posting_data) + + return result + + +@api_view(['GET']) +@permission_classes((ApiKeyHeaderPermission,)) +def course_updates(request, course_id): + """ + GET retrieves the course overview module, which - in MongoDB - is stored with the following + naming convention {"_id.org":"i4x", "_id.course":, "_id.category":"course_info", "_id.name":"updates"} + """ + store = modulestore() + response_data = OrderedDict() + + try: + course_module = store.get_course(course_id) + if not course_module: + return Response({}, status=status.HTTP_404_NOT_FOUND) + + content = get_course_info_section(request, course_module, 'updates') + + if not content: + return Response({}, status=status.HTTP_404_NOT_FOUND) + + if request.GET.get('parse') and request.GET.get('parse') in ['True', 'true']: + try: + response_data['postings'] = _parse_updates_html(content) + except: + log.exception( + u"Error prasing course updates. Content = {0}".format( + content + )) + return Response({'err': 'could_not_parse'}, status=status.HTTP_409_CONFLICT) + else: + response_data['content'] = content + + except InvalidLocationError: + return Response({}, status=status.HTTP_404_NOT_FOUND) + + return Response(response_data) + + +@api_view(['GET']) +@permission_classes((ApiKeyHeaderPermission,)) +def static_tabs_list(request, course_id): + """ + GET returns an array of Static Tabs inside of a course + """ + store = modulestore() + response_data = OrderedDict() + + try: + course_module = store.get_course(course_id) + if not course_module: + return Response({}, status=status.HTTP_404_NOT_FOUND) + + tabs = [] + for tab in course_module.tabs: + if tab.type == 'static_tab': + tab_data = OrderedDict() + tab_data['id'] = tab.url_slug + tab_data['name'] = tab.name + if request.GET.get('detail') and request.GET.get('detail') in ['True', 'true']: + tab_data['content'] = get_static_tab_contents(request, + course_module, + tab, + wrap_xmodule_display=False + ) + + tabs.append(tab_data) + + response_data['tabs'] = tabs + + except InvalidLocationError: + return Response({}, status=status.HTTP_404_NOT_FOUND) + + return Response(response_data) + + +@api_view(['GET']) +@permission_classes((ApiKeyHeaderPermission,)) +def static_tab_detail(request, course_id, tab_id): + """ + GET returns an array of Static Tabs inside of a course + """ + store = modulestore() + response_data = OrderedDict() + + try: + course_module = store.get_course(course_id) + if not course_module: + return Response({}, status=status.HTTP_404_NOT_FOUND) + + for tab in course_module.tabs: + if tab.type == 'static_tab' and tab.url_slug == tab_id: + response_data['id'] = tab.url_slug + response_data['name'] = tab.name + response_data['content'] = get_static_tab_contents(request, + course_module, + tab, + wrap_xmodule_display=False + ) + + except InvalidLocationError: + return Response({}, status=status.HTTP_404_NOT_FOUND) + + if not response_data: + return Response({}, status=status.HTTP_404_NOT_FOUND) + + return Response(response_data) + +@api_view(['GET', 'POST', 'DELETE']) +@permission_classes((ApiKeyHeaderPermission,)) +def course_users_list(request, course_id): + """ + GET returns a list of users enrolled in the course_id + POST enrolls a student in the course. Note, this can be a user_id or just an email, in case + the user does not exist in the system + """ + store = modulestore() + response_data = OrderedDict() + + try: + # find the course + course_module = store.get_course(course_id) + if not course_module: + return Response({}, status=status.HTTP_404_NOT_FOUND) + except InvalidLocationError: + return Response({}, status=status.HTTP_404_NOT_FOUND) + + if request.method == 'GET': + # Get a list of all enrolled students + users = CourseEnrollment.users_enrolled_in(course_id) + response_data['enrollments'] = [] + for user in users: + user_data = OrderedDict() + user_data['id'] = user.id + user_data['email'] = user.email + user_data['username'] = user.username + # @TODO: Should we create a URI resourse that points to user?!? But that's in a different URL subpath + response_data['enrollments'].append(user_data) + + # Then list all enrollments which are pending. These are enrollments for students that have not yet + # created an account + pending_enrollments = CourseEnrollmentAllowed.objects.filter(course_id=course_id) + if pending_enrollments: + response_data['pending_enrollments'] = [] + for cea in pending_enrollments: + response_data['pending_enrollments'].append(cea.email) + return Response(response_data) + + elif request.method == 'POST': + if 'user_id' in request.DATA: + user_id = request.DATA['user_id'] + try: + existing_user = User.objects.get(id=user_id) + CourseEnrollment.enroll(existing_user, course_id) + except ObjectDoesNotExist: + return Response({'err': 'user_does_not_exist'}, status=status.HTTP_400_BAD_REQUEST) + elif 'email' in request.DATA: + # If caller passed in an email, then let's look up user by email address + # if it doesn't exist then we need to assume that the student does not exist + # in our database and that the instructor is pre-enrolling ment + email = request.DATA['email'] + try: + existing_user = User.objects.get(email=email) + CourseEnrollment.enroll(existing_user, course_id) + except ObjectDoesNotExist: + if not request.DATA.get('allow_pending', False): + return Response({'err': 'user_does_not_exist'}, status=status.HTTP_400_BAD_REQUEST) + + # In this case we can pre-enroll a non-existing student. This is what the + # CourseEnrollmentAllowed table is for + # NOTE: This logic really should live in CourseEnrollment..... + cea, _ = CourseEnrollmentAllowed.objects.get_or_create(course_id=course_id, email=email) + cea.auto_enroll = True + cea.save() + + return Response({}, status.HTTP_201_CREATED) diff --git a/lms/djangoapps/api_manager/groups_urls.py b/lms/djangoapps/api_manager/groups_urls.py new file mode 100644 index 000000000000..6388c5e19bb7 --- /dev/null +++ b/lms/djangoapps/api_manager/groups_urls.py @@ -0,0 +1,13 @@ +""" Groups API URI specification """ +from django.conf.urls import patterns, url + +urlpatterns = patterns('api_manager.groups_views', + url(r'/*$^', 'group_list'), + url(r'^(?P[0-9]+)$', 'group_detail'), + url(r'^(?P[0-9]+)/courses/*$', 'group_courses_list'), + url(r'^(?P[0-9]+)/courses/(?P[a-zA-Z0-9/_:]+)$', 'group_courses_detail'), + url(r'^(?P[0-9]+)/users/*$', 'group_users_list'), + url(r'^(?P[0-9]+)/users/(?P[0-9]+)$', 'group_users_detail'), + url(r'^(?P[0-9]+)/groups/*$', 'group_groups_list'), + url(r'^(?P[0-9]+)/groups/(?P[0-9]+)$', 'group_groups_detail'), + ) diff --git a/lms/djangoapps/api_manager/groups_views.py b/lms/djangoapps/api_manager/groups_views.py new file mode 100644 index 000000000000..0ce739d06ac2 --- /dev/null +++ b/lms/djangoapps/api_manager/groups_views.py @@ -0,0 +1,416 @@ +""" API implementation for group-oriented interactions. """ +import uuid +import json +from collections import OrderedDict + +from django.contrib.auth.models import Group, User +from django.core.exceptions import ObjectDoesNotExist +from django.utils import timezone + +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.response import Response + +from api_manager.permissions import ApiKeyHeaderPermission +from api_manager.models import GroupRelationship, CourseGroupRelationship, GroupProfile +from xmodule.modulestore.django import modulestore +from xmodule.modulestore import Location, InvalidLocationError + +RELATIONSHIP_TYPES = {'hierarchical': 'h', 'graph': 'g'} + + +def _generate_base_uri(request): + """ + Constructs the protocol:host:path component of the resource uri + """ + protocol = 'http' + if request.is_secure(): + protocol = protocol + 's' + resource_uri = '{}://{}{}'.format( + protocol, + request.get_host(), + request.path + ) + return resource_uri + + +@api_view(['GET', 'POST']) +@permission_classes((ApiKeyHeaderPermission,)) +def group_list(request): + """ + GET retrieves a list of groups in the system filtered by type + POST creates a new group in the system + """ + if request.method == 'GET': + if not 'type' in request.GET: + return Response({}, status=status.HTTP_400_BAD_REQUEST) + + response_data = [] + profiles = GroupProfile.objects.filter(group_type=request.GET['type']) + for profile in profiles: + item_data = OrderedDict() + item_data['group_id'] = profile.group_id + item_data['group_type'] = profile.group_type + item_data['data'] = json.loads(profile.data) + response_data.append(item_data) + + return Response(response_data) + elif request.method == 'POST': + response_data = {} + base_uri = _generate_base_uri(request) + # Group name must be unique, but we need to support dupes + group = Group.objects.create(name=str(uuid.uuid4())) + original_group_name = request.DATA['name'] + + group.name = '{:04d}: {}'.format(group.id, original_group_name) + group.record_active = True + group.record_date_created = timezone.now() + group.record_date_modified = timezone.now() + group.save() + + # Relationship model also allows us to use duplicate names + GroupRelationship.objects.create(name=original_group_name, group_id=group.id, parent_group=None) + + # allow for optional meta information about groups, this will end up in the GroupProfile table + group_type = request.DATA.get('group_type') + data = request.DATA.get('data') + + if group_type or data: + profile, _ = GroupProfile.objects.get_or_create(group_id=group.id, group_type=group_type, data=data) + + response_data = {'id': group.id, 'name': original_group_name} + base_uri = _generate_base_uri(request) + response_data['uri'] = '{}/{}'.format(base_uri, group.id) + response_status = status.HTTP_201_CREATED + return Response(response_data, status=response_status) + + +@api_view(['GET', 'POST']) +@permission_classes((ApiKeyHeaderPermission,)) +def group_detail(request, group_id): + """ + GET retrieves an existing group from the system + """ + + response_data = {} + base_uri = _generate_base_uri(request) + try: + existing_group = Group.objects.get(id=group_id) + existing_group_relationship = GroupRelationship.objects.get(group_id=group_id) + except ObjectDoesNotExist: + return Response({}, status.HTTP_404_NOT_FOUND) + + if request.method == 'GET': + response_data['name'] = existing_group_relationship.name + response_data['id'] = existing_group.id + response_data['uri'] = base_uri + response_data['resources'] = [] + resource_uri = '{}/users'.format(base_uri) + response_data['resources'].append({'uri': resource_uri}) + resource_uri = '{}/groups'.format(base_uri) + response_data['resources'].append({'uri': resource_uri}) + + # see if there is an (optional) GroupProfile + try: + existing_group_profile = GroupProfile.objects.get(group_id=group_id) + if existing_group_profile.group_type: + response_data['group_type'] = existing_group_profile.group_type + data = existing_group_profile.data + if data: + response_data['data'] = json.loads(data) + except ObjectDoesNotExist: + pass + + response_status = status.HTTP_200_OK + + return Response(response_data, status=response_status) + elif request.method == 'POST': + # update GroupProfile data + + group_type = request.DATA.get('group_type') + data = request.DATA.get('data') + + if not group_type and not data: + return Response({}, status.HTTP_400_BAD_REQUEST) + + profile, _ = GroupProfile.objects.get_or_create(group_id=group_id) + profile.group_type = group_type + profile.data = data + profile.save() + + return Response({}) + + +@api_view(['POST']) +@permission_classes((ApiKeyHeaderPermission,)) +def group_users_list(request, group_id): + """ + POST creates a new group-user relationship in the system + """ + response_data = {} + group_id = group_id + user_id = request.DATA['user_id'] + base_uri = _generate_base_uri(request) + try: + existing_group = Group.objects.get(id=group_id) + existing_user = User.objects.get(id=user_id) + except ObjectDoesNotExist: + existing_group = None + existing_user = None + if existing_group and existing_user: + try: + existing_relationship = Group.objects.get(user=existing_user) + except ObjectDoesNotExist: + existing_relationship = None + if existing_relationship is None: + existing_group.user_set.add(existing_user.id) + response_data['uri'] = '{}/{}'.format(base_uri, existing_user.id) + response_data['group_id'] = str(existing_group.id) + response_data['user_id'] = str(existing_user.id) + response_status = status.HTTP_201_CREATED + else: + response_data['uri'] = '{}/{}'.format(base_uri, existing_user.id) + response_data['message'] = "Relationship already exists." + response_status = status.HTTP_409_CONFLICT + else: + response_status = status.HTTP_404_NOT_FOUND + return Response(response_data, status=response_status) + + +@api_view(['GET', 'DELETE']) +@permission_classes((ApiKeyHeaderPermission,)) +def group_users_detail(request, group_id, user_id): + """ + GET retrieves an existing group-user relationship from the system + DELETE removes/inactivates/etc. an existing group-user relationship + """ + if request.method == 'GET': + response_data = {} + base_uri = _generate_base_uri(request) + try: + existing_group = Group.objects.get(id=group_id) + existing_relationship = existing_group.user_set.get(id=user_id) + except ObjectDoesNotExist: + existing_group = None + existing_relationship = None + if existing_group and existing_relationship: + response_data['group_id'] = existing_group.id + response_data['user_id'] = existing_relationship.id + response_data['uri'] = base_uri + response_status = status.HTTP_200_OK + else: + response_status = status.HTTP_404_NOT_FOUND + return Response(response_data, status=response_status) + elif request.method == 'DELETE': + try: + existing_group = Group.objects.get(id=group_id) + existing_group.user_set.remove(user_id) + existing_group.save() + except ObjectDoesNotExist: + pass + return Response({}, status=status.HTTP_204_NO_CONTENT) + + +@api_view(['POST', 'GET']) +@permission_classes((ApiKeyHeaderPermission,)) +def group_groups_list(request, group_id): + """ + POST creates a new group-group relationship in the system + GET retrieves the existing group-group relationships for the specified group + """ + if request.method == 'POST': + response_data = {} + to_group_id = request.DATA['group_id'] + relationship_type = request.DATA['relationship_type'] + base_uri = _generate_base_uri(request) + response_data['uri'] = '{}/{}'.format(base_uri, to_group_id) + response_data['group_id'] = str(to_group_id) + response_data['relationship_type'] = relationship_type + try: + from_group_relationship = GroupRelationship.objects.get(group__id=group_id) + to_group_relationship = GroupRelationship.objects.get(group__id=to_group_id) + except ObjectDoesNotExist: + from_group_relationship = None + to_group_relationship = None + if from_group_relationship and to_group_relationship: + response_status = status.HTTP_201_CREATED + if relationship_type == RELATIONSHIP_TYPES['hierarchical']: + to_group_relationship.parent_group = from_group_relationship + to_group_relationship.save() + elif relationship_type == RELATIONSHIP_TYPES['graph']: + from_group_relationship.add_linked_group_relationship(to_group_relationship) + else: + response_data['message'] = "Relationship type '%s' not currently supported" % relationship_type + response_data['field_conflict'] = 'relationship_type' + response_status = status.HTTP_406_NOT_ACCEPTABLE + else: + response_status = status.HTTP_404_NOT_FOUND + return Response(response_data, status=response_status) + elif request.method == 'GET': + try: + from_group_relationship = GroupRelationship.objects.get(group__id=group_id) + except ObjectDoesNotExist: + from_group_relationship = None + response_data = [] + if from_group_relationship: + base_uri = _generate_base_uri(request) + child_groups = GroupRelationship.objects.filter(parent_group_id=group_id) + if child_groups: + for group in child_groups: + response_data.append({ + "id": group.group_id, + "relationship_type": RELATIONSHIP_TYPES['hierarchical'], + "uri": '{}/{}'.format(base_uri, group.group.id) + }) + linked_groups = from_group_relationship.get_linked_group_relationships() + if linked_groups: + for group in linked_groups: + response_data.append({ + "id": group.to_group_relationship_id, + "relationship_type": RELATIONSHIP_TYPES['graph'], + "uri": '{}/{}'.format(base_uri, group.to_group_relationship_id) + }) + response_status = status.HTTP_200_OK + else: + response_status = status.HTTP_404_NOT_FOUND + return Response(response_data, status=response_status) + + +@api_view(['GET', 'DELETE']) +@permission_classes((ApiKeyHeaderPermission,)) +def group_groups_detail(request, group_id, related_group_id): + """ + GET retrieves an existing group-group relationship from the system + DELETE removes/inactivates/etc. an existing group-group relationship + """ + if request.method == 'GET': + response_data = {} + base_uri = _generate_base_uri(request) + response_data['uri'] = base_uri + response_data['from_group_id'] = group_id + response_data['to_group_id'] = related_group_id + response_status = status.HTTP_404_NOT_FOUND + from_group_relationship = GroupRelationship.objects.get(group__id=group_id) + if from_group_relationship: + to_group_relationship = GroupRelationship.objects.get(group__id=related_group_id) + if to_group_relationship and str(to_group_relationship.parent_group_id) == str(group_id): + response_data['relationship_type'] = RELATIONSHIP_TYPES['hierarchical'] + response_status = status.HTTP_200_OK + else: + to_group = Group.objects.get(id=to_group_relationship.group_id) + linked_group_exists = from_group_relationship.check_linked_group_relationship(to_group, symmetrical=True) + if linked_group_exists: + response_data['relationship_type'] = RELATIONSHIP_TYPES['graph'] + response_status = status.HTTP_200_OK + return Response(response_data, response_status) + elif request.method == 'DELETE': + try: + from_group_relationship = GroupRelationship.objects.get(group__id=group_id) + except ObjectDoesNotExist: + from_group_relationship = None + try: + to_group_relationship = GroupRelationship.objects.get(group__id=related_group_id) + except ObjectDoesNotExist: + to_group = None + to_group_relationship = None + if from_group_relationship: + if to_group_relationship: + if to_group_relationship.parent_group_id is from_group_relationship.group_id: + to_group_relationship.parent_group_id = None + to_group_relationship.save() + else: + from_group_relationship.remove_linked_group_relationship(to_group_relationship) + from_group_relationship.save() + # No 'else' clause here -> It's ok if we didn't find a match + response_status = status.HTTP_204_NO_CONTENT + else: + response_status = status.HTTP_404_NOT_FOUND + return Response({}, status=response_status) + + +@api_view(['GET', 'POST']) +@permission_classes((ApiKeyHeaderPermission,)) +def group_courses_list(request, group_id): + """ + GET returns all courses that has a relationship to the group + POST creates a new group-course relationship in the system + """ + response_data = {} + + try: + existing_group = Group.objects.get(id=group_id) + except ObjectDoesNotExist: + return Response({}, status.HTTP_404_NOT_FOUND) + + store = modulestore() + + if request.method == 'GET': + members = CourseGroupRelationship.objects.filter(group=existing_group) + response_data['courses'] = [] + for member in members: + course = store.get_course(member.course_id) + course_data = { + 'course_id': member.course_id, + 'display_name': course.display_name + } + response_data['courses'].append(course_data) + response_status = status.HTTP_200_OK + else: + course_id = request.DATA['course_id'] + + base_uri = _generate_base_uri(request) + response_data['uri'] = '{}/{}'.format(base_uri, course_id) + + existing_course = store.get_course(course_id) + if not existing_course: + return Response({}, status.HTTP_404_NOT_FOUND) + + try: + existing_relationship = CourseGroupRelationship.objects.get(course_id=course_id, group=existing_group) + except ObjectDoesNotExist: + existing_relationship = None + + if existing_relationship is None: + new_relationship = CourseGroupRelationship.objects.create(course_id=course_id, group=existing_group) + response_data['group_id'] = str(new_relationship.group_id) + response_data['course_id'] = str(new_relationship.course_id) + response_status = status.HTTP_201_CREATED + else: + response_data['message'] = "Relationship already exists." + response_status = status.HTTP_409_CONFLICT + + return Response(response_data, status=response_status) + + +@api_view(['GET', 'DELETE']) +@permission_classes((ApiKeyHeaderPermission,)) +def group_courses_detail(request, group_id, course_id): + """ + GET retrieves an existing group-course relationship from the system + DELETE removes/inactivates/etc. an existing group-course relationship + """ + if request.method == 'GET': + response_data = {} + base_uri = _generate_base_uri(request) + response_data['uri'] = base_uri + try: + existing_group = Group.objects.get(id=group_id) + existing_relationship = CourseGroupRelationship.objects.get(course_id=course_id, group=existing_group) + except ObjectDoesNotExist: + existing_group = None + existing_relationship = None + if existing_group and existing_relationship: + response_data['group_id'] = existing_group.id + response_data['course_id'] = existing_relationship.course_id + response_status = status.HTTP_200_OK + else: + response_status = status.HTTP_404_NOT_FOUND + return Response(response_data, status=response_status) + elif request.method == 'DELETE': + try: + existing_group = Group.objects.get(id=group_id) + existing_group.coursegrouprelationship_set.get(course_id=course_id).delete() + existing_group.save() + except ObjectDoesNotExist: + pass + return Response({}, status=status.HTTP_204_NO_CONTENT) diff --git a/lms/djangoapps/api_manager/migrations/0001_initial.py b/lms/djangoapps/api_manager/migrations/0001_initial.py new file mode 100644 index 000000000000..27ac2ef079b2 --- /dev/null +++ b/lms/djangoapps/api_manager/migrations/0001_initial.py @@ -0,0 +1,83 @@ +# -*- 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 'GroupRelationship' + db.create_table('api_manager_grouprelationship', ( + ('group', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['auth.Group'], unique=True, primary_key=True)), + ('name', self.gf('django.db.models.fields.CharField')(max_length=255)), + ('parent_group', self.gf('django.db.models.fields.related.ForeignKey')(default=0, related_name='child_groups', null=True, blank=True, to=orm['api_manager.GroupRelationship'])), + ('record_active', self.gf('django.db.models.fields.BooleanField')(default=True)), + ('record_date_created', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime(2014, 3, 27, 0, 0))), + ('record_date_modified', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)), + )) + db.send_create_signal('api_manager', ['GroupRelationship']) + + # Adding model 'LinkedGroupRelationship' + db.create_table('api_manager_linkedgrouprelationship', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('from_group_relationship', self.gf('django.db.models.fields.related.ForeignKey')(related_name='from_group_relationships', to=orm['api_manager.GroupRelationship'])), + ('to_group_relationship', self.gf('django.db.models.fields.related.ForeignKey')(related_name='to_group_relationships', to=orm['api_manager.GroupRelationship'])), + ('record_active', self.gf('django.db.models.fields.BooleanField')(default=True)), + ('record_date_created', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime(2014, 3, 27, 0, 0))), + ('record_date_modified', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)), + )) + db.send_create_signal('api_manager', ['LinkedGroupRelationship']) + + + def backwards(self, orm): + # Deleting model 'GroupRelationship' + db.delete_table('api_manager_grouprelationship') + + # Deleting model 'LinkedGroupRelationship' + db.delete_table('api_manager_linkedgrouprelationship') + + + models = { + 'api_manager.grouprelationship': { + 'Meta': {'object_name': 'GroupRelationship'}, + 'group': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.Group']", 'unique': 'True', 'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'parent_group': ('django.db.models.fields.related.ForeignKey', [], {'default': '0', 'related_name': "'child_groups'", 'null': 'True', 'blank': 'True', 'to': "orm['api_manager.GroupRelationship']"}), + 'record_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'record_date_created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2014, 3, 27, 0, 0)'}), + 'record_date_modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}) + }, + 'api_manager.linkedgrouprelationship': { + 'Meta': {'object_name': 'LinkedGroupRelationship'}, + 'from_group_relationship': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'from_group_relationships'", 'to': "orm['api_manager.GroupRelationship']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'record_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'record_date_created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2014, 3, 27, 0, 0)'}), + 'record_date_modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'to_group_relationship': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'to_group_relationships'", 'to': "orm['api_manager.GroupRelationship']"}) + }, + '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'}) + }, + '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'}) + } + } + + complete_apps = ['api_manager'] \ No newline at end of file diff --git a/lms/djangoapps/api_manager/migrations/0002_auto__add_coursegrouprelationship__add_groupprofile.py b/lms/djangoapps/api_manager/migrations/0002_auto__add_coursegrouprelationship__add_groupprofile.py new file mode 100644 index 000000000000..976d775f5466 --- /dev/null +++ b/lms/djangoapps/api_manager/migrations/0002_auto__add_coursegrouprelationship__add_groupprofile.py @@ -0,0 +1,91 @@ +# -*- 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 'CourseGroupRelationship' + db.create_table('api_manager_coursegrouprelationship', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('group', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.Group'])), + )) + db.send_create_signal('api_manager', ['CourseGroupRelationship']) + + # Adding model 'GroupProfile' + db.create_table('auth_groupprofile', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('group', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.Group'])), + ('group_type', self.gf('django.db.models.fields.CharField')(max_length=32, null=True, db_index=True)), + ('data', self.gf('django.db.models.fields.TextField')(blank=True)), + )) + db.send_create_signal('api_manager', ['GroupProfile']) + + + def backwards(self, orm): + # Deleting model 'CourseGroupRelationship' + db.delete_table('api_manager_coursegrouprelationship') + + # Deleting model 'GroupProfile' + db.delete_table('auth_groupprofile') + + + models = { + 'api_manager.coursegrouprelationship': { + 'Meta': {'object_name': 'CourseGroupRelationship'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'group': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.Group']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'api_manager.groupprofile': { + 'Meta': {'object_name': 'GroupProfile', 'db_table': "'auth_groupprofile'"}, + 'data': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'group': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.Group']"}), + 'group_type': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'api_manager.grouprelationship': { + 'Meta': {'object_name': 'GroupRelationship'}, + 'group': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.Group']", 'unique': 'True', 'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'parent_group': ('django.db.models.fields.related.ForeignKey', [], {'default': '0', 'related_name': "'child_groups'", 'null': 'True', 'blank': 'True', 'to': "orm['api_manager.GroupRelationship']"}), + 'record_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'record_date_created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2014, 4, 21, 0, 0)'}), + 'record_date_modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}) + }, + 'api_manager.linkedgrouprelationship': { + 'Meta': {'object_name': 'LinkedGroupRelationship'}, + 'from_group_relationship': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'from_group_relationships'", 'to': "orm['api_manager.GroupRelationship']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'record_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'record_date_created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2014, 4, 21, 0, 0)'}), + 'record_date_modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'to_group_relationship': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'to_group_relationships'", 'to': "orm['api_manager.GroupRelationship']"}) + }, + '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'}) + }, + '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'}) + } + } + + complete_apps = ['api_manager'] \ No newline at end of file diff --git a/lms/djangoapps/api_manager/migrations/__init__.py b/lms/djangoapps/api_manager/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/api_manager/models.py b/lms/djangoapps/api_manager/models.py new file mode 100644 index 000000000000..62bb23f7ad62 --- /dev/null +++ b/lms/djangoapps/api_manager/models.py @@ -0,0 +1,110 @@ +# pylint: disable=E1101 + +""" Database ORM models managed by this Django app """ + +from django.contrib.auth.models import Group +from django.db import models +from django.utils import timezone + + +class GroupRelationship(models.Model): + """ + The GroupRelationship model contains information describing the relationships of a group, + which allows us to utilize Django's user/group/permission + models and features instead of rolling our own. + """ + group = models.OneToOneField(Group, primary_key=True) + name = models.CharField(max_length=255) + parent_group = models.ForeignKey('self', + related_name="child_groups", + blank=True, null=True, default=0) + linked_groups = models.ManyToManyField('self', + through="LinkedGroupRelationship", + symmetrical=False, + related_name="linked_to+"), + record_active = models.BooleanField(default=True) + record_date_created = models.DateTimeField(default=timezone.now()) + record_date_modified = models.DateTimeField(auto_now=True) + + def add_linked_group_relationship(self, to_group_relationship, symmetrical=True): + """ Create a new group-group relationship """ + relationship = LinkedGroupRelationship.objects.get_or_create( + from_group_relationship=self, + to_group_relationship=to_group_relationship) + if symmetrical: + # avoid recursion by passing `symm=False` + to_group_relationship.add_linked_group_relationship(self, False) + return relationship + + def remove_linked_group_relationship(self, to_group_relationship, symmetrical=True): + """ Remove an existing group-group relationship """ + LinkedGroupRelationship.objects.filter( + from_group_relationship=self, + to_group_relationship=to_group_relationship).delete() + if symmetrical: + # avoid recursion by passing `symm=False` + to_group_relationship.remove_linked_group_relationship(self, False) + return + + def get_linked_group_relationships(self): + """ Retrieve an existing group-group relationship """ + efferent_relationships = LinkedGroupRelationship.objects.filter(from_group_relationship=self) + matching_relationships = efferent_relationships + return matching_relationships + + def check_linked_group_relationship(self, relationship_to_check, symmetrical=False): + """ Confirm the existence of a possibly-existing group-group relationship """ + query = dict( + to_group_relationships__from_group_relationship=self, + to_group_relationships__to_group_relationship=relationship_to_check, + ) + if symmetrical: + query.update( + from_group_relationships__to_group_relationship=self, + from_group_relationships__from_group_relationship=relationship_to_check, + ) + return GroupRelationship.objects.filter(**query).exists() + + +class LinkedGroupRelationship(models.Model): + """ + The LinkedGroupRelationship model manages self-referential two-way + relationships between group entities via the GroupRelationship model. + Specifying the intermediary table allows for the definition of additional + relationship information + """ + from_group_relationship = models.ForeignKey(GroupRelationship, + related_name="from_group_relationships", + verbose_name="From Group") + to_group_relationship = models.ForeignKey(GroupRelationship, + related_name="to_group_relationships", + verbose_name="To Group") + record_active = models.BooleanField(default=True) + record_date_created = models.DateTimeField(default=timezone.now()) + record_date_modified = models.DateTimeField(auto_now=True) + + +class CourseGroupRelationship(models.Model): + """ + The CourseGroupRelationship model contains information describing the + link between a course and a group. A typical use case for this table + is to manage the courses for an XSeries or other sort of program. + """ + course_id = models.CharField(max_length=255, db_index=True) + group = models.ForeignKey(Group, db_index=True) + + +class GroupProfile(models.Model): + """ + This table will provide additional tables regarding groups. This has a foreign key to + the auth_groups table + """ + + class Meta: + db_table = "auth_groupprofile" + + group = models.ForeignKey(Group, db_index=True) + group_type = models.CharField(null=True, max_length=32, db_index=True) + + data = models.TextField(blank=True) # JSON dictionary for generic key/value pairs + diff --git a/lms/djangoapps/api_manager/permissions.py b/lms/djangoapps/api_manager/permissions.py new file mode 100644 index 000000000000..e57536f3520e --- /dev/null +++ b/lms/djangoapps/api_manager/permissions.py @@ -0,0 +1,52 @@ +""" Permissions classes utilized by Django REST Framework """ +import logging + +from django.conf import settings + +from rest_framework import permissions + +log = logging.getLogger(__name__) + + +class ApiKeyHeaderPermission(permissions.BasePermission): + """ + Check for permissions by matching the configured API key and header + + """ + def has_permission(self, request, view): + """ + If settings.DEBUG is True and settings.EDX_API_KEY is not set or None, + then allow the request. Otherwise, allow the request if and only if + settings.EDX_API_KEY is set and the X-Edx-Api-Key HTTP header is + present in the request and matches the setting. + """ + + debug_enabled = settings.DEBUG + api_key = getattr(settings, "EDX_API_KEY", None) + + # DEBUG mode rules over all else + # Including the api_key check here ensures we don't break the feature locally + if debug_enabled and api_key is None: + log.warn("EDX_API_KEY Override: Debug Mode") + return True + + # If we're not DEBUG, we need a local api key + if api_key is None: + return False + + # The client needs to present the same api key + header_key = request.META.get('HTTP_X_EDX_API_KEY') + if header_key is None: + try: + header_key = request.META['headers'].get('X-Edx-Api-Key') + except KeyError: + return False + if header_key is None: + return False + + # The api key values need to be the same + if header_key != api_key: + return False + + # Allow the request to take place + return True diff --git a/lms/djangoapps/api_manager/serializers.py b/lms/djangoapps/api_manager/serializers.py new file mode 100644 index 000000000000..65dd803ad2e2 --- /dev/null +++ b/lms/djangoapps/api_manager/serializers.py @@ -0,0 +1,14 @@ +""" Django REST Framework Serializers """ + +from django.contrib.auth.models import User + +from rest_framework import serializers + + +class UserSerializer(serializers.ModelSerializer): + """ Serializer for User model interactions """ + class Meta: + """ Serializer/field specification """ + model = User + fields = ("id", "email", "username") + read_only_fields = ("id", "email", "username") diff --git a/lms/djangoapps/api_manager/sessions_urls.py b/lms/djangoapps/api_manager/sessions_urls.py new file mode 100644 index 000000000000..8f9d1e77c05c --- /dev/null +++ b/lms/djangoapps/api_manager/sessions_urls.py @@ -0,0 +1,7 @@ +""" Sessions API URI specification """ +from django.conf.urls import patterns, url + +urlpatterns = patterns('api_manager.sessions_views', + url(r'/*$^', 'session_list'), + url(r'^(?P[a-z0-9]+)$', 'session_detail'), + ) diff --git a/lms/djangoapps/api_manager/sessions_views.py b/lms/djangoapps/api_manager/sessions_views.py new file mode 100644 index 000000000000..6679954f8984 --- /dev/null +++ b/lms/djangoapps/api_manager/sessions_views.py @@ -0,0 +1,117 @@ +# pylint: disable=E1101 + +""" API implementation for session-oriented interactions. """ + +from django.conf import settings +from django.contrib.auth import authenticate, login +from django.contrib.auth import SESSION_KEY, BACKEND_SESSION_KEY, load_backend +from django.contrib.auth.models import AnonymousUser, User +from django.core.exceptions import ObjectDoesNotExist +from django.utils.importlib import import_module +from django.utils.translation import ugettext as _ + +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.response import Response + +from api_manager.permissions import ApiKeyHeaderPermission +from api_manager.serializers import UserSerializer +from student.models import LoginFailures + + +def _generate_base_uri(request): + """ + Constructs the protocol:host:path component of the resource uri + """ + protocol = 'http' + if request.is_secure(): + protocol = protocol + 's' + resource_uri = '{}://{}{}'.format( + protocol, + request.get_host(), + request.path + ) + return resource_uri + + +@api_view(['POST']) +@permission_classes((ApiKeyHeaderPermission,)) +def session_list(request): + """ + POST creates a new system session, supported authentication modes: + 1. Open edX username/password + """ + response_data = {} + base_uri = _generate_base_uri(request) + try: + existing_user = User.objects.get(username=request.DATA['username']) + except ObjectDoesNotExist: + existing_user = None + + # see if account has been locked out due to excessive login failures + if existing_user and LoginFailures.is_feature_enabled(): + if LoginFailures.is_user_locked_out(existing_user): + response_status = status.HTTP_403_FORBIDDEN + response_data['message'] = _('This account has been temporarily locked due to excessive login failures. ' + 'Try again later.') + return Response(response_data, status=response_status) + + if existing_user: + user = authenticate(username=existing_user.username, password=request.DATA['password']) + if user is not None: + + # successful login, clear failed login attempts counters, if applicable + if LoginFailures.is_feature_enabled(): + LoginFailures.clear_lockout_counter(user) + + if user.is_active: + login(request, user) + response_data['token'] = request.session.session_key + response_data['expires'] = request.session.get_expiry_age() + user_dto = UserSerializer(user) + response_data['user'] = user_dto.data + response_data['uri'] = '{}/{}'.format(base_uri, request.session.session_key) + response_status = status.HTTP_201_CREATED + else: + response_status = status.HTTP_403_FORBIDDEN + else: + # tick the failed login counters if the user exists in the database + if LoginFailures.is_feature_enabled(): + LoginFailures.increment_lockout_counter(existing_user) + + response_status = status.HTTP_401_UNAUTHORIZED + else: + response_status = status.HTTP_404_NOT_FOUND + return Response(response_data, status=response_status) + + +@api_view(['GET', 'DELETE']) +@permission_classes((ApiKeyHeaderPermission,)) +def session_detail(request, session_id): + """ + GET retrieves an existing system session + DELETE flushes an existing system session from the system + """ + response_data = {} + base_uri = _generate_base_uri(request) + engine = import_module(settings.SESSION_ENGINE) + session = engine.SessionStore(session_id) + if request.method == 'GET': + try: + user_id = session[SESSION_KEY] + backend_path = session[BACKEND_SESSION_KEY] + backend = load_backend(backend_path) + user = backend.get_user(user_id) or AnonymousUser() + except KeyError: + user = AnonymousUser() + if user.is_authenticated(): + response_data['token'] = session.session_key + response_data['expires'] = session.get_expiry_age() + response_data['uri'] = base_uri + response_data['user_id'] = user.id + return Response(response_data, status=status.HTTP_200_OK) + else: + return Response(response_data, status=status.HTTP_404_NOT_FOUND) + elif request.method == 'DELETE': + session.flush() + return Response(response_data, status=status.HTTP_204_NO_CONTENT) diff --git a/lms/djangoapps/api_manager/system_views.py b/lms/djangoapps/api_manager/system_views.py new file mode 100644 index 000000000000..d8aa544c819d --- /dev/null +++ b/lms/djangoapps/api_manager/system_views.py @@ -0,0 +1,54 @@ +""" BASE API VIEWS """ + +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.response import Response + +from api_manager.permissions import ApiKeyHeaderPermission + + +def _generate_base_uri(request): + """ + Constructs the protocol:host:path component of the resource uri + """ + protocol = 'http' + if request.is_secure(): + protocol = protocol + 's' + resource_uri = '{}://{}{}'.format( + protocol, + request.get_host(), + request.path + ) + return resource_uri + + +@api_view(['GET']) +@permission_classes((ApiKeyHeaderPermission,)) +def system_detail(request): + """Returns top-level descriptive information about the Open edX API""" + base_uri = _generate_base_uri(request) + response_data = {} + response_data['name'] = "Open edX System API" + response_data['description'] = "System interface for managing groups, users, and sessions." + response_data['documentation'] = "http://docs.openedxapi.apiary.io/#get-%2Fapi%2Fsystem" + response_data['uri'] = base_uri + return Response(response_data, status=status.HTTP_200_OK) + + +@api_view(['GET']) +@permission_classes((ApiKeyHeaderPermission,)) +def api_detail(request): + """Returns top-level descriptive information about the Open edX API""" + base_uri = _generate_base_uri(request) + response_data = {} + response_data['name'] = "Open edX API" + response_data['description'] = "Machine interface for interactions with Open edX." + response_data['documentation'] = "http://docs.openedxapi.apiary.io" + response_data['uri'] = base_uri + response_data['resources'] = [] + response_data['resources'].append({'uri': base_uri + 'courses'}) + response_data['resources'].append({'uri': base_uri + 'groups'}) + response_data['resources'].append({'uri': base_uri + 'sessions'}) + response_data['resources'].append({'uri': base_uri + 'system'}) + response_data['resources'].append({'uri': base_uri + 'users'}) + return Response(response_data, status=status.HTTP_200_OK) diff --git a/lms/djangoapps/api_manager/tests/__init__.py b/lms/djangoapps/api_manager/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/api_manager/tests/content.py b/lms/djangoapps/api_manager/tests/content.py new file mode 100644 index 000000000000..033593fd7240 --- /dev/null +++ b/lms/djangoapps/api_manager/tests/content.py @@ -0,0 +1,78 @@ +""" +Some test content strings. Best to keep them out of the test files because they take up a lot of +text space +""" + +from textwrap import dedent + +TEST_COURSE_UPDATES_CONTENT = dedent(""" +
    +
  1. +

    April 18, 2014

    + This does not have a paragraph tag around it +
  2. +
  3. +

    April 17, 2014

    + Some text before paragraph tag

    This is inside paragraph tag

    Some text after tag +
  4. +
  5. +

    April 16, 2014

    + Some text before paragraph tag

    This is inside paragraph tag

    Some text after tag

    one more

    +
  6. +
  7. +

    April 15, 2014

    +

    A perfectly

    formatted piece

    of HTML

    +
  8. +
+""" +) + +TEST_STATIC_TAB1_CONTENT = dedent(""" +
This is static tab1
+""" +) + +TEST_STATIC_TAB2_CONTENT = dedent(""" +
This is static tab2
+""" +) + +TEST_COURSE_OVERVIEW_CONTENT = dedent(""" +
+

About This Course

+

Include your long course description here. The long course description should contain 150-400 words.

+ +

This is paragraph 2 of the long course description. Add more paragraphs as needed. Make sure to enclose them in paragraph tags.

+
+ +
+

Prerequisites

+

Add information about course prerequisites here.

+
+ +
+

Course Staff

+
+
+ Course Staff Image #1 +
+ +

Staff Member #1

+

Biography of instructor/staff member #1

+
+ +
+
+ Course Staff Image #2 +
+ +

Staff Member #2

+

Biography of instructor/staff member #2

+
+
+ +
+

Some text here

+
+ """) + diff --git a/lms/djangoapps/api_manager/tests/test_courses_views.py b/lms/djangoapps/api_manager/tests/test_courses_views.py new file mode 100644 index 000000000000..a5aa0cdbdf31 --- /dev/null +++ b/lms/djangoapps/api_manager/tests/test_courses_views.py @@ -0,0 +1,553 @@ +# pylint: disable=E1103 + +""" +Run these tests @ Devstack: + rake fasttest_lms[common/djangoapps/api_manager/tests/test_group_views.py] +""" +import simplejson as json +import unittest +import uuid +from random import randint + +from django.core.cache import cache +from django.test import TestCase, Client +from django.test.utils import override_settings + +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + +from .content import TEST_COURSE_OVERVIEW_CONTENT, TEST_COURSE_UPDATES_CONTENT +from .content import TEST_STATIC_TAB1_CONTENT, TEST_STATIC_TAB2_CONTENT + +TEST_API_KEY = str(uuid.uuid4()) + + +class SecureClient(Client): + """ Django test client using a "secure" connection. """ + def __init__(self, *args, **kwargs): + kwargs = kwargs.copy() + kwargs.update({'SERVER_PORT': 443, 'wsgi.url_scheme': 'https'}) + super(SecureClient, self).__init__(*args, **kwargs) + + +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) +@override_settings(EDX_API_KEY=TEST_API_KEY) +class CoursesApiTests(TestCase): + """ Test suite for Courses API views """ + + def setUp(self): + self.maxDiff = 3000 + self.test_server_prefix = 'https://testserver' + self.base_courses_uri = '/api/courses' + self.base_groups_uri = '/api/groups' + self.test_group_name = 'Alpha Group' + + self.course = CourseFactory.create() + self.test_data = '{}'.format(str(uuid.uuid4())) + + self.chapter = ItemFactory.create( + category="chapter", + parent_location=self.course.location, + data=self.test_data, + display_name="Overview" + ) + + self.module = ItemFactory.create( + category="videosequence", + parent_location=self.chapter.location, + data=self.test_data, + display_name="Video_Sequence" + ) + + self.submodule = ItemFactory.create( + category="video", + parent_location=self.module.location, + data=self.test_data, + display_name="Video_Resources" + ) + + self.overview = ItemFactory.create( + category="about", + parent_location=self.course.location, + data=TEST_COURSE_OVERVIEW_CONTENT, + display_name="overview" + ) + + self.updates = ItemFactory.create( + category="course_info", + parent_location=self.course.location, + data=TEST_COURSE_UPDATES_CONTENT, + display_name="updates" + ) + + self.static_tab1 = ItemFactory.create( + category="static_tab", + parent_location=self.course.location, + data=TEST_STATIC_TAB1_CONTENT, + display_name="syllabus" + ) + + self.static_tab2 = ItemFactory.create( + category="static_tab", + parent_location=self.course.location, + data=TEST_STATIC_TAB2_CONTENT, + display_name="readings" + ) + + self.test_course_id = self.course.id + self.test_bogus_course_id = 'foo/bar/baz' + self.test_course_name = self.course.display_name + self.test_course_number = self.course.number + self.test_course_org = self.course.org + self.test_chapter_id = self.chapter.id + self.test_module_id = self.module.id + self.test_submodule_id = self.submodule.id + self.base_modules_uri = '/api/courses/' + self.test_course_id + '/modules' + self.base_chapters_uri = self.base_modules_uri + '?type=chapter' + + self.client = SecureClient() + cache.clear() + + def do_get(self, uri): + """Submit an HTTP GET request""" + headers = { + 'Content-Type': 'application/json', + 'X-Edx-Api-Key': str(TEST_API_KEY), + } + response = self.client.get(uri, headers=headers) + return response + + def do_post(self, uri, data): + """Submit an HTTP POST request""" + headers = { + 'X-Edx-Api-Key': str(TEST_API_KEY), + } + json_data = json.dumps(data) + + response = self.client.post(uri, headers=headers, content_type='application/json', data=json_data) + return response + + def do_delete(self, uri): + """Submit an HTTP DELETE request""" + headers = { + 'Content-Type': 'application/json', + 'X-Edx-Api-Key': str(TEST_API_KEY), + } + response = self.client.delete(uri, headers=headers) + return response + + def test_course_list_get(self): + test_uri = self.base_courses_uri + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data), 0) + matched_course = False + for course in response.data: + if matched_course is False and course['id'] == self.test_course_id: + self.assertEqual(course['name'], self.test_course_name) + self.assertEqual(course['number'], self.test_course_number) + self.assertEqual(course['org'], self.test_course_org) + confirm_uri = self.test_server_prefix + test_uri + '/' + course['id'] + self.assertEqual(course['uri'], confirm_uri) + matched_course = True + self.assertTrue(matched_course) + + def test_course_detail_get(self): + test_uri = self.base_courses_uri + '/' + self.test_course_id + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data), 0) + self.assertEqual(response.data['id'], self.test_course_id) + self.assertEqual(response.data['name'], self.test_course_name) + self.assertEqual(response.data['number'], self.test_course_number) + self.assertEqual(response.data['org'], self.test_course_org) + confirm_uri = self.test_server_prefix + test_uri + self.assertEqual(response.data['uri'], confirm_uri) + self.assertGreater(len(response.data['modules']), 0) + + def test_course_detail_get_notfound(self): + test_uri = self.base_courses_uri + '/' + 'p29038cvp9hjwefion' + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + + def test_course_tree_get(self): + # query the course tree to quickly get naviation information + test_uri = self.base_courses_uri + '/' + self.test_course_id + '/tree/2' + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data), 0) + self.assertEqual(response.data['category'], 'course') + self.assertEqual(response.data['name'], self.course.display_name) + self.assertEqual(len(response.data['modules']), 1) + + chapter = response.data['modules'][0] + self.assertEqual(chapter['category'], 'chapter') + self.assertEqual(chapter['name'], 'Overview') + self.assertEqual(len(chapter['modules']), 1) + + sequence = chapter['modules'][0] + self.assertEqual(sequence['category'], 'videosequence') + self.assertEqual(sequence['name'], 'Video_Sequence') + self.assertNotIn('modules', sequence) + + def test_course_tree_get_root(self): + # query the course tree to quickly get naviation information + test_uri = self.base_courses_uri + '/' + self.test_course_id + '/tree/0' + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data), 0) + self.assertEqual(response.data['category'], 'course') + self.assertEqual(response.data['name'], self.course.display_name) + self.assertNotIn('modules', response.data) + + def test_chapter_list_get(self): + test_uri = self.base_chapters_uri + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data), 0) + matched_chapter = False + for chapter in response.data: + if matched_chapter is False and chapter['id'] == self.test_chapter_id: + self.assertIsNotNone(chapter['uri']) + self.assertGreater(len(chapter['uri']), 0) + confirm_uri = self.test_server_prefix + self.base_modules_uri + '/' + chapter['id'] + self.assertEqual(chapter['uri'], confirm_uri) + matched_chapter = True + self.assertTrue(matched_chapter) + + def test_chapter_detail_get(self): + test_uri = self.base_modules_uri + '/' + self.test_chapter_id + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data['id']), 0) + self.assertEqual(response.data['id'], self.test_chapter_id) + confirm_uri = self.test_server_prefix + test_uri + self.assertEqual(response.data['uri'], confirm_uri) + self.assertGreater(len(response.data['modules']), 0) + + def test_modules_list_get(self): + test_uri = self.base_modules_uri + '/' + self.test_module_id + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data), 0) + matched_submodule = False + for submodule in response.data['modules']: + if matched_submodule is False and submodule['id'] == self.test_submodule_id: + self.assertIsNotNone(submodule['uri']) + self.assertGreater(len(submodule['uri']), 0) + confirm_uri = self.test_server_prefix + self.base_modules_uri + '/' + submodule['id'] + self.assertEqual(submodule['uri'], confirm_uri) + matched_submodule = True + self.assertTrue(matched_submodule) + + def test_modules_detail_get(self): + test_uri = self.base_modules_uri + '/' + self.test_module_id + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data), 0) + self.assertEqual(response.data['id'], self.test_module_id) + confirm_uri = self.test_server_prefix + test_uri + self.assertEqual(response.data['uri'], confirm_uri) + self.assertGreater(len(response.data['modules']), 0) + + def test_modules_detail_get_notfound(self): + test_uri = self.base_modules_uri + '/' + '2p38fp2hjfp9283' + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + + def test_modules_list_get_filtered_submodules_for_module(self): + test_uri = self.base_modules_uri + '/' + self.test_module_id + '/submodules?type=video' + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data), 0) + matched_submodule = False + for submodule in response.data: + if matched_submodule is False and submodule['id'] == self.test_submodule_id: + confirm_uri = self.test_server_prefix + self.base_modules_uri + '/' + submodule['id'] + self.assertEqual(submodule['uri'], confirm_uri) + matched_submodule = True + self.assertTrue(matched_submodule) + + def test_modules_list_get_notfound(self): + test_uri = self.base_modules_uri + '/2p38fp2hjfp9283/submodules?type=video' + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + + def test_course_groups_list_post(self): + data = {'name': self.test_group_name} + response = self.do_post(self.base_groups_uri, data) + group_id = response.data['id'] + + test_uri = '{}/{}/groups'.format(self.base_courses_uri, self.test_course_id) + data = {'group_id': group_id} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 201) + + confirm_uri = self.test_server_prefix + test_uri + '/' + str(group_id) + self.assertEqual(response.data['uri'], confirm_uri) + self.assertEqual(response.data['course_id'], str(self.test_course_id)) + self.assertEqual(response.data['group_id'], str(group_id)) + + def test_course_groups_list_post_duplicate(self): + data = {'name': self.test_group_name} + response = self.do_post(self.base_groups_uri, data) + group_id = response.data['id'] + test_uri = '{}/{}/groups'.format(self.base_courses_uri, self.test_course_id) + data = {'group_id': group_id} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 201) + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 409) + + def test_group_courses_list_post_invalid_resources(self): + test_uri = self.base_courses_uri + '/1239878976/groups' + data = {'group_id': "98723896"} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 404) + + def test_course_groups_detail_get(self): + data = {'name': self.test_group_name} + response = self.do_post(self.base_groups_uri, data) + group_id = response.data['id'] + test_uri = '{}/{}/groups'.format(self.base_courses_uri, self.test_course_id) + data = {'group_id': response.data['id']} + response = self.do_post(test_uri, data) + test_uri = response.data['uri'] + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['uri'], test_uri) + self.assertEqual(response.data['course_id'], self.test_course_id) + self.assertEqual(response.data['group_id'], str(group_id)) + + def test_course_groups_detail_delete(self): + data = {'name': self.test_group_name} + response = self.do_post(self.base_groups_uri, data) + test_uri = '{}/{}/groups'.format(self.base_courses_uri, self.test_course_id) + data = {'group_id': response.data['id']} + response = self.do_post(test_uri, data) + test_uri = response.data['uri'] + response = self.do_delete(test_uri) + self.assertEqual(response.status_code, 204) + response = self.do_delete(test_uri) + self.assertEqual(response.status_code, 204) # Idempotent + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + + def test_course_groups_detail_delete_invalid_course(self): + test_uri = '{}/{}/groups/123124'.format(self.base_courses_uri, self.test_bogus_course_id) + response = self.do_delete(test_uri) + self.assertEqual(response.status_code, 204) + + def test_course_groups_detail_delete_invalid_group(self): + test_uri = '{}/{}/groups/123124'.format(self.base_courses_uri, self.test_course_id) + response = self.do_delete(test_uri) + self.assertEqual(response.status_code, 204) + + def test_course_groups_detail_get_undefined(self): + data = {'name': self.test_group_name} + response = self.do_post(self.base_groups_uri, data) + group_id = response.data['id'] + test_uri = '{}/{}/groups/{}'.format(self.base_courses_uri, self.test_course_id, group_id) + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + + def test_get_course_overview_unparsed(self): + test_uri = self.base_courses_uri + '/' + self.test_course_id + '/overview' + + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data), 0) + self.assertEqual(response.data['overview_html'], self.overview.data) + + def _find_item_by_class(self, items, class_name): + for item in items: + if item['class'] == class_name: + return item + return None + + def test_get_course_overview_parsed(self): + test_uri = self.base_courses_uri + '/' + self.test_course_id + '/overview?parse=true' + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data), 0) + sections = response.data['sections'] + self.assertEqual(len(sections), 4) + self.assertIsNotNone(self._find_item_by_class(sections, 'about')) + self.assertIsNotNone(self._find_item_by_class(sections, 'prerequisites')) + self.assertIsNotNone(self._find_item_by_class(sections, 'course-staff')) + self.assertIsNotNone(self._find_item_by_class(sections, 'faq')) + + course_staff = self._find_item_by_class(sections, 'course-staff') + teachers = course_staff['articles'] + self.assertEqual(len(teachers), 2) + self.assertEqual(teachers[0]['name'], "Staff Member #1") + self.assertEqual(teachers[0]['image_src'], "/images/pl-faculty.png") + self.assertIn("

Biography of instructor/staff member #1

", teachers[0]['bio']) + self.assertEqual(teachers[1]['name'], "Staff Member #2") + self.assertEqual(teachers[1]['image_src'], "/images/pl-faculty.png") + self.assertIn("

Biography of instructor/staff member #2

", teachers[1]['bio']) + + about = self._find_item_by_class(sections, 'about') + self.assertGreater(len(about['body']), 0) + prerequisites = self._find_item_by_class(sections, 'prerequisites') + self.assertGreater(len(prerequisites['body']), 0) + faq = self._find_item_by_class(sections, 'faq') + self.assertGreater(len(faq['body']), 0) + + def test_get_course_updates(self): + # first try raw without any parsing + test_uri = self.base_courses_uri + '/' + self.test_course_id + '/updates' + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data), 0) + self.assertEqual(response.data['content'], self.updates.data) + + # then try parsed + test_uri = self.base_courses_uri + '/' + self.test_course_id + '/updates?parse=True' + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data), 0) + + postings = response.data['postings'] + self.assertEqual(len(postings), 4) + self.assertEqual(postings[0]['date'], 'April 18, 2014') + self.assertEqual(postings[0]['content'], 'This does not have a paragraph tag around it') + self.assertEqual(postings[1]['date'], 'April 17, 2014') + self.assertEqual(postings[1]['content'], 'Some text before paragraph tag

This is inside paragraph tag

Some text after tag') + self.assertEqual(postings[2]['date'], 'April 16, 2014') + self.assertEqual(postings[2]['content'], 'Some text before paragraph tag

This is inside paragraph tag

Some text after tag

one more

') + self.assertEqual(postings[3]['date'], 'April 15, 2014') + self.assertEqual(postings[3]['content'], '

A perfectly

formatted piece

of HTML

') + + def test_static_tab_list(self): + test_uri = self.base_courses_uri + '/' + self.test_course_id + '/static_tabs' + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data), 0) + + tabs = response.data['tabs'] + self.assertEqual(len(tabs), 2) + self.assertEqual(tabs[0]['name'], u'syllabus') + self.assertEqual(tabs[0]['id'], u'syllabus') + self.assertEqual(tabs[1]['name'], u'readings') + self.assertEqual(tabs[1]['id'], u'readings') + + # now try when we get the details on the tabs + test_uri = self.base_courses_uri + '/' + self.test_course_id + '/static_tabs?detail=true' + response = self.do_get(test_uri) + + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data), 0) + + tabs = response.data['tabs'] + self.assertEqual(tabs[0]['name'], u'syllabus') + self.assertEqual(tabs[0]['id'], u'syllabus') + self.assertEqual(tabs[0]['content'], self.static_tab1.data) + self.assertEqual(tabs[1]['name'], u'readings') + self.assertEqual(tabs[1]['id'], u'readings') + self.assertEqual(tabs[1]['content'], self.static_tab2.data) + + #try a bogus course_id to test failure case + test_uri = self.base_courses_uri + '/' + self.test_bogus_course_id + '/static_tabs' + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + + def test_static_tab_detail(self): + test_uri = self.base_courses_uri + '/' + self.test_course_id + '/static_tabs/syllabus' + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data), 0) + tab = response.data + self.assertEqual(tab['name'], u'syllabus') + self.assertEqual(tab['id'], u'syllabus') + self.assertEqual(tab['content'], self.static_tab1.data) + + test_uri = self.base_courses_uri + '/' + self.test_course_id + '/static_tabs/readings' + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data), 0) + tab = response.data + self.assertEqual(tab['name'], u'readings') + self.assertEqual(tab['id'], u'readings') + self.assertEqual(tab['content'], self.static_tab2.data) + + # try a bogus courseId + test_uri = self.base_courses_uri + '/' + self.test_bogus_course_id + '/static_tabs/syllabus' + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + + # try a not found item + test_uri = self.base_courses_uri + '/' + self.test_course_id + '/static_tabs/bogus' + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + + def test_course_enrollments(self): + test_uri = self.base_courses_uri + '/' + self.test_course_id + '/users' + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data), 0) + + # assert that there is no enrolled students + enrollments = response.data['enrollments'] + self.assertEqual(len(enrollments), 0) + self.assertNotIn('pending_enrollments', response.data) + + # enroll a non-existing student + # first, don't allow non-existing + post_data = {} + post_data['email'] = 'test+pending@tester.com' + post_data['allow_pending'] = False + response = self.do_post(test_uri, post_data) + self.assertEqual(response.status_code, 400) + + post_data['allow_pending'] = True + response = self.do_post(test_uri, post_data) + self.assertEqual(response.status_code, 201) + + # re-run query + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data), 0) + + # assert that we just have a single pending enrollment + enrollments = response.data['enrollments'] + self.assertEqual(len(enrollments), 0) + self.assertIn('pending_enrollments', response.data) + pending = response.data['pending_enrollments'] + self.assertEqual(len(pending), 1) + self.assertEqual(pending[0], 'test+pending@tester.com') + + # create a new user (note, this calls into the /users/ subsystem) + test_user_uri = '/api/users' + local_username = "some_test_user" + str(randint(11, 99)) + local_email = "test+notpending@tester.com" + data = { + 'email': local_email, + 'username': local_username, + 'password': 'fooabr', + 'first_name': 'Joe', + 'last_name': 'Brown' + } + response = self.do_post(test_user_uri, data) + self.assertEqual(response.status_code, 201) + self.assertGreater(response.data['id'], 0) + created_user_id = response.data['id'] + + # now register this user + post_data = {} + post_data['user_id'] = created_user_id + response = self.do_post(test_uri, post_data) + self.assertEqual(response.status_code, 201) + + # now re-query, we should see it listed now in the list of enrollments + # re-run query + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data), 0) + + # assert that we just have a single pending enrollment + enrollments = response.data['enrollments'] + self.assertEqual(len(enrollments), 1) + self.assertEqual(enrollments[0]['id'], created_user_id) + self.assertEqual(enrollments[0]['email'], local_email) + self.assertEqual(enrollments[0]['username'], local_username) diff --git a/lms/djangoapps/api_manager/tests/test_groups_views.py b/lms/djangoapps/api_manager/tests/test_groups_views.py new file mode 100644 index 000000000000..2b55f8d0b3d3 --- /dev/null +++ b/lms/djangoapps/api_manager/tests/test_groups_views.py @@ -0,0 +1,656 @@ +# pylint: disable=E1103 + +""" +Run these tests @ Devstack: + rake fasttest_lms[common/djangoapps/api_manager/tests/test_group_views.py] +""" +from random import randint +import uuid +import json + +from django.core.cache import cache +from django.test import TestCase, Client +from django.test.utils import override_settings + +from api_manager.models import GroupRelationship +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE +from xmodule.modulestore.tests.factories import CourseFactory + +TEST_API_KEY = str(uuid.uuid4()) + + +class SecureClient(Client): + """ Django test client using a "secure" connection. """ + def __init__(self, *args, **kwargs): + kwargs = kwargs.copy() + kwargs.update({'SERVER_PORT': 443, 'wsgi.url_scheme': 'https'}) + super(SecureClient, self).__init__(*args, **kwargs) + + +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) +@override_settings(EDX_API_KEY=TEST_API_KEY) +class GroupsApiTests(TestCase): + """ Test suite for Groups API views """ + + def setUp(self): + self.test_server_prefix = 'https://testserver' + self.test_username = str(uuid.uuid4()) + self.test_password = str(uuid.uuid4()) + self.test_email = str(uuid.uuid4()) + '@test.org' + self.test_group_name = str(uuid.uuid4()) + self.base_users_uri = '/api/users' + self.base_groups_uri = '/api/groups' + + self.course = CourseFactory.create() + self.test_course_id = self.course.id + + self.client = SecureClient() + cache.clear() + + def do_post(self, uri, data): + """Submit an HTTP POST request""" + headers = { + 'Content-Type': 'application/json', + 'X-Edx-Api-Key': str(TEST_API_KEY), + } + response = self.client.post(uri, headers=headers, data=data) + return response + + def do_get(self, uri): + """Submit an HTTP GET request""" + headers = { + 'Content-Type': 'application/json', + 'X-Edx-Api-Key': str(TEST_API_KEY), + } + response = self.client.get(uri, headers=headers) + return response + + def do_delete(self, uri): + """Submit an HTTP DELETE request""" + headers = { + 'Content-Type': 'application/json', + 'X-Edx-Api-Key': str(TEST_API_KEY), + } + response = self.client.delete(uri, headers=headers) + return response + + def test_group_list_post(self): + data = {'name': self.test_group_name} + response = self.do_post(self.base_groups_uri, data) + self.assertEqual(response.status_code, 201) + self.assertGreater(response.data['id'], 0) + confirm_uri = self.test_server_prefix + self.base_groups_uri + '/' + str(response.data['id']) + self.assertEqual(response.data['uri'], confirm_uri) + self.assertGreater(len(response.data['name']), 0) + + # @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + # def test_group_list_post_duplicate(self): + # data = {'name': self.test_group_name} + # response = self.do_post(self.base_groups_uri, data) + # self.assertEqual(response.status_code, 201) + # self.assertGreater(response.data['id'], 0) + # confirm_uri = self.test_server_prefix + self.base_groups_uri + '/' + str(response.data['id']) + # self.assertEqual(response.data['uri'], confirm_uri) + # response = self.do_post(self.base_groups_uri, data) + # self.assertEqual(response.status_code, 409) + + def test_group_detail_get(self): + data = {'name': self.test_group_name} + response = self.do_post(self.base_groups_uri, data) + self.assertGreater(response.data['id'], 0) + group_id = response.data['id'] + test_uri = self.base_groups_uri + '/' + str(group_id) + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['id'], group_id) + confirm_uri = self.test_server_prefix + test_uri + self.assertEqual(response.data['uri'], confirm_uri) + self.assertEqual(response.data['name'], self.test_group_name) + + def test_group_list_get_with_profile(self): + data = { + 'name': self.test_group_name, + 'group_type': 'series', + 'data': json.dumps({'display_name': 'My first series'}) + } + response = self.do_post(self.base_groups_uri, data) + self.assertGreater(response.data['id'], 0) + group_id = response.data['id'] + + # query for list of groups, but don't put the type filter (bad) + test_uri = self.base_groups_uri + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 400) + + # try again with filter + test_uri = self.base_groups_uri + '?type=series' + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]['group_id'], group_id) + self.assertEqual(response.data[0]['group_type'], 'series') + self.assertEqual(response.data[0]['data']['display_name'], 'My first series') + + # query the group detail + test_uri = self.base_groups_uri + '/' + str(group_id) + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['id'], group_id) + confirm_uri = self.test_server_prefix + test_uri + self.assertEqual(response.data['uri'], confirm_uri) + self.assertEqual(response.data['name'], self.test_group_name) + self.assertEqual(response.data['group_type'], 'series') + self.assertEqual(response.data['data']['display_name'], 'My first series') + + # update the profile + + # first with missing data + response = self.do_post(test_uri, {}) + self.assertEqual(response.status_code, 400) + + data = { + 'name': self.test_group_name, + 'group_type': 'seriesX', + 'data': json.dumps({'display_name': 'My updated series'}) + } + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 200) + + # requery the filter + test_uri = self.base_groups_uri + '?type=series' + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 0) + + test_uri = self.base_groups_uri + '?type=seriesX' + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]['group_id'], group_id) + self.assertEqual(response.data[0]['group_type'], 'seriesX') + self.assertEqual(response.data[0]['data']['display_name'], 'My updated series') + + def test_group_detail_get_undefined(self): + test_uri = self.base_groups_uri + '/123456789' + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + + def test_group_users_list_post(self): + local_username = self.test_username + str(randint(11, 99)) + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password} + response = self.do_post(self.base_users_uri, data) + user_id = response.data['id'] + data = {'name': 'Alpha Group'} + response = self.do_post(self.base_groups_uri, data) + group_id = response.data['id'] + test_uri = self.base_groups_uri + '/' + str(group_id) + response = self.do_get(test_uri) + test_uri = test_uri + '/users' + data = {'user_id': user_id} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 201) + confirm_uri = self.test_server_prefix + test_uri + '/' + str(response.data['user_id']) + self.assertEqual(response.data['uri'], confirm_uri) + self.assertEqual(response.data['group_id'], str(group_id)) + self.assertEqual(response.data['user_id'], str(user_id)) + + def test_group_users_list_post_duplicate(self): + local_username = self.test_username + str(randint(11, 99)) + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password} + response = self.do_post(self.base_users_uri, data) + user_id = response.data['id'] + data = {'name': 'Alpha Group'} + response = self.do_post(self.base_groups_uri, data) + test_uri = self.base_groups_uri + '/' + str(response.data['id']) + response = self.do_get(test_uri) + test_uri = test_uri + '/users' + data = {'user_id': user_id} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 201) + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 409) + + def test_group_users_list_post_invalid_resources(self): + test_uri = self.base_groups_uri + '/1239878976' + test_uri = test_uri + '/users' + data = {'user_id': "98723896"} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 404) + + def test_group_users_detail_get(self): + local_username = self.test_username + str(randint(11, 99)) + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password} + response = self.do_post(self.base_users_uri, data) + user_id = response.data['id'] + data = {'name': 'Alpha Group'} + response = self.do_post(self.base_groups_uri, data) + group_id = response.data['id'] + test_uri = self.base_groups_uri + '/' + str(response.data['id']) + response = self.do_get(test_uri) + test_uri = test_uri + '/users' + data = {'user_id': user_id} + response = self.do_post(test_uri, data) + test_uri = test_uri + '/' + str(user_id) + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data['uri']), 0) + confirm_uri = self.test_server_prefix + test_uri + self.assertEqual(response.data['uri'], confirm_uri) + self.assertEqual(response.data['group_id'], group_id) + self.assertEqual(response.data['user_id'], user_id) + + def test_group_users_detail_delete(self): + local_username = self.test_username + str(randint(11, 99)) + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password} + response = self.do_post(self.base_users_uri, data) + user_id = response.data['id'] + data = {'name': 'Alpha Group'} + response = self.do_post(self.base_groups_uri, data) + test_uri = self.base_groups_uri + '/' + str(response.data['id']) + response = self.do_get(test_uri) + test_uri = test_uri + '/users' + data = {'user_id': user_id} + response = self.do_post(test_uri, data) + test_uri = test_uri + '/' + str(user_id) + response = self.do_delete(test_uri) + self.assertEqual(response.status_code, 204) + response = self.do_delete(test_uri) + self.assertEqual(response.status_code, 204) # Idempotent + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + + def test_group_users_detail_delete_invalid_group(self): + test_uri = self.base_groups_uri + '/123987102/users/123124' + response = self.do_delete(test_uri) + self.assertEqual(response.status_code, 204) + + def test_group_users_detail_delete_invalid_user(self): + data = {'name': self.test_group_name} + response = self.do_post(self.base_groups_uri, data) + test_uri = self.base_groups_uri + '/' + str(response.data['id']) + test_uri = test_uri + '/users/123124' + response = self.do_delete(test_uri) + self.assertEqual(response.status_code, 204) + + def test_group_users_detail_get_undefined(self): + local_username = self.test_username + str(randint(11, 99)) + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password} + response = self.do_post(self.base_users_uri, data) + user_id = response.data['id'] + data = {'name': 'Alpha Group'} + response = self.do_post(self.base_groups_uri, data) + group_id = response.data['id'] + test_uri = self.base_groups_uri + '/' + str(group_id) + '/users/' + str(user_id) + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + + def test_group_groups_list_post_hierarchical(self): + data = {'name': 'Alpha Group'} + alpha_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(alpha_response.status_code, 201) + data = {'name': 'Beta Group'} + beta_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(beta_response.status_code, 201) + data = {'name': 'Delta Group'} + delta_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(delta_response.status_code, 201) + test_uri = alpha_response.data['uri'] + '/groups' + group_id = delta_response.data['id'] + relationship_type = 'h' # Hierarchical + data = {'group_id': group_id, 'relationship_type': relationship_type} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 201) + self.assertGreater(len(response.data['uri']), 0) + confirm_uri = test_uri + '/' + str(response.data['group_id']) + self.assertEqual(response.data['uri'], confirm_uri) + self.assertEqual(response.data['group_id'], str(group_id)) + self.assertEqual(response.data['relationship_type'], relationship_type) + + def test_group_groups_list_post_linked(self): + data = {'name': 'Alpha Group'} + alpha_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(alpha_response.status_code, 201) + data = {'name': 'Beta Group'} + beta_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(beta_response.status_code, 201) + data = {'name': 'Delta Group'} + delta_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(delta_response.status_code, 201) + test_uri = alpha_response.data['uri'] + '/groups' + group_id = delta_response.data['id'] + relationship_type = 'g' # Graph + data = {'group_id': group_id, 'relationship_type': relationship_type} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 201) + self.assertGreater(len(response.data['uri']), 0) + confirm_uri = test_uri + '/' + str(response.data['group_id']) + self.assertEqual(response.data['uri'], confirm_uri) + self.assertEqual(response.data['group_id'], str(group_id)) + self.assertEqual(response.data['relationship_type'], relationship_type) + + def test_group_groups_list_post_linked_duplicate(self): + data = {'name': 'Alpha Group'} + alpha_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(alpha_response.status_code, 201) + data = {'name': 'Beta Group'} + beta_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(beta_response.status_code, 201) + data = {'name': 'Delta Group'} + delta_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(delta_response.status_code, 201) + test_uri = alpha_response.data['uri'] + '/groups' + group_id = delta_response.data['id'] + relationship_type = 'g' # Graph + data = {'group_id': group_id, 'relationship_type': relationship_type} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 201) + response = self.do_post(test_uri, data) + # Duplicate responses are idemnotent in this case + self.assertEqual(response.status_code, 201) + + def test_group_groups_list_post_invalid_group(self): + test_uri = self.base_groups_uri + '/123098/groups' + relationship_type = 'g' # Graph + data = {'group_id': '232987', 'relationship_type': relationship_type} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 404) + + def test_group_groups_list_post_invalid_relationship_type(self): + data = {'name': 'Alpha Group'} + alpha_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(alpha_response.status_code, 201) + data = {'name': 'Beta Group'} + beta_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(beta_response.status_code, 201) + data = {'name': 'Delta Group'} + delta_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(delta_response.status_code, 201) + test_uri = alpha_response.data['uri'] + '/groups' + group_id = delta_response.data['id'] + relationship_type = "z" # Graph + data = {'group_id': group_id, 'relationship_type': relationship_type} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 406) + + def test_group_groups_list_get(self): + data = {'name': 'Bravo Group'} + bravo_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(bravo_response.status_code, 201) + bravo_group_id = bravo_response.data['id'] + bravo_groups_uri = bravo_response.data['uri'] + '/groups' + + data = {'name': 'Charlie Group'} + charlie_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(charlie_response.status_code, 201) + charlie_group_id = charlie_response.data['id'] + relationship_type = 'h' # Hierarchical + data = {'group_id': charlie_group_id, 'relationship_type': relationship_type} + response = self.do_post(bravo_groups_uri, data) + self.assertEqual(response.status_code, 201) + + data = {'name': 'Foxtrot Group'} + foxtrot_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(foxtrot_response.status_code, 201) + foxtrot_group_id = foxtrot_response.data['id'] + relationship_type = 'g' # Graph + data = {'group_id': foxtrot_group_id, 'relationship_type': relationship_type} + response = self.do_post(bravo_groups_uri, data) + self.assertEqual(response.status_code, 201) + + data = {'name': 'Tango Group'} + tango_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(tango_response.status_code, 201) + tango_group_id = tango_response.data['id'] + tango_uri = tango_response.data['uri'] + data = {'group_id': bravo_group_id, 'relationship_type': relationship_type} + tango_groups_uri = tango_uri + '/groups' + response = self.do_post(tango_groups_uri, data) + self.assertEqual(response.status_code, 201) + + response = self.do_get(bravo_groups_uri) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data), 0) + group_idlist = (charlie_group_id, foxtrot_group_id, tango_group_id) + relationship_count = 0 + for relationship in response.data: + relationship_count = relationship_count + 1 + group_id = relationship['id'] + self.assertGreater(group_id, 0) + self.assertFalse(bravo_group_id == group_id) + self.assertTrue(relationship['relationship_type'] in ["h", "g"]) + self.assertGreater(len(relationship['uri']), 0) + self.assertEqual(relationship_count, len(group_idlist)) + + def test_group_groups_list_get_notfound(self): + test_uri = self.base_groups_uri + '/213213123/groups' + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + + def test_group_groups_detail_get_hierarchical(self): + data = {'name': 'Alpha Group'} + alpha_response = self.do_post(self.base_groups_uri, data) + alpha_group_id = alpha_response.data['id'] + self.assertEqual(alpha_response.status_code, 201) + data = {'name': 'Beta Group'} + beta_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(beta_response.status_code, 201) + data = {'name': 'Delta Group'} + delta_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(delta_response.status_code, 201) + test_uri = alpha_response.data['uri'] + '/groups' + delta_group_id = delta_response.data['id'] + relationship_type = 'h' # Hierarchical + data = {'group_id': delta_group_id, 'relationship_type': relationship_type} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 201) + test_uri = response.data['uri'] + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data['uri']), 0) + confirm_uri = test_uri + self.assertEqual(response.data['uri'], confirm_uri) + self.assertEqual(response.data['from_group_id'], str(alpha_group_id)) + self.assertEqual(response.data['to_group_id'], str(delta_group_id)) + self.assertEqual(response.data['relationship_type'], relationship_type) + + def test_group_groups_detail_get_linked(self): + data = {'name': 'Alpha Group'} + alpha_response = self.do_post(self.base_groups_uri, data) + alpha_group_id = alpha_response.data['id'] + self.assertEqual(alpha_response.status_code, 201) + data = {'name': 'Beta Group'} + beta_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(beta_response.status_code, 201) + data = {'name': 'Delta Group'} + delta_response = self.do_post(self.base_groups_uri, data) + delta_group_id = delta_response.data['id'] + self.assertEqual(delta_response.status_code, 201) + test_uri = alpha_response.data['uri'] + '/groups' + relationship_type = 'g' # Graph + data = {'group_id': delta_group_id, 'relationship_type': relationship_type} + delta_group = GroupRelationship.objects.get(group_id=delta_group_id) + delta_group.parent_group_id = None + delta_group.save() + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 201) + test_uri = response.data['uri'] + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data['uri']), 0) + confirm_uri = test_uri + self.assertEqual(response.data['uri'], confirm_uri) + self.assertEqual(response.data['from_group_id'], str(alpha_group_id)) + self.assertEqual(response.data['to_group_id'], str(delta_group_id)) + self.assertEqual(response.data['relationship_type'], relationship_type) + + def test_group_groups_detail_get_notfound(self): + data = {'name': 'Alpha Group'} + alpha_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(alpha_response.status_code, 201) + test_uri = alpha_response.data['uri'] + '/groups/gaois89sdf98' + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + + def test_group_groups_detail_delete_hierarchical(self): + data = {'name': 'Alpha Group'} + alpha_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(alpha_response.status_code, 201) + data = {'name': 'Beta Group'} + beta_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(beta_response.status_code, 201) + data = {'name': 'Delta Group'} + delta_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(delta_response.status_code, 201) + data = {'name': 'Gamma Group'} + gamma_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(gamma_response.status_code, 201) + test_uri = alpha_response.data['uri'] + '/groups' + group_id = gamma_response.data['id'] + relationship_type = 'h' + data = {'group_id': group_id, 'relationship_type': relationship_type} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 201) + test_uri = response.data['uri'] + response = self.do_delete(test_uri) + self.assertEqual(response.status_code, 204) + response = self.do_delete(test_uri) + self.assertEqual(response.status_code, 204) + try: + self.assertIsNone(response.data['message']) + except KeyError: + pass + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + + def test_group_groups_detail_delete_linked(self): + data = {'name': 'Alpha Group'} + alpha_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(alpha_response.status_code, 201) + data = {'name': 'Beta Group'} + beta_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(beta_response.status_code, 201) + data = {'name': 'Delta Group'} + delta_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(delta_response.status_code, 201) + data = {'name': 'Gamma Group'} + gamma_response = self.do_post(self.base_groups_uri, data) + self.assertEqual(gamma_response.status_code, 201) + test_uri = alpha_response.data['uri'] + '/groups' + group_id = gamma_response.data['id'] + relationship_type = 'g' + data = {'group_id': group_id, 'relationship_type': relationship_type} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 201) + test_uri = response.data['uri'] + response = self.do_delete(test_uri) + self.assertEqual(response.status_code, 204) + try: + self.assertIsNone(response.data['message']) + except KeyError: + pass + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + + def test_group_groups_detail_delete_invalid(self): + test_uri = self.base_groups_uri + '/1231234232/groups/1' + response = self.do_delete(test_uri) + self.assertEqual(response.status_code, 404) + + def test_group_courses_list_post(self): + data = {'name': self.test_group_name} + response = self.do_post(self.base_groups_uri, data) + self.assertEqual(response.status_code, 201) + group_id = response.data['id'] + test_uri = response.data['uri'] + '/courses' + data = {'course_id': self.test_course_id} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 201) + confirm_uri = test_uri + '/' + self.course.id + self.assertEqual(response.data['uri'], confirm_uri) + self.assertEqual(response.data['group_id'], str(group_id)) + self.assertEqual(response.data['course_id'], self.test_course_id) + + # then re-query to check membership + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['courses']), 1) + self.assertEqual(response.data['courses'][0]['course_id'], self.test_course_id) + self.assertEqual(response.data['courses'][0]['display_name'], self.course.display_name) + + def test_group_courses_list_post_duplicate(self): + data = {'name': self.test_group_name} + response = self.do_post(self.base_groups_uri, data) + self.assertEqual(response.status_code, 201) + group_id = response.data['id'] + test_uri = response.data['uri'] + '/courses' + data = {'course_id': self.test_course_id} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 201) + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 409) + + def test_group_courses_list_post_invalid_resources(self): + test_uri = self.base_groups_uri + '/1239878976/courses' + data = {'course_id': "98723896"} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 404) + + def test_group_courses_detail_get(self): + data = {'name': self.test_group_name} + response = self.do_post(self.base_groups_uri, data) + self.assertEqual(response.status_code, 201) + group_id = response.data['id'] + test_uri = response.data['uri'] + '/courses' + data = {'course_id': self.test_course_id} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 201) + test_uri = '{}/{}/courses/{}'.format(self.base_groups_uri, group_id, self.test_course_id) + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + confirm_uri = '{}{}/{}/courses/{}'.format( + self.test_server_prefix, + self.base_groups_uri, + group_id, + self.test_course_id + ) + self.assertEqual(response.data['uri'], confirm_uri) + self.assertEqual(response.data['group_id'], group_id) + self.assertEqual(response.data['course_id'], self.test_course_id) + + def test_group_courses_detail_delete(self): + data = {'name': self.test_group_name} + response = self.do_post(self.base_groups_uri, data) + self.assertEqual(response.status_code, 201) + test_uri = response.data['uri'] + '/courses' + data = {'course_id': self.test_course_id} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 201) + test_uri = response.data['uri'] + response = self.do_delete(test_uri) + self.assertEqual(response.status_code, 204) + response = self.do_delete(test_uri) + self.assertEqual(response.status_code, 204) # Idempotent + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + + def test_group_courses_detail_delete_invalid_group(self): + test_uri = self.base_groups_uri + '/123987102/courses/123124' + response = self.do_delete(test_uri) + self.assertEqual(response.status_code, 204) + + def test_group_courses_detail_delete_invalid_course(self): + data = {'name': self.test_group_name} + response = self.do_post(self.base_groups_uri, data) + self.assertEqual(response.status_code, 201) + test_uri = response.data['uri'] + '/courses/123124' + response = self.do_delete(test_uri) + self.assertEqual(response.status_code, 204) + + def test_group_courses_detail_get_undefined(self): + data = {'name': self.test_group_name} + response = self.do_post(self.base_groups_uri, data) + self.assertEqual(response.status_code, 201) + group_id = response.data['id'] + test_uri = '{}/courses/{}'.format(response.data['uri'], self.course.id) + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) diff --git a/lms/djangoapps/api_manager/tests/test_permissions.py b/lms/djangoapps/api_manager/tests/test_permissions.py new file mode 100644 index 000000000000..1aec4d57d8e3 --- /dev/null +++ b/lms/djangoapps/api_manager/tests/test_permissions.py @@ -0,0 +1,147 @@ +""" +Run these tests @ Devstack: + rake fasttest_lms[common/djangoapps/api_manager/tests/test_permissions.py] +""" +from random import randint +import unittest +import uuid + +from django.conf import settings +from django.test import TestCase +from django.test.utils import override_settings + +TEST_API_KEY = "123456ABCDEF" + + +@override_settings(DEBUG=True, EDX_API_KEY=None) +class PermissionsTestsDebug(TestCase): + """ Test suite for Permissions helper classes """ + def setUp(self): + self.test_username = str(uuid.uuid4()) + self.test_password = str(uuid.uuid4()) + self.test_email = str(uuid.uuid4()) + '@test.org' + + def do_post(self, uri, data): + """Submit an HTTP POST request""" + headers = { + 'Content-Type': 'application/json', + 'X-Edx-Api-Key': str(TEST_API_KEY), + } + response = self.client.post(uri, headers=headers, data=data) + return response + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_has_permission_debug_enabled(self): + test_uri = '/api/users' + local_username = self.test_username + str(randint(11, 99)) + local_username = local_username[3:-1] # username is a 32-character field + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 201) + + +@override_settings(DEBUG=False, EDX_API_KEY="123456ABCDEF") +class PermissionsTestsApiKey(TestCase): + """ Test suite for Permissions helper classes """ + def setUp(self): + self.test_username = str(uuid.uuid4()) + self.test_password = str(uuid.uuid4()) + self.test_email = str(uuid.uuid4()) + '@test.org' + + def do_post(self, uri, data): + """Submit an HTTP POST request""" + headers = { + 'Content-Type': 'application/json', + 'X-Edx-Api-Key': str(TEST_API_KEY), + } + response = self.client.post(uri, headers=headers, data=data) + return response + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_has_permission_valid_api_key(self): + test_uri = '/api/users' + local_username = self.test_username + str(randint(11, 99)) + local_username = local_username[3:-1] # username is a 32-character field + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 201) + + +@override_settings(DEBUG=False, EDX_API_KEY=None) +class PermissionsTestDeniedMissingServerKey(TestCase): + """ Test suite for Permissions helper classes """ + def setUp(self): + self.test_username = str(uuid.uuid4()) + self.test_password = str(uuid.uuid4()) + self.test_email = str(uuid.uuid4()) + '@test.org' + + def do_post(self, uri, data): + """Submit an HTTP POST request""" + headers = { + 'Content-Type': 'application/json', + 'X-Edx-Api-Key': str(TEST_API_KEY), + } + response = self.client.post(uri, headers=headers, data=data) + return response + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_has_permission_missing_server_key(self): + test_uri = '/api/users' + local_username = self.test_username + str(randint(11, 99)) + local_username = local_username[3:-1] # username is a 32-character field + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 403) + + +@override_settings(DEBUG=False, EDX_API_KEY="67890VWXYZ") +class PermissionsTestDeniedMissingClientKey(TestCase): + """ Test suite for Permissions helper classes """ + def setUp(self): + self.test_username = str(uuid.uuid4()) + self.test_password = str(uuid.uuid4()) + self.test_email = str(uuid.uuid4()) + '@test.org' + + def do_post(self, uri, data): + """Submit an HTTP POST request""" + headers = { + 'Content-Type': 'application/json', + } + response = self.client.post(uri, headers=headers, data=data) + return response + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_has_permission_invalid_client_key(self): + test_uri = '/api/users' + local_username = self.test_username + str(randint(11, 99)) + local_username = local_username[3:-1] # username is a 32-character field + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 403) + + +@override_settings(DEBUG=False, EDX_API_KEY="67890VWXYZ") +class PermissionsTestDeniedInvalidClientKey(TestCase): + """ Test suite for Permissions helper classes """ + def setUp(self): + self.test_username = str(uuid.uuid4()) + self.test_password = str(uuid.uuid4()) + self.test_email = str(uuid.uuid4()) + '@test.org' + + def do_post(self, uri, data): + """Submit an HTTP POST request""" + headers = { + 'Content-Type': 'application/json', + 'X-Edx-Api-Key': str(TEST_API_KEY), + } + response = self.client.post(uri, headers=headers, data=data) + return response + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_has_permission_invalid_client_key(self): + test_uri = '/api/users' + local_username = self.test_username + str(randint(11, 99)) + local_username = local_username[3:-1] # username is a 32-character field + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 403) diff --git a/lms/djangoapps/api_manager/tests/test_sessions_security.py b/lms/djangoapps/api_manager/tests/test_sessions_security.py new file mode 100644 index 000000000000..09f2b80ebe68 --- /dev/null +++ b/lms/djangoapps/api_manager/tests/test_sessions_security.py @@ -0,0 +1,233 @@ +""" +Tests for session api with advance security features +""" +import json +import uuid +import unittest +from mock import patch +from datetime import datetime, timedelta +from freezegun import freeze_time +from pytz import UTC + +from django.test import TestCase +from django.test.client import Client +from django.test.utils import override_settings +from django.utils.translation import ugettext as _ +from django.conf import settings +from django.core.cache import cache +from student.tests.factories import UserFactory + +TEST_API_KEY = str(uuid.uuid4()) + + +@override_settings(EDX_API_KEY=TEST_API_KEY) +@patch.dict("django.conf.settings.FEATURES", {'ENFORCE_PASSWORD_POLICY': True}) +@patch.dict("django.conf.settings.FEATURES", {'ENABLE_MAX_FAILED_LOGIN_ATTEMPTS': True}) +class SessionApiSecurityTest(TestCase): + """ + Test api_manager.session.session_list view + """ + + def setUp(self): + """ + Create one user and save it to the database + """ + self.user = UserFactory.build(username='test', email='test@edx.org') + self.user.set_password('test_password') + self.user.save() + + # Create the test client + self.client = Client() + cache.clear() + self.session_url = '/api/sessions' + self.user_url = '/api/users' + + @override_settings(MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED=10) + def test_login_ratelimited_success(self): + """ + Try (and fail) logging in with fewer attempts than the limit of 10 + and verify that you can still successfully log in afterwards. + """ + for i in xrange(9): + password = u'test_password{0}'.format(i) + response = self._do_post_request(self.session_url, 'test', password, secure=True) + self.assertEqual(response.status_code, 401) + + # now try logging in with a valid password and check status + response = self._do_post_request(self.session_url, 'test', 'test_password', secure=True) + self._assert_response(response, status=201) + + @override_settings(MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED=10) + def test_login_blockout(self): + """ + Try (and fail) logging in with 10 attempts + and verify that user is blocked out. + """ + for i in xrange(10): + password = u'test_password{0}'.format(i) + response = self._do_post_request(self.session_url, 'test', password, secure=True) + self.assertEqual(response.status_code, 401) + + # check to see if this response indicates blockout + response = self._do_post_request(self.session_url, 'test', 'test_password', secure=True) + message = _('This account has been temporarily locked due to excessive login failures. Try again later.') + self._assert_response(response, status=403, message=message) + + @override_settings(MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED=10, + MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS=1800) + def test_blockout_reset_time_period(self): + """ + Try logging in 10 times to block user and then login with right + credentials(after 30 minutes) to verify blocked out time expired and + user can login successfully. + """ + for i in xrange(10): + password = u'test_password{0}'.format(i) + response = self._do_post_request(self.session_url, 'test', password, secure=True) + self.assertEqual(response.status_code, 401) + + # check to see if this response indicates blockout + response = self._do_post_request(self.session_url, 'test', 'test_password', secure=True) + message = _('This account has been temporarily locked due to excessive login failures. Try again later.') + self._assert_response(response, status=403, message=message) + + # now reset the time to 30 from now in future + reset_time = datetime.now(UTC) + timedelta(seconds=1800) + with freeze_time(reset_time): + response = self._do_post_request(self.session_url, 'test', 'test_password', secure=True) + self._assert_response(response, status=201) + + @override_settings(PASSWORD_MIN_LENGTH=4) + def test_with_short_password(self): + """ + Try (and fail) user creation with shorter password + """ + response = self._do_post_request(self.user_url, 'test', 'abc', email='test@edx.org', + first_name='John', last_name='Doe', secure=True) + message = _('Password: Invalid Length (must be 4 characters or more)') + self._assert_response(response, status=400, message=message) + + @override_settings(PASSWORD_MAX_LENGTH=12) + def test_with_long_password(self): + """ + Try (and fail) user creation with longer password + """ + response = self._do_post_request(self.user_url, 'test', 'test_password', email='test@edx.org', + first_name='John', last_name='Doe', secure=True) + message = _('Password: Invalid Length (must be 12 characters or less)') + self._assert_response(response, status=400, message=message) + + @override_settings(PASSWORD_COMPLEXITY={'UPPER': 2, 'LOWER': 2, 'PUNCTUATION': 2, 'DIGITS': 2}) + def test_password_without_uppercase(self): + """ + Try (and fail) user creation since password should have atleast + 2 upper characters + """ + response = self._do_post_request(self.user_url, 'test', 'test.pa64!', email='test@edx.org', + first_name='John', last_name='Doe', secure=True) + message = _('Password: Must be more complex (must contain 2 or more uppercase characters)') + self._assert_response(response, status=400, message=message) + + @override_settings(PASSWORD_COMPLEXITY={'UPPER': 2, 'LOWER': 2, 'PUNCTUATION': 2, 'DIGITS': 2}) + def test_password_without_lowercase(self): + """ + Try (and fail) user creation without any numeric characters + in password + """ + response = self._do_post_request(self.user_url, 'test', 'TEST.PA64!', email='test@edx.org', + first_name='John', last_name='Doe', secure=True) + message = _('Password: Must be more complex (must contain 2 or more lowercase characters)') + self._assert_response(response, status=400, message=message) + + @override_settings(PASSWORD_COMPLEXITY={'UPPER': 2, 'LOWER': 2, 'PUNCTUATION': 2, 'DIGITS': 2}) + def test_password_without_punctuation(self): + """ + Try (and fail) user creation without any punctuation in password + """ + response = self._do_post_request(self.user_url, 'test', 'test64SS', email='test@edx.org', + first_name='John', last_name='Doe', secure=True) + message = _('Password: Must be more complex (must contain 2 or more uppercase characters,' + ' must contain 2 or more punctuation characters)') + self._assert_response(response, status=400, message=message) + + @override_settings(PASSWORD_COMPLEXITY={'UPPER': 2, 'LOWER': 2, 'PUNCTUATION': 2, 'DIGITS': 2}) + def test_password_without_numeric(self): + """ + Try (and fail) user creation without any numeric characters in password + """ + response = self._do_post_request(self.user_url, 'test', 'test.paSS!', email='test@edx.org', + first_name='John', last_name='Doe', secure=True) + message = _('Password: Must be more complex (must contain 2 or more uppercase characters,' + ' must contain 2 or more digits)') + self._assert_response(response, status=400, message=message) + + @override_settings(PASSWORD_COMPLEXITY={'UPPER': 2, 'LOWER': 2, 'PUNCTUATION': 2, 'DIGITS': 2}) + def test_password_with_complexity(self): + """ + This should pass since it has everything needed for a complex password + """ + response = self._do_post_request(self.user_url, str(uuid.uuid4()), 'Test.Me64!', email='test@edx.org', + first_name='John', last_name='Doe', secure=True) + self._assert_response(response, status=201) + + def test_user_with_invalid_email(self): + """ + Try (and fail) user creation with invalid email address + """ + response = self._do_post_request(self.user_url, 'test', 'Test.Me64!', email='test-edx.org', + first_name='John', last_name='Doe', secure=True) + message = _('Valid e-mail is required.') + self._assert_response(response, status=400, message=message) + + def test_user_with_invalid_username(self): + """ + Try (and fail) user creation with invalid username + """ + response = self._do_post_request(self.user_url, 'user name', 'Test.Me64!', email='test@edx.org', + first_name='John', last_name='Doe', secure=True) + message = _('Username should only consist of A-Z and 0-9, with no spaces.') + self._assert_response(response, status=400, message=message) + + def _do_post_request(self, url, username, password, **kwargs): + """ + Post the login info + """ + post_params, extra = {'username': username, 'password': password}, {} + if kwargs.get('email'): + post_params['email'] = kwargs.get('email') + if kwargs.get('first_name'): + post_params['first_name'] = kwargs.get('first_name') + if kwargs.get('last_name'): + post_params['last_name'] = kwargs.get('last_name') + + headers = {'X-Edx-Api-Key': TEST_API_KEY, 'Content-Type': 'application/json'} + if kwargs.get('secure', False): + extra['wsgi.url_scheme'] = 'https' + return self.client.post(url, post_params, headers=headers, **extra) + + def _assert_response(self, response, status=200, success=None, message=None): + """ + Assert that the response had status 200 and returned a valid + JSON-parseable dict. + + If success is provided, assert that the response had that + value for 'success' in the JSON dict. + + If message is provided, assert that the response contained that + value for 'message' in the JSON dict. + """ + self.assertEqual(response.status_code, status) + + try: + response_dict = json.loads(response.content) + except ValueError: + self.fail("Could not parse response content as JSON: %s" + % str(response.content)) + + if success is not None: + self.assertEqual(response_dict['success'], success) + + if message is not None: + msg = ("'%s' did not contain '%s'" % + (response_dict['message'], message)) + self.assertTrue(message in response_dict['message'], msg) diff --git a/lms/djangoapps/api_manager/tests/test_sessions_views.py b/lms/djangoapps/api_manager/tests/test_sessions_views.py new file mode 100644 index 000000000000..d0660b793601 --- /dev/null +++ b/lms/djangoapps/api_manager/tests/test_sessions_views.py @@ -0,0 +1,157 @@ +# pylint: disable=E1101 +# pylint: disable=E1103 + +""" +Run these tests @ Devstack: + rake fasttest_lms[common/djangoapps/api_manager/tests/test_session_views.py] +""" +from random import randint +import unittest +import uuid + +from django.conf import settings +from django.contrib.auth.models import User +from django.core.cache import cache +from django.test import TestCase, Client +from django.test.utils import override_settings + +TEST_API_KEY = str(uuid.uuid4()) + + +class SecureClient(Client): + """ Django test client using a "secure" connection. """ + def __init__(self, *args, **kwargs): + kwargs = kwargs.copy() + kwargs.update({'SERVER_PORT': 443, 'wsgi.url_scheme': 'https'}) + super(SecureClient, self).__init__(*args, **kwargs) + + +@override_settings(EDX_API_KEY=TEST_API_KEY) +class SessionsApiTests(TestCase): + """ Test suite for Sessions API views """ + + def setUp(self): + self.test_server_prefix = 'https://testserver' + self.test_username = str(uuid.uuid4()) + self.test_password = str(uuid.uuid4()) + self.test_email = str(uuid.uuid4()) + '@test.org' + self.base_users_uri = '/api/users' + self.base_sessions_uri = '/api/sessions' + + self.client = SecureClient() + cache.clear() + + def do_post(self, uri, data): + """Submit an HTTP POST request""" + headers = { + 'Content-Type': 'application/json', + 'X-Edx-Api-Key': str(TEST_API_KEY), + } + response = self.client.post(uri, headers=headers, data=data) + return response + + def do_get(self, uri): + """Submit an HTTP GET request""" + headers = { + 'Content-Type': 'application/json', + 'X-Edx-Api-Key': str(TEST_API_KEY), + } + response = self.client.get(uri, headers=headers) + return response + + def do_delete(self, uri): + """Submit an HTTP DELETE request""" + headers = { + 'Content-Type': 'application/json', + 'X-Edx-Api-Key': str(TEST_API_KEY), + } + response = self.client.delete(uri, headers=headers) + return response + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_session_list_post_valid(self): + local_username = self.test_username + str(randint(11, 99)) + local_username = local_username[3:-1] # username is a 32-character field + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password} + response = self.do_post(self.base_users_uri, data) + user_id = response.data['id'] + data = {'username': local_username, 'password': self.test_password} + response = self.do_post(self.base_sessions_uri, data) + self.assertEqual(response.status_code, 201) + self.assertGreater(len(response.data['token']), 0) + confirm_uri = self.test_server_prefix + self.base_sessions_uri + '/' + response.data['token'] + self.assertEqual(response.data['uri'], confirm_uri) + self.assertGreater(response.data['expires'], 0) + self.assertGreater(len(response.data['user']), 0) + self.assertEqual(str(response.data['user']['username']), local_username) + self.assertEqual(response.data['user']['id'], user_id) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_session_list_post_invalid(self): + local_username = self.test_username + str(randint(11, 99)) + local_username = local_username[3:-1] # username is a 32-character field + bad_password = "12345" + data = {'email': self.test_email, 'username': local_username, 'password': bad_password} + response = self.do_post(self.base_users_uri, data) + data = {'username': local_username, 'password': self.test_password} + response = self.do_post(self.base_sessions_uri, data) + self.assertEqual(response.status_code, 401) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_session_list_post_valid_inactive(self): + local_username = self.test_username + str(randint(11, 99)) + local_username = local_username[3:-1] # username is a 32-character field + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password} + response = self.do_post(self.base_users_uri, data) + user = User.objects.get(username=local_username) + user.is_active = False + user.save() + data = {'username': local_username, 'password': self.test_password} + response = self.do_post(self.base_sessions_uri, data) + self.assertEqual(response.status_code, 403) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_session_list_post_invalid_notfound(self): + data = {'username': 'user_12321452334', 'password': self.test_password} + response = self.do_post(self.base_sessions_uri, data) + self.assertEqual(response.status_code, 404) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_session_detail_get(self): + local_username = self.test_username + str(randint(11, 99)) + local_username = local_username[3:-1] # username is a 32-character field + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password} + response = self.do_post(self.base_users_uri, data) + data = {'username': local_username, 'password': self.test_password} + response = self.do_post(self.base_sessions_uri, data) + test_uri = self.base_sessions_uri + '/' + response.data['token'] + post_token = response.data['token'] + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['token'], post_token) + response = self.do_delete(test_uri) + self.assertEqual(response.status_code, 204) + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_session_detail_get_undefined(self): + test_uri = self.base_sessions_uri + "/123456789" + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_session_detail_delete(self): + local_username = self.test_username + str(randint(11, 99)) + local_username = local_username[3:-1] # username is a 32-character field + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password} + response = self.do_post(self.base_users_uri, data) + self.assertEqual(response.status_code, 201) + data = {'username': local_username, 'password': self.test_password} + response = self.do_post(self.base_sessions_uri, data) + self.assertEqual(response.status_code, 201) + test_uri = self.base_users_uri + str(response.data['user']['id']) + response = self.do_delete(test_uri) + self.assertEqual(response.status_code, 204) + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) diff --git a/lms/djangoapps/api_manager/tests/test_users_views.py b/lms/djangoapps/api_manager/tests/test_users_views.py new file mode 100644 index 000000000000..d459c2a547ec --- /dev/null +++ b/lms/djangoapps/api_manager/tests/test_users_views.py @@ -0,0 +1,513 @@ +# pylint: disable=E1103 + +""" +Run these tests @ Devstack: + rake fasttest_lms[common/djangoapps/api_manager/tests/test_user_views.py] +""" +from random import randint +import simplejson as json +import unittest +import uuid + +from django.conf import settings +from django.contrib.auth.models import User +from django.core.cache import cache +from django.test import TestCase, Client +from django.test.utils import override_settings + +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + +TEST_API_KEY = str(uuid.uuid4()) + + +class SecureClient(Client): + """ Django test client using a "secure" connection. """ + def __init__(self, *args, **kwargs): + kwargs = kwargs.copy() + kwargs.update({'SERVER_PORT': 443, 'wsgi.url_scheme': 'https'}) + super(SecureClient, self).__init__(*args, **kwargs) + + +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) +@override_settings(EDX_API_KEY=TEST_API_KEY) +class UsersApiTests(TestCase): + """ Test suite for Users API views """ + + def setUp(self): + self.test_server_prefix = 'https://testserver' + self.test_username = str(uuid.uuid4()) + self.test_password = str(uuid.uuid4()) + self.test_email = str(uuid.uuid4()) + '@test.org' + self.test_first_name = str(uuid.uuid4()) + self.test_last_name = str(uuid.uuid4()) + + self.client = SecureClient() + cache.clear() + + def do_post(self, uri, data): + """Submit an HTTP POST request""" + headers = { + 'X-Edx-Api-Key': str(TEST_API_KEY), + } + json_data = json.dumps(data) + + response = self.client.post(uri, headers=headers, content_type='application/json', data=json_data) + return response + + def do_get(self, uri): + """Submit an HTTP GET request""" + headers = { + 'Content-Type': 'application/json', + 'X-Edx-Api-Key': str(TEST_API_KEY), + } + response = self.client.get(uri, headers=headers) + return response + + def do_delete(self, uri): + """Submit an HTTP DELETE request""" + headers = { + 'Content-Type': 'application/json', + 'X-Edx-Api-Key': str(TEST_API_KEY), + } + response = self.client.delete(uri, headers=headers) + return response + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_user_list_post(self): + test_uri = '/api/users' + local_username = self.test_username + str(randint(11, 99)) + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password, 'first_name': self.test_first_name, 'last_name': self.test_last_name} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 201) + self.assertGreater(response.data['id'], 0) + confirm_uri = self.test_server_prefix + test_uri + '/' + str(response.data['id']) + self.assertEqual(response.data['uri'], confirm_uri) + self.assertEqual(response.data['email'], self.test_email) + self.assertEqual(response.data['username'], local_username) + self.assertEqual(response.data['first_name'], self.test_first_name) + self.assertEqual(response.data['last_name'], self.test_last_name) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_user_list_post_duplicate(self): + test_uri = '/api/users' + local_username = self.test_username + str(randint(11, 99)) + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password, 'first_name': self.test_first_name, 'last_name': self.test_last_name} + response = self.do_post(test_uri, data) + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 409) + self.assertGreater(response.data['message'], 0) + self.assertEqual(response.data['field_conflict'], 'username') + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_user_detail_get(self): + test_uri = '/api/users' + local_username = self.test_username + str(randint(11, 99)) + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password, 'first_name': self.test_first_name, 'last_name': self.test_last_name} + response = self.do_post(test_uri, data) + test_uri = test_uri + '/' + str(response.data['id']) + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertGreater(response.data['id'], 0) + confirm_uri = self.test_server_prefix + test_uri + self.assertEqual(response.data['uri'], confirm_uri) + self.assertEqual(response.data['email'], self.test_email) + self.assertEqual(response.data['username'], local_username) + self.assertEqual(response.data['first_name'], self.test_first_name) + self.assertEqual(response.data['last_name'], self.test_last_name) + self.assertEqual(len(response.data['resources']), 2) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_user_detail_delete(self): + test_uri = '/api/users' + local_username = self.test_username + str(randint(11, 99)) + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password, 'first_name': self.test_first_name, 'last_name': self.test_last_name} + response = self.do_post(test_uri, data) + test_uri = test_uri + '/' + str(response.data['id']) + response = self.do_delete(test_uri) + self.assertEqual(response.status_code, 204) + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + response = self.do_delete(test_uri) # User no longer exists, should get a 204 all the same + self.assertEqual(response.status_code, 204) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_user_detail_get_undefined(self): + test_uri = '/api/users/123456789' + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_user_groups_list_post(self): + test_uri = '/api/groups' + data = {'name': 'Alpha Group'} + response = self.do_post(test_uri, data) + group_id = response.data['id'] + test_uri = '/api/users' + local_username = self.test_username + str(randint(11, 99)) + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password, 'first_name': self.test_first_name, 'last_name': self.test_last_name} + response = self.do_post(test_uri, data) + user_id = response.data['id'] + test_uri = test_uri + '/' + str(response.data['id']) + response = self.do_get(test_uri) + test_uri = test_uri + '/groups' + data = {'group_id': group_id} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 201) + self.assertGreater(len(response.data['uri']), 0) + confirm_uri = self.test_server_prefix + test_uri + '/' + str(group_id) + self.assertEqual(response.data['uri'], confirm_uri) + self.assertEqual(response.data['group_id'], str(group_id)) + self.assertEqual(response.data['user_id'], str(user_id)) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_user_groups_list_post_duplicate(self): + test_uri = '/api/groups' + data = {'name': 'Alpha Group'} + response = self.do_post(test_uri, data) + group_id = response.data['id'] + test_uri = '/api/users' + local_username = self.test_username + str(randint(11, 99)) + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password, 'first_name': self.test_first_name, 'last_name': self.test_last_name} + response = self.do_post(test_uri, data) + test_uri = test_uri + '/' + str(response.data['id']) + response = self.do_get(test_uri) + test_uri = test_uri + '/groups' + data = {'group_id': group_id} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 201) + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 409) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_user_groups_list_post_invalid_user(self): + test_uri = '/api/groups' + data = {'name': 'Alpha Group'} + response = self.do_post(test_uri, data) + group_id = response.data['id'] + test_uri = '/api/users/897698769/groups' + data = {'group_id': group_id} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 404) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_user_groups_detail_get(self): + test_uri = '/api/groups' + data = {'name': 'Alpha Group'} + response = self.do_post(test_uri, data) + group_id = response.data['id'] + test_uri = '/api/users' + local_username = self.test_username + str(randint(11, 99)) + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password, 'first_name': self.test_first_name, 'last_name': self.test_last_name} + response = self.do_post(test_uri, data) + user_id = response.data['id'] + test_uri = test_uri + '/' + str(response.data['id']) + '/groups' + data = {'group_id': group_id} + response = self.do_post(test_uri, data) + test_uri = test_uri + '/' + str(group_id) + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data['uri']), 0) + confirm_uri = self.test_server_prefix + test_uri + self.assertEqual(response.data['uri'], confirm_uri) + self.assertEqual(response.data['group_id'], group_id) + self.assertEqual(response.data['user_id'], user_id) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_user_groups_detail_delete(self): + test_uri = '/api/groups' + data = {'name': 'Alpha Group'} + response = self.do_post(test_uri, data) + group_id = response.data['id'] + test_uri = '/api/users' + local_username = self.test_username + str(randint(11, 99)) + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password, 'first_name': self.test_first_name, 'last_name': self.test_last_name} + response = self.do_post(test_uri, data) + test_uri = test_uri + '/' + str(response.data['id']) + '/groups' + data = {'group_id': group_id} + response = self.do_post(test_uri, data) + test_uri = test_uri + '/' + str(group_id) + response = self.do_delete(test_uri) + self.assertEqual(response.status_code, 204) + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + response = self.do_delete(test_uri) # Relationship no longer exists, should get a 204 all the same + self.assertEqual(response.status_code, 204) + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_user_groups_detail_get_undefined(self): + test_uri = '/api/groups' + data = {'name': 'Alpha Group'} + response = self.do_post(test_uri, data) + group_id = response.data['id'] + test_uri = '/api/users' + local_username = self.test_username + str(randint(11, 99)) + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password, 'first_name': self.test_first_name, 'last_name': self.test_last_name} + response = self.do_post(test_uri, data) + user_id = response.data['id'] + test_uri = '/api/users/' + str(user_id) + '/groups/' + str(group_id) + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_user_courses_list_post(self): + course = CourseFactory.create() + test_uri = '/api/users' + local_username = self.test_username + str(randint(11, 99)) + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password, 'first_name': self.test_first_name, 'last_name': self.test_last_name} + response = self.do_post(test_uri, data) + user_id = response.data['id'] + test_uri = '{}/{}/courses'.format(test_uri, str(user_id)) + data = {'course_id': course.id} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 201) + confirm_uri = self.test_server_prefix + test_uri + '/' + course.id + self.assertEqual(response.data['uri'], confirm_uri) + self.assertEqual(response.data['id'], course.id) + self.assertTrue(response.data['is_active']) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_user_courses_list_post_undefined_user(self): + course = CourseFactory.create() + test_uri = '/api/users' + user_id = '234234' + test_uri = '{}/{}/courses'.format(test_uri, str(user_id)) + data = {'course_id': course.id} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 404) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_user_courses_list_post_undefined_course(self): + test_uri = '/api/users' + local_username = self.test_username + str(randint(11, 99)) + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password, 'first_name': self.test_first_name, 'last_name': self.test_last_name} + response = self.do_post(test_uri, data) + user_id = response.data['id'] + test_uri = '{}/{}/courses'.format(test_uri, str(user_id)) + data = {'course_id': '234asdfapsdf'} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 404) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_user_courses_list_get(self): + course = CourseFactory.create(display_name="TEST COURSE") + test_uri = '/api/users' + local_username = self.test_username + str(randint(11, 99)) + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password, 'first_name': self.test_first_name, 'last_name': self.test_last_name} + response = self.do_post(test_uri, data) + user_id = response.data['id'] + test_uri = '{}/{}/courses'.format(test_uri, str(user_id)) + data = {'course_id': course.id} + response = self.do_post(test_uri, data) + self.assertEqual(response.status_code, 201) + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + confirm_uri = self.test_server_prefix + test_uri + '/' + course.id + self.assertEqual(response.data[0]['uri'], confirm_uri) + self.assertEqual(response.data[0]['id'], course.id) + self.assertTrue(response.data[0]['is_active']) + self.assertEqual(response.data[0]['name'], course.display_name) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_user_courses_list_get_undefined_user(self): + test_uri = '/api/users/2134234/courses' + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_user_courses_detail_post_position_course_as_descriptor(self): + course = CourseFactory.create() + test_data = '{}'.format(str(uuid.uuid4())) + chapter1 = ItemFactory.create( + category="chapter", + parent_location=course.location, + data=test_data, + display_name="Chapter 1" + ) + chapter2 = ItemFactory.create( + category="chapter", + parent_location=course.location, + data=test_data, + display_name="Chapter 2" + ) + chapter3 = ItemFactory.create( + category="chapter", + parent_location=course.location, + data=test_data, + display_name="Chapter 3" + ) + test_uri = '/api/users' + local_username = self.test_username + str(randint(11, 99)) + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password, 'first_name': self.test_first_name, 'last_name': self.test_last_name} + response = self.do_post(test_uri, data) + user_id = response.data['id'] + test_uri = test_uri + '/' + str(user_id) + '/courses' + data = {'course_id': course.id} + response = self.do_post(test_uri, data) + test_uri = test_uri + '/' + str(course.id) + self.assertEqual(response.status_code, 201) + position_data = { + 'position': { + 'parent_module_id': str(course.id), + 'child_module_id': str(chapter3.location) + + } + } + response = self.do_post(test_uri, data=position_data) + self.assertEqual(response.data['position'], chapter3.id) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_user_courses_detail_post_position_invalid_user(self): + course = CourseFactory.create() + test_data = '{}'.format(str(uuid.uuid4())) + chapter1 = ItemFactory.create( + category="chapter", + parent_location=course.location, + data=test_data, + display_name="Chapter 1" + ) + user_id = 2342334 + course_id = 'asdfa9sd8fasdf' + test_uri = '/api/users/{}/courses/{}'.format(str(user_id), course_id) + position_data = { + 'position': { + 'parent_module_id': course_id, + 'child_module_id': str(chapter1.location) + + } + } + response = self.do_post(test_uri, data=position_data) + self.assertEqual(response.status_code, 404) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_user_courses_detail_post_position_course_as_module(self): + course = CourseFactory.create() + test_data = '{}'.format(str(uuid.uuid4())) + chapter1 = ItemFactory.create( + category="chapter", + parent_location=course.location, + data=test_data, + display_name="Chapter 1" + ) + test_uri = '/api/users' + local_username = self.test_username + str(randint(11, 99)) + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password, 'first_name': self.test_first_name, 'last_name': self.test_last_name} + response = self.do_post(test_uri, data) + user_id = response.data['id'] + test_uri = test_uri + '/' + str(user_id) + '/courses' + data = {'course_id': course.id} + response = self.do_post(test_uri, data) + test_uri = test_uri + '/' + str(course.id) + self.assertEqual(response.status_code, 201) + position_data = { + 'position': { + 'parent_module_id': str(course.location), + 'child_module_id': str(chapter1.location) + + } + } + response = self.do_post(test_uri, data=position_data) + self.assertEqual(response.data['position'], chapter1.id) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_user_courses_detail_get(self): + course = CourseFactory.create() + test_data = '{}'.format(str(uuid.uuid4())) + chapter1 = ItemFactory.create( + category="chapter", + parent_location=course.location, + data=test_data, + display_name="Overview" + ) + test_uri = '/api/users' + local_username = self.test_username + str(randint(11, 99)) + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password, 'first_name': self.test_first_name, 'last_name': self.test_last_name} + response = self.do_post(test_uri, data) + user_id = response.data['id'] + test_uri = test_uri + '/' + str(user_id) + '/courses' + data = {'course_id': course.id} + response = self.do_post(test_uri, data) + test_uri = test_uri + '/' + str(course.id) + self.assertEqual(response.status_code, 201) + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + confirm_uri = self.test_server_prefix + test_uri + self.assertEqual(response.data['uri'], confirm_uri) + self.assertEqual(response.data['course_id'], course.id) + self.assertEqual(response.data['user_id'], user_id) + position_data = { + 'position': { + 'parent_module_id': str(course.location), + 'child_module_id': str(chapter1.location) + + } + } + response = self.do_post(confirm_uri, data=position_data) + self.assertEqual(response.data['position'], chapter1.id) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_user_courses_detail_get_undefined_user(self): + test_uri = '/api/users/2134234/courses/a8df7asvd98' + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_user_courses_detail_get_undefined_enrollment(self): + course = CourseFactory.create() + test_uri = '/api/users' + local_username = self.test_username + str(randint(11, 99)) + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password, 'first_name': self.test_first_name, 'last_name': self.test_last_name} + response = self.do_post(test_uri, data) + user_id = response.data['id'] + test_uri = '/api/users/' + str(user_id) + '/courses/' + str(course.id) + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_user_courses_detail_delete(self): + course = CourseFactory.create() + test_uri = '/api/users' + local_username = self.test_username + str(randint(11, 99)) + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password, 'first_name': self.test_first_name, 'last_name': self.test_last_name} + response = self.do_post(test_uri, data) + user_id = response.data['id'] + post_uri = test_uri + '/' + str(user_id) + '/courses' + data = {'course_id': course.id} + response = self.do_post(post_uri, data) + self.assertEqual(response.status_code, 201) + test_uri = post_uri + '/' + str(course.id) + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + response = self.do_delete(test_uri) + self.assertEqual(response.status_code, 204) + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + response = self.do_post(post_uri, data) # Re-enroll the student in the course + self.assertEqual(response.status_code, 201) + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + response = self.do_delete(test_uri) + self.assertEqual(response.status_code, 204) + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 404) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_user_courses_detail_delete_undefined_user(self): + course = CourseFactory.create() + user_id = '2134234' + test_uri = '/api/users/{}/courses/{}'.format(str(user_id), str(course.id)) + response = self.do_delete(test_uri) + self.assertEqual(response.status_code, 204) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_user_courses_detail_delete_undefined_course(self): + test_uri = '/api/users' + local_username = self.test_username + str(randint(11, 99)) + data = {'email': self.test_email, 'username': local_username, 'password': self.test_password, 'first_name': self.test_first_name, 'last_name': self.test_last_name} + response = self.do_post(test_uri, data) + user_id = response.data['id'] + test_uri = '{}/{}/oasdf987sdf'.format(test_uri, str(user_id)) + response = self.do_delete(test_uri) + self.assertEqual(response.status_code, 404) diff --git a/lms/djangoapps/api_manager/tests/test_views.py b/lms/djangoapps/api_manager/tests/test_views.py new file mode 100644 index 000000000000..e95326f2e98b --- /dev/null +++ b/lms/djangoapps/api_manager/tests/test_views.py @@ -0,0 +1,79 @@ +# pylint: disable=E1103 + +""" +Run these tests @ Devstack: + rake fasttest_lms[common/djangoapps/api_manager/tests/test_views.py] +""" +import unittest +import uuid + +from django.conf import settings +from django.core.cache import cache +from django.test import TestCase, Client +from django.test.utils import override_settings + +TEST_API_KEY = str(uuid.uuid4()) + + +class SecureClient(Client): + """ Django test client using a "secure" connection. """ + def __init__(self, *args, **kwargs): + kwargs = kwargs.copy() + kwargs.update({'SERVER_PORT': 443, 'wsgi.url_scheme': 'https'}) + super(SecureClient, self).__init__(*args, **kwargs) + + +@override_settings(EDX_API_KEY=TEST_API_KEY) +class SystemApiTests(TestCase): + """ Test suite for base API views """ + + def setUp(self): + self.test_server_prefix = "https://testserver/api" + self.test_username = str(uuid.uuid4()) + self.test_password = str(uuid.uuid4()) + self.test_email = str(uuid.uuid4()) + '@test.org' + self.test_group_name = str(uuid.uuid4()) + + self.client = SecureClient() + cache.clear() + + def do_get(self, uri): + """Submit an HTTP GET request""" + headers = { + 'Content-Type': 'application/json', + 'X-Edx-Api-Key': str(TEST_API_KEY), + } + response = self.client.get(uri, headers=headers) + return response + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_system_detail_get(self): + """ Ensure the system returns base data about the system """ + test_uri = self.test_server_prefix + '/system' + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertIsNotNone(response.data['uri']) + self.assertGreater(len(response.data['uri']), 0) + self.assertEqual(response.data['uri'], test_uri) + self.assertIsNotNone(response.data['documentation']) + self.assertGreater(len(response.data['documentation']), 0) + self.assertIsNotNone(response.data['name']) + self.assertGreater(len(response.data['name']), 0) + self.assertIsNotNone(response.data['description']) + self.assertGreater(len(response.data['description']), 0) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_system_detail_api_get(self): + """ Ensure the system returns base data about the API """ + test_uri = self.test_server_prefix + response = self.do_get(test_uri) + self.assertEqual(response.status_code, 200) + self.assertIsNotNone(response.data['uri']) + self.assertGreater(len(response.data['uri']), 0) + self.assertEqual(response.data['uri'], test_uri) + self.assertIsNotNone(response.data['documentation']) + self.assertGreater(len(response.data['documentation']), 0) + self.assertIsNotNone(response.data['name']) + self.assertGreater(len(response.data['name']), 0) + self.assertIsNotNone(response.data['description']) + self.assertGreater(len(response.data['description']), 0) diff --git a/lms/djangoapps/api_manager/urls.py b/lms/djangoapps/api_manager/urls.py new file mode 100644 index 000000000000..b81295e5aefe --- /dev/null +++ b/lms/djangoapps/api_manager/urls.py @@ -0,0 +1,20 @@ +""" + The URI scheme for resources is as follows: + Resource type: /api/{resource_type} + Specific resource: /api/{resource_type}/{resource_id} + + The remaining URIs provide information about the API and/or module + System: General context and intended usage + API: Top-level description of overall API (must live somewhere) +""" + +from django.conf.urls import include, patterns, url + +urlpatterns = patterns('api_manager.system_views', + url(r'^$', 'api_detail'), + url(r'^system$', 'system_detail'), + url(r'^users/*', include('api_manager.users_urls')), + url(r'^groups/*', include('api_manager.groups_urls')), + url(r'^sessions/*', include('api_manager.sessions_urls')), + url(r'^courses/*', include('api_manager.courses_urls')), + ) diff --git a/lms/djangoapps/api_manager/users_urls.py b/lms/djangoapps/api_manager/users_urls.py new file mode 100644 index 000000000000..9eeb4460cbc1 --- /dev/null +++ b/lms/djangoapps/api_manager/users_urls.py @@ -0,0 +1,11 @@ +""" Users API URI specification """ +from django.conf.urls import patterns, url + +urlpatterns = patterns('api_manager.users_views', + url(r'/*$^', 'user_list'), + url(r'^(?P[0-9]+)$', 'user_detail'), + url(r'^(?P[0-9]+)/courses/*$', 'user_courses_list'), + url(r'^(?P[0-9]+)/courses/(?P[a-zA-Z0-9/_:]+)$', 'user_courses_detail'), + url(r'^(?P[0-9]+)/groups/*$', 'user_groups_list'), + url(r'^(?P[0-9]+)/groups/(?P[0-9]+)$', 'user_groups_detail'), + ) diff --git a/lms/djangoapps/api_manager/users_views.py b/lms/djangoapps/api_manager/users_views.py new file mode 100644 index 000000000000..aa5783059c33 --- /dev/null +++ b/lms/djangoapps/api_manager/users_views.py @@ -0,0 +1,381 @@ +""" API implementation for user-oriented interactions. """ + +import logging + +from django.contrib.auth.models import User, Group +from django.core.exceptions import ObjectDoesNotExist +from django.db import IntegrityError +from django.core.validators import validate_email, validate_slug, ValidationError +from django.conf import settings +from django.utils.translation import ugettext_lazy as _ + +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.response import Response + +from api_manager.permissions import ApiKeyHeaderPermission +from courseware import module_render +from courseware.model_data import FieldDataCache +from courseware.views import get_module_for_descriptor, save_child_position, get_current_child +from student.models import CourseEnrollment, PasswordHistory +from xmodule.modulestore.django import modulestore +from util.password_policy_validators import ( + validate_password_length, validate_password_complexity, + validate_password_dictionary +) + +log = logging.getLogger(__name__) + + +def _generate_base_uri(request): + """ + Constructs the protocol:host:path component of the resource uri + """ + protocol = 'http' + if request.is_secure(): + protocol = protocol + 's' + resource_uri = '{}://{}{}'.format( + protocol, + request.get_host(), + request.path + ) + return resource_uri + + +def _serialize_user(response_data, user): + """ + Loads the object data into the response dict + This should probably evolve to use DRF serializers + """ + response_data['email'] = user.email + response_data['username'] = user.username + response_data['first_name'] = user.first_name + response_data['last_name'] = user.last_name + response_data['id'] = user.id + return response_data + + +def _save_module_position(request, user, course_id, course_descriptor, position): + """ + Records the indicated position for the specified course + Really no reason to generalize this out of user_courses_detail aside from pylint complaining + """ + field_data_cache = FieldDataCache([course_descriptor], course_id, user) + if course_id == position['parent_module_id']: + parent_module = get_module_for_descriptor( + user, + request, + course_descriptor, + field_data_cache, + course_id + ) + else: + parent_module = module_render.get_module( + user, + request, + position['parent_module_id'], + field_data_cache, + course_id + ) + child_module = module_render.get_module( + user, + request, + position['child_module_id'], + field_data_cache, + course_id + ) + save_child_position(parent_module, child_module.location.name) + saved_module = get_current_child(parent_module) + return saved_module.id + + +@api_view(['POST']) +@permission_classes((ApiKeyHeaderPermission,)) +def user_list(request): + """ + POST creates a new user in the system + """ + response_data = {} + base_uri = _generate_base_uri(request) + email = request.DATA['email'] + username = request.DATA['username'] + password = request.DATA['password'] + first_name = request.DATA.get('first_name', '') + last_name = request.DATA.get('last_name', '') + + # enforce password complexity as an optional feature + if settings.FEATURES.get('ENFORCE_PASSWORD_POLICY', False): + try: + validate_password_length(password) + validate_password_complexity(password) + validate_password_dictionary(password) + except ValidationError, err: + status_code = status.HTTP_400_BAD_REQUEST + response_data['message'] = _('Password: ') + '; '.join(err.messages) + return Response(response_data, status=status_code) + try: + validate_email(email) + except ValidationError: + status_code = status.HTTP_400_BAD_REQUEST + response_data['message'] = _('Valid e-mail is required.') + return Response(response_data, status=status_code) + + try: + validate_slug(username) + except ValidationError: + status_code = status.HTTP_400_BAD_REQUEST + response_data['message'] = _('Username should only consist of A-Z and 0-9, with no spaces.') + return Response(response_data, status=status_code) + + + try: + user = User.objects.create(email=email, username=username) + except IntegrityError: + user = None + else: + user.set_password(password) + user.first_name = first_name + user.last_name = last_name + user.save() + + # add this account creation to password history + # NOTE, this will be a NOP unless the feature has been turned on in configuration + password_history_entry = PasswordHistory() + password_history_entry.create(user) + + # CDODGE: @TODO: We will have to extend this to look in the CourseEnrollmentAllowed table and + # auto-enroll students when they create a new account. Also be sure to remove from + # the CourseEnrollmentAllow table after the auto-registration has taken place + if user: + status_code = status.HTTP_201_CREATED + response_data = _serialize_user(response_data, user) + response_data['uri'] = '{}/{}'.format(base_uri, str(user.id)) + else: + status_code = status.HTTP_409_CONFLICT + response_data['message'] = "User '%s' already exists", username + response_data['field_conflict'] = "username" + return Response(response_data, status=status_code) + + +@api_view(['GET', 'DELETE']) +@permission_classes((ApiKeyHeaderPermission,)) +def user_detail(request, user_id): + """ + GET retrieves an existing user from the system + DELETE removes/inactivates/etc. an existing user + """ + if request.method == 'GET': + response_data = {} + base_uri = _generate_base_uri(request) + try: + existing_user = User.objects.get(id=user_id, is_active=True) + _serialize_user(response_data, existing_user) + response_data['uri'] = base_uri + response_data['resources'] = [] + resource_uri = '{}/groups'.format(base_uri) + response_data['resources'].append({'uri': resource_uri}) + resource_uri = '{}/courses'.format(base_uri) + response_data['resources'].append({'uri': resource_uri}) + return Response(response_data, status=status.HTTP_200_OK) + except ObjectDoesNotExist: + return Response(response_data, status=status.HTTP_404_NOT_FOUND) + elif request.method == 'DELETE': + response_data = {} + try: + existing_user = User.objects.get(id=user_id, is_active=True) + existing_user.is_active = False + existing_user.save() + except ObjectDoesNotExist: + # It's ok if we don't find a match + pass + return Response(response_data, status=status.HTTP_204_NO_CONTENT) + + +@api_view(['POST']) +@permission_classes((ApiKeyHeaderPermission,)) +def user_groups_list(request, user_id): + """ + POST creates a new user-group relationship in the system + """ + response_data = {} + group_id = request.DATA['group_id'] + base_uri = _generate_base_uri(request) + response_data['uri'] = '{}/{}'.format(base_uri, str(group_id)) + try: + existing_user = User.objects.get(id=user_id) + existing_group = Group.objects.get(id=group_id) + except ObjectDoesNotExist: + existing_user = None + existing_group = None + if existing_user and existing_group: + try: + existing_relationship = existing_user.groups.get(id=existing_group.id) + except ObjectDoesNotExist: + existing_relationship = None + if existing_relationship is None: + existing_user.groups.add(existing_group.id) + response_data['uri'] = '{}/{}'.format(base_uri, existing_user.id) + response_data['group_id'] = str(existing_group.id) + response_data['user_id'] = str(existing_user.id) + response_status = status.HTTP_201_CREATED + else: + response_data['uri'] = '{}/{}'.format(base_uri, existing_group.id) + response_data['message'] = "Relationship already exists." + response_status = status.HTTP_409_CONFLICT + else: + response_status = status.HTTP_404_NOT_FOUND + return Response(response_data, status=response_status) + + +@api_view(['GET', 'DELETE']) +@permission_classes((ApiKeyHeaderPermission,)) +def user_groups_detail(request, user_id, group_id): + """ + GET retrieves an existing user-group relationship from the system + DELETE removes/inactivates/etc. an existing user-group relationship + """ + if request.method == 'GET': + response_data = {} + base_uri = _generate_base_uri(request) + try: + existing_user = User.objects.get(id=user_id, is_active=True) + existing_relationship = existing_user.groups.get(id=group_id) + except ObjectDoesNotExist: + existing_user = None + existing_relationship = None + if existing_user and existing_relationship: + response_data['user_id'] = existing_user.id + response_data['group_id'] = existing_relationship.id + response_data['uri'] = base_uri + response_status = status.HTTP_200_OK + else: + response_status = status.HTTP_404_NOT_FOUND + return Response(response_data, status=response_status) + elif request.method == 'DELETE': + existing_user = User.objects.get(id=user_id, is_active=True) + existing_user.groups.remove(group_id) + existing_user.save() + return Response({}, status=status.HTTP_204_NO_CONTENT) + + +@api_view(['POST', 'GET']) +@permission_classes((ApiKeyHeaderPermission,)) +def user_courses_list(request, user_id): + """ + POST creates a new course enrollment for a user + GET creates the list of enrolled courses for a user + """ + if request.method == 'POST': + store = modulestore() + response_data = {} + user_id = user_id + course_id = request.DATA['course_id'] + try: + user = User.objects.get(id=user_id) + course_descriptor = store.get_course(course_id) + except (ObjectDoesNotExist, ValueError): + user = None + course_descriptor = None + if user and course_descriptor: + base_uri = _generate_base_uri(request) + course_enrollment = CourseEnrollment.enroll(user, course_id) + response_data['uri'] = '{}/{}'.format(base_uri, course_id) + response_data['id'] = course_id + response_data['name'] = course_descriptor.display_name + response_data['is_active'] = course_enrollment.is_active + status_code = status.HTTP_201_CREATED + else: + status_code = status.HTTP_404_NOT_FOUND + return Response(response_data, status=status_code) + elif request.method == 'GET': + store = modulestore() + response_data = [] + base_uri = _generate_base_uri(request) + try: + user = User.objects.get(id=user_id) + except ObjectDoesNotExist: + user = None + if user: + enrollments = CourseEnrollment.enrollments_for_user(user=user) + for enrollment in enrollments: + descriptor = store.get_course(enrollment.course_id) + course_data = { + "id": enrollment.course_id, + "uri": '{}/{}'.format(base_uri, enrollment.course_id), + "is_active": enrollment.is_active, + "name": descriptor.display_name + } + response_data.append(course_data) + return Response(response_data, status=status.HTTP_200_OK) + else: + status_code = status.HTTP_404_NOT_FOUND + return Response(response_data, status=status_code) + + +@api_view(['GET', 'POST', 'DELETE']) +@permission_classes((ApiKeyHeaderPermission,)) +def user_courses_detail(request, user_id, course_id): + """ + GET identifies an ACTIVE course enrollment for the specified user + DELETE unenrolls the specified user from a course + """ + if request.method == 'GET': + store = modulestore() + response_data = {} + base_uri = _generate_base_uri(request) + try: + user = User.objects.get(id=user_id, is_active=True) + course_descriptor = store.get_course(course_id) + except (ObjectDoesNotExist, ValueError): + user = None + course_descriptor = None + if user and CourseEnrollment.is_enrolled(user, course_id): + response_data['user_id'] = user.id + response_data['course_id'] = course_id + response_data['uri'] = base_uri + field_data_cache = FieldDataCache([course_descriptor], course_id, user) + course_module = module_render.get_module( + user, + request, + course_descriptor.location, + field_data_cache, + course_id) + response_data['position'] = course_module.position + response_status = status.HTTP_200_OK + else: + response_status = status.HTTP_404_NOT_FOUND + return Response(response_data, status=response_status) + elif request.method == 'POST': + store = modulestore() + base_uri = _generate_base_uri(request) + response_data = {} + response_data['uri'] = base_uri + try: + user = User.objects.get(id=user_id) + course_descriptor = store.get_course(course_id) + except (ObjectDoesNotExist, ValueError): + user = None + course_descriptor = None + if user and course_descriptor: + response_data['user_id'] = user.id + response_data['course_id'] = course_id + response_status = status.HTTP_201_CREATED + if request.DATA['position']: + response_data['position'] = _save_module_position( + request, + user, + course_id, + course_descriptor, + request.DATA['position'] + ) + else: + response_status = status.HTTP_404_NOT_FOUND + return Response(response_data, status=response_status) + elif request.method == 'DELETE': + try: + user = User.objects.get(id=user_id, is_active=True) + except ObjectDoesNotExist: + user = None + if user: + CourseEnrollment.unenroll(user, course_id) + return Response({}, status=status.HTTP_204_NO_CONTENT) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 9e6f246688d8..73b2a835f6bf 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -11,6 +11,7 @@ from django.conf import settings from django.contrib.auth.models import User from django.core.cache import cache +from django.core.context_processors import csrf from django.core.exceptions import PermissionDenied from django.core.urlresolvers import reverse from django.http import Http404, HttpResponse @@ -29,7 +30,7 @@ from xblock.core import XBlock from xblock.fields import Scope from xblock.runtime import KvsFieldData, KeyValueStore -from xblock.exceptions import NoSuchHandlerError +from xblock.exceptions import NoSuchHandlerError, NoSuchViewError from xblock.django.request import django_to_webob_request, webob_to_django_response from xmodule.error_module import ErrorDescriptor, NonStaffErrorDescriptor from xmodule.exceptions import NotFoundError, ProcessingError @@ -562,23 +563,19 @@ def xblock_resource(request, block_type, uri): # pylint: disable=unused-argumen return HttpResponse(content, mimetype=mimetype) -def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user): +def _get_module_by_usage_id(request, course_id, usage_id): """ - Invoke an XBlock handler, either authenticated or not. + Gets a module instance based on its `usage_id` in a course, for a given request/user + Returns (location, descriptor, instance) """ location = unquote_slashes(usage_id) + user = request.user # Check parameters and fail fast if there's a problem if not Location.is_valid(location): raise Http404("Invalid location") - # Check submitted files - files = request.FILES or {} - error_msg = _check_files_limits(files) - if error_msg: - return HttpResponse(json.dumps({'success': error_msg})) - try: descriptor = modulestore().get_instance(course_id, location) except ItemNotFoundError: @@ -590,13 +587,6 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user): ) raise Http404 - tracking_context_name = 'module_callback_handler' - tracking_context = { - 'module': { - 'display_name': descriptor.display_name_with_default, - } - } - field_data_cache = FieldDataCache.cache_for_descriptor_descendents( course_id, user, @@ -609,6 +599,29 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user): log.debug("No module %s for user %s -- access denied?", location, user) raise Http404 + return (location, descriptor, instance) + + +def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user): + """ + Invoke an XBlock handler, either authenticated or not. + + """ + location, descriptor, instance = _get_module_by_usage_id(request, course_id, usage_id) + + # Check submitted files + files = request.FILES or {} + error_msg = _check_files_limits(files) + if error_msg: + return HttpResponse(json.dumps({'success': error_msg})) + + tracking_context_name = 'module_callback_handler' + tracking_context = { + 'module': { + 'display_name': descriptor.display_name_with_default, + } + } + req = django_to_webob_request(request) try: with tracker.get_tracker().context(tracking_context_name, tracking_context): @@ -637,6 +650,30 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user): return webob_to_django_response(resp) +def xblock_view(request, course_id, usage_id, view_name): + """ + Returns the rendered view of a given XBlock, with related resources + + Returns a json object containing two keys: + html: The rendered html of the view + resources: A list of tuples where the first element is the resource hash, and + the second is the resource description + """ + location, descriptor, instance = _get_module_by_usage_id(request, course_id, usage_id) + + try: + fragment = instance.render(view_name) + except NoSuchViewError: + log.exception("Attempt to render missing view on %s: %s", instance, view_name) + raise Http404 + + return JsonResponse({ + 'html': fragment.content, + 'resources': fragment.resources, + 'csrf_token': str(csrf(request)['csrf_token']), + }) + + def get_score_bucket(grade, max_grade): """ Function to split arbitrary score ranges into 3 buckets. diff --git a/lms/djangoapps/courseware/tests/test_navigation.py b/lms/djangoapps/courseware/tests/test_navigation.py index fb9cf95f27df..ac01e540ae86 100644 --- a/lms/djangoapps/courseware/tests/test_navigation.py +++ b/lms/djangoapps/courseware/tests/test_navigation.py @@ -2,6 +2,7 @@ This test file will run through some LMS test scenarios regarding access and navigation of the LMS """ import time +import mock from django.conf import settings from django.core.urlresolvers import reverse @@ -125,3 +126,89 @@ def test_accordion_state(self): self.assertRedirects(resp, reverse('courseware_chapter', kwargs={'course_id': self.course.id, 'chapter': 'factory_chapter'})) + + @mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_COURSEWARE_GOOGLE_ANALYTICS': True}) + def test_courseware_google_analytics(self): + """ + Verifies that when we turn on the feature to get google analytics on courseware + we see the expected HTML + """ + + email, password = self.STUDENT_INFO[0] + self.login(email, password) + self.enroll(self.course, True) + + # Now we directly navigate to a section in a chapter other than 'Overview'. + resp = self.client.get(reverse('courseware', + kwargs={'course_id': self.course.id}), follow=True) + + self.assertIn("_gaq.push(['_setAccount', 'add-your-GA-account-ID-here']);", resp.content) + + @mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_COURSEWARE_GOOGLE_ANALYTICS': False}) + def test_courseware_google_analytics_disabled(self): + """ + Verifies that when we turn off the feature to get google analytics on courseware + we do not see the associated HTML + """ + + email, password = self.STUDENT_INFO[0] + self.login(email, password) + self.enroll(self.course, True) + + # Now we directly navigate to a section in a chapter other than 'Overview'. + resp = self.client.get(reverse('courseware', + kwargs={'course_id': self.course.id}), follow=True) + + self.assertNotIn("_gaq", resp.content) + + @mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_COURSEWARE_GOOGLE_ANALYTICS': True}) + @override_settings(GOOGLE_ANALYTICS_ACCOUNT_ID='dummy') + def test_courseware_google_analytics_custom_account(self): + """ + Verifies that when we override the Google Analytics account Id, that it appears in + the Google Analytics rendering + """ + + email, password = self.STUDENT_INFO[0] + self.login(email, password) + self.enroll(self.course, True) + + # Now we directly navigate to a section in a chapter other than 'Overview'. + resp = self.client.get(reverse('courseware', + kwargs={'course_id': self.course.id}), follow=True) + + self.assertIn("_gaq.push(['_setAccount', 'dummy']);", resp.content) + + def test_courseware_custom_site_verification(self): + """ + Verifies that when we override the Google Analytics account Id, that it appears in + the Google Analytics rendering + """ + + email, password = self.STUDENT_INFO[0] + self.login(email, password) + self.enroll(self.course, True) + + # Now we directly navigate to a section in a chapter other than 'Overview'. + resp = self.client.get(reverse('courseware', + kwargs={'course_id': self.course.id}), follow=True) + + self.assertIn('', + resp.content) + + @override_settings(GOOGLE_SITE_VERIFICATION='dummy') + def test_courseware_default_site_verification(self): + """ + Verifies that when we override the Google Analytics account Id, that it appears in + the Google Analytics rendering + """ + + email, password = self.STUDENT_INFO[0] + self.login(email, password) + self.enroll(self.course, True) + + # Now we directly navigate to a section in a chapter other than 'Overview'. + resp = self.client.get(reverse('courseware', + kwargs={'course_id': self.course.id}), follow=True) + + self.assertIn('', resp.content) diff --git a/lms/djangoapps/courseware/tests/test_password_history.py b/lms/djangoapps/courseware/tests/test_password_history.py new file mode 100644 index 000000000000..de9befecd111 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_password_history.py @@ -0,0 +1,354 @@ +""" +This file will test through the LMS some of the PasswordHistory features +""" +import json +from mock import patch +from uuid import uuid4 + +from django.contrib.auth.models import User +from django.utils import timezone +from datetime import timedelta +from django.test.utils import override_settings + +from django.core.urlresolvers import reverse +from django.contrib.auth.tokens import default_token_generator +from django.utils.http import int_to_base36 + +from freezegun import freeze_time + +from student.models import PasswordHistory +from courseware.tests.helpers import LoginEnrollmentTestCase + + +@patch.dict("django.conf.settings.FEATURES", {'ADVANCED_SECURITY': True}) +class TestPasswordHistory(LoginEnrollmentTestCase): + """ + Go through some of the PasswordHistory use cases + """ + + def _login(self, email, password, should_succeed=True, err_msg_check=None): + """ + Override the base implementation so we can do appropriate asserts + """ + resp = self.client.post(reverse('login'), {'email': email, 'password': password}) + data = json.loads(resp.content) + + self.assertEqual(resp.status_code, 200) + if should_succeed: + self.assertTrue(data['success']) + else: + self.assertFalse(data['success']) + if err_msg_check: + self.assertIn(err_msg_check, data['value']) + + def _setup_user(self, is_staff=False, password=None): + """ + Override the base implementation to randomize the email + """ + email = 'foo_{0}@test.com'.format(uuid4().hex[:8]) + password = password if password else 'foo' + username = 'test_{0}'.format(uuid4().hex[:8]) + self.create_account(username, email, password) + self.activate_user(email) + + # manually twiddle the is_staff bit, if needed + if is_staff: + user = User.objects.get(email=email) + user.is_staff = True + user.save() + + return email, password + + def _update_password(self, email, new_password): + """ + Helper method to reset a password + """ + user = User.objects.get(email=email) + user.set_password(new_password) + user.save() + history = PasswordHistory() + history.create(user) + + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DAYS_FOR_STAFF_ACCOUNTS_PASSWORD_RESETS': None}) + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DAYS_FOR_STUDENT_ACCOUNTS_PASSWORD_RESETS': None}) + def test_no_forced_password_change(self): + """ + Makes sure default behavior is correct when we don't have this turned on + """ + + email, password = self._setup_user() + self._login(email, password) + + email, password = self._setup_user(is_staff=True) + self._login(email, password) + + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DAYS_FOR_STAFF_ACCOUNTS_PASSWORD_RESETS': 1}) + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DAYS_FOR_STUDENT_ACCOUNTS_PASSWORD_RESETS': 5}) + def test_forced_password_change(self): + """ + Make sure password are viewed as expired in LMS after the policy time has elapsed + """ + + student_email, student_password = self._setup_user() + staff_email, staff_password = self._setup_user(is_staff=True) + + self._login(student_email, student_password) + self._login(staff_email, staff_password) + + staff_reset_time = timezone.now() + timedelta(days=1) + with freeze_time(staff_reset_time): + self._login(student_email, student_password) + + # staff should fail because password expired + self._login(staff_email, staff_password, should_succeed=False, + err_msg_check="Your password has expired due to password policy on this account") + + # if we reset the password, we should be able to log in + self._update_password(staff_email, "updated") + self._login(staff_email, "updated") + + student_reset_time = timezone.now() + timedelta(days=5) + with freeze_time(student_reset_time): + # Both staff and student logins should fail because user must + # reset the password + + self._login(student_email, student_password, should_succeed=False, + err_msg_check="Your password has expired due to password policy on this account") + self._update_password(student_email, "updated") + self._login(student_email, "updated") + + self._login(staff_email, staff_password, should_succeed=False, + err_msg_check="Your password has expired due to password policy on this account") + self._update_password(staff_email, "updated2") + self._login(staff_email, "updated2") + + def test_allow_all_password_reuse(self): + """ + Tests that password_reset flows work as expected if reuse config is missing, meaning + passwords can always be reused + """ + student_email, _ = self._setup_user() + user = User.objects.get(email=student_email) + + err_msg = 'You are re-using a password that you have used recently.' + + token = default_token_generator.make_token(user) + uidb36 = int_to_base36(user.id) + + # try to do a password reset with the same password as before + resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { + 'new_password1': 'foo', + 'new_password2': 'foo' + }, follow=True) + + self.assertNotIn( + err_msg, + resp.content + ) + + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE': 1}) + def test_student_password_reset_reuse(self): + """ + Goes through the password reset flows to make sure the various password reuse policies are enforced + """ + student_email, _ = self._setup_user() + user = User.objects.get(email=student_email) + + err_msg = 'You are re-using a password that you have used recently. You must have 1 distinct password(s)' + success_msg = 'Your Password Reset is Complete' + + token = default_token_generator.make_token(user) + uidb36 = int_to_base36(user.id) + + # try to do a password reset with the same password as before + resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { + 'new_password1': 'foo', + 'new_password2': 'foo' + }, follow=True) + + self.assertIn( + err_msg, + resp.content + ) + + # now retry with a different password + resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { + 'new_password1': 'bar', + 'new_password2': 'bar' + }, follow=True) + + self.assertIn( + success_msg, + resp.content + ) + + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE': 2}) + def test_staff_password_reset_reuse(self): + """ + Goes through the password reset flows to make sure the various password reuse policies are enforced + """ + staff_email, _ = self._setup_user(is_staff=True) + user = User.objects.get(email=staff_email) + + err_msg = 'You are re-using a password that you have used recently. You must have 2 distinct password(s)' + success_msg = 'Your Password Reset is Complete' + + token = default_token_generator.make_token(user) + uidb36 = int_to_base36(user.id) + + # try to do a password reset with the same password as before + resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { + 'new_password1': 'foo', + 'new_password2': 'foo', + }, follow=True) + + self.assertIn( + err_msg, + resp.content + ) + + # now use different one + user = User.objects.get(email=staff_email) + token = default_token_generator.make_token(user) + uidb36 = int_to_base36(user.id) + + resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { + 'new_password1': 'bar', + 'new_password2': 'bar', + }, follow=True) + + self.assertIn( + success_msg, + resp.content + ) + + # now try again with the first one + user = User.objects.get(email=staff_email) + token = default_token_generator.make_token(user) + uidb36 = int_to_base36(user.id) + + resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { + 'new_password1': 'foo', + 'new_password2': 'foo', + }, follow=True) + + # should be rejected + self.assertIn( + err_msg, + resp.content + ) + + # now use different one + user = User.objects.get(email=staff_email) + token = default_token_generator.make_token(user) + uidb36 = int_to_base36(user.id) + + resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { + 'new_password1': 'baz', + 'new_password2': 'baz', + }, follow=True) + + self.assertIn( + success_msg, + resp.content + ) + + # now we should be able to reuse the first one + user = User.objects.get(email=staff_email) + token = default_token_generator.make_token(user) + uidb36 = int_to_base36(user.id) + + resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { + 'new_password1': 'foo', + 'new_password2': 'foo', + }, follow=True) + + self.assertIn( + success_msg, + resp.content + ) + + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS': 1}) + def test_password_reset_frequency_limit(self): + """ + Asserts the frequency limit on how often we can change passwords + """ + staff_email, _ = self._setup_user(is_staff=True) + + success_msg = 'Your Password Reset is Complete' + + # try to reset password, it should fail + user = User.objects.get(email=staff_email) + token = default_token_generator.make_token(user) + uidb36 = int_to_base36(user.id) + + # try to do a password reset with the same password as before + resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { + 'new_password1': 'foo', + 'new_password2': 'foo', + }, follow=True) + + self.assertNotIn( + success_msg, + resp.content + ) + + # pretend we're in the future + staff_reset_time = timezone.now() + timedelta(days=1) + with freeze_time(staff_reset_time): + user = User.objects.get(email=staff_email) + token = default_token_generator.make_token(user) + uidb36 = int_to_base36(user.id) + + # try to do a password reset with the same password as before + resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { + 'new_password1': 'foo', + 'new_password2': 'foo', + }, follow=True) + + self.assertIn( + success_msg, + resp.content + ) + + @patch.dict("django.conf.settings.FEATURES", {'ENFORCE_PASSWORD_POLICY': True}) + @override_settings(PASSWORD_MIN_LENGTH=6) + def test_password_policy_on_password_reset(self): + """ + This makes sure the proper asserts on password policy also works on password reset + """ + staff_email, _ = self._setup_user(is_staff=True, password='foofoo') + + success_msg = 'Your Password Reset is Complete' + + # try to reset password, it should fail + user = User.objects.get(email=staff_email) + token = default_token_generator.make_token(user) + uidb36 = int_to_base36(user.id) + + # try to do a password reset with the same password as before + resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { + 'new_password1': 'foo', + 'new_password2': 'foo', + }, follow=True) + + self.assertNotIn( + success_msg, + resp.content + ) + + # try to reset password with a long enough password + user = User.objects.get(email=staff_email) + token = default_token_generator.make_token(user) + uidb36 = int_to_base36(user.id) + + # try to do a password reset with the same password as before + resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { + 'new_password1': 'foofoo', + 'new_password2': 'foofoo', + }, follow=True) + + self.assertIn( + success_msg, + resp.content + ) diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 1ea573a8b572..b46b58237101 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -169,6 +169,7 @@ def save_child_position(seq_module, child_name): """ child_name: url_name of the child """ + print child_name for position, c in enumerate(seq_module.get_display_items(), start=1): if c.url_name == child_name: # Only save if position changed @@ -772,7 +773,7 @@ def notification_image_for_tab(course_tab, user, course): return None -def get_static_tab_contents(request, course, tab): +def get_static_tab_contents(request, course, tab, wrap_xmodule_display=True): """ Returns the contents for the given static tab """ @@ -787,7 +788,7 @@ def get_static_tab_contents(request, course, tab): course.id, request.user, modulestore().get_instance(course.id, loc), depth=0 ) tab_module = get_module( - request.user, request, loc, field_data_cache, course.id, static_asset_path=course.static_asset_path + request.user, request, loc, field_data_cache, course.id, static_asset_path=course.static_asset_path, wrap_xmodule_display=wrap_xmodule_display ) logging.debug('course_module = {0}'.format(tab_module)) diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 3e38521c9e00..1e8e9495dca2 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -276,6 +276,14 @@ HOSTNAME_MODULESTORE_DEFAULT_MAPPINGS = ENV_TOKENS.get('HOSTNAME_MODULESTORE_DEFAULT_MAPPINGS',{}) +############# CORS headers for cross-domain requests ################# + +if FEATURES.get('ENABLE_CORS_HEADERS'): + INSTALLED_APPS += ('corsheaders',) + MIDDLEWARE_CLASSES += ('corsheaders.middleware.CorsMiddleware',) + CORS_ALLOW_CREDENTIALS = True + CORS_ORIGIN_WHITELIST = ENV_TOKENS.get('CORS_ORIGIN_WHITELIST', ()) + ############################## SECURE AUTH ITEMS ############### # Secret things: passwords, access keys, etc. @@ -393,3 +401,10 @@ ##### Third-party auth options ################################################ THIRD_PARTY_AUTH = AUTH_TOKENS.get('THIRD_PARTY_AUTH', THIRD_PARTY_AUTH) + +##### ADVANCED_SECURITY_CONFIG ##### +ADVANCED_SECURITY_CONFIG = ENV_TOKENS.get('ADVANCED_SECURITY_CONFIG', {}) + +##### Google Analytics and Google Web Master Tools ##### +GOOGLE_ANALYTICS_ACCOUNT_ID = ENV_TOKENS.get('GOOGLE_ANALYTICS_ACCOUNT_ID', GOOGLE_ANALYTICS_ACCOUNT_ID) +GOOGLE_SITE_VERIFICATION = ENV_TOKENS.get('GOOGLE_SITE_VERIFICATION', GOOGLE_SITE_VERIFICATION) diff --git a/lms/envs/common.py b/lms/envs/common.py index 021916764f84..5da3a45fa69a 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -113,6 +113,9 @@ # with Shib. Feature was requested by Stanford's office of general counsel 'SHIB_DISABLE_TOS': False, + # Allows to configure the LMS to provide CORS headers to serve requests from other domains + 'ENABLE_CORS_HEADERS': False, + # Can be turned off if course lists need to be hidden. Effects views and templates. 'COURSES_ARE_BROWSABLE': True, @@ -223,6 +226,9 @@ # Toggle embargo functionality 'EMBARGO': False, + # Turn on Google Analytics for courseware + 'ENABLE_COURSEWARE_GOOGLE_ANALYTICS': False, + # Whether the Wiki subsystem should be accessible via the direct /wiki/ paths. Setting this to True means # that people can submit content and modify the Wiki in any arbitrary manner. We're leaving this as True in the # defaults, so that we maintain current behavior @@ -240,6 +246,12 @@ # Prevent concurrent logins per user 'PREVENT_CONCURRENT_LOGINS': False, + + # Turn off Advanced Security by default + 'ADVANCED_SECURITY': False, + + # Turn on/off the Open edX API + 'API': False, } # Used for A/B testing @@ -1194,6 +1206,9 @@ # Monitoring functionality 'monitoring', + + # EDX API application + 'api_manager', ) ######################### MARKETING SITE ############################### @@ -1237,6 +1252,14 @@ INSTALLED_APPS += ('django_cas',) MIDDLEWARE_CLASSES += ('django_cas.middleware.CASMiddleware',) +############# CORS headers for cross-domain requests ################# + +if FEATURES.get('ENABLE_CORS_HEADERS'): + INSTALLED_APPS += ('corsheaders',) + MIDDLEWARE_CLASSES += ('corsheaders.middleware.CorsMiddleware',) + CORS_ALLOW_CREDENTIALS = True + CORS_ORIGIN_WHITELIST = ('') + ###################### Registration ################################## # For each of the fields, give one of the following values: @@ -1516,3 +1539,11 @@ # Stub for third_party_auth options. # See common/djangoapps/third_party_auth/settings.py for configuration details. THIRD_PARTY_AUTH = {} + +### ADVANCED_SECURITY_CONFIG +# Empty by default +ADVANCED_SECURITY_CONFIG = {} + +### configuration for Google Analytics and Google Web Master Tools +GOOGLE_ANALYTICS_ACCOUNT_ID = 'add-your-GA-account-ID-here' +GOOGLE_SITE_VERIFICATION = 'add-your-Google-site-verification-here' diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 3722efd6ae37..d34d10b1f345 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -26,7 +26,6 @@ FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True # Enable email for all Studio courses FEATURES['REQUIRE_COURSE_EMAIL_AUTH'] = False # Give all courses email (don't require django-admin perms) - ################################ DEBUG TOOLBAR ################################ INSTALLED_APPS += ('debug_toolbar',) @@ -66,6 +65,11 @@ FEATURES['ENABLE_PAYMENT_FAKE'] = True CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = '/shoppingcart/payment_fake/' +########################### EDX API ################################# + +FEATURES['API'] = True + + ##################################################################### # Lastly, see if the developer has any local overrides. try: diff --git a/lms/envs/test.py b/lms/envs/test.py index 5b9e6a5f4af8..c699b15a1d7f 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -44,6 +44,9 @@ # Toggles embargo on for testing FEATURES['EMBARGO'] = True +# Toggles API on for testing +FEATURES['API'] = True + # Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it. WIKI_ENABLED = True diff --git a/lms/lib/xblock/runtime.py b/lms/lib/xblock/runtime.py index 895e8f2c74c5..a8f46fdc9263 100644 --- a/lms/lib/xblock/runtime.py +++ b/lms/lib/xblock/runtime.py @@ -4,6 +4,7 @@ import re +from django.conf import settings from django.core.urlresolvers import reverse from user_api import user_service @@ -106,10 +107,11 @@ def local_resource_url(self, block, uri): """ local_resource_url for Studio """ - return reverse('xblock_resource_url', kwargs={ + path = reverse('xblock_resource_url', kwargs={ 'block_type': block.scope_ids.block_type, 'uri': uri, }) + return '//{}{}'.format(settings.SITE_NAME, path) class LmsPartitionService(PartitionService): diff --git a/lms/templates/google_analytics.html b/lms/templates/google_analytics.html index 273fbac970b8..99b6ce04b236 100644 --- a/lms/templates/google_analytics.html +++ b/lms/templates/google_analytics.html @@ -1,6 +1,6 @@