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 @@ + + + + +This is a custom SVG webfont generated by Font Squirrel. +Copyright : Digitized data copyright 20102011 Google Corporation +Foundry : Ascender Corporation +Foundry URL : httpwwwascendercorpcom + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ + + + + +This is a custom SVG webfont generated by Font Squirrel. +Copyright : Digitized data copyright 20102011 Google Corporation +Foundry : Ascender Corporation +Foundry URL : httpwwwascendercorpcom + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ + + + + +This is a custom SVG webfont generated by Font Squirrel. +Copyright : Digitized data copyright 2011 Google Corporation +Foundry : Ascender Corporation +Foundry URL : httpwwwascendercorpcom + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ + + + + +This is a custom SVG webfont generated by Font Squirrel. +Copyright : Digitized data copyright 20102011 Google Corporation +Foundry : Ascender Corporation +Foundry URL : httpwwwascendercorpcom + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ + + + + +This is a custom SVG webfont generated by Font Squirrel. +Copyright : Digitized data copyright 20102011 Google Corporation +Foundry : Ascender Corporation +Foundry URL : httpwwwascendercorpcom + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ + + + + +This is a custom SVG webfont generated by Font Squirrel. +Copyright : Digitized data copyright 20102011 Google Corporation +Foundry : Ascender Corporation +Foundry URL : httpwwwascendercorpcom + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ + + + + +This is a custom SVG webfont generated by Font Squirrel. +Copyright : Digitized data copyright 20102011 Google Corporation +Foundry : Ascender Corporation +Foundry URL : httpwwwascendercorpcom + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ + + + + +This is a custom SVG webfont generated by Font Squirrel. +Copyright : Digitized data copyright 20102011 Google Corporation +Foundry : Ascender Corporation +Foundry URL : httpwwwascendercorpcom + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ + + + + +This is a custom SVG webfont generated by Font Squirrel. +Copyright : Digitized data copyright 2011 Google Corporation +Foundry : Ascender Corporation +Foundry URL : httpwwwascendercorpcom + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ + + + + +This is a custom SVG webfont generated by Font Squirrel. +Copyright : Digitized data copyright 20102011 Google Corporation +Foundry : Ascender Corporation +Foundry URL : httpwwwascendercorpcom + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ - - + + @@ -63,7 +63,7 @@ - + @@ -138,7 +138,7 @@ - + @@ -178,7 +178,7 @@ - + @@ -193,7 +193,7 @@ - + @@ -250,7 +250,7 @@ - + @@ -277,7 +277,7 @@ - + @@ -317,8 +317,8 @@ - - + + @@ -333,7 +333,67 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/lms/static/fonts/vendor/fontawesome-webfont.ttf b/common/static/fonts/vendor/fontawesome-webfont.ttf similarity index 65% rename from lms/static/fonts/vendor/fontawesome-webfont.ttf rename to common/static/fonts/vendor/fontawesome-webfont.ttf index 908f69ec9a..d365924691 100755 Binary files a/lms/static/fonts/vendor/fontawesome-webfont.ttf and b/common/static/fonts/vendor/fontawesome-webfont.ttf differ diff --git a/common/static/fonts/vendor/fontawesome-webfont.woff b/common/static/fonts/vendor/fontawesome-webfont.woff new file mode 100755 index 0000000000..b9bd17e158 Binary files /dev/null and b/common/static/fonts/vendor/fontawesome-webfont.woff differ diff --git a/lms/djangoapps/bulk_email/tasks.py b/lms/djangoapps/bulk_email/tasks.py index a2f376cdcc..2dd168aeb5 100644 --- a/lms/djangoapps/bulk_email/tasks.py +++ b/lms/djangoapps/bulk_email/tasks.py @@ -318,9 +318,12 @@ def send_course_email(entry_id, email_id, to_list, global_email_context, subtask # Check that the requested subtask is actually known to the current InstructorTask entry. # If this fails, it throws an exception, which should fail this subtask immediately. # This can happen when the parent task has been run twice, and results in duplicate - # subtasks being created for the same InstructorTask entry. We hope to catch this condition - # in perform_delegate_email_batches(), but just in case we fail to do so there, - # we check here as well. + # subtasks being created for the same InstructorTask entry. This can happen when Celery + # loses its connection to its broker, and any current tasks get requeued. + # We hope to catch this condition in perform_delegate_email_batches() when it's the parent + # task that is resubmitted, but just in case we fail to do so there, we check here as well. + # There is also a possibility that this task will be run twice by Celery, for the same reason. + # To deal with that, we need to confirm that the task has not already been completed. check_subtask_is_valid(entry_id, current_task_id) send_exception = None diff --git a/lms/djangoapps/bulk_email/tests/test_err_handling.py b/lms/djangoapps/bulk_email/tests/test_err_handling.py index dfe098306c..3761d9bbbd 100644 --- a/lms/djangoapps/bulk_email/tests/test_err_handling.py +++ b/lms/djangoapps/bulk_email/tests/test_err_handling.py @@ -5,6 +5,8 @@ from itertools import cycle from mock import patch from smtplib import SMTPDataError, SMTPServerDisconnected, SMTPConnectError +from celery.states import SUCCESS + from django.test.utils import override_settings from django.conf import settings from django.core.management import call_command @@ -19,7 +21,12 @@ from student.tests.factories import UserFactory, AdminFactory, CourseEnrollmentF from bulk_email.models import CourseEmail, SEND_TO_ALL from bulk_email.tasks import perform_delegate_email_batches, send_course_email from instructor_task.models import InstructorTask -from instructor_task.subtasks import create_subtask_status, initialize_subtask_info +from instructor_task.subtasks import ( + create_subtask_status, + initialize_subtask_info, + update_subtask_status, + DuplicateTaskException, +) class EmailTestException(Exception): @@ -210,7 +217,7 @@ class TestEmailErrors(ModuleStoreTestCase): subtask_id = "subtask-id-value" subtask_status = create_subtask_status(subtask_id) email_id = 1001 - with self.assertRaisesRegexp(ValueError, 'unable to find email subtasks of instructor task'): + with self.assertRaisesRegexp(DuplicateTaskException, 'unable to find email subtasks of instructor task'): send_course_email(entry_id, email_id, to_list, global_email_context, subtask_status) def test_send_email_missing_subtask(self): @@ -224,9 +231,24 @@ class TestEmailErrors(ModuleStoreTestCase): different_subtask_id = "bogus-subtask-id-value" subtask_status = create_subtask_status(different_subtask_id) bogus_email_id = 1001 - with self.assertRaisesRegexp(ValueError, 'unable to find status for email subtask of instructor task'): + with self.assertRaisesRegexp(DuplicateTaskException, 'unable to find status for email subtask of instructor task'): send_course_email(entry_id, bogus_email_id, to_list, global_email_context, subtask_status) + def test_send_email_completed_subtask(self): + # test at a lower level, to ensure that the course gets checked down below too. + entry = InstructorTask.create(self.course.id, "task_type", "task_key", "task_input", self.instructor) + entry_id = entry.id # pylint: disable=E1101 + subtask_id = "subtask-id-value" + initialize_subtask_info(entry, "emailed", 100, [subtask_id]) + subtask_status = create_subtask_status(subtask_id, state=SUCCESS) + update_subtask_status(entry_id, subtask_id, subtask_status) + bogus_email_id = 1001 + to_list = ['test@test.com'] + global_email_context = {'course_title': 'dummy course'} + new_subtask_status = create_subtask_status(subtask_id) + with self.assertRaisesRegexp(DuplicateTaskException, 'already completed'): + send_course_email(entry_id, bogus_email_id, to_list, global_email_context, new_subtask_status) + def dont_test_send_email_undefined_email(self): # test at a lower level, to ensure that the course gets checked down below too. entry = InstructorTask.create(self.course.id, "task_type", "task_key", "task_input", self.instructor) diff --git a/lms/djangoapps/instructor/tests/test_email.py b/lms/djangoapps/instructor/tests/test_email.py index de8832a625..f3b4194228 100644 --- a/lms/djangoapps/instructor/tests/test_email.py +++ b/lms/djangoapps/instructor/tests/test_email.py @@ -1,7 +1,8 @@ """ Unit tests for email feature flag in new instructor dashboard. Additionally tests that bulk email is always disabled for -non-Mongo backed courses, regardless of email feature flag. +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 @@ -90,7 +91,7 @@ class TestNewInstructorDashboardEmailViewMongoBacked(ModuleStoreTestCase): # 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 + # Assert that the URL for the email view is in the response response = self.client.get(self.url) self.assertTrue(self.email_link in response.content) diff --git a/lms/djangoapps/instructor/tests/test_legacy_email.py b/lms/djangoapps/instructor/tests/test_legacy_email.py index 20b8271bdf..25a70e4f8a 100644 --- a/lms/djangoapps/instructor/tests/test_legacy_email.py +++ b/lms/djangoapps/instructor/tests/test_legacy_email.py @@ -1,20 +1,21 @@ """ -Unit tests for email feature flag in legacy instructor dashboard -and student dashboard. Additionally tests that bulk email -is always disabled for non-Mongo backed courses, regardless -of email feature flag. +Unit tests for email feature flag in legacy instructor 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 from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE -from student.tests.factories import AdminFactory, UserFactory, CourseEnrollmentFactory +from student.tests.factories import AdminFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore import XML_MODULESTORE_TYPE +from bulk_email.models import CourseAuthorization + from mock import patch @@ -59,6 +60,32 @@ class TestInstructorDashboardEmailView(ModuleStoreTestCase): send_to_label = '' self.assertTrue(send_to_label in response.content) + @patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': True}) + def test_email_flag_unauthorized(self): + # Assert that the URL for the email view is not in the response + # email is enabled, but this course is not authorized to send email + response = self.client.get(self.url) + self.assertFalse(self.email_link in response.content) + + @patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': True}) + def test_email_flag_authorized(self): + # Assert that the URL for the email view is in the response + # email is enabled, and this course is authorized to send email + + # Assert that instructor email is not enabled for this course + self.assertFalse(CourseAuthorization.instructor_email_enabled(self.course.id)) + response = self.client.get(self.url) + self.assertFalse(self.email_link in response.content) + + # 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)) + response = self.client.get(self.url) + self.assertTrue(self.email_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 @@ -81,62 +108,3 @@ class TestInstructorDashboardEmailView(ModuleStoreTestCase): # Assert that the URL for the email view is not in the response response = self.client.get(self.url) self.assertFalse(self.email_link in response.content) - - -@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") - - # URL for dashboard - self.url = reverse('dashboard') - # URL for email settings modal - self.email_modal_link = (('') - .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}) - 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}) - def test_email_flag_true_xml_store(self): - # If the enable email setting is enabled, but this is an XML backed course, - # the email view shouldn't be available on the instructor dashboard. - - # The course factory uses a MongoModuleStore backing, so patch the - # `get_modulestore_type` method to pretend to be XML-backed. - # This is OK; we're simply testing that the `is_mongo_modulestore_type` flag - # in `instructor/views/legacy.py` is doing the correct thing. - - with patch('xmodule.modulestore.mongo.base.MongoModuleStore.get_modulestore_type') as mock_modulestore: - mock_modulestore.return_value = XML_MODULESTORE_TYPE - - # 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) diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index a744d157a1..303028c21f 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -726,18 +726,31 @@ def instructor_dashboard(request, course_id): email_subject = request.POST.get("subject") html_message = request.POST.get("message") - # Create the CourseEmail object. This is saved immediately, so that - # any transaction that has been pending up to this point will also be - # committed. - email = CourseEmail.create(course_id, request.user, email_to_option, email_subject, html_message) + try: + # Create the CourseEmail object. This is saved immediately, so that + # any transaction that has been pending up to this point will also be + # committed. + email = CourseEmail.create(course_id, request.user, email_to_option, email_subject, html_message) - # Submit the task, so that the correct InstructorTask object gets created (for monitoring purposes) - submit_bulk_course_email(request, course_id, email.id) # pylint: disable=E1101 + # Submit the task, so that the correct InstructorTask object gets created (for monitoring purposes) + submit_bulk_course_email(request, course_id, email.id) # pylint: disable=E1101 + + except Exception as err: + # Catch any errors and deliver a message to the user + error_msg = "Failed to send email! ({0})".format(err) + msg += "" + error_msg + "" + log.exception(error_msg) - if email_to_option == "all": - email_msg = '

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.

' else: - email_msg = '

Your email was successfully queued for sending.

' + # If sending the task succeeds, deliver a success message to the user. + if email_to_option == "all": + email_msg = '

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.

' + else: + email_msg = '

Your email was successfully queued for sending.

' + + elif "Show Background Email Task History" in action: + message, datatable = get_background_task_table(course_id, task_type='bulk_course_email') + msg += message elif "Show Background Email Task History" in action: message, datatable = get_background_task_table(course_id, task_type='bulk_course_email') diff --git a/lms/djangoapps/instructor_task/subtasks.py b/lms/djangoapps/instructor_task/subtasks.py index 175cf084a5..6b8b1e2046 100644 --- a/lms/djangoapps/instructor_task/subtasks.py +++ b/lms/djangoapps/instructor_task/subtasks.py @@ -14,6 +14,11 @@ from instructor_task.models import InstructorTask, PROGRESS, QUEUING TASK_LOG = get_task_logger(__name__) +class DuplicateTaskException(Exception): + """Exception indicating that a task already exists or has already completed.""" + pass + + def create_subtask_status(task_id, succeeded=0, failed=0, skipped=0, retried_nomax=0, retried_withmax=0, state=None): """ Create and return a dict for tracking the status of a subtask. @@ -149,30 +154,48 @@ def initialize_subtask_info(entry, action_name, total_num, subtask_id_list): def check_subtask_is_valid(entry_id, current_task_id): """ - Confirms that the current subtask is known to the InstructorTask. + Confirms that the current subtask is known to the InstructorTask and hasn't already been completed. - This may happen if a task that spawns subtasks is called twice with - the same task_id and InstructorTask entry_id. The set of subtasks - that are recorded in the InstructorTask from the first call get clobbered - by the the second set of subtasks. So when the first set of subtasks - actually run, they won't be found in the InstructorTask. + Problems can occur when the parent task has been run twice, and results in duplicate + subtasks being created for the same InstructorTask entry. This can happen when Celery + loses its connection to its broker, and any current tasks get requeued. - Raises a ValueError exception if not. + If a parent task gets requeued, then the same InstructorTask may have a different set of + subtasks defined (to do the same thing), so the subtasks from the first queuing would not + be known to the InstructorTask. We return an exception in this case. + + If a subtask gets requeued, then the first time the subtask runs it should run fine to completion. + However, we want to prevent it from running again, so we check here to see what the existing + subtask's status is. If it is complete, we return an exception. + + Raises a DuplicateTaskException exception if it's not a task that should be run. """ + # Confirm that the InstructorTask actually defines subtasks. entry = InstructorTask.objects.get(pk=entry_id) if len(entry.subtasks) == 0: format_str = "Unexpected task_id '{}': unable to find email subtasks of instructor task '{}'" msg = format_str.format(current_task_id, entry) TASK_LOG.warning(msg) - raise ValueError(msg) + raise DuplicateTaskException(msg) + # Confirm that the InstructorTask knows about this particular subtask. subtask_dict = json.loads(entry.subtasks) subtask_status_info = subtask_dict['status'] if current_task_id not in subtask_status_info: format_str = "Unexpected task_id '{}': unable to find status for email subtask of instructor task '{}'" msg = format_str.format(current_task_id, entry) TASK_LOG.warning(msg) - raise ValueError(msg) + raise DuplicateTaskException(msg) + + # Confirm that the InstructorTask doesn't think that this subtask has already been + # performed successfully. + subtask_status = subtask_status_info[current_task_id] + subtask_state = subtask_status.get('state') + if subtask_state in READY_STATES: + format_str = "Unexpected task_id '{}': already completed - status {} for email subtask of instructor task '{}'" + msg = format_str.format(current_task_id, subtask_status, entry) + TASK_LOG.warning(msg) + raise DuplicateTaskException(msg) @transaction.commit_manually diff --git a/lms/envs/common.py b/lms/envs/common.py index 762d842785..513f3e110f 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -590,6 +590,7 @@ MIDDLEWARE_CLASSES = ( # Instead of AuthenticationMiddleware, we use a cached backed version #'django.contrib.auth.middleware.AuthenticationMiddleware', 'cache_toolbox.middleware.CacheBackedAuthenticationMiddleware', + 'student.middleware.UserStandingMiddleware', 'contentserver.middleware.StaticContentServer', 'crum.CurrentRequestUserMiddleware', @@ -651,25 +652,49 @@ open_ended_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/open_end notes_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/notes/**/*.coffee')) PIPELINE_CSS = { - 'application': { - 'source_filenames': ['sass/application.css'], - 'output_filename': 'css/lms-application.css', + 'style-vendor': { + 'source_filenames': [ + 'css/vendor/font-awesome.css', + 'css/vendor/jquery.qtip.min.css', + 'css/vendor/responsive-carousel/responsive-carousel.css', + 'css/vendor/responsive-carousel/responsive-carousel.slide.css', + ], + 'output_filename': 'css/lms-style-vendor.css', }, - 'course': { + 'style-app': { + 'source_filenames': [ + 'sass/application.css', + 'sass/ie.css' + ], + 'output_filename': 'css/lms-style-app.css', + }, + 'style-app-extend1': { + 'source_filenames': [ + 'sass/application-extend1.css', + ], + 'output_filename': 'css/lms-style-app-extend1.css', + }, + 'style-app-extend2': { + 'source_filenames': [ + 'sass/application-extend2.css', + ], + 'output_filename': 'css/lms-style-app-extend2.css', + }, + 'style-course-vendor': { 'source_filenames': [ 'js/vendor/CodeMirror/codemirror.css', 'css/vendor/jquery.treeview.css', 'css/vendor/ui-lightness/jquery-ui-1.8.22.custom.css', - 'css/vendor/jquery.qtip.min.css', 'css/vendor/annotator.min.css', + ], + 'output_filename': 'css/lms-style-course-vendor.css', + }, + 'style-course': { + 'source_filenames': [ 'sass/course.css', 'xmodule/modules.css', ], - 'output_filename': 'css/lms-course.css', - }, - 'ie-fixes': { - 'source_filenames': ['sass/ie.css'], - 'output_filename': 'css/lms-ie.css', + 'output_filename': 'css/lms-style-course.css', }, } diff --git a/lms/static/sass/vendor/responsive-carousel/_responsive-carousel.scss b/lms/static/css/vendor/responsive-carousel/responsive-carousel.css similarity index 100% rename from lms/static/sass/vendor/responsive-carousel/_responsive-carousel.scss rename to lms/static/css/vendor/responsive-carousel/responsive-carousel.css diff --git a/lms/static/sass/vendor/responsive-carousel/_responsive-carousel.slide.scss b/lms/static/css/vendor/responsive-carousel/responsive-carousel.slide.css similarity index 100% rename from lms/static/sass/vendor/responsive-carousel/_responsive-carousel.slide.scss rename to lms/static/css/vendor/responsive-carousel/responsive-carousel.slide.css diff --git a/lms/static/fonts/vendor/FontAwesome.otf b/lms/static/fonts/vendor/FontAwesome.otf deleted file mode 100644 index 32dd8b1cd5..0000000000 Binary files a/lms/static/fonts/vendor/FontAwesome.otf and /dev/null differ diff --git a/lms/static/fonts/vendor/fontawesome-webfont.eot b/lms/static/fonts/vendor/fontawesome-webfont.eot deleted file mode 100755 index c080283bdd..0000000000 Binary files a/lms/static/fonts/vendor/fontawesome-webfont.eot and /dev/null differ diff --git a/lms/static/fonts/vendor/fontawesome-webfont.woff b/lms/static/fonts/vendor/fontawesome-webfont.woff deleted file mode 100755 index a33af950ae..0000000000 Binary files a/lms/static/fonts/vendor/fontawesome-webfont.woff and /dev/null differ diff --git a/lms/static/sass/_shame.scss b/lms/static/sass/_shame.scss index 2fc6669fcd..64beba6723 100644 --- a/lms/static/sass/_shame.scss +++ b/lms/static/sass/_shame.scss @@ -242,3 +242,10 @@ footer .references { outline: thin dotted !important; } } + +// ==================== + +// poor cascade made worse by CSS splitting requires us to redefine the dashboard views' visual top padding +.dashboard { + padding-top: 60px; +} diff --git a/lms/static/sass/application-extend1.scss.mako b/lms/static/sass/application-extend1.scss.mako new file mode 100644 index 0000000000..86a442301d --- /dev/null +++ b/lms/static/sass/application-extend1.scss.mako @@ -0,0 +1,62 @@ +## NOTE: This Sass infrastructure is redundant, but needed in order to address an IE9 rule limit within CSS - http://blogs.msdn.com/b/ieinternals/archive/2011/05/14/10164546.aspx + + +// lms - css application architecture (platform) +// ==================== + +// libs and resets *do not edit* +@import 'bourbon/bourbon'; // lib - bourbon + +// BASE *default edX offerings* +// ==================== + +// base - utilities +@import 'base/reset'; +@import 'base/mixins'; +@import 'base/variables'; + +## THEMING +## ------- +## Set up this file to import an edX theme library if the environment +## indicates that a theme should be used. The assumption is that the +## theme resides outside of this main edX repository, in a directory +## called themes//, with its base Sass file in +## themes//static/sass/_.scss. That one entry +## point can be used to @import in as many other things as needed. +% if env.get('THEME_NAME') is not None: + // import theme's Sass overrides + @import '${env.get('THEME_NAME')}'; +% endif + +@import 'base/base'; + +// base - assets +@import 'base/font_face'; +@import 'base/extends'; +@import 'base/animations'; + +// base - starter +@import 'base/base'; + +// base - elements +@import 'elements/typography'; +@import 'elements/controls'; + +// shared - platform +@import 'multicourse/home'; +@import 'multicourse/dashboard'; +@import 'multicourse/account'; +@import 'multicourse/testcenter-register'; +@import 'multicourse/courses'; +@import 'multicourse/course_about'; +@import 'multicourse/jobs'; +@import 'multicourse/media-kit'; +@import 'multicourse/about_pages'; +@import 'multicourse/press_release'; +@import 'multicourse/password_reset'; +@import 'multicourse/error-pages'; +@import 'multicourse/help'; +@import 'multicourse/edge'; + +## NOTE: needed here for cascade and dependency purposes, but not a great permanent solution +@import 'shame'; // shame file - used for any bad-form/orphaned scss that knowingly violate edX FED architecture/standards (see - http://csswizardry.com/2013/04/shame-css/) diff --git a/lms/static/sass/application-extend2.scss.mako b/lms/static/sass/application-extend2.scss.mako new file mode 100644 index 0000000000..32c32e9d2f --- /dev/null +++ b/lms/static/sass/application-extend2.scss.mako @@ -0,0 +1,54 @@ +## NOTE: This Sass infrastructure is redundant, but needed in order to address an IE9 rule limit within CSS - http://blogs.msdn.com/b/ieinternals/archive/2011/05/14/10164546.aspx + + +// lms - css application architecture (platform) +// ==================== + +// libs and resets *do not edit* +@import 'bourbon/bourbon'; // lib - bourbon + +// BASE *default edX offerings* +// ==================== + +// base - utilities +@import 'base/reset'; +@import 'base/mixins'; +@import 'base/variables'; + +## THEMING +## ------- +## Set up this file to import an edX theme library if the environment +## indicates that a theme should be used. The assumption is that the +## theme resides outside of this main edX repository, in a directory +## called themes//, with its base Sass file in +## themes//static/sass/_.scss. That one entry +## point can be used to @import in as many other things as needed. +% if env.get('THEME_NAME') is not None: + // import theme's Sass overrides + @import '${env.get('THEME_NAME')}'; +% endif + +@import 'base/base'; + +// base - assets +@import 'base/font_face'; +@import 'base/extends'; +@import 'base/animations'; + +// base - starter +@import 'base/base'; + +// base - elements +@import 'elements/typography'; +@import 'elements/controls'; + +// base - specific views +@import 'views/verification'; +@import 'views/shoppingcart'; + +// applications +@import 'discussion'; +@import 'news'; + +## NOTE: needed here for cascade and dependency purposes, but not a great permanent solution +@import 'shame'; // shame file - used for any bad-form/orphaned scss that knowingly violate edX FED architecture/standards (see - http://csswizardry.com/2013/04/shame-css/) diff --git a/lms/static/sass/application.scss.mako b/lms/static/sass/application.scss.mako index a500748121..16688d5368 100644 --- a/lms/static/sass/application.scss.mako +++ b/lms/static/sass/application.scss.mako @@ -1,21 +1,16 @@ +## Note: This Sass infrastructure is repeated in application-extend1 and application-extend2, but needed in order to address an IE9 rule limit within CSS - http://blogs.msdn.com/b/ieinternals/archive/2011/05/14/10164546.aspx + // lms - css application architecture // ==================== // libs and resets *do not edit* @import 'bourbon/bourbon'; // lib - bourbon -// VENDOR + REBASE *referenced/used vendor presentation and reset* -// ==================== - -@import 'base/reset'; -@import 'vendor/font-awesome'; -@import 'vendor/responsive-carousel/responsive-carousel'; -@import 'vendor/responsive-carousel/responsive-carousel.slide'; - // BASE *default edX offerings* // ==================== -// base - utilities +// base - utilities +@import 'base/reset'; @import 'base/mixins'; @import 'base/variables'; @@ -46,10 +41,6 @@ @import 'elements/typography'; @import 'elements/controls'; -// base - specific views -@import 'views/verification'; -@import 'views/shoppingcart'; - // shared - course @import 'shared/forms'; @import 'shared/footer'; @@ -60,24 +51,5 @@ @import 'shared/activation_messages'; @import 'shared/unsubscribe'; -// shared - platform -@import 'multicourse/home'; -@import 'multicourse/dashboard'; -@import 'multicourse/account'; -@import 'multicourse/testcenter-register'; -@import 'multicourse/courses'; -@import 'multicourse/course_about'; -@import 'multicourse/jobs'; -@import 'multicourse/media-kit'; -@import 'multicourse/about_pages'; -@import 'multicourse/press_release'; -@import 'multicourse/password_reset'; -@import 'multicourse/error-pages'; -@import 'multicourse/help'; -@import 'multicourse/edge'; - -// applications -@import 'discussion'; -@import 'news'; - +## NOTE: needed here for cascade and dependency purposes, but not a great permanent solution @import 'shame'; // shame file - used for any bad-form/orphaned scss that knowingly violate edX FED architecture/standards (see - http://csswizardry.com/2013/04/shame-css/) diff --git a/lms/static/sass/base/_reset.scss b/lms/static/sass/base/_reset.scss index e774e7a59b..879f805689 100644 --- a/lms/static/sass/base/_reset.scss +++ b/lms/static/sass/base/_reset.scss @@ -54,7 +54,6 @@ input[type="search"]::-webkit-search-decoration, input[type="search"]::-webkit-s button::-moz-focus-inner, input::-moz-focus-inner { border: 0; padding: 0; } textarea { overflow: auto; vertical-align: top; resize: vertical; } input:valid, textarea:valid { } -input:invalid, textarea:invalid { background-color: #f0dddd; } table { border-collapse: collapse; border-spacing: 0; } td { vertical-align: top; } @@ -70,7 +69,7 @@ td { vertical-align: top; } @media only screen and (min-width: 35em) { - + } @@ -85,13 +84,13 @@ td { vertical-align: top; } .clearfix { *zoom: 1; } @media print { - * { background: transparent !important; color: black !important; box-shadow:none !important; text-shadow: none !important; filter:none !important; -ms-filter: none !important; } + * { background: transparent !important; color: black !important; box-shadow:none !important; text-shadow: none !important; filter:none !important; -ms-filter: none !important; } a, a:visited { text-decoration: underline; } a[href]:after { content: " (" attr(href) ")"; } abbr[title]:after { content: " (" attr(title) ")"; } - .ir a:after, a[href^="javascript:"]:after, a[href^="#"]:after { content: ""; } + .ir a:after, a[href^="javascript:"]:after, a[href^="#"]:after { content: ""; } pre, blockquote { border: 1px solid #999; page-break-inside: avoid; } - thead { display: table-header-group; } + thead { display: table-header-group; } tr, img { page-break-inside: avoid; } img { max-width: 100% !important; } @page { margin: 0.5cm; } diff --git a/lms/static/sass/elements/_typography.scss b/lms/static/sass/elements/_typography.scss index 043a205278..2cf11ce79b 100644 --- a/lms/static/sass/elements/_typography.scss +++ b/lms/static/sass/elements/_typography.scss @@ -194,3 +194,92 @@ %t-weight5 { font-weight: 700; } + +// ==================== + +// MISC: extends - type +// application: canned headings +%hd-lv1 { + @extend %t-title1; + @extend %t-weight1; + color: $m-gray-d4; + margin: 0 0 ($baseline*2) 0; +} + +%hd-lv2 { + @extend %t-title4; + @extend %t-weight1; + margin: 0 0 ($baseline*0.75) 0; + border-bottom: 1px solid $m-gray-l3; + padding-bottom: ($baseline/2); + color: $m-gray-d4; +} + +%hd-lv3 { + @extend %t-title6; + @extend %t-weight4; + margin: 0 0 ($baseline/4) 0; + color: $m-gray-d4; +} + +%hd-lv4 { + @extend %t-title6; + @extend %t-weight2; + margin: 0 0 $baseline 0; + color: $m-gray-d4; +} + +%hd-lv5 { + @extend %t-title7; + @extend %t-weight4; + margin: 0 0 ($baseline/4) 0; + color: $m-gray-d4; +} + +// application: canned copy +%copy-base { + @extend %t-copy-base; + color: $m-gray-d2; +} + +%copy-lead1 { + @extend %t-copy-lead2; + color: $m-gray; +} + +%copy-detail { + @extend %t-copy-sub1; + @extend %t-weight3; + color: $m-gray-d1; +} + +%copy-metadata { + @extend %t-copy-sub2; + color: $m-gray-d1; + + + %copy-metadata-value { + @extend %t-weight2; + } + + %copy-metadata-value { + @extend %t-weight4; + } +} + +// application: canned links +%copy-link { + border-bottom: 1px dotted transparent; + + &:hover, &:active { + border-color: $link-color-d1; + } +} + +%copy-badge { + @extend %t-title8; + @extend %t-weight5; + border-radius: ($baseline/5); + padding: ($baseline/2) $baseline; + text-transform: uppercase; +} diff --git a/lms/static/sass/ie.scss b/lms/static/sass/ie.scss index 87a8b07fe6..258707a497 100644 --- a/lms/static/sass/ie.scss +++ b/lms/static/sass/ie.scss @@ -2,184 +2,194 @@ @import "base/variables"; // These are all quick solutions for IE please rewrite -//Make overlay white because ie doesn't like rgba -.highlighted-courses .courses .course header.course-preview, .find-courses .courses .course header.course-preview, -.home .highlighted-courses > h2, .home .highlighted-courses > section.outside-app h1, section.outside-app .home .highlighted-courses > h1, -header.global { - background: #FFF; -} +.ie { -// hide all actions -.home > header .title .actions, -.home > header .title:hover .actions { - display: none; - height: auto; -} + //Make overlay white because ie doesn't like rgba + .highlighted-courses .courses .course header.course-preview, .find-courses .courses .course header.course-preview, + .home .highlighted-courses > h2, .home .highlighted-courses > section.outside-app h1, section.outside-app .home .highlighted-courses > h1, + header.global { + background: #FFF; + } -.home > header .title { - &:hover { + // hide all actions + .home > header .title .actions, + .home > header .title:hover .actions { + display: none; + height: auto; + } - > hgroup { - h1 { - border-bottom: 0; - padding-bottom: 0; + .home > header .title { + &:hover { + + > hgroup { + h1 { + border-bottom: 0; + padding-bottom: 0; + } + + h2 { + opacity: 1.0; + } } - h2 { + .actions { + opacity: 0; + } + } + } + + // because ie doesn't like :last + .last { + margin-right: 0 !important; + } + + // make partners not animate + .home .university-partners .partners a { + .name { + position: static; + } + + &:hover { + text-decoration: none; + + &::before { opacity: 1.0; } + + .name { + bottom: 0px; + } + + img { + top: 0px; + } } - .actions { - opacity: 0; - } - } -} - -// because ie doesn't like :last -.last { - margin-right: 0 !important; -} - -// make partners not animate -.home .university-partners .partners a { - .name { - position: static; } - &:hover { - text-decoration: none; + .home .university-partners .partners { + width: 660px; - &::before { - opacity: 1.0; + li.partner { + float: left; + display: block; + padding: 0; + width: 220px; + overflow: hidden; + } + } + + // make animations on homepage not animate and show everything + .highlighted-courses .courses .course, .find-courses .courses .course { + .meta-info { + display: none; } - .name { - bottom: 0px; + .inner-wrapper { + height: 100%; + overflow: visible; + position: relative; } - img { + header.course-preview { + left: 0px; + position: relative; top: 0px; + width: 100%; + z-index: 3; + height: auto; + + hgroup { + position: relative; + right: 0; + top: 0; + } + + } + + .info { + height: auto; + position: static; + overflow: visible; + + .desc { + height: auto; + } + } + + &:hover { + background: rgb(245,245,245); + border-color: rgb(170,170,170); + box-shadow: 0 1px 16px 0 rgba($blue, 0.4); + + .info { + top: 0; + } + + .meta-info { + opacity: 0; + } } } -} - -.home .university-partners .partners { - width: 660px; - - li.partner { - float: left; - display: block; - padding: 0; - width: 220px; - overflow: hidden; + // make overlay flat black since IE cant handle rgba + #lean_overlay { + background: #000; } -} -// make animations on homepage not animate and show everything -.highlighted-courses .courses .course, .find-courses .courses .course { - .meta-info { + // active navigation + nav.course-material ol.course-tabs li a.active, nav.course-material .xmodule_SequenceModule nav.sequence-nav ol.course-tabs li a.seq_video.active, .xmodule_SequenceModule nav.sequence-nav nav.course-material ol.course-tabs li a.seq_video.active { + background-color: #333; + background-color: rgba(0, 0, 0, .4); + } + + // make dropdown user consistent size + header.global ol.user > li.primary a.dropdown { + padding-top: 6px; + padding-bottom: 6px; + } + + // always hide arrow in IE + .dashboard .my-courses .my-course .cover .arrow { display: none; } - .inner-wrapper { - height: 100%; - overflow: visible; - position: relative; - } - - header.course-preview { - left: 0px; - position: relative; - top: 0px; - width: 100%; - z-index: 3; - height: auto; - - hgroup { - position: relative; - right: 0; - top: 0; - } - - } - - .info { - height: auto; - position: static; - overflow: visible; - - .desc { - height: auto; - } - } - - &:hover { - background: rgb(245,245,245); - border-color: rgb(170,170,170); - box-shadow: 0 1px 16px 0 rgba($blue, 0.4); - - .info { - top: 0; - } - - .meta-info { - opacity: 0; - } - } -} - -// make overlay flat black since IE cant handle rgba -#lean_overlay { - background: #000; -} - -// active navigation -nav.course-material ol.course-tabs li a.active, nav.course-material .xmodule_SequenceModule nav.sequence-nav ol.course-tabs li a.seq_video.active, .xmodule_SequenceModule nav.sequence-nav nav.course-material ol.course-tabs li a.seq_video.active { - background-color: #333; - background-color: rgba(0, 0, 0, .4); -} - -// make dropdown user consistent size -header.global ol.user > li.primary a.dropdown { - padding-top: 6px; - padding-bottom: 6px; -} - -// always hide arrow in IE -.dashboard .my-courses .my-course .cover .arrow { - display: none; -} - -.ie-banner { - display: block !important; -} - -div.course-wrapper { - display: block !important; - - section.course-content, - section.course-index { + div.course-wrapper { display: block !important; - float: left; + + section.course-content, + section.course-index { + display: block !important; + float: left; + } + + section.course-content { + width: 71.27%; + } } - section.course-content { - width: 71.27%; - } -} - -.sidebar { - float: left !important; - display: block !important; -} - -.sequence-nav ol { - display: block !important; - - li { + .sidebar { float: left !important; - width: 50px; + display: block !important; + } + + .sequence-nav ol { + display: block !important; + + li { + float: left !important; + width: 50px; + } + } + + .course-wrapper { + clear: both !important; + } +} + +.lte9 { + + .ie-banner { + display: block !important; } } diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index 43051c887c..40795b922b 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -1,95 +1,7 @@ // lms - views - verification flow // ==================== -// MISC: extends - type -// application: canned headings -%hd-lv1 { - @extend %t-title1; - @extend %t-weight1; - color: $m-gray-d4; - margin: 0 0 ($baseline*2) 0; -} - -%hd-lv2 { - @extend %t-title4; - @extend %t-weight1; - margin: 0 0 ($baseline*0.75) 0; - border-bottom: 1px solid $m-gray-l3; - padding-bottom: ($baseline/2); - color: $m-gray-d4; -} - -%hd-lv3 { - @extend %t-title6; - @extend %t-weight4; - margin: 0 0 ($baseline/4) 0; - color: $m-gray-d4; -} - -%hd-lv4 { - @extend %t-title6; - @extend %t-weight2; - margin: 0 0 $baseline 0; - color: $m-gray-d4; -} - -%hd-lv5 { - @extend %t-title7; - @extend %t-weight4; - margin: 0 0 ($baseline/4) 0; - color: $m-gray-d4; -} - -// application: canned copy -%copy-base { - @extend %t-copy-base; - color: $m-gray-d2; -} - -%copy-lead1 { - @extend %t-copy-lead2; - color: $m-gray; -} - -%copy-detail { - @extend %t-copy-sub1; - @extend %t-weight3; - color: $m-gray-d1; -} - -%copy-metadata { - @extend %t-copy-sub2; - color: $m-gray-d1; - - - %copy-metadata-value { - @extend %t-weight2; - } - - %copy-metadata-value { - @extend %t-weight4; - } -} - -// application: canned links -%copy-link { - border-bottom: 1px dotted transparent; - - &:hover, &:active { - border-color: $link-color-d1; - } -} - -%copy-badge { - @extend %t-title8; - @extend %t-weight5; - border-radius: ($baseline/5); - padding: ($baseline/2) $baseline; - text-transform: uppercase; -} - -// ==================== - +// MISC: extends - button %btn-verify-primary { @extend %btn-primary-green; } diff --git a/lms/templates/courseware/courseware-error.html b/lms/templates/courseware/courseware-error.html index f0f7969026..9018495bff 100644 --- a/lms/templates/courseware/courseware-error.html +++ b/lms/templates/courseware/courseware-error.html @@ -6,7 +6,8 @@ <%block name="title">${_("Courseware")} - ${settings.PLATFORM_NAME} <%block name="headextra"> - <%static:css group='course'/> +<%static:css group='style-course-vendor'/> +<%static:css group='style-course'/> <%include file="/courseware/course_navigation.html" args="active_page='courseware'" /> diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index fd3c741be6..9e3b19076b 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -5,8 +5,11 @@ <%block name="title">${_("{course_number} Courseware").format(course_number=course.display_number_with_default) | h} <%block name="headextra"> - <%static:css group='course'/> - <%include file="../discussion/_js_head_dependencies.html" /> +<%static:css group='style-course-vendor'/> +<%static:css group='style-course'/> + +<%include file="../discussion/_js_head_dependencies.html" /> + % if show_chat: ## It'd be better to have this in a place like lms/css/vendor/candy, diff --git a/lms/templates/courseware/gradebook.html b/lms/templates/courseware/gradebook.html index 2c9f3e8474..8828721555 100644 --- a/lms/templates/courseware/gradebook.html +++ b/lms/templates/courseware/gradebook.html @@ -11,7 +11,8 @@ <%block name="headextra"> - <%static:css group='course'/> +<%static:css group='style-course-vendor'/> +<%static:css group='style-course'/>