diff --git a/.gitignore b/.gitignore
index e92d49a0f2..4df5d79916 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,6 +51,8 @@ node_modules
*.scssc
lms/static/sass/*.css
lms/static/sass/application.scss
+lms/static/sass/application-extend1.scss
+lms/static/sass/application-extend2.scss
lms/static/sass/course.scss
cms/static/sass/*.css
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 49e5841712..47180ee3a7 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -16,6 +16,11 @@ Blades: LTI module can now load external content in a new window.
LMS: Disable data download buttons on the instructor dashboard for large courses
+Common: Adds ability to disable a student's account. Students with disabled
+accounts will be prohibited from site access.
+
+LMS: Fix issue with CourseMode expiration dates
+
LMS: Ported bulk emailing to the beta instructor dashboard.
LMS: Add monitoring of bulk email subtasks to display progress on instructor dash.
diff --git a/cms/envs/common.py b/cms/envs/common.py
index 1048570070..91815d09e6 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -154,6 +154,7 @@ MIDDLEWARE_CLASSES = (
# Instead of AuthenticationMiddleware, we use a cache-backed version
'cache_toolbox.middleware.CacheBackedAuthenticationMiddleware',
+ 'student.middleware.UserStandingMiddleware',
'contentserver.middleware.StaticContentServer',
'django.contrib.messages.middleware.MessageMiddleware',
diff --git a/common/djangoapps/student/middleware.py b/common/djangoapps/student/middleware.py
new file mode 100644
index 0000000000..b4f6ad0686
--- /dev/null
+++ b/common/djangoapps/student/middleware.py
@@ -0,0 +1,37 @@
+"""
+Middleware that checks user standing for the purpose of keeping users with
+disabled accounts from accessing the site.
+"""
+from django.http import HttpResponseForbidden
+from django.utils.translation import ugettext as _
+from django.conf import settings
+from student.models import UserStanding
+
+class UserStandingMiddleware(object):
+ """
+ Checks a user's standing on request. Returns a 403 if the user's
+ status is 'disabled'.
+ """
+ def process_request(self, request):
+ user = request.user
+ try:
+ user_account = UserStanding.objects.get(user=user.id)
+ # because user is a unique field in UserStanding, there will either be
+ # one or zero user_accounts associated with a UserStanding
+ except UserStanding.DoesNotExist:
+ pass
+ else:
+ if user_account.account_status == UserStanding.ACCOUNT_DISABLED:
+ msg = _(
+ 'Your account has been disabled. If you believe '
+ 'this was done in error, please contact us at '
+ '{link_start}{support_email}{link_end}'
+ ).format(
+ support_email=settings.DEFAULT_FEEDBACK_EMAIL,
+ link_start=u''.format(
+ address=settings.DEFAULT_FEEDBACK_EMAIL,
+ subject_line=_('Disabled Account'),
+ ),
+ link_end=u''
+ )
+ return HttpResponseForbidden(msg)
diff --git a/common/djangoapps/student/migrations/0028_auto__add_userstanding.py b/common/djangoapps/student/migrations/0028_auto__add_userstanding.py
new file mode 100644
index 0000000000..d26699448a
--- /dev/null
+++ b/common/djangoapps/student/migrations/0028_auto__add_userstanding.py
@@ -0,0 +1,188 @@
+# -*- 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 'UserStanding'
+ db.create_table('student_userstanding', (
+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='standing', unique=True, to=orm['auth.User'])),
+ ('account_status', self.gf('django.db.models.fields.CharField')(max_length=31, blank=True)),
+ ('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], blank=True)),
+ ('standing_last_changed_at', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)),
+ ))
+ db.send_create_signal('student', ['UserStanding'])
+
+
+ def backwards(self, orm):
+ # Deleting model 'UserStanding'
+ db.delete_table('student_userstanding')
+
+
+ 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.courseenrollment': {
+ 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'},
+ 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}),
+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
+ },
+ 'student.courseenrollmentallowed': {
+ 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'},
+ 'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
+ },
+ 'student.pendingemailchange': {
+ 'Meta': {'object_name': 'PendingEmailChange'},
+ 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
+ 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
+ },
+ 'student.pendingnamechange': {
+ 'Meta': {'object_name': 'PendingNameChange'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+ 'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
+ 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
+ },
+ 'student.registration': {
+ 'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"},
+ 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'})
+ },
+ 'student.testcenterregistration': {
+ 'Meta': {'object_name': 'TestCenterRegistration'},
+ 'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
+ 'accommodation_request': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
+ 'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
+ 'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}),
+ 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
+ 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}),
+ 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'eligibility_appointment_date_first': ('django.db.models.fields.DateField', [], {'db_index': 'True'}),
+ 'eligibility_appointment_date_last': ('django.db.models.fields.DateField', [], {'db_index': 'True'}),
+ 'exam_series_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
+ 'testcenter_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['student.TestCenterUser']"}),
+ 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
+ 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
+ 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
+ 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
+ },
+ 'student.testcenteruser': {
+ 'Meta': {'object_name': 'TestCenterUser'},
+ 'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
+ 'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
+ 'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
+ 'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
+ 'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
+ 'client_candidate_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}),
+ 'company_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '50', 'blank': 'True'}),
+ 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
+ 'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
+ 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}),
+ 'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}),
+ 'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}),
+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
+ 'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}),
+ 'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
+ 'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}),
+ 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
+ 'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}),
+ 'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
+ 'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+ 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
+ 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
+ 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}),
+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}),
+ 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
+ },
+ 'student.userprofile': {
+ 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"},
+ 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}),
+ 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
+ 'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
+ 'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
+ 'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
+ 'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+ 'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
+ 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}),
+ 'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'})
+ },
+ 'student.userstanding': {
+ 'Meta': {'object_name': 'UserStanding'},
+ 'account_status': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}),
+ 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'standing_last_changed_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'standing'", 'unique': 'True', 'to': "orm['auth.User']"})
+ },
+ 'student.usertestgroup': {
+ 'Meta': {'object_name': 'UserTestGroup'},
+ 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
+ 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'})
+ }
+ }
+
+ complete_apps = ['student']
\ No newline at end of file
diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py
index da16a2fda2..78e673df71 100644
--- a/common/djangoapps/student/models.py
+++ b/common/djangoapps/student/models.py
@@ -33,6 +33,27 @@ from pytz import UTC
log = logging.getLogger(__name__)
AUDIT_LOG = logging.getLogger("audit")
+class UserStanding(models.Model):
+ """
+ This table contains a student's account's status.
+ Currently, we're only disabling accounts; in the future we can imagine
+ taking away more specific privileges, like forums access, or adding
+ more specific karma levels or probationary stages.
+ """
+ ACCOUNT_DISABLED = "disabled"
+ ACCOUNT_ENABLED = "enabled"
+ USER_STANDING_CHOICES = (
+ (ACCOUNT_DISABLED, u"Account Disabled"),
+ (ACCOUNT_ENABLED, u"Account Enabled"),
+ )
+
+ user = models.ForeignKey(User, db_index=True, related_name='standing', unique=True)
+ account_status = models.CharField(
+ blank=True, max_length=31, choices=USER_STANDING_CHOICES
+ )
+ changed_by = models.ForeignKey(User, blank=True)
+ standing_last_changed_at = models.DateTimeField(auto_now=True)
+
class UserProfile(models.Model):
"""This is where we store all the user demographic fields. We have a
diff --git a/common/djangoapps/student/tests/factories.py b/common/djangoapps/student/tests/factories.py
index 49864fcbd4..b2f4d95776 100644
--- a/common/djangoapps/student/tests/factories.py
+++ b/common/djangoapps/student/tests/factories.py
@@ -1,6 +1,7 @@
from student.models import (User, UserProfile, Registration,
CourseEnrollmentAllowed, CourseEnrollment,
- PendingEmailChange)
+ PendingEmailChange, UserStanding,
+ )
from django.contrib.auth.models import Group
from datetime import datetime
from factory import DjangoModelFactory, SubFactory, PostGenerationMethodCall, post_generation, Sequence
@@ -16,6 +17,13 @@ class GroupFactory(DjangoModelFactory):
name = u'staff_MITx/999/Robot_Super_Course'
+class UserStandingFactory(DjangoModelFactory):
+ FACTORY_FOR = UserStanding
+
+ user = None
+ account_status = None
+ changed_by = None
+
class UserProfileFactory(DjangoModelFactory):
FACTORY_FOR = UserProfile
diff --git a/common/djangoapps/student/tests/test_bulk_email_settings.py b/common/djangoapps/student/tests/test_bulk_email_settings.py
new file mode 100644
index 0000000000..98fddeaddd
--- /dev/null
+++ b/common/djangoapps/student/tests/test_bulk_email_settings.py
@@ -0,0 +1,134 @@
+"""
+Unit tests for email feature flag in student dashboard. Additionally tests
+that bulk email is always disabled for non-Mongo backed courses, regardless
+of email feature flag, and that the view is conditionally available when
+Course Auth is turned on.
+"""
+
+from django.test.utils import override_settings
+from django.conf import settings
+from django.core.urlresolvers import reverse, NoReverseMatch
+from unittest.case import SkipTest
+
+from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
+from student.tests.factories import UserFactory, CourseEnrollmentFactory
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory
+from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE
+
+from bulk_email.models import CourseAuthorization
+
+from mock import patch
+
+
+@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
+class TestStudentDashboardEmailView(ModuleStoreTestCase):
+ """
+ Check for email view displayed with flag
+ """
+ def setUp(self):
+ self.course = CourseFactory.create()
+
+ # Create student account
+ student = UserFactory.create()
+ CourseEnrollmentFactory.create(user=student, course_id=self.course.id)
+ self.client.login(username=student.username, password="test")
+
+ try:
+ # URL for dashboard
+ self.url = reverse('dashboard')
+ except NoReverseMatch:
+ raise SkipTest("Skip this test if url cannot be found (ie running from CMS tests)")
+ # URL for email settings modal
+ self.email_modal_link = (
+ ('Email Settings').format(
+ self.course.org,
+ self.course.number,
+ self.course.display_name.replace(' ', '_')
+ )
+ )
+
+ def tearDown(self):
+ """
+ Undo all patches.
+ """
+ patch.stopall()
+
+ @patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': False})
+ def test_email_flag_true(self):
+ # Assert that the URL for the email view is in the response
+ response = self.client.get(self.url)
+ self.assertTrue(self.email_modal_link in response.content)
+
+ @patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': False})
+ def test_email_flag_false(self):
+ # Assert that the URL for the email view is not in the response
+ response = self.client.get(self.url)
+ self.assertFalse(self.email_modal_link in response.content)
+
+ @patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': True})
+ def test_email_unauthorized(self):
+ # Assert that instructor email is not enabled for this course
+ self.assertFalse(CourseAuthorization.instructor_email_enabled(self.course.id))
+ # Assert that the URL for the email view is not in the response
+ # if this course isn't authorized
+ response = self.client.get(self.url)
+ self.assertFalse(self.email_modal_link in response.content)
+
+ @patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': True})
+ def test_email_authorized(self):
+ # Authorize the course to use email
+ cauth = CourseAuthorization(course_id=self.course.id, email_enabled=True)
+ cauth.save()
+ # Assert that instructor email is enabled for this course
+ self.assertTrue(CourseAuthorization.instructor_email_enabled(self.course.id))
+ # Assert that the URL for the email view is not in the response
+ # if this course isn't authorized
+ response = self.client.get(self.url)
+ self.assertTrue(self.email_modal_link in response.content)
+
+
+@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
+class TestStudentDashboardEmailViewXMLBacked(ModuleStoreTestCase):
+ """
+ Check for email view on student dashboard, with XML backed course.
+ """
+ def setUp(self):
+ self.course_name = 'edX/toy/2012_Fall'
+
+ # Create student account
+ student = UserFactory.create()
+ CourseEnrollmentFactory.create(user=student, course_id=self.course_name)
+ self.client.login(username=student.username, password="test")
+
+ try:
+ # URL for dashboard
+ self.url = reverse('dashboard')
+ except NoReverseMatch:
+ raise SkipTest("Skip this test if url cannot be found (ie running from CMS tests)")
+
+ # URL for email settings modal
+ self.email_modal_link = (
+ ('Email Settings').format(
+ 'edX',
+ 'toy',
+ '2012_Fall'
+ )
+ )
+
+ @patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': False})
+ def test_email_flag_true_xml_store(self):
+ # The flag is enabled, and since REQUIRE_COURSE_EMAIL_AUTH is False, all courses should
+ # be authorized to use email. But the course is not Mongo-backed (should not work)
+ response = self.client.get(self.url)
+ self.assertFalse(self.email_modal_link in response.content)
+
+ @patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': False, 'REQUIRE_COURSE_EMAIL_AUTH': False})
+ def test_email_flag_false_xml_store(self):
+ # Email disabled, shouldn't see link.
+ response = self.client.get(self.url)
+ self.assertFalse(self.email_modal_link in response.content)
diff --git a/common/djangoapps/student/tests/test_userstanding.py b/common/djangoapps/student/tests/test_userstanding.py
new file mode 100644
index 0000000000..730984c918
--- /dev/null
+++ b/common/djangoapps/student/tests/test_userstanding.py
@@ -0,0 +1,115 @@
+"""
+These are tests for disabling and enabling student accounts, and for making sure
+that students with disabled accounts are unable to access the courseware.
+"""
+from student.tests.factories import UserFactory, UserStandingFactory
+from student.models import UserStanding
+from django.test import TestCase, Client
+from django.core.urlresolvers import reverse, NoReverseMatch
+from nose.plugins.skip import SkipTest
+
+
+class UserStandingTest(TestCase):
+ """test suite for user standing view for enabling and disabling accounts"""
+
+ def setUp(self):
+ # create users
+ self.bad_user = UserFactory.create(
+ username='bad_user',
+ )
+ self.good_user = UserFactory.create(
+ username='good_user',
+ )
+ self.non_staff = UserFactory.create(
+ username='non_staff',
+ )
+ self.admin = UserFactory.create(
+ username='admin',
+ is_staff=True,
+ )
+
+ # create clients
+ self.bad_user_client = Client()
+ self.good_user_client = Client()
+ self.non_staff_client = Client()
+ self.admin_client = Client()
+
+ for user, client in [
+ (self.bad_user, self.bad_user_client),
+ (self.good_user, self.good_user_client),
+ (self.non_staff, self.non_staff_client),
+ (self.admin, self.admin_client),
+ ]:
+ client.login(username=user.username, password='test')
+
+ UserStandingFactory.create(
+ user=self.bad_user,
+ account_status=UserStanding.ACCOUNT_DISABLED,
+ changed_by=self.admin
+ )
+
+ # set different stock urls for lms and cms
+ # to test disabled accounts' access to site
+ try:
+ self.some_url = reverse('dashboard')
+ except NoReverseMatch:
+ self.some_url = reverse('index')
+
+ # since it's only possible to disable accounts from lms, we're going
+ # to skip tests for cms
+
+ def test_disable_account(self):
+ self.assertEqual(
+ UserStanding.objects.filter(user=self.good_user).count(), 0
+ )
+ try:
+ response = self.admin_client.post(reverse('disable_account_ajax'), {
+ 'username': self.good_user.username,
+ 'account_action': 'disable',
+ })
+ except NoReverseMatch:
+ raise SkipTest()
+ self.assertEqual(
+ UserStanding.objects.get(user=self.good_user).account_status,
+ UserStanding.ACCOUNT_DISABLED
+ )
+
+ def test_disabled_account_403s(self):
+ response = self.bad_user_client.get(self.some_url)
+ self.assertEqual(response.status_code, 403)
+
+ def test_reenable_account(self):
+ try:
+ response = self.admin_client.post(reverse('disable_account_ajax'), {
+ 'username': self.bad_user.username,
+ 'account_action': 'reenable'
+ })
+ except NoReverseMatch:
+ raise SkipTest()
+ self.assertEqual(
+ UserStanding.objects.get(user=self.bad_user).account_status,
+ UserStanding.ACCOUNT_ENABLED
+ )
+
+ def test_non_staff_cant_access_disable_view(self):
+ try:
+ response = self.non_staff_client.get(reverse('manage_user_standing'), {
+ 'user': self.non_staff,
+ })
+ except NoReverseMatch:
+ raise SkipTest()
+ self.assertEqual(response.status_code, 404)
+
+ def test_non_staff_cant_disable_account(self):
+ try:
+ response = self.non_staff_client.post(reverse('disable_account_ajax'), {
+ 'username': self.good_user.username,
+ 'user': self.non_staff,
+ 'account_action': 'disable'
+ })
+ except NoReverseMatch:
+ raise SkipTest()
+ self.assertEqual(response.status_code, 404)
+ self.assertEqual(
+ UserStanding.objects.filter(user=self.good_user).count(), 0
+ )
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index 285509bf5a..a379cca35e 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -16,6 +16,7 @@ from django.contrib.auth import logout, authenticate, login
from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required
from django.contrib.auth.views import password_reset_confirm
+# from django.contrib.sessions.models import Session
from django.core.cache import cache
from django.core.context_processors import csrf
from django.core.mail import send_mail
@@ -29,18 +30,21 @@ from django.shortcuts import redirect
from django_future.csrf import ensure_csrf_cookie
from django.utils.http import cookie_date, base36_to_int, urlencode
from django.utils.translation import ugettext as _
-from django.views.decorators.http import require_POST
+from django.views.decorators.http import require_POST, require_GET
+from django.contrib.admin.views.decorators import staff_member_required
+from django.utils.translation import ugettext as _u
from ratelimitbackend.exceptions import RateLimitException
from mitxmako.shortcuts import render_to_response, render_to_string
from course_modes.models import CourseMode
-from student.models import (Registration, UserProfile, TestCenterUser, TestCenterUserForm,
- TestCenterRegistration, TestCenterRegistrationForm,
- PendingNameChange, PendingEmailChange,
- CourseEnrollment, unique_id_for_user,
- get_testcenter_registration, CourseEnrollmentAllowed)
+from student.models import (
+ Registration, UserProfile, TestCenterUser, TestCenterUserForm,
+ TestCenterRegistration, TestCenterRegistrationForm, PendingNameChange,
+ PendingEmailChange, CourseEnrollment, unique_id_for_user,
+ get_testcenter_registration, CourseEnrollmentAllowed, UserStanding,
+)
from student.forms import PasswordResetFormNoActive
from certificates.models import CertificateStatuses, certificate_status_for_student
@@ -58,7 +62,7 @@ from courseware.access import has_access
from external_auth.models import ExternalAuthMap
import external_auth.views
-from bulk_email.models import Optout
+from bulk_email.models import Optout, CourseAuthorization
import shoppingcart
import track.views
@@ -66,6 +70,8 @@ import track.views
from dogapi import dog_stats_api
from pytz import UTC
+from util.json_request import JsonResponse
+
log = logging.getLogger("mitx.student")
AUDIT_LOG = logging.getLogger("audit")
@@ -297,10 +303,13 @@ def dashboard(request):
cert_statuses = {course.id: cert_info(request.user, course) for course, _enrollment in courses}
# only show email settings for Mongo course and when bulk email is turned on
- show_email_settings_for = frozenset(course.id for course, _enrollment in courses
- if (settings.MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] and
- modulestore().get_modulestore_type(course.id) == MONGO_MODULESTORE_TYPE))
-
+ show_email_settings_for = frozenset(
+ course.id for course, _enrollment in courses if (
+ settings.MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] and
+ modulestore().get_modulestore_type(course.id) == MONGO_MODULESTORE_TYPE and
+ CourseAuthorization.instructor_email_enabled(course.id)
+ )
+ )
# get info w.r.t ExternalAuthMap
external_auth_map = None
try:
@@ -601,6 +610,81 @@ def logout_user(request):
domain=settings.SESSION_COOKIE_DOMAIN)
return response
+@require_GET
+@login_required
+@ensure_csrf_cookie
+def manage_user_standing(request):
+ """
+ Renders the view used to manage user standing. Also displays a table
+ of user accounts that have been disabled and who disabled them.
+ """
+ if not request.user.is_staff:
+ raise Http404
+ all_disabled_accounts = UserStanding.objects.filter(
+ account_status=UserStanding.ACCOUNT_DISABLED
+ )
+
+ all_disabled_users = [standing.user for standing in all_disabled_accounts]
+
+ headers = ['username', 'account_changed_by']
+ rows = []
+ for user in all_disabled_users:
+ row = [user.username, user.standing.all()[0].changed_by]
+ rows.append(row)
+
+
+ context = {'headers': headers, 'rows': rows}
+
+ return render_to_response("manage_user_standing.html", context)
+
+@require_POST
+@login_required
+@ensure_csrf_cookie
+def disable_account_ajax(request):
+ """
+ Ajax call to change user standing. Endpoint of the form
+ in manage_user_standing.html
+ """
+ if not request.user.is_staff:
+ raise Http404
+ username = request.POST.get('username')
+ context = {}
+ if username is None or username.strip() == '':
+ context['message'] = _u('Please enter a username')
+ return JsonResponse(context, status=400)
+
+ account_action = request.POST.get('account_action')
+ if account_action is None:
+ context['message'] = _u('Please choose an option')
+ return JsonResponse(context, status=400)
+
+ username = username.strip()
+ try:
+ user = User.objects.get(username=username)
+ except User.DoesNotExist:
+ context['message'] = _u("User with username {} does not exist").format(username)
+ return JsonResponse(context, status=400)
+ else:
+ user_account, _ = UserStanding.objects.get_or_create(
+ user=user, defaults={'changed_by': request.user},
+ )
+ if account_action == 'disable':
+ user_account.account_status = UserStanding.ACCOUNT_DISABLED
+ context['message'] = _u("Successfully disabled {}'s account").format(username)
+ log.info("{} disabled {}'s account".format(request.user, username))
+ elif account_action == 'reenable':
+ user_account.account_status = UserStanding.ACCOUNT_ENABLED
+ context['message'] = _u("Successfully reenabled {}'s account").format(username)
+ log.info("{} reenabled {}'s account".format(request.user, username))
+ else:
+ context['message'] = _u("Unexpected account status")
+ return JsonResponse(context, status=400)
+ user_account.changed_by = request.user
+ user_account.standing_last_changed_at = datetime.datetime.now(UTC)
+ user_account.save()
+
+ return JsonResponse(context)
+
@login_required
@ensure_csrf_cookie
diff --git a/common/djangoapps/terrain/course_helpers.py b/common/djangoapps/terrain/course_helpers.py
index 40c89b1087..df91204e15 100644
--- a/common/djangoapps/terrain/course_helpers.py
+++ b/common/djangoapps/terrain/course_helpers.py
@@ -65,6 +65,21 @@ def add_to_course_staff(username, course_num):
user.groups.add(group)
+@world.absorb
+def add_to_course_staff(username, course_num):
+ """
+ Add the user with `username` to the course staff group
+ for `course_num`.
+ """
+ # Based on code in lms/djangoapps/courseware/access.py
+ group_name = "instructor_{}".format(course_num)
+ group, _ = Group.objects.get_or_create(name=group_name)
+ group.save()
+
+ user = User.objects.get(username=username)
+ user.groups.add(group)
+
+
@world.absorb
def clear_courses():
# Flush and initialize the module store
diff --git a/common/lib/xmodule/xmodule/css/video/display.scss b/common/lib/xmodule/xmodule/css/video/display.scss
index 989ea33a26..84b613d388 100644
--- a/common/lib/xmodule/xmodule/css/video/display.scss
+++ b/common/lib/xmodule/xmodule/css/video/display.scss
@@ -633,6 +633,8 @@ div.video {
ol.subtitles {
width: 0;
height: 0;
+
+ visibility: hidden;
}
ol.subtitles.html5 {
@@ -666,6 +668,8 @@ div.video {
ol.subtitles {
right: -(flex-grid(4));
width: auto;
+
+ visibility: hidden;
}
}
diff --git a/lms/static/sass/vendor/_font-awesome.scss b/common/static/css/vendor/font-awesome.css
similarity index 81%
rename from lms/static/sass/vendor/_font-awesome.scss
rename to common/static/css/vendor/font-awesome.css
index 035ca8c750..aee05c3cc9 100644
--- a/lms/static/sass/vendor/_font-awesome.scss
+++ b/common/static/css/vendor/font-awesome.css
@@ -1,33 +1,34 @@
/*!
- * Font Awesome 3.1.0
+ * Font Awesome 3.2.1
* the iconic font designed for Bootstrap
- * -------------------------------------------------------
- * The full suite of pictographic icons, examples, and documentation
- * can be found at: http://fontawesome.io
+ * ------------------------------------------------------------------------------
+ * The full suite of pictographic icons, examples, and documentation can be
+ * found at http://fontawesome.io. Stay up to date on Twitter at
+ * http://twitter.com/fontawesome.
*
* License
- * -------------------------------------------------------
- * - The Font Awesome font is licensed under the SIL Open Font License v1.1 -
+ * ------------------------------------------------------------------------------
+ * - The Font Awesome font is licensed under SIL OFL 1.1 -
* http://scripts.sil.org/OFL
- * - Font Awesome CSS, LESS, and SASS files are licensed under the MIT License -
+ * - Font Awesome CSS, LESS, and SASS files are licensed under MIT License -
* http://opensource.org/licenses/mit-license.html
- * - Font Awesome documentation licensed under CC BY 3.0 License -
+ * - Font Awesome documentation licensed under CC BY 3.0 -
* http://creativecommons.org/licenses/by/3.0/
* - Attribution is no longer required in Font Awesome 3.0, but much appreciated:
* "Font Awesome by Dave Gandy - http://fontawesome.io"
-
- * Contact
- * -------------------------------------------------------
+ *
+ * Author - Dave Gandy
+ * ------------------------------------------------------------------------------
* Email: dave@fontawesome.io
- * Twitter: http://twitter.com/fortaweso_me
- * Work: Lead Product Designer @ http://kyruus.com
+ * Twitter: http://twitter.com/davegandy
+ * Work: Lead Product Designer @ Kyruus - http://kyruus.com
*/
/* FONT PATH
* -------------------------- */
@font-face {
font-family: 'FontAwesome';
- src: url('../fonts/vendor/fontawesome-webfont.eot?v=3.1.0');
- src: url('../fonts/vendor/fontawesome-webfont.eot?#iefix&v=3.1.0') format('embedded-opentype'), url('../fonts/vendor/fontawesome-webfont.woff?v=3.1.0') format('woff'), url('../fonts/vendor/fontawesome-webfont.ttf?v=3.1.0') format('truetype'), url('../fonts/vendor/fontawesome-webfont.svg#fontawesomeregular?v=3.1.0') format('svg');
+ src: url('../../fonts/vendor/fontawesome-webfont.eot?v=3.2.1');
+ src: url('../../fonts/vendor/fontawesome-webfont.eot?#iefix&v=3.2.1') format('embedded-opentype'), url('../../fonts/vendor/fontawesome-webfont.woff?v=3.2.1') format('woff'), url('../../fonts/vendor/fontawesome-webfont.ttf?v=3.2.1') format('truetype'), url('../../fonts/vendor/fontawesome-webfont.svg#fontawesomeregular?v=3.2.1') format('svg');
font-weight: normal;
font-style: normal;
}
@@ -55,31 +56,34 @@
}
/* makes sure icons active on rollover in links */
a [class^="icon-"],
-a [class*=" icon-"],
-a [class^="icon-"]:before,
-a [class*=" icon-"]:before {
+a [class*=" icon-"] {
display: inline;
}
/* increased font size for icon-large */
[class^="icon-"].icon-fixed-width,
[class*=" icon-"].icon-fixed-width {
display: inline-block;
- width: 1.2857142857142858em;
- text-align: center;
+ width: 1.1428571428571428em;
+ text-align: right;
+ padding-right: 0.2857142857142857em;
}
[class^="icon-"].icon-fixed-width.icon-large,
[class*=" icon-"].icon-fixed-width.icon-large {
- width: 1.5714285714285714em;
+ width: 1.4285714285714286em;
}
-ul.icons-ul {
- list-style-type: none;
- text-indent: -0.7142857142857143em;
+.icons-ul {
margin-left: 2.142857142857143em;
+ list-style-type: none;
}
-ul.icons-ul > li .icon-li {
- width: 0.7142857142857143em;
- display: inline-block;
+.icons-ul > li {
+ position: relative;
+}
+.icons-ul .icon-li {
+ position: absolute;
+ left: -2.142857142857143em;
+ width: 2.142857142857143em;
text-align: center;
+ line-height: inherit;
}
[class^="icon-"].hide,
[class*=" icon-"].hide {
@@ -239,6 +243,11 @@ ul.icons-ul > li .icon-li {
.btn.btn-large [class*=" icon-"].pull-right.icon-2x {
margin-left: .2em;
}
+/* Fixes alignment in nav lists */
+.nav-list [class^="icon-"],
+.nav-list [class*=" icon-"] {
+ line-height: inherit;
+}
/* EXTRAS
* -------------------------- */
/* Stacked and layered icon */
@@ -273,6 +282,12 @@ ul.icons-ul > li .icon-li {
-webkit-animation: spin 2s infinite linear;
animation: spin 2s infinite linear;
}
+/* Prevent stack and spinners from being taken inline when inside a link */
+a .icon-stack,
+a .icon-spin {
+ display: inline-block;
+ text-decoration: none;
+}
@-moz-keyframes spin {
0% {
-moz-transform: rotate(0deg);
@@ -352,6 +367,14 @@ ul.icons-ul > li .icon-li {
-o-transform: scale(1, -1);
transform: scale(1, -1);
}
+/* ensure rotation occurs inside anchor tags */
+a .icon-rotate-90:before,
+a .icon-rotate-180:before,
+a .icon-rotate-270:before,
+a .icon-flip-horizontal:before,
+a .icon-flip-vertical:before {
+ display: inline-block;
+}
/* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen
readers do not read off random characters that represent icons */
.icon-glass:before {
@@ -363,7 +386,7 @@ ul.icons-ul > li .icon-li {
.icon-search:before {
content: "\f002";
}
-.icon-envelope:before {
+.icon-envelope-alt:before {
content: "\f003";
}
.icon-heart:before {
@@ -402,12 +425,14 @@ ul.icons-ul > li .icon-li {
.icon-zoom-out:before {
content: "\f010";
}
+.icon-power-off:before,
.icon-off:before {
content: "\f011";
}
.icon-signal:before {
content: "\f012";
}
+.icon-gear:before,
.icon-cog:before {
content: "\f013";
}
@@ -417,7 +442,7 @@ ul.icons-ul > li .icon-li {
.icon-home:before {
content: "\f015";
}
-.icon-file:before {
+.icon-file-alt:before {
content: "\f016";
}
.icon-time:before {
@@ -441,11 +466,10 @@ ul.icons-ul > li .icon-li {
.icon-play-circle:before {
content: "\f01d";
}
-.icon-repeat:before,
-.icon-rotate-right:before {
+.icon-rotate-right:before,
+.icon-repeat:before {
content: "\f01e";
}
-/* F020 doesn't work in Safari. all shifted one down */
.icon-refresh:before {
content: "\f021";
}
@@ -638,8 +662,8 @@ ul.icons-ul > li .icon-li {
.icon-arrow-down:before {
content: "\f063";
}
-.icon-share-alt:before,
-.icon-mail-forward:before {
+.icon-mail-forward:before,
+.icon-share-alt:before {
content: "\f064";
}
.icon-resize-full:before {
@@ -732,16 +756,17 @@ ul.icons-ul > li .icon-li {
.icon-key:before {
content: "\f084";
}
+.icon-gears:before,
.icon-cogs:before {
content: "\f085";
}
.icon-comments:before {
content: "\f086";
}
-.icon-thumbs-up:before {
+.icon-thumbs-up-alt:before {
content: "\f087";
}
-.icon-thumbs-down:before {
+.icon-thumbs-down-alt:before {
content: "\f088";
}
.icon-star-half:before {
@@ -780,6 +805,7 @@ ul.icons-ul > li .icon-li {
.icon-phone:before {
content: "\f095";
}
+.icon-unchecked:before,
.icon-check-empty:before {
content: "\f096";
}
@@ -879,6 +905,7 @@ ul.icons-ul > li .icon-li {
.icon-copy:before {
content: "\f0c5";
}
+.icon-paperclip:before,
.icon-paper-clip:before {
content: "\f0c6";
}
@@ -951,14 +978,14 @@ ul.icons-ul > li .icon-li {
.icon-sort-up:before {
content: "\f0de";
}
-.icon-envelope-alt:before {
+.icon-envelope:before {
content: "\f0e0";
}
.icon-linkedin:before {
content: "\f0e1";
}
-.icon-undo:before,
-.icon-rotate-left:before {
+.icon-rotate-left:before,
+.icon-undo:before {
content: "\f0e2";
}
.icon-legal:before {
@@ -1015,7 +1042,7 @@ ul.icons-ul > li .icon-li {
.icon-food:before {
content: "\f0f5";
}
-.icon-file-alt:before {
+.icon-file-text-alt:before {
content: "\f0f6";
}
.icon-building:before {
@@ -1093,10 +1120,13 @@ ul.icons-ul > li .icon-li {
.icon-circle:before {
content: "\f111";
}
-.icon-reply:before,
-.icon-mail-reply:before {
+.icon-mail-reply:before,
+.icon-reply:before {
content: "\f112";
}
+.icon-github-alt:before {
+ content: "\f113";
+}
.icon-folder-close-alt:before {
content: "\f114";
}
@@ -1266,3 +1296,184 @@ ul.icons-ul > li .icon-li {
.icon-share-sign:before {
content: "\f14d";
}
+.icon-compass:before {
+ content: "\f14e";
+}
+.icon-collapse:before {
+ content: "\f150";
+}
+.icon-collapse-top:before {
+ content: "\f151";
+}
+.icon-expand:before {
+ content: "\f152";
+}
+.icon-euro:before,
+.icon-eur:before {
+ content: "\f153";
+}
+.icon-gbp:before {
+ content: "\f154";
+}
+.icon-dollar:before,
+.icon-usd:before {
+ content: "\f155";
+}
+.icon-rupee:before,
+.icon-inr:before {
+ content: "\f156";
+}
+.icon-yen:before,
+.icon-jpy:before {
+ content: "\f157";
+}
+.icon-renminbi:before,
+.icon-cny:before {
+ content: "\f158";
+}
+.icon-won:before,
+.icon-krw:before {
+ content: "\f159";
+}
+.icon-bitcoin:before,
+.icon-btc:before {
+ content: "\f15a";
+}
+.icon-file:before {
+ content: "\f15b";
+}
+.icon-file-text:before {
+ content: "\f15c";
+}
+.icon-sort-by-alphabet:before {
+ content: "\f15d";
+}
+.icon-sort-by-alphabet-alt:before {
+ content: "\f15e";
+}
+.icon-sort-by-attributes:before {
+ content: "\f160";
+}
+.icon-sort-by-attributes-alt:before {
+ content: "\f161";
+}
+.icon-sort-by-order:before {
+ content: "\f162";
+}
+.icon-sort-by-order-alt:before {
+ content: "\f163";
+}
+.icon-thumbs-up:before {
+ content: "\f164";
+}
+.icon-thumbs-down:before {
+ content: "\f165";
+}
+.icon-youtube-sign:before {
+ content: "\f166";
+}
+.icon-youtube:before {
+ content: "\f167";
+}
+.icon-xing:before {
+ content: "\f168";
+}
+.icon-xing-sign:before {
+ content: "\f169";
+}
+.icon-youtube-play:before {
+ content: "\f16a";
+}
+.icon-dropbox:before {
+ content: "\f16b";
+}
+.icon-stackexchange:before {
+ content: "\f16c";
+}
+.icon-instagram:before {
+ content: "\f16d";
+}
+.icon-flickr:before {
+ content: "\f16e";
+}
+.icon-adn:before {
+ content: "\f170";
+}
+.icon-bitbucket:before {
+ content: "\f171";
+}
+.icon-bitbucket-sign:before {
+ content: "\f172";
+}
+.icon-tumblr:before {
+ content: "\f173";
+}
+.icon-tumblr-sign:before {
+ content: "\f174";
+}
+.icon-long-arrow-down:before {
+ content: "\f175";
+}
+.icon-long-arrow-up:before {
+ content: "\f176";
+}
+.icon-long-arrow-left:before {
+ content: "\f177";
+}
+.icon-long-arrow-right:before {
+ content: "\f178";
+}
+.icon-apple:before {
+ content: "\f179";
+}
+.icon-windows:before {
+ content: "\f17a";
+}
+.icon-android:before {
+ content: "\f17b";
+}
+.icon-linux:before {
+ content: "\f17c";
+}
+.icon-dribbble:before {
+ content: "\f17d";
+}
+.icon-skype:before {
+ content: "\f17e";
+}
+.icon-foursquare:before {
+ content: "\f180";
+}
+.icon-trello:before {
+ content: "\f181";
+}
+.icon-female:before {
+ content: "\f182";
+}
+.icon-male:before {
+ content: "\f183";
+}
+.icon-gittip:before {
+ content: "\f184";
+}
+.icon-sun:before {
+ content: "\f185";
+}
+.icon-moon:before {
+ content: "\f186";
+}
+.icon-archive:before {
+ content: "\f187";
+}
+.icon-bug:before {
+ content: "\f188";
+}
+.icon-vk:before {
+ content: "\f189";
+}
+.icon-weibo:before {
+ content: "\f18a";
+}
+.icon-renren:before {
+ content: "\f18b";
+}
diff --git a/common/static/fonts/OpenSans/OpenSans-Bold-webfont.eot b/common/static/fonts/OpenSans/OpenSans-Bold-webfont.eot
new file mode 100755
index 0000000000..e1c7674430
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-Bold-webfont.eot differ
diff --git a/common/static/fonts/OpenSans/OpenSans-Bold-webfont.svg b/common/static/fonts/OpenSans/OpenSans-Bold-webfont.svg
new file mode 100755
index 0000000000..364b368678
--- /dev/null
+++ b/common/static/fonts/OpenSans/OpenSans-Bold-webfont.svg
@@ -0,0 +1,146 @@
+
+
+
\ No newline at end of file
diff --git a/common/static/fonts/OpenSans/OpenSans-Bold-webfont.ttf b/common/static/fonts/OpenSans/OpenSans-Bold-webfont.ttf
new file mode 100755
index 0000000000..2d94f0629d
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-Bold-webfont.ttf differ
diff --git a/common/static/fonts/OpenSans/OpenSans-Bold-webfont.woff b/common/static/fonts/OpenSans/OpenSans-Bold-webfont.woff
new file mode 100755
index 0000000000..cd86852d0a
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-Bold-webfont.woff differ
diff --git a/common/static/fonts/OpenSans/OpenSans-BoldItalic-webfont.eot b/common/static/fonts/OpenSans/OpenSans-BoldItalic-webfont.eot
new file mode 100755
index 0000000000..f44ac9a331
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-BoldItalic-webfont.eot differ
diff --git a/common/static/fonts/OpenSans/OpenSans-BoldItalic-webfont.svg b/common/static/fonts/OpenSans/OpenSans-BoldItalic-webfont.svg
new file mode 100755
index 0000000000..8392240a1d
--- /dev/null
+++ b/common/static/fonts/OpenSans/OpenSans-BoldItalic-webfont.svg
@@ -0,0 +1,146 @@
+
+
+
\ No newline at end of file
diff --git a/common/static/fonts/OpenSans/OpenSans-BoldItalic-webfont.ttf b/common/static/fonts/OpenSans/OpenSans-BoldItalic-webfont.ttf
new file mode 100755
index 0000000000..f74e0e3ca7
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-BoldItalic-webfont.ttf differ
diff --git a/common/static/fonts/OpenSans/OpenSans-BoldItalic-webfont.woff b/common/static/fonts/OpenSans/OpenSans-BoldItalic-webfont.woff
new file mode 100755
index 0000000000..f3248c1142
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-BoldItalic-webfont.woff differ
diff --git a/common/static/fonts/OpenSans/OpenSans-ExtraBold-webfont.eot b/common/static/fonts/OpenSans/OpenSans-ExtraBold-webfont.eot
new file mode 100755
index 0000000000..73653a1b85
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-ExtraBold-webfont.eot differ
diff --git a/common/static/fonts/OpenSans/OpenSans-ExtraBold-webfont.svg b/common/static/fonts/OpenSans/OpenSans-ExtraBold-webfont.svg
new file mode 100755
index 0000000000..a9aed6ba3f
--- /dev/null
+++ b/common/static/fonts/OpenSans/OpenSans-ExtraBold-webfont.svg
@@ -0,0 +1,146 @@
+
+
+
\ No newline at end of file
diff --git a/common/static/fonts/OpenSans/OpenSans-ExtraBold-webfont.ttf b/common/static/fonts/OpenSans/OpenSans-ExtraBold-webfont.ttf
new file mode 100755
index 0000000000..707fae2440
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-ExtraBold-webfont.ttf differ
diff --git a/common/static/fonts/OpenSans/OpenSans-ExtraBold-webfont.woff b/common/static/fonts/OpenSans/OpenSans-ExtraBold-webfont.woff
new file mode 100755
index 0000000000..223715a5fb
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-ExtraBold-webfont.woff differ
diff --git a/common/static/fonts/OpenSans/OpenSans-ExtraBoldItalic-webfont.eot b/common/static/fonts/OpenSans/OpenSans-ExtraBoldItalic-webfont.eot
new file mode 100755
index 0000000000..68463e3881
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-ExtraBoldItalic-webfont.eot differ
diff --git a/common/static/fonts/OpenSans/OpenSans-ExtraBoldItalic-webfont.svg b/common/static/fonts/OpenSans/OpenSans-ExtraBoldItalic-webfont.svg
new file mode 100755
index 0000000000..0d69082833
--- /dev/null
+++ b/common/static/fonts/OpenSans/OpenSans-ExtraBoldItalic-webfont.svg
@@ -0,0 +1,146 @@
+
+
+
\ No newline at end of file
diff --git a/common/static/fonts/OpenSans/OpenSans-ExtraBoldItalic-webfont.ttf b/common/static/fonts/OpenSans/OpenSans-ExtraBoldItalic-webfont.ttf
new file mode 100755
index 0000000000..da8f41f3c1
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-ExtraBoldItalic-webfont.ttf differ
diff --git a/common/static/fonts/OpenSans/OpenSans-ExtraBoldItalic-webfont.woff b/common/static/fonts/OpenSans/OpenSans-ExtraBoldItalic-webfont.woff
new file mode 100755
index 0000000000..ddd0573dd2
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-ExtraBoldItalic-webfont.woff differ
diff --git a/common/static/fonts/OpenSans/OpenSans-Italic-webfont.eot b/common/static/fonts/OpenSans/OpenSans-Italic-webfont.eot
new file mode 100755
index 0000000000..277c1899cd
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-Italic-webfont.eot differ
diff --git a/common/static/fonts/OpenSans/OpenSans-Italic-webfont.svg b/common/static/fonts/OpenSans/OpenSans-Italic-webfont.svg
new file mode 100755
index 0000000000..29c7497fed
--- /dev/null
+++ b/common/static/fonts/OpenSans/OpenSans-Italic-webfont.svg
@@ -0,0 +1,146 @@
+
+
+
\ No newline at end of file
diff --git a/common/static/fonts/OpenSans/OpenSans-Italic-webfont.ttf b/common/static/fonts/OpenSans/OpenSans-Italic-webfont.ttf
new file mode 100755
index 0000000000..63f187e984
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-Italic-webfont.ttf differ
diff --git a/common/static/fonts/OpenSans/OpenSans-Italic-webfont.woff b/common/static/fonts/OpenSans/OpenSans-Italic-webfont.woff
new file mode 100755
index 0000000000..469a29bbfb
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-Italic-webfont.woff differ
diff --git a/common/static/fonts/OpenSans/OpenSans-Light-webfont.eot b/common/static/fonts/OpenSans/OpenSans-Light-webfont.eot
new file mode 100755
index 0000000000..837daab8df
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-Light-webfont.eot differ
diff --git a/common/static/fonts/OpenSans/OpenSans-Light-webfont.svg b/common/static/fonts/OpenSans/OpenSans-Light-webfont.svg
new file mode 100755
index 0000000000..bdb672653b
--- /dev/null
+++ b/common/static/fonts/OpenSans/OpenSans-Light-webfont.svg
@@ -0,0 +1,146 @@
+
+
+
\ No newline at end of file
diff --git a/common/static/fonts/OpenSans/OpenSans-Light-webfont.ttf b/common/static/fonts/OpenSans/OpenSans-Light-webfont.ttf
new file mode 100755
index 0000000000..b50ef9dcb5
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-Light-webfont.ttf differ
diff --git a/common/static/fonts/OpenSans/OpenSans-Light-webfont.woff b/common/static/fonts/OpenSans/OpenSans-Light-webfont.woff
new file mode 100755
index 0000000000..99514d1a8a
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-Light-webfont.woff differ
diff --git a/common/static/fonts/OpenSans/OpenSans-LightItalic-webfont.eot b/common/static/fonts/OpenSans/OpenSans-LightItalic-webfont.eot
new file mode 100755
index 0000000000..f0ebf2c0ef
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-LightItalic-webfont.eot differ
diff --git a/common/static/fonts/OpenSans/OpenSans-LightItalic-webfont.svg b/common/static/fonts/OpenSans/OpenSans-LightItalic-webfont.svg
new file mode 100755
index 0000000000..60765da837
--- /dev/null
+++ b/common/static/fonts/OpenSans/OpenSans-LightItalic-webfont.svg
@@ -0,0 +1,146 @@
+
+
+
\ No newline at end of file
diff --git a/common/static/fonts/OpenSans/OpenSans-LightItalic-webfont.ttf b/common/static/fonts/OpenSans/OpenSans-LightItalic-webfont.ttf
new file mode 100755
index 0000000000..5898c8c781
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-LightItalic-webfont.ttf differ
diff --git a/common/static/fonts/OpenSans/OpenSans-LightItalic-webfont.woff b/common/static/fonts/OpenSans/OpenSans-LightItalic-webfont.woff
new file mode 100755
index 0000000000..9c978dc30c
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-LightItalic-webfont.woff differ
diff --git a/common/static/fonts/OpenSans/OpenSans-Regular-webfont.eot b/common/static/fonts/OpenSans/OpenSans-Regular-webfont.eot
new file mode 100755
index 0000000000..dd6fd2cb3a
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-Regular-webfont.eot differ
diff --git a/common/static/fonts/OpenSans/OpenSans-Regular-webfont.svg b/common/static/fonts/OpenSans/OpenSans-Regular-webfont.svg
new file mode 100755
index 0000000000..01038bb1c7
--- /dev/null
+++ b/common/static/fonts/OpenSans/OpenSans-Regular-webfont.svg
@@ -0,0 +1,146 @@
+
+
+
\ No newline at end of file
diff --git a/common/static/fonts/OpenSans/OpenSans-Regular-webfont.ttf b/common/static/fonts/OpenSans/OpenSans-Regular-webfont.ttf
new file mode 100755
index 0000000000..05951e7b36
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-Regular-webfont.ttf differ
diff --git a/common/static/fonts/OpenSans/OpenSans-Regular-webfont.woff b/common/static/fonts/OpenSans/OpenSans-Regular-webfont.woff
new file mode 100755
index 0000000000..274664b28e
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-Regular-webfont.woff differ
diff --git a/common/static/fonts/OpenSans/OpenSans-Semibold-webfont.eot b/common/static/fonts/OpenSans/OpenSans-Semibold-webfont.eot
new file mode 100755
index 0000000000..289aade3e2
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-Semibold-webfont.eot differ
diff --git a/common/static/fonts/OpenSans/OpenSans-Semibold-webfont.svg b/common/static/fonts/OpenSans/OpenSans-Semibold-webfont.svg
new file mode 100755
index 0000000000..cc2ca42755
--- /dev/null
+++ b/common/static/fonts/OpenSans/OpenSans-Semibold-webfont.svg
@@ -0,0 +1,146 @@
+
+
+
\ No newline at end of file
diff --git a/common/static/fonts/OpenSans/OpenSans-Semibold-webfont.ttf b/common/static/fonts/OpenSans/OpenSans-Semibold-webfont.ttf
new file mode 100755
index 0000000000..6f15073125
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-Semibold-webfont.ttf differ
diff --git a/common/static/fonts/OpenSans/OpenSans-Semibold-webfont.woff b/common/static/fonts/OpenSans/OpenSans-Semibold-webfont.woff
new file mode 100755
index 0000000000..4e47cb1a69
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-Semibold-webfont.woff differ
diff --git a/common/static/fonts/OpenSans/OpenSans-SemiboldItalic-webfont.eot b/common/static/fonts/OpenSans/OpenSans-SemiboldItalic-webfont.eot
new file mode 100755
index 0000000000..50a8a6f755
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-SemiboldItalic-webfont.eot differ
diff --git a/common/static/fonts/OpenSans/OpenSans-SemiboldItalic-webfont.svg b/common/static/fonts/OpenSans/OpenSans-SemiboldItalic-webfont.svg
new file mode 100755
index 0000000000..65b50e2a68
--- /dev/null
+++ b/common/static/fonts/OpenSans/OpenSans-SemiboldItalic-webfont.svg
@@ -0,0 +1,146 @@
+
+
+
\ No newline at end of file
diff --git a/common/static/fonts/OpenSans/OpenSans-SemiboldItalic-webfont.ttf b/common/static/fonts/OpenSans/OpenSans-SemiboldItalic-webfont.ttf
new file mode 100755
index 0000000000..55ba3120f7
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-SemiboldItalic-webfont.ttf differ
diff --git a/common/static/fonts/OpenSans/OpenSans-SemiboldItalic-webfont.woff b/common/static/fonts/OpenSans/OpenSans-SemiboldItalic-webfont.woff
new file mode 100755
index 0000000000..0adc6df162
Binary files /dev/null and b/common/static/fonts/OpenSans/OpenSans-SemiboldItalic-webfont.woff differ
diff --git a/common/static/fonts/vendor/FontAwesome.otf b/common/static/fonts/vendor/FontAwesome.otf
new file mode 100644
index 0000000000..70125459f7
Binary files /dev/null and b/common/static/fonts/vendor/FontAwesome.otf differ
diff --git a/common/static/fonts/vendor/fontawesome-webfont.eot b/common/static/fonts/vendor/fontawesome-webfont.eot
new file mode 100755
index 0000000000..0662cb96bf
Binary files /dev/null and b/common/static/fonts/vendor/fontawesome-webfont.eot differ
diff --git a/lms/static/fonts/vendor/fontawesome-webfont.svg b/common/static/fonts/vendor/fontawesome-webfont.svg
similarity index 78%
rename from lms/static/fonts/vendor/fontawesome-webfont.svg
rename to common/static/fonts/vendor/fontawesome-webfont.svg
index 10a1e1bbf7..2edb4ec34c 100755
--- a/lms/static/fonts/vendor/fontawesome-webfont.svg
+++ b/common/static/fonts/vendor/fontawesome-webfont.svg
@@ -52,8 +52,8 @@
Your email was successfully queued for sending. Please note that for large public classes (~10k), it may take 1-2 hours to send all emails.
Your email was successfully queued for sending.
Your email was successfully queued for sending. Please note that for large public classes (~10k), it may take 1-2 hours to send all emails.
Your email was successfully queued for sending.