diff --git a/.gitignore b/.gitignore index 76cc1efa95..05e76c4caa 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ :2e# .AppleDouble database.sqlite -private-requirements.txt +requirements/private.txt courseware/static/js/mathjax/* flushdb.sh build @@ -26,6 +26,7 @@ Gemfile.lock conf/locale/en/LC_MESSAGES/*.po !messages.po lms/static/sass/*.css +lms/static/sass/application.scss cms/static/sass/*.css lms/lib/comment_client/python nosetests.xml diff --git a/.reviewboardrc b/.reviewboardrc new file mode 100644 index 0000000000..b79235a4a4 --- /dev/null +++ b/.reviewboardrc @@ -0,0 +1,2 @@ +REVIEWBOARD_URL = "https://rbcommons.com/s/edx/" +GUESS_FIELDS = True diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000000..cdfdd4c2fe --- /dev/null +++ b/AUTHORS @@ -0,0 +1,74 @@ +Piotr Mitros +Kyle Fiedler +Ernie Park +Bridger Maxwell +Lyla Fischer +David Ormsbee +Chris Terman +Reda Lemeden +Anant Agarwal +Jean-Michel Claus +Calen Pennington +JM Van Thong +Prem Sichanugrist +Isaac Chuang +Galen Frechette +Edward Loveall +Matt Jankowski +John Jarvis +Victor Shnayder +Matthew Mongeau +Tony Kim +Arjun Singh +John Hess +Carlos Andrés Rocha +Mike Chen +Rocky Duan +Sidhanth Rao +Brittany Cheng +Dhaval Adjodah +Tom Giannattasio +Ibrahim Awwal +Sarina Canelake +Mark L. Chang +Dean Dieker +Tommy MacWilliam +Nate Hardison +Chris Dodge +Kevin Chugh +Ned Batchelder +Alexander Kryklia +Vik Paruchuri +Louis Sobel +Brian Wilson +Ashley Penney +Don Mitchell +Aaron Culich +Brian Talbot +Jay Zoldak +Valera Rozuvan +Diana Huang +Marco Morales +Christina Roberts +Robert Chirwa +Ed Zarecor +Deena Wang +Jean Manuel-Nater +Emily Zhang <1800.ehz.hang@gmail.com> +Jennifer Akana +Peter Baratta +Julian Arni +Arthur Barrett +Vasyl Nakvasiuk +Will Daly +James Tauber +Greg Price +Joe Blaylock +Sef Kloninger +Anto Stupak +David Adams +Steve Strassmann +Giulio Gratta +David Baumgold +Jason Bau +Frances Botsford diff --git a/README.md b/README.md index 90b82ff07a..ed52c21fb2 100644 --- a/README.md +++ b/README.md @@ -77,8 +77,8 @@ environment), and Node has a library installer called Once you've got your languages and virtual environments set up, install the libraries like so: - $ pip install -r requirements/base.txt - $ pip install -r requirements/post.txt + $ pip install -r requirements/edx/base.txt + $ pip install -r requirements/edx/post.txt $ bundle install $ npm install diff --git a/cms/djangoapps/contentstore/features/advanced-settings.feature b/cms/djangoapps/contentstore/features/advanced-settings.feature index ca5b62e596..6f6cc50702 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.feature +++ b/cms/djangoapps/contentstore/features/advanced-settings.feature @@ -11,7 +11,8 @@ Feature: Advanced (manual) course policy Given I am on the Advanced Course Settings page in Studio Then the settings are alphabetized - @skip-phantom + # Skipped because Ubuntu ChromeDriver cannot click notification "Cancel" + @skip Scenario: Test cancel editing key value Given I am on the Advanced Course Settings page in Studio When I edit the value of a policy key @@ -20,7 +21,8 @@ Feature: Advanced (manual) course policy And I reload the page Then the policy key value is unchanged - @skip-phantom + # Skipped because Ubuntu ChromeDriver cannot click notification "Save" + @skip Scenario: Test editing key value Given I am on the Advanced Course Settings page in Studio When I edit the value of a policy key and save @@ -28,7 +30,8 @@ Feature: Advanced (manual) course policy And I reload the page Then the policy key value is changed - @skip-phantom + # Skipped because Ubuntu ChromeDriver cannot edit CodeMirror input + @skip Scenario: Test how multi-line input appears Given I am on the Advanced Course Settings page in Studio When I create a JSON object as a value @@ -36,7 +39,8 @@ Feature: Advanced (manual) course policy And I reload the page Then it is displayed as formatted - @skip-phantom + # Skipped because Ubuntu ChromeDriver cannot edit CodeMirror input + @skip Scenario: Test automatic quoting of non-JSON values Given I am on the Advanced Course Settings page in Studio When I create a non-JSON value not in quotes 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.feature b/cms/djangoapps/contentstore/features/checklists.feature index ddf1adf263..3767144c99 100644 --- a/cms/djangoapps/contentstore/features/checklists.feature +++ b/cms/djangoapps/contentstore/features/checklists.feature @@ -10,8 +10,6 @@ Feature: Course checklists Then I can check and uncheck tasks in a checklist And They are correctly selected after I reload the page - @skip-phantom - @skip-firefox Scenario: A task can link to a location within Studio Given I have opened Checklists When I select a link to the course outline @@ -19,8 +17,6 @@ Feature: Course checklists And I press the browser back button Then I am brought back to the course outline in the correct state - @skip-phantom - @skip-firefox Scenario: A task can link to a location outside Studio Given I have opened Checklists When I select a link to help page 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.feature b/cms/djangoapps/contentstore/features/course-settings.feature index fc9641cb46..e869bfe47a 100644 --- a/cms/djangoapps/contentstore/features/course-settings.feature +++ b/cms/djangoapps/contentstore/features/course-settings.feature @@ -1,20 +1,17 @@ Feature: Course Settings As a course author, I want to be able to configure my course settings. - @skip-phantom Scenario: User can set course dates Given I have opened a new course in Studio When I select Schedule and Details And I set course dates Then I see the set dates on refresh - @skip-phantom Scenario: User can clear previously set course dates (except start date) Given I have set course dates And I clear all the dates except start Then I see cleared dates on refresh - @skip-phantom Scenario: User cannot clear the course start date Given I have set course dates And I clear the course start date 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.feature b/cms/djangoapps/contentstore/features/section.feature index 24cbeb3db9..236cf501fc 100644 --- a/cms/djangoapps/contentstore/features/section.feature +++ b/cms/djangoapps/contentstore/features/section.feature @@ -3,7 +3,6 @@ Feature: Create Section As a course author I want to create and edit sections - @skip-phantom Scenario: Add a new section to a course Given I have opened a new course in Studio When I click the New Section link @@ -27,7 +26,8 @@ Feature: Create Section And I save a new section release date Then the section release date is updated - @skip-phantom + # Skipped because Ubuntu ChromeDriver hangs on alert + @skip Scenario: Delete section Given I have opened a new course in Studio And I have added a 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/features/signup.py b/cms/djangoapps/contentstore/features/signup.py index 6ca358183b..398f8d074d 100644 --- a/cms/djangoapps/contentstore/features/signup.py +++ b/cms/djangoapps/contentstore/features/signup.py @@ -18,10 +18,7 @@ def i_fill_in_the_registration_form(step): @step('I press the Create My Account button on the registration form$') def i_press_the_button_on_the_registration_form(step): submit_css = 'form#register_form button#submit' - # Workaround for click not working on ubuntu - # for some unknown reason. - e = world.css_find(submit_css) - e.type(' ') + world.css_click(submit_css) @step('I should see be on the studio home page$') diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature b/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature index a0e0a48f9e..c9f5b43dfb 100644 --- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature +++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature @@ -14,7 +14,6 @@ Feature: Overview Toggle Section When I navigate to the course overview page Then I do not see the "Collapse All Sections" link - @skip-phantom Scenario: Collapse link appears after creating first section of a course Given I have a course with no sections When I navigate to the course overview page @@ -22,7 +21,8 @@ Feature: Overview Toggle Section Then I see the "Collapse All Sections" link And all sections are expanded - @skip-phantom + # Skipped because Ubuntu ChromeDriver hangs on alert + @skip Scenario: Collapse link is not removed after last section of a course is deleted Given I have a course with 1 section And I navigate to the course overview page diff --git a/cms/djangoapps/contentstore/features/subsection.feature b/cms/djangoapps/contentstore/features/subsection.feature index 28285bf8a1..8bb12467ff 100644 --- a/cms/djangoapps/contentstore/features/subsection.feature +++ b/cms/djangoapps/contentstore/features/subsection.feature @@ -3,14 +3,12 @@ Feature: Create Subsection As a course author I want to create and edit subsections - @skip-phantom Scenario: Add a new subsection to a section Given I have opened a new course section in Studio When I click the New Subsection link And I enter the subsection name and click save Then I see my subsection on the Courseware page - @skip-phantom Scenario: Add a new subsection (with a name containing a quote) to a section (bug #216) Given I have opened a new course section in Studio When I click the New Subsection link @@ -27,7 +25,6 @@ Feature: Create Subsection And I reload the page Then I see it marked as Homework - @skip-phantom Scenario: Set a due date in a different year (bug #256) Given I have opened a new subsection in Studio And I have set a release date and due date in different years @@ -35,7 +32,8 @@ Feature: Create Subsection And I reload the page Then I see the correct dates - @skip-phantom + # Skipped because Ubuntu ChromeDriver hangs on alert + @skip Scenario: Delete a subsection Given I have opened a new course section in Studio And I have added a new subsection diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py index f9e5b52bb2..edc8b17168 100644 --- a/cms/djangoapps/contentstore/features/subsection.py +++ b/cms/djangoapps/contentstore/features/subsection.py @@ -63,14 +63,6 @@ def test_have_set_dates_in_different_years(step): set_date_and_time('input#due_date', '01/02/2012', 'input#due_time', '04:00') -@step('I see the correct dates$') -def i_see_the_correct_dates(step): - assert_equal('12/25/2011', world.css_find('input#start_date').first.value) - assert_equal('03:00', world.css_find('input#start_time').first.value) - assert_equal('01/02/2012', world.css_find('input#due_date').first.value) - assert_equal('04:00', world.css_find('input#due_time').first.value) - - @step('I mark it as Homework$') def i_mark_it_as_homework(step): world.css_click('a.menu-toggle') @@ -101,8 +93,20 @@ def the_subsection_does_not_exist(step): assert world.browser.is_element_not_present_by_css(css) +@step('I see the correct dates$') +def i_see_the_correct_dates(step): + assert_equal('12/25/2011', get_date('input#start_date')) + assert_equal('03:00', get_date('input#start_time')) + assert_equal('01/02/2012', get_date('input#due_date')) + assert_equal('04:00', get_date('input#due_time')) + + ############ HELPER METHODS ################### +def get_date(css): + return world.css_find(css).first.value.strip() + + def save_subsection_name(name): name_css = 'input.new-subsection-name-input' save_css = 'input.new-subsection-name-save' diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 0aec61729c..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) @@ -74,7 +76,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.client.login(username=uname, password=password) def check_edit_unit(self, test_course_name): - import_from_xml(modulestore(), 'common/test/data/', [test_course_name]) + import_from_xml(modulestore('direct'), 'common/test/data/', [test_course_name]) for descriptor in modulestore().get_items(Location(None, None, 'vertical', None, None)): print "Checking ", descriptor.location.url() @@ -101,7 +103,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): Unfortunately, None = published for the revision field, so get_items() would return both draft and non-draft copies. ''' - store = modulestore() + store = modulestore('direct') draft_store = modulestore('draft') import_from_xml(store, 'common/test/data/', ['simple']) @@ -128,7 +130,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): module as 'own-metadata' when publishing. Also verifies the metadata inheritance is properly computed ''' - store = modulestore() + store = modulestore('direct') draft_store = modulestore('draft') import_from_xml(store, 'common/test/data/', ['simple']) @@ -186,7 +188,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertEqual(html_module.lms.graceperiod, new_graceperiod) def test_get_depth_with_drafts(self): - import_from_xml(modulestore(), 'common/test/data/', ['simple']) + import_from_xml(modulestore('direct'), 'common/test/data/', ['simple']) course = modulestore('draft').get_item( Location(['i4x', 'edX', 'simple', 'course', '2012_Fall', None]), @@ -221,17 +223,17 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertEqual(num_drafts, 1) def test_import_textbook_as_content_element(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) - module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) + course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) self.assertGreater(len(course.textbooks), 0) def test_static_tab_reordering(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) - module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) + course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) # reverse the ordering @@ -253,10 +255,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertEqual(reverse_tabs, course_tabs) def test_import_polls(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) - module_store = modulestore('direct') - found = False + import_from_xml(module_store, 'common/test/data/', ['full']) items = module_store.get_items(['i4x', 'edX', 'full', 'poll_question', None, None]) found = len(items) > 0 @@ -270,9 +270,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertGreater(err_cnt, 0) def test_delete(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) - direct_store = modulestore('direct') + import_from_xml(direct_store, 'common/test/data/', ['full']) sequential = direct_store.get_item(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None])) @@ -306,8 +305,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): This test case verifies that a course can use specialized override for about data, e.g. /about/Fall_2012/effort.html while there is a base definition in /about/effort.html ''' - import_from_xml(modulestore(), 'common/test/data/', ['full']) module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) + effort = module_store.get_item(Location(['i4x', 'edX', 'full', 'about', 'effort', None])) self.assertEqual(effort.data, '6 hours') @@ -316,9 +316,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertEqual(effort.data, 'TBD') def test_remove_hide_progress_tab(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) - module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) source_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') course = module_store.get_item(source_location) @@ -333,14 +332,14 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): 'display_name': 'Robot Super Course', } - import_from_xml(modulestore(), 'common/test/data/', ['full']) + module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) resp = self.client.post(reverse('create_new_course'), course_data) self.assertEqual(resp.status_code, 200) data = parse_json(resp) self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course') - module_store = modulestore('direct') content_store = contentstore() source_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') @@ -355,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) @@ -365,9 +364,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertEqual(resp.status_code, 400) def test_delete_course(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) - module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) + content_store = contentstore() location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') @@ -378,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') @@ -418,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()]) @@ -443,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)) @@ -523,8 +522,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf') def test_prefetch_children(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) + location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') wrapper = MongoCollectionFindWrapper(module_store.collection.find) @@ -610,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) @@ -736,7 +744,7 @@ class ContentStoreTest(ModuleStoreTestCase): Import and walk through some common URL endpoints. This just verifies non-500 and no other correct behavior, so it is not a deep test """ - import_from_xml(modulestore(), 'common/test/data/', ['simple']) + import_from_xml(modulestore('direct'), 'common/test/data/', ['simple']) loc = Location(['i4x', 'edX', 'simple', 'course', '2012_Fall', None]) resp = self.client.get(reverse('course_index', kwargs={'org': loc.org, @@ -803,44 +811,46 @@ 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) + def test_import_metadata_with_attempts_empty_string(self): - import_from_xml(modulestore(), 'common/test/data/', ['simple']) module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['simple']) + did_load_item = False try: module_store.get_item(Location(['i4x', 'edX', 'simple', 'problem', 'ps01-simple', None])) @@ -852,8 +862,9 @@ class ContentStoreTest(ModuleStoreTestCase): self.assertTrue(did_load_item) def test_forum_id_generation(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) + new_component_location = Location('i4x', 'edX', 'full', 'discussion', 'new_component') source_template_location = Location('i4x', 'edx', 'templates', 'discussion', 'Discussion_Tag') @@ -865,9 +876,8 @@ class ContentStoreTest(ModuleStoreTestCase): self.assertNotEquals(new_discussion_item.discussion_id, '$$GUID$$') def test_update_modulestore_signal_did_fire(self): - - import_from_xml(modulestore(), 'common/test/data/', ['full']) module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) try: module_store.modulestore_update_signal = Signal(providing_args=['modulestore', 'course_id', 'location']) @@ -891,9 +901,9 @@ class ContentStoreTest(ModuleStoreTestCase): self.assertTrue(self.got_signal) def test_metadata_inheritance(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) - module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) + course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) verticals = module_store.get_items(['i4x', 'edX', 'full', 'vertical', None, None]) diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index c9f6b2053e..2a4ff46038 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -17,7 +17,6 @@ from xmodule.modulestore.tests.factories import CourseFactory from models.settings.course_metadata import CourseMetadata from xmodule.modulestore.xml_importer import import_from_xml -from xmodule.modulestore.django import modulestore from xmodule.fields import Date @@ -256,7 +255,7 @@ class CourseMetadataEditingTest(CourseTestCase): def setUp(self): CourseTestCase.setUp(self) # add in the full class too - import_from_xml(modulestore(), 'common/test/data/', ['full']) + import_from_xml(get_modulestore(self.course_location), 'common/test/data/', ['full']) self.fullcourse_location = Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None]) def test_fetch_initial_fields(self): diff --git a/cms/djangoapps/contentstore/tests/test_item.py b/cms/djangoapps/contentstore/tests/test_item.py new file mode 100644 index 0000000000..07264cdc30 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_item.py @@ -0,0 +1,29 @@ +from contentstore.utils import get_modulestore, get_url_reverse +from contentstore.tests.test_course_settings import CourseTestCase +from xmodule.modulestore.tests.factories import CourseFactory +from django.core.urlresolvers import reverse + + +class DeleteItem(CourseTestCase): + def setUp(self): + """ Creates the test course with a static page in it. """ + super(DeleteItem, self).setUp() + self.course = CourseFactory.create(org='mitX', number='333', display_name='Dummy Course') + + def testDeleteStaticPage(self): + # Add static tab + data = { + 'parent_location': 'i4x://mitX/333/course/Dummy_Course', + 'template': 'i4x://edx/templates/static_tab/Empty' + } + + resp = self.client.post(reverse('clone_item'), data) + self.assertEqual(resp.status_code, 200) + + # Now delete it. There was a bug that the delete was failing (static tabs do not exist in draft modulestore). + resp = self.client.post(reverse('delete_item'), resp.content, "application/json") + self.assertEqual(resp.status_code, 200) + + + + diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py index eb7bfb6db9..3b755b0ec2 100644 --- a/cms/djangoapps/contentstore/tests/test_utils.py +++ b/cms/djangoapps/contentstore/tests/test_utils.py @@ -1,6 +1,8 @@ """ Tests for utils. """ from contentstore import utils import mock +import collections +import copy from django.test import TestCase from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase @@ -70,3 +72,79 @@ class UrlReverseTestCase(ModuleStoreTestCase): 'https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about', utils.get_url_reverse('https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about', course) ) + + +class ExtraPanelTabTestCase(TestCase): + """ Tests adding and removing extra course tabs. """ + + def get_tab_type_dicts(self, tab_types): + """ Returns an array of tab dictionaries. """ + if tab_types: + return [{'tab_type': tab_type} for tab_type in tab_types.split(',')] + else: + return [] + + def get_course_with_tabs(self, tabs=[]): + """ Returns a mock course object with a tabs attribute. """ + course = collections.namedtuple('MockCourse', ['tabs']) + if isinstance(tabs, basestring): + course.tabs = self.get_tab_type_dicts(tabs) + else: + course.tabs = tabs + return course + + def test_add_extra_panel_tab(self): + """ Tests if a tab can be added to a course tab list. """ + for tab_type in utils.EXTRA_TAB_PANELS.keys(): + tab = utils.EXTRA_TAB_PANELS.get(tab_type) + + # test adding with changed = True + for tab_setup in ['', 'x', 'x,y,z']: + course = self.get_course_with_tabs(tab_setup) + expected_tabs = copy.copy(course.tabs) + expected_tabs.append(tab) + changed, actual_tabs = utils.add_extra_panel_tab(tab_type, course) + self.assertTrue(changed) + self.assertEqual(actual_tabs, expected_tabs) + + # test adding with changed = False + tab_test_setup = [ + [tab], + [tab, self.get_tab_type_dicts('x,y,z')], + [self.get_tab_type_dicts('x,y'), tab, self.get_tab_type_dicts('z')], + [self.get_tab_type_dicts('x,y,z'), tab]] + + for tab_setup in tab_test_setup: + course = self.get_course_with_tabs(tab_setup) + expected_tabs = copy.copy(course.tabs) + changed, actual_tabs = utils.add_extra_panel_tab(tab_type, course) + self.assertFalse(changed) + self.assertEqual(actual_tabs, expected_tabs) + + def test_remove_extra_panel_tab(self): + """ Tests if a tab can be removed from a course tab list. """ + for tab_type in utils.EXTRA_TAB_PANELS.keys(): + tab = utils.EXTRA_TAB_PANELS.get(tab_type) + + # test removing with changed = True + tab_test_setup = [ + [tab], + [tab, self.get_tab_type_dicts('x,y,z')], + [self.get_tab_type_dicts('x,y'), tab, self.get_tab_type_dicts('z')], + [self.get_tab_type_dicts('x,y,z'), tab]] + + for tab_setup in tab_test_setup: + course = self.get_course_with_tabs(tab_setup) + expected_tabs = [t for t in course.tabs if t != utils.EXTRA_TAB_PANELS.get(tab_type)] + changed, actual_tabs = utils.remove_extra_panel_tab(tab_type, course) + self.assertTrue(changed) + self.assertEqual(actual_tabs, expected_tabs) + + # test removing with changed = False + for tab_setup in ['', 'x', 'x,y,z']: + course = self.get_course_with_tabs(tab_setup) + expected_tabs = copy.copy(course.tabs) + changed, actual_tabs = utils.remove_extra_panel_tab(tab_type, course) + self.assertFalse(changed) + self.assertEqual(actual_tabs, expected_tabs) + diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index a5a3b47bce..ea3e3ecd6a 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -9,6 +9,8 @@ DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_ta #In order to instantiate an open ended tab automatically, need to have this data OPEN_ENDED_PANEL = {"name": "Open Ended Panel", "type": "open_ended"} +NOTES_PANEL = {"name": "My Notes", "type": "notes"} +EXTRA_TAB_PANELS = dict([(p['type'], p) for p in [OPEN_ENDED_PANEL, NOTES_PANEL]]) def get_modulestore(location): @@ -192,9 +194,10 @@ class CoursePageNames: Checklists = "checklists" -def add_open_ended_panel_tab(course): +def add_extra_panel_tab(tab_type, course): """ - Used to add the open ended panel tab to a course if it does not exist. + Used to add the panel tab to a course if it does not exist. + @param tab_type: A string representing the tab type. @param course: A course object from the modulestore. @return: Boolean indicating whether or not a tab was added and a list of tabs for the course. """ @@ -202,16 +205,19 @@ def add_open_ended_panel_tab(course): course_tabs = copy.copy(course.tabs) changed = False #Check to see if open ended panel is defined in the course - if OPEN_ENDED_PANEL not in course_tabs: + + tab_panel = EXTRA_TAB_PANELS.get(tab_type) + if tab_panel not in course_tabs: #Add panel to the tabs if it is not defined - course_tabs.append(OPEN_ENDED_PANEL) + course_tabs.append(tab_panel) changed = True return changed, course_tabs -def remove_open_ended_panel_tab(course): +def remove_extra_panel_tab(tab_type, course): """ - Used to remove the open ended panel tab from a course if it exists. + Used to remove the panel tab from a course if it exists. + @param tab_type: A string representing the tab type. @param course: A course object from the modulestore. @return: Boolean indicating whether or not a tab was added and a list of tabs for the course. """ @@ -219,8 +225,10 @@ def remove_open_ended_panel_tab(course): course_tabs = copy.copy(course.tabs) changed = False #Check to see if open ended panel is defined in the course - if OPEN_ENDED_PANEL in course_tabs: + + tab_panel = EXTRA_TAB_PANELS.get(tab_type) + if tab_panel in course_tabs: #Add panel to the tabs if it is not defined - course_tabs = [ct for ct in course_tabs if ct != OPEN_ENDED_PANEL] + course_tabs = [ct for ct in course_tabs if ct != tab_panel] changed = True return changed, course_tabs diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 5127effae6..89b5e8bdc7 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -41,7 +41,8 @@ log = logging.getLogger(__name__) COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video'] OPEN_ENDED_COMPONENT_TYPES = ["combinedopenended", "peergrading"] -ADVANCED_COMPONENT_TYPES = ['annotatable', 'word_cloud'] + OPEN_ENDED_COMPONENT_TYPES +NOTE_COMPONENT_TYPES = ['notes'] +ADVANCED_COMPONENT_TYPES = ['annotatable', 'word_cloud'] + OPEN_ENDED_COMPONENT_TYPES + NOTE_COMPONENT_TYPES ADVANCED_COMPONENT_CATEGORY = 'advanced' ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules' diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index c6fa340f67..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_open_ended_panel_tab, \ - remove_open_ended_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 @@ -32,7 +28,12 @@ from util.json_request import expect_json from .access import has_access, get_location_and_verify_access from .requests import get_request_method from .tabs import initialize_course_tabs -from .component import OPEN_ENDED_COMPONENT_TYPES, ADVANCED_COMPONENT_POLICY_KEY +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', @@ -135,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()})) @@ -352,38 +356,52 @@ def course_advanced_updates(request, org, course, name): request_body = json.loads(request.body) # Whether or not to filter the tabs key out of the settings metadata filter_tabs = True - # Check to see if the user instantiated any advanced components. - # This is a hack to add the open ended panel tab - # to a course automatically if the user has indicated that they want - # to edit the combinedopenended or peergrading - # module, and to remove it if they have removed the open ended elements. + + #Check to see if the user instantiated any advanced components. This is a hack + #that does the following : + # 1) adds/removes the open ended panel tab to a course automatically if the user + # has indicated that they want to edit the combinedopendended or peergrading module + # 2) adds/removes the notes panel tab to a course automatically if the user has + # indicated that they want the notes module enabled in their course + # TODO refactor the above into distinct advanced policy settings if ADVANCED_COMPONENT_POLICY_KEY in request_body: - # Check to see if the user instantiated any open ended components - found_oe_type = False - # Get the course so that we can scrape current tabs + #Get the course so that we can scrape current tabs course_module = modulestore().get_item(location) - for oe_type in OPEN_ENDED_COMPONENT_TYPES: - if oe_type in request_body[ADVANCED_COMPONENT_POLICY_KEY]: - # Add an open ended tab to the course if needed - changed, new_tabs = add_open_ended_panel_tab(course_module) - # If a tab has been added to the course, then send the - # metadata along to CourseMetadata.update_from_json + + #Maps tab types to components + tab_component_map = { + 'open_ended': OPEN_ENDED_COMPONENT_TYPES, + 'notes': NOTE_COMPONENT_TYPES, + } + + #Check to see if the user instantiated any notes or open ended components + for tab_type in tab_component_map.keys(): + component_types = tab_component_map.get(tab_type) + found_ac_type = False + for ac_type in component_types: + if ac_type in request_body[ADVANCED_COMPONENT_POLICY_KEY]: + #Add tab to the course if needed + changed, new_tabs = add_extra_panel_tab(tab_type, course_module) + #If a tab has been added to the course, then send the metadata along to CourseMetadata.update_from_json + if changed: + course_module.tabs = new_tabs + request_body.update({'tabs': new_tabs}) + #Indicate that tabs should not be filtered out of the metadata + filter_tabs = False + #Set this flag to avoid the tab removal code below. + found_ac_type = True + break + #If we did not find a module type in the advanced settings, + # we may need to remove the tab from the course. + if not found_ac_type: + #Remove tab from the course if needed + changed, new_tabs = remove_extra_panel_tab(tab_type, course_module) if changed: + course_module.tabs = new_tabs request_body.update({'tabs': new_tabs}) - # Indicate that tabs should not be filtered out of the metadata + #Indicate that tabs should *not* be filtered out of the metadata filter_tabs = False - # Set this flag to avoid the open ended tab removal code below. - found_oe_type = True - break - # If we did not find an open ended module type in the advanced settings, - # we may need to remove the open ended tab from the course. - if not found_oe_type: - # Remove open ended tab to the course if needed - changed, new_tabs = remove_open_ended_panel_tab(course_module) - if changed: - request_body.update({'tabs': new_tabs}) - # Indicate that tabs should not be filtered out of the metadata - filter_tabs = False + response_json = json.dumps(CourseMetadata.update_from_json(location, request_body, filter_tabs=filter_tabs)) diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 67a4ad4e0c..25094ddcfe 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -113,7 +113,7 @@ def delete_item(request): delete_children = request.POST.get('delete_children', False) delete_all_versions = request.POST.get('delete_all_versions', False) - store = modulestore() + store = get_modulestore(item_location) item = store.get_item(item_location) diff --git a/cms/djangoapps/contentstore/views/public.py b/cms/djangoapps/contentstore/views/public.py index 0433aa9e9d..0ee228b996 100644 --- a/cms/djangoapps/contentstore/views/public.py +++ b/cms/djangoapps/contentstore/views/public.py @@ -8,7 +8,7 @@ from mitxmako.shortcuts import render_to_response from external_auth.views import ssl_login_shortcut from .user import index -__all__ = ['signup', 'old_login_redirect', 'login_page', 'howitworks', 'ux_alerts'] +__all__ = ['signup', 'old_login_redirect', 'login_page', 'howitworks'] """ Public views @@ -49,10 +49,3 @@ def howitworks(request): return index(request) else: return render_to_response('howitworks.html', {}) - - -def ux_alerts(request): - """ - static/proof-of-concept views - """ - return render_to_response('ux-alerts.html', {}) diff --git a/cms/djangoapps/contentstore/views/requests.py b/cms/djangoapps/contentstore/views/requests.py index b02a13fe3f..c493441c77 100644 --- a/cms/djangoapps/contentstore/views/requests.py +++ b/cms/djangoapps/contentstore/views/requests.py @@ -21,7 +21,7 @@ def event(request): A noop to swallow the analytics call so that cms methods don't spook and poor developers looking at console logs don't get distracted :-) ''' - return HttpResponse(True) + return HttpResponse(status=204) def get_request_method(request): diff --git a/cms/envs/acceptance.py b/cms/envs/acceptance.py index 1e7a32dc68..36616ab257 100644 --- a/cms/envs/acceptance.py +++ b/cms/envs/acceptance.py @@ -2,33 +2,52 @@ This config file extends the test environment configuration so that we can run the lettuce acceptance tests. """ + +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + from .test import * # You need to start the server in debug mode, # otherwise the browser will not render the pages correctly DEBUG = True -# Show the courses that are in the data directory -COURSES_ROOT = ENV_ROOT / "data" -DATA_DIR = COURSES_ROOT -# MODULESTORE = { -# 'default': { -# 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', -# 'OPTIONS': { -# 'data_dir': DATA_DIR, -# 'default_class': 'xmodule.hidden_module.HiddenDescriptor', -# } -# } -# } +# Disable warnings for acceptance tests, to make the logs readable +import logging +logging.disable(logging.ERROR) +MODULESTORE_OPTIONS = { + 'default_class': 'xmodule.raw_module.RawDescriptor', + 'host': 'localhost', + 'db': 'test_xmodule', + 'collection': 'acceptance_modulestore', + 'fs_root': TEST_ROOT / "data", + 'render_template': 'mitxmako.shortcuts.render_to_string', +} + +MODULESTORE = { + 'default': { + 'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore', + 'OPTIONS': MODULESTORE_OPTIONS + }, + 'direct': { + 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', + 'OPTIONS': MODULESTORE_OPTIONS + }, + 'draft': { + 'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore', + 'OPTIONS': MODULESTORE_OPTIONS + } +} # Set this up so that rake lms[acceptance] and running the # harvest command both use the same (test) database # which they can flush without messing up your dev db DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ENV_ROOT / "db" / "test_mitx.db", - 'TEST_NAME': ENV_ROOT / "db" / "test_mitx.db", + 'NAME': TEST_ROOT / "db" / "test_mitx.db", + 'TEST_NAME': TEST_ROOT / "db" / "test_mitx.db", } } diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 05c57d8263..9fabb3b9e8 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -1,6 +1,11 @@ """ This is the default template for our main set of AWS servers. """ + +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + import json from .common import * @@ -28,6 +33,48 @@ EMAIL_BACKEND = 'django_ses.SESBackend' SESSION_ENGINE = 'django.contrib.sessions.backends.cache' DEFAULT_FILE_STORAGE = 'storages.backends.s3boto.S3BotoStorage' +###################################### CELERY ################################ + +# Don't use a connection pool, since connections are dropped by ELB. +BROKER_POOL_LIMIT = 0 +BROKER_CONNECTION_TIMEOUT = 1 + +# For the Result Store, use the django cache named 'celery' +CELERY_RESULT_BACKEND = 'cache' +CELERY_CACHE_BACKEND = 'celery' + +# When the broker is behind an ELB, use a heartbeat to refresh the +# connection and to detect if it has been dropped. +BROKER_HEARTBEAT = 10.0 +BROKER_HEARTBEAT_CHECKRATE = 2 + +# Each worker should only fetch one message at a time +CELERYD_PREFETCH_MULTIPLIER = 1 + +# Skip djcelery migrations, since we don't use the database as the broker +SOUTH_MIGRATION_MODULES = { + 'djcelery': 'ignore', +} + +# Rename the exchange and queues for each variant + +QUEUE_VARIANT = CONFIG_PREFIX.lower() + +CELERY_DEFAULT_EXCHANGE = 'edx.{0}core'.format(QUEUE_VARIANT) + +HIGH_PRIORITY_QUEUE = 'edx.{0}core.high'.format(QUEUE_VARIANT) +DEFAULT_PRIORITY_QUEUE = 'edx.{0}core.default'.format(QUEUE_VARIANT) +LOW_PRIORITY_QUEUE = 'edx.{0}core.low'.format(QUEUE_VARIANT) + +CELERY_DEFAULT_QUEUE = DEFAULT_PRIORITY_QUEUE +CELERY_DEFAULT_ROUTING_KEY = DEFAULT_PRIORITY_QUEUE + +CELERY_QUEUES = { + HIGH_PRIORITY_QUEUE: {}, + LOW_PRIORITY_QUEUE: {}, + DEFAULT_PRIORITY_QUEUE: {} +} + ############# NON-SECURE ENV CONFIG ############################## # Things like server locations, ports, etc. with open(ENV_ROOT / CONFIG_PREFIX + "env.json") as env_file: @@ -78,3 +125,14 @@ CONTENTSTORE = AUTH_TOKENS['CONTENTSTORE'] # Datadog for events! DATADOG_API = AUTH_TOKENS.get("DATADOG_API") + +# Celery Broker +CELERY_BROKER_TRANSPORT = ENV_TOKENS.get("CELERY_BROKER_TRANSPORT", "") +CELERY_BROKER_HOSTNAME = ENV_TOKENS.get("CELERY_BROKER_HOSTNAME", "") +CELERY_BROKER_USER = AUTH_TOKENS.get("CELERY_BROKER_USER", "") +CELERY_BROKER_PASSWORD = AUTH_TOKENS.get("CELERY_BROKER_PASSWORD", "") + +BROKER_URL = "{0}://{1}:{2}@{3}".format(CELERY_BROKER_TRANSPORT, + CELERY_BROKER_USER, + CELERY_BROKER_PASSWORD, + CELERY_BROKER_HOSTNAME) diff --git a/cms/envs/common.py b/cms/envs/common.py index a53830082b..9c02d3d279 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -19,6 +19,10 @@ Longer TODO: multiple sites, but we do need a way to map their data assets. """ +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + import sys import lms.envs.common from path import path @@ -34,6 +38,9 @@ MITX_FEATURES = { 'STAFF_EMAIL': '', # email address for staff (eg to request course creation) 'STUDIO_NPS_SURVEY': True, 'SEGMENT_IO': True, + + # Enable URL that shows information about the status of variuous services + 'ENABLE_SERVICE_STATUS': False, } ENABLE_JASMINE = False @@ -214,7 +221,9 @@ PIPELINE_JS = { 'source_filenames': sorted( rooted_glob(COMMON_ROOT / 'static/', 'coffee/src/**/*.js') + rooted_glob(PROJECT_ROOT / 'static/', 'coffee/src/**/*.js') - ) + ['js/hesitate.js', 'js/base.js'], + ) + ['js/hesitate.js', 'js/base.js', + 'js/models/feedback.js', 'js/views/feedback.js', + 'js/models/section.js', 'js/views/section.js'], 'output_filename': 'js/cms-application.js', 'test_order': 0 }, @@ -240,6 +249,51 @@ STATICFILES_IGNORE_PATTERNS = ( PIPELINE_YUI_BINARY = 'yui-compressor' +################################# CELERY ###################################### + +# Message configuration + +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' + +CELERY_MESSAGE_COMPRESSION = 'gzip' + +# Results configuration + +CELERY_IGNORE_RESULT = False +CELERY_STORE_ERRORS_EVEN_IF_IGNORED = True + +# Events configuration + +CELERY_TRACK_STARTED = True + +CELERY_SEND_EVENTS = True +CELERY_SEND_TASK_SENT_EVENT = True + +# Exchange configuration + +CELERY_DEFAULT_EXCHANGE = 'edx.core' +CELERY_DEFAULT_EXCHANGE_TYPE = 'direct' + +# Queues configuration + +HIGH_PRIORITY_QUEUE = 'edx.core.high' +DEFAULT_PRIORITY_QUEUE = 'edx.core.default' +LOW_PRIORITY_QUEUE = 'edx.core.low' + +CELERY_QUEUE_HA_POLICY = 'all' + +CELERY_CREATE_MISSING_QUEUES = True + +CELERY_DEFAULT_QUEUE = DEFAULT_PRIORITY_QUEUE +CELERY_DEFAULT_ROUTING_KEY = DEFAULT_PRIORITY_QUEUE + +CELERY_QUEUES = { + HIGH_PRIORITY_QUEUE: {}, + LOW_PRIORITY_QUEUE: {}, + DEFAULT_PRIORITY_QUEUE: {} +} + ############################ APPS ##################################### INSTALLED_APPS = ( @@ -249,8 +303,12 @@ INSTALLED_APPS = ( 'django.contrib.sessions', 'django.contrib.sites', 'django.contrib.messages', + 'djcelery', 'south', + # Monitor the status of services + 'service_status', + # For CMS 'contentstore', 'auth', @@ -264,4 +322,11 @@ INSTALLED_APPS = ( 'pipeline', 'staticfiles', 'static_replace', + + # comment common + 'django_comment_common', ) + +################# EDX MARKETING SITE ################################## + +EDXMKTG_COOKIE_NAME = 'edxloggedin' diff --git a/cms/envs/dev.py b/cms/envs/dev.py index dbf9c5574c..9acbf84a95 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -1,6 +1,10 @@ """ This config file runs the simplest dev environment""" +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + from .common import * from logsettings import get_logger_config @@ -116,10 +120,14 @@ SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' PIPELINE_SASS_ARGUMENTS = '--debug-info --require {proj_dir}/static/sass/bourbon/lib/bourbon.rb'.format(proj_dir=PROJECT_ROOT) +################################# CELERY ###################################### + +# By default don't use a worker, execute tasks as if they were local functions +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 = ( @@ -151,5 +159,8 @@ DEBUG_TOOLBAR_MONGO_STACKTRACES = True # disable NPS survey in dev mode MITX_FEATURES['STUDIO_NPS_SURVEY'] = False +# Enable URL that shows information about the status of variuous services +MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True + # segment-io key for dev SEGMENT_IO_KEY = 'mty8edrrsg' diff --git a/cms/envs/dev_ike.py b/cms/envs/dev_ike.py index 1ebf219d44..0c798b68aa 100644 --- a/cms/envs/dev_ike.py +++ b/cms/envs/dev_ike.py @@ -1,3 +1,7 @@ +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + # dev environment for ichuang/mit # FORCE_SCRIPT_NAME = '/cms' diff --git a/cms/envs/dev_with_worker.py b/cms/envs/dev_with_worker.py new file mode 100644 index 0000000000..078567c493 --- /dev/null +++ b/cms/envs/dev_with_worker.py @@ -0,0 +1,39 @@ +""" +This config file follows the dev enviroment, but adds the +requirement of a celery worker running in the background to process +celery tasks. + +The worker can be executed using: + +django_admin.py celery worker +""" + +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + +from dev import * + +################################# CELERY ###################################### + +# Requires a separate celery worker + +CELERY_ALWAYS_EAGER = False + +# Use django db as the broker and result store + +BROKER_URL = 'django://' +INSTALLED_APPS += ('djcelery.transport', ) +CELERY_RESULT_BACKEND = 'database' +DJKOMBU_POLLING_INTERVAL = 1.0 + +# Disable transaction management because we are using a worker. Views +# that request a task and wait for the result will deadlock otherwise. + +MIDDLEWARE_CLASSES = tuple( + c for c in MIDDLEWARE_CLASSES + if c != 'django.middleware.transaction.TransactionMiddleware') + +# Note: other alternatives for disabling transactions don't work in 1.4 +# https://code.djangoproject.com/ticket/2304 +# https://code.djangoproject.com/ticket/16039 diff --git a/cms/envs/jasmine.py b/cms/envs/jasmine.py index 6c7cbcdcb0..a4b8292d71 100644 --- a/cms/envs/jasmine.py +++ b/cms/envs/jasmine.py @@ -2,6 +2,10 @@ This configuration is used for running jasmine tests """ +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + from .test import * from logsettings import get_logger_config @@ -32,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 @@ -41,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/envs/test.py b/cms/envs/test.py index 63b5efc645..6d78b0d7d6 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -7,6 +7,11 @@ sessions. Assumes structure: /mitx # The location of this repo /log # Where we're going to write log files """ + +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + from .common import * import os from path import path @@ -41,14 +46,14 @@ MODULESTORE_OPTIONS = { 'default_class': 'xmodule.raw_module.RawDescriptor', 'host': 'localhost', 'db': 'test_xmodule', - 'collection': 'modulestore', - 'fs_root': GITHUB_REPO_ROOT, + 'collection': 'test_modulestore', + 'fs_root': TEST_ROOT / "data", 'render_template': 'mitxmako.shortcuts.render_to_string', } MODULESTORE = { 'default': { - 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', + 'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore', 'OPTIONS': MODULESTORE_OPTIONS }, 'direct': { @@ -108,6 +113,12 @@ CACHES = { } } +################################# CELERY ###################################### + +CELERY_ALWAYS_EAGER = True +CELERY_RESULT_BACKEND = 'cache' +BROKER_TRANSPORT = 'memory' + ################### Make tests faster #http://slacy.com/blog/2012/04/make-your-tests-faster-in-django-1-4/ PASSWORD_HASHERS = ( @@ -121,3 +132,4 @@ SEGMENT_IO_KEY = '***REMOVED***' # disable NPS survey in test mode MITX_FEATURES['STUDIO_NPS_SURVEY'] = False +MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True diff --git a/cms/static/client_templates/checklist.html b/cms/static/client_templates/checklist.html index 6884b0e9c9..e985ab9509 100644 --- a/cms/static/client_templates/checklist.html +++ b/cms/static/client_templates/checklist.html @@ -11,11 +11,11 @@ <%= percentChecked %>% of checklist completed

- + <%= checklistShortDescription %>

Tasks Completed: <%= itemsChecked %>/<%= items.length %> - +
@@ -58,4 +58,4 @@ <% taskIndex+=1; }) %> - \ No newline at end of file + diff --git a/cms/static/coffee/files.json b/cms/static/coffee/files.json index c2e1a8acf6..73dfc565a2 100644 --- a/cms/static/coffee/files.json +++ b/cms/static/coffee/files.json @@ -8,6 +8,8 @@ "js/vendor/json2.js", "js/vendor/underscore-min.js", "js/vendor/backbone-min.js", - "js/vendor/jquery.leanModal.min.js" + "js/vendor/jquery.leanModal.min.js", + "js/vendor/sinon-1.7.1.js", + "js/test/i18n.js" ] } diff --git a/cms/static/coffee/fixtures/section-name-edit.underscore b/cms/static/coffee/fixtures/section-name-edit.underscore new file mode 120000 index 0000000000..89284ccf90 --- /dev/null +++ b/cms/static/coffee/fixtures/section-name-edit.underscore @@ -0,0 +1 @@ +../../../templates/js/section-name-edit.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/system-feedback.underscore b/cms/static/coffee/fixtures/system-feedback.underscore new file mode 120000 index 0000000000..10893f87c4 --- /dev/null +++ b/cms/static/coffee/fixtures/system-feedback.underscore @@ -0,0 +1 @@ +../../../templates/js/system-feedback.underscore \ No newline at end of file diff --git a/cms/static/coffee/spec/helpers.coffee b/cms/static/coffee/spec/helpers.coffee index 91a411a8fc..116983edf5 100644 --- a/cms/static/coffee/spec/helpers.coffee +++ b/cms/static/coffee/spec/helpers.coffee @@ -1,3 +1,5 @@ +jasmine.getFixtures().fixturesPath = 'fixtures' + # Stub jQuery.cookie @stubCookies = csrftoken: "stubCSRFToken" diff --git a/cms/static/coffee/spec/main_spec.coffee b/cms/static/coffee/spec/main_spec.coffee index 8b2fa52866..9383f2547e 100644 --- a/cms/static/coffee/spec/main_spec.coffee +++ b/cms/static/coffee/spec/main_spec.coffee @@ -22,3 +22,37 @@ describe "main helper", -> it "setup AJAX CSRF token", -> expect($.ajaxSettings.headers["X-CSRFToken"]).toEqual("stubCSRFToken") + +describe "AJAX Errors", -> + tpl = readFixtures('system-feedback.underscore') + + beforeEach -> + setFixtures($(" + + ## javascript - - <%static:js group='main'/> <%static:js group='module-js'/> @@ -49,17 +52,16 @@ - + + + +
<%include file="widgets/header.html" /> - <%block name="view_alerts"> - <%block name="view_banners"> +
<%block name="content"> @@ -70,10 +72,10 @@ <%include file="widgets/footer.html" /> <%include file="widgets/tender.html" /> - <%block name="view_notifications"> +
- <%block name="view_prompts"> +
<%block name="jsextra"> 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 @@ - - @@ -53,7 +52,7 @@

Page Actions

diff --git a/cms/templates/edit-tabs.html b/cms/templates/edit-tabs.html index 161c938020..77eb1cb9b8 100644 --- a/cms/templates/edit-tabs.html +++ b/cms/templates/edit-tabs.html @@ -27,7 +27,7 @@

Page Actions

@@ -41,7 +41,7 @@ @@ -76,7 +76,7 @@ - + close modal diff --git a/cms/templates/howitworks.html b/cms/templates/howitworks.html index 6c0029c425..b5acc66339 100644 --- a/cms/templates/howitworks.html +++ b/cms/templates/howitworks.html @@ -28,7 +28,7 @@ Studio Helps You Keep Your Courses Organized
Studio Helps You Keep Your Courses Organized
- + @@ -62,7 +62,7 @@ Learning is More than Just Lectures
Learning is More than Just Lectures
- + @@ -96,7 +96,7 @@ Studio Gives You Simple, Fast, and Incremental Publishing. With Friends.
Studio Gives You Simple, Fast, and Incremental Publishing. With Friends.
- + @@ -132,7 +132,7 @@

Sign Up for Studio Today!

- +
  • Sign Up & Start Making an edX Course @@ -152,7 +152,7 @@ - + close modal @@ -165,7 +165,7 @@ - + close modal @@ -178,8 +178,8 @@ - + close modal - \ No newline at end of file + diff --git a/cms/templates/index.html b/cms/templates/index.html index 007033623d..e7205c9430 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -45,7 +45,7 @@ diff --git a/cms/templates/js/section-name-edit.underscore b/cms/templates/js/section-name-edit.underscore new file mode 100644 index 0000000000..cfbd64fffe --- /dev/null +++ b/cms/templates/js/section-name-edit.underscore @@ -0,0 +1,5 @@ +
    + + " /> + " /> +
    diff --git a/cms/templates/js/system-feedback.underscore b/cms/templates/js/system-feedback.underscore new file mode 100644 index 0000000000..b8ef1b8dc8 --- /dev/null +++ b/cms/templates/js/system-feedback.underscore @@ -0,0 +1,48 @@ +
    aria-describedby="<%= type %>-<%= intent %>-description" <% } %> + <% if (obj.actions) { %>role="dialog"<% } %> + > +
    + <% if(obj.icon) { %> + <% var iconClass = {"warning": "warning-sign", "confirmation": "ok", "error": "warning-sign", "announcement": "bullhorn", "step-required": "exclamation-sign", "help": "question-sign", "saving": "cog"} %> + + <% } %> + +
    +

    <%= title %>

    + <% if(obj.message) { %>

    <%= message %>

    <% } %> +
    + + <% if(obj.actions) { %> + + <% } %> + + <% if(obj.closeIcon) { %> + + + close <%= type %> + + <% } %> +
    +
    diff --git a/cms/templates/manage_users.html b/cms/templates/manage_users.html index 0edf88510c..462dd32c81 100644 --- a/cms/templates/manage_users.html +++ b/cms/templates/manage_users.html @@ -16,7 +16,7 @@ diff --git a/cms/templates/overview.html b/cms/templates/overview.html index fe2636a346..d327c8b324 100644 --- a/cms/templates/overview.html +++ b/cms/templates/overview.html @@ -20,6 +20,12 @@ + + + + @@ -115,13 +129,13 @@

    Page Actions

    @@ -137,14 +151,7 @@
    -

    - ${section.display_name_with_default} - -

    +

    diff --git a/cms/templates/settings_advanced.html b/cms/templates/settings_advanced.html index 858dc9cbf0..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"> - @@ -110,7 +109,7 @@ editor.render();
@@ -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 321be55985..2c6846bece 100644 --- a/cms/templates/settings_graders.html +++ b/cms/templates/settings_graders.html @@ -12,7 +12,6 @@ from contentstore import utils - @@ -117,7 +116,7 @@ from contentstore import utils
  • diff --git a/cms/templates/ux-alerts.html b/cms/templates/ux-alerts.html deleted file mode 100644 index b7e91acae8..0000000000 --- a/cms/templates/ux-alerts.html +++ /dev/null @@ -1,572 +0,0 @@ -<%inherit file="base.html" /> -<%block name="title">Studio Alerts -<%block name="bodyclass">is-signedin course uxdesign alerts - -<%block name="jsextra"> - - - -<%block name="content"> -
    -
    -

    - UX Design - > System Notifications -

    -
    -
    - -
    -
    -
    -
    -
    -

    Alerts

    - persistant, static messages to the user -
    - -

    In Studio, alerts are 1) general warnings/notes (e.g. drafts, published content, next steps) about the current view a user is interacting with or 2) notes about the status (e.g. saved confirmations, errors, next system steps) of any previous state that need to communicated to the user when arriving at the current view.

    - -

    Different Static Examples of Alerts

    -

    Note: alerts will probably never been shown based on click or page action and will primarily be loaded along with a pageload and initial display

    - - -
    - -
    -
    -

    Notifications

    - contextual, feedback-based, and temporal messages to the user -
    - -

    In Studio, notifications are meant to inform the user of 1) any system status (e.g. saving, processing/validating) occurring based on any action they have taken or 2) any decisions (e.g. saving/discarding) a user must make to confirm.

    - -

    Different Static Examples of Notifications

    - - -
    - -
    -
    -

    Prompts

    - presents a user with a choice, based on their previous interaction, that must be decided before they can proceed -
    - -

    In Studio, prompts are dialogs that are presented above all other page components and present a user with a choice, based on their previous interaction, that must be decided before they can proceed (or return to the previous interaction step).

    - -

    Different Static Examples of Prompts

    - - -
    -
    -
    -
    - - -<%block name="view_alerts"> - -
    -
    - - -
    -

    You are editing a draft

    -

    Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa.

    -
    - - -
    -
    - - -
    -
    - - -
    -

    You are editing a draft

    -

    Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa.

    -
    - - -
    -
    - - -
    -
    - - -
    -

    A Newer Version of This Exists

    -

    Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa.

    -
    - - -
    -
    - - -
    -
    - - -
    -

    Your changes have been saved

    -

    Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Please see below

    -
    -
    -
    - - -
    -
    - - -
    -

    Your changes have been saved

    -

    Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Please see below

    -
    - - - - close alert - -
    -
    - - -
    -
    - - -
    -

    X Has been removed

    -

    Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa.

    -
    -
    -
    - - -
    -
    - - -
    -

    We're sorry, there was a error with Studio

    -

    Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa.

    -
    -
    -
    - - -
    -
    - - -
    -

    There was an error in your submission

    -

    Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Please see below

    -
    - - -
    -
    - - -
    -
    - 📢 - -
    -

    Studio will be unavailable this weekend

    -

    Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa.

    -
    - - -
    -
    - - -
    -
    - 📢 - -
    -

    Studio will be unavailable this weekend

    -

    Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa.

    -
    -
    -
    - - -
    -
    - - -
    -

    Your Studio account has been created, but needs to be activated

    -

    Donec sed odio dui. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Etiam porta sem malesuada magna mollis euismod. Cras mattis consectetur purus sit amet fermentum. Curabitur blandit tempus porttitor.

    -
    - - -
    -
    - - -<%block name="view_notifications"> - - - - - - - - - - -
    -
    - - -
    -

    Saving …

    -
    -
    -
    - - -
    -
    - - -
    -

    Your Section Has Been Created

    -
    -
    -
    - - -
    -
    - - -
    -

    Fun Fact:

    -

    Using the checkmark will allow you make a subsection gradable as an assignment, which counts towards a student's total grade

    -
    - - - - close notification - -
    -
    - - -<%block name="view_prompts"> - - - - - - - - - diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 167d5417d7..3c8a598b5e 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -21,7 +21,7 @@
      + %if flag_moderator: +
    1. + + Show Flagged Discussions + +
    2. + + %endif
    3. Following diff --git a/lms/templates/discussion/_underscore_templates.html b/lms/templates/discussion/_underscore_templates.html index 24e3b467be..fcbcf1a52c 100644 --- a/lms/templates/discussion/_underscore_templates.html +++ b/lms/templates/discussion/_underscore_templates.html @@ -3,6 +3,7 @@ + + +
      +
      +

      Log Into Your Account

      +
      +
      + +
      + diff --git a/lms/templates/main.html b/lms/templates/main.html index 42d5a71228..313025d09a 100644 --- a/lms/templates/main.html +++ b/lms/templates/main.html @@ -1,8 +1,18 @@ <%namespace name='static' file='static_content.html'/> +<%! from django.utils import html %> <%block name="title">edX + @@ -48,3 +58,10 @@ <%block name="js_extra"/> + +<%def name="login_query()">${ + "?course_id={0}&enrollment_action={1}".format( + html.escape(course_id), + html.escape(enrollment_action) + ) if course_id and enrollment_action else "" +} diff --git a/lms/templates/mktg_iframe.html b/lms/templates/mktg_iframe.html new file mode 100644 index 0000000000..6d02f3fcc5 --- /dev/null +++ b/lms/templates/mktg_iframe.html @@ -0,0 +1,53 @@ +<%namespace name='static' file='static_content.html'/> + + + + <%block name="title"> + + + + + + <%static:css group='application'/> + <%static:js group='main_vendor'/> + + + + + + <%block name="headextra"/> + + % if not course: + <%include file="google_analytics.html" /> + % endif + + + + + + + + +
      + + <%block name="content"> +
      + + <%static:js group='application'/> + <%block name="js_extra"> + + diff --git a/lms/templates/navigation.html b/lms/templates/navigation.html index 4bb99d1ebd..82d08f6ca9 100644 --- a/lms/templates/navigation.html +++ b/lms/templates/navigation.html @@ -1,8 +1,6 @@ ## mako -## TODO: Split this into two files, one for people who are authenticated, and -## one for people who aren't. Assume a Course object is passed to the former, -## instead of using settings.COURSE_TITLE <%namespace name='static' file='static_content.html'/> +<%namespace file='main.html' import="login_query"/> <%! from django.core.urlresolvers import reverse @@ -38,19 +36,23 @@ site_status_msg = get_site_status_msg(course_id)
      % endif
      % if course: @@ -92,8 +107,6 @@ site_status_msg = get_site_status_msg(course_id) % endif %if not user.is_authenticated(): - <%include file="login_modal.html" /> - <%include file="signup_modal.html" /> <%include file="forgot_password_modal.html" /> %endif diff --git a/lms/templates/notes.html b/lms/templates/notes.html new file mode 100644 index 0000000000..3fea6faa3e --- /dev/null +++ b/lms/templates/notes.html @@ -0,0 +1,81 @@ +<%namespace name='static' file='static_content.html'/> +<%inherit file="main.html" /> +<%! + from django.core.urlresolvers import reverse +%> + +<%block name="headextra"> + <%static:css group='course'/> + <%static:js group='courseware'/> + + + + +<%block name="js_extra"> + + + +<%include file="/courseware/course_navigation.html" args="active_page='notes'" /> + +
      +
      +

      My Notes

      + % for note in notes: +
      +
      ${note.quote|h}
      +
      ${note.text.replace("\n", "
      ") | n,h}
      +
        + % if note.tags: +
      • Tags: ${note.tags|h}
      • + % endif +
      • Author: ${note.user.username}
      • +
      • Created: ${note.created.strftime('%m/%d/%Y %H:%m')}
      • +
      • Source: ${note.uri|h}
      • +
      +
      + % endfor + % if notes is UNDEFINED or len(notes) == 0: +

      You do not have any notes.

      + % endif +
      +
      + + + + diff --git a/lms/templates/register.html b/lms/templates/register.html new file mode 100644 index 0000000000..06b6fe169a --- /dev/null +++ b/lms/templates/register.html @@ -0,0 +1,272 @@ +<%inherit file="main.html" /> + +<%namespace name='static' file='static_content.html'/> +<%namespace file='main.html' import="login_query"/> +<%! from django.core.urlresolvers import reverse %> +<%! from django.utils import html %> +<%! from django_countries.countries import COUNTRIES %> +<%! from student.models import UserProfile %> +<%! from datetime import date %> +<%! import calendar %> + +<%block name="title">Register for edX + +<%block name="js_extra"> + + + +
      +
      +

      Register for edX

      +
      +
      + +
      +
      +
      +

      Registration Form

      +
      + +
      + + + + + + +

      + Please complete the following fields to register for an edX account.
      + Required fields are noted by bold text and an asterisk (*). +

      + +
      + Required Information + + % if has_extauth_info is UNDEFINED: + +
        +
      1. + + +
      2. +
      3. + + +
      4. +
      5. + + + Will be shown in any discussions or forums you participate in +
      6. +
      7. + + + Needed for any certificates you may earn (cannot be changed later) +
      8. +
      + + % else: + +
      +

      Welcome ${extauth_email}

      +

      Enter a public username:

      +
      + +
        +
      1. + + + Will be shown in any discussions or forums you participate in +
      2. +
      + + % endif +
      + +
      + Optional Personal Information + +
        +
      1. +
        + + +
        + +
        + + +
        + +
        + + +
        +
      2. +
      +
      + +
      + Optional Personal Information + +
        +
      1. + + +
      2. + +
      3. + + +
      4. +
      +
      + +
      + Account Acknowledgements + +
        +
      1. +
        + + +
        + +
        + + +
        +
      2. +
      +
      + +% if course_id and enrollment_action: + + +% endif + +
      + +
      +
      +
      + + +
      diff --git a/lms/templates/registration/activation_complete.html b/lms/templates/registration/activation_complete.html index 7d3579b34e..7eb805e730 100644 --- a/lms/templates/registration/activation_complete.html +++ b/lms/templates/registration/activation_complete.html @@ -23,7 +23,7 @@ %if user_logged_in: Visit your dashboard to see your courses. %else: - You can now login. + You can now log in. %endif

    diff --git a/lms/templates/registration/password_reset_complete.html b/lms/templates/registration/password_reset_complete.html index 0338ce57b0..3847f615b9 100644 --- a/lms/templates/registration/password_reset_complete.html +++ b/lms/templates/registration/password_reset_complete.html @@ -1,9 +1,66 @@ {% load i18n %} +{% load compressed %} +{% load staticfiles %} + + + -

    Password reset complete

    + Your Password Reset is Complete -{% block content %} + {% compressed_css 'application' %} -Your password has been set. You may go ahead and log in now. + -{% endblock %} + + + + + + +
    + +
    + +
    +
    +
    +
    +

    Your Password Reset is Complete

    +
    +
    + + {% block content %} +
    +

    Your password has been set. You may go ahead and log in now..

    +
    + {% endblock %} +
    +
    diff --git a/lms/templates/registration/password_reset_confirm.html b/lms/templates/registration/password_reset_confirm.html index e80955180c..5809408dad 100644 --- a/lms/templates/registration/password_reset_confirm.html +++ b/lms/templates/registration/password_reset_confirm.html @@ -1,10 +1,10 @@ {% load compressed %} {% load staticfiles %} - - Reset password - MITx 6.002x + + Reset Your edX Password {% compressed_css 'application' %} @@ -12,55 +12,120 @@ + + - + -
    - -
    +
    + +
    - {% block content %} -
    +
    +
    +
    +
    +

    Reset Your edX Password

    +
    +
    - {% if validlink %} +
    + {% if validlink %} +
    +

    Password Reset Form

    +
    -
    -

    Enter new password

    -
    -
    +
    {% csrf_token %} + + -

    Please enter your new password twice so we can verify you typed it in correctly.

    + - {% csrf_token %} - {{ form.new_password1.errors }} - - {{ form.new_password1 }} + - {{ form.new_password2.errors }} - - {{ form.new_password2 }} +

    + Please enter your new password twice so we can verify you typed it in correctly.
    + Required fields are noted by bold text and an asterisk (*). +

    -
    - +
    + Required Information + +
      +
    1. + + +
    2. +
    3. + + +
    4. +
    +
    + +
    + +
    + + + {% else %} + +
    +

    Your Password Reset Was Unsuccessful

    +
    +

    The password reset link was invalid, possibly because the link has already been used. Please return to the login page and start the password reset process again.

    + + {% endif %} +
    + +
    - {% endblock %} - - - +
    diff --git a/lms/templates/static_htmlbook.html b/lms/templates/static_htmlbook.html index 830ddddca9..8a3c50f680 100644 --- a/lms/templates/static_htmlbook.html +++ b/lms/templates/static_htmlbook.html @@ -26,22 +26,41 @@ // chapters, and it should be in-bounds. chapterToLoad = options.chapterNum; } + var anchorToLoad = null; + if (options.chapters) { + anchorToLoad = options.anchor_id; + } - loadUrl = function htmlViewLoadUrl(url) { + var onComplete = function() {}; + if(options.notesEnabled) { + onComplete = function(url) { + return function() { + $('#viewerContainer').trigger('notes:init', [url]); + } + }; + } + + loadUrl = function htmlViewLoadUrl(url, anchorId) { // clear out previous load, if any: parentElement = document.getElementById('bookpage'); while (parentElement.hasChildNodes()) parentElement.removeChild(parentElement.lastChild); // load new URL in: - $('#bookpage').load(url); - }; + $('#bookpage').load(url, null, onComplete(url)); - loadChapterUrl = function htmlViewLoadChapterUrl(chapterNum) { + // if there is an anchor set, then go to that location: + if (anchorId != null) { + // TODO: add implementation.... + } + + }; + + loadChapterUrl = function htmlViewLoadChapterUrl(chapterNum, anchorId) { if (chapterNum < 1 || chapterNum > chapterUrls.length) { return; } var chapterUrl = chapterUrls[chapterNum-1]; - loadUrl(chapterUrl); + loadUrl(chapterUrl, anchorId); }; // define navigation links for chapters: @@ -54,15 +73,15 @@ }; for (var index = 1; index <= chapterUrls.length; index += 1) { $("#htmlchapter-" + index).click(loadChapterUrlHelper(index)); - } + } } // finally, load the appropriate url/page if (urlToLoad != null) { - loadUrl(urlToLoad); + loadUrl(urlToLoad, anchorToLoad); } else { - loadChapterUrl(chapterToLoad); - } + loadChapterUrl(chapterToLoad, anchorToLoad); + } } })(jQuery); @@ -82,6 +101,14 @@ %if chapter is not None: options.chapterNum = ${chapter}; %endif + %if anchor_id is not UNDEFINED and anchor_id is not None: + options.anchor_id = ${anchor_id}; + %endif + + options.notesEnabled = false; + %if notes_enabled is not UNDEFINED and notes_enabled: + options.notesEnabled = true; + %endif $('#outerContainer').myHTMLViewer(options); }); diff --git a/lms/urls.py b/lms/urls.py index d5b0e46bb9..d1bee076fc 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -8,7 +8,7 @@ from . import one_time_startup import django.contrib.auth.views # Uncomment the next two lines to enable the admin: -if settings.DEBUG: +if settings.DEBUG or settings.MITX_FEATURES.get('ENABLE_DJANGO_ADMIN_SITE'): admin.autodiscover() urlpatterns = ('', # nopep8 @@ -17,6 +17,8 @@ urlpatterns = ('', # nopep8 url(r'^update_certificate$', 'certificates.views.update_certificate'), url(r'^$', 'branding.views.index', name="root"), # Main marketing page, or redirect to courseware url(r'^dashboard$', 'student.views.dashboard', name="dashboard"), + url(r'^login$', 'student.views.signin_user', name="signin_user"), + url(r'^register$', 'student.views.register_user', name="register_user"), url(r'^admin_dashboard$', 'dashboard.views.dashboard'), @@ -35,8 +37,8 @@ urlpatterns = ('', # nopep8 url(r'^accounts/login$', 'student.views.accounts_login', name="accounts_login"), - url(r'^login$', 'student.views.login_user', name="login"), - url(r'^login/(?P[^/]*)$', 'student.views.login_user'), + url(r'^login_ajax$', 'student.views.login_user', name="login"), + url(r'^login_ajax/(?P[^/]*)$', 'student.views.login_user'), url(r'^logout$', 'student.views.logout_user', name='logout'), url(r'^create_account$', 'student.views.create_account'), url(r'^activate/(?P[^/]*)$', 'student.views.activate_account', name="activate"), @@ -177,11 +179,19 @@ if settings.COURSEWARE_ENABLED: url(r'^courses/?$', 'branding.views.courses', name="courses"), url(r'^change_enrollment$', - 'student.views.change_enrollment_view', name="change_enrollment"), + 'student.views.change_enrollment', name="change_enrollment"), #About the course url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/about$', 'courseware.views.course_about', name="about_course"), + #View for mktg site (kept for backwards compatibility TODO - remove before merge to master) + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/mktg-about$', + 'courseware.views.mktg_course_about', name="mktg_about_course"), + #View for mktg site + url(r'^mktg/(?P.*)$', + 'courseware.views.mktg_course_about', name="mktg_about_course"), + + #Inside the course url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/$', @@ -283,6 +293,10 @@ if settings.COURSEWARE_ENABLED: url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/peer_grading$', 'open_ended_grading.views.peer_grading', name='peer_grading'), + + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/notes$', 'notes.views.notes', name='notes'), + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/notes/', include('notes.urls')), + ) # allow course staff to change to student view of courseware @@ -316,7 +330,7 @@ if settings.COURSEWARE_ENABLED: if settings.ENABLE_JASMINE: urlpatterns += (url(r'^_jasmine/', include('django_jasmine.urls')),) -if settings.DEBUG: +if settings.DEBUG or settings.MITX_FEATURES.get('ENABLE_DJANGO_ADMIN_SITE'): ## Jasmine and admin urlpatterns += (url(r'^admin/', include(admin.site.urls)),) @@ -353,12 +367,22 @@ if settings.MITX_FEATURES.get('ENABLE_SQL_TRACKING_LOGS'): url(r'^event_logs/(?P.+)$', 'track.views.view_tracking_log'), ) +if settings.MITX_FEATURES.get('ENABLE_SERVICE_STATUS'): + urlpatterns += ( + url(r'^status/', include('service_status.urls')), + ) + # FoldIt views urlpatterns += ( # The path is hardcoded into their app... url(r'^comm/foldit_ops', 'foldit.views.foldit_ops', name="foldit_ops"), ) +if settings.MITX_FEATURES.get('ENABLE_DEBUG_RUN_PYTHON'): + urlpatterns += ( + url(r'^debug/run_python', 'debug.views.run_python'), + ) + urlpatterns = patterns(*urlpatterns) if settings.DEBUG: 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 similarity index 99% rename from .pylintrc rename to 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/rakefile b/rakefile index cef93e67eb..20101a14db 100644 --- a/rakefile +++ b/rakefile @@ -1,3 +1,4 @@ +require 'json' require 'rake/clean' require './rakefiles/helpers.rb' @@ -7,6 +8,13 @@ end # Build Constants REPO_ROOT = File.dirname(__FILE__) +ENV_ROOT = File.dirname(REPO_ROOT) REPORT_DIR = File.join(REPO_ROOT, "reports") +# Environment constants +SERVICE_VARIANT = ENV['SERVICE_VARIANT'] +CONFIG_PREFIX = SERVICE_VARIANT ? SERVICE_VARIANT + "." : "" +ENV_FILE = File.join(ENV_ROOT, CONFIG_PREFIX + "env.json") +ENV_TOKENS = File.exists?(ENV_FILE) ? JSON.parse(File.read(ENV_FILE)) : {} + task :default => [:test, :pep8, :pylint] diff --git a/rakefiles/assets.rake b/rakefiles/assets.rake index 0954dc9815..c0757b712b 100644 --- a/rakefiles/assets.rake +++ b/rakefiles/assets.rake @@ -1,3 +1,31 @@ +# Theming constants +THEME_NAME = ENV_TOKENS['THEME_NAME'] +USE_CUSTOM_THEME = !(THEME_NAME.nil? || THEME_NAME.empty?) +if USE_CUSTOM_THEME + THEME_ROOT = File.join(ENV_ROOT, "themes", THEME_NAME) + THEME_SASS = File.join(THEME_ROOT, "static", "sass") +end + +# Run the specified file through the Mako templating engine, providing +# the ENV_TOKENS to the templating context. +def preprocess_with_mako(filename) + # simple command-line invocation of Mako engine + mako = "from mako.template import Template;" + + "print Template(filename=\"#{filename}\")" + + # Total hack. It works because a Python dict literal has + # the same format as a JSON object. + ".render(env=#{ENV_TOKENS.to_json});" + + # strip off the .mako extension + output_filename = filename.chomp(File.extname(filename)) + + # just pipe from stdout into the new file, exiting on failure + File.open(output_filename, 'w') do |file| + file.write(`python -c '#{mako}'`) + exit_code = $?.to_i + abort "#{mako} failed with #{exit_code}" if exit_code.to_i != 0 + end +end def xmodule_cmd(watch=false, debug=false) xmodule_cmd = 'xmodule_assets common/static/xmodule' @@ -13,14 +41,36 @@ def xmodule_cmd(watch=false, debug=false) end def coffee_cmd(watch=false, debug=false) - "node_modules/.bin/coffee #{watch ? '--watch' : ''} --compile */static" + if watch + # On OSx, coffee fails with EMFILE when + # trying to watch all of our coffee files at the same + # time. + # + # Ref: https://github.com/joyent/node/issues/2479 + # + # Instead, watch 50 files per process in parallel + cmds = [] + Dir['*/static/**/*.coffee'].each_slice(50) do |coffee_files| + cmds << "node_modules/.bin/coffee --watch --compile #{coffee_files.join(' ')}" + end + cmds + else + 'node_modules/.bin/coffee --compile */static' + end end def sass_cmd(watch=false, debug=false) + sass_load_paths = ["./common/static/sass"] + sass_watch_paths = ["*/static"] + if USE_CUSTOM_THEME + sass_load_paths << THEME_SASS + sass_watch_paths << THEME_SASS + end + "sass #{debug ? '--debug-info' : '--style compressed'} " + - "--load-path ./common/static/sass " + + "--load-path #{sass_load_paths.join(' ')} " + "--require ./common/static/sass/bourbon/lib/bourbon.rb " + - "#{watch ? '--watch' : '--update'} */static" + "#{watch ? '--watch' : '--update'} #{sass_watch_paths.join(' ')}" end desc "Compile all assets" @@ -31,6 +81,13 @@ namespace :assets do desc "Compile all assets in debug mode" multitask :debug + desc "Preprocess all static assets that have the .mako extension" + task :preprocess do + # Run assets through the Mako templating engine. Right now we + # just hardcode the asset filenames. + preprocess_with_mako("lms/static/sass/application.scss.mako") + end + desc "Watch all assets for changes and automatically recompile" task :watch => 'assets:_watch' do puts "Press ENTER to terminate".red @@ -39,11 +96,15 @@ namespace :assets do {:xmodule => :install_python_prereqs, :coffee => :install_node_prereqs, - :sass => :install_ruby_prereqs}.each_pair do |asset_type, prereq_task| + :sass => [:install_ruby_prereqs, :preprocess]}.each_pair do |asset_type, prereq_tasks| desc "Compile all #{asset_type} assets" - task asset_type => prereq_task do + task asset_type => prereq_tasks do cmd = send(asset_type.to_s + "_cmd", watch=false, debug=false) - sh(cmd) + if cmd.kind_of?(Array) + cmd.each {|c| sh(c)} + else + sh(cmd) + end end multitask :all => asset_type @@ -52,7 +113,7 @@ namespace :assets do namespace asset_type do desc "Compile all #{asset_type} assets in debug mode" - task :debug => prereq_task do + task :debug => prereq_tasks do cmd = send(asset_type.to_s + "_cmd", watch=false, debug=true) sh(cmd) end @@ -63,9 +124,13 @@ namespace :assets do $stdin.gets end - task :_watch => prereq_task do + task :_watch => prereq_tasks do cmd = send(asset_type.to_s + "_cmd", watch=true, debug=true) - background_process(cmd) + if cmd.kind_of?(Array) + cmd.each {|c| background_process(c)} + else + background_process(cmd) + end end end end diff --git a/rakefiles/django.rake b/rakefiles/django.rake index 594c7d6ec3..8b42192130 100644 --- a/rakefiles/django.rake +++ b/rakefiles/django.rake @@ -5,7 +5,7 @@ default_options = { task :predjango => :install_python_prereqs do sh("find . -type f -name *.pyc -delete") - sh('pip install -q --no-index -r requirements/local.txt') + sh('pip install -q --no-index -r requirements/edx/local.txt') end @@ -25,6 +25,14 @@ end sh(django_admin(system, args.env, 'runserver', args.options)) end + desc "Start #{system} Celery worker" + task "#{system}_worker", [:options] => [:predjango] do |t, args| + args.with_defaults(:options => default_options[system]) + django_admin = ENV['DJANGO_ADMIN_PATH'] || select_executable('django-admin.py', 'django-admin') + command = 'celery worker' + sh("#{django_admin} #{command} --loglevel=INFO --settings=#{system}.envs.dev_with_worker --pythonpath=. #{args.join(' ')}") + end + # Per environment tasks environments(system).each do |env| desc "Attempt to import the settings file #{system}.envs.#{env} and report any errors" @@ -122,4 +130,4 @@ namespace :cms do "Example: \`rake cms:export COURSE_ID=MITx/12345/name OUTPUT_PATH=foo.tar.gz\`" end end -end \ No newline at end of file +end diff --git a/rakefiles/helpers.rb b/rakefiles/helpers.rb index be5929d805..f344aa2042 100644 --- a/rakefiles/helpers.rb +++ b/rakefiles/helpers.rb @@ -14,16 +14,33 @@ def report_dir_path(dir) return File.join(REPORT_DIR, dir.to_s) end -def when_changed(*files) - Rake::Task[PREREQS_MD5_DIR].invoke - cache_file = File.join(PREREQS_MD5_DIR, files.join('-').gsub(/\W+/, '-')) + '.md5' +def compute_fingerprint(files, dirs) digest = Digest::MD5.new() + + # Digest the contents of all the files. Dir[*files].select{|file| File.file?(file)}.each do |file| digest.file(file) end - if !File.exists?(cache_file) or digest.hexdigest != File.read(cache_file) + + # Digest the names of the files in all the dirs. + dirs.each do |dir| + file_names = Dir.entries(dir).sort.join(" ") + digest.update(file_names) + end + + digest.hexdigest +end + +# Hash the contents of all the files, and the names of files in the dirs. +# Run the block if they've changed. +def when_changed(unchanged_message, files, dirs=[]) + Rake::Task[PREREQS_MD5_DIR].invoke + cache_file = File.join(PREREQS_MD5_DIR, files[0].gsub(/\W+/, '-').sub(/-+$/, '')) + '.md5' + if !File.exists?(cache_file) or compute_fingerprint(files, dirs) != File.read(cache_file) yield - File.write(cache_file, digest.hexdigest) + File.write(cache_file, compute_fingerprint(files, dirs)) + elsif !unchanged_message.empty? + puts unchanged_message end end diff --git a/rakefiles/jasmine.rake b/rakefiles/jasmine.rake index d9b3bee427..4182bef9e2 100644 --- a/rakefiles/jasmine.rake +++ b/rakefiles/jasmine.rake @@ -35,11 +35,20 @@ def django_for_jasmine(system, django_reload) end def template_jasmine_runner(lib) - coffee_files = Dir["#{lib}/**/js/**/*.coffee", "common/static/coffee/src/**/*.coffee"] + case lib + when /common\/lib\/.+/ + coffee_files = Dir["#{lib}/**/js/**/*.coffee", "common/static/coffee/src/**/*.coffee"] + when /common\/static\/coffee/ + coffee_files = Dir["#{lib}/**/*.coffee"] + else + puts('I do not know how to run jasmine tests for #{lib}') + exit + end if !coffee_files.empty? 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") @@ -50,7 +59,8 @@ 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)} - template = ERB.new(File.read("#{lib}/jasmine_test_runner.html.erb")) + 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| f.write(template.result(binding)) @@ -58,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 @@ -70,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| @@ -89,9 +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" => "browse_jasmine_common/static/coffee" + +desc "Use phantomjs to run jasmine tests for discussion from the console" +task "phantomjs_jasmine_discussion" => "phantomjs_jasmine_common/static/coffee" diff --git a/rakefiles/prereqs.rake b/rakefiles/prereqs.rake index 430e650127..ff8b4b8784 100644 --- a/rakefiles/prereqs.rake +++ b/rakefiles/prereqs.rake @@ -1,6 +1,5 @@ require './rakefiles/helpers.rb' - PREREQS_MD5_DIR = ENV["PREREQ_CACHE_DIR"] || File.join(REPO_ROOT, '.prereqs_cache') CLOBBER.include(PREREQS_MD5_DIR) @@ -12,28 +11,33 @@ task :install_prereqs => [:install_node_prereqs, :install_ruby_prereqs, :install desc "Install all node prerequisites for the lms and cms" task :install_node_prereqs => "ws:migrate" do - when_changed('package.json') do + unchanged = 'Node requirements unchanged, nothing to install' + when_changed(unchanged, ['package.json']) do sh('npm install') end unless ENV['NO_PREREQ_INSTALL'] end desc "Install all ruby prerequisites for the lms and cms" task :install_ruby_prereqs => "ws:migrate" do - when_changed('Gemfile') do + unchanged = 'Ruby requirements unchanged, nothing to install' + when_changed(unchanged, ['Gemfile']) do sh('bundle install') end unless ENV['NO_PREREQ_INSTALL'] end desc "Install all python prerequisites for the lms and cms" task :install_python_prereqs => "ws:migrate" do - when_changed('requirements/**') do + site_packages_dir = `python -c 'import os; import distutils.sysconfig as dusc; print dusc.get_python_lib()'`.chomp + 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/base.txt') - sh('pip install --exists-action w -r requirements/post.txt') - # Check for private-requirements.txt: used to install our libs as working dirs, - # or personal-use tools. + 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 + # working dirs, or for personal-use tools. if File.file?("requirements/private.txt") sh('pip install -r requirements/private.txt') end end unless ENV['NO_PREREQ_INSTALL'] -end \ No newline at end of file +end diff --git a/rakefiles/quality.rake b/rakefiles/quality.rake index 00ce627ac5..927f765eb5 100644 --- a/rakefiles/quality.rake +++ b/rakefiles/quality.rake @@ -1,3 +1,20 @@ +def run_pylint(system, report_dir, flags='') + apps = Dir["#{system}", "#{system}/djangoapps/*", "#{system}/lib/*"].map do |app| + File.basename(app) + end.select do |app| + app !=~ /.pyc$/ + end.map do |app| + if app =~ /.py$/ + app.gsub('.py', '') + else + app + end + end + + pythonpath_prefix = "PYTHONPATH=#{system}:#{system}/djangoapps:#{system}/lib:common/djangoapps:common/lib" + sh("#{pythonpath_prefix} pylint #{flags} -f parseable #{apps.join(' ')} | tee #{report_dir}/pylint.report") +end + [:lms, :cms, :common].each do |system| report_dir = report_dir_path(system) @@ -11,21 +28,18 @@ desc "Run pylint on all #{system} code" task "pylint_#{system}" => [report_dir, :install_python_prereqs] do - apps = Dir["#{system}/*.py", "#{system}/djangoapps/*", "#{system}/lib/*"].map do |app| - File.basename(app) - end.select do |app| - app !=~ /.pyc$/ - end.map do |app| - if app =~ /.py$/ - app.gsub('.py', '') - else - app - end - end - - pythonpath_prefix = "PYTHONPATH=#{system}:#{system}/djangoapps:#{system}/lib:common/djangoapps:common/lib" - sh("#{pythonpath_prefix} pylint --rcfile=.pylintrc -f parseable #{apps.join(' ')} | tee #{report_dir}/pylint.report") + run_pylint(system, report_dir) end + namespace "pylint_#{system}" do + desc "Run pylint checking for errors only, and aborting if there are any" + task :errors do + run_pylint(system, report_dir, '-E') + end + end + namespace :pylint do + task :errors => "pylint_#{system}:errors" + end + task :pylint => "pylint_#{system}" end \ No newline at end of file diff --git a/rakefiles/tests.rake b/rakefiles/tests.rake index c3c7c72584..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!" @@ -24,6 +25,23 @@ def run_tests(system, report_dir, stop_on_failure=true) end 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)) +end + + directory REPORT_DIR task :clean_test_files do @@ -37,15 +55,26 @@ 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 + desc "Run acceptance tests" + task "test_acceptance_#{system}", [:harvest_args] => ["#{system}:gather_assets:acceptance", "fasttest_acceptance_#{system}"] + + desc "Run acceptance tests without collectstatic" + task "fasttest_acceptance_#{system}", [:harvest_args] => ["clean_test_files", :predjango, report_dir] do |t, args| + args.with_defaults(:harvest_args => '') + run_acceptance_tests(system, report_dir, args.harvest_args) + end + + task :fasttest => "fasttest_#{system}" TEST_TASK_DIRS << system @@ -82,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 @@ -116,4 +145,4 @@ namespace :coverage do sh("coverage xml -o #{report_dir}/coverage.xml --rcfile=#{dir}/.coveragerc") end end -end \ No newline at end of file +end diff --git a/requirements/edx-sandbox/base.txt b/requirements/edx-sandbox/base.txt new file mode 100644 index 0000000000..d801f46c8e --- /dev/null +++ b/requirements/edx-sandbox/base.txt @@ -0,0 +1 @@ +numpy==1.6.2 diff --git a/requirements/edx-sandbox/local.txt b/requirements/edx-sandbox/local.txt new file mode 100644 index 0000000000..ba24805057 --- /dev/null +++ b/requirements/edx-sandbox/local.txt @@ -0,0 +1,6 @@ +# Install these packages from the edx-platform working tree +# NOTE: if you change code in these packages, you MUST change the version +# number in its setup.py or the code WILL NOT be installed during deploy. +common/lib/calc +common/lib/chem +common/lib/sandbox-packages diff --git a/requirements/edx-sandbox/post.txt b/requirements/edx-sandbox/post.txt new file mode 100644 index 0000000000..218fdf307e --- /dev/null +++ b/requirements/edx-sandbox/post.txt @@ -0,0 +1,3 @@ +# Packages to install in the Python sandbox for secured execution. +scipy==0.11.0 +lxml==3.0.1 diff --git a/requirements/base.txt b/requirements/edx/base.txt similarity index 86% rename from requirements/base.txt rename to requirements/edx/base.txt index f6cc250587..01768bcac9 100644 --- a/requirements/base.txt +++ b/requirements/edx/base.txt @@ -3,8 +3,9 @@ beautifulsoup4==4.1.3 beautifulsoup==3.2.1 boto==2.6.0 +celery==3.0.19 distribute==0.6.28 -django-celery==3.0.11 +django-celery==3.0.17 django-countries==1.5 django-followit==0.0.3 django-keyedcache==1.4-6 @@ -28,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 @@ -42,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 @@ -70,9 +71,10 @@ 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 rednose==0.3 selenium==2.31.0 splinter==0.5.0 @@ -81,6 +83,4 @@ django-jasmine==0.3.2 django_debug_toolbar django-debug-toolbar-mongo -# Install pylint from a specific commit on trunk -# to get the fix for this issue: http://www.logilab.org/ticket/122793 -https://bitbucket.org/logilab/pylint/get/e828cb5.zip +git+https://github.com/mfogel/django-settings-context-processor.git diff --git a/requirements/github.txt b/requirements/edx/github.txt similarity index 80% rename from requirements/github.txt rename to requirements/edx/github.txt index 35ad8af027..f280d66557 100644 --- a/requirements/github.txt +++ b/requirements/edx/github.txt @@ -8,4 +8,5 @@ -e git://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk # Our libraries: --e git+https://github.com/edx/XBlock.git@483e0cb1#egg=XBlock +-e git+https://github.com/edx/XBlock.git@2144a25d#egg=XBlock +-e git+https://github.com/edx/codejail.git@72cf791#egg=codejail diff --git a/requirements/local.txt b/requirements/edx/local.txt similarity index 73% rename from requirements/local.txt rename to requirements/edx/local.txt index 201467d11e..a72f1f6dea 100644 --- a/requirements/local.txt +++ b/requirements/edx/local.txt @@ -1,4 +1,6 @@ # Python libraries to install that are local to the mitx repo +-e common/lib/calc -e common/lib/capa +-e common/lib/chem -e common/lib/xmodule -e . diff --git a/requirements/edx/post.txt b/requirements/edx/post.txt new file mode 100644 index 0000000000..b637b65db0 --- /dev/null +++ b/requirements/edx/post.txt @@ -0,0 +1,2 @@ +# 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 diff --git a/requirements/repo.txt b/requirements/edx/repo.txt similarity index 100% rename from requirements/repo.txt rename to requirements/edx/repo.txt diff --git a/requirements/post.txt b/requirements/post.txt deleted file mode 100644 index e1e26b381a..0000000000 --- a/requirements/post.txt +++ /dev/null @@ -1,6 +0,0 @@ - -# This must be installed after distribute 0.6.28 -MySQL-python==1.2.4c1 - -# This must be installed after numpy -scipy==0.11.0 diff --git a/scripts/runone.py b/scripts/runone.py index 2227ae0adf..a644aa077b 100755 --- a/scripts/runone.py +++ b/scripts/runone.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -from django.core import management import argparse import os @@ -42,21 +41,34 @@ def main(argv): test_py_path = find_full_path(test_py_path) test_spec = "%s:%s.%s" % (test_py_path, test_class, test_method) + settings = None if test_py_path.startswith('cms'): settings = 'cms.envs.test' elif test_py_path.startswith('lms'): settings = 'lms.envs.test' + + if settings: + # Run as a django test suite + from django.core import management + + django_args = ["django-admin.py", "test", "--pythonpath=."] + django_args.append("--settings=%s" % settings) + if args.nocapture: + django_args.append("-s") + django_args.append(test_spec) + + print " ".join(django_args) + management.execute_from_command_line(django_args) else: - raise Exception("Couldn't determine settings to use!") + # Run as a nose test suite + import nose.core + nose_args = ["nosetests"] + if args.nocapture: + nose_args.append("-s") + nose_args.append(test_spec) + print " ".join(nose_args) + nose.core.main(argv=nose_args) - django_args = ["django-admin.py", "test", "--pythonpath=."] - django_args.append("--settings=%s" % settings) - if args.nocapture: - django_args.append("-s") - django_args.append(test_spec) - - print " ".join(django_args) - management.execute_from_command_line(django_args) if __name__ == "__main__": main(sys.argv[1:])