diff --git a/cms/djangoapps/contentstore/features/advanced-settings.py b/cms/djangoapps/contentstore/features/advanced-settings.py index ea5b24b21f..3acebecac8 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.py +++ b/cms/djangoapps/contentstore/features/advanced-settings.py @@ -19,9 +19,7 @@ DISPLAY_NAME_VALUE = '"Robot Super Course"' ############### ACTIONS #################### @step('I select the Advanced Settings$') def i_select_advanced_settings(step): - expand_icon_css = 'li.nav-course-settings i.icon-expand' - if world.browser.is_element_present_by_css(expand_icon_css): - world.css_click(expand_icon_css) + world.click_course_settings() link_css = 'li.nav-course-settings-advanced a' world.css_click(link_css) diff --git a/cms/djangoapps/contentstore/features/checklists.py b/cms/djangoapps/contentstore/features/checklists.py index 1c9fbf0994..9552d35036 100644 --- a/cms/djangoapps/contentstore/features/checklists.py +++ b/cms/djangoapps/contentstore/features/checklists.py @@ -10,9 +10,7 @@ from selenium.common.exceptions import StaleElementReferenceException ############### ACTIONS #################### @step('I select Checklists from the Tools menu$') def i_select_checklists(step): - expand_icon_css = 'li.nav-course-tools i.icon-expand' - if world.browser.is_element_present_by_css(expand_icon_css): - world.css_click(expand_icon_css) + world.click_tools() link_css = 'li.nav-course-tools-checklists a' world.css_click(link_css) diff --git a/cms/djangoapps/contentstore/features/course-settings.py b/cms/djangoapps/contentstore/features/course-settings.py index d69266b7de..bd86fff9b7 100644 --- a/cms/djangoapps/contentstore/features/course-settings.py +++ b/cms/djangoapps/contentstore/features/course-settings.py @@ -25,9 +25,7 @@ DEFAULT_TIME = "00:00" ############### ACTIONS #################### @step('I select Schedule and Details$') def test_i_select_schedule_and_details(step): - expand_icon_css = 'li.nav-course-settings i.icon-expand' - if world.browser.is_element_present_by_css(expand_icon_css): - world.css_click(expand_icon_css) + world.click_course_settings() link_css = 'li.nav-course-settings-schedule a' world.css_click(link_css) diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py index 5da7720945..aa2e9d68f8 100644 --- a/cms/djangoapps/contentstore/features/courses.py +++ b/cms/djangoapps/contentstore/features/courses.py @@ -62,4 +62,4 @@ def i_am_on_tab(step, tab_name): @step('I see a link for adding a new section$') def i_see_new_section_link(step): link_css = 'a.new-courseware-section-button' - assert world.css_has_text(link_css, '+ New Section') + assert world.css_has_text(link_css, 'New Section') diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py index 59c5a37b33..9a896d8ebe 100644 --- a/cms/djangoapps/contentstore/features/section.py +++ b/cms/djangoapps/contentstore/features/section.py @@ -62,7 +62,7 @@ def i_click_to_edit_section_name(step): @step('I see the complete section name with a quote in the editor$') def i_see_complete_section_name_with_quote_in_editor(step): - css = '.edit-section-name' + css = '.section-name-edit input[type=text]' assert world.is_css_present(css) assert_equal(world.browser.find_by_css(css).value, 'Section with "Quote"') diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 7c669c80f6..a36ed76d11 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -34,6 +34,8 @@ from xmodule.course_module import CourseDescriptor from xmodule.seq_module import SequenceDescriptor from xmodule.modulestore.exceptions import ItemNotFoundError +from django_comment_common.utils import are_permissions_roles_seeded + TEST_DATA_MODULESTORE = copy.deepcopy(settings.MODULESTORE) TEST_DATA_MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data') TEST_DATA_MODULESTORE['direct']['OPTIONS']['fs_root'] = path('common/test/data') @@ -45,7 +47,7 @@ class MongoCollectionFindWrapper(object): self.counter = 0 def find(self, query, *args, **kwargs): - self.counter = self.counter+1 + self.counter = self.counter + 1 return self.original(query, *args, **kwargs) @@ -352,7 +354,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): clone_items = module_store.get_items(Location(['i4x', 'MITx', '999', 'vertical', None])) self.assertGreater(len(clone_items), 0) for descriptor in items: - new_loc = descriptor.location._replace(org='MITx', course='999') + new_loc = descriptor.location.replace(org='MITx', course='999') print "Checking {0} should now also be at {1}".format(descriptor.location.url(), new_loc.url()) resp = self.client.get(reverse('edit_unit', kwargs={'location': new_loc.url()})) self.assertEqual(resp.status_code, 200) @@ -375,15 +377,15 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertEqual(len(items), 0) def verify_content_existence(self, modulestore, root_dir, location, dirname, category_name, filename_suffix=''): - fs = OSFS(root_dir / 'test_export') - self.assertTrue(fs.exists(dirname)) + filesystem = OSFS(root_dir / 'test_export') + self.assertTrue(filesystem.exists(dirname)) query_loc = Location('i4x', location.org, location.course, category_name, None) items = modulestore.get_items(query_loc) for item in items: - fs = OSFS(root_dir / ('test_export/' + dirname)) - self.assertTrue(fs.exists(item.location.name + filename_suffix)) + filesystem = OSFS(root_dir / ('test_export/' + dirname)) + self.assertTrue(filesystem.exists(item.location.name + filename_suffix)) def test_export_course(self): module_store = modulestore('direct') @@ -415,7 +417,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # add private to list of children sequential = module_store.get_item(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None])) - private_location_no_draft = private_vertical.location._replace(revision=None) + private_location_no_draft = private_vertical.location.replace(revision=None) module_store.update_children(sequential.location, sequential.children + [private_location_no_draft.url()]) @@ -440,20 +442,20 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.verify_content_existence(module_store, root_dir, location, 'custom_tags', 'custom_tag_template') # check for graiding_policy.json - fs = OSFS(root_dir / 'test_export/policies/6.002_Spring_2012') - self.assertTrue(fs.exists('grading_policy.json')) + filesystem = OSFS(root_dir / 'test_export/policies/6.002_Spring_2012') + self.assertTrue(filesystem.exists('grading_policy.json')) course = module_store.get_item(location) # compare what's on disk compared to what we have in our course - with fs.open('grading_policy.json', 'r') as grading_policy: + with filesystem.open('grading_policy.json', 'r') as grading_policy: on_disk = loads(grading_policy.read()) self.assertEqual(on_disk, course.grading_policy) #check for policy.json - self.assertTrue(fs.exists('policy.json')) + self.assertTrue(filesystem.exists('policy.json')) # compare what's on disk to what we have in the course module - with fs.open('policy.json', 'r') as course_policy: + with filesystem.open('policy.json', 'r') as course_policy: on_disk = loads(course_policy.read()) self.assertIn('course/6.002_Spring_2012', on_disk) self.assertEqual(on_disk['course/6.002_Spring_2012'], own_metadata(course)) @@ -608,6 +610,14 @@ class ContentStoreTest(ModuleStoreTestCase): data = parse_json(resp) self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course') + def test_create_course_check_forum_seeding(self): + """Test new course creation and verify forum seeding """ + resp = self.client.post(reverse('create_new_course'), self.course_data) + self.assertEqual(resp.status_code, 200) + data = parse_json(resp) + self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course') + self.assertTrue(are_permissions_roles_seeded('MITx/999/Robot_Super_Course')) + def test_create_course_duplicate_course(self): """Test new course creation - error path""" resp = self.client.post(reverse('create_new_course'), self.course_data) @@ -801,37 +811,37 @@ class ContentStoreTest(ModuleStoreTestCase): self.assertEqual(200, resp.status_code) # go look at a subsection page - subsection_location = loc._replace(category='sequential', name='test_sequence') + subsection_location = loc.replace(category='sequential', name='test_sequence') resp = self.client.get(reverse('edit_subsection', kwargs={'location': subsection_location.url()})) self.assertEqual(200, resp.status_code) # go look at the Edit page - unit_location = loc._replace(category='vertical', name='test_vertical') + unit_location = loc.replace(category='vertical', name='test_vertical') resp = self.client.get(reverse('edit_unit', kwargs={'location': unit_location.url()})) self.assertEqual(200, resp.status_code) # delete a component - del_loc = loc._replace(category='html', name='test_html') + del_loc = loc.replace(category='html', name='test_html') resp = self.client.post(reverse('delete_item'), json.dumps({'id': del_loc.url()}), "application/json") self.assertEqual(200, resp.status_code) # delete a unit - del_loc = loc._replace(category='vertical', name='test_vertical') + del_loc = loc.replace(category='vertical', name='test_vertical') resp = self.client.post(reverse('delete_item'), json.dumps({'id': del_loc.url()}), "application/json") self.assertEqual(200, resp.status_code) # delete a unit - del_loc = loc._replace(category='sequential', name='test_sequence') + del_loc = loc.replace(category='sequential', name='test_sequence') resp = self.client.post(reverse('delete_item'), json.dumps({'id': del_loc.url()}), "application/json") self.assertEqual(200, resp.status_code) # delete a chapter - del_loc = loc._replace(category='chapter', name='chapter_2') + del_loc = loc.replace(category='chapter', name='chapter_2') resp = self.client.post(reverse('delete_item'), json.dumps({'id': del_loc.url()}), "application/json") self.assertEqual(200, resp.status_code) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index f326764589..07f6b9669c 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -13,17 +13,13 @@ from django.core.urlresolvers import reverse from mitxmako.shortcuts import render_to_response from xmodule.modulestore.django import modulestore -from xmodule.modulestore.exceptions \ - import ItemNotFoundError, InvalidLocationError + +from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError from xmodule.modulestore import Location -from contentstore.course_info_model \ - import get_course_updates, update_course_updates, delete_course_update -from contentstore.utils \ - import get_lms_link_for_item, add_extra_panel_tab, \ - remove_extra_panel_tab -from models.settings.course_details \ - import CourseDetails, CourseSettingsEncoder +from contentstore.course_info_model import get_course_updates, update_course_updates, delete_course_update +from contentstore.utils import get_lms_link_for_item, add_extra_panel_tab, remove_extra_panel_tab +from models.settings.course_details import CourseDetails, CourseSettingsEncoder from models.settings.course_grading import CourseGradingModel from models.settings.course_metadata import CourseMetadata from auth.authz import create_all_course_groups @@ -35,6 +31,10 @@ from .tabs import initialize_course_tabs from .component import OPEN_ENDED_COMPONENT_TYPES, \ NOTE_COMPONENT_TYPES, ADVANCED_COMPONENT_POLICY_KEY +from django_comment_common.utils import seed_permissions_roles + +# TODO: should explicitly enumerate exports with __all__ + __all__ = ['course_index', 'create_new_course', 'course_info', 'course_info_updates', 'get_course_settings', 'course_config_graders_page', @@ -136,6 +136,9 @@ def create_new_course(request): create_all_course_groups(request.user, new_course.location) + # seed the forums + seed_permissions_roles(new_course.location.course_id) + return HttpResponse(json.dumps({'id': new_course.location.url()})) diff --git a/cms/envs/common.py b/cms/envs/common.py index 3c323f5090..0c66717760 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -323,6 +323,9 @@ INSTALLED_APPS = ( 'pipeline', 'staticfiles', 'static_replace', + + # comment common + 'django_comment_common', ) ################# EDX MARKETING SITE ################################## diff --git a/cms/envs/dev.py b/cms/envs/dev.py index 203e4bd909..9acbf84a95 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -127,8 +127,7 @@ CELERY_ALWAYS_EAGER = True ################################ DEBUG TOOLBAR ################################# INSTALLED_APPS += ('debug_toolbar', 'debug_toolbar_mongo') -MIDDLEWARE_CLASSES += ('django_comment_client.utils.QueryCountDebugMiddleware', - 'debug_toolbar.middleware.DebugToolbarMiddleware',) +MIDDLEWARE_CLASSES += ('debug_toolbar.middleware.DebugToolbarMiddleware',) INTERNAL_IPS = ('127.0.0.1',) DEBUG_TOOLBAR_PANELS = ( diff --git a/cms/envs/jasmine.py b/cms/envs/jasmine.py index f3a982aa43..a4b8292d71 100644 --- a/cms/envs/jasmine.py +++ b/cms/envs/jasmine.py @@ -36,8 +36,13 @@ PIPELINE_JS['spec'] = { } JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee' +JASMINE_REPORT_DIR = os.environ.get('JASMINE_REPORT_DIR', 'reports/cms/jasmine') + +TEMPLATE_CONTEXT_PROCESSORS += ('settings_context_processor.context_processors.settings',) +TEMPLATE_VISIBLE_SETTINGS = ('JASMINE_REPORT_DIR', ) STATICFILES_DIRS.append(REPO_ROOT/'node_modules/phantom-jasmine/lib') +STATICFILES_DIRS.append(REPO_ROOT/'node_modules/jasmine-reporters/src') # Remove the localization middleware class because it requires the test database # to be sync'd and migrated in order to run the jasmine tests interactively @@ -45,4 +50,4 @@ STATICFILES_DIRS.append(REPO_ROOT/'node_modules/phantom-jasmine/lib') MIDDLEWARE_CLASSES = tuple(e for e in MIDDLEWARE_CLASSES \ if e != 'django.middleware.locale.LocaleMiddleware') -INSTALLED_APPS += ('django_jasmine', ) +INSTALLED_APPS += ('django_jasmine', 'settings_context_processor') diff --git a/cms/templates/base.html b/cms/templates/base.html index d5bd8d6813..34653f64bb 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -64,10 +64,6 @@ - diff --git a/cms/templates/checklists.html b/cms/templates/checklists.html index e5227c71fd..6f78e952c0 100644 --- a/cms/templates/checklists.html +++ b/cms/templates/checklists.html @@ -9,7 +9,6 @@ - - diff --git a/cms/templates/settings.html b/cms/templates/settings.html index 5baf460e25..2adc0cd980 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -15,7 +15,6 @@ from contentstore import utils - diff --git a/cms/templates/settings_advanced.html b/cms/templates/settings_advanced.html index 0400d95694..242148418e 100644 --- a/cms/templates/settings_advanced.html +++ b/cms/templates/settings_advanced.html @@ -11,7 +11,6 @@ from contentstore import utils <%block name="jsextra"> - diff --git a/cms/templates/settings_discussions_faculty.html b/cms/templates/settings_discussions_faculty.html index fc30b6eebb..5dfb4b76ae 100644 --- a/cms/templates/settings_discussions_faculty.html +++ b/cms/templates/settings_discussions_faculty.html @@ -6,27 +6,26 @@ <%namespace name='static' file='static_content.html'/> <%! -from contentstore import utils +from contentstore import utils %> <%block name="jsextra"> - - + <%block name="content">
-
+

Settings

@@ -74,7 +73,7 @@ from contentstore import utils
A brief description of your education, experience, and expertise -
+
Delete Faculty Member @@ -102,7 +101,7 @@ from contentstore import utils Upload Faculty Photo - Max size: 30KB + Max size: 30KB
@@ -114,7 +113,7 @@ from contentstore import utils A brief description of your education, experience, and expertise - + @@ -143,7 +142,7 @@ from contentstore import utils
- +
randomize all problems @@ -217,7 +216,7 @@ from contentstore import utils
- +
randomize all problems @@ -283,7 +282,7 @@ from contentstore import utils

Discussions

- +

General Settings

@@ -296,7 +295,7 @@ from contentstore import utils
- +
Students and faculty will be able to post anonymously @@ -320,7 +319,7 @@ from contentstore import utils
- +
Students and faculty will be able to post anonymously @@ -329,7 +328,7 @@ from contentstore import utils
- +
This option is disabled since there are previous discussions that are anonymous. @@ -351,7 +350,7 @@ from contentstore import utils - +
  • diff --git a/cms/templates/settings_graders.html b/cms/templates/settings_graders.html index ceab8cd862..2c6846bece 100644 --- a/cms/templates/settings_graders.html +++ b/cms/templates/settings_graders.html @@ -12,7 +12,6 @@ from contentstore import utils - diff --git a/common/djangoapps/django_comment_common/__init__.py b/common/djangoapps/django_comment_common/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/django_comment_common/migrations/0001_initial.py b/common/djangoapps/django_comment_common/migrations/0001_initial.py new file mode 100644 index 0000000000..f2c3ca3aee --- /dev/null +++ b/common/djangoapps/django_comment_common/migrations/0001_initial.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +from south.v2 import SchemaMigration + + +class Migration(SchemaMigration): +# +# cdodge: This is basically an empty migration since everything has - up to now - managed in the django_comment_client app +# But going forward we should be using this migration +# + def forwards(self, orm): + pass + + def backwards(self, orm): + pass + + 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'}, + 'about': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'avatar_type': ('django.db.models.fields.CharField', [], {'default': "'n'", 'max_length': '1'}), + 'bronze': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'consecutive_days_visit_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'blank': 'True'}), + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'date_of_birth': ('django.db.models.fields.DateField', [], {'null': 'True', 'blank': 'True'}), + 'display_tag_filter_strategy': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'email_isvalid': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'email_key': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True'}), + 'email_tag_filter_strategy': ('django.db.models.fields.SmallIntegerField', [], {'default': '1'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'gold': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'gravatar': ('django.db.models.fields.CharField', [], {'max_length': '32'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'ignored_tags': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'interesting_tags': ('django.db.models.fields.TextField', [], {'blank': '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'}), + 'last_seen': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'location': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}), + 'new_response_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'questions_per_page': ('django.db.models.fields.SmallIntegerField', [], {'default': '10'}), + 'real_name': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}), + 'reputation': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1'}), + 'seen_response_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'show_country': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'silver': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'w'", 'max_length': '2'}), + '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'}), + 'website': ('django.db.models.fields.URLField', [], {'max_length': '200', 'blank': 'True'}) + }, + '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'}) + }, + 'django_comment_common.permission': { + 'Meta': {'object_name': 'Permission'}, + 'name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'primary_key': 'True'}), + 'roles': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'permissions'", 'symmetrical': 'False', 'to': "orm['django_comment_common.Role']"}) + }, + 'django_comment_common.role': { + 'Meta': {'object_name': 'Role'}, + 'course_id': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '30'}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'roles'", 'symmetrical': 'False', 'to': "orm['auth.User']"}) + } + } + + complete_apps = ['django_comment_common'] diff --git a/common/djangoapps/django_comment_common/migrations/__init__.py b/common/djangoapps/django_comment_common/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/django_comment_common/models.py b/common/djangoapps/django_comment_common/models.py new file mode 100644 index 0000000000..ec722b718a --- /dev/null +++ b/common/djangoapps/django_comment_common/models.py @@ -0,0 +1,74 @@ +import logging + +from django.db import models +from django.contrib.auth.models import User + +from django.dispatch import receiver +from django.db.models.signals import post_save + +from student.models import CourseEnrollment + +from xmodule.modulestore.django import modulestore +from xmodule.course_module import CourseDescriptor + +FORUM_ROLE_ADMINISTRATOR = 'Administrator' +FORUM_ROLE_MODERATOR = 'Moderator' +FORUM_ROLE_COMMUNITY_TA = 'Community TA' +FORUM_ROLE_STUDENT = 'Student' + + +@receiver(post_save, sender=CourseEnrollment) +def assign_default_role(sender, instance, **kwargs): + if instance.user.is_staff: + role = Role.objects.get_or_create(course_id=instance.course_id, name="Moderator")[0] + else: + role = Role.objects.get_or_create(course_id=instance.course_id, name="Student")[0] + + logging.info("assign_default_role: adding %s as %s" % (instance.user, role)) + instance.user.roles.add(role) + + +class Role(models.Model): + name = models.CharField(max_length=30, null=False, blank=False) + users = models.ManyToManyField(User, related_name="roles") + course_id = models.CharField(max_length=255, blank=True, db_index=True) + + class Meta: + # use existing table that was originally created from django_comment_client app + db_table = 'django_comment_client_role' + + def __unicode__(self): + return self.name + " for " + (self.course_id if self.course_id else "all courses") + + def inherit_permissions(self, role): # TODO the name of this method is a little bit confusing, + # since it's one-off and doesn't handle inheritance later + if role.course_id and role.course_id != self.course_id: + logging.warning("%s cannot inherit permissions from %s due to course_id inconsistency", \ + self, role) + for per in role.permissions.all(): + self.add_permission(per) + + def add_permission(self, permission): + self.permissions.add(Permission.objects.get_or_create(name=permission)[0]) + + def has_permission(self, permission): + course_loc = CourseDescriptor.id_to_location(self.course_id) + course = modulestore().get_instance(self.course_id, course_loc) + if self.name == FORUM_ROLE_STUDENT and \ + (permission.startswith('edit') or permission.startswith('update') or permission.startswith('create')) and \ + (not course.forum_posts_allowed): + return False + + return self.permissions.filter(name=permission).exists() + + +class Permission(models.Model): + name = models.CharField(max_length=30, null=False, blank=False, primary_key=True) + roles = models.ManyToManyField(Role, related_name="permissions") + + class Meta: + # use existing table that was originally created from django_comment_client app + db_table = 'django_comment_client_permission' + + def __unicode__(self): + return self.name diff --git a/common/djangoapps/django_comment_common/utils.py b/common/djangoapps/django_comment_common/utils.py new file mode 100644 index 0000000000..f74116d59f --- /dev/null +++ b/common/djangoapps/django_comment_common/utils.py @@ -0,0 +1,56 @@ +from django_comment_common.models import Role + +_STUDENT_ROLE_PERMISSIONS = ["vote", "update_thread", "follow_thread", "unfollow_thread", + "update_comment", "create_sub_comment", "unvote", "create_thread", + "follow_commentable", "unfollow_commentable", "create_comment", ] + +_MODERATOR_ROLE_PERMISSIONS = ["edit_content", "delete_thread", "openclose_thread", + "endorse_comment", "delete_comment", "see_all_cohorts"] + +_ADMINISTRATOR_ROLE_PERMISSIONS = ["manage_moderator"] + +def seed_permissions_roles(course_id): + administrator_role = Role.objects.get_or_create(name="Administrator", course_id=course_id)[0] + moderator_role = Role.objects.get_or_create(name="Moderator", course_id=course_id)[0] + community_ta_role = Role.objects.get_or_create(name="Community TA", course_id=course_id)[0] + student_role = Role.objects.get_or_create(name="Student", course_id=course_id)[0] + + for per in _STUDENT_ROLE_PERMISSIONS: + student_role.add_permission(per) + + for per in _MODERATOR_ROLE_PERMISSIONS: + moderator_role.add_permission(per) + + for per in _ADMINISTRATOR_ROLE_PERMISSIONS: + administrator_role.add_permission(per) + + moderator_role.inherit_permissions(student_role) + + # For now, Community TA == Moderator, except for the styling. + community_ta_role.inherit_permissions(moderator_role) + + administrator_role.inherit_permissions(moderator_role) + + +def are_permissions_roles_seeded(course_id): + + try: + administrator_role = Role.objects.get(name="Administrator", course_id=course_id) + moderator_role = Role.objects.get(name="Moderator", course_id=course_id) + student_role = Role.objects.get(name="Student", course_id=course_id) + except: + return False + + for per in _STUDENT_ROLE_PERMISSIONS: + if not student_role.has_permission(per): + return False + + for per in _MODERATOR_ROLE_PERMISSIONS + _STUDENT_ROLE_PERMISSIONS: + if not moderator_role.has_permission(per): + return False + + for per in _ADMINISTRATOR_ROLE_PERMISSIONS + _MODERATOR_ROLE_PERMISSIONS + _STUDENT_ROLE_PERMISSIONS: + if not administrator_role.has_permission(per): + return False + + return True diff --git a/common/djangoapps/student/tests/factories.py b/common/djangoapps/student/tests/factories.py index 9560025441..d73bb6f01d 100644 --- a/common/djangoapps/student/tests/factories.py +++ b/common/djangoapps/student/tests/factories.py @@ -1,43 +1,47 @@ from student.models import (User, UserProfile, Registration, - CourseEnrollmentAllowed, CourseEnrollment) + CourseEnrollmentAllowed, CourseEnrollment, + PendingEmailChange) from django.contrib.auth.models import Group from datetime import datetime -from factory import DjangoModelFactory, Factory, SubFactory, PostGenerationMethodCall, post_generation +from factory import DjangoModelFactory, SubFactory, PostGenerationMethodCall, post_generation, Sequence from uuid import uuid4 +# Factories don't have __init__ methods, and are self documenting +# pylint: disable=W0232 + class GroupFactory(DjangoModelFactory): FACTORY_FOR = Group - name = 'staff_MITx/999/Robot_Super_Course' + name = u'staff_MITx/999/Robot_Super_Course' class UserProfileFactory(DjangoModelFactory): FACTORY_FOR = UserProfile user = None - name = 'Robot Test' + name = u'Robot Test' level_of_education = None - gender = 'm' + gender = u'm' mailing_address = None - goals = 'World domination' + goals = u'World domination' class RegistrationFactory(DjangoModelFactory): FACTORY_FOR = Registration user = None - activation_key = uuid4().hex + activation_key = uuid4().hex.decode('ascii') class UserFactory(DjangoModelFactory): FACTORY_FOR = User - username = 'robot' - email = 'robot+test@edx.org' + username = Sequence(u'robot{0}'.format) + email = Sequence(u'robot+test+{0}@edx.org'.format) password = PostGenerationMethodCall('set_password', 'test') - first_name = 'Robot' + first_name = Sequence(u'Robot{0}'.format) last_name = 'Test' is_staff = False is_active = True @@ -64,7 +68,7 @@ class CourseEnrollmentFactory(DjangoModelFactory): FACTORY_FOR = CourseEnrollment user = SubFactory(UserFactory) - course_id = 'edX/toy/2012_Fall' + course_id = u'edX/toy/2012_Fall' class CourseEnrollmentAllowedFactory(DjangoModelFactory): @@ -72,3 +76,17 @@ class CourseEnrollmentAllowedFactory(DjangoModelFactory): email = 'test@edx.org' course_id = 'edX/test/2012_Fall' + + +class PendingEmailChangeFactory(DjangoModelFactory): + """Factory for PendingEmailChange objects + + user: generated by UserFactory + new_email: sequence of new+email+{}@edx.org + activation_key: sequence of integers, padded to 30 characters + """ + FACTORY_FOR = PendingEmailChange + + user = SubFactory(UserFactory) + new_email = Sequence(u'new+email+{0}@edx.org'.format) + activation_key = Sequence(u'{:0<30d}'.format) diff --git a/common/djangoapps/student/tests/test_email.py b/common/djangoapps/student/tests/test_email.py new file mode 100644 index 0000000000..3b31bb5c28 --- /dev/null +++ b/common/djangoapps/student/tests/test_email.py @@ -0,0 +1,261 @@ +import json +import django.db + +from student.tests.factories import UserFactory, RegistrationFactory, PendingEmailChangeFactory +from student.views import reactivation_email_for_user, change_email_request, confirm_email_change +from student.models import UserProfile, PendingEmailChange +from django.contrib.auth.models import User +from django.test import TestCase, TransactionTestCase +from django.test.client import RequestFactory +from mock import Mock, patch +from django.http import Http404, HttpResponse +from django.conf import settings +from nose.plugins.skip import SkipTest + + +class TestException(Exception): + """Exception used for testing that nothing will catch explicitly""" + pass + + +def mock_render_to_string(template_name, context): + """Return a string that encodes template_name and context""" + return str((template_name, sorted(context.iteritems()))) + + +def mock_render_to_response(template_name, context): + """Return an HttpResponse with content that encodes template_name and context""" + return HttpResponse(mock_render_to_string(template_name, context)) + + +class EmailTestMixin(object): + """Adds useful assertions for testing `email_user`""" + + def assertEmailUser(self, email_user, subject_template, subject_context, body_template, body_context): + """Assert that `email_user` was used to send and email with the supplied subject and body + + `email_user`: The mock `django.contrib.auth.models.User.email_user` function + to verify + `subject_template`: The template to have been used for the subject + `subject_context`: The context to have been used for the subject + `body_template`: The template to have been used for the body + `body_context`: The context to have been used for the body + """ + email_user.assert_called_with( + mock_render_to_string(subject_template, subject_context), + mock_render_to_string(body_template, body_context), + settings.DEFAULT_FROM_EMAIL + ) + + +@patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) +@patch('django.contrib.auth.models.User.email_user') +class ReactivationEmailTests(EmailTestMixin, TestCase): + """Test sending a reactivation email to a user""" + + def setUp(self): + self.user = UserFactory.create() + self.registration = RegistrationFactory.create(user=self.user) + + def reactivation_email(self): + """Send the reactivation email, and return the response as json data""" + return json.loads(reactivation_email_for_user(self.user).content) + + def assertReactivateEmailSent(self, email_user): + """Assert that the correct reactivation email has been sent""" + context = { + 'name': self.user.profile.name, + 'key': self.registration.activation_key + } + + self.assertEmailUser( + email_user, + 'emails/activation_email_subject.txt', + context, + 'emails/activation_email.txt', + context + ) + + def test_reactivation_email_failure(self, email_user): + self.user.email_user.side_effect = Exception + response_data = self.reactivation_email() + + self.assertReactivateEmailSent(email_user) + self.assertFalse(response_data['success']) + + def test_reactivation_email_success(self, email_user): + response_data = self.reactivation_email() + + self.assertReactivateEmailSent(email_user) + self.assertTrue(response_data['success']) + + +class EmailChangeRequestTests(TestCase): + """Test changing a user's email address""" + + def setUp(self): + self.user = UserFactory.create() + self.new_email = 'new.email@edx.org' + self.req_factory = RequestFactory() + self.request = self.req_factory.post('unused_url', data={ + 'password': 'test', + 'new_email': self.new_email + }) + self.request.user = self.user + self.user.email_user = Mock() + + def run_request(self, request=None): + """Execute request and return result parsed as json + + If request isn't passed in, use self.request instead + """ + if request is None: + request = self.request + + response = change_email_request(self.request) + return json.loads(response.content) + + def assertFailedRequest(self, response_data, expected_error): + """Assert that `response_data` indicates a failed request that returns `expected_error`""" + self.assertFalse(response_data['success']) + self.assertEquals(expected_error, response_data['error']) + self.assertFalse(self.user.email_user.called) + + def test_unauthenticated(self): + self.user.is_authenticated = False + with self.assertRaises(Http404): + change_email_request(self.request) + self.assertFalse(self.user.email_user.called) + + def test_invalid_password(self): + self.request.POST['password'] = 'wrong' + self.assertFailedRequest(self.run_request(), 'Invalid password') + + def test_invalid_emails(self): + for email in ('bad_email', 'bad_email@', '@bad_email'): + self.request.POST['new_email'] = email + self.assertFailedRequest(self.run_request(), 'Valid e-mail address required.') + + def check_duplicate_email(self, email): + """Test that a request to change a users email to `email` fails""" + request = self.req_factory.post('unused_url', data={ + 'new_email': email, + 'password': 'test', + }) + request.user = self.user + self.assertFailedRequest(self.run_request(request), 'An account with this e-mail already exists.') + + def test_duplicate_email(self): + UserFactory.create(email=self.new_email) + self.check_duplicate_email(self.new_email) + + def test_capitalized_duplicate_email(self): + raise SkipTest("We currently don't check for emails in a case insensitive way, but we should") + UserFactory.create(email=self.new_email) + self.check_duplicate_email(self.new_email.capitalize()) + + # TODO: Finish testing the rest of change_email_request + + +@patch('django.contrib.auth.models.User.email_user') +@patch('student.views.render_to_response', Mock(side_effect=mock_render_to_response, autospec=True)) +@patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) +class EmailChangeConfirmationTests(EmailTestMixin, TransactionTestCase): + """Test that confirmation of email change requests function even in the face of exceptions thrown while sending email""" + def setUp(self): + self.user = UserFactory.create() + self.profile = UserProfile.objects.get(user=self.user) + self.req_factory = RequestFactory() + self.request = self.req_factory.get('unused_url') + self.request.user = self.user + self.user.email_user = Mock() + self.pending_change_request = PendingEmailChangeFactory.create(user=self.user) + self.key = self.pending_change_request.activation_key + + def assertRolledBack(self): + """Assert that no changes to user, profile, or pending email have been made to the db""" + self.assertEquals(self.user.email, User.objects.get(username=self.user.username).email) + self.assertEquals(self.profile.meta, UserProfile.objects.get(user=self.user).meta) + self.assertEquals(1, PendingEmailChange.objects.count()) + + def assertFailedBeforeEmailing(self, email_user): + """Assert that the function failed before emailing a user""" + self.assertRolledBack() + self.assertFalse(email_user.called) + + def check_confirm_email_change(self, expected_template, expected_context): + """Call `confirm_email_change` and assert that the content was generated as expected + + `expected_template`: The name of the template that should have been used + to generate the content + `expected_context`: The context dictionary that should have been used to + generate the content + """ + response = confirm_email_change(self.request, self.key) + self.assertEquals( + mock_render_to_response(expected_template, expected_context).content, + response.content + ) + + def assertChangeEmailSent(self, email_user): + """Assert that the correct email was sent to confirm an email change""" + context = { + 'old_email': self.user.email, + 'new_email': self.pending_change_request.new_email, + } + self.assertEmailUser( + email_user, + 'emails/email_change_subject.txt', + context, + 'emails/confirm_email_change.txt', + context + ) + + def test_not_pending(self, email_user): + self.key = 'not_a_key' + self.check_confirm_email_change('invalid_email_key.html', {}) + self.assertFailedBeforeEmailing(email_user) + + def test_duplicate_email(self, email_user): + UserFactory.create(email=self.pending_change_request.new_email) + self.check_confirm_email_change('email_exists.html', {}) + self.assertFailedBeforeEmailing(email_user) + + def test_old_email_fails(self, email_user): + email_user.side_effect = [Exception, None] + self.check_confirm_email_change('email_change_failed.html', { + 'email': self.user.email, + }) + self.assertRolledBack() + self.assertChangeEmailSent(email_user) + + def test_new_email_fails(self, email_user): + email_user.side_effect = [None, Exception] + self.check_confirm_email_change('email_change_failed.html', { + 'email': self.pending_change_request.new_email + }) + self.assertRolledBack() + self.assertChangeEmailSent(email_user) + + def test_successful_email_change(self, email_user): + self.check_confirm_email_change('email_change_successful.html', { + 'old_email': self.user.email, + 'new_email': self.pending_change_request.new_email + }) + self.assertChangeEmailSent(email_user) + meta = json.loads(UserProfile.objects.get(user=self.user).meta) + self.assertIn('old_emails', meta) + self.assertEquals(self.user.email, meta['old_emails'][0][0]) + self.assertEquals( + self.pending_change_request.new_email, + User.objects.get(username=self.user.username).email + ) + self.assertEquals(0, PendingEmailChange.objects.count()) + + @patch('student.views.PendingEmailChange.objects.get', Mock(side_effect=TestException)) + @patch('student.views.transaction.rollback', wraps=django.db.transaction.rollback) + def test_always_rollback(self, rollback, _email_user): + with self.assertRaises(TestException): + confirm_email_change(self.request, self.key) + + rollback.assert_called_with() diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index e8a70d6089..8059026e12 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -19,7 +19,7 @@ from django.core.context_processors import csrf from django.core.mail import send_mail from django.core.urlresolvers import reverse from django.core.validators import validate_email, validate_slug, ValidationError -from django.db import IntegrityError +from django.db import IntegrityError, transaction from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotAllowed, HttpResponseRedirect, Http404 from django.shortcuts import redirect from django_future.csrf import ensure_csrf_cookie, csrf_exempt @@ -655,7 +655,7 @@ def create_account(request, post_override=None): elif not settings.GENERATE_RANDOM_USER_CREDENTIALS: res = user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL) except: - log.exception(sys.exc_info()) + log.warning('Unable to send activation email to user', exc_info=True) js['value'] = 'Could not send activation e-mail.' return HttpResponse(json.dumps(js)) @@ -975,7 +975,11 @@ def reactivation_email_for_user(user): subject = ''.join(subject.splitlines()) message = render_to_string('emails/activation_email.txt', d) - res = user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL) + try: + res = user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL) + except: + log.warning('Unable to send reactivation email', exc_info=True) + return HttpResponse(json.dumps({'success': False, 'error': 'Unable to send reactivation email'})) return HttpResponse(json.dumps({'success': True})) @@ -1001,7 +1005,7 @@ def change_email_request(request): return HttpResponse(json.dumps({'success': False, 'error': 'Valid e-mail address required.'})) - if len(User.objects.filter(email=new_email)) != 0: + if User.objects.filter(email=new_email).count() != 0: ## CRITICAL TODO: Handle case sensitivity for e-mails return HttpResponse(json.dumps({'success': False, 'error': 'An account with this e-mail already exists.'})) @@ -1036,41 +1040,63 @@ def change_email_request(request): @ensure_csrf_cookie +@transaction.commit_manually def confirm_email_change(request, key): ''' User requested a new e-mail. This is called when the activation link is clicked. We confirm with the old e-mail, and update ''' try: - pec = PendingEmailChange.objects.get(activation_key=key) - except PendingEmailChange.DoesNotExist: - return render_to_response("invalid_email_key.html", {}) + try: + pec = PendingEmailChange.objects.get(activation_key=key) + except PendingEmailChange.DoesNotExist: + transaction.rollback() + return render_to_response("invalid_email_key.html", {}) - user = pec.user - d = {'old_email': user.email, - 'new_email': pec.new_email} + user = pec.user + address_context = { + 'old_email': user.email, + 'new_email': pec.new_email + } - if len(User.objects.filter(email=pec.new_email)) != 0: - return render_to_response("email_exists.html", d) + if len(User.objects.filter(email=pec.new_email)) != 0: + transaction.rollback() + return render_to_response("email_exists.html", {}) - subject = render_to_string('emails/email_change_subject.txt', d) - subject = ''.join(subject.splitlines()) - message = render_to_string('emails/confirm_email_change.txt', d) - up = UserProfile.objects.get(user=user) - meta = up.get_meta() - if 'old_emails' not in meta: - meta['old_emails'] = [] - meta['old_emails'].append([user.email, datetime.datetime.now().isoformat()]) - up.set_meta(meta) - up.save() - # Send it to the old email... - user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL) - user.email = pec.new_email - user.save() - pec.delete() - # And send it to the new email... - user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL) + subject = render_to_string('emails/email_change_subject.txt', address_context) + subject = ''.join(subject.splitlines()) + message = render_to_string('emails/confirm_email_change.txt', address_context) + up = UserProfile.objects.get(user=user) + meta = up.get_meta() + if 'old_emails' not in meta: + meta['old_emails'] = [] + meta['old_emails'].append([user.email, datetime.datetime.now().isoformat()]) + up.set_meta(meta) + up.save() + # Send it to the old email... + try: + user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL) + except Exception: + transaction.rollback() + log.warning('Unable to send confirmation email to old address', exc_info=True) + return render_to_response("email_change_failed.html", {'email': user.email}) - return render_to_response("email_change_successful.html", d) + user.email = pec.new_email + user.save() + pec.delete() + # And send it to the new email... + try: + user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL) + except Exception: + transaction.rollback() + log.warning('Unable to send confirmation email to new address', exc_info=True) + return render_to_response("email_change_failed.html", {'email': pec.new_email}) + + transaction.commit() + return render_to_response("email_change_successful.html", address_context) + except Exception: + # If we get an unexpected exception, be sure to rollback the transaction + transaction.rollback() + raise @ensure_csrf_cookie diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py index 40b839ae24..79e9b0afdb 100644 --- a/common/djangoapps/terrain/ui_helpers.py +++ b/common/djangoapps/terrain/ui_helpers.py @@ -123,3 +123,17 @@ def save_the_html(path='/tmp'): f = open('%s/%s' % (path, filename), 'w') f.write(html) f.close() + + +@world.absorb +def click_course_settings(): + course_settings_css = 'li.nav-course-settings' + if world.browser.is_element_present_by_css(course_settings_css): + world.css_click(course_settings_css) + + +@world.absorb +def click_tools(): + tools_css = 'li.nav-course-tools' + if world.browser.is_element_present_by_css(tools_css): + world.css_click(tools_css) diff --git a/common/lib/capa/jasmine_test_runner.html.erb b/common/lib/capa/jasmine_test_runner.html.erb deleted file mode 100644 index 7b078daedd..0000000000 --- a/common/lib/capa/jasmine_test_runner.html.erb +++ /dev/null @@ -1,48 +0,0 @@ - - - - Jasmine Test Runner - - - - - - - - - - - - - - - - - - - <% for src in js_source %> - - <% end %> - - - <% for src in js_specs %> - - <% end %> - - - - - - - - diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py index ae04e3aac4..33c7b61251 100644 --- a/common/lib/xmodule/xmodule/modulestore/__init__.py +++ b/common/lib/xmodule/xmodule/modulestore/__init__.py @@ -9,7 +9,7 @@ import re from collections import namedtuple from .exceptions import InvalidLocationError, InsufficientSpecificationError -from xmodule.errortracker import ErrorLog, make_error_tracker +from xmodule.errortracker import make_error_tracker from bson.son import SON log = logging.getLogger('mitx.' + 'modulestore') @@ -64,7 +64,6 @@ class Location(_LocationBase): """ return re.sub('_+', '_', invalid.sub('_', value)) - @staticmethod def clean(value): """ @@ -72,7 +71,6 @@ class Location(_LocationBase): """ return Location._clean(value, INVALID_CHARS) - @staticmethod def clean_keeping_underscores(value): """ @@ -82,7 +80,6 @@ class Location(_LocationBase): """ return INVALID_CHARS.sub('_', value) - @staticmethod def clean_for_url_name(value): """ @@ -154,9 +151,7 @@ class Location(_LocationBase): to mean wildcard selection. """ - - if (org is None and course is None and category is None and - name is None and revision is None): + if (org is None and course is None and category is None and name is None and revision is None): location = loc_or_tag else: location = (loc_or_tag, org, course, category, name, revision) @@ -191,7 +186,7 @@ class Location(_LocationBase): match = MISSING_SLASH_URL_RE.match(location) if match is None: log.debug('location is instance of %s but no URL match' % basestring) - raise InvalidLocationError(location) + raise InvalidLocationError(location) groups = match.groupdict() check_dict(groups) return _LocationBase.__new__(_cls, **groups) @@ -233,7 +228,7 @@ class Location(_LocationBase): html id attributes """ s = "-".join(str(v) for v in self.list() - if v is not None) + if v is not None) return Location.clean_for_html(s) def dict(self): @@ -258,6 +253,12 @@ class Location(_LocationBase): at the location URL hierachy""" return "/".join([self.org, self.course, self.name]) + def replace(self, **kwargs): + ''' + Expose a public method for replacing location elements + ''' + return self._replace(**kwargs) + class ModuleStore(object): """ @@ -382,12 +383,6 @@ class ModuleStore(object): ''' raise NotImplementedError - def get_course(self, course_id): - ''' - Look for a specific course id. Returns the course descriptor, or None if not found. - ''' - raise NotImplementedError - def get_parent_locations(self, location, course_id): '''Find all locations that are the parents of this location in this course. Needed for path_to_location(). @@ -406,8 +401,7 @@ class ModuleStore(object): courses = [ course for course in self.get_courses() - if course.location.org == location.org - and course.location.course == location.course + if course.location.org == location.org and course.location.course == location.course ] return courses diff --git a/common/lib/xmodule/xmodule/modulestore/draft.py b/common/lib/xmodule/xmodule/modulestore/draft.py index c3f1b23688..9262c5e9d6 100644 --- a/common/lib/xmodule/xmodule/modulestore/draft.py +++ b/common/lib/xmodule/xmodule/modulestore/draft.py @@ -13,11 +13,12 @@ def as_draft(location): """ return Location(location)._replace(revision=DRAFT) + def as_published(location): """ Returns the Location that is the published version for `location` """ - return Location(location)._replace(revision=None) + return Location(location)._replace(revision=None) def wrap_draft(item): diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py index 31237af7b9..8cf148f742 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py @@ -3,7 +3,6 @@ from time import gmtime from uuid import uuid4 from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore -from xmodule.timeparse import stringify_time from xmodule.modulestore.inheritance import own_metadata diff --git a/common/lib/xmodule/xmodule/randomize_module.py b/common/lib/xmodule/xmodule/randomize_module.py index 240f33e33e..434706530b 100644 --- a/common/lib/xmodule/xmodule/randomize_module.py +++ b/common/lib/xmodule/xmodule/randomize_module.py @@ -4,6 +4,8 @@ import random from xmodule.x_module import XModule from xmodule.seq_module import SequenceDescriptor +from lxml import etree + from xblock.core import Scope, Integer log = logging.getLogger('mitx.' + __name__) diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py index 7480cda0c5..2f54bbf405 100644 --- a/common/lib/xmodule/xmodule/xml_module.py +++ b/common/lib/xmodule/xmodule/xml_module.py @@ -136,6 +136,7 @@ class XmlDescriptor(XModuleDescriptor): 'hide_progress_tab': bool_map, 'allow_anonymous': bool_map, 'allow_anonymous_to_peers': bool_map, + 'show_timezone': bool_map, } diff --git a/common/templates/jasmine/base.html b/common/templates/jasmine/base.html index 9a1b3bed92..0133edadfa 100644 --- a/common/templates/jasmine/base.html +++ b/common/templates/jasmine/base.html @@ -12,6 +12,7 @@ + {% load compressed %} {# static files #} @@ -37,15 +38,14 @@ + @@ -44,30 +45,10 @@ diff --git a/common/test/data/full/course.xml b/common/test/data/full/course.xml index 7a05db42f2..b2f9097020 100644 --- a/common/test/data/full/course.xml +++ b/common/test/data/full/course.xml @@ -1 +1 @@ - + diff --git a/common/test/data/full/sequential/Administrivia_and_Circuit_Elements.xml b/common/test/data/full/sequential/Administrivia_and_Circuit_Elements.xml index 26f8f5a08d..47b19f75ed 100644 --- a/common/test/data/full/sequential/Administrivia_and_Circuit_Elements.xml +++ b/common/test/data/full/sequential/Administrivia_and_Circuit_Elements.xml @@ -12,4 +12,13 @@ Minor correction: Six elements (five resistors)… + + + + + +

    Inline content…

    + +
    +
    diff --git a/common/test/phantom-jasmine b/common/test/phantom-jasmine deleted file mode 160000 index a54d435b55..0000000000 --- a/common/test/phantom-jasmine +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a54d435b5556650efbcdb0490e6c7928ac75238a diff --git a/doc/testing.md b/doc/testing.md index d6c7b7ee86..e5d035d90e 100644 --- a/doc/testing.md +++ b/doc/testing.md @@ -8,7 +8,7 @@ and acceptance tests. ### Unit Tests * Each test case should be concise: setup, execute, check, and teardown. -If you find yourself writing tests with many steps, consider refactoring +If you find yourself writing tests with many steps, consider refactoring the unit under tests into smaller units, and then testing those individually. * As a rule of thumb, your unit tests should cover every code branch. @@ -16,19 +16,19 @@ the unit under tests into smaller units, and then testing those individually. * Mock or patch external dependencies. We use [voidspace mock](http://www.voidspace.org.uk/python/mock/). -* We unit test Python code (using [unittest](http://docs.python.org/2/library/unittest.html)) and +* We unit test Python code (using [unittest](http://docs.python.org/2/library/unittest.html)) and Javascript (using [Jasmine](http://pivotal.github.io/jasmine/)) ### Integration Tests * Test several units at the same time. Note that you can still mock or patch dependencies -that are not under test! For example, you might test that -`LoncapaProblem`, `NumericalResponse`, and `CorrectMap` in the +that are not under test! For example, you might test that +`LoncapaProblem`, `NumericalResponse`, and `CorrectMap` in the `capa` package work together, while still mocking out template rendering. * Use integration tests to ensure that units are hooked up correctly. -You do not need to test every possible input--that's what unit -tests are for. Instead, focus on testing the "happy path" +You do not need to test every possible input--that's what unit +tests are for. Instead, focus on testing the "happy path" to verify that the components work together correctly. * Many of our tests use the [Django test client](https://docs.djangoproject.com/en/dev/topics/testing/overview/) to simulate @@ -43,8 +43,8 @@ these tests simulate user interactions through the browser using Overall, you want to write the tests that **maximize coverage** while **minimizing maintenance**. -In practice, this usually means investing heavily -in unit tests, which tend to be the most robust to changes in the code base. +In practice, this usually means investing heavily +in unit tests, which tend to be the most robust to changes in the code base. ![Test Pyramid](test_pyramid.png) @@ -53,13 +53,13 @@ and acceptance tests. Most of our tests are unit tests or integration tests. ## Test Locations -* Python unit and integration tests: Located in +* Python unit and integration tests: Located in subpackages called `tests`. -For example, the tests for the `capa` package are located in +For example, the tests for the `capa` package are located in `common/lib/capa/capa/tests`. * Javascript unit tests: Located in `spec` folders. For example, -`common/lib/xmodule/xmodule/js/spec` and `{cms,lms}/static/coffee/spec` +`common/lib/xmodule/xmodule/js/spec` and `{cms,lms}/static/coffee/spec` For consistency, you should use the same directory structure for implementation and test. For example, the test for `src/views/module.coffee` should be written in `spec/views/module_spec.coffee`. @@ -88,7 +88,7 @@ because the `capa` package handles problem XML. Before running tests, ensure that you have all the dependencies. You can install dependencies using: - pip install -r requirements.txt + rake install_prereqs ## Running Python Unit tests @@ -101,7 +101,7 @@ You can run tests using `rake` commands. For example, rake test -runs all the tests. It also runs `collectstatic`, which prepares the static files used by the site (for example, compiling Coffeescript to Javascript). +runs all the tests. It also runs `collectstatic`, which prepares the static files used by the site (for example, compiling Coffeescript to Javascript). You can also run the tests without `collectstatic`, which tends to be faster: @@ -117,12 +117,11 @@ xmodule can be tested independently, with this: To run a single django test class: - django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/courseware/tests/tests.py:TestViewAuth + rake test_lms[courseware.tests.tests:testViewAuth] To run a single django test: - django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/courseware/tests/tests.py:TestViewAuth.test_dark_launch - + rake test_lms[courseware.tests.tests:TestViewAuth.test_dark_launch] To run a single nose test file: @@ -150,7 +149,7 @@ If the `phantomjs` binary is not on the path, set the `PHANTOMJS_PATH` environme PHANTOMJS_PATH=/path/to/phantomjs rake phantomjs_jasmine_{lms,cms} -Once you have run the `rake` command, your browser should open to +Once you have run the `rake` command, your browser should open to to `http://localhost/_jasmine/`, which displays the test results. **Troubleshooting**: If you get an error message while running the `rake` task, @@ -163,7 +162,7 @@ Most of our tests use [Splinter](http://splinter.cobrateam.info/) to simulate UI browser interactions. Splinter, in turn, uses [Selenium](http://docs.seleniumhq.org/) to control the Chrome browser. -**Prerequisite**: You must have [ChromeDriver](https://code.google.com/p/selenium/wiki/ChromeDriver) +**Prerequisite**: You must have [ChromeDriver](https://code.google.com/p/selenium/wiki/ChromeDriver) installed to run the tests in Chrome. The tests are confirmed to run with Chrome (not Chromium) version 26.0.0.1410.63 with ChromeDriver version r195636. @@ -184,13 +183,7 @@ To start the debugger on failure, add the `--pdb` option: To run tests faster by not collecting static files, you can use `rake fasttest_acceptance_lms` and `rake fasttest_acceptance_cms`. - -**Troubleshooting**: If you get an error message that says something about harvest not being a command, you probably are missing a requirement. -Try running: - - pip install -r requirements.txt - -**Note**: The acceptance tests can *not* currently run in parallel. +**Note**: The acceptance tests can *not* currently run in parallel. ## Viewing Test Coverage diff --git a/jenkins/test.sh b/jenkins/test.sh index d8cd2c1843..35be3a0121 100755 --- a/jenkins/test.sh +++ b/jenkins/test.sh @@ -73,8 +73,8 @@ rake pylint > pylint.log || cat pylint.log TESTS_FAILED=0 # Run the python unit tests -rake test_cms[false] || TESTS_FAILED=1 -rake test_lms[false] || TESTS_FAILED=1 +rake test_cms || TESTS_FAILED=1 +rake test_lms || TESTS_FAILED=1 rake test_common/lib/capa || TESTS_FAILED=1 rake test_common/lib/xmodule || TESTS_FAILED=1 @@ -82,7 +82,7 @@ rake test_common/lib/xmodule || TESTS_FAILED=1 rake phantomjs_jasmine_lms || TESTS_FAILED=1 rake phantomjs_jasmine_cms || TESTS_FAILED=1 rake phantomjs_jasmine_common/lib/xmodule || TESTS_FAILED=1 -rake phantomjs_jasmine_discussion || TESTS_FAILED=1 +rake phantomjs_jasmine_common/static/coffee || TESTS_FAILED=1 rake coverage:xml coverage:html diff --git a/lms/djangoapps/django_comment_client/base/views.py b/lms/djangoapps/django_comment_client/base/views.py index e906fb5f7e..34e369c1ef 100644 --- a/lms/djangoapps/django_comment_client/base/views.py +++ b/lms/djangoapps/django_comment_client/base/views.py @@ -26,7 +26,7 @@ from course_groups.cohorts import get_cohort_id, is_commentable_cohorted from django_comment_client.utils import JsonResponse, JsonError, extract, get_courseware_context from django_comment_client.permissions import check_permissions_by_view, cached_has_permission -from django_comment_client.models import Role +from django_comment_common.models import Role from courseware.access import has_access log = logging.getLogger(__name__) diff --git a/lms/djangoapps/django_comment_client/management/commands/assign_role.py b/lms/djangoapps/django_comment_client/management/commands/assign_role.py index 1be3bff719..4e9321410c 100644 --- a/lms/djangoapps/django_comment_client/management/commands/assign_role.py +++ b/lms/djangoapps/django_comment_client/management/commands/assign_role.py @@ -1,7 +1,7 @@ from optparse import make_option from django.core.management.base import BaseCommand, CommandError -from django_comment_client.models import Role +from django_comment_common.models import Role from django.contrib.auth.models import User diff --git a/lms/djangoapps/django_comment_client/management/commands/assign_roles_for_course.py b/lms/djangoapps/django_comment_client/management/commands/assign_roles_for_course.py index 72100738d9..9ef4f3d0b1 100644 --- a/lms/djangoapps/django_comment_client/management/commands/assign_roles_for_course.py +++ b/lms/djangoapps/django_comment_client/management/commands/assign_roles_for_course.py @@ -7,7 +7,7 @@ Enrollments. from django.core.management.base import BaseCommand, CommandError from student.models import CourseEnrollment -from django_comment_client.models import assign_default_role +from django_comment_common.models import assign_default_role class Command(BaseCommand): diff --git a/lms/djangoapps/django_comment_client/management/commands/create_roles_for_existing.py b/lms/djangoapps/django_comment_client/management/commands/create_roles_for_existing.py index d5ba0042fc..037bb292ec 100644 --- a/lms/djangoapps/django_comment_client/management/commands/create_roles_for_existing.py +++ b/lms/djangoapps/django_comment_client/management/commands/create_roles_for_existing.py @@ -7,7 +7,7 @@ Enrollments. from django.core.management.base import BaseCommand, CommandError from student.models import CourseEnrollment -from django_comment_client.models import assign_default_role +from django_comment_common.models import assign_default_role class Command(BaseCommand): diff --git a/lms/djangoapps/django_comment_client/management/commands/seed_permissions_roles.py b/lms/djangoapps/django_comment_client/management/commands/seed_permissions_roles.py index 9d6eefd11d..1073d7dbcf 100644 --- a/lms/djangoapps/django_comment_client/management/commands/seed_permissions_roles.py +++ b/lms/djangoapps/django_comment_client/management/commands/seed_permissions_roles.py @@ -1,5 +1,5 @@ from django.core.management.base import BaseCommand, CommandError -from django_comment_client.models import Role +from django_comment_common.utils import seed_permissions_roles class Command(BaseCommand): @@ -13,26 +13,4 @@ class Command(BaseCommand): raise CommandError("Too many arguments") course_id = args[0] - administrator_role = Role.objects.get_or_create(name="Administrator", course_id=course_id)[0] - moderator_role = Role.objects.get_or_create(name="Moderator", course_id=course_id)[0] - community_ta_role = Role.objects.get_or_create(name="Community TA", course_id=course_id)[0] - student_role = Role.objects.get_or_create(name="Student", course_id=course_id)[0] - - for per in ["vote", "update_thread", "follow_thread", "unfollow_thread", - "update_comment", "create_sub_comment", "unvote", "create_thread", - "follow_commentable", "unfollow_commentable", "create_comment", ]: - student_role.add_permission(per) - - for per in ["edit_content", "delete_thread", "openclose_thread", - "endorse_comment", "delete_comment", "see_all_cohorts"]: - moderator_role.add_permission(per) - - for per in ["manage_moderator"]: - administrator_role.add_permission(per) - - moderator_role.inherit_permissions(student_role) - - # For now, Community TA == Moderator, except for the styling. - community_ta_role.inherit_permissions(moderator_role) - - administrator_role.inherit_permissions(moderator_role) + seed_permissions_roles(course_id) diff --git a/lms/djangoapps/django_comment_client/management/commands/show_permissions.py b/lms/djangoapps/django_comment_client/management/commands/show_permissions.py index f24f183193..67fc29ea97 100644 --- a/lms/djangoapps/django_comment_client/management/commands/show_permissions.py +++ b/lms/djangoapps/django_comment_client/management/commands/show_permissions.py @@ -1,4 +1,5 @@ from django.core.management.base import BaseCommand, CommandError +from django_comment_common.models import Permission, Role from django.contrib.auth.models import User diff --git a/lms/djangoapps/django_comment_client/models.py b/lms/djangoapps/django_comment_client/models.py index 71e7a81f68..76d27be3bf 100644 --- a/lms/djangoapps/django_comment_client/models.py +++ b/lms/djangoapps/django_comment_client/models.py @@ -1,64 +1 @@ -import logging - -from django.db import models -from django.contrib.auth.models import User - -from django.dispatch import receiver -from django.db.models.signals import post_save - -from student.models import CourseEnrollment - -from courseware.courses import get_course_by_id - -FORUM_ROLE_ADMINISTRATOR = 'Administrator' -FORUM_ROLE_MODERATOR = 'Moderator' -FORUM_ROLE_COMMUNITY_TA = 'Community TA' -FORUM_ROLE_STUDENT = 'Student' - - -@receiver(post_save, sender=CourseEnrollment) -def assign_default_role(sender, instance, **kwargs): - if instance.user.is_staff: - role = Role.objects.get_or_create(course_id=instance.course_id, name="Moderator")[0] - else: - role = Role.objects.get_or_create(course_id=instance.course_id, name="Student")[0] - - logging.info("assign_default_role: adding %s as %s" % (instance.user, role)) - instance.user.roles.add(role) - - -class Role(models.Model): - name = models.CharField(max_length=30, null=False, blank=False) - users = models.ManyToManyField(User, related_name="roles") - course_id = models.CharField(max_length=255, blank=True, db_index=True) - - def __unicode__(self): - return self.name + " for " + (self.course_id if self.course_id else "all courses") - - def inherit_permissions(self, role): # TODO the name of this method is a little bit confusing, - # since it's one-off and doesn't handle inheritance later - if role.course_id and role.course_id != self.course_id: - logging.warning("%s cannot inherit permissions from %s due to course_id inconsistency", - self, role) - for per in role.permissions.all(): - self.add_permission(per) - - def add_permission(self, permission): - self.permissions.add(Permission.objects.get_or_create(name=permission)[0]) - - def has_permission(self, permission): - course = get_course_by_id(self.course_id) - if self.name == FORUM_ROLE_STUDENT and \ - (permission.startswith('edit') or permission.startswith('update') or permission.startswith('create')) and \ - (not course.forum_posts_allowed): - return False - - return self.permissions.filter(name=permission).exists() - - -class Permission(models.Model): - name = models.CharField(max_length=30, null=False, blank=False, primary_key=True) - roles = models.ManyToManyField(Role, related_name="permissions") - - def __unicode__(self): - return self.name +# This file is intentionally blank. It has been moved to common/djangoapps/django_comment_common diff --git a/lms/djangoapps/django_comment_client/permissions.py b/lms/djangoapps/django_comment_client/permissions.py index cc3ead53e7..1a523a170a 100644 --- a/lms/djangoapps/django_comment_client/permissions.py +++ b/lms/djangoapps/django_comment_client/permissions.py @@ -1,4 +1,4 @@ -from .models import Role, Permission +from django_comment_common.models import Role, Permission from django.db.models.signals import post_save from django.dispatch import receiver from student.models import CourseEnrollment diff --git a/lms/djangoapps/django_comment_client/tests.py b/lms/djangoapps/django_comment_client/tests.py index a5cfce4dc7..8fd8ed7e2b 100644 --- a/lms/djangoapps/django_comment_client/tests.py +++ b/lms/djangoapps/django_comment_client/tests.py @@ -6,7 +6,7 @@ from django.test import TestCase from student.models import CourseEnrollment from django_comment_client.permissions import has_permission -from django_comment_client.models import Role +from django_comment_common.models import Role class PermissionsTestCase(TestCase): diff --git a/lms/djangoapps/django_comment_client/tests/factories.py b/lms/djangoapps/django_comment_client/tests/factories.py index eb1d9477c3..4a82c8f1bb 100644 --- a/lms/djangoapps/django_comment_client/tests/factories.py +++ b/lms/djangoapps/django_comment_client/tests/factories.py @@ -1,5 +1,5 @@ from factory import DjangoModelFactory -from django_comment_client.models import Role, Permission +from django_comment_common.models import Role, Permission class RoleFactory(DjangoModelFactory): diff --git a/lms/djangoapps/django_comment_client/tests/test_models.py b/lms/djangoapps/django_comment_client/tests/test_models.py index 0835c841e2..e45c883931 100644 --- a/lms/djangoapps/django_comment_client/tests/test_models.py +++ b/lms/djangoapps/django_comment_client/tests/test_models.py @@ -1,4 +1,4 @@ -import django_comment_client.models as models +import django_comment_common.models as models import django_comment_client.permissions as permissions from django.test import TestCase diff --git a/lms/djangoapps/django_comment_client/tests/test_utils.py b/lms/djangoapps/django_comment_client/tests/test_utils.py index a7c0ce0a39..555264cb5f 100644 --- a/lms/djangoapps/django_comment_client/tests/test_utils.py +++ b/lms/djangoapps/django_comment_client/tests/test_utils.py @@ -1,6 +1,6 @@ from django.test import TestCase from student.tests.factories import UserFactory, CourseEnrollmentFactory - +from django_comment_common.models import Role, Permission from factories import RoleFactory import django_comment_client.utils as utils diff --git a/lms/djangoapps/django_comment_client/utils.py b/lms/djangoapps/django_comment_client/utils.py index 0363607cfe..276956f0e9 100644 --- a/lms/djangoapps/django_comment_client/utils.py +++ b/lms/djangoapps/django_comment_client/utils.py @@ -14,7 +14,7 @@ from django.core.urlresolvers import reverse from django.db import connection from django.http import HttpResponse from django.utils import simplejson -from django_comment_client.models import Role +from django_comment_common.models import Role from django_comment_client.permissions import check_permissions_by_view from xmodule.modulestore.exceptions import NoPathToItem diff --git a/lms/djangoapps/instructor/tests/test_forum_admin.py b/lms/djangoapps/instructor/tests/test_forum_admin.py index d2d58fb61c..7b4e729867 100644 --- a/lms/djangoapps/instructor/tests/test_forum_admin.py +++ b/lms/djangoapps/instructor/tests/test_forum_admin.py @@ -9,7 +9,7 @@ from django.test.utils import override_settings from django.contrib.auth.models import Group from django.core.urlresolvers import reverse -from django_comment_client.models import Role, FORUM_ROLE_ADMINISTRATOR, \ +from django_comment_common.models import Role, FORUM_ROLE_ADMINISTRATOR, \ FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_STUDENT from django_comment_client.utils import has_forum_access diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views.py index dd6748e691..00b1b918b3 100644 --- a/lms/djangoapps/instructor/views.py +++ b/lms/djangoapps/instructor/views.py @@ -27,7 +27,7 @@ from courseware.access import (has_access, get_access_group_name, course_beta_test_group_name) from courseware.courses import get_course_with_access from courseware.models import StudentModule -from django_comment_client.models import (Role, +from django_comment_common.models import (Role, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA) diff --git a/lms/envs/common.py b/lms/envs/common.py index 741d624ed7..e7bc9519d9 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -700,8 +700,7 @@ INSTALLED_APPS = ( # Discussion forums 'django_comment_client', - - # Student notes + 'django_comment_common', 'notes', ) diff --git a/lms/envs/jasmine.py b/lms/envs/jasmine.py index 2c30bc7de7..4a78ed8075 100644 --- a/lms/envs/jasmine.py +++ b/lms/envs/jasmine.py @@ -36,7 +36,12 @@ PIPELINE_JS['spec'] = { } JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee' +JASMINE_REPORT_DIR = os.environ.get('JASMINE_REPORT_DIR', 'reports/lms/jasmine') + +TEMPLATE_CONTEXT_PROCESSORS += ('settings_context_processor.context_processors.settings',) +TEMPLATE_VISIBLE_SETTINGS = ('JASMINE_REPORT_DIR', ) STATICFILES_DIRS.append(REPO_ROOT/'node_modules/phantom-jasmine/lib') +STATICFILES_DIRS.append(REPO_ROOT/'node_modules/jasmine-reporters/src') -INSTALLED_APPS += ('django_jasmine', ) +INSTALLED_APPS += ('django_jasmine', 'settings_context_processor') diff --git a/lms/static/sass/base/_base.scss b/lms/static/sass/base/_base.scss index e62dd12541..6f43a02df7 100644 --- a/lms/static/sass/base/_base.scss +++ b/lms/static/sass/base/_base.scss @@ -2,8 +2,8 @@ // overflow-y: scroll; // } -body { - background: rgb(250,250,250); +html, body { + background: $body-bg; font-family: $sans-serif; font-size: 1em; font-style: normal; @@ -61,20 +61,20 @@ p + p, ul + p, ol + p { p { a:link, a:visited { - color: $blue; + color: $link-color; font: normal 1em/1em $serif; text-decoration: none; @include transition(all, 0.1s, linear); &:hover { - color: $blue; + color: $link-color; text-decoration: underline; } } } a:link, a:visited { - color: $blue; + color: $link-color; font: normal 1em/1em $sans-serif; text-decoration: none; @include transition(all, 0.1s, linear); @@ -87,8 +87,8 @@ a:link, a:visited { .content-wrapper { width: flex-grid(12); margin: 0 auto; + background: $content-wrapper-bg; padding-bottom: ($baseline*2); - background: rgb(255,255,255); } .container { @@ -164,7 +164,7 @@ mark { display: none; padding: 10px; @include linear-gradient(top, rgba(0, 0, 0, .1), rgba(0, 0, 0, .0)); - background-color: $pink; + background-color: $site-status-color; box-shadow: 0 -1px 0 rgba(0, 0, 0, .3) inset; font-size: 14px; diff --git a/lms/static/sass/base/_extends.scss b/lms/static/sass/base/_extends.scss index 2998e25dca..d244eff55f 100644 --- a/lms/static/sass/base/_extends.scss +++ b/lms/static/sass/base/_extends.scss @@ -1,39 +1,30 @@ .faded-hr-divider { - @include background-image(linear-gradient(180deg, rgba(200,200,200, 0) 0%, - rgba(200,200,200, 1) 50%, - rgba(200,200,200, 0))); + @include background-image($faded-hr-image-1); height: 1px; width: 100%; } .faded-hr-divider-medium { - @include background-image(linear-gradient(180deg, rgba(240,240,240, 0) 0%, - rgba(240,240,240, 1) 50%, - rgba(240,240,240, 0))); + @include background-image($faded-hr-image-4); height: 1px; width: 100%; } .faded-hr-divider-light { - @include background-image(linear-gradient(180deg, rgba(255,255,255, 0) 0%, - rgba(255,255,255, 0.8) 50%, - rgba(255,255,255, 0))); + @include background-image($faded-hr-image-5); height: 1px; width: 100%; } .faded-vertical-divider { - @include background-image(linear-gradient(90deg, rgba(200,200,200, 0) 0%, - rgba(200,200,200, 1) 50%, - rgba(200,200,200, 0))); + @include background-image($faded-hr-image-1); height: 100%; width: 1px; } .faded-vertical-divider-light { - @include background-image(linear-gradient(90deg, rgba(255,255,255, 0) 0%, - rgba(255,255,255, 0.6) 50%, - rgba(255,255,255, 0))); + @include background-image($faded-hr-image-6); + background: transparent; height: 100%; width: 1px; } @@ -66,14 +57,12 @@ } .fade-right-hr-divider { - @include background-image(linear-gradient(180deg, rgba(200,200,200, 0) 0%, - rgba(200,200,200, 1))); + @include background-image($faded-hr-image-2); border: none; } .fade-left-hr-divider { - @include background-image(linear-gradient(180deg, rgba(200,200,200, 1) 0%, - rgba(200,200,200, 0))); + @include background-image($faded-hr-image-3); border: none; } diff --git a/lms/static/sass/base/_variables.scss b/lms/static/sass/base/_variables.scss index ddbd930323..6bd593c28c 100644 --- a/lms/static/sass/base/_variables.scss +++ b/lms/static/sass/base/_variables.scss @@ -14,6 +14,14 @@ $monospace: Monaco, 'Bitstream Vera Sans Mono', 'Lucida Console', monospace; $body-font-family: $sans-serif; $serif: $georgia; +$body-font-size: em(14); +$body-line-height: golden-ratio(.875em, 1); +$base-font-color: rgb(60,60,60); +$baseFontColor: rgb(60,60,60); +$base-font-color: rgb(60,60,60); +$lighter-base-font-color: rgb(100,100,100); +$very-light-text: #fff; + $white: rgb(255,255,255); $black: rgb(0,0,0); $blue: rgb(29,157,217); @@ -52,6 +60,66 @@ $baseFontColor: rgb(60,60,60); $lighter-base-font-color: rgb(100,100,100); $text-color: $dark-gray; -$body-font-family: $sans-serif; -$body-font-size: em(14); -$body-line-height: golden-ratio(.875em, 1); +$body-bg: rgb(250,250,250); +$header-image: linear-gradient(-90deg, rgba(255,255,255, 1), rgba(230,230,230, 0.9)); +$header-bg: transparent; +$courseware-header-image: linear-gradient(top, #fff, #eee); +$courseware-header-bg: transparent; +$footer-bg: transparent; +$courseware-footer-border: none; +$courseware-footer-shadow: none; +$courseware-footer-margin: 0px; + +$button-bg-image: linear-gradient(#fff 0%, rgb(250,250,250) 50%, rgb(237,237,237) 50%, rgb(220,220,220) 100%); +$button-bg-color: transparent; +$button-bg-hover-color: #fff; + +$faded-hr-image-1: linear-gradient(180deg, rgba(200,200,200, 0) 0%, rgba(200,200,200, 1) 50%, rgba(200,200,200, 0)); +$faded-hr-image-2: linear-gradient(180deg, rgba(200,200,200, 0) 0%, rgba(200,200,200, 1)); +$faded-hr-image-3: linear-gradient(180deg, rgba(200,200,200, 1) 0%, rgba(200,200,200, 0)); +$faded-hr-image-4: linear-gradient(180deg, rgba(240,240,240, 0) 0%, rgba(240,240,240, 1) 50%, rgba(240,240,240, 0)); +$faded-hr-image-5: linear-gradient(180deg, rgba(255,255,255, 0) 0%, rgba(255,255,255, 0.8) 50%, rgba(255,255,255, 0)); +$faded-hr-image-6: linear-gradient(90deg, rgba(255,255,255, 0) 0%, rgba(255,255,255, 0.6) 50%, rgba(255,255,255, 0)); + +$dashboard-profile-header-image: linear-gradient(-90deg, rgb(255,255,255), rgb(245,245,245)); +$dashboard-profile-header-color: transparent; +$dashboard-profile-color: rgb(252,252,252); +$dot-color: $light-gray; + +$content-wrapper-bg: rgb(255,255,255); +$course-bg-color: #d6d6d6; +$course-bg-image: url(../images/bg-texture.png); + +$course-profile-bg: rgb(245,245,245); +$course-header-bg: rgba(255,255,255, 0.93); + +$border-color-1: rgb(190,190,190); +$border-color-2: rgb(200,200,200); +$border-color-3: rgb(100,100,100); +$border-color-4: rgb(252,252,252); + +$link-color: $blue; +$link-hover: $pink; +$selection-color-1: $pink; +$selection-color-2: #444; +$site-status-color: $pink; + +$button-color: $blue; +$button-archive-color: #eee; + +$shadow-color: $blue; + +$sidebar-chapter-bg-top: rgba(255, 255, 255, .6); +$sidebar-chapter-bg-bottom: rgba(255, 255, 255, 0); +$sidebar-chapter-bg: #eee; +$sidebar-active-image: linear-gradient(top, #e6e6e6, #d6d6d6); + +$form-bg-color: #fff; +$modal-bg-color: rgb(245,245,245); + +//----------------- +// CSS BG Images +//----------------- +$homepage-bg-image: '../images/homepage-bg.jpg'; + +$video-thumb-url: '../images/courses/video-thumb.jpg'; \ No newline at end of file diff --git a/lms/static/sass/course/_info.scss b/lms/static/sass/course/_info.scss index bfd90505cf..741a7f9a22 100644 --- a/lms/static/sass/course/_info.scss +++ b/lms/static/sass/course/_info.scss @@ -117,7 +117,7 @@ div.info-wrapper { @include transition(all .2s); h4 { - color: $blue; + color: $link-color; font-size: 1em; font-weight: normal; padding-left: 30px; diff --git a/lms/static/sass/course/base/_base.scss b/lms/static/sass/course/base/_base.scss index 6183c8a675..584412ca22 100644 --- a/lms/static/sass/course/base/_base.scss +++ b/lms/static/sass/course/base/_base.scss @@ -1,7 +1,8 @@ body { min-width: 980px; min-height: 100%; - background: url(../images/bg-texture.png) #d6d6d6; + background-image: $course-bg-image; + background-color: $course-bg-color; } body, h1, h2, h3, h4, h5, h6, p, p a:link, p a:visited, a, label { @@ -34,7 +35,7 @@ a { width: 100%; border-radius: 3px; border: 1px solid $outer-border-color; - background: #fff; + background: $body-bg; @include box-shadow(0 1px 2px rgba(0, 0, 0, 0.05)); } } @@ -49,8 +50,8 @@ textarea, input[type="text"], input[type="email"], input[type="password"] { - background: rgb(250,250,250); - border: 1px solid rgb(200,200,200); + background: $body-bg; + border: 1px solid $border-color-2; @include border-radius(0); @include box-shadow(0 1px 0 0 rgba(255,255,255, 0.6), inset 0 0 3px 0 rgba(0,0,0, 0.1)); @include box-sizing(border-box); @@ -65,7 +66,7 @@ input[type="password"] { } &:focus { - border-color: lighten($blue, 20%); + border-color: lighten($link-color, 20%); @include box-shadow(0 0 6px 0 rgba($blue, 0.4), inset 0 0 4px 0 rgba(0,0,0, 0.15)); outline: none; } @@ -94,7 +95,7 @@ img { } ::selection, ::-moz-selection, ::-webkit-selection { - background: #444; + background: $selection-color-2; color: #fff; } @@ -143,7 +144,7 @@ img { max-width: 350px; padding: 15px 20px 17px; border-radius: 3px; - border: 1px solid #333; + border: 1px solid $border-color-3; background: -webkit-linear-gradient(top, rgba(255, 255, 255, .1), rgba(255, 255, 255, 0)) rgba(30, 30, 30, .92); box-shadow: 0 1px 3px rgba(0, 0, 0, .3), 0 1px 0 rgba(255, 255, 255, .1) inset; font-size: 13px; diff --git a/lms/static/sass/course/base/_extends.scss b/lms/static/sass/course/base/_extends.scss index bcb93a3645..a94a9511fe 100644 --- a/lms/static/sass/course/base/_extends.scss +++ b/lms/static/sass/course/base/_extends.scss @@ -1,5 +1,5 @@ h1.top-header { - border-bottom: 1px solid #e3e3e3; + border-bottom: 1px solid $border-color-2; text-align: left; font-size: em(24); font-weight: 100; diff --git a/lms/static/sass/course/courseware/_sidebar.scss b/lms/static/sass/course/courseware/_sidebar.scss index 81b497d4f9..6cf6f6a602 100644 --- a/lms/static/sass/course/courseware/_sidebar.scss +++ b/lms/static/sass/course/courseware/_sidebar.scss @@ -2,7 +2,7 @@ section.course-index { @extend .sidebar; @extend .tran; @include border-radius(3px 0 0 3px); - border-right: 1px solid #ddd; + border-right: 1px solid $border-color-2; #open_close_accordion { display: none; @@ -70,8 +70,8 @@ section.course-index { width: 100% !important; @include box-sizing(border-box); padding: 11px 14px; - @include linear-gradient(top, rgba(255, 255, 255, .6), rgba(255, 255, 255, 0)); - background-color: #eee; + @include linear-gradient(top, $sidebar-chapter-bg-top, $sidebar-chapter-bg-bottom); + background-color: $sidebar-chapter-bg; @include box-shadow(0 1px 0 #fff inset, 0 -1px 0 rgba(0, 0, 0, .1) inset); @include transition(background-color .1s); @@ -169,9 +169,9 @@ section.course-index { } > a { - border: 1px solid #bbb; + border: 1px solid $border-color-1; @include box-shadow(0 1px 0 rgba(255, 255, 255, .35) inset); - @include linear-gradient(top, #e6e6e6, #d6d6d6); + background: $sidebar-active-image; &:after { opacity: 1; diff --git a/lms/static/sass/course/layout/_courseware_header.scss b/lms/static/sass/course/layout/_courseware_header.scss index e27a6e99d8..4d8f000668 100644 --- a/lms/static/sass/course/layout/_courseware_header.scss +++ b/lms/static/sass/course/layout/_courseware_header.scss @@ -75,9 +75,9 @@ header.global.slim { &#login { display: block; - @include background-image(linear-gradient(-90deg, lighten($blue, 8%), lighten($blue, 5%) 50%, $blue 50%, darken($blue, 10%) 100%)); + @include background-image(linear-gradient(-90deg, lighten($link-color, 8%), lighten($link-color, 5%) 50%, $link-color 50%, darken($link-color, 10%) 100%)); border: 1px solid transparent; - border-color: darken($blue, 10%); + border-color: darken($link-color, 10%); @include border-radius(3px); @include box-sizing(border-box); @include box-shadow(0 1px 0 0 rgba(255,255,255, 0.6)); @@ -97,7 +97,7 @@ header.global.slim { vertical-align: middle; &:hover, &.active { - @include background-image(linear-gradient(-90deg, $blue, $blue 50%, $blue 50%, $blue 100%)); + @include background-image(linear-gradient(-90deg, $link-color, $link-color 50%, $link-color 50%, $link-color 100%)); } } } diff --git a/lms/static/sass/course/layout/_footer.scss b/lms/static/sass/course/layout/_footer.scss index 7abf35a819..699846e781 100644 --- a/lms/static/sass/course/layout/_footer.scss +++ b/lms/static/sass/course/layout/_footer.scss @@ -1,4 +1,5 @@ footer { - border: none; - box-shadow: none; + border: $courseware-footer-border; + box-shadow: $courseware-footer-shadow; + margin-top: $courseware-footer-margin; } \ No newline at end of file diff --git a/lms/static/sass/course/wiki/_wiki.scss b/lms/static/sass/course/wiki/_wiki.scss index 1bc38abd9a..d064b6d345 100644 --- a/lms/static/sass/course/wiki/_wiki.scss +++ b/lms/static/sass/course/wiki/_wiki.scss @@ -113,7 +113,7 @@ section.wiki { } &:focus { - border-color: $blue; + border-color: $link-color; } } } @@ -276,7 +276,7 @@ section.wiki { li { &.active { a { - color: $blue; + color: $link-color; .icon-view, .icon-home { diff --git a/lms/static/sass/multicourse/_course_about.scss b/lms/static/sass/multicourse/_course_about.scss index 195760721e..9eab7c0a4f 100644 --- a/lms/static/sass/multicourse/_course_about.scss +++ b/lms/static/sass/multicourse/_course_about.scss @@ -4,11 +4,11 @@ } header.course-profile { - background: rgb(245,245,245); - @include background-image(url('/static/images/homepage-bg.jpg')); + background: $course-profile-bg; + @include background-image(url($homepage-bg-image)); background-size: cover; @include box-shadow(0 1px 80px 0 rgba(0,0,0, 0.5)); - border-bottom: 1px solid rgb(100,100,100); + border-bottom: 1px solid $border-color-3; @include box-shadow(inset 0 1px 5px 0 rgba(0,0,0, 0.1)); height: 280px; margin-top: -69px; @@ -18,8 +18,8 @@ width: 100%; .intro-inner-wrapper { - background: rgba(255,255,255, 0.93); - border: 1px solid rgb(100,100,100); + background: $course-header-bg; + border: 1px solid $border-color-3; @include box-shadow(0 4px 25px 0 rgba(0,0,0, 0.5)); @include box-sizing(border-box); @include clearfix; @@ -44,7 +44,7 @@ z-index: 2; > hgroup { - border-bottom: 1px solid rgb(210,210,210); + border-bottom: 1px solid $border-color-2; @include box-shadow(0 1px 0 0 rgba(255,255,255, 0.6)); margin-bottom: 20px; padding-bottom: 20px; @@ -68,7 +68,7 @@ text-transform: none; &:hover { - color: $blue; + color: $link-color; } } } @@ -85,7 +85,7 @@ text-transform: none; &:hover { - color: $blue; + color: $link-color; } } } @@ -99,7 +99,7 @@ width: flex-grid(12); > a.find-courses, a.register { - @include button(shiny, $blue); + @include button(shiny, $button-color); @include box-sizing(border-box); @include border-radius(3px); display: block; @@ -122,7 +122,7 @@ } strong { - @include button(shiny, $blue); + @include button(shiny, $button-color); @include box-sizing(border-box); @include border-radius(3px); display: block; @@ -140,10 +140,10 @@ } span.register { - background: lighten($blue, 20%); - border: 1px solid $blue; + background: $button-archive-color; + border: 1px solid darken($button-archive-color, 50%); @include box-sizing(border-box); - color: darken($blue, 20%); + color: darken($button-archive-color, 50%); display: block; letter-spacing: 1px; padding: 10px 0px 8px; @@ -176,7 +176,7 @@ z-index: 2; .hero { - border: 1px solid rgb(100,100,100); + border: 1px solid $border-color-3; height: 100%; overflow: hidden; position: relative; @@ -235,7 +235,7 @@ @include clearfix; nav { - border-bottom: 1px solid rgb(220,220,220); + border-bottom: 1px solid $border-color-2; @include box-sizing(border-box); @include clearfix; margin: 40px 0; @@ -262,7 +262,7 @@ } &:hover, &.active { - border-color: rgb(200,200,200); + border-color: $border-color-2; color: $base-font-color; text-decoration: none; } @@ -296,7 +296,7 @@ .teacher-image { background: rgb(255,255,255); - border: 1px solid rgb(200,200,200); + border: 1px solid $border-color-2; height: 115px; float: left; margin: 0 15px 0px 0; @@ -351,7 +351,7 @@ > section { @include box-shadow(inset 0 0 3px 0 rgba(0,0,0, 0.15)); - border: 1px solid rgb(200,200,200); + border: 1px solid $border-color-2; &.course-summary { padding: 16px 20px 30px; @@ -401,7 +401,7 @@ } a.university-name { - border-right: 1px solid rgb(200,200,200); + border-right: 1px solid $border-color-2; color: $base-font-color; font-family: $sans-serif; font-style: italic; @@ -498,12 +498,12 @@ li { @include clearfix; - border-bottom: 1px dotted rgb(220,220,220); + border-bottom: 1px dotted $border-color-2; margin-bottom: 20px; padding-bottom: 10px; &.prerequisites { - border: 1px solid rgb(220,220,220); + border: 1px solid $border-color-2; margin: 0 -10px 0; padding: 10px; diff --git a/lms/static/sass/multicourse/_courses.scss b/lms/static/sass/multicourse/_courses.scss index 45ecfcd23f..ac31da4d2a 100644 --- a/lms/static/sass/multicourse/_courses.scss +++ b/lms/static/sass/multicourse/_courses.scss @@ -1,12 +1,13 @@ .find-courses, .university-profile { - background: rgb(252,252,252); + background: $course-profile-bg; padding-bottom: 60px; header.search { - background: rgb(240,240,240); + background: $course-profile-bg; background-size: cover; + @include background-image(url($homepage-bg-image)); background-position: center top !important; - border-bottom: 1px solid rgb(100,100,100); + border-bottom: 1px solid $border-color-3; @include box-shadow(inset 0 -1px 8px 0 rgba(0,0,0, 0.2), inset 0 1px 12px 0 rgba(0,0,0, 0.3)); height: 430px; margin-top: -69px; @@ -24,8 +25,8 @@ > hgroup { background: #FFF; - background: rgba(255,255,255, 0.93); - border: 1px solid rgb(100,100,100); + background: $course-header-bg; + border: 1px solid $border-color-3; @include box-shadow(0 4px 25px 0 rgba(0,0,0, 0.5)); padding: 20px 30px; position: relative; @@ -83,7 +84,7 @@ } section.message { - border-top: 1px solid rgb(220,220,220); + border-top: 1px solid $border-color-2; @include clearfix; margin-top: 20px; padding-top: 60px; diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index cc54b9b242..b173647550 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -30,8 +30,9 @@ width: flex-grid(3); header.profile { - @include background-image(linear-gradient(-90deg, rgb(255,255,255), rgb(245,245,245))); - border: 1px solid rgb(200,200,200); + @include background-image($dashboard-profile-header-image); + background-color: $dashboard-profile-header-color; + border: 1px solid $border-color-2; @include border-radius(4px); @include box-sizing(border-box); width: flex-grid(12); @@ -53,8 +54,8 @@ padding: 0px 10px; > ul { - background: rgb(252,252,252); - border: 1px solid rgb(200,200,200); + background: $dashboard-profile-color; + border: 1px solid $border-color-2; border-top: none; //@include border-bottom-radius(4px); @include box-sizing(border-box); @@ -66,7 +67,7 @@ li { @include clearfix; - border-bottom: 1px dotted rgb(220,220,220); + border-bottom: 1px dotted $border-color-2; list-style: none; margin-bottom: 15px; padding-bottom: 17px; @@ -128,8 +129,8 @@ .news-carousel { @include clearfix; margin: 30px 10px 0; - border: 1px solid rgb(200,200,200); - background: rgb(252,252,252); + border: 1px solid $border-color-2; + background: $dashboard-profile-color; @include box-shadow(inset 0 0 3px 0 rgba(0,0,0, 0.15)); * { @@ -156,14 +157,14 @@ width: 11px; height: 11px; border-radius: 11px; - background: $light-gray; + background: $dot-color; &:hover { - background: #ccc; + background: $lighter-base-font-color; } &.current { - background: $blue; + background: $link-color; } } @@ -201,7 +202,7 @@ img { width: 100%; - border: 1px solid $light-gray; + border: 1px solid $border-color-1; } } @@ -229,7 +230,7 @@ width: flex-grid(9); > header { - border-bottom: 1px solid rgb(210,210,210); + border-bottom: 1px solid $border-color-2; margin-bottom: 30px; } @@ -246,8 +247,9 @@ a { background: rgb(240,240,240); - @include background-image(linear-gradient(-90deg, rgb(245,245,245) 0%, rgb(243,243,243) 50%, rgb(237,237,237) 50%, rgb(235,235,235) 100%)); - border: 1px solid rgb(220,220,220); + @include background-image($button-bg-image); + background-color: $button-bg-color; + border: 1px solid $border-color-2; @include border-radius(4px); @include box-shadow(0 1px 8px 0 rgba(0,0,0, 0.1)); @include box-sizing(border-box); @@ -260,7 +262,7 @@ text-shadow: 0 1px rgba(255,255,255, 0.6); &:hover { - color: $blue; + color: $link-color; text-decoration: none; } } @@ -272,7 +274,7 @@ margin-right: flex-gutter(); margin-bottom: 50px; padding-bottom: 50px; - border-bottom: 1px solid $light-gray; + border-bottom: 1px solid $border-color-1; position: relative; width: flex-grid(12); z-index: 20; @@ -343,7 +345,7 @@ .course-status { background: $yellow; - border: 1px solid rgb(200,200,200); + border: 1px solid $border-color-2; @include box-shadow(0 1px 0 0 rgba(255,255,255, 0.6)); margin-top: 17px; margin-right: flex-gutter(); @@ -362,7 +364,7 @@ .course-status-completed { background: #ccc; - color: #fff; + color: $very-light-text; p { color: #222; @@ -374,7 +376,7 @@ } .enter-course { - @include button(simple, $blue); + @include button(simple, $button-color); @include box-sizing(border-box); @include border-radius(3px); display: block; @@ -386,7 +388,7 @@ margin-top: 16px; &.archived { - @include button(simple, #eee); + @include button(simple, $button-archive-color); font: normal 15px/1.6rem $sans-serif; padding: 6px 32px 7px; diff --git a/lms/static/sass/multicourse/_home.scss b/lms/static/sass/multicourse/_home.scss index b5546aa470..ea8ddaf654 100644 --- a/lms/static/sass/multicourse/_home.scss +++ b/lms/static/sass/multicourse/_home.scss @@ -7,15 +7,15 @@ } > header { - background: rgb(255,255,255); - @include background-image(url('/static/images/homepage-bg.jpg')); + background: $dashboard-profile-color; + @include background-image(url($homepage-bg-image)); background-size: cover; - border-bottom: 1px solid rgb(80,80,80); - @include box-shadow(0 1px 0 0 rgba(255,255,255, 0.9), inset 0 -1px 5px 0 rgba(0,0,0, 0.1)); + border-bottom: 1px solid $border-color-3; + @include box-shadow(0 1px 0 0 $course-header-bg, inset 0 -1px 5px 0 rgba(0,0,0, 0.1)); @include clearfix; height: 460px; - margin-top: -69px; overflow: hidden; + margin-top: -69px; padding: 0px; width: flex-grid(12); @@ -31,8 +31,8 @@ .title { background: #FFF; - background: rgba(255,255,255, 0.93); - border: 1px solid rgb(100,100,100); + background: $course-header-bg; + border: 1px solid $border-color-3; @include box-shadow(0 4px 25px 0 rgba(0,0,0, 0.5)); @include box-sizing(border-box); min-height: 120px; @@ -80,8 +80,8 @@ .media { background: #FFF; - background: rgba(255,255,255, 0.93); - border: 1px solid rgb(100,100,100); + background: $course-header-bg; + border: 1px solid $border-color-3; border-left: 0; @include box-sizing(border-box); // @include box-shadow(0 4px 25px 0 rgba(0,0,0, 0.5)); @@ -101,7 +101,7 @@ height: 100%; overflow: hidden; position: relative; - background: url('../images/courses/video-thumb.jpg') center no-repeat; + background: url($video-thumb-url) center no-repeat; @include background-size(cover); .play-intro { @@ -164,9 +164,9 @@ > h2 { @include background-image(linear-gradient(-90deg, rgb(250,250,250), rgb(230,230,230))); - border: 1px solid rgb(200,200,200); + border: 1px solid $border-color-2; @include border-radius(4px); - border-top-color: rgb(190,190,190); + border-top-color: $border-color-1; @include box-shadow(inset 0 0 0 1px rgba(255,255,255, 0.4), 0 0px 12px 0 rgba(0,0,0, 0.2)); color: $lighter-base-font-color; letter-spacing: 1px; @@ -180,7 +180,7 @@ } .university-partners { - border-bottom: 1px solid rgb(210,210,210); + border-bottom: 1px solid $border-color-2; margin-bottom: 0px; overflow: hidden; position: relative; @@ -366,13 +366,13 @@ } .more-info { - border: 1px solid rgb(200,200,200); + border: 1px solid $border-color-2; margin-bottom: 80px; width: flex-grid(12); header { @include background-image(linear-gradient(-90deg, rgb(250,250,250), rgb(230,230,230))); - border-bottom: 1px solid rgb(200,200,200); + border-bottom: 1px solid $border-color-2; @include clearfix; padding: 10px 20px 8px; position: relative; @@ -415,14 +415,14 @@ width: flex-grid(12); .blog-posts { - border-bottom: 1px solid rgb(220,220,220); + border-bottom: 1px solid $border-color-2; margin-bottom: 20px; padding-bottom: 20px; @include clearfix; > article { border: 1px dotted transparent; - border-color: rgb(220,220,220); + border-color: $border-color-2; @include box-sizing(border-box); @include clearfix; float: left; @@ -432,8 +432,8 @@ width: flex-grid(4); &:hover { - background: rgb(248,248,248); - border: 1px solid rgb(220,220,220); + background: $body-bg; + border: 1px solid $border-color-2; @include box-shadow(inset 0 0 3px 0 rgba(0,0,0, 0.1)); } @@ -442,7 +442,7 @@ } .post-graphics { - border: 1px solid rgb(190,190,190); + border: 1px solid $border-color-1; @include box-sizing(border-box); display: block; float: left; diff --git a/lms/static/sass/shared/_course_object.scss b/lms/static/sass/shared/_course_object.scss index e99559a49f..f78c483925 100644 --- a/lms/static/sass/shared/_course_object.scss +++ b/lms/static/sass/shared/_course_object.scss @@ -31,8 +31,8 @@ } .course { - background: rgb(250,250,250); - border: 1px solid rgb(180,180,180); + background: $body-bg; + border: 1px solid $border-color-1; @include border-radius(2px); @include box-sizing(border-box); @include box-shadow(0 1px 10px 0 rgba(0,0,0, 0.15), inset 0 0 0 1px rgba(255,255,255, 0.9)); @@ -42,7 +42,7 @@ @include transition(all, 0.15s, linear); .status { - background: $blue; + background: $link-color; color: white; font-size: 10px; left: 10px; @@ -55,7 +55,7 @@ } .status:after { - border-bottom: 6px solid shade($blue, 50%); + border-bottom: 6px solid shade($link-color, 50%); border-right: 6px solid transparent; content: ""; display: block; @@ -90,7 +90,7 @@ } .inner-wrapper { - border: 1px solid rgba(255,255,255, 1); + border: 1px solid $border-color-4; height: 100%; height: 200px; overflow: hidden; @@ -116,12 +116,12 @@ text-decoration: none; .info-link { - color: $blue; + color: $link-color; opacity: 1; } h2 { - color: $blue; + color: $link-color; } } @@ -176,7 +176,7 @@ // } .info { - background: rgb(255,255,255); + background: $content-wrapper-bg; height: 220px + 130px; left: 0px; position: absolute; @@ -221,14 +221,14 @@ width: 100%; .university { - border-right: 1px solid rgb(200,200,200); + border-right: 1px solid $border-color-2; color: $lighter-base-font-color; letter-spacing: 1px; margin-right: 10px; padding-right: 10px; &:hover { - color: $blue; + color: $link-color; } } @@ -240,9 +240,9 @@ } &:hover { - background: rgb(245,245,245); - border-color: rgb(170,170,170); - @include box-shadow(0 1px 16px 0 rgba($blue, 0.4)); + background: $course-profile-bg; + border-color: $border-color-1; + @include box-shadow(0 1px 16px 0 rgba($shadow-color, 0.4)); .info { top: -150px; diff --git a/lms/static/sass/shared/_footer.scss b/lms/static/sass/shared/_footer.scss index d891ff408b..e3e99ae301 100644 --- a/lms/static/sass/shared/_footer.scss +++ b/lms/static/sass/shared/_footer.scss @@ -159,4 +159,4 @@ width: 360px; } } -} \ No newline at end of file +} diff --git a/lms/static/sass/shared/_forms.scss b/lms/static/sass/shared/_forms.scss index 79d476f420..3350081850 100644 --- a/lms/static/sass/shared/_forms.scss +++ b/lms/static/sass/shared/_forms.scss @@ -15,8 +15,8 @@ input[type="text"], input[type="email"], input[type="password"], input[type="tel"] { - background: rgb(250,250,250); - border: 1px solid rgb(200,200,200); + background: $form-bg-color; + border: 1px solid $border-color-2; @include border-radius(3px); @include box-shadow(0 1px 0 0 rgba(255,255,255, 0.6), inset 0 0 3px 0 rgba(0,0,0, 0.1)); @include box-sizing(border-box); @@ -31,8 +31,8 @@ input[type="tel"] { } &:focus { - border-color: lighten($blue, 20%); - @include box-shadow(0 0 6px 0 rgba($blue, 0.4), inset 0 0 4px 0 rgba(0,0,0, 0.15)); + border-color: darken($button-archive-color, 50%); + @include box-shadow(0 0 6px 0 darken($button-archive-color, 50%), inset 0 0 4px 0 rgba(0,0,0, 0.15)); outline: none; } } @@ -46,7 +46,7 @@ input[type="button"], button, .button { @include border-radius(3px); - @include button(shiny, $blue); + @include button(shiny, $button-color); font: normal 1.2rem/1.6rem $sans-serif; letter-spacing: 1px; padding: 4px 20px; diff --git a/lms/static/sass/shared/_header.scss b/lms/static/sass/shared/_header.scss index 5eb453448c..6987b35c84 100644 --- a/lms/static/sass/shared/_header.scss +++ b/lms/static/sass/shared/_header.scss @@ -54,8 +54,7 @@ header.global { li.secondary { > a { - color: $lighter-base-font-color; - color: $blue; + color: $link-color; display: block; font-family: $sans-serif; @include inline-block; @@ -78,9 +77,9 @@ header.global { margin-right: 5px; > a { - @include background-image(linear-gradient(#fff 0%, rgb(250,250,250) 50%, rgb(237,237,237) 50%, rgb(220,220,220) 100%)); - border: 1px solid transparent; - border-color: rgb(200,200,200); + @include background-image($button-bg-image); + background-color: $button-bg-color; + border: 1px solid $border-color-2; @include border-radius(3px); @include box-sizing(border-box); @include box-shadow(0 1px 0 0 rgba(255,255,255, 0.6)); @@ -101,7 +100,7 @@ header.global { } &:hover, &.active { - background: #FFF; + background: $button-bg-hover-color; } } } @@ -159,10 +158,10 @@ header.global { } ul.dropdown-menu { - background: rgb(252,252,252); + background: $border-color-4; @include border-radius(4px); @include box-shadow(0 2px 24px 0 rgba(0,0,0, 0.3)); - border: 1px solid rgb(100,100,100); + border: 1px solid $border-color-3; display: none; padding: 5px 10px; position: absolute; @@ -178,12 +177,12 @@ header.global { &::before { background: transparent; border: { - top: 6px solid rgba(252,252,252, 1); - right: 6px solid rgba(252,252,252, 1); + top: 6px solid $border-color-4; + right: 6px solid $border-color-4; bottom: 6px solid transparent; left: 6px solid transparent; } - @include box-shadow(1px 0 0 0 rgb(0,0,0), 0 -1px 0 0 rgb(0,0,0)); + @include box-shadow(1px 0 0 0 $border-color-3, 0 -1px 0 0 $border-color-3); content: ""; display: block; height: 0px; @@ -196,7 +195,7 @@ header.global { li { display: block; - border-top: 1px dotted rgba(200,200,200, 1); + border-top: 1px dotted $border-color-2; @include box-shadow(inset 0 1px 0 0 rgba(255,255,255, 0.05)); &:first-child { @@ -208,7 +207,7 @@ header.global { border: 1px solid transparent; @include border-radius(3px); @include box-sizing(border-box); - color: $blue; + color: $link-color; cursor: pointer; display: block; margin: 5px 0px; @@ -328,4 +327,4 @@ header.global { text-decoration: none; color: $m-blue-s1 !important; } -} \ No newline at end of file +} diff --git a/lms/static/sass/shared/_modal.scss b/lms/static/sass/shared/_modal.scss index 8ff58c1c14..7a51213dee 100644 --- a/lms/static/sass/shared/_modal.scss +++ b/lms/static/sass/shared/_modal.scss @@ -52,7 +52,7 @@ } .inner-wrapper { - background: rgb(245,245,245); + background: $modal-bg-color; @include border-radius(0px); border: 1px solid rgba(0, 0, 0, 0.9); @include box-shadow(inset 0 1px 0 0 rgba(255, 255, 255, 0.7)); @@ -149,7 +149,7 @@ } label { - color: #646464; + color: $text-color; &.field-error { display: block; diff --git a/lms/templates/email_change_failed.html b/lms/templates/email_change_failed.html new file mode 100644 index 0000000000..e228df4a9c --- /dev/null +++ b/lms/templates/email_change_failed.html @@ -0,0 +1,3 @@ +

    E-mail change failed.

    + +

    We were unable to send a confirmation email to ${email}

    diff --git a/package.json b/package.json index 7fa287018a..2dd67d5be4 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "dependencies": { "coffee-script": "1.6.X", - "phantom-jasmine": "0.1.0" + "phantom-jasmine": "0.1.0", + "jasmine-reporters": "0.2.1" } } diff --git a/pylintrc b/pylintrc index 792079ce03..d4085379b4 100644 --- a/pylintrc +++ b/pylintrc @@ -110,7 +110,9 @@ generated-members= get_url, size, content, - status_code + status_code, +# For factory_body factories + create [BASIC] diff --git a/rakefiles/jasmine.rake b/rakefiles/jasmine.rake index 1e5050801e..4182bef9e2 100644 --- a/rakefiles/jasmine.rake +++ b/rakefiles/jasmine.rake @@ -48,6 +48,7 @@ def template_jasmine_runner(lib) sh("node_modules/.bin/coffee -c #{coffee_files.join(' ')}") end phantom_jasmine_path = File.expand_path("node_modules/phantom-jasmine") + jasmine_reporters_path = File.expand_path("node_modules/jasmine-reporters") common_js_root = File.expand_path("common/static/js") common_coffee_root = File.expand_path("common/static/coffee/src") @@ -58,6 +59,7 @@ def template_jasmine_runner(lib) js_specs = Dir[spec_glob].sort_by {|p| [p.split('/').length, p]} .map {|f| File.expand_path(f)} js_source = Dir[src_glob].sort_by {|p| [p.split('/').length, p]} .map {|f| File.expand_path(f)} + report_dir = report_dir_path("#{lib}/jasmine") template = ERB.new(File.read("common/templates/jasmine/jasmine_test_runner.html.erb")) template_output = "#{lib}/jasmine_test_runner.html" File.open(template_output, 'w') do |f| @@ -66,6 +68,11 @@ def template_jasmine_runner(lib) yield File.expand_path(template_output) end +def run_phantom_js(url) + phantomjs = ENV['PHANTOMJS_PATH'] || 'phantomjs' + sh("#{phantomjs} node_modules/jasmine-reporters/test/phantomjs-testrunner.js #{url}") +end + [:lms, :cms].each do |system| desc "Open jasmine tests for #{system} in your default browser" task "browse_jasmine_#{system}" => :assets do @@ -78,14 +85,16 @@ end desc "Use phantomjs to run jasmine tests for #{system} from the console" task "phantomjs_jasmine_#{system}" => :assets do - phantomjs = ENV['PHANTOMJS_PATH'] || 'phantomjs' django_for_jasmine(system, false) do |jasmine_url| - sh("#{phantomjs} node_modules/phantom-jasmine/lib/run_jasmine_test.coffee #{jasmine_url}") + run_phantom_js(jasmine_url) end end end -Dir["common/lib/*"].select{|lib| File.directory?(lib)}.each do |lib| +STATIC_JASMINE_TESTS = Dir["common/lib/*"].select{|lib| File.directory?(lib)} +STATIC_JASMINE_TESTS << 'common/static/coffee' + +STATIC_JASMINE_TESTS.each do |lib| desc "Open jasmine tests for #{lib} in your default browser" task "browse_jasmine_#{lib}" do template_jasmine_runner(lib) do |f| @@ -97,26 +106,14 @@ Dir["common/lib/*"].select{|lib| File.directory?(lib)}.each do |lib| desc "Use phantomjs to run jasmine tests for #{lib} from the console" task "phantomjs_jasmine_#{lib}" do - phantomjs = ENV['PHANTOMJS_PATH'] || 'phantomjs' template_jasmine_runner(lib) do |f| - sh("#{phantomjs} node_modules/phantom-jasmine/lib/run_jasmine_test.coffee #{f}") + run_phantom_js(f) end end end desc "Open jasmine tests for discussion in your default browser" -task "browse_jasmine_discussion" do - template_jasmine_runner("common/static/coffee") do |f| - sh("python -m webbrowser -t 'file://#{f}'") - puts "Press ENTER to terminate".red - $stdin.gets - end -end +task "browse_jasmine_discussion" => "browse_jasmine_common/static/coffee" desc "Use phantomjs to run jasmine tests for discussion from the console" -task "phantomjs_jasmine_discussion" do - phantomjs = ENV['PHANTOMJS_PATH'] || 'phantomjs' - template_jasmine_runner("common/static/coffee") do |f| - sh("#{phantomjs} node_modules/phantom-jasmine/lib/run_jasmine_test.coffee #{f}") - end -end +task "phantomjs_jasmine_discussion" => "phantomjs_jasmine_common/static/coffee" diff --git a/rakefiles/prereqs.rake b/rakefiles/prereqs.rake index f453372065..ff8b4b8784 100644 --- a/rakefiles/prereqs.rake +++ b/rakefiles/prereqs.rake @@ -31,6 +31,7 @@ task :install_python_prereqs => "ws:migrate" do unchanged = 'Python requirements unchanged, nothing to install' when_changed(unchanged, ['requirements/**/*'], [site_packages_dir]) do ENV['PIP_DOWNLOAD_CACHE'] ||= '.pip_download_cache' + sh('pip install --exists-action w -r requirements/edx/pre.txt') sh('pip install --exists-action w -r requirements/edx/base.txt') sh('pip install --exists-action w -r requirements/edx/post.txt') # requirements/private.txt is used to install our libs as diff --git a/rakefiles/tests.rake b/rakefiles/tests.rake index ebe8ea6375..448a482f04 100644 --- a/rakefiles/tests.rake +++ b/rakefiles/tests.rake @@ -12,10 +12,11 @@ def run_under_coverage(cmd, root) return cmd end -def run_tests(system, report_dir, stop_on_failure=true) +def run_tests(system, report_dir, test_id=nil, stop_on_failure=true) ENV['NOSE_XUNIT_FILE'] = File.join(report_dir, "nosetests.xml") dirs = Dir["common/djangoapps/*"] + Dir["#{system}/djangoapps/*"] - cmd = django_admin(system, :test, 'test', '--logging-clear-handlers', *dirs.each) + test_id = dirs.join(' ') if test_id.nil? or test_id == '' + cmd = django_admin(system, :test, 'test', '--logging-clear-handlers', test_id) sh(run_under_coverage(cmd, system)) do |ok, res| if !ok and stop_on_failure abort "Test failed!" @@ -25,6 +26,16 @@ def run_tests(system, report_dir, stop_on_failure=true) end def run_acceptance_tests(system, report_dir, harvest_args) + # HACK: Since now the CMS depends on the existence of some database tables + # that used to be in LMS (Role/Permissions for Forums) we need to make + # sure the acceptance tests create/migrate the database tables + # that are represented in the LMS. We might be able to address this by moving + # out the migrations from lms/django_comment_client, but then we'd have to + # repair all the existing migrations from the upgrade tables in the DB. + if system == :cms + sh(django_admin('lms', 'acceptance', 'syncdb', '--noinput')) + sh(django_admin('lms', 'acceptance', 'migrate', '--noinput')) + end sh(django_admin(system, 'acceptance', 'syncdb', '--noinput')) sh(django_admin(system, 'acceptance', 'migrate', '--noinput')) sh(django_admin(system, 'acceptance', 'harvest', '--debug-mode', '--tag -skip', harvest_args)) @@ -44,13 +55,13 @@ TEST_TASK_DIRS = [] # Per System tasks desc "Run all django tests on our djangoapps for the #{system}" - task "test_#{system}", [:stop_on_failure] => ["clean_test_files", :predjango, "#{system}:gather_assets:test", "fasttest_#{system}"] + task "test_#{system}", [:test_id, :stop_on_failure] => ["clean_test_files", :predjango, "#{system}:gather_assets:test", "fasttest_#{system}"] # Have a way to run the tests without running collectstatic -- useful when debugging without # messing with static files. - task "fasttest_#{system}", [:stop_on_failure] => [report_dir, :install_prereqs, :predjango] do |t, args| - args.with_defaults(:stop_on_failure => 'true') - run_tests(system, report_dir, args.stop_on_failure) + task "fasttest_#{system}", [:test_id, :stop_on_failure] => [report_dir, :install_prereqs, :predjango] do |t, args| + args.with_defaults(:stop_on_failure => 'true', :test_id => nil) + run_tests(system, report_dir, args.test_id, args.stop_on_failure) end # Run acceptance tests @@ -100,7 +111,7 @@ end task :test do TEST_TASK_DIRS.each do |dir| - Rake::Task["test_#{dir}"].invoke(false) + Rake::Task["test_#{dir}"].invoke(nil, false) end if $failed_tests > 0 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 3d8b95f8e2..01768bcac9 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -29,7 +29,6 @@ mako==0.7.3 Markdown==2.2.1 networkx==1.7 nltk==2.0.4 -numpy==1.6.2 paramiko==1.9.0 path.py==3.0.1 Pillow==1.7.8 @@ -43,6 +42,7 @@ python-openid==2.2.5 pytz==2012h PyYAML==3.10 requests==0.14.2 +scipy==0.11.0 Shapely==1.2.16 sorl-thumbnail==11.12 South==0.7.6 @@ -71,7 +71,7 @@ transifex-client==0.8 coverage==3.6 factory_boy==2.0.2 lettuce==0.2.16 -mock==0.8.0 +mock==1.0.1 nosexcover==1.0.7 pep8==1.4.5 pylint==0.28 @@ -82,3 +82,5 @@ django_nose==1.1 django-jasmine==0.3.2 django_debug_toolbar django-debug-toolbar-mongo + +git+https://github.com/mfogel/django-settings-context-processor.git diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 6b28d3edd9..f280d66557 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -9,4 +9,4 @@ # Our libraries: -e git+https://github.com/edx/XBlock.git@2144a25d#egg=XBlock --e git+https://github.com/edx/codejail.git@07494f1#egg=codejail +-e git+https://github.com/edx/codejail.git@72cf791#egg=codejail diff --git a/requirements/edx/post.txt b/requirements/edx/post.txt index e1e26b381a..b637b65db0 100644 --- a/requirements/edx/post.txt +++ b/requirements/edx/post.txt @@ -1,6 +1,2 @@ - -# This must be installed after distribute 0.6.28 -MySQL-python==1.2.4c1 - -# This must be installed after numpy -scipy==0.11.0 +# This must be installed after distribute has been updated. +MySQL-python==1.2.4 diff --git a/requirements/edx/pre.txt b/requirements/edx/pre.txt new file mode 100644 index 0000000000..a8dff9bf9a --- /dev/null +++ b/requirements/edx/pre.txt @@ -0,0 +1,3 @@ +# Numpy and scipy can't be installed in the same pip run. +# Install numpy before other things to help resolve the problem. +numpy==1.6.2