diff --git a/AUTHORS b/AUTHORS index 7d6397629f..9bb4ede121 100644 --- a/AUTHORS +++ b/AUTHORS @@ -75,4 +75,6 @@ Frances Botsford Jonah Stanley Slater Victoroff Peter Fogg -Renzo Lucioni \ No newline at end of file +Bethany LaPenta +Renzo Lucioni +Felix Sun diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 89872937bf..ff900d6161 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,12 +5,85 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +Common: Add tests for documentation generation to test suite + +Blades: User answer now preserved (and changeable) after clicking "show answer" in choice problems + +LMS: Users are no longer auto-activated if they click "reset password" +This is now done when they click on the link in the reset password +email they receive (along with usual path through activation email). + +LMS: Problem rescoring. Added options on the Grades tab of the +Instructor Dashboard to allow a particular student's submission for a +particular problem to be rescored. Provides an option to see a +history of background tasks for a given problem and student. + +Blades: Small UX fix on capa multiple-choice problems. Make labels only +as wide as the text to reduce accidental choice selections. + +Studio: Remove XML from the video component editor. All settings are +moved to be edited as metadata. + +XModule: Only write out assets files if the contents have changed. + +XModule: Don't delete generated xmodule asset files when compiling (for +instance, when XModule provides a coffeescript file, don't delete +the associated javascript) + +Studio: For courses running on edx.org (marketing site), disable fields in +Course Settings that do not apply. + +Common: Make asset watchers run as singletons (so they won't start if the +watcher is already running in another shell). + +Common: Use coffee directly when watching for coffeescript file changes. + +Common: Make rake provide better error messages if packages are missing. + +Common: Repairs development documentation generation by sphinx. + +LMS: Problem rescoring. Added options on the Grades tab of the +Instructor Dashboard to allow all students' submissions for a +particular problem to be rescored. Also supports resetting all +students' number of attempts to zero. Provides a list of background +tasks that are currently running for the course, and an option to +see a history of background tasks for a given problem. + +LMS: Fixed the preferences scope for storing data in xmodules. + +LMS: Forums. Added handling for case where discussion module can get `None` as +value of lms.start in `lms/djangoapps/django_comment_client/utils.py` + +Studio, LMS: Make ModelTypes more strict about their expected content (for +instance, Boolean, Integer, String), but also allow them to hold either the +typed value, or a String that can be converted to their typed value. For example, +an Integer can contain 3 or '3'. This changed an update to the xblock library. + +LMS: Courses whose id matches a regex in the COURSES_WITH_UNSAFE_CODE Django +setting now run entirely outside the Python sandbox. + +Blades: Added tests for Video Alpha player. + +Common: Have the capa module handle unicode better (especially errors) + +Blades: Video Alpha bug fix for speed changing to 1.0 in Firefox. + +Blades: Additional event tracking added to Video Alpha: fullscreen switch, show/hide +captions. + +CMS: Allow editors to delete uploaded files/assets + +XModules: `XModuleDescriptor.__init__` and `XModule.__init__` dropped the +`location` parameter (and added it as a field), and renamed `system` to `runtime`, +to accord more closely to `XBlock.__init__` LMS: Some errors handling Non-ASCII data in XML courses have been fixed. LMS: Add page-load tracking using segment-io (if SEGMENT_IO_LMS_KEY and SEGMENT_IO_LMS feature flag is on) +Blades: Simplify calc.py (which is used for the Numerical/Formula responses); add trig/other functions. + LMS: Background colors on login, register, and courseware have been corrected back to white. @@ -26,7 +99,10 @@ student. Blades: Staff debug info is now accessible for Graphical Slider Tool problems. Blades: For Video Alpha the events ready, play, pause, seek, and speed change -are logged on the server (in the logs). +are logged on the server (in the logs). + +Common: all dates and times are not time zone aware datetimes. No code should create or use struct_times nor naive +datetimes. Common: Developers can now have private Django settings files. @@ -47,7 +123,7 @@ Common: The "duplicate email" error message is more informative. Studio: Component metadata settings editor. -Studio: Autoplay is disabled (only in Studio). +Studio: Autoplay for Video Alpha is disabled (only in Studio). Studio: Single-click creation for video and discussion components. @@ -80,3 +156,5 @@ Common: Updated CodeJail. Common: Allow setting of authentication session cookie name. +LMS: Option to email students when enroll/un-enroll them. + diff --git a/Gemfile b/Gemfile index 7f7b146978..1ad685c34d 100644 --- a/Gemfile +++ b/Gemfile @@ -4,3 +4,4 @@ gem 'sass', '3.1.15' gem 'bourbon', '~> 1.3.6' gem 'colorize', '~> 0.5.8' gem 'launchy', '~> 2.1.2' +gem 'sys-proctable', '~> 0.9.3' diff --git a/cms/CHANGELOG.md b/cms/CHANGELOG.md deleted file mode 100644 index d21d08d23c..0000000000 --- a/cms/CHANGELOG.md +++ /dev/null @@ -1,21 +0,0 @@ -Instructions -============ -For each pull request, add one or more lines to the bottom of the change list. When -code is released to production, change the `Upcoming` entry to todays date, and add -a new block at the bottom of the file. - - Upcoming - -------- - -Change log entries should be targeted at end users. A good place to start is the -user story that instigated the pull request. - - -Changes -======= - -Upcoming --------- -* Fix: Deleting last component in a unit does not work -* Fix: Unit name is editable when a unit is public -* Fix: Visual feedback inconsistent when saving a unit name change diff --git a/cms/djangoapps/auth/authz.py b/cms/djangoapps/auth/authz.py index 71b5e97bc2..a544906875 100644 --- a/cms/djangoapps/auth/authz.py +++ b/cms/djangoapps/auth/authz.py @@ -1,5 +1,6 @@ from django.contrib.auth.models import User, Group from django.core.exceptions import PermissionDenied +from django.conf import settings from xmodule.modulestore import Location @@ -12,6 +13,9 @@ but this implementation should be data compatible with the LMS implementation INSTRUCTOR_ROLE_NAME = 'instructor' STAFF_ROLE_NAME = 'staff' +# This is the group of people who have permission to create new courses on edge or edx. +COURSE_CREATOR_GROUP_NAME = "course_creator_group" + # we're just making a Django group for each location/role combo # to do this we're just creating a Group name which is a formatted string # of those two variables @@ -36,12 +40,10 @@ def get_users_in_course_group_by_role(location, role): return group.user_set.all() -''' -Create all permission groups for a new course and subscribe the caller into those roles -''' - - def create_all_course_groups(creator, location): + """ + Create all permission groups for a new course and subscribe the caller into those roles + """ create_new_course_group(creator, location, INSTRUCTOR_ROLE_NAME) create_new_course_group(creator, location, STAFF_ROLE_NAME) @@ -57,13 +59,11 @@ def create_new_course_group(creator, location, role): return -''' -This is to be called only by either a command line code path or through a app which has already -asserted permissions -''' - - def _delete_course_group(location): + """ + This is to be called only by either a command line code path or through a app which has already + asserted permissions + """ # remove all memberships instructors = Group.objects.get(name=get_course_groupname_for_role(location, INSTRUCTOR_ROLE_NAME)) for user in instructors.user_set.all(): @@ -75,13 +75,11 @@ def _delete_course_group(location): user.groups.remove(staff) user.save() -''' -This is to be called only by either a command line code path or through an app which has already -asserted permissions to do this action -''' - - def _copy_course_group(source, dest): + """ + This is to be called only by either a command line code path or through an app which has already + asserted permissions to do this action + """ instructors = Group.objects.get(name=get_course_groupname_for_role(source, INSTRUCTOR_ROLE_NAME)) new_instructors_group = Group.objects.get(name=get_course_groupname_for_role(dest, INSTRUCTOR_ROLE_NAME)) for user in instructors.user_set.all(): @@ -100,10 +98,34 @@ def add_user_to_course_group(caller, user, location, role): if not is_user_in_course_group_role(caller, location, INSTRUCTOR_ROLE_NAME): raise PermissionDenied - if user.is_active and user.is_authenticated: - groupname = get_course_groupname_for_role(location, role) + group = Group.objects.get(name=get_course_groupname_for_role(location, role)) + return _add_user_to_group(user, group) - group = Group.objects.get(name=groupname) + +def add_user_to_creator_group(caller, user): + """ + Adds the user to the group of course creators. + + The caller must have staff access to perform this operation. + + Note that on the edX site, we currently limit course creators to edX staff, and this + method is a no-op in that environment. + """ + if not caller.is_active or not caller.is_authenticated or not caller.is_staff: + raise PermissionDenied + + (group, created) = Group.objects.get_or_create(name=COURSE_CREATOR_GROUP_NAME) + if created: + group.save() + return _add_user_to_group(user, group) + + +def _add_user_to_group(user, group): + """ + This is to be called only by either a command line code path or through an app which has already + asserted permissions to do this action + """ + if user.is_active and user.is_authenticated: user.groups.add(group) user.save() return True @@ -129,11 +151,29 @@ def remove_user_from_course_group(caller, user, location, role): # see if the user is actually in that role, if not then we don't have to do anything if is_user_in_course_group_role(user, location, role): - groupname = get_course_groupname_for_role(location, role) + _remove_user_from_group(user, get_course_groupname_for_role(location, role)) - group = Group.objects.get(name=groupname) - user.groups.remove(group) - user.save() + +def remove_user_from_creator_group(caller, user): + """ + Removes user from the course creator group. + + The caller must have staff access to perform this operation. + """ + if not caller.is_active or not caller.is_authenticated or not caller.is_staff: + raise PermissionDenied + + _remove_user_from_group(user, COURSE_CREATOR_GROUP_NAME) + + +def _remove_user_from_group(user, group_name): + """ + This is to be called only by either a command line code path or through an app which has already + asserted permissions to do this action + """ + group = Group.objects.get(name=group_name) + user.groups.remove(group) + user.save() def is_user_in_course_group_role(user, location, role): @@ -142,3 +182,26 @@ def is_user_in_course_group_role(user, location, role): return user.is_staff or user.groups.filter(name=get_course_groupname_for_role(location, role)).count() > 0 return False + + +def is_user_in_creator_group(user): + """ + Returns true if the user has permissions to create a course. + + Will always return True if user.is_staff is True. + + Note that on the edX site, we currently limit course creators to edX staff. On + other sites, this method checks that the user is in the course creator group. + """ + if user.is_staff: + return True + + # On edx, we only allow edX staff to create courses. This may be relaxed in the future. + if settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False): + return False + + # Feature flag for using the creator group setting. Will be removed once the feature is complete. + if settings.MITX_FEATURES.get('ENABLE_CREATOR_GROUP', False): + return user.groups.filter(name=COURSE_CREATOR_GROUP_NAME).count() > 0 + + return True diff --git a/cms/djangoapps/auth/tests/test_authz.py b/cms/djangoapps/auth/tests/test_authz.py new file mode 100644 index 0000000000..173155df4c --- /dev/null +++ b/cms/djangoapps/auth/tests/test_authz.py @@ -0,0 +1,176 @@ +""" +Tests authz.py +""" +import mock + +from django.test import TestCase +from django.contrib.auth.models import User +from django.core.exceptions import PermissionDenied + +from auth.authz import add_user_to_creator_group, remove_user_from_creator_group, is_user_in_creator_group,\ + create_all_course_groups, add_user_to_course_group, STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME,\ + is_user_in_course_group_role, remove_user_from_course_group + + +class CreatorGroupTest(TestCase): + """ + Tests for the course creator group. + """ + + def setUp(self): + """ Test case setup """ + self.user = User.objects.create_user('testuser', 'test+courses@edx.org', 'foo') + self.admin = User.objects.create_user('Mark', 'admin+courses@edx.org', 'foo') + self.admin.is_staff = True + + def test_creator_group_not_enabled(self): + """ + Tests that is_user_in_creator_group always returns True if ENABLE_CREATOR_GROUP + and DISABLE_COURSE_CREATION are both not turned on. + """ + self.assertTrue(is_user_in_creator_group(self.user)) + + def test_creator_group_enabled_but_empty(self): + """ Tests creator group feature on, but group empty. """ + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}): + self.assertFalse(is_user_in_creator_group(self.user)) + + # Make user staff. This will cause is_user_in_creator_group to return True. + self.user.is_staff = True + self.assertTrue(is_user_in_creator_group(self.user)) + + def test_creator_group_enabled_nonempty(self): + """ Tests creator group feature on, user added. """ + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}): + self.assertTrue(add_user_to_creator_group(self.admin, self.user)) + self.assertTrue(is_user_in_creator_group(self.user)) + + # check that a user who has not been added to the group still returns false + user_not_added = User.objects.create_user('testuser2', 'test+courses2@edx.org', 'foo2') + self.assertFalse(is_user_in_creator_group(user_not_added)) + + # remove first user from the group and verify that is_user_in_creator_group now returns false + remove_user_from_creator_group(self.admin, self.user) + self.assertFalse(is_user_in_creator_group(self.user)) + + def test_add_user_not_authenticated(self): + """ + Tests that adding to creator group fails if user is not authenticated + """ + self.user.is_authenticated = False + self.assertFalse(add_user_to_creator_group(self.admin, self.user)) + + def test_add_user_not_active(self): + """ + Tests that adding to creator group fails if user is not active + """ + self.user.is_active = False + self.assertFalse(add_user_to_creator_group(self.admin, self.user)) + + def test_course_creation_disabled(self): + """ Tests that the COURSE_CREATION_DISABLED flag overrides course creator group settings. """ + with mock.patch.dict('django.conf.settings.MITX_FEATURES', + {'DISABLE_COURSE_CREATION': True, "ENABLE_CREATOR_GROUP": True}): + # Add user to creator group. + self.assertTrue(add_user_to_creator_group(self.admin, self.user)) + + # DISABLE_COURSE_CREATION overrides (user is not marked as staff). + self.assertFalse(is_user_in_creator_group(self.user)) + + # Mark as staff. Now is_user_in_creator_group returns true. + self.user.is_staff = True + self.assertTrue(is_user_in_creator_group(self.user)) + + # Remove user from creator group. is_user_in_creator_group still returns true because is_staff=True + remove_user_from_creator_group(self.admin, self.user) + self.assertTrue(is_user_in_creator_group(self.user)) + + def test_add_user_to_group_requires_staff_access(self): + with self.assertRaises(PermissionDenied): + self.admin.is_staff = False + add_user_to_creator_group(self.admin, self.user) + + with self.assertRaises(PermissionDenied): + add_user_to_creator_group(self.user, self.user) + + def test_add_user_to_group_requires_active(self): + with self.assertRaises(PermissionDenied): + self.admin.is_active = False + add_user_to_creator_group(self.admin, self.user) + + def test_add_user_to_group_requires_authenticated(self): + with self.assertRaises(PermissionDenied): + self.admin.is_authenticated = False + add_user_to_creator_group(self.admin, self.user) + + def test_remove_user_from_group_requires_staff_access(self): + with self.assertRaises(PermissionDenied): + self.admin.is_staff = False + remove_user_from_creator_group(self.admin, self.user) + + def test_remove_user_from_group_requires_active(self): + with self.assertRaises(PermissionDenied): + self.admin.is_active = False + remove_user_from_creator_group(self.admin, self.user) + + def test_remove_user_from_group_requires_authenticated(self): + with self.assertRaises(PermissionDenied): + self.admin.is_authenticated = False + remove_user_from_creator_group(self.admin, self.user) + + +class CourseGroupTest(TestCase): + """ + Tests for instructor and staff groups for a particular course. + """ + + def setUp(self): + """ Test case setup """ + self.creator = User.objects.create_user('testcreator', 'testcreator+courses@edx.org', 'foo') + self.staff = User.objects.create_user('teststaff', 'teststaff+courses@edx.org', 'foo') + self.location = 'i4x', 'mitX', '101', 'course', 'test' + + def test_add_user_to_course_group(self): + """ + Tests adding user to course group (happy path). + """ + # Create groups for a new course (and assign instructor role to the creator). + self.assertFalse(is_user_in_course_group_role(self.creator, self.location, INSTRUCTOR_ROLE_NAME)) + create_all_course_groups(self.creator, self.location) + self.assertTrue(is_user_in_course_group_role(self.creator, self.location, INSTRUCTOR_ROLE_NAME)) + + # Add another user to the staff role. + self.assertFalse(is_user_in_course_group_role(self.staff, self.location, STAFF_ROLE_NAME)) + self.assertTrue(add_user_to_course_group(self.creator, self.staff, self.location, STAFF_ROLE_NAME)) + self.assertTrue(is_user_in_course_group_role(self.staff, self.location, STAFF_ROLE_NAME)) + + def test_add_user_to_course_group_permission_denied(self): + """ + Verifies PermissionDenied if caller of add_user_to_course_group is not instructor role. + """ + create_all_course_groups(self.creator, self.location) + with self.assertRaises(PermissionDenied): + add_user_to_course_group(self.staff, self.staff, self.location, STAFF_ROLE_NAME) + + def test_remove_user_from_course_group(self): + """ + Tests removing user from course group (happy path). + """ + create_all_course_groups(self.creator, self.location) + + self.assertTrue(add_user_to_course_group(self.creator, self.staff, self.location, STAFF_ROLE_NAME)) + self.assertTrue(is_user_in_course_group_role(self.staff, self.location, STAFF_ROLE_NAME)) + + remove_user_from_course_group(self.creator, self.staff, self.location, STAFF_ROLE_NAME) + self.assertFalse(is_user_in_course_group_role(self.staff, self.location, STAFF_ROLE_NAME)) + + remove_user_from_course_group(self.creator, self.creator, self.location, INSTRUCTOR_ROLE_NAME) + self.assertFalse(is_user_in_course_group_role(self.creator, self.location, INSTRUCTOR_ROLE_NAME)) + + def test_remove_user_from_course_group_permission_denied(self): + """ + Verifies PermissionDenied if caller of remove_user_from_course_group is not instructor role. + """ + create_all_course_groups(self.creator, self.location) + with self.assertRaises(PermissionDenied): + remove_user_from_course_group(self.staff, self.staff, self.location, STAFF_ROLE_NAME) diff --git a/cms/djangoapps/contentstore/features/advanced-settings.feature b/cms/djangoapps/contentstore/features/advanced-settings.feature index 558294e890..13600f2086 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.feature +++ b/cms/djangoapps/contentstore/features/advanced-settings.feature @@ -28,11 +28,18 @@ Feature: Advanced (manual) course policy 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 + When I create a JSON object as a value for "discussion_topics" Then it is displayed as formatted And I reload the page Then it is displayed as formatted + Scenario: Test error if value supplied is of the wrong type + Given I am on the Advanced Course Settings page in Studio + When I create a JSON object as a value for "display_name" + Then I get an error on save + And I reload the page + Then the policy key value is unchanged + 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 eb00c06ba9..1661e1c391 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.py +++ b/cms/djangoapps/contentstore/features/advanced-settings.py @@ -2,13 +2,8 @@ #pylint: disable=W0621 from lettuce import world, step -from common import * -from nose.tools import assert_false, assert_equal - -""" -http://selenium.googlecode.com/svn/trunk/docs/api/py/webdriver/selenium.webdriver.common.keys.html -""" -from selenium.webdriver.common.keys import Keys +from nose.tools import assert_false, assert_equal, assert_regexp_matches, assert_true +from common import type_in_codemirror KEY_CSS = '.key input.policy-key' VALUE_CSS = 'textarea.json' @@ -32,19 +27,21 @@ def i_am_on_advanced_course_settings(step): @step(u'I press the "([^"]*)" notification button$') def press_the_notification_button(step, name): - css = 'a.%s-button' % name.lower() - world.css_click(css) + css = 'a.action-%s' % name.lower() + + # Save was clicked if either the save notification bar is gone, or we have a error notification + # overlaying it (expected in the case of typing Object into display_name). + def save_clicked(): + confirmation_dismissed = world.is_css_not_present('.is-shown.wrapper-notification-warning') + error_showing = world.is_css_present('.is-shown.wrapper-notification-error') + return confirmation_dismissed or error_showing + + assert_true(world.css_click(css, success_condition=save_clicked), 'Save button not clicked after 5 attempts.') @step(u'I edit the value of a policy key$') def edit_the_value_of_a_policy_key(step): - """ - It is hard to figure out how to get into the CodeMirror - area, so cheat and do it from the policy key field :) - """ - world.css_find(".CodeMirror")[get_index_of(DISPLAY_NAME_KEY)].click() - g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea") - g._element.send_keys(Keys.ARROW_LEFT, ' ', 'X') + type_in_codemirror(get_index_of(DISPLAY_NAME_KEY), 'X') @step(u'I edit the value of a policy key and save$') @@ -52,9 +49,9 @@ def edit_the_value_of_a_policy_key_and_save(step): change_display_name_value(step, '"foo"') -@step('I create a JSON object as a value$') -def create_JSON_object(step): - change_display_name_value(step, '{"key": "value", "key_2": "value_2"}') +@step('I create a JSON object as a value for "(.*)"$') +def create_JSON_object(step, key): + change_value(step, key, '{"key": "value", "key_2": "value_2"}') @step('I create a non-JSON value not in quotes$') @@ -82,7 +79,12 @@ def they_are_alphabetized(step): @step('it is displayed as formatted$') def it_is_formatted(step): - assert_policy_entries([DISPLAY_NAME_KEY], ['{\n "key": "value",\n "key_2": "value_2"\n}']) + assert_policy_entries(['discussion_topics'], ['{\n "key": "value",\n "key_2": "value_2"\n}']) + + +@step('I get an error on save$') +def error_on_save(step): + assert_regexp_matches(world.css_text('#notification-error-description'), 'Incorrect setting format') @step('it is displayed as a string') @@ -124,12 +126,9 @@ def get_display_name_value(): def change_display_name_value(step, new_value): + change_value(step, DISPLAY_NAME_KEY, new_value) - world.css_find(".CodeMirror")[get_index_of(DISPLAY_NAME_KEY)].click() - g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea") - display_name = get_display_name_value() - for count in range(len(display_name)): - g._element.send_keys(Keys.END, Keys.BACK_SPACE) - # Must delete "" before typing the JSON value - g._element.send_keys(Keys.END, Keys.BACK_SPACE, Keys.BACK_SPACE, new_value) + +def change_value(step, key, new_value): + type_in_codemirror(get_index_of(key), new_value) press_the_notification_button(step, "Save") diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index 494192ad06..bdf07fc5ae 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -1,9 +1,8 @@ -#pylint: disable=C0111 -#pylint: disable=W0621 +# pylint: disable=C0111 +# pylint: disable=W0621 from lettuce import world, step from nose.tools import assert_true -from nose.tools import assert_equal from auth.authz import get_user_by_email @@ -13,10 +12,15 @@ import time from logging import getLogger logger = getLogger(__name__) +_COURSE_NAME = 'Robot Super Course' +_COURSE_NUM = '999' +_COURSE_ORG = 'MITx' + ########### STEP HELPERS ############## + @step('I (?:visit|access|open) the Studio homepage$') -def i_visit_the_studio_homepage(step): +def i_visit_the_studio_homepage(_step): # To make this go to port 8001, put # LETTUCE_SERVER_PORT = 8001 # in your settings.py file. @@ -26,17 +30,17 @@ def i_visit_the_studio_homepage(step): @step('I am logged into Studio$') -def i_am_logged_into_studio(step): +def i_am_logged_into_studio(_step): log_into_studio() @step('I confirm the alert$') -def i_confirm_with_ok(step): +def i_confirm_with_ok(_step): world.browser.get_alert().accept() @step(u'I press the "([^"]*)" delete icon$') -def i_press_the_category_delete_icon(step, category): +def i_press_the_category_delete_icon(_step, category): if category == 'section': css = 'a.delete-button.delete-section-button span.delete-icon' elif category == 'subsection': @@ -47,13 +51,14 @@ def i_press_the_category_delete_icon(step, category): @step('I have opened a new course in Studio$') -def i_have_opened_a_new_course(step): +def i_have_opened_a_new_course(_step): open_new_course() ####### HELPER FUNCTIONS ############## def open_new_course(): world.clear_courses() + create_studio_user() log_into_studio() create_a_course() @@ -75,9 +80,9 @@ def create_studio_user( def fill_in_course_info( - name='Robot Super Course', - org='MITx', - num='101'): + name=_COURSE_NAME, + org=_COURSE_ORG, + num=_COURSE_NUM): world.css_fill('.new-course-name', name) world.css_fill('.new-course-org', org) world.css_fill('.new-course-number', num) @@ -86,10 +91,7 @@ def fill_in_course_info( def log_into_studio( uname='robot', email='robot+studio@edx.org', - password='test', - is_staff=False): - - create_studio_user(uname=uname, email=email, is_staff=is_staff) + password='test'): world.browser.cookies.delete() world.visit('/') @@ -107,14 +109,14 @@ def log_into_studio( def create_a_course(): - c = world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') + world.CourseFactory.create(org=_COURSE_ORG, course=_COURSE_NUM, display_name=_COURSE_NAME) # Add the user to the instructor group of the course # so they will have the permissions to see it in studio - g = world.GroupFactory.create(name='instructor_MITx/999/Robot_Super_Course') - u = get_user_by_email('robot+studio@edx.org') - u.groups.add(g) - u.save() + course = world.GroupFactory.create(name='instructor_MITx/{course_num}/{course_name}'.format(course_num=_COURSE_NUM, course_name=_COURSE_NAME.replace(" ", "_"))) + user = get_user_by_email('robot+studio@edx.org') + user.groups.add(course) + user.save() world.browser.reload() course_link_css = 'span.class-name' @@ -147,6 +149,7 @@ def set_date_and_time(date_css, desired_date, time_css, desired_time): world.css_fill(date_css, desired_date) # hit TAB to get to the time field e = world.css_find(date_css).first + # pylint: disable=W0212 e._element.send_keys(Keys.TAB) world.css_fill(time_css, desired_time) e = world.css_find(time_css).first @@ -169,3 +172,24 @@ def open_new_unit(step): step.given('I have added a new subsection') step.given('I expand the first section') world.css_click('a.new-unit-item') + + +@step('when I view the video it (.*) show the captions') +def shows_captions(step, show_captions): + # Prevent cookies from overriding course settings + world.browser.cookies.delete('hide_captions') + if show_captions == 'does not': + assert world.css_find('.video')[0].has_class('closed') + else: + assert world.is_css_not_present('.video.closed') + + +def type_in_codemirror(index, text): + world.css_click(".CodeMirror", index=index) + g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea") + if world.is_mac(): + g._element.send_keys(Keys.COMMAND + 'a') + else: + g._element.send_keys(Keys.CONTROL + 'a') + g._element.send_keys(Keys.DELETE) + g._element.send_keys(text) diff --git a/cms/djangoapps/contentstore/features/course-team.feature b/cms/djangoapps/contentstore/features/course-team.feature new file mode 100644 index 0000000000..fc1212f398 --- /dev/null +++ b/cms/djangoapps/contentstore/features/course-team.feature @@ -0,0 +1,34 @@ +Feature: Course Team + As a course author, I want to be able to add others to my team + + Scenario: Users can add other users + Given I have opened a new course in Studio + And the user "alice" exists + And I am viewing the course team settings + When I add "alice" to the course team + And "alice" logs in + Then she does see the course on her page + + Scenario: Added users cannot delete or add other users + Given I have opened a new course in Studio + And the user "bob" exists + And I am viewing the course team settings + When I add "bob" to the course team + And "bob" logs in + Then he cannot delete users + And he cannot add users + + Scenario: Users can delete other users + Given I have opened a new course in Studio + And the user "carol" exists + And I am viewing the course team settings + When I add "carol" to the course team + And I delete "carol" from the course team + And "carol" logs in + Then she does not see the course on her page + + Scenario: Users cannot add users that do not exist + Given I have opened a new course in Studio + And I am viewing the course team settings + When I add "dennis" to the course team + Then I should see "Could not find user by email address" somewhere on the page diff --git a/cms/djangoapps/contentstore/features/course-team.py b/cms/djangoapps/contentstore/features/course-team.py new file mode 100644 index 0000000000..c126773db6 --- /dev/null +++ b/cms/djangoapps/contentstore/features/course-team.py @@ -0,0 +1,67 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step +from common import create_studio_user, log_into_studio, _COURSE_NAME + +PASSWORD = 'test' +EMAIL_EXTENSION = '@edx.org' + + +@step(u'I am viewing the course team settings') +def view_grading_settings(_step): + world.click_course_settings() + link_css = 'li.nav-course-settings-team a' + world.css_click(link_css) + + +@step(u'the user "([^"]*)" exists$') +def create_other_user(_step, name): + create_studio_user(uname=name, password=PASSWORD, email=(name + EMAIL_EXTENSION)) + + +@step(u'I add "([^"]*)" to the course team') +def add_other_user(_step, name): + new_user_css = 'a.new-user-button' + world.css_click(new_user_css) + + email_css = 'input.email-input' + f = world.css_find(email_css) + f._element.send_keys(name, EMAIL_EXTENSION) + + confirm_css = '#add_user' + world.css_click(confirm_css) + + +@step(u'I delete "([^"]*)" from the course team') +def delete_other_user(_step, name): + to_delete_css = 'a.remove-user[data-id="{name}{extension}"]'.format(name=name, extension=EMAIL_EXTENSION) + world.css_click(to_delete_css) + + +@step(u'"([^"]*)" logs in$') +def other_user_login(_step, name): + log_into_studio(uname=name, password=PASSWORD, email=name + EMAIL_EXTENSION) + + +@step(u's?he does( not)? see the course on (his|her) page') +def see_course(_step, doesnt_see_course, gender): + class_css = 'span.class-name' + all_courses = world.css_find(class_css) + all_names = [item.html for item in all_courses] + if doesnt_see_course: + assert not _COURSE_NAME in all_names + else: + assert _COURSE_NAME in all_names + + +@step(u's?he cannot delete users') +def cannot_delete(_step): + to_delete_css = 'a.remove-user' + assert world.is_css_not_present(to_delete_css) + + +@step(u's?he cannot add users') +def cannot_add(_step): + add_css = 'a.new-user' + assert world.is_css_not_present(add_css) diff --git a/cms/djangoapps/contentstore/features/course-updates.feature b/cms/djangoapps/contentstore/features/course-updates.feature new file mode 100644 index 0000000000..81714c43ae --- /dev/null +++ b/cms/djangoapps/contentstore/features/course-updates.feature @@ -0,0 +1,37 @@ +Feature: Course updates + As a course author, I want to be able to provide updates to my students + + Scenario: Users can add updates + Given I have opened a new course in Studio + And I go to the course updates page + When I add a new update with the text "Hello" + Then I should see the update "Hello" + + Scenario: Users can edit updates + Given I have opened a new course in Studio + And I go to the course updates page + When I add a new update with the text "Hello" + And I modify the text to "Goodbye" + Then I should see the update "Goodbye" + + Scenario: Users can delete updates + Given I have opened a new course in Studio + And I go to the course updates page + And I add a new update with the text "Hello" + When I will confirm all alerts + And I delete the update + Then I should not see the update "Hello" + + + Scenario: Users can edit update dates + Given I have opened a new course in Studio + And I go to the course updates page + And I add a new update with the text "Hello" + When I edit the date to "June 1, 2013" + Then I should see the date "June 1, 2013" + + Scenario: Users can change handouts + Given I have opened a new course in Studio + And I go to the course updates page + When I modify the handout to "
    Test
" + Then I see the handout "Test" diff --git a/cms/djangoapps/contentstore/features/course-updates.py b/cms/djangoapps/contentstore/features/course-updates.py new file mode 100644 index 0000000000..d838061698 --- /dev/null +++ b/cms/djangoapps/contentstore/features/course-updates.py @@ -0,0 +1,84 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step +from selenium.webdriver.common.keys import Keys +from common import type_in_codemirror + + +@step(u'I go to the course updates page') +def go_to_updates(_step): + menu_css = 'li.nav-course-courseware' + updates_css = 'li.nav-course-courseware-updates' + world.css_click(menu_css) + world.css_click(updates_css) + + +@step(u'I add a new update with the text "([^"]*)"$') +def add_update(_step, text): + update_css = 'a.new-update-button' + world.css_click(update_css) + change_text(text) + + +@step(u'I should( not)? see the update "([^"]*)"$') +def check_update(_step, doesnt_see_update, text): + update_css = 'div.update-contents' + update = world.css_find(update_css) + if doesnt_see_update: + assert len(update) == 0 or not text in update.html + else: + assert text in update.html + + +@step(u'I modify the text to "([^"]*)"$') +def modify_update(_step, text): + button_css = 'div.post-preview a.edit-button' + world.css_click(button_css) + change_text(text) + + +@step(u'I delete the update$') +def click_button(_step): + button_css = 'div.post-preview a.delete-button' + world.css_click(button_css) + + +@step(u'I edit the date to "([^"]*)"$') +def change_date(_step, new_date): + button_css = 'div.post-preview a.edit-button' + world.css_click(button_css) + date_css = 'input.date' + date = world.css_find(date_css) + for i in range(len(date.value)): + date._element.send_keys(Keys.END, Keys.BACK_SPACE) + date._element.send_keys(new_date) + save_css = 'a.save-button' + world.css_click(save_css) + + +@step(u'I should see the date "([^"]*)"$') +def check_date(_step, date): + date_css = 'span.date-display' + date_html = world.css_find(date_css) + assert date == date_html.html + + +@step(u'I modify the handout to "([^"]*)"$') +def edit_handouts(_step, text): + edit_css = 'div.course-handouts > a.edit-button' + world.css_click(edit_css) + change_text(text) + + +@step(u'I see the handout "([^"]*)"$') +def check_handout(_step, handout): + handout_css = 'div.handouts-content' + handouts = world.css_find(handout_css) + assert handout in handouts.html + + +def change_text(text): + type_in_codemirror(0, text) + save_css = 'a.save-button' + world.css_click(save_css) diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py index a3e838a9d1..5b279d402f 100644 --- a/cms/djangoapps/contentstore/features/courses.py +++ b/cms/djangoapps/contentstore/features/courses.py @@ -10,6 +10,7 @@ from common import * @step('There are no courses$') def no_courses(step): world.clear_courses() + create_studio_user() @step('I click the New Course button$') diff --git a/cms/djangoapps/contentstore/features/grading.feature b/cms/djangoapps/contentstore/features/grading.feature new file mode 100644 index 0000000000..78634cb964 --- /dev/null +++ b/cms/djangoapps/contentstore/features/grading.feature @@ -0,0 +1,53 @@ +Feature: Course Grading + As a course author, I want to be able to configure how my course is graded + + Scenario: Users can add grading ranges + Given I have opened a new course in Studio + And I am viewing the grading settings + When I add "1" new grade + Then I see I now have "3" grades + + Scenario: Users can only have up to 5 grading ranges + Given I have opened a new course in Studio + And I am viewing the grading settings + When I add "6" new grades + Then I see I now have "5" grades + + #Cannot reliably make the delete button appear so using javascript instead + Scenario: Users can delete grading ranges + Given I have opened a new course in Studio + And I am viewing the grading settings + When I add "1" new grade + And I delete a grade + Then I see I now have "2" grades + + Scenario: Users can move grading ranges + Given I have opened a new course in Studio + And I am viewing the grading settings + When I move a grading section + Then I see that the grade range has changed + + Scenario: Users can modify Assignment types + Given I have opened a new course in Studio + And I have populated the course + And I am viewing the grading settings + When I change assignment type "Homework" to "New Type" + And I go back to the main course page + Then I do see the assignment name "New Type" + And I do not see the assignment name "Homework" + + Scenario: Users can delete Assignment types + Given I have opened a new course in Studio + And I have populated the course + And I am viewing the grading settings + When I delete the assignment type "Homework" + And I go back to the main course page + Then I do not see the assignment name "Homework" + + Scenario: Users can add Assignment types + Given I have opened a new course in Studio + And I have populated the course + And I am viewing the grading settings + When I add a new assignment type "New Type" + And I go back to the main course page + Then I do see the assignment name "New Type" diff --git a/cms/djangoapps/contentstore/features/grading.py b/cms/djangoapps/contentstore/features/grading.py new file mode 100644 index 0000000000..4e59897c1c --- /dev/null +++ b/cms/djangoapps/contentstore/features/grading.py @@ -0,0 +1,108 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step +from common import * + + +@step(u'I am viewing the grading settings') +def view_grading_settings(step): + world.click_course_settings() + link_css = 'li.nav-course-settings-grading a' + world.css_click(link_css) + + +@step(u'I add "([^"]*)" new grade') +def add_grade(step, many): + grade_css = '.new-grade-button' + for i in range(int(many)): + world.css_click(grade_css) + + +@step(u'I delete a grade') +def delete_grade(step): + #grade_css = 'li.grade-specific-bar > a.remove-button' + #range_css = '.grade-specific-bar' + #world.css_find(range_css)[1].mouseover() + #world.css_click(grade_css) + world.browser.execute_script('document.getElementsByClassName("remove-button")[0].click()') + + +@step(u'I see I now have "([^"]*)" grades$') +def view_grade_slider(step, how_many): + grade_slider_css = '.grade-specific-bar' + all_grades = world.css_find(grade_slider_css) + assert len(all_grades) == int(how_many) + + +@step(u'I move a grading section') +def move_grade_slider(step): + moveable_css = '.ui-resizable-e' + f = world.css_find(moveable_css).first + f.action_chains.drag_and_drop_by_offset(f._element, 100, 0).perform() + + +@step(u'I see that the grade range has changed') +def confirm_change(step): + range_css = '.range' + all_ranges = world.css_find(range_css) + for i in range(len(all_ranges)): + assert all_ranges[i].html != '0-50' + + +@step(u'I change assignment type "([^"]*)" to "([^"]*)"$') +def change_assignment_name(step, old_name, new_name): + name_id = '#course-grading-assignment-name' + index = get_type_index(old_name) + f = world.css_find(name_id)[index] + assert index != -1 + for count in range(len(old_name)): + f._element.send_keys(Keys.END, Keys.BACK_SPACE) + f._element.send_keys(new_name) + + +@step(u'I go back to the main course page') +def main_course_page(step): + main_page_link_css = 'a[href="/MITx/999/course/Robot_Super_Course"]' + world.css_click(main_page_link_css) + + +@step(u'I do( not)? see the assignment name "([^"]*)"$') +def see_assignment_name(step, do_not, name): + assignment_menu_css = 'ul.menu > li > a' + assignment_menu = world.css_find(assignment_menu_css) + allnames = [item.html for item in assignment_menu] + if do_not: + assert not name in allnames + else: + assert name in allnames + + +@step(u'I delete the assignment type "([^"]*)"$') +def delete_assignment_type(step, to_delete): + delete_css = '.remove-grading-data' + world.css_click(delete_css, index=get_type_index(to_delete)) + + +@step(u'I add a new assignment type "([^"]*)"$') +def add_assignment_type(step, new_name): + add_button_css = '.add-grading-data' + world.css_click(add_button_css) + name_id = '#course-grading-assignment-name' + f = world.css_find(name_id)[4] + f._element.send_keys(new_name) + + +@step(u'I have populated the course') +def populate_course(step): + step.given('I have added a new section') + step.given('I have added a new subsection') + + +def get_type_index(name): + name_id = '#course-grading-assignment-name' + f = world.css_find(name_id) + for i in range(len(f)): + if f[i].value == name: + return i + return -1 diff --git a/cms/djangoapps/contentstore/features/problem-editor.feature b/cms/djangoapps/contentstore/features/problem-editor.feature index bde350d8a3..cc1d766d2e 100644 --- a/cms/djangoapps/contentstore/features/problem-editor.feature +++ b/cms/djangoapps/contentstore/features/problem-editor.feature @@ -3,65 +3,71 @@ Feature: Problem Editor Scenario: User can view metadata Given I have created a Blank Common Problem - And I edit and select Settings + When I edit and select Settings Then I see five alphabetized settings and their expected values And Edit High Level Source is not visible Scenario: User can modify String values Given I have created a Blank Common Problem - And I edit and select Settings + When I edit and select Settings Then I can modify the display name And my display name change is persisted on save Scenario: User can specify special characters in String values Given I have created a Blank Common Problem - And I edit and select Settings + When I edit and select Settings Then I can specify special characters in the display name And my special characters and persisted on save Scenario: User can revert display name to unset Given I have created a Blank Common Problem - And I edit and select Settings + When I edit and select Settings Then I can revert the display name to unset And my display name is unset on save Scenario: User can select values in a Select Given I have created a Blank Common Problem - And I edit and select Settings + When I edit and select Settings Then I can select Per Student for Randomization And my change to randomization is persisted And I can revert to the default value for randomization Scenario: User can modify float input values Given I have created a Blank Common Problem - And I edit and select Settings + When I edit and select Settings Then I can set the weight to "3.5" And my change to weight is persisted And I can revert to the default value of unset for weight Scenario: User cannot type letters in float number field Given I have created a Blank Common Problem - And I edit and select Settings + When I edit and select Settings Then if I set the weight to "abc", it remains unset Scenario: User cannot type decimal values integer number field Given I have created a Blank Common Problem - And I edit and select Settings + When I edit and select Settings Then if I set the max attempts to "2.34", it displays initially as "234", and is persisted as "234" Scenario: User cannot type out of range values in an integer number field Given I have created a Blank Common Problem - And I edit and select Settings + When I edit and select Settings Then if I set the max attempts to "-3", it displays initially as "-3", and is persisted as "0" Scenario: Settings changes are not saved on Cancel Given I have created a Blank Common Problem - And I edit and select Settings + When I edit and select Settings Then I can set the weight to "3.5" And I can modify the display name Then If I press Cancel my changes are not persisted Scenario: Edit High Level source is available for LaTeX problem Given I have created a LaTeX Problem - And I edit and select Settings + When I edit and select Settings Then Edit High Level Source is visible + + Scenario: High Level source is persisted for LaTeX problem (bug STUD-280) + Given I have created a LaTeX Problem + When I edit and compile the High Level Source + Then my change to the High Level Source is persisted + And when I view the High Level Source I see my changes diff --git a/cms/djangoapps/contentstore/features/problem-editor.py b/cms/djangoapps/contentstore/features/problem-editor.py index 5dfcf55046..8691a6772e 100644 --- a/cms/djangoapps/contentstore/features/problem-editor.py +++ b/cms/djangoapps/contentstore/features/problem-editor.py @@ -3,6 +3,7 @@ from lettuce import world, step from nose.tools import assert_equal +from common import type_in_codemirror DISPLAY_NAME = "Display Name" MAXIMUM_ATTEMPTS = "Maximum Attempts" @@ -41,7 +42,9 @@ def i_see_five_settings_with_values(step): @step('I can modify the display name') def i_can_modify_the_display_name(step): - world.get_setting_entry(DISPLAY_NAME).find_by_css('.setting-input')[0].fill('modified') + # Verifying that the display name can be a string containing a floating point value + # (to confirm that we don't throw an error because it is of the wrong type). + world.get_setting_entry(DISPLAY_NAME).find_by_css('.setting-input')[0].fill('3.4') verify_modified_display_name() @@ -133,12 +136,12 @@ def set_the_max_attempts(step, max_attempts_set, max_attempts_displayed, max_att @step('Edit High Level Source is not visible') def edit_high_level_source_not_visible(step): - verify_high_level_source(step, False) + verify_high_level_source_links(step, False) @step('Edit High Level Source is visible') -def edit_high_level_source_visible(step): - verify_high_level_source(step, True) +def edit_high_level_source_links_visible(step): + verify_high_level_source_links(step, True) @step('If I press Cancel my changes are not persisted') @@ -151,13 +154,33 @@ def cancel_does_not_save_changes(step): @step('I have created a LaTeX Problem') def create_latex_problem(step): world.click_new_component_button(step, '.large-problem-icon') - # Go to advanced tab (waiting for the tab to be visible) - world.css_find('#ui-id-2') + # Go to advanced tab. world.css_click('#ui-id-2') world.click_component_from_menu("i4x://edx/templates/problem/Problem_Written_in_LaTeX", '.xmodule_CapaModule') -def verify_high_level_source(step, visible): +@step('I edit and compile the High Level Source') +def edit_latex_source(step): + open_high_level_source() + type_in_codemirror(1, "hi") + world.css_click('.hls-compile') + + +@step('my change to the High Level Source is persisted') +def high_level_source_persisted(step): + def verify_text(driver): + return world.css_find('.problem').text == 'hi' + + world.wait_for(verify_text) + + +@step('I view the High Level Source I see my changes') +def high_level_source_in_editor(step): + open_high_level_source() + assert_equal('hi', world.css_find('.source-edit-box').value) + + +def verify_high_level_source_links(step, visible): assert_equal(visible, world.is_css_present('.launch-latex-compiler')) world.cancel_component(step) assert_equal(visible, world.is_css_present('.upload-button')) @@ -172,7 +195,7 @@ def verify_modified_randomization(): def verify_modified_display_name(): - world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, 'modified', True) + world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, '3.4', True) def verify_modified_display_name_with_special_chars(): @@ -185,3 +208,8 @@ def verify_unset_display_name(): def set_weight(weight): world.get_setting_entry(PROBLEM_WEIGHT).find_by_css('.setting-input')[0].fill(weight) + + +def open_high_level_source(): + world.css_click('a.edit-button') + world.css_click('.launch-latex-compiler > a') diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py index 9d63fa73c8..989c73e010 100644 --- a/cms/djangoapps/contentstore/features/section.py +++ b/cms/djangoapps/contentstore/features/section.py @@ -1,5 +1,5 @@ -#pylint: disable=C0111 -#pylint: disable=W0621 +# pylint: disable=C0111 +# pylint: disable=W0621 from lettuce import world, step from common import * @@ -8,7 +8,7 @@ from nose.tools import assert_equal ############### ACTIONS #################### -@step('I click the new section link$') +@step('I click the New Section link$') def i_click_new_section_link(_step): link_css = 'a.new-courseware-section-button' world.css_click(link_css) diff --git a/cms/djangoapps/contentstore/features/static-pages.feature b/cms/djangoapps/contentstore/features/static-pages.feature new file mode 100644 index 0000000000..9997df69f0 --- /dev/null +++ b/cms/djangoapps/contentstore/features/static-pages.feature @@ -0,0 +1,24 @@ +Feature: Static Pages + As a course author, I want to be able to add static pages + + Scenario: Users can add static pages + Given I have opened a new course in Studio + And I go to the static pages page + When I add a new page + Then I should see a "Empty" static page + + Scenario: Users can delete static pages + Given I have opened a new course in Studio + And I go to the static pages page + And I add a new page + When I will confirm all alerts + And I "delete" the "Empty" page + Then I should not see a "Empty" static page + + Scenario: Users can edit static pages + Given I have opened a new course in Studio + And I go to the static pages page + And I add a new page + When I "edit" the "Empty" page + And I change the name to "New" + Then I should see a "New" static page diff --git a/cms/djangoapps/contentstore/features/static-pages.py b/cms/djangoapps/contentstore/features/static-pages.py new file mode 100644 index 0000000000..a16a3246da --- /dev/null +++ b/cms/djangoapps/contentstore/features/static-pages.py @@ -0,0 +1,59 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step +from selenium.webdriver.common.keys import Keys + + +@step(u'I go to the static pages page') +def go_to_static(_step): + menu_css = 'li.nav-course-courseware' + static_css = 'li.nav-course-courseware-pages' + world.css_find(menu_css).click() + world.css_find(static_css).click() + + +@step(u'I add a new page') +def add_page(_step): + button_css = 'a.new-button' + world.css_find(button_css).click() + + +@step(u'I should( not)? see a "([^"]*)" static page$') +def see_page(_step, doesnt, page): + index = get_index(page) + if doesnt: + assert index == -1 + else: + assert index != -1 + + +@step(u'I "([^"]*)" the "([^"]*)" page$') +def click_edit_delete(_step, edit_delete, page): + button_css = 'a.%s-button' % edit_delete + index = get_index(page) + assert index != -1 + world.css_find(button_css)[index].click() + + +@step(u'I change the name to "([^"]*)"$') +def change_name(_step, new_name): + settings_css = '#settings-mode' + world.css_find(settings_css).click() + input_css = 'input.setting-input' + name_input = world.css_find(input_css) + old_name = name_input.value + for count in range(len(old_name)): + name_input._element.send_keys(Keys.END, Keys.BACK_SPACE) + name_input._element.send_keys(new_name) + save_button = 'a.save-button' + world.css_find(save_button).click() + + +def get_index(name): + page_name_css = 'section[data-type="HTMLModule"]' + all_pages = world.css_find(page_name_css) + for i in range(len(all_pages)): + if all_pages[i].html == '\n {name}\n'.format(name=name): + return i + return -1 diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py index 3a39f3cc15..1fbd965871 100644 --- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py +++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py @@ -1,5 +1,5 @@ -#pylint: disable=C0111 -#pylint: disable=W0621 +# pylint: disable=C0111 +# pylint: disable=W0621 from lettuce import world, step from common import * @@ -50,7 +50,8 @@ def have_a_course_with_two_sections(step): @step(u'I navigate to the course overview page$') def navigate_to_the_course_overview_page(step): - log_into_studio(is_staff=True) + create_studio_user(is_staff=True) + log_into_studio() course_locator = '.class-name' world.css_click(course_locator) diff --git a/cms/djangoapps/contentstore/features/upload.feature b/cms/djangoapps/contentstore/features/upload.feature new file mode 100644 index 0000000000..b3c1fc2ce3 --- /dev/null +++ b/cms/djangoapps/contentstore/features/upload.feature @@ -0,0 +1,38 @@ +Feature: Upload Files + As a course author, I want to be able to upload files for my students + + Scenario: Users can upload files + Given I have opened a new course in Studio + And I go to the files and uploads page + When I upload the file "test" + Then I should see the file "test" was uploaded + And The url for the file "test" is valid + + Scenario: Users can update files + Given I have opened a new course in studio + And I go to the files and uploads page + When I upload the file "test" + And I upload the file "test" + Then I should see only one "test" + + Scenario: Users can delete uploaded files + Given I have opened a new course in studio + And I go to the files and uploads page + When I upload the file "test" + And I delete the file "test" + Then I should not see the file "test" was uploaded + + Scenario: Users can download files + Given I have opened a new course in studio + And I go to the files and uploads page + When I upload the file "test" + Then I can download the correct "test" file + + Scenario: Users can download updated files + Given I have opened a new course in studio + And I go to the files and uploads page + When I upload the file "test" + And I modify "test" + And I reload the page + And I upload the file "test" + Then I can download the correct "test" file diff --git a/cms/djangoapps/contentstore/features/upload.py b/cms/djangoapps/contentstore/features/upload.py new file mode 100644 index 0000000000..258fc5ebcf --- /dev/null +++ b/cms/djangoapps/contentstore/features/upload.py @@ -0,0 +1,108 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step +from django.conf import settings +import requests +import string +import random +import os + +TEST_ROOT = settings.COMMON_TEST_DATA_ROOT +HTTP_PREFIX = "http://localhost:8001" + + +@step(u'I go to the files and uploads page') +def go_to_uploads(_step): + menu_css = 'li.nav-course-courseware' + uploads_css = 'li.nav-course-courseware-uploads' + world.css_find(menu_css).click() + world.css_find(uploads_css).click() + + +@step(u'I upload the file "([^"]*)"$') +def upload_file(_step, file_name): + upload_css = 'a.upload-button' + world.css_find(upload_css).click() + + file_css = 'input.file-input' + upload = world.css_find(file_css) + #uploading the file itself + path = os.path.join(TEST_ROOT, 'uploads/', file_name) + upload._element.send_keys(os.path.abspath(path)) + + close_css = 'a.close-button' + world.css_find(close_css).click() + + +@step(u'I should( not)? see the file "([^"]*)" was uploaded$') +def check_upload(_step, do_not_see_file, file_name): + index = get_index(file_name) + if do_not_see_file: + assert index == -1 + else: + assert index != -1 + + +@step(u'The url for the file "([^"]*)" is valid$') +def check_url(_step, file_name): + r = get_file(file_name) + assert r.status_code == 200 + + +@step(u'I delete the file "([^"]*)"$') +def delete_file(_step, file_name): + index = get_index(file_name) + assert index != -1 + delete_css = "a.remove-asset-button" + world.css_click(delete_css, index=index) + + prompt_confirm_css = 'li.nav-item > a.action-primary' + world.css_click(prompt_confirm_css) + + +@step(u'I should see only one "([^"]*)"$') +def no_duplicate(_step, file_name): + names_css = 'td.name-col > a.filename' + all_names = world.css_find(names_css) + only_one = False + for i in range(len(all_names)): + if file_name == all_names[i].html: + only_one = not only_one + assert only_one + + +@step(u'I can download the correct "([^"]*)" file$') +def check_download(_step, file_name): + path = os.path.join(TEST_ROOT, 'uploads/', file_name) + with open(os.path.abspath(path), 'r') as cur_file: + cur_text = cur_file.read() + r = get_file(file_name) + downloaded_text = r.text + assert cur_text == downloaded_text + + +@step(u'I modify "([^"]*)"$') +def modify_upload(_step, file_name): + new_text = ''.join(random.choice(string.ascii_uppercase + string.digits) for x in range(10)) + path = os.path.join(TEST_ROOT, 'uploads/', file_name) + with open(os.path.abspath(path), 'w') as cur_file: + cur_file.write(new_text) + + +def get_index(file_name): + names_css = 'td.name-col > a.filename' + all_names = world.css_find(names_css) + for i in range(len(all_names)): + if file_name == all_names[i].html: + return i + return -1 + + +def get_file(file_name): + index = get_index(file_name) + assert index != -1 + + url_css = 'input.embeddable-xml-input' + url = world.css_find(url_css)[index].value + return requests.get(HTTP_PREFIX + url) diff --git a/cms/djangoapps/contentstore/features/video-editor.feature b/cms/djangoapps/contentstore/features/video-editor.feature index 4c2a460042..f28ee568dc 100644 --- a/cms/djangoapps/contentstore/features/video-editor.feature +++ b/cms/djangoapps/contentstore/features/video-editor.feature @@ -4,10 +4,20 @@ Feature: Video Component Editor Scenario: User can view metadata Given I have created a Video component And I edit and select Settings - Then I see only the Video display name setting + Then I see the correct settings and default values Scenario: User can modify display name Given I have created a Video component And I edit and select Settings Then I can modify the display name And my display name change is persisted on save + + Scenario: Captions are hidden when "show captions" is false + Given I have created a Video component + And I have set "show captions" to False + Then when I view the video it does not show the captions + + Scenario: Captions are shown when "show captions" is true + Given I have created a Video component + And I have set "show captions" to True + Then when I view the video it does show the captions diff --git a/cms/djangoapps/contentstore/features/video-editor.py b/cms/djangoapps/contentstore/features/video-editor.py index 27423575c3..a6865fdd6d 100644 --- a/cms/djangoapps/contentstore/features/video-editor.py +++ b/cms/djangoapps/contentstore/features/video-editor.py @@ -1,9 +1,23 @@ # disable missing docstring -#pylint: disable=C0111 +# pylint: disable=C0111 from lettuce import world, step -@step('I see only the video display name setting$') -def i_see_only_the_video_display_name(step): - world.verify_all_setting_entries([['Display Name', "default", True]]) +@step('I see the correct settings and default values$') +def i_see_the_correct_settings_and_values(step): + world.verify_all_setting_entries([['Default Speed', 'OEoXaMPEzfM', False], + ['Display Name', 'default', True], + ['Download Track', '', False], + ['Download Video', '', False], + ['Show Captions', 'True', False], + ['Speed: .75x', '', False], + ['Speed: 1.25x', '', False], + ['Speed: 1.5x', '', False]]) + + +@step('I have set "show captions" to (.*)') +def set_show_captions(step, setting): + world.css_click('a.edit-button') + world.browser.select('Show Captions', setting) + world.css_click('a.save-button') diff --git a/cms/djangoapps/contentstore/features/video.feature b/cms/djangoapps/contentstore/features/video.feature index 0129732d30..e4caa70ef6 100644 --- a/cms/djangoapps/contentstore/features/video.feature +++ b/cms/djangoapps/contentstore/features/video.feature @@ -9,7 +9,16 @@ Feature: Video Component Given I have clicked the new unit button Then creating a video takes a single click - Scenario: Captions are shown correctly + Scenario: Captions are hidden correctly Given I have created a Video component And I have hidden captions Then when I view the video it does not show the captions + + Scenario: Captions are shown correctly + Given I have created a Video component + Then when I view the video it does show the captions + + Scenario: Captions are toggled correctly + Given I have created a Video component + And I have toggled captions + Then when I view the video it does show the captions diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py index fd8624999e..190f8e9f1e 100644 --- a/cms/djangoapps/contentstore/features/video.py +++ b/cms/djangoapps/contentstore/features/video.py @@ -6,23 +6,28 @@ from lettuce import world, step @step('when I view the video it does not have autoplay enabled') -def does_not_autoplay(step): +def does_not_autoplay(_step): assert world.css_find('.video')[0]['data-autoplay'] == 'False' assert world.css_find('.video_control')[0].has_class('play') @step('creating a video takes a single click') -def video_takes_a_single_click(step): +def video_takes_a_single_click(_step): assert(not world.is_css_present('.xmodule_VideoModule')) world.css_click("a[data-location='i4x://edx/templates/video/default']") assert(world.is_css_present('.xmodule_VideoModule')) -@step('I have hidden captions') -def set_show_captions_false(step): - world.css_click('a.hide-subtitles') - - -@step('when I view the video it does not show the captions') -def does_not_show_captions(step): - assert world.css_find('.video')[0].has_class('closed') +@step('I have (hidden|toggled) captions') +def hide_or_show_captions(step, shown): + button_css = 'a.hide-subtitles' + if shown == 'hidden': + world.css_click(button_css) + if shown == 'toggled': + world.css_click(button_css) + # When we click the first time, a tooltip shows up. We want to + # click the button rather than the tooltip, so move the mouse + # away to make it disappear. + button = world.css_find(button_css) + button.mouse_out() + world.css_click(button_css) diff --git a/cms/djangoapps/contentstore/management/commands/empty_asset_trashcan.py b/cms/djangoapps/contentstore/management/commands/empty_asset_trashcan.py new file mode 100644 index 0000000000..9af3277a2b --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/empty_asset_trashcan.py @@ -0,0 +1,25 @@ +from django.core.management.base import BaseCommand, CommandError +from xmodule.course_module import CourseDescriptor +from xmodule.contentstore.utils import empty_asset_trashcan +from xmodule.modulestore.django import modulestore +from .prompt import query_yes_no + + +class Command(BaseCommand): + help = '''Empty the trashcan. Can pass an optional course_id to limit the damage.''' + + def handle(self, *args, **options): + if len(args) != 1 and len(args) != 0: + raise CommandError("empty_asset_trashcan requires one or no arguments: ||") + + locs = [] + + if len(args) == 1: + locs.append(CourseDescriptor.id_to_location(args[0])) + else: + courses = modulestore('direct').get_courses() + for course in courses: + locs.append(course.location) + + if query_yes_no("Emptying trashcan. Confirm?", default="no"): + empty_asset_trashcan(locs) diff --git a/cms/djangoapps/contentstore/management/commands/restore_asset_from_trashcan.py b/cms/djangoapps/contentstore/management/commands/restore_asset_from_trashcan.py new file mode 100644 index 0000000000..6770bfaf44 --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/restore_asset_from_trashcan.py @@ -0,0 +1,13 @@ +from django.core.management.base import BaseCommand, CommandError +from xmodule.contentstore.utils import restore_asset_from_trashcan + + +class Command(BaseCommand): + help = '''Restore a deleted asset from the trashcan back to it's original course''' + + def handle(self, *args, **options): + if len(args) != 1 and len(args) != 0: + raise CommandError("restore_asset_from_trashcan requires one argument: ") + + restore_asset_from_trashcan(args[0]) + diff --git a/cms/djangoapps/contentstore/module_info_model.py b/cms/djangoapps/contentstore/module_info_model.py index f7d1bbd8fe..726d4bb0ce 100644 --- a/cms/djangoapps/contentstore/module_info_model.py +++ b/cms/djangoapps/contentstore/module_info_model.py @@ -5,10 +5,7 @@ from xmodule.modulestore import Location def get_module_info(store, location, parent_location=None, rewrite_static_links=False): try: - if location.revision is None: - module = store.get_item(location) - else: - module = store.get_item(location) + module = store.get_item(location) except ItemNotFoundError: # create a new one template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty']) @@ -39,10 +36,7 @@ def get_module_info(store, location, parent_location=None, rewrite_static_links= def set_module_info(store, location, post_data): module = None try: - if location.revision is None: - module = store.get_item(location) - else: - module = store.get_item(location) + module = store.get_item(location) except: pass diff --git a/cms/djangoapps/contentstore/tests/test_checklists.py b/cms/djangoapps/contentstore/tests/test_checklists.py index f0889b0861..52e9ba14fe 100644 --- a/cms/djangoapps/contentstore/tests/test_checklists.py +++ b/cms/djangoapps/contentstore/tests/test_checklists.py @@ -19,6 +19,23 @@ class ChecklistTestCase(CourseTestCase): modulestore = get_modulestore(self.course.location) return modulestore.get_item(self.course.location).checklists + def compare_checklists(self, persisted, request): + """ + Handles url expansion as possible difference and descends into guts + :param persisted: + :param request: + """ + self.assertEqual(persisted['short_description'], request['short_description']) + compare_urls = (persisted.get('action_urls_expanded') == request.get('action_urls_expanded')) + for pers, req in zip(persisted['items'], request['items']): + self.assertEqual(pers['short_description'], req['short_description']) + self.assertEqual(pers['long_description'], req['long_description']) + self.assertEqual(pers['is_checked'], req['is_checked']) + if compare_urls: + self.assertEqual(pers['action_url'], req['action_url']) + self.assertEqual(pers['action_text'], req['action_text']) + self.assertEqual(pers['action_external'], req['action_external']) + def test_get_checklists(self): """ Tests the get checklists method. """ checklists_url = get_url_reverse('Checklists', self.course) @@ -31,9 +48,9 @@ class ChecklistTestCase(CourseTestCase): self.course.checklists = None modulestore = get_modulestore(self.course.location) modulestore.update_metadata(self.course.location, own_metadata(self.course)) - self.assertEquals(self.get_persisted_checklists(), None) + self.assertEqual(self.get_persisted_checklists(), None) response = self.client.get(checklists_url) - self.assertEquals(payload, response.content) + self.assertEqual(payload, response.content) def test_update_checklists_no_index(self): """ No checklist index, should return all of them. """ @@ -43,7 +60,8 @@ class ChecklistTestCase(CourseTestCase): 'name': self.course.location.name}) returned_checklists = json.loads(self.client.get(update_url).content) - self.assertListEqual(self.get_persisted_checklists(), returned_checklists) + for pay, resp in zip(self.get_persisted_checklists(), returned_checklists): + self.compare_checklists(pay, resp) def test_update_checklists_index_ignored_on_get(self): """ Checklist index ignored on get. """ @@ -53,7 +71,8 @@ class ChecklistTestCase(CourseTestCase): 'checklist_index': 1}) returned_checklists = json.loads(self.client.get(update_url).content) - self.assertListEqual(self.get_persisted_checklists(), returned_checklists) + for pay, resp in zip(self.get_persisted_checklists(), returned_checklists): + self.compare_checklists(pay, resp) def test_update_checklists_post_no_index(self): """ No checklist index, will error on post. """ @@ -78,13 +97,18 @@ class ChecklistTestCase(CourseTestCase): 'course': self.course.location.course, 'name': self.course.location.name, 'checklist_index': 2}) + + def get_first_item(checklist): + return checklist['items'][0] + payload = self.course.checklists[2] - self.assertFalse(payload.get('is_checked')) - payload['is_checked'] = True + self.assertFalse(get_first_item(payload).get('is_checked')) + get_first_item(payload)['is_checked'] = True returned_checklist = json.loads(self.client.post(update_url, json.dumps(payload), "application/json").content) - self.assertTrue(returned_checklist.get('is_checked')) - self.assertEqual(self.get_persisted_checklists()[2], returned_checklist) + self.assertTrue(get_first_item(returned_checklist).get('is_checked')) + pers = self.get_persisted_checklists() + self.compare_checklists(pers[2], returned_checklist) def test_update_checklists_delete_unsupported(self): """ Delete operation is not supported. """ @@ -93,4 +117,4 @@ class ChecklistTestCase(CourseTestCase): 'name': self.course.location.name, 'checklist_index': 100}) response = self.client.delete(update_url) - self.assertContains(response, 'Unsupported request', status_code=400) \ No newline at end of file + self.assertContains(response, 'Unsupported request', status_code=400) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 03449fc22f..b946aac6bb 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1,5 +1,6 @@ import json import shutil +import mock from django.test.client import Client from django.test.utils import override_settings from django.conf import settings @@ -16,6 +17,8 @@ from django.dispatch import Signal from contentstore.utils import get_modulestore from contentstore.tests.utils import parse_json +from auth.authz import add_user_to_creator_group + from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory @@ -23,11 +26,13 @@ from xmodule.modulestore import Location from xmodule.modulestore.store_utilities import clone_course from xmodule.modulestore.store_utilities import delete_course from xmodule.modulestore.django import modulestore -from xmodule.contentstore.django import contentstore +from xmodule.contentstore.django import contentstore, _CONTENTSTORE from xmodule.templates import update_templates from xmodule.modulestore.xml_exporter import export_to_xml from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint from xmodule.modulestore.inheritance import own_metadata +from xmodule.contentstore.content import StaticContent +from xmodule.contentstore.utils import restore_asset_from_trashcan, empty_asset_trashcan from xmodule.capa_module import CapaDescriptor from xmodule.course_module import CourseDescriptor @@ -35,15 +40,18 @@ from xmodule.seq_module import SequenceDescriptor from xmodule.modulestore.exceptions import ItemNotFoundError from contentstore.views.component import ADVANCED_COMPONENT_TYPES +from xmodule.exceptions import NotFoundError from django_comment_common.utils import are_permissions_roles_seeded from xmodule.exceptions import InvalidVersionError import datetime from pytz import UTC +from uuid import uuid4 +from pymongo import MongoClient -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') + +TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) +TEST_DATA_CONTENTSTORE['OPTIONS']['db'] = 'test_xcontent_%s' % uuid4().hex class MongoCollectionFindWrapper(object): @@ -56,13 +64,16 @@ class MongoCollectionFindWrapper(object): return self.original(query, *args, **kwargs) -@override_settings(MODULESTORE=TEST_DATA_MODULESTORE) +@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) class ContentStoreToyCourseTest(ModuleStoreTestCase): """ Tests that rely on the toy courses. TODO: refactor using CourseFactory so they do not. """ def setUp(self): + + settings.MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data') + settings.MODULESTORE['direct']['OPTIONS']['fs_root'] = path('common/test/data') uname = 'testuser' email = 'test+courses@edx.org' password = 'foo' @@ -80,6 +91,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.client = Client() self.client.login(username=uname, password=password) + def tearDown(self): + mongo = MongoClient() + mongo.drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db']) + _CONTENTSTORE.clear() + def check_components_on_page(self, component_types, expected_types): """ Ensure that the right types end up on the page. @@ -129,7 +145,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # just pick one vertical descriptor = store.get_items(Location('i4x', 'edX', 'simple', 'vertical', None, None))[0] - location = descriptor.location._replace(name='.' + descriptor.location.name) + location = descriptor.location.replace(name='.' + descriptor.location.name) resp = self.client.get(reverse('edit_unit', kwargs={'location': location.url()})) self.assertEqual(resp.status_code, 400) @@ -221,7 +237,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): draft_store.clone_item(html_module.location, html_module.location) html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None]) - new_graceperiod = timedelta(**{'hours': 1}) + new_graceperiod = timedelta(hours=1) self.assertNotIn('graceperiod', own_metadata(html_module)) html_module.lms.graceperiod = new_graceperiod @@ -366,7 +382,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ''' 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') @@ -382,6 +397,157 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): course = module_store.get_item(source_location) self.assertFalse(course.hide_progress_tab) + def test_asset_import(self): + ''' + This test validates that an image asset is imported and a thumbnail was generated for a .gif + ''' + content_store = contentstore() + + module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full'], static_content_store=content_store) + + course_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') + course = module_store.get_item(course_location) + + self.assertIsNotNone(course) + + # make sure we have some assets in our contentstore + all_assets = content_store.get_all_content_for_course(course_location) + self.assertGreater(len(all_assets), 0) + + # make sure we have some thumbnails in our contentstore + content_store.get_all_content_thumbnails_for_course(course_location) + + # + # cdodge: temporarily comment out assertion on thumbnails because many environments + # will not have the jpeg converter installed and this test will fail + # + # + # self.assertGreater(len(all_thumbnails), 0) + + content = None + try: + location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif') + content = content_store.find(location) + except NotFoundError: + pass + + self.assertIsNotNone(content) + + # + # cdodge: temporarily comment out assertion on thumbnails because many environments + # will not have the jpeg converter installed and this test will fail + # + # self.assertIsNotNone(content.thumbnail_location) + # + # thumbnail = None + # try: + # thumbnail = content_store.find(content.thumbnail_location) + # except: + # pass + # + # self.assertIsNotNone(thumbnail) + + def test_asset_delete_and_restore(self): + ''' + This test will exercise the soft delete/restore functionality of the assets + ''' + content_store = contentstore() + trash_store = contentstore('trashcan') + module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full'], static_content_store=content_store) + + # look up original (and thumbnail) in content store, should be there after import + location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif') + content = content_store.find(location, throw_on_not_found=False) + thumbnail_location = content.thumbnail_location + self.assertIsNotNone(content) + + # + # cdodge: temporarily comment out assertion on thumbnails because many environments + # will not have the jpeg converter installed and this test will fail + # + # self.assertIsNotNone(thumbnail_location) + + # go through the website to do the delete, since the soft-delete logic is in the view + + url = reverse('remove_asset', kwargs={'org': 'edX', 'course': 'full', 'name': '6.002_Spring_2012'}) + resp = self.client.post(url, {'location': '/c4x/edX/full/asset/circuits_duality.gif'}) + self.assertEqual(resp.status_code, 200) + + asset_location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif') + + # now try to find it in store, but they should not be there any longer + content = content_store.find(asset_location, throw_on_not_found=False) + self.assertIsNone(content) + + if thumbnail_location: + thumbnail = content_store.find(thumbnail_location, throw_on_not_found=False) + self.assertIsNone(thumbnail) + + # now try to find it and the thumbnail in trashcan - should be in there + content = trash_store.find(asset_location, throw_on_not_found=False) + self.assertIsNotNone(content) + + if thumbnail_location: + thumbnail = trash_store.find(thumbnail_location, throw_on_not_found=False) + self.assertIsNotNone(thumbnail) + + # let's restore the asset + restore_asset_from_trashcan('/c4x/edX/full/asset/circuits_duality.gif') + + # now try to find it in courseware store, and they should be back after restore + content = content_store.find(asset_location, throw_on_not_found=False) + self.assertIsNotNone(content) + + if thumbnail_location: + thumbnail = content_store.find(thumbnail_location, throw_on_not_found=False) + self.assertIsNotNone(thumbnail) + + def test_empty_trashcan(self): + ''' + This test will exercise the empting of the asset trashcan + ''' + content_store = contentstore() + trash_store = contentstore('trashcan') + module_store = modulestore('direct') + + import_from_xml(module_store, 'common/test/data/', ['full'], static_content_store=content_store) + + course_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') + + location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif') + content = content_store.find(location, throw_on_not_found=False) + self.assertIsNotNone(content) + + # go through the website to do the delete, since the soft-delete logic is in the view + + url = reverse('remove_asset', kwargs={'org': 'edX', 'course': 'full', 'name': '6.002_Spring_2012'}) + resp = self.client.post(url, {'location': '/c4x/edX/full/asset/circuits_duality.gif'}) + self.assertEqual(resp.status_code, 200) + + # make sure there's something in the trashcan + all_assets = trash_store.get_all_content_for_course(course_location) + self.assertGreater(len(all_assets), 0) + + # make sure we have some thumbnails in our trashcan + _all_thumbnails = trash_store.get_all_content_thumbnails_for_course(course_location) + # + # cdodge: temporarily comment out assertion on thumbnails because many environments + # will not have the jpeg converter installed and this test will fail + # + # self.assertGreater(len(all_thumbnails), 0) + + # empty the trashcan + empty_asset_trashcan([course_location]) + + # make sure trashcan is empty + all_assets = trash_store.get_all_content_for_course(course_location) + self.assertEqual(len(all_assets), 0) + + all_thumbnails = trash_store.get_all_content_thumbnails_for_course(course_location) + self.assertEqual(len(all_thumbnails), 0) + def test_clone_course(self): course_data = { @@ -426,24 +592,21 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): location = Location('i4x://MITx/999/chapter/neuvo') self.assertRaises(InvalidVersionError, draft_store.clone_item, 'i4x://edx/templates/chapter/Empty', - location) + location) direct_store.clone_item('i4x://edx/templates/chapter/Empty', location) - self.assertRaises(InvalidVersionError, draft_store.clone_item, location, - location) + self.assertRaises(InvalidVersionError, draft_store.clone_item, location, location) - self.assertRaises(InvalidVersionError, draft_store.update_item, location, - 'chapter data') + self.assertRaises(InvalidVersionError, draft_store.update_item, location, 'chapter data') # taking advantage of update_children and other functions never checking that the ids are valid self.assertRaises(InvalidVersionError, draft_store.update_children, location, - ['i4x://MITx/999/problem/doesntexist']) + ['i4x://MITx/999/problem/doesntexist']) self.assertRaises(InvalidVersionError, draft_store.update_metadata, location, - {'due': datetime.datetime.now(UTC)}) + {'due': datetime.datetime.now(UTC)}) self.assertRaises(InvalidVersionError, draft_store.unpublish, location) - def test_bad_contentstore_request(self): resp = self.client.get('http://localhost:8001/c4x/CDX/123123/asset/&images_circuits_Lab7Solution2.png') self.assertEqual(resp.status_code, 400) @@ -461,12 +624,12 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): items = module_store.get_items(Location(['i4x', 'edX', 'full', 'vertical', None])) self.assertEqual(len(items), 0) - def verify_content_existence(self, modulestore, root_dir, location, dirname, category_name, filename_suffix=''): + def verify_content_existence(self, store, root_dir, location, dirname, category_name, filename_suffix=''): 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) + items = store.get_items(query_loc) for item in items: filesystem = OSFS(root_dir / ('test_export/' + dirname)) @@ -612,7 +775,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): def test_prefetch_children(self): 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) @@ -655,6 +817,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): export_to_xml(module_store, content_store, location, root_dir, 'test_export') +@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) class ContentStoreTest(ModuleStoreTestCase): """ Tests for the CMS ContentStore application. @@ -691,8 +854,19 @@ class ContentStoreTest(ModuleStoreTestCase): 'display_name': 'Robot Super Course', } + def tearDown(self): + mongo = MongoClient() + mongo.drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db']) + _CONTENTSTORE.clear() + def test_create_course(self): """Test new course creation - happy path""" + self.assert_created_course() + + def assert_created_course(self): + """ + Checks that the course was created properly. + """ resp = self.client.post(reverse('create_new_course'), self.course_data) self.assertEqual(resp.status_code, 200) data = parse_json(resp) @@ -700,41 +874,72 @@ class ContentStoreTest(ModuleStoreTestCase): 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.assert_created_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""" + self.client.post(reverse('create_new_course'), self.course_data) + self.assert_course_creation_failed('There is already a course defined with this name.') + + def assert_course_creation_failed(self, error_message): + """ + Checks that the course did not get created + """ resp = self.client.post(reverse('create_new_course'), self.course_data) - resp = self.client.post(reverse('create_new_course'), self.course_data) - data = parse_json(resp) self.assertEqual(resp.status_code, 200) - self.assertEqual(data['ErrMsg'], 'There is already a course defined with this name.') + data = parse_json(resp) + self.assertEqual(data['ErrMsg'], error_message) def test_create_course_duplicate_number(self): """Test new course creation - error path""" - resp = self.client.post(reverse('create_new_course'), self.course_data) + self.client.post(reverse('create_new_course'), self.course_data) self.course_data['display_name'] = 'Robot Super Course Two' - resp = self.client.post(reverse('create_new_course'), self.course_data) - data = parse_json(resp) - - self.assertEqual(resp.status_code, 200) - self.assertEqual(data['ErrMsg'], - 'There is already a course defined with the same organization and course number.') + self.assert_course_creation_failed('There is already a course defined with the same organization and course number.') def test_create_course_with_bad_organization(self): """Test new course creation - error path for bad organization name""" self.course_data['org'] = 'University of California, Berkeley' - resp = self.client.post(reverse('create_new_course'), self.course_data) - data = parse_json(resp) + self.assert_course_creation_failed( + "Unable to create course 'Robot Super Course'.\n\nInvalid characters in 'University of California, Berkeley'.") - self.assertEqual(resp.status_code, 200) - self.assertEqual(data['ErrMsg'], - "Unable to create course 'Robot Super Course'.\n\nInvalid characters in 'University of California, Berkeley'.") + def test_create_course_with_course_creation_disabled_staff(self): + """Test new course creation -- course creation disabled, but staff access.""" + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'DISABLE_COURSE_CREATION': True}): + self.assert_created_course() + + def test_create_course_with_course_creation_disabled_not_staff(self): + """Test new course creation -- error path for course creation disabled, not staff access.""" + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'DISABLE_COURSE_CREATION': True}): + self.user.is_staff = False + self.user.save() + self.assert_course_permission_denied() + + def test_create_course_no_course_creators_staff(self): + """Test new course creation -- course creation group enabled, staff, group is empty.""" + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_CREATOR_GROUP': True}): + self.assert_created_course() + + def test_create_course_no_course_creators_not_staff(self): + """Test new course creation -- error path for course creator group enabled, not staff, group is empty.""" + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}): + self.user.is_staff = False + self.user.save() + self.assert_course_permission_denied() + + def test_create_course_with_course_creator(self): + """Test new course creation -- use course creator group""" + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}): + add_user_to_creator_group(self.user, self.user) + self.assert_created_course() + + def assert_course_permission_denied(self): + """ + Checks that the course did not get created due to a PermissionError. + """ + resp = self.client.post(reverse('create_new_course'), self.course_data) + self.assertEqual(resp.status_code, 403) def test_course_index_view_with_no_courses(self): """Test viewing the index page with no courses""" @@ -934,11 +1139,9 @@ class ContentStoreTest(ModuleStoreTestCase): json.dumps({'id': del_loc.url()}), "application/json") self.assertEqual(200, resp.status_code) - def test_import_metadata_with_attempts_empty_string(self): 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])) diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 8c15b1ae95..5c2a15ac87 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -1,11 +1,16 @@ +""" +Tests for Studio Course Settings. +""" import datetime import json import copy +import mock from django.contrib.auth.models import User from django.test.client import Client from django.core.urlresolvers import reverse from django.utils.timezone import UTC +from django.test.utils import override_settings from xmodule.modulestore import Location from models.settings.course_details import (CourseDetails, CourseSettingsEncoder) @@ -21,6 +26,9 @@ from xmodule.fields import Date class CourseTestCase(ModuleStoreTestCase): + """ + Base class for test classes below. + """ def setUp(self): """ These tests need a user in the DB so that the django Test Client @@ -51,6 +59,9 @@ class CourseTestCase(ModuleStoreTestCase): class CourseDetailsTestCase(CourseTestCase): + """ + Tests the first course settings page (course dates, overview, etc.). + """ def test_virgin_fetch(self): details = CourseDetails.fetch(self.course_location) self.assertEqual(details.course_location, self.course_location, "Location not copied into") @@ -81,9 +92,9 @@ class CourseDetailsTestCase(CourseTestCase): Test the encoder out of its original constrained purpose to see if it functions for general use """ details = {'location': Location(['tag', 'org', 'course', 'category', 'name']), - 'number': 1, - 'string': 'string', - 'datetime': datetime.datetime.now(UTC())} + 'number': 1, + 'string': 'string', + 'datetime': datetime.datetime.now(UTC())} jsondetails = json.dumps(details, cls=CourseSettingsEncoder) jsondetails = json.loads(jsondetails) @@ -118,8 +129,60 @@ class CourseDetailsTestCase(CourseTestCase): jsondetails.effort, "After set effort" ) + @override_settings(MKTG_URLS={'ROOT': 'dummy-root'}) + def test_marketing_site_fetch(self): + settings_details_url = reverse( + 'settings_details', + kwargs={ + 'org': self.course_location.org, + 'name': self.course_location.name, + 'course': self.course_location.course + } + ) + + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}): + response = self.client.get(settings_details_url) + self.assertContains(response, "Course Summary Page") + self.assertContains(response, "course summary page will not be viewable") + + self.assertContains(response, "Course Start Date") + self.assertContains(response, "Course End Date") + self.assertNotContains(response, "Enrollment Start Date") + self.assertNotContains(response, "Enrollment End Date") + self.assertContains(response, "not the dates shown on your course summary page") + + self.assertNotContains(response, "Introducing Your Course") + self.assertNotContains(response, "Requirements") + + def test_regular_site_fetch(self): + settings_details_url = reverse( + 'settings_details', + kwargs={ + 'org': self.course_location.org, + 'name': self.course_location.name, + 'course': self.course_location.course + } + ) + + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': False}): + response = self.client.get(settings_details_url) + self.assertContains(response, "Course Summary Page") + self.assertNotContains(response, "course summary page will not be viewable") + + self.assertContains(response, "Course Start Date") + self.assertContains(response, "Course End Date") + self.assertContains(response, "Enrollment Start Date") + self.assertContains(response, "Enrollment End Date") + self.assertNotContains(response, "not the dates shown on your course summary page") + + self.assertContains(response, "Introducing Your Course") + self.assertContains(response, "Requirements") + class CourseDetailsViewTest(CourseTestCase): + """ + Tests for modifying content on the first course settings page (course dates, overview, etc.). + """ def alter_field(self, url, details, field, val): setattr(details, field, val) # Need to partially serialize payload b/c the mock doesn't handle it correctly @@ -181,6 +244,9 @@ class CourseDetailsViewTest(CourseTestCase): class CourseGradingTest(CourseTestCase): + """ + Tests for the course settings grading page. + """ def test_initial_grader(self): descriptor = get_modulestore(self.course_location).get_item(self.course_location) test_grader = CourseGradingModel(descriptor) @@ -256,6 +322,9 @@ class CourseGradingTest(CourseTestCase): class CourseMetadataEditingTest(CourseTestCase): + """ + Tests for CourseMetadata. + """ def setUp(self): CourseTestCase.setUp(self) # add in the full class too diff --git a/cms/djangoapps/contentstore/tests/test_item.py b/cms/djangoapps/contentstore/tests/test_item.py index 07264cdc30..1831a5769a 100644 --- a/cms/djangoapps/contentstore/tests/test_item.py +++ b/cms/djangoapps/contentstore/tests/test_item.py @@ -1,4 +1,3 @@ -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 diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py index f769652493..f7f330f91e 100644 --- a/cms/djangoapps/contentstore/tests/tests.py +++ b/cms/djangoapps/contentstore/tests/tests.py @@ -3,6 +3,10 @@ from django.core.urlresolvers import reverse from .utils import parse_json, user, registration from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from contentstore.tests.test_course_settings import CourseTestCase +from xmodule.modulestore.tests.factories import CourseFactory +import datetime +from pytz import UTC class ContentStoreTestCase(ModuleStoreTestCase): @@ -162,3 +166,21 @@ class AuthTestCase(ContentStoreTestCase): self.assertEqual(resp.status_code, 302) # Logged in should work. + + +class ForumTestCase(CourseTestCase): + def setUp(self): + """ Creates the test course. """ + super(ForumTestCase, self).setUp() + self.course = CourseFactory.create(org='testX', number='727', display_name='Forum Course') + + def test_blackouts(self): + now = datetime.datetime.now(UTC) + self.course.discussion_blackouts = [(t.isoformat(), t2.isoformat()) for t, t2 in + [(now - datetime.timedelta(days=14), now - datetime.timedelta(days=11)), + (now + datetime.timedelta(days=24), now + datetime.timedelta(days=30))]] + self.assertTrue(self.course.forum_posts_allowed) + self.course.discussion_blackouts = [(t.isoformat(), t2.isoformat()) for t, t2 in + [(now - datetime.timedelta(days=14), now + datetime.timedelta(days=2)), + (now + datetime.timedelta(days=24), now + datetime.timedelta(days=30))]] + self.assertFalse(self.course.forum_posts_allowed) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 6f766ff7f5..c9c40ab95d 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -10,7 +10,7 @@ from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES log = logging.getLogger(__name__) -#In order to instantiate an open ended tab automatically, need to have this data +# 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]]) @@ -224,14 +224,14 @@ def add_extra_panel_tab(tab_type, course): @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. """ - #Copy course tabs + # Copy course tabs course_tabs = copy.copy(course.tabs) changed = False - #Check to see if open ended panel is defined in the course + # Check to see if open ended panel is defined in the course 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 + # Add panel to the tabs if it is not defined course_tabs.append(tab_panel) changed = True return changed, course_tabs @@ -244,14 +244,14 @@ def remove_extra_panel_tab(tab_type, course): @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. """ - #Copy course tabs + # Copy course tabs course_tabs = copy.copy(course.tabs) changed = False - #Check to see if open ended panel is defined in the course + # Check to see if open ended panel is defined in the course tab_panel = EXTRA_TAB_PANELS.get(tab_type) if tab_panel in course_tabs: - #Add panel to the tabs if it is not defined + # Add panel to the tabs if it is not defined 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/assets.py b/cms/djangoapps/contentstore/views/assets.py index 229788f24d..41077abd8f 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -25,6 +25,8 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore import Location from xmodule.contentstore.content import StaticContent from xmodule.util.date_utils import get_default_time_display +from xmodule.modulestore import InvalidLocationError +from xmodule.exceptions import NotFoundError from ..utils import get_url_reverse from .access import get_location_and_verify_access @@ -78,10 +80,17 @@ def asset_index(request, org, course, name): 'active_tab': 'assets', 'context_course': course_module, 'assets': asset_display, - 'upload_asset_callback_url': upload_asset_callback_url + 'upload_asset_callback_url': upload_asset_callback_url, + 'remove_asset_callback_url': reverse('remove_asset', kwargs={ + 'org': org, + 'course': course, + 'name': name + }) }) +@login_required +@ensure_csrf_cookie def upload_asset(request, org, course, coursename): ''' cdodge: this method allows for POST uploading of files into the course asset library, which will @@ -145,6 +154,57 @@ def upload_asset(request, org, course, coursename): return response +@ensure_csrf_cookie +@login_required +def remove_asset(request, org, course, name): + ''' + This method will perform a 'soft-delete' of an asset, which is basically to copy the asset from + the main GridFS collection and into a Trashcan + ''' + get_location_and_verify_access(request, org, course, name) + + location = request.POST['location'] + + # make sure the location is valid + try: + loc = StaticContent.get_location_from_path(location) + except InvalidLocationError: + # return a 'Bad Request' to browser as we have a malformed Location + response = HttpResponse() + response.status_code = 400 + return response + + # also make sure the item to delete actually exists + try: + content = contentstore().find(loc) + except NotFoundError: + response = HttpResponse() + response.status_code = 404 + return response + + # ok, save the content into the trashcan + contentstore('trashcan').save(content) + + # see if there is a thumbnail as well, if so move that as well + if content.thumbnail_location is not None: + try: + thumbnail_content = contentstore().find(content.thumbnail_location) + contentstore('trashcan').save(thumbnail_content) + # hard delete thumbnail from origin + contentstore().delete(thumbnail_content.get_id()) + # remove from any caching + del_cached_content(thumbnail_content.location) + except: + pass # OK if this is left dangling + + # delete the original + contentstore().delete(content.get_id()) + # remove from cache + del_cached_content(content.location) + + return HttpResponse() + + @ensure_csrf_cookie @login_required def import_course(request, org, course, name): @@ -180,13 +240,13 @@ def import_course(request, org, course, name): # find the 'course.xml' file for dirpath, _dirnames, filenames in os.walk(course_dir): - for files in filenames: - if files == 'course.xml': + for filename in filenames: + if filename == 'course.xml': break - if files == 'course.xml': + if filename == 'course.xml': break - if files != 'course.xml': + if filename != 'course.xml': return HttpResponse(json.dumps({'ErrMsg': 'Could not find the course.xml file in the package.'})) logging.debug('found course.xml at {0}'.format(dirpath)) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index e1c176eebe..8862115c45 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -12,8 +12,8 @@ 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 @@ -21,7 +21,7 @@ from contentstore.utils import get_lms_link_for_item, add_extra_panel_tab, remov 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 +from auth.authz import create_all_course_groups, is_user_in_creator_group from util.json_request import expect_json from .access import has_access, get_location_and_verify_access @@ -33,9 +33,6 @@ from .component import OPEN_ENDED_COMPONENT_TYPES, \ from django_comment_common.utils import seed_permissions_roles import datetime from django.utils.timezone import UTC - -# TODO: should explicitly enumerate exports with __all__ - __all__ = ['course_index', 'create_new_course', 'course_info', 'course_info_updates', 'get_course_settings', 'course_config_graders_page', @@ -84,7 +81,7 @@ def course_index(request, org, course, name): @expect_json def create_new_course(request): - if settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False) and not request.user.is_staff: + if not is_user_in_creator_group(request.user): raise PermissionDenied() # This logic is repeated in xmodule/modulestore/tests/factories.py @@ -230,7 +227,8 @@ def get_course_settings(request, org, course, name): kwargs={"org": org, "course": course, "name": name, - "section": "details"}) + "section": "details"}), + 'about_page_editable': not settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False) }) @@ -402,8 +400,11 @@ def course_advanced_updates(request, org, course, name): 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, + try: + response_json = json.dumps(CourseMetadata.update_from_json(location, request_body, filter_tabs=filter_tabs)) + except (TypeError, ValueError), e: + return HttpResponseBadRequest("Incorrect setting format. " + str(e), content_type="text/plain") + return HttpResponse(response_json, mimetype="application/json") diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 25094ddcfe..abc5f48564 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -103,7 +103,7 @@ def clone_item(request): @expect_json def delete_item(request): item_location = request.POST['id'] - item_loc = Location(item_location) + item_location = Location(item_location) # check permissions for this user within this course if not has_access(request.user, item_location): @@ -124,11 +124,11 @@ def delete_item(request): # cdodge: we need to remove our parent's pointer to us so that it is no longer dangling if delete_all_versions: - parent_locs = modulestore('direct').get_parent_locations(item_loc, None) + parent_locs = modulestore('direct').get_parent_locations(item_location, None) for parent_loc in parent_locs: parent = modulestore('direct').get_item(parent_loc) - item_url = item_loc.url() + item_url = item_location.url() if item_url in parent.children: children = parent.children children.remove(item_url) diff --git a/cms/djangoapps/contentstore/views/session_kv_store.py b/cms/djangoapps/contentstore/views/session_kv_store.py index 309518c27d..54ab25ff54 100644 --- a/cms/djangoapps/contentstore/views/session_kv_store.py +++ b/cms/djangoapps/contentstore/views/session_kv_store.py @@ -2,27 +2,27 @@ from xblock.runtime import KeyValueStore, InvalidScopeError class SessionKeyValueStore(KeyValueStore): - def __init__(self, request, model_data): - self._model_data = model_data + def __init__(self, request, descriptor_model_data): + self._descriptor_model_data = descriptor_model_data self._session = request.session def get(self, key): try: - return self._model_data[key.field_name] + return self._descriptor_model_data[key.field_name] except (KeyError, InvalidScopeError): return self._session[tuple(key)] def set(self, key, value): try: - self._model_data[key.field_name] = value + self._descriptor_model_data[key.field_name] = value except (KeyError, InvalidScopeError): self._session[tuple(key)] = value def delete(self, key): try: - del self._model_data[key.field_name] + del self._descriptor_model_data[key.field_name] except (KeyError, InvalidScopeError): del self._session[tuple(key)] def has(self, key): - return key in self._model_data or key in self._session + return key in self._descriptor_model_data or key in self._session diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py index 07eb4bc309..884a4e4fef 100644 --- a/cms/djangoapps/models/settings/course_details.py +++ b/cms/djangoapps/models/settings/course_details.py @@ -41,25 +41,25 @@ class CourseDetails(object): course.enrollment_start = descriptor.enrollment_start course.enrollment_end = descriptor.enrollment_end - temploc = course_location._replace(category='about', name='syllabus') + temploc = course_location.replace(category='about', name='syllabus') try: course.syllabus = get_modulestore(temploc).get_item(temploc).data except ItemNotFoundError: pass - temploc = temploc._replace(name='overview') + temploc = temploc.replace(name='overview') try: course.overview = get_modulestore(temploc).get_item(temploc).data except ItemNotFoundError: pass - temploc = temploc._replace(name='effort') + temploc = temploc.replace(name='effort') try: course.effort = get_modulestore(temploc).get_item(temploc).data except ItemNotFoundError: pass - temploc = temploc._replace(name='video') + temploc = temploc.replace(name='video') try: raw_video = get_modulestore(temploc).get_item(temploc).data course.intro_video = CourseDetails.parse_video_tag(raw_video) @@ -126,16 +126,16 @@ class CourseDetails(object): # NOTE: below auto writes to the db w/o verifying that any of the fields actually changed # to make faster, could compare against db or could have client send over a list of which fields changed. - temploc = Location(course_location)._replace(category='about', name='syllabus') + temploc = Location(course_location).replace(category='about', name='syllabus') update_item(temploc, jsondict['syllabus']) - temploc = temploc._replace(name='overview') + temploc = temploc.replace(name='overview') update_item(temploc, jsondict['overview']) - temploc = temploc._replace(name='effort') + temploc = temploc.replace(name='effort') update_item(temploc, jsondict['effort']) - temploc = temploc._replace(name='video') + temploc = temploc.replace(name='video') recomposed_video_tag = CourseDetails.recompose_video_tag(jsondict['intro_video']) update_item(temploc, recomposed_video_tag) @@ -153,9 +153,9 @@ class CourseDetails(object): if not raw_video: return None - keystring_matcher = re.search('(?<=embed/)[a-zA-Z0-9_-]+', raw_video) + keystring_matcher = re.search(r'(?<=embed/)[a-zA-Z0-9_-]+', raw_video) if keystring_matcher is None: - keystring_matcher = re.search(' - beforeEach -> - @model = new CMS.Models.SystemFeedback() - - it "should have an empty message by default", -> - expect(@model.get("message")).toEqual("") - - it "should have an empty title by default", -> - expect(@model.get("title")).toEqual("") - - it "should not have an intent set by default", -> - expect(@model.get("intent")).toBeNull() - - -describe "CMS.Models.WarningMessage", -> - beforeEach -> - @model = new CMS.Models.WarningMessage() - - it "should have the correct intent", -> - expect(@model.get("intent")).toEqual("warning") - -describe "CMS.Models.ErrorMessage", -> - beforeEach -> - @model = new CMS.Models.ErrorMessage() - - it "should have the correct intent", -> - expect(@model.get("intent")).toEqual("error") - -describe "CMS.Models.ConfirmationMessage", -> - beforeEach -> - @model = new CMS.Models.ConfirmationMessage() - - it "should have the correct intent", -> - expect(@model.get("intent")).toEqual("confirmation") diff --git a/cms/static/coffee/spec/views/feedback_spec.coffee b/cms/static/coffee/spec/views/feedback_spec.coffee index 3e7d080a7c..adec11e2a7 100644 --- a/cms/static/coffee/spec/views/feedback_spec.coffee +++ b/cms/static/coffee/spec/views/feedback_spec.coffee @@ -17,80 +17,115 @@ beforeEach -> return text.test(trimmedText) else return trimmedText.indexOf(text) != -1; + toHaveBeenPrevented: -> + # remove this when we upgrade jasmine-jquery + eventName = @actual.eventName + selector = @actual.selector + @message = -> + [ + "Expected event #{eventName} to have been prevented on #{selector}", + "Expected event #{eventName} not to have been prevented on #{selector}" + ] + return jasmine.JQuery.events.wasPrevented(selector, eventName) -describe "CMS.Views.Alert as base class", -> +describe "CMS.Views.SystemFeedback", -> beforeEach -> - @model = new CMS.Models.ConfirmationMessage({ + @options = title: "Portal" message: "Welcome to the Aperture Science Computer-Aided Enrichment Center" - }) # it will be interesting to see when this.render is called, so lets spy on it - spyOn(CMS.Views.Alert.prototype, 'render').andCallThrough() + @renderSpy = spyOn(CMS.Views.Alert.Confirmation.prototype, 'render').andCallThrough() + @showSpy = spyOn(CMS.Views.Alert.Confirmation.prototype, 'show').andCallThrough() + @hideSpy = spyOn(CMS.Views.Alert.Confirmation.prototype, 'hide').andCallThrough() - it "renders on initalize", -> - view = new CMS.Views.Alert({model: @model}) - expect(view.render).toHaveBeenCalled() + it "requires a type and an intent", -> + neither = => + new CMS.Views.SystemFeedback(@options) + noType = => + options = $.extend({}, @options) + options.intent = "confirmation" + new CMS.Views.SystemFeedback(options) + noIntent = => + options = $.extend({}, @options) + options.type = "alert" + new CMS.Views.SystemFeedback(options) + both = => + options = $.extend({}, @options) + options.type = "alert" + options.intent = "confirmation" + new CMS.Views.SystemFeedback(options) + + expect(neither).toThrow() + expect(noType).toThrow() + expect(noIntent).toThrow() + expect(both).not.toThrow() + + # for simplicity, we'll use CMS.Views.Alert.Confirmation from here on, + # which extends and proxies to CMS.Views.SystemFeedback + + it "does not show on initalize", -> + view = new CMS.Views.Alert.Confirmation(@options) + expect(@renderSpy).not.toHaveBeenCalled() + expect(@showSpy).not.toHaveBeenCalled() it "renders the template", -> - view = new CMS.Views.Alert({model: @model}) + view = new CMS.Views.Alert.Confirmation(@options) + view.show() + expect(view.$(".action-close")).toBeDefined() expect(view.$('.wrapper')).toBeShown() - expect(view.$el).toContainText(@model.get("title")) - expect(view.$el).toContainText(@model.get("message")) + expect(view.$el).toContainText(@options.title) + expect(view.$el).toContainText(@options.message) it "close button sends a .hide() message", -> - spyOn(CMS.Views.Alert.prototype, 'hide').andCallThrough() - - view = new CMS.Views.Alert({model: @model}) + view = new CMS.Views.Alert.Confirmation(@options).show() view.$(".action-close").click() - expect(CMS.Views.Alert.prototype.hide).toHaveBeenCalled() + expect(@hideSpy).toHaveBeenCalled() expect(view.$('.wrapper')).toBeHiding() describe "CMS.Views.Prompt", -> - beforeEach -> - @model = new CMS.Models.ConfirmationMessage({ - title: "Portal" - message: "Welcome to the Aperture Science Computer-Aided Enrichment Center" - }) - # for some reason, expect($("body")) blows up the test runner, so this test # just exercises the Prompt rather than asserting on anything. Best I can # do for now. :( it "changes class on body", -> # expect($("body")).not.toHaveClass("prompt-is-shown") - view = new CMS.Views.Prompt({model: @model}) + view = new CMS.Views.Prompt.Confirmation({ + title: "Portal" + message: "Welcome to the Aperture Science Computer-Aided Enrichment Center" + }) # expect($("body")).toHaveClass("prompt-is-shown") view.hide() # expect($("body")).not.toHaveClass("prompt-is-shown") -describe "CMS.Views.Alert click events", -> +describe "CMS.Views.SystemFeedback click events", -> beforeEach -> - @model = new CMS.Models.WarningMessage( + @primaryClickSpy = jasmine.createSpy('primaryClick') + @secondaryClickSpy = jasmine.createSpy('secondaryClick') + @view = new CMS.Views.Notification.Warning( title: "Unsaved", message: "Your content is currently Unsaved.", actions: primary: text: "Save", class: "save-button", - click: jasmine.createSpy('primaryClick') - secondary: [{ + click: @primaryClickSpy + secondary: text: "Revert", class: "cancel-button", - click: jasmine.createSpy('secondaryClick') - }] - + click: @secondaryClickSpy ) - - @view = new CMS.Views.Alert({model: @model}) + @view.show() it "should trigger the primary event on a primary click", -> - @view.primaryClick() - expect(@model.get('actions').primary.click).toHaveBeenCalled() + @view.$(".action-primary").click() + expect(@primaryClickSpy).toHaveBeenCalled() + expect(@secondaryClickSpy).not.toHaveBeenCalled() it "should trigger the secondary event on a secondary click", -> - @view.secondaryClick() - expect(@model.get('actions').secondary[0].click).toHaveBeenCalled() + @view.$(".action-secondary").click() + expect(@secondaryClickSpy).toHaveBeenCalled() + expect(@primaryClickSpy).not.toHaveBeenCalled() it "should apply class to primary action", -> expect(@view.$(".action-primary")).toHaveClass("save-button") @@ -98,22 +133,89 @@ describe "CMS.Views.Alert click events", -> it "should apply class to secondary action", -> expect(@view.$(".action-secondary")).toHaveClass("cancel-button") + it "should preventDefault on primary action", -> + spyOnEvent(".action-primary", "click") + @view.$(".action-primary").click() + expect("click").toHaveBeenPreventedOn(".action-primary") + + it "should preventDefault on secondary action", -> + spyOnEvent(".action-secondary", "click") + @view.$(".action-secondary").click() + expect("click").toHaveBeenPreventedOn(".action-secondary") + +describe "CMS.Views.SystemFeedback not preventing events", -> + beforeEach -> + @clickSpy = jasmine.createSpy('clickSpy') + @view = new CMS.Views.Alert.Confirmation( + title: "It's all good" + message: "No reason for this alert" + actions: + primary: + text: "Whatever" + click: @clickSpy + preventDefault: false + ) + @view.show() + + it "should not preventDefault", -> + spyOnEvent(".action-primary", "click") + @view.$(".action-primary").click() + expect("click").not.toHaveBeenPreventedOn(".action-primary") + expect(@clickSpy).toHaveBeenCalled() + +describe "CMS.Views.SystemFeedback multiple secondary actions", -> + beforeEach -> + @secondarySpyOne = jasmine.createSpy('secondarySpyOne') + @secondarySpyTwo = jasmine.createSpy('secondarySpyTwo') + @view = new CMS.Views.Notification.Warning( + title: "No Primary", + message: "Pick a secondary action", + actions: + secondary: [ + { + text: "Option One" + class: "option-one" + click: @secondarySpyOne + }, { + text: "Option Two" + class: "option-two" + click: @secondarySpyTwo + } + ] + ) + @view.show() + + it "should render both", -> + expect(@view.el).toContain(".action-secondary.option-one") + expect(@view.el).toContain(".action-secondary.option-two") + expect(@view.el).not.toContain(".action-secondary.option-one.option-two") + expect(@view.$(".action-secondary.option-one")).toContainText("Option One") + expect(@view.$(".action-secondary.option-two")).toContainText("Option Two") + + it "should differentiate clicks (1)", -> + @view.$(".option-one").click() + expect(@secondarySpyOne).toHaveBeenCalled() + expect(@secondarySpyTwo).not.toHaveBeenCalled() + + it "should differentiate clicks (2)", -> + @view.$(".option-two").click() + expect(@secondarySpyOne).not.toHaveBeenCalled() + expect(@secondarySpyTwo).toHaveBeenCalled() + describe "CMS.Views.Notification minShown and maxShown", -> beforeEach -> - @model = new CMS.Models.SystemFeedback( - intent: "saving" - title: "Saving" - ) - spyOn(CMS.Views.Notification.prototype, 'show').andCallThrough() - spyOn(CMS.Views.Notification.prototype, 'hide').andCallThrough() + @showSpy = spyOn(CMS.Views.Notification.Saving.prototype, 'show') + @showSpy.andCallThrough() + @hideSpy = spyOn(CMS.Views.Notification.Saving.prototype, 'hide') + @hideSpy.andCallThrough() @clock = sinon.useFakeTimers() afterEach -> @clock.restore() it "a minShown view should not hide too quickly", -> - view = new CMS.Views.Notification({model: @model, minShown: 1000}) - expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled() + view = new CMS.Views.Notification.Saving({minShown: 1000}) + view.show() expect(view.$('.wrapper')).toBeShown() # call hide() on it, but the minShown should prevent it from hiding right away @@ -125,8 +227,8 @@ describe "CMS.Views.Notification minShown and maxShown", -> expect(view.$('.wrapper')).toBeHiding() it "a maxShown view should hide by itself", -> - view = new CMS.Views.Notification({model: @model, maxShown: 1000}) - expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled() + view = new CMS.Views.Notification.Saving({maxShown: 1000}) + view.show() expect(view.$('.wrapper')).toBeShown() # wait for the maxShown timeout to expire, and check again @@ -134,13 +236,13 @@ describe "CMS.Views.Notification minShown and maxShown", -> expect(view.$('.wrapper')).toBeHiding() it "a minShown view can stay visible longer", -> - view = new CMS.Views.Notification({model: @model, minShown: 1000}) - expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled() + view = new CMS.Views.Notification.Saving({minShown: 1000}) + view.show() expect(view.$('.wrapper')).toBeShown() # wait for the minShown timeout to expire, and check again @clock.tick(1001) - expect(CMS.Views.Notification.prototype.hide).not.toHaveBeenCalled() + expect(@hideSpy).not.toHaveBeenCalled() expect(view.$('.wrapper')).toBeShown() # can now hide immediately @@ -148,8 +250,8 @@ describe "CMS.Views.Notification minShown and maxShown", -> expect(view.$('.wrapper')).toBeHiding() it "a maxShown view can hide early", -> - view = new CMS.Views.Notification({model: @model, maxShown: 1000}) - expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled() + view = new CMS.Views.Notification.Saving({maxShown: 1000}) + view.show() expect(view.$('.wrapper')).toBeShown() # wait 50 milliseconds, and hide it early @@ -162,7 +264,8 @@ describe "CMS.Views.Notification minShown and maxShown", -> expect(view.$('.wrapper')).toBeHiding() it "a view can have both maxShown and minShown", -> - view = new CMS.Views.Notification({model: @model, minShown: 1000, maxShown: 2000}) + view = new CMS.Views.Notification.Saving({minShown: 1000, maxShown: 2000}) + view.show() # can't hide early @clock.tick(50) diff --git a/cms/static/coffee/src/main.coffee b/cms/static/coffee/src/main.coffee index efcd869113..8043b41638 100644 --- a/cms/static/coffee/src/main.coffee +++ b/cms/static/coffee/src/main.coffee @@ -18,11 +18,15 @@ $ -> $(document).ajaxError (event, jqXHR, ajaxSettings, thrownError) -> if ajaxSettings.notifyOnError is false return - msg = new CMS.Models.ErrorMessage( + if jqXHR.responseText + message = _.str.truncate(jqXHR.responseText, 300) + else + message = gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.") + msg = new CMS.Views.Notification.Error( "title": gettext("Studio's having trouble saving your work") - "message": jqXHR.responseText || gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.") + "message": message ) - new CMS.Views.Notification({model: msg}) + msg.show() window.onTouchBasedDevice = -> navigator.userAgent.match /iPhone|iPod|iPad/i diff --git a/cms/static/coffee/src/views/module_edit.coffee b/cms/static/coffee/src/views/module_edit.coffee index d0a76a6c15..5154591d6f 100644 --- a/cms/static/coffee/src/views/module_edit.coffee +++ b/cms/static/coffee/src/views/module_edit.coffee @@ -44,8 +44,17 @@ class CMS.Views.ModuleEdit extends Backbone.View [@metadataEditor.getDisplayName()]) @$el.find('.component-name').html(title) + customMetadata: -> + # Hack to support metadata fields that aren't part of the metadata editor (ie, LaTeX high level source). + # Walk through the set of elements which have the 'data-metadata_name' attribute and + # build up an object to pass back to the server on the subsequent POST. + # Note that these values will always be sent back on POST, even if they did not actually change. + _metadata = {} + _metadata[$(el).data("metadata-name")] = el.value for el in $('[data-metadata-name]', @$component_editor()) + return _metadata + changedMetadata: -> - return @metadataEditor.getModifiedMetadataValues() + return _.extend(@metadataEditor.getModifiedMetadataValues(), @customMetadata()) cloneTemplate: (parent, template) -> $.post("/clone_item", { diff --git a/cms/static/js/base.js b/cms/static/js/base.js index c626fa1b3f..d1cffdc427 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -25,15 +25,12 @@ $(document).ready(function() { $newComponentTemplatePickers = $('.new-component-templates'); $newComponentButton = $('.new-component-button'); $spinner = $(''); - $body.bind('keyup', onKeyUp); $('.expand-collapse-icon').bind('click', toggleSubmodules); $('.visibility-options').bind('change', setVisibility); $modal.bind('click', hideModal); $modalCover.bind('click', hideModal); - $('.uploads .upload-button').bind('click', showUploadModal); - $('.upload-modal .close-button').bind('click', hideModal); $body.on('click', '.embeddable-xml-input', function() { $(this).select(); @@ -145,8 +142,6 @@ $(document).ready(function() { $('.edit-section-start-cancel').bind('click', cancelSetSectionScheduleDate); $('.edit-section-start-save').bind('click', saveSetSectionScheduleDate); - $('.upload-modal .choose-file-button').bind('click', showFileSelectionMenu); - $body.on('click', '.section-published-date .edit-button', editSectionPublishDate); $body.on('click', '.section-published-date .schedule-button', editSectionPublishDate); $body.on('click', '.edit-subsection-publish-settings .save-button', saveSetSectionScheduleDate); @@ -398,73 +393,6 @@ function _deleteItem($el) { }); } -function showUploadModal(e) { - e.preventDefault(); - $modal = $('.upload-modal').show(); - $('.file-input').bind('change', startUpload); - $modalCover.show(); -} - -function showFileSelectionMenu(e) { - e.preventDefault(); - $('.file-input').click(); -} - -function startUpload(e) { - var files = $('.file-input').get(0).files; - if (files.length === 0) - return; - - $('.upload-modal h1').html(gettext('Uploading…')); - $('.upload-modal .file-name').html(files[0].name); - $('.upload-modal .file-chooser').ajaxSubmit({ - beforeSend: resetUploadBar, - uploadProgress: showUploadFeedback, - complete: displayFinishedUpload - }); - $('.upload-modal .choose-file-button').hide(); - $('.upload-modal .progress-bar').removeClass('loaded').show(); -} - -function resetUploadBar() { - var percentVal = '0%'; - $('.upload-modal .progress-fill').width(percentVal); - $('.upload-modal .progress-fill').html(percentVal); -} - -function showUploadFeedback(event, position, total, percentComplete) { - var percentVal = percentComplete + '%'; - $('.upload-modal .progress-fill').width(percentVal); - $('.upload-modal .progress-fill').html(percentVal); -} - -function displayFinishedUpload(xhr) { - if (xhr.status = 200) { - markAsLoaded(); - } - - var resp = JSON.parse(xhr.responseText); - $('.upload-modal .embeddable-xml-input').val(xhr.getResponseHeader('asset_url')); - $('.upload-modal .embeddable').show(); - $('.upload-modal .file-name').hide(); - $('.upload-modal .progress-fill').html(resp.msg); - $('.upload-modal .choose-file-button').html(gettext('Load Another File')).show(); - $('.upload-modal .progress-fill').width('100%'); - - // see if this id already exists, if so, then user must have updated an existing piece of content - $("tr[data-id='" + resp.url + "']").remove(); - - var template = $('#new-asset-element').html(); - var html = Mustache.to_html(template, resp); - $('table > tbody').prepend(html); - - analytics.track('Uploaded a File', { - 'course': course_location_analytics, - 'asset_url': resp.url - }); - -} - function markAsLoaded() { $('.upload-modal .copy-button').css('display', 'inline-block'); $('.upload-modal .progress-bar').addClass('loaded'); @@ -484,12 +412,6 @@ function hideModal(e) { } } -function onKeyUp(e) { - if (e.which == 87) { - $body.toggleClass('show-wip hide-wip'); - } -} - function toggleSock(e) { e.preventDefault(); diff --git a/cms/static/js/models/feedback.js b/cms/static/js/models/feedback.js deleted file mode 100644 index 1f1ee57000..0000000000 --- a/cms/static/js/models/feedback.js +++ /dev/null @@ -1,49 +0,0 @@ -CMS.Models.SystemFeedback = Backbone.Model.extend({ - defaults: { - "intent": null, // "warning", "confirmation", "error", "announcement", "step-required", etc - "title": "", - "message": "" - /* could also have an "actions" hash: here is an example demonstrating - the expected structure - "actions": { - "primary": { - "text": "Save", - "class": "action-save", - "click": function() { - // do something when Save is clicked - // `this` refers to the model - } - }, - "secondary": [ - { - "text": "Cancel", - "class": "action-cancel", - "click": function() {} - }, { - "text": "Discard Changes", - "class": "action-discard", - "click": function() {} - } - ] - } - */ - } -}); - -CMS.Models.WarningMessage = CMS.Models.SystemFeedback.extend({ - defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, { - "intent": "warning" - }) -}); - -CMS.Models.ErrorMessage = CMS.Models.SystemFeedback.extend({ - defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, { - "intent": "error" - }) -}); - -CMS.Models.ConfirmationMessage = CMS.Models.SystemFeedback.extend({ - defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, { - "intent": "confirmation" - }) -}); diff --git a/cms/static/js/models/section.js b/cms/static/js/models/section.js index 467a2709a6..902585c58c 100644 --- a/cms/static/js/models/section.js +++ b/cms/static/js/models/section.js @@ -22,22 +22,16 @@ CMS.Models.Section = Backbone.Model.extend({ }, showNotification: function() { if(!this.msg) { - this.msg = new CMS.Models.SystemFeedback({ - intent: "saving", - title: gettext("Saving…") - }); - } - if(!this.msgView) { - this.msgView = new CMS.Views.Notification({ - model: this.msg, + this.msg = new CMS.Views.Notification.Saving({ + title: gettext("Saving…"), closeIcon: false, minShown: 1250 }); } - this.msgView.show(); + this.msg.show(); }, hideNotification: function() { - if(!this.msgView) { return; } - this.msgView.hide(); + if(!this.msg) { return; } + this.msg.hide(); } }); diff --git a/cms/static/js/views/assets.js b/cms/static/js/views/assets.js new file mode 100644 index 0000000000..18ef131f52 --- /dev/null +++ b/cms/static/js/views/assets.js @@ -0,0 +1,115 @@ +$(document).ready(function() { + $('.uploads .upload-button').bind('click', showUploadModal); + $('.upload-modal .close-button').bind('click', hideModal); + $('.upload-modal .choose-file-button').bind('click', showFileSelectionMenu); + $('.remove-asset-button').bind('click', removeAsset); +}); + +function removeAsset(e){ + e.preventDefault(); + + var that = this; + var msg = new CMS.Views.Prompt.Confirmation({ + title: gettext("Delete File Confirmation"), + message: gettext("Are you sure you wish to delete this item. It cannot be reversed!\n\nAlso any content that links/refers to this item will no longer work (e.g. broken images and/or links)"), + actions: { + primary: { + text: gettext("OK"), + click: function(view) { + // call the back-end to actually remove the asset + var url = $('.asset-library').data('remove-asset-callback-url'); + var row = $(that).closest('tr'); + $.post(url, + { 'location': row.data('id') }, + function() { + // show the post-commit confirmation + $(".wrapper-alert-confirmation").addClass("is-shown").attr('aria-hidden','false'); + row.remove(); + analytics.track('Deleted Asset', { + 'course': course_location_analytics, + 'id': row.data('id') + }); + } + ); + view.hide(); + } + }, + secondary: [{ + text: gettext("Cancel"), + click: function(view) { + view.hide(); + } + }] + } + }); + return msg.show(); +} + +function showUploadModal(e) { + e.preventDefault(); + $modal = $('.upload-modal').show(); + $('.file-input').bind('change', startUpload); + $modalCover.show(); +} + +function showFileSelectionMenu(e) { + e.preventDefault(); + $('.file-input').click(); +} + +function startUpload(e) { + var files = $('.file-input').get(0).files; + if (files.length === 0) + return; + + $('.upload-modal h1').html(gettext('Uploading…')); + $('.upload-modal .file-name').html(files[0].name); + $('.upload-modal .file-chooser').ajaxSubmit({ + beforeSend: resetUploadBar, + uploadProgress: showUploadFeedback, + complete: displayFinishedUpload + }); + $('.upload-modal .choose-file-button').hide(); + $('.upload-modal .progress-bar').removeClass('loaded').show(); +} + +function resetUploadBar() { + var percentVal = '0%'; + $('.upload-modal .progress-fill').width(percentVal); + $('.upload-modal .progress-fill').html(percentVal); +} + +function showUploadFeedback(event, position, total, percentComplete) { + var percentVal = percentComplete + '%'; + $('.upload-modal .progress-fill').width(percentVal); + $('.upload-modal .progress-fill').html(percentVal); +} + +function displayFinishedUpload(xhr) { + if (xhr.status == 200) { + markAsLoaded(); + } + + var resp = JSON.parse(xhr.responseText); + $('.upload-modal .embeddable-xml-input').val(xhr.getResponseHeader('asset_url')); + $('.upload-modal .embeddable').show(); + $('.upload-modal .file-name').hide(); + $('.upload-modal .progress-fill').html(resp.msg); + $('.upload-modal .choose-file-button').html(gettext('Load Another File')).show(); + $('.upload-modal .progress-fill').width('100%'); + + // see if this id already exists, if so, then user must have updated an existing piece of content + $("tr[data-id='" + resp.url + "']").remove(); + + var template = $('#new-asset-element').html(); + var html = Mustache.to_html(template, resp); + $('table > tbody').prepend(html); + + // re-bind the listeners to delete it + $('.remove-asset-button').bind('click', removeAsset); + + analytics.track('Uploaded a File', { + 'course': course_location_analytics, + 'asset_url': resp.url + }); +} diff --git a/cms/static/js/views/feedback.js b/cms/static/js/views/feedback.js index 1a1a33ec1b..3bfeeb5af2 100644 --- a/cms/static/js/views/feedback.js +++ b/cms/static/js/views/feedback.js @@ -1,39 +1,73 @@ -CMS.Views.Alert = Backbone.View.extend({ +CMS.Views.SystemFeedback = Backbone.View.extend({ options: { - type: "alert", + title: "", + message: "", + intent: null, // "warning", "confirmation", "error", "announcement", "step-required", etc + type: null, // "alert", "notification", or "prompt": set by subclass shown: true, // is this view currently being shown? icon: true, // should we render an icon related to the message intent? closeIcon: true, // should we render a close button in the top right corner? minShown: 0, // length of time after this view has been shown before it can be hidden (milliseconds) maxShown: Infinity // length of time after this view has been shown before it will be automatically hidden (milliseconds) + + /* Could also have an "actions" hash: here is an example demonstrating + the expected structure. For each action, by default the framework + will call preventDefault on the click event before the function is + run; to make it not do that, just pass `preventDefault: false` in + the action object. + + actions: { + primary: { + "text": "Save", + "class": "action-save", + "click": function(view) { + // do something when Save is clicked + } + }, + secondary: [ + { + "text": "Cancel", + "class": "action-cancel", + "click": function(view) {} + }, { + "text": "Discard Changes", + "class": "action-discard", + "click": function(view) {} + } + ] + } + */ }, initialize: function() { + if(!this.options.type) { + throw "SystemFeedback: type required (given " + + JSON.stringify(this.options) + ")"; + } + if(!this.options.intent) { + throw "SystemFeedback: intent required (given " + + JSON.stringify(this.options) + ")"; + } var tpl = $("#system-feedback-tpl").text(); if(!tpl) { console.error("Couldn't load system-feedback template"); } this.template = _.template(tpl); this.setElement($("#page-"+this.options.type)); - this.listenTo(this.model, 'change', this.render); - return this.show(); - }, - render: function() { - var attrs = $.extend({}, this.options, this.model.attributes); - this.$el.html(this.template(attrs)); + // handle single "secondary" action + if (this.options.actions && this.options.actions.secondary && + !_.isArray(this.options.actions.secondary)) { + this.options.actions.secondary = [this.options.actions.secondary]; + } return this; }, - events: { - "click .action-close": "hide", - "click .action-primary": "primaryClick", - "click .action-secondary": "secondaryClick" - }, + // public API: show() and hide() show: function() { clearTimeout(this.hideTimeout); this.options.shown = true; this.shownAt = new Date(); this.render(); if($.isNumeric(this.options.maxShown)) { - this.hideTimeout = setTimeout($.proxy(this.hide, this), + this.hideTimeout = setTimeout(_.bind(this.hide, this), this.options.maxShown); } return this; @@ -43,7 +77,7 @@ CMS.Views.Alert = Backbone.View.extend({ this.options.minShown > new Date() - this.shownAt) { clearTimeout(this.hideTimeout); - this.hideTimeout = setTimeout($.proxy(this.hide, this), + this.hideTimeout = setTimeout(_.bind(this.hide, this), this.options.minShown - (new Date() - this.shownAt)); } else { this.options.shown = false; @@ -52,40 +86,70 @@ CMS.Views.Alert = Backbone.View.extend({ } return this; }, - primaryClick: function() { - var actions = this.model.get("actions"); + // the rest of the API should be considered semi-private + events: { + "click .action-close": "hide", + "click .action-primary": "primaryClick", + "click .action-secondary": "secondaryClick" + }, + render: function() { + // there can be only one active view of a given type at a time: only + // one alert, only one notification, only one prompt. Therefore, we'll + // use a singleton approach. + var parent = CMS.Views[_.str.capitalize(this.options.type)]; + if(parent && parent.active && parent.active !== this) { + parent.active.stopListening(); + parent.active.undelegateEvents(); + } + this.$el.html(this.template(this.options)); + parent.active = this; + return this; + }, + primaryClick: function(event) { + var actions = this.options.actions; if(!actions) { return; } var primary = actions.primary; if(!primary) { return; } + if(primary.preventDefault !== false) { + event.preventDefault(); + } if(primary.click) { - primary.click.call(this.model, this); + primary.click.call(event.target, this, event); } }, - secondaryClick: function(e) { - var actions = this.model.get("actions"); + secondaryClick: function(event) { + var actions = this.options.actions; if(!actions) { return; } var secondaryList = actions.secondary; if(!secondaryList) { return; } // which secondary action was clicked? var i = 0; // default to the first secondary action (easier for testing) - if(e && e.target) { - i = _.indexOf(this.$(".action-secondary"), e.target); + if(event && event.target) { + i = _.indexOf(this.$(".action-secondary"), event.target); + } + var secondary = secondaryList[i]; + if(secondary.preventDefault !== false) { + event.preventDefault(); } - var secondary = this.model.get("actions").secondary[i]; if(secondary.click) { - secondary.click.call(this.model, this); + secondary.click.call(event.target, this, event); } } }); -CMS.Views.Notification = CMS.Views.Alert.extend({ - options: $.extend({}, CMS.Views.Alert.prototype.options, { +CMS.Views.Alert = CMS.Views.SystemFeedback.extend({ + options: $.extend({}, CMS.Views.SystemFeedback.prototype.options, { + type: "alert" + }) +}); +CMS.Views.Notification = CMS.Views.SystemFeedback.extend({ + options: $.extend({}, CMS.Views.SystemFeedback.prototype.options, { type: "notification", closeIcon: false }) }); -CMS.Views.Prompt = CMS.Views.Alert.extend({ - options: $.extend({}, CMS.Views.Alert.prototype.options, { +CMS.Views.Prompt = CMS.Views.SystemFeedback.extend({ + options: $.extend({}, CMS.Views.SystemFeedback.prototype.options, { type: "prompt", closeIcon: false, icon: false @@ -98,6 +162,27 @@ CMS.Views.Prompt = CMS.Views.Alert.extend({ $body.removeClass('prompt-is-shown'); } // super() in Javascript has awkward syntax :( - return CMS.Views.Alert.prototype.render.apply(this, arguments); + return CMS.Views.SystemFeedback.prototype.render.apply(this, arguments); } }); + +// create CMS.Views.Alert.Warning, CMS.Views.Notification.Confirmation, +// CMS.Views.Prompt.StepRequired, etc +var capitalCamel, types, intents; +capitalCamel = _.compose(_.str.capitalize, _.str.camelize); +types = ["alert", "notification", "prompt"]; +intents = ["warning", "error", "confirmation", "announcement", "step-required", "help", "saving"]; +_.each(types, function(type) { + _.each(intents, function(intent) { + // "class" is a reserved word in Javascript, so use "klass" instead + var klass, subklass; + klass = CMS.Views[capitalCamel(type)]; + subklass = klass.extend({ + options: $.extend({}, klass.prototype.options, { + type: type, + intent: intent + }) + }); + klass[capitalCamel(intent)] = subklass; + }); +}); diff --git a/cms/static/js/views/section.js b/cms/static/js/views/section.js index 622249414d..eccc547a06 100644 --- a/cms/static/js/views/section.js +++ b/cms/static/js/views/section.js @@ -67,7 +67,7 @@ CMS.Views.SectionEdit = Backbone.View.extend({ showInvalidMessage: function(model, error, options) { model.set("name", model.previous("name")); var that = this; - var msg = new CMS.Models.ErrorMessage({ + var prompt = new CMS.Views.Prompt.Error({ title: gettext("Your change could not be saved"), message: error, actions: { @@ -80,6 +80,6 @@ CMS.Views.SectionEdit = Backbone.View.extend({ } } }); - new CMS.Views.Prompt({model: msg}); + prompt.show(); } }); diff --git a/cms/static/js/views/settings/advanced_view.js b/cms/static/js/views/settings/advanced_view.js index 863393d341..302a918de1 100644 --- a/cms/static/js/views/settings/advanced_view.js +++ b/cms/static/js/views/settings/advanced_view.js @@ -20,9 +20,6 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ self.render(); } ); - // because these are outside of this.$el, they can't be in the event hash - $('.save-button').on('click', this, this.saveView); - $('.cancel-button').on('click', this, this.revertView); this.listenTo(this.model, 'invalid', this.handleValidationError); }, render: function() { @@ -45,7 +42,6 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ var policyValues = listEle$.find('.json'); _.each(policyValues, this.attachJSONEditor, this); - this.showMessage(); return this; }, attachJSONEditor : function (textarea) { @@ -61,7 +57,9 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ mode: "application/json", lineNumbers: false, lineWrapping: false, onChange: function(instance, changeobj) { // this event's being called even when there's no change :-( - if (instance.getValue() !== oldValue) self.showSaveCancelButtons(); + if (instance.getValue() !== oldValue && !self.notificationBarShowing) { + self.showNotificationBar(); + } }, onFocus : function(mirror) { $(textarea).parent().children('label').addClass("is-focused"); @@ -99,59 +97,65 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ } }); }, - showMessage: function (type) { - $(".wrapper-alert").removeClass("is-shown"); - if (type) { - if (type === this.error_saving) { - $(".wrapper-alert-error").addClass("is-shown").attr('aria-hidden','false'); - } - else if (type === this.successful_changes) { - $(".wrapper-alert-confirmation").addClass("is-shown").attr('aria-hidden','false'); - this.hideSaveCancelButtons(); - } - } - else { - // This is the case of the page first rendering, or when Cancel is pressed. - this.hideSaveCancelButtons(); + showNotificationBar: function() { + var self = this; + var message = gettext("Your changes will not take effect until you save your progress. Take care with key and value formatting, as validation is not implemented.") + var confirm = new CMS.Views.Notification.Warning({ + title: gettext("You've Made Some Changes"), + message: message, + actions: { + primary: { + "text": gettext("Save Changes"), + "class": "action-save", + "click": function() { + self.saveView(); + confirm.hide(); + self.notificationBarShowing = false; + } + }, + secondary: [{ + "text": gettext("Cancel"), + "class": "action-cancel", + "click": function() { + self.revertView(); + confirm.hide(); + self.notificationBarShowing = false; + } + }] + }}); + this.notificationBarShowing = true; + confirm.show(); + if(this.saved) { + this.saved.hide(); } }, - showSaveCancelButtons: function(event) { - if (!this.notificationBarShowing) { - this.$el.find(".message-status").removeClass("is-shown"); - $('.wrapper-notification').removeClass('is-hiding').addClass('is-shown').attr('aria-hidden','false'); - this.notificationBarShowing = true; - } - }, - hideSaveCancelButtons: function() { - if (this.notificationBarShowing) { - $('.wrapper-notification').removeClass('is-shown').addClass('is-hiding').attr('aria-hidden','true'); - this.notificationBarShowing = false; - } - }, - saveView : function(event) { - window.CmsUtils.smoothScrollTop(event); + saveView : function() { // TODO one last verification scan: // call validateKey on each to ensure proper format // check for dupes - var self = event.data; - self.model.save({}, + var self = this; + this.model.save({}, { success : function() { self.render(); - self.showMessage(self.successful_changes); + var message = gettext("Please note that validation of your policy key and value pairs is not currently in place yet. If you are having difficulties, please review your policy pairs."); + self.saved = new CMS.Views.Alert.Confirmation({ + title: gettext("Your policy changes have been saved."), + message: message, + closeIcon: false + }); + self.saved.show(); analytics.track('Saved Advanced Settings', { 'course': course_location_analytics }); - } }); }, - revertView : function(event) { - event.preventDefault(); - var self = event.data; - self.model.deleteKeys = []; - self.model.clear({silent : true}); - self.model.fetch({ + revertView : function() { + var self = this; + this.model.deleteKeys = []; + this.model.clear({silent : true}); + this.model.fetch({ success : function() { self.render(); }, reset: true }); diff --git a/cms/static/sass/_variables.scss b/cms/static/sass/_variables.scss index 14c215c7fd..bad87952d6 100644 --- a/cms/static/sass/_variables.scss +++ b/cms/static/sass/_variables.scss @@ -24,16 +24,16 @@ $f-monospace: 'Bitstream Vera Sans Mono', Consolas, Courier, monospace; // colors - new for re-org $black: rgb(0,0,0); -$black-t0: rgba(0,0,0,0.125); -$black-t1: rgba(0,0,0,0.25); -$black-t2: rgba(0,0,0,0.50); -$black-t3: rgba(0,0,0,0.75); +$black-t0: rgba($black, 0.125); +$black-t1: rgba($black, 0.25); +$black-t2: rgba($black, 0.5); +$black-t3: rgba($black, 0.75); $white: rgb(255,255,255); -$white-t0: rgba(255,255,255,0.125); -$white-t1: rgba(255,255,255,0.25); -$white-t2: rgba(255,255,255,0.50); -$white-t3: rgba(255,255,255,0.75); +$white-t0: rgba($white, 0.125); +$white-t1: rgba($white, 0.25); +$white-t2: rgba($white, 0.5); +$white-t3: rgba($white, 0.75); $gray: rgb(127,127,127); $gray-l1: tint($gray,20%); @@ -63,10 +63,10 @@ $blue-s3: saturate($blue,45%); $blue-u1: desaturate($blue,15%); $blue-u2: desaturate($blue,30%); $blue-u3: desaturate($blue,45%); -$blue-t0: rgba(85, 151, 221,0.125); -$blue-t1: rgba(85, 151, 221,0.25); -$blue-t2: rgba(85, 151, 221,0.50); -$blue-t3: rgba(85, 151, 221,0.75); +$blue-t0: rgba($blue, 0.125); +$blue-t1: rgba($blue, 0.25); +$blue-t2: rgba($blue, 0.50); +$blue-t3: rgba($blue, 0.75); $pink: rgb(183, 37, 103); $pink-l1: tint($pink,20%); @@ -153,10 +153,11 @@ $orange-u1: desaturate($orange,15%); $orange-u2: desaturate($orange,30%); $orange-u3: desaturate($orange,45%); -$shadow: rgba(0,0,0,0.2); -$shadow-l1: rgba(0,0,0,0.1); -$shadow-l2: rgba(0,0,0,0.05); -$shadow-d1: rgba(0,0,0,0.4); +$shadow: rgba($black, 0.2); +$shadow-l1: rgba($black, 0.1); +$shadow-l2: rgba($black, 0.05); +$shadow-d1: rgba($black, 0.4); +$shadow-d2: rgba($black, 0.6); // ==================== @@ -186,4 +187,3 @@ $error-red: rgb(253, 87, 87); // type $sans-serif: $f-sans-serif; $body-line-height: golden-ratio(.875em, 1); - diff --git a/cms/static/sass/elements/_system-help.scss b/cms/static/sass/elements/_system-help.scss index 7fcb218282..a9a3e16128 100644 --- a/cms/static/sass/elements/_system-help.scss +++ b/cms/static/sass/elements/_system-help.scss @@ -1,2 +1,40 @@ // studio - elements - system help // ==================== + +// notices - in-context: to be used as notices to users within the context of a form/action +.notice-incontext { + @extend .ui-well; + @include border-radius(($baseline/10)); + + .title { + @extend .t-title7; + margin-bottom: ($baseline/4); + font-weight: 600; + } + + .copy { + @extend .t-copy-sub1; + @include transition(opacity 0.25s ease-in-out 0); + opacity: 0.75; + } + + strong { + font-weight: 600; + } + + &:hover { + + .copy { + opacity: 1.0; + } + } +} + +// particular warnings around a workflow for something +.notice-workflow { + background: $yellow-l5; + + .copy { + color: $gray-d1; + } +} diff --git a/cms/static/sass/views/_assets.scss b/cms/static/sass/views/_assets.scss index d01dd988ef..d4cff42ee9 100644 --- a/cms/static/sass/views/_assets.scss +++ b/cms/static/sass/views/_assets.scss @@ -76,6 +76,10 @@ body.course.uploads { width: 250px; } + .delete-col { + width: 20px; + } + .embeddable-xml-input { @include box-shadow(none); width: 100%; diff --git a/cms/static/sass/views/_settings.scss b/cms/static/sass/views/_settings.scss index 735774511f..cbb1034626 100644 --- a/cms/static/sass/views/_settings.scss +++ b/cms/static/sass/views/_settings.scss @@ -21,7 +21,7 @@ body.course.settings { font-size: 14px; } - .message-status { + .message-status { display: none; @include border-top-radius(2px); @include box-sizing(border-box); @@ -52,6 +52,12 @@ body.course.settings { } } + // notices - used currently for edx mktg + .notice-workflow { + margin-top: ($baseline); + } + + // in form - elements .group-settings { margin: 0 0 ($baseline*2) 0; diff --git a/cms/templates/asset_index.html b/cms/templates/asset_index.html index f03a9012f8..abbc5bb1b4 100644 --- a/cms/templates/asset_index.html +++ b/cms/templates/asset_index.html @@ -1,5 +1,6 @@ <%inherit file="base.html" /> <%! from django.core.urlresolvers import reverse %> +<%! from django.utils.translation import ugettext as _ %> <%block name="bodyclass">is-signedin course uploads <%block name="title">Files & Uploads @@ -30,6 +31,9 @@ + + + @@ -56,7 +60,7 @@
-
+
@@ -64,6 +68,7 @@ + @@ -86,6 +91,9 @@ + % endfor @@ -129,3 +137,21 @@ + +<%block name="view_alerts"> + +
+
+ + +
+

${_('Your file has been deleted.')}

+
+ + + + ${_('close alert')} + +
+
+ diff --git a/cms/templates/base.html b/cms/templates/base.html index 07587860e5..695a97f1da 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -38,6 +38,7 @@ + @@ -54,15 +55,12 @@ -
<%include file="widgets/header.html" /> - ## remove this block after advanced settings notification is rewritten - <%block name="view_alerts">
<%block name="content"> @@ -74,13 +72,9 @@ <%include file="widgets/footer.html" /> <%include file="widgets/tender.html" /> - ## remove this block after advanced settings notification is rewritten - <%block name="view_notifications">
- ## remove this block after advanced settings notification is rewritten - <%block name="view_prompts">
<%block name="jsextra"> diff --git a/cms/templates/emails/activation_email.txt b/cms/templates/emails/activation_email.txt index 5a1d63b670..4badb4ca88 100644 --- a/cms/templates/emails/activation_email.txt +++ b/cms/templates/emails/activation_email.txt @@ -1,4 +1,4 @@ -Thank you for signing up for edX edge! To activate your account, +Thank you for signing up for edX Studio! To activate your account, please copy and paste this address into your web browser's address bar: diff --git a/cms/templates/emails/activation_email_subject.txt b/cms/templates/emails/activation_email_subject.txt index 0b0fb2ffe9..f4ffdccb14 100644 --- a/cms/templates/emails/activation_email_subject.txt +++ b/cms/templates/emails/activation_email_subject.txt @@ -1 +1 @@ -Your account for edX edge +Your account for edX Studio diff --git a/cms/templates/new_item.html b/cms/templates/new_item.html deleted file mode 100644 index 45cb157845..0000000000 --- a/cms/templates/new_item.html +++ /dev/null @@ -1,19 +0,0 @@ -
-
${parent_name}
-
${parent_location}
- -
- % for module_type, module_templates in templates: -
-
${module_type}
-
- % for template in module_templates: - ${template.display_name_with_default} - % endfor -
-
- % endfor -
- Cancel -
- diff --git a/cms/templates/overview.html b/cms/templates/overview.html index 43d0afc263..a504d50019 100644 --- a/cms/templates/overview.html +++ b/cms/templates/overview.html @@ -167,7 +167,8 @@ %else: Will Release: ${date_utils.get_default_time_display(section.lms.start)} - Edit + Edit %endif diff --git a/cms/templates/settings.html b/cms/templates/settings.html index 2adc0cd980..14c79e586a 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -1,3 +1,5 @@ +<%! from django.utils.translation import ugettext as _ %> + <%inherit file="base.html" /> <%block name="title">Schedule & Details Settings <%block name="bodyclass">is-signedin course schedule settings @@ -50,8 +52,8 @@ from contentstore import utils

- Settings - > Schedule & Details + ${_("Settings")} + > ${_("Schedule & Details")}

@@ -62,59 +64,68 @@ from contentstore import utils
-

Basic Information

- The nuts and bolts of your course +

${_("Basic Information")}

+ ${_("The nuts and bolts of your course")}
  1. - - + +
  2. - - + +
  3. - - + +
-

Course Summary Page (for student enrollment and access)

+

${_("Course Summary Page")} ${_("(for student enrollment and access)")}

+ + % if not about_page_editable: +
+

${_("Promoting Your Course with edX")}

+
+

${_('Your course summary page will not be viewable until your course has been announced. To provide content for the page and preview it, follow the instructions provided by your PM or Conrad Warre (conrad@edx.org).')}

+
+
+ % endif

-

Course Schedule

- Important steps and segments of your course +

${_('Course Schedule')}

+ ${_('Dates that control when your course can be viewed.')}
  1. - + - First day the course begins + ${_("First day the course begins")}
    - +
    @@ -122,29 +133,30 @@ from contentstore import utils
  2. - + - Last day your course is active + ${_("Last day your course is active")}
    - +
+ % if about_page_editable:
  1. - + - First day students can enroll + ${_("First day students can enroll")}
    - +
    @@ -152,91 +164,106 @@ from contentstore import utils
  2. - + - Last day students can enroll + ${_("Last day students can enroll")}
    - +
-
+ % endif + % if not about_page_editable: +
+

${_("These Dates Are Not Used When Promoting Your Course")}

+
+

${_('These dates impact when your courseware can be viewed, but they are not the dates shown on your course summary page. To provide the course start and registration dates as shown on your course summary page, follow the instructions provided by your PM or Conrad Warre (conrad@edx.org).')}

+
+
+ % endif +
+ % if about_page_editable: +
+
+

${_("Introducing Your Course")}

+ ${_("Information for prospective students")} +
-
-
-

Introducing Your Course

- Information for prospective students -
+
    +
  1. + + + <%def name='overview_text()'><% + a_link_start = '' + _("your course summary page") + '' + a_link = a_link_start + utils.get_lms_link_for_about_page(course_location) + a_link_end + text = _("Introductions, prerequisites, FAQs that are used on %s (formatted in HTML)") % a_link + %>${text} + ${overview_text()} +
  2. -
      -
    1. - - - Introductions, prerequisites, FAQs that are used on your course summary page (formatted in HTML) -
    2. +
    3. + + -
    4. - -
      -
      - -
      - -
      +
      + + ${_("Enter your YouTube video's ID (along with any restriction parameters)")} +
      +
    5. +
    +
-
- - Enter your YouTube video's ID (along with any restriction parameters) -
- - -
+
-
+
+
+

${_("Requirements")}

+ ${_("Expectations of the students taking this course")} +
-
-
-

Requirements

- Expectations of the students taking this course -
- -
    -
  1. - - - Time spent on all course work -
  2. -
-
+
    +
  1. + + + ${_("Time spent on all course work")} +
  2. +
+
+ % endif -
Name Date Added URL
+ +
+ + + + + + + + + + + %for tasknum, instructor_task in enumerate(instructor_tasks): + + + + + + + + + + + %endfor +
Task TypeTask inputsTask IdRequesterSubmittedTask StateDuration (sec)Task Progress
${instructor_task.task_type}${instructor_task.task_input}${instructor_task.task_id}${instructor_task.requester}${instructor_task.created}${instructor_task.task_state}unknownunknown
+ +
+ +%endif + +##----------------------------------------------------------------------------- + +%if course_stats and modeflag.get('Psychometrics') is None: + +
+
+

+


+

${course_stats['title'] | h}

+ + + %for hname in course_stats['header']: + + %endfor + + %for row in course_stats['data']: + + %for value in row: + + %endfor + + %endfor +
${hname | h}
${value | h}
+

+%endif + ##----------------------------------------------------------------------------- %if modeflag.get('Psychometrics'): diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index e2fbaed9cf..c41d753444 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -138,8 +138,14 @@
Full Name (edit)
${ user.profile.name | h }
  • - Email (edit) ${ user.email | h } + Email + % if external_auth_map is None or 'shib' not in external_auth_map.external_domain: + (edit) + % endif + ${ user.email | h }
  • + + % if external_auth_map is None or 'shib' not in external_auth_map.external_domain:
  • Reset Password
    @@ -147,6 +153,8 @@
  • + % endif + diff --git a/lms/templates/emails/enroll_email_allowedmessage.txt b/lms/templates/emails/enroll_email_allowedmessage.txt new file mode 100644 index 0000000000..eab347166e --- /dev/null +++ b/lms/templates/emails/enroll_email_allowedmessage.txt @@ -0,0 +1,13 @@ +Dear student, + +You have been invited to join ${course_id} at ${site_name} by a member of the course staff. + +To finish your registration, please visit ${registration_url} and fill out the registration form. +% if auto_enroll: +Once you have registered and activated your account, you will see ${course_id} listed on your dashboard. +% else: +Once you have registered and activated your account, visit ${course_url} to join the course. +% endif + +---- +This email was automatically sent from ${site_name} to ${email_address} \ No newline at end of file diff --git a/lms/templates/emails/enroll_email_allowedsubject.txt b/lms/templates/emails/enroll_email_allowedsubject.txt new file mode 100644 index 0000000000..41da60d1db --- /dev/null +++ b/lms/templates/emails/enroll_email_allowedsubject.txt @@ -0,0 +1 @@ +You have been invited to register for ${course_id} \ No newline at end of file diff --git a/lms/templates/emails/enroll_email_enrolledmessage.txt b/lms/templates/emails/enroll_email_enrolledmessage.txt new file mode 100644 index 0000000000..8e8f24efed --- /dev/null +++ b/lms/templates/emails/enroll_email_enrolledmessage.txt @@ -0,0 +1,8 @@ +Dear ${first_name} ${last_name} + +You have been enrolled in ${course_id} at ${site_name} by a member of the course staff. The course should now appear on your ${site_name} dashboard. + +To start accessing course materials, please visit ${course_url} + +---- +This email was automatically sent from ${site_name} to ${first_name} ${last_name} \ No newline at end of file diff --git a/lms/templates/emails/enroll_email_enrolledsubject.txt b/lms/templates/emails/enroll_email_enrolledsubject.txt new file mode 100644 index 0000000000..db897a3299 --- /dev/null +++ b/lms/templates/emails/enroll_email_enrolledsubject.txt @@ -0,0 +1 @@ +You have been enrolled in ${course_id} \ No newline at end of file diff --git a/lms/templates/emails/unenroll_email_allowedmessage.txt b/lms/templates/emails/unenroll_email_allowedmessage.txt new file mode 100644 index 0000000000..9bd0bd3cfd --- /dev/null +++ b/lms/templates/emails/unenroll_email_allowedmessage.txt @@ -0,0 +1,6 @@ +Dear Student, + +You have been un-enrolled from course ${course_id} by a member of the course staff. Please disregard the invitation previously sent. + +---- +This email was automatically sent from ${site_name} to ${email_address} \ No newline at end of file diff --git a/lms/templates/emails/unenroll_email_enrolledmessage.txt b/lms/templates/emails/unenroll_email_enrolledmessage.txt new file mode 100644 index 0000000000..8a7f9f996e --- /dev/null +++ b/lms/templates/emails/unenroll_email_enrolledmessage.txt @@ -0,0 +1,8 @@ +Dear ${first_name} ${last_name} + +You have been un-enrolled in ${course_id} at ${site_name} by a member of the course staff. The course will no longer appear on your ${site_name} dashboard. + +Your other courses have not been affected. + +---- +This email was automatically sent from ${site_name} to ${first_name} ${last_name} \ No newline at end of file diff --git a/lms/templates/emails/unenroll_email_subject.txt b/lms/templates/emails/unenroll_email_subject.txt new file mode 100644 index 0000000000..f79218ff22 --- /dev/null +++ b/lms/templates/emails/unenroll_email_subject.txt @@ -0,0 +1 @@ +You have been un-enrolled from ${course_id} \ No newline at end of file diff --git a/lms/templates/extauth_failure.html b/lms/templates/extauth_failure.html index fa53ab1084..330c63e604 100644 --- a/lms/templates/extauth_failure.html +++ b/lms/templates/extauth_failure.html @@ -2,10 +2,10 @@ "http://www.w3.org/TR/html4/strict.dtd"> - OpenID failed + External Authentication failed -

    OpenID failed

    +

    External Authentication failed

    ${message}

    diff --git a/lms/templates/main.html b/lms/templates/main.html index b00446d190..5c0c383b84 100644 --- a/lms/templates/main.html +++ b/lms/templates/main.html @@ -21,17 +21,17 @@ Home | class.stanford.edu % else: edX + + % endif - diff --git a/lms/templates/navigation.html b/lms/templates/navigation.html index 190a58f691..a26e1ca367 100644 --- a/lms/templates/navigation.html +++ b/lms/templates/navigation.html @@ -95,16 +95,26 @@ site_status_msg = get_site_status_msg(course_id) % endif % if not settings.MITX_FEATURES['DISABLE_LOGIN_BUTTON']: - + % if course and settings.MITX_FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD') and course.enrollment_domain: + + % else: + + % endif % endif diff --git a/lms/templates/register.html b/lms/templates/register.html index 73a6df9319..1a42d402e5 100644 --- a/lms/templates/register.html +++ b/lms/templates/register.html @@ -136,16 +136,37 @@ % else:
    -

    Welcome ${extauth_email}

    +

    Welcome ${extauth_id}

    Enter a public username:

      + + % if ask_for_email: + +
    1. + + +
    2. + + % endif +
    3. Will be shown in any discussions or forums you participate in
    4. + + % if ask_for_fullname: + +
    5. + + + Needed for any certificates you may earn (cannot be changed later) +
    6. + + % endif +
    % endif @@ -210,11 +231,16 @@
    1. + + % if has_extauth_info is UNDEFINED or ask_for_tos : +
      + % endif +
      <% @@ -246,6 +272,8 @@

      Registration Help

      + % if has_extauth_info is UNDEFINED: +

      Already registered?

      @@ -254,6 +282,8 @@

      + + % endif ## TODO: Use a %block tag or something to allow themes to ## override in a more generalizable fashion. diff --git a/lms/templates/registration/password_reset_email.html b/lms/templates/registration/password_reset_email.html index bf6c3e0891..68073d9ddd 100644 --- a/lms/templates/registration/password_reset_email.html +++ b/lms/templates/registration/password_reset_email.html @@ -3,7 +3,7 @@ {% trans "Please go to the following page and choose a new password:" %} {% block reset_link %} -https://{{domain}}{% url 'django.contrib.auth.views.password_reset_confirm' uidb36=uid token=token %} +https://{{domain}}{% url 'student.views.password_reset_confirm_wrapper' uidb36=uid token=token %} {% endblock %} If you didn't request this change, you can disregard this email - we have not yet reset your password. diff --git a/lms/templates/signup_modal.html b/lms/templates/signup_modal.html index a68e36e902..9c1a868e2d 100644 --- a/lms/templates/signup_modal.html +++ b/lms/templates/signup_modal.html @@ -32,11 +32,23 @@ % else: -

      Welcome ${extauth_email}


      +

      Welcome ${extauth_id}


      Enter a public username:

      - + - + + + % if ask_for_email: + + + % endif + + + % if ask_for_fullname: + + + % endif + % endif
      diff --git a/lms/templates/simplewiki/simplewiki_base.html b/lms/templates/simplewiki/simplewiki_base.html deleted file mode 100644 index e19d8d61ca..0000000000 --- a/lms/templates/simplewiki/simplewiki_base.html +++ /dev/null @@ -1,164 +0,0 @@ -##This file is based on the template from the SimpleWiki source which carries the GPL license - -<%inherit file="../main.html"/> -<%namespace name='static' file='../static_content.html'/> - -<%block name="headextra"> - <%static:css group='course'/> - - -<%! - from django.core.urlresolvers import reverse - from simplewiki.views import wiki_reverse -%> - -<%block name="js_extra"> - - -## TODO (cpennington): Remove this when we have a good way for modules to specify js to load on the page -## and in the wiki - - - - - - - <%block name="wiki_head"/> - - - -<%block name="bodyextra"> - -%if course: -<%include file="/courseware/course_navigation.html" args="active_page='wiki'" /> -%endif - -
      -
      - <%block name="wiki_panel"> -
      -

      Course Wiki

      -
        -
      • -

        - All Articles -

        -
      • - -
      • -

        - Create Article -

        - -
        - <% - baseURL = wiki_reverse("wiki_create", course=course, kwargs={"article_path" : namespace + "/" }) - %> -
        -
        - - -
        -
          -
        • - -
        • -
        -
        -
        -
      • - - -
      - -
      - - -
      - %if wiki_article is not UNDEFINED: -
      - %if wiki_article.locked: -

      This article has been locked

      - %endif -

      Last modified: ${wiki_article.modified_on.strftime("%b %d, %Y, %I:%M %p")}

      - %endif - - %if wiki_article is not UNDEFINED: - -
      - %endif - - <%block name="wiki_page_title"/> - <%block name="wiki_body"/> -
      -
      -
      - diff --git a/lms/templates/simplewiki/simplewiki_edit.html b/lms/templates/simplewiki/simplewiki_edit.html deleted file mode 100644 index 0381a21857..0000000000 --- a/lms/templates/simplewiki/simplewiki_edit.html +++ /dev/null @@ -1,76 +0,0 @@ -##This file is based on the template from the SimpleWiki source which carries the GPL license - -<%inherit file="simplewiki_base.html"/> - -<%block name="title"> - -%if create_article: -Wiki – Create Article – MITx 6.002x -%else: -${"Edit " + wiki_title + " - " if wiki_title is not UNDEFINED else ""}MITx 6.002x Wiki -%endif - - -<%block name="wiki_page_title"> -%if create_article: -

      Create article

      -%else: -

      ${ wiki_article.title }

      -%endif - - -<%block name="wiki_head"> - - - - - - - - - - -<%block name="wiki_body"> -
      -
      - -
      - ${wiki_form} - %if create_article: - - %else: - - - %endif - -<%include file="simplewiki_instructions.html"/> - - diff --git a/lms/templates/simplewiki/simplewiki_error.html b/lms/templates/simplewiki/simplewiki_error.html deleted file mode 100644 index 0ce0763def..0000000000 --- a/lms/templates/simplewiki/simplewiki_error.html +++ /dev/null @@ -1,79 +0,0 @@ -##This file is based on the template from the SimpleWiki source which carries the GPL license - -<%inherit file="simplewiki_base.html"/> - -<%! - from simplewiki.views import wiki_reverse -%> - -<%block name="title">Wiki Error – MITx 6.002x - - -<%block name="wiki_page_title"> -

      Oops...

      - - - -<%block name="wiki_body"> -
      -%if wiki_error is not UNDEFINED: -${wiki_error} -%endif - -%if wiki_err_notfound is not UNDEFINED: -

      - The page you requested could not be found. - Click here to create it. -

      -%elif wiki_err_no_namespace is not UNDEFINED and wiki_err_no_namespace: -

      - You must specify a namespace to create an article in. -

      -%elif wiki_err_bad_namespace is not UNDEFINED and wiki_err_bad_namespace: -

      - The namespace for this article does not exist. This article cannot be created. -

      -%elif wiki_err_locked is not UNDEFINED and wiki_err_locked: -

      - The article you are trying to modify is locked. -

      -%elif wiki_err_noread is not UNDEFINED and wiki_err_noread: -

      - You do not have access to read this article. -

      -%elif wiki_err_nowrite is not UNDEFINED and wiki_err_nowrite: -

      - You do not have access to edit this article. -

      -%elif wiki_err_noanon is not UNDEFINED and wiki_err_noanon: -

      - Anonymous attachments are not allowed. Try logging in. -

      -%elif wiki_err_create is not UNDEFINED and wiki_err_create: -

      - You do not have access to create this article. -

      -%elif wiki_err_encode is not UNDEFINED and wiki_err_encode: -

      - The url you requested could not be handled by the wiki. - Probably you used a bad character in the URL. - Only use digits, English letters, underscore and dash. For instance - /wiki/An_Article-1 -

      -%elif wiki_err_deleted is not UNDEFINED and wiki_err_deleted: -

      - The article you tried to access has been deleted. You may be able to restore it to an earlier version in its history, or create a new version. -

      -%elif wiki_err_norevision is not UNDEFINED: -

      - This article does not contain revision ${wiki_err_norevision | h}. -

      -%else: -

      - An error has occured. -

      -%endif - -
      - - diff --git a/lms/templates/simplewiki/simplewiki_history.html b/lms/templates/simplewiki/simplewiki_history.html deleted file mode 100644 index 0fc77eeb0c..0000000000 --- a/lms/templates/simplewiki/simplewiki_history.html +++ /dev/null @@ -1,92 +0,0 @@ -##This file is based on the template from the SimpleWiki source which carries the GPL license - -<%inherit file="simplewiki_base.html"/> - -<%block name="title">${"Revision history of " + wiki_title + " - " if wiki_title is not UNDEFINED else ""}Wiki – MITx 6.002x - -<%! - from django.core.urlresolvers import reverse - from simplewiki.views import wiki_reverse -%> - -<%block name="wiki_page_title"> -

      -${ wiki_article.title } -

      - - -<%block name="wiki_body"> - -
      - -
      - - - - - - - - - - - <% loopCount = 0 %> - %for revision in wiki_history: - %if revision.deleted < 2 or show_delete_revision: - <% loopCount += 1 %> - - - - - - - %endif - %endfor - - %if wiki_prev_page or wiki_next_page: - - - - - - %endif -
      RevisionCommentDiffModified
      - - - - ${ revision.revision_text if revision.revision_text else "None" } - %for x in revision.get_diff(): - ${x|h}
      - %endfor
      ${revision.get_user()} -
      - ${revision.revision_date.strftime("%b %d, %Y, %I:%M %p")} -
      - %if wiki_prev_page: - Previous page - %endif - %if wiki_next_page: - Next page - %endif -
      -
      - - %if show_delete_revision: - - - - - %endif -
      -
      - diff --git a/lms/templates/simplewiki/simplewiki_instructions.html b/lms/templates/simplewiki/simplewiki_instructions.html deleted file mode 100644 index 449b92b004..0000000000 --- a/lms/templates/simplewiki/simplewiki_instructions.html +++ /dev/null @@ -1,24 +0,0 @@ -
      - This wiki uses Markdown for styling. -

      MITx Additions:

      -

      circuit-schematic:

      -

      $LaTeX Math Expression$

      - To create a new wiki article, create a link to it. Clicking the link gives you the creation page. -

      [Article Name](wiki:ArticleName)

      - -

      Useful examples:

      -

      [Link](http://google.com)

      -

      Huge Header -
      ====

      -

      Smaller Header -
      -------

      -

      *emphasis* or _emphasis_

      -

      **strong** or __strong__

      -

      - Unordered List -
        - Sub Item 1 -
        - Sub Item 2

      -

      1. Ordered -
      2. List

      - -

      Need more help? There are several useful guides online.

      -
      diff --git a/lms/templates/simplewiki/simplewiki_revision_feed.html b/lms/templates/simplewiki/simplewiki_revision_feed.html deleted file mode 100644 index 69b69afdff..0000000000 --- a/lms/templates/simplewiki/simplewiki_revision_feed.html +++ /dev/null @@ -1,63 +0,0 @@ -##This file is based on the template from the SimpleWiki source which carries the GPL license - -<%inherit file="simplewiki_base.html"/> - -<%block name="title">Wiki - Revision feed - MITx 6.002x - -<%! - from simplewiki.views import wiki_reverse -%> - -<%block name="wiki_page_title"> -

      Revision Feed - Page ${wiki_page}

      - - -<%block name="wiki_body"> - - - - - - - - - - - <% loopCount = 0 %> - %for revision in wiki_history: - %if revision.deleted < 2 or show_delete_revision: - <% loopCount += 1 %> - - - - - - - %endif - %endfor - - %if wiki_prev_page or wiki_next_page: - - - - - - %endif -
      RevisionCommentDiffModified
      - ${revision.article.title} - ${revision} - - ${ revision.revision_text if revision.revision_text else "None" } - %for x in revision.get_diff(): - ${x|h}
      - %endfor
      ${revision.get_user()} -
      - ${revision.revision_date.strftime("%b %d, %Y, %I:%M %p")} -
      - %if wiki_prev_page: - Previous page - %endif - %if wiki_next_page: - Next page - %endif -
      - diff --git a/lms/templates/simplewiki/simplewiki_searchresults.html b/lms/templates/simplewiki/simplewiki_searchresults.html deleted file mode 100644 index e64a01ae62..0000000000 --- a/lms/templates/simplewiki/simplewiki_searchresults.html +++ /dev/null @@ -1,34 +0,0 @@ -##This file is based on the template from the SimpleWiki source which carries the GPL license - -<%inherit file="simplewiki_base.html"/> - -<%block name="title">Wiki - Search Results - MITx 6.002x - -<%! - from simplewiki.views import wiki_reverse -%> - -<%block name="wiki_page_title"> -

      -%if wiki_search_query: -Search results for ${wiki_search_query | h} -%else: -Displaying all articles -%endif -

      - - -<%block name="wiki_body"> -
      -
        -%for article in wiki_search_results: -<% article_deleted = not article.current_revision.deleted == 0 %> -
      • ${article.title} ${'(Deleted)' if article_deleted else ''}

      • -%endfor - -%if not wiki_search_results: -No articles matching ${wiki_search_query if wiki_search_query is not UNDEFINED else ""} ! -%endif -
      -
      - diff --git a/lms/templates/simplewiki/simplewiki_updateprogressbar.html b/lms/templates/simplewiki/simplewiki_updateprogressbar.html deleted file mode 100644 index a7739d6bf1..0000000000 --- a/lms/templates/simplewiki/simplewiki_updateprogressbar.html +++ /dev/null @@ -1,37 +0,0 @@ -##This file is based on the template from the SimpleWiki source which carries the GPL license -##This file has been converted to Mako, but not tested. It is because uploads are disabled for the wiki. If they are reenabled, this may contain bugs. -<%! - from django.template.defaultfilters import filesizeformat -%> - - -%if started: - -%else: -%if finished: - -%else: -%if overwrite_warning: - -%else: -%if too_big: - -%else: - -%endif -%endif -%endif -%endif diff --git a/lms/templates/simplewiki/simplewiki_view.html b/lms/templates/simplewiki/simplewiki_view.html deleted file mode 100644 index 53f0030eaf..0000000000 --- a/lms/templates/simplewiki/simplewiki_view.html +++ /dev/null @@ -1,15 +0,0 @@ -##This file is based on the template from the SimpleWiki source which carries the GPL license - -<%inherit file="simplewiki_base.html"/> - -<%block name="title">${wiki_title + " - " if wiki_title is not UNDEFINED else ""}Wiki – MITx 6.002x - -<%block name="wiki_page_title"> -

      ${ wiki_article.title } ${'- Deleted Revision!' if wiki_current_revision_deleted else ''}

      - - -<%block name="wiki_body"> -
      - ${ wiki_article_revision.contents_parsed| n} -
      - diff --git a/lms/templates/video.html b/lms/templates/video.html index 267372176a..77c8a5ee16 100644 --- a/lms/templates/video.html +++ b/lms/templates/video.html @@ -2,37 +2,34 @@

      ${display_name}

      % endif -%if settings.MITX_FEATURES['STUB_VIDEO_FOR_TESTING']: -
      -
      -
      -
      -
      -
        -
      • -
      • -
        0:00 / 0:00
        -
      • -
      - -
      -
      -
      -
      -%elif settings.MITX_FEATURES.get('USE_YOUTUBE_OBJECT_API') and normal_speed_video_id: +%if settings.MITX_FEATURES.get('USE_YOUTUBE_OBJECT_API') and normal_speed_video_id: + % if not settings.MITX_FEATURES['STUB_VIDEO_FOR_TESTING']: + value="https://www.youtube.com/v/${normal_speed_video_id}?version=3&autoplay=1&rel=0"> + % endif + - + %else: -
      +
      diff --git a/lms/templates/videoalpha.html b/lms/templates/videoalpha.html index 2028d3c320..2bb5d817a8 100644 --- a/lms/templates/videoalpha.html +++ b/lms/templates/videoalpha.html @@ -2,33 +2,38 @@

      ${display_name}

      % endif -%if settings.MITX_FEATURES['STUB_VIDEO_FOR_TESTING']: -
      -%else: -
      -
      -
      -
      -
      -
      -
      -
      -
      -
      -%endif +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      % if sources.get('main'):
      diff --git a/lms/templates/widgets/segment-io.html b/lms/templates/widgets/segment-io.html index dea222653e..dd9787a77c 100644 --- a/lms/templates/widgets/segment-io.html +++ b/lms/templates/widgets/segment-io.html @@ -1,9 +1,7 @@ +% if settings.MITX_FEATURES.get('SEGMENT_IO_LMS'): +% else: + + + +% endif \ No newline at end of file diff --git a/lms/urls.py b/lms/urls.py index 8f393584ac..52a7d99aaf 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -3,7 +3,8 @@ from django.conf.urls import patterns, include, url from django.contrib import admin from django.conf.urls.static import static -from . import one_time_startup +# Not used, the work is done in the imported module. +from . import one_time_startup # pylint: disable=W0611 import django.contrib.auth.views @@ -50,7 +51,7 @@ urlpatterns = ('', # nopep8 url(r'^password_change_done/$', django.contrib.auth.views.password_change_done, name='auth_password_change_done'), url(r'^password_reset_confirm/(?P[0-9A-Za-z]+)-(?P.+)/$', - django.contrib.auth.views.password_reset_confirm, + 'student.views.password_reset_confirm_wrapper', name='auth_password_reset_confirm'), url(r'^password_reset_complete/$', django.contrib.auth.views.password_reset_complete, name='auth_password_reset_complete'), @@ -98,6 +99,8 @@ if not settings.MITX_FEATURES["USE_CUSTOM_THEME"]: url(r'^press$', 'student.views.press', name="press"), url(r'^media-kit$', 'static_template_view.views.render', {'template': 'media-kit.html'}, name="media-kit"), + url(r'^faq$', 'static_template_view.views.render', + {'template': 'faq.html'}, name="faq_edx"), url(r'^help$', 'static_template_view.views.render', {'template': 'help.html'}, name="help_edx"), @@ -113,8 +116,6 @@ if not settings.MITX_FEATURES["USE_CUSTOM_THEME"]: url(r'^submit_feedback$', 'util.views.submit_feedback'), - # TODO: These urls no longer work. They need to be updated before they are re-enabled - # url(r'^reactivate/(?P[^/]*)$', 'student.views.reactivation_email'), ) # Only enable URLs for those marketing links actually enabled in the @@ -125,7 +126,7 @@ for key, value in settings.MKTG_URL_LINK_MAP.items(): continue # These urls are enabled separately - if key == "ROOT" or key == "COURSES": + if key == "ROOT" or key == "COURSES" or key == "FAQ": continue # Make the assumptions that the templates are all in the same dir @@ -187,7 +188,7 @@ if settings.COURSEWARE_ENABLED: # into the database. url(r'^software-licenses$', 'licenses.views.user_software_license', name="user_software_license"), - url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/xqueue/(?P[^/]*)/(?P.*?)/(?P[^/]*)$', + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/xqueue/(?P[^/]*)/(?P.*?)/(?P[^/]*)$', 'courseware.module_render.xqueue_callback', name='xqueue_callback'), url(r'^change_setting$', 'student.views.change_setting', @@ -361,6 +362,21 @@ if settings.MITX_FEATURES.get('AUTH_USE_OPENID'): url(r'^openid/logo.gif$', 'django_openid_auth.views.logo', name='openid-logo'), ) +if settings.MITX_FEATURES.get('AUTH_USE_SHIB'): + urlpatterns += ( + url(r'^shib-login/$', 'external_auth.views.shib_login', name='shib-login'), + ) + +if settings.MITX_FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD'): + urlpatterns += ( + url(r'^course_specific_login/(?P[^/]+/[^/]+/[^/]+)/$', + 'external_auth.views.course_specific_login', name='course-specific-login'), + url(r'^course_specific_register/(?P[^/]+/[^/]+/[^/]+)/$', + 'external_auth.views.course_specific_register', name='course-specific-register'), + + ) + + if settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'): urlpatterns += ( url(r'^openid/provider/login/$', 'external_auth.views.provider_login', name='openid-provider-login'), @@ -392,6 +408,17 @@ if settings.MITX_FEATURES.get('ENABLE_SERVICE_STATUS'): url(r'^status/', include('service_status.urls')), ) +if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'): + urlpatterns += ( + url(r'^instructor_task_status/$', 'instructor_task.views.instructor_task_status', name='instructor_task_status'), + ) + +if settings.MITX_FEATURES.get('RUN_AS_ANALYTICS_SERVER_ENABLED'): + urlpatterns += ( + url(r'^edinsights_service/', include('edinsights.core.urls')), + ) + import edinsights.core.registry + # FoldIt views urlpatterns += ( # The path is hardcoded into their app... diff --git a/lms/wsgi_apache_lms.py b/lms/wsgi_apache_lms.py new file mode 100644 index 0000000000..0f9950ca41 --- /dev/null +++ b/lms/wsgi_apache_lms.py @@ -0,0 +1,15 @@ +import os + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "lms.envs.aws") +os.environ.setdefault("SERVICE_VARIANT", "lms") + +# This application object is used by the development server +# as well as any WSGI server configured to use this file. +from django.core.wsgi import get_wsgi_application +application = get_wsgi_application() + +from django.conf import settings +from xmodule.modulestore.django import modulestore + +for store_name in settings.MODULESTORE: + modulestore(store_name) diff --git a/lms/xmodule_namespace.py b/lms/xmodule_namespace.py index 6b78d18db0..aaef0b76db 100644 --- a/lms/xmodule_namespace.py +++ b/lms/xmodule_namespace.py @@ -1,15 +1,15 @@ """ Namespace that defines fields common to all blocks used in the LMS """ -from xblock.core import Namespace, Boolean, Scope, String -from xmodule.fields import Date, Timedelta, StringyFloat, StringyBoolean +from xblock.core import Namespace, Boolean, Scope, String, Float +from xmodule.fields import Date, Timedelta class LmsNamespace(Namespace): """ Namespace that defines fields common to all blocks used in the LMS """ - hide_from_toc = StringyBoolean( + hide_from_toc = Boolean( help="Whether to display this module in the table of contents", default=False, scope=Scope.settings @@ -37,7 +37,7 @@ class LmsNamespace(Namespace): ) showanswer = String(help="When to show the problem answer to the student", scope=Scope.settings, default="closed") rerandomize = String(help="When to rerandomize the problem", default="always", scope=Scope.settings) - days_early_for_beta = StringyFloat( + days_early_for_beta = Float( help="Number of days early to show content to beta users", default=None, scope=Scope.settings diff --git a/pylintrc b/pylintrc index d4085379b4..dea0f240c6 100644 --- a/pylintrc +++ b/pylintrc @@ -35,9 +35,15 @@ load-plugins= # it should appear only once). disable= # Never going to use these +# I0011: Locally disabling W0232 # C0301: Line too long -# W0142: Used * or ** magic # W0141: Used builtin function 'map' +# W0142: Used * or ** magic + I0011,C0301,W0141,W0142, + +# Django makes classes that trigger these +# W0232: Class has no __init__ method + W0232, # Might use these when the code is in better shape # C0302: Too many lines in module @@ -50,7 +56,7 @@ disable= # R0912: Too many branches # R0913: Too many arguments # R0914: Too many local variables - C0301,C0302,W0141,W0142,R0201,R0901,R0902,R0903,R0904,R0911,R0912,R0913,R0914 + C0302,R0201,R0901,R0902,R0903,R0904,R0911,R0912,R0913,R0914 [REPORTS] diff --git a/rakefile b/rakefile index 20101a14db..2cf442bca9 100644 --- a/rakefile +++ b/rakefile @@ -1,9 +1,11 @@ -require 'json' -require 'rake/clean' -require './rakefiles/helpers.rb' - -Dir['rakefiles/*.rake'].each do |rakefile| - import rakefile +begin + require 'json' + require 'rake/clean' + require './rakelib/helpers.rb' +rescue LoadError => error + puts "Import failed (#{error})" + puts "Please run `bundle install` to bootstrap ruby dependencies" + exit 1 end # Build Constants diff --git a/rakefiles/assets.rake b/rakelib/assets.rake similarity index 78% rename from rakefiles/assets.rake rename to rakelib/assets.rake index 009c87048c..5c8abc1fb0 100644 --- a/rakefiles/assets.rake +++ b/rakelib/assets.rake @@ -6,6 +6,8 @@ if USE_CUSTOM_THEME THEME_SASS = File.join(THEME_ROOT, "static", "sass") end +MINIMAL_DARWIN_NOFILE_LIMIT = 8000 + def xmodule_cmd(watch=false, debug=false) xmodule_cmd = 'xmodule_assets common/static/xmodule' if watch @@ -21,24 +23,14 @@ def xmodule_cmd(watch=false, debug=false) end def coffee_cmd(watch=false, debug=false) - 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 - # - # So, instead, we use watchmedo, which works around the problem - "watchmedo shell-command " + - "--command 'node_modules/.bin/coffee -c ${watch_src_path}' " + - "--recursive " + - "--patterns '*.coffee' " + - "--ignore-directories " + - "--wait " + - "." - else - 'node_modules/.bin/coffee --compile .' + if watch && Launchy::Application.new.host_os_family.darwin? + available_files = Process::getrlimit(:NOFILE)[0] + if available_files < MINIMAL_DARWIN_NOFILE_LIMIT + Process.setrlimit(:NOFILE, MINIMAL_DARWIN_NOFILE_LIMIT) + + end end + "node_modules/.bin/coffee --compile #{watch ? '--watch' : ''} ." end def sass_cmd(watch=false, debug=false) @@ -52,11 +44,12 @@ def sass_cmd(watch=false, debug=false) "sass #{debug ? '--debug-info' : '--style compressed'} " + "--load-path #{sass_load_paths.join(' ')} " + "--require ./common/static/sass/bourbon/lib/bourbon.rb " + - "#{watch ? '--watch' : '--update'} #{sass_watch_paths.join(' ')}" + "#{watch ? '--watch' : '--update'} -E utf-8 #{sass_watch_paths.join(' ')}" end +# This task takes arguments purely to pass them via dependencies to the preprocess task desc "Compile all assets" -multitask :assets => 'assets:all' +task :assets, [:system, :env] => 'assets:all' namespace :assets do @@ -78,10 +71,11 @@ namespace :assets do end {:xmodule => [:install_python_prereqs], - :coffee => [:install_node_prereqs], + :coffee => [:install_node_prereqs, :'assets:coffee:clobber'], :sass => [:install_ruby_prereqs, :preprocess]}.each_pair do |asset_type, prereq_tasks| + # This task takes arguments purely to pass them via dependencies to the preprocess task desc "Compile all #{asset_type} assets" - task asset_type => prereq_tasks do + task asset_type, [:system, :env] => prereq_tasks do |t, args| cmd = send(asset_type.to_s + "_cmd", watch=false, debug=false) if cmd.kind_of?(Array) cmd.each {|c| sh(c)} @@ -90,7 +84,8 @@ namespace :assets do end end - multitask :all => asset_type + # This task takes arguments purely to pass them via dependencies to the preprocess task + multitask :all, [:system, :env] => asset_type multitask :debug => "assets:#{asset_type}:debug" multitask :_watch => "assets:#{asset_type}:_watch" @@ -111,9 +106,9 @@ namespace :assets do task :_watch => (prereq_tasks + ["assets:#{asset_type}:debug"]) do cmd = send(asset_type.to_s + "_cmd", watch=true, debug=true) if cmd.kind_of?(Array) - cmd.each {|c| background_process(c)} + cmd.each {|c| singleton_process(c)} else - background_process(cmd) + singleton_process(cmd) end end end @@ -127,6 +122,11 @@ namespace :assets do multitask :coffee => 'assets:xmodule' namespace :coffee do multitask :debug => 'assets:xmodule:debug' + + desc "Remove compiled coffeescript files" + task :clobber do + FileList['*/static/coffee/**/*.js'].each {|f| File.delete(f)} + end end namespace :xmodule do diff --git a/rakefiles/deploy.rake b/rakelib/deploy.rake similarity index 100% rename from rakefiles/deploy.rake rename to rakelib/deploy.rake diff --git a/rakefiles/deprecated.rake b/rakelib/deprecated.rake similarity index 100% rename from rakefiles/deprecated.rake rename to rakelib/deprecated.rake diff --git a/rakefiles/django.rake b/rakelib/django.rake similarity index 100% rename from rakefiles/django.rake rename to rakelib/django.rake diff --git a/rakefiles/docs.rake b/rakelib/docs.rake similarity index 89% rename from rakefiles/docs.rake rename to rakelib/docs.rake index f10fc80d59..2247b686fa 100644 --- a/rakefiles/docs.rake +++ b/rakelib/docs.rake @@ -22,9 +22,7 @@ task :showdocs, [:options] do |t, args| path = "docs" end - Dir.chdir("#{path}/build/html") do - Launchy.open('index.html') - end + Launchy.open("#{path}/build/html/index.html") end desc "Build docs and show them in browser" diff --git a/rakefiles/helpers.rb b/rakelib/helpers.rb similarity index 90% rename from rakefiles/helpers.rb rename to rakelib/helpers.rb index 4b10bef709..3373214a19 100644 --- a/rakefiles/helpers.rb +++ b/rakelib/helpers.rb @@ -1,4 +1,6 @@ require 'digest/md5' +require 'sys/proctable' +require 'colorize' def find_executable(exec) path = %x(which #{exec}).strip @@ -84,6 +86,16 @@ def background_process(*command) end end +# Runs a command as a background process, as long as no other processes +# tagged with the same tag are running +def singleton_process(*command) + if Sys::ProcTable.ps.select {|proc| proc.cmdline.include?(command.join(' '))}.empty? + background_process(*command) + else + puts "Process '#{command.join(' ')} already running, skipping".blue + end +end + def environments(system) Dir["#{system}/envs/**/*.py"].select{|file| ! (/__init__.py$/ =~ file)}.map do |env_file| env_file.gsub("#{system}/envs/", '').gsub(/\.py/, '').gsub('/', '.') diff --git a/rakefiles/i18n.rake b/rakelib/i18n.rake similarity index 100% rename from rakefiles/i18n.rake rename to rakelib/i18n.rake diff --git a/rakefiles/jasmine.rake b/rakelib/jasmine.rake similarity index 87% rename from rakefiles/jasmine.rake rename to rakelib/jasmine.rake index ab3209c9ec..ff72161937 100644 --- a/rakefiles/jasmine.rake +++ b/rakelib/jasmine.rake @@ -61,10 +61,10 @@ def template_jasmine_runner(lib) yield File.expand_path(template_output) end -def jasmine_browser(url, wait=10) +def jasmine_browser(url, jitter=3, wait=10) # Jitter starting the browser so that the tests don't all try and # start the browser simultaneously - sleep(rand(3)) + sleep(rand(jitter)) sh("python -m webbrowser -t '#{url}'") sleep(wait) end @@ -80,15 +80,24 @@ end namespace :jasmine do namespace system do desc "Open jasmine tests for #{system} in your default browser" - task :browser do + task :browser => [:clean_reports_dir] do Rake::Task[:assets].invoke(system, 'jasmine') django_for_jasmine(system, true) do |jasmine_url| jasmine_browser(jasmine_url) end end + desc "Open jasmine tests for #{system} in your default browser, and dynamically recompile coffeescript" + task :'browser:watch' => [:clean_reports_dir, :'assets:coffee:_watch'] do + django_for_jasmine(system, true) do |jasmine_url| + jasmine_browser(jasmine_url, jitter=0, wait=0) + end + puts "Press ENTER to terminate".red + $stdin.gets + end + desc "Use phantomjs to run jasmine tests for #{system} from the console" - task :phantomjs do + task :phantomjs => [:clean_reports_dir] do Rake::Task[:assets].invoke(system, 'jasmine') phantomjs = ENV['PHANTOMJS_PATH'] || 'phantomjs' django_for_jasmine(system, false) do |jasmine_url| @@ -113,7 +122,7 @@ static_js_dirs.each do |dir| namespace :jasmine do namespace dir do desc "Open jasmine tests for #{dir} in your default browser" - task :browser do + task :browser => [:clean_reports_dir] do # We need to use either CMS or LMS to preprocess files. Use LMS by default Rake::Task['assets:coffee'].invoke('lms', 'jasmine') template_jasmine_runner(dir) do |f| @@ -122,7 +131,7 @@ static_js_dirs.each do |dir| end desc "Use phantomjs to run jasmine tests for #{dir} from the console" - task :phantomjs do + task :phantomjs => [:clean_reports_dir] do # We need to use either CMS or LMS to preprocess files. Use LMS by default Rake::Task[:assets].invoke('lms', 'jasmine') template_jasmine_runner(dir) do |f| diff --git a/rakefiles/prereqs.rake b/rakelib/prereqs.rake similarity index 98% rename from rakefiles/prereqs.rake rename to rakelib/prereqs.rake index ff8b4b8784..e06d411435 100644 --- a/rakefiles/prereqs.rake +++ b/rakelib/prereqs.rake @@ -1,5 +1,3 @@ -require './rakefiles/helpers.rb' - PREREQS_MD5_DIR = ENV["PREREQ_CACHE_DIR"] || File.join(REPO_ROOT, '.prereqs_cache') CLOBBER.include(PREREQS_MD5_DIR) diff --git a/rakefiles/quality.rake b/rakelib/quality.rake similarity index 100% rename from rakefiles/quality.rake rename to rakelib/quality.rake diff --git a/rakefiles/tests.rake b/rakelib/tests.rake similarity index 73% rename from rakefiles/tests.rake rename to rakelib/tests.rake index b4754c2c3c..2bbe3a6ad8 100644 --- a/rakefiles/tests.rake +++ b/rakelib/tests.rake @@ -1,6 +1,9 @@ # Set up the clean and clobber tasks CLOBBER.include(REPORT_DIR, 'test_root/*_repo', 'test_root/staticfiles') +# Create the directory to hold coverage reports, if it doesn't already exist. +directory REPORT_DIR + def run_under_coverage(cmd, root) cmd0, cmd_rest = cmd.split(" ", 2) # We use "python -m coverage" so that the proper python will run the importable coverage @@ -13,7 +16,7 @@ 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/*"] test_id = dirs.join(' ') if test_id.nil? or test_id == '' - cmd = django_admin(system, :test, 'test', '--logging-clear-handlers', test_id) + cmd = django_admin(system, :test, 'test', '--logging-clear-handlers', '--liveserver=localhost:8000-9000', test_id) test_sh(run_under_coverage(cmd, system)) end @@ -33,13 +36,32 @@ def run_acceptance_tests(system, report_dir, harvest_args) test_sh(django_admin(system, 'acceptance', 'harvest', '--debug-mode', '--tag -skip', harvest_args)) end - -directory REPORT_DIR +# Run documentation tests +desc "Run documentation tests" +task :test_docs do + # Be sure that sphinx can build docs w/o exceptions. + test_message = "If test fails, you shoud run %s and look at whole output and fix exceptions. +(You shouldn't fix rst warnings and errors for this to pass, just get rid of exceptions.)" + puts (test_message % ["rake doc"]).colorize( :light_green ) + test_sh('rake builddocs') + puts (test_message % ["rake doc[pub]"]).colorize( :light_green ) + test_sh('rake builddocs[pub]') +end task :clean_test_files do + desc "Clean fixture files used by tests" sh("git clean -fqdx test_root") end +task :clean_reports_dir => REPORT_DIR do + desc "Clean coverage files, to ensure that we don't use stale data to generate reports." + + # We delete the files but preserve the directory structure + # so that coverage.py has a place to put the reports. + sh("find #{REPORT_DIR} -type f -delete") +end + + TEST_TASK_DIRS = [] [:lms, :cms].each do |system| @@ -47,21 +69,21 @@ TEST_TASK_DIRS = [] # Per System tasks desc "Run all django tests on our djangoapps for the #{system}" - task "test_#{system}", [:test_id] => ["clean_test_files", :predjango, "#{system}:gather_assets:test", "fasttest_#{system}"] + task "test_#{system}", [:test_id] => [: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}", [:test_id] => [report_dir, :install_prereqs, :predjango] do |t, args| + task "fasttest_#{system}", [:test_id] => [report_dir, :clean_reports_dir, :install_prereqs, :predjango] do |t, args| args.with_defaults(:test_id => nil) run_tests(system, report_dir, args.test_id) end # Run acceptance tests desc "Run acceptance tests" - task "test_acceptance_#{system}", [:harvest_args] => ["#{system}:gather_assets:acceptance", "fasttest_acceptance_#{system}"] + task "test_acceptance_#{system}", [:harvest_args] => [:clean_test_files, "#{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| + task "fasttest_acceptance_#{system}", [:harvest_args] => [report_dir, :clean_reports_dir, :predjango] do |t, args| args.with_defaults(:harvest_args => '') run_acceptance_tests(system, report_dir, args.harvest_args) end @@ -77,7 +99,7 @@ Dir["common/lib/*"].select{|lib| File.directory?(lib)}.each do |lib| report_dir = report_dir_path(lib) desc "Run tests for common lib #{lib}" - task "test_#{lib}" => ["clean_test_files", report_dir] do + task "test_#{lib}" => [report_dir, :clean_reports_dir] do ENV['NOSE_XUNIT_FILE'] = File.join(report_dir, "nosetests.xml") cmd = "nosetests #{lib}" test_sh(run_under_coverage(cmd, lib)) @@ -103,7 +125,7 @@ TEST_TASK_DIRS.each do |dir| end desc "Run all tests" -task :test +task :test => :test_docs desc "Build the html, xml, and diff coverage reports" task :coverage => :report_dirs do diff --git a/rakefiles/workspace.rake b/rakelib/workspace.rake similarity index 100% rename from rakefiles/workspace.rake rename to rakelib/workspace.rake diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 8f3d4594ac..f64568dc10 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -3,11 +3,11 @@ # Third-party: -e git://github.com/edx/django-staticfiles.git@6d2504e5c8#egg=django-staticfiles -e git://github.com/edx/django-pipeline.git#egg=django-pipeline --e git://github.com/edx/django-wiki.git@e2e84558#egg=django-wiki +-e git://github.com/edx/django-wiki.git@ac906abe#egg=django-wiki -e git://github.com/dementrock/pystache_custom.git@776973740bdaad83a3b029f96e415a7d1e8bec2f#egg=pystache_custom-dev -e git://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk # Our libraries: --e git+https://github.com/edx/XBlock.git@2144a25d#egg=XBlock +-e git+https://github.com/edx/XBlock.git@4d8735e883#egg=XBlock -e git+https://github.com/edx/codejail.git@0a1b468#egg=codejail --e git+https://github.com/edx/diff-cover.git@v0.1.1#egg=diff_cover +-e git+https://github.com/edx/diff-cover.git@v0.1.3#egg=diff_cover diff --git a/scripts/create-dev-env.sh b/scripts/create-dev-env.sh index ede86b123a..0816b72d21 100755 --- a/scripts/create-dev-env.sh +++ b/scripts/create-dev-env.sh @@ -73,7 +73,7 @@ change_git_push_defaults() { #Set git push defaults to upstream rather than master output "Changing git defaults" git config --global push.default upstream - + } clone_repos() { @@ -96,13 +96,31 @@ clone_repos() { fi } +set_base_default() { # if PROJECT_HOME not set + # 2 possibilities: this is from cloned repo, or not + + # See if remote's url is named edx-platform (this works for forks too, but + # not if the name was changed). + cd "$( dirname "${BASH_SOURCE[0]}" )" + this_repo=$(basename $(git ls-remote --get-url 2>/dev/null) 2>/dev/null) || + echo -n "" + + if [[ "x$this_repo" = "xedx-platform.git" ]]; then + # We are in the edx repo and already have git installed. Let git do the + # work of finding base dir: + echo "$(dirname $(git rev-parse --show-toplevel))" + else + echo "$HOME/edx_all" + fi +} + ### START PROG=${0##*/} # Adjust this to wherever you'd like to place the codebase -BASE="${PROJECT_HOME:-$HOME}/edx_all" +BASE="${PROJECT_HOME:-$(set_base_default)}" # Use a sensible default (~/.virtualenvs) for your Python virtualenvs # unless you've already got one set up with virtualenvwrapper. @@ -206,10 +224,10 @@ case `uname -s` in distro=`lsb_release -cs` case $distro in - wheezy|jessie|maya|olivia|nadia|precise|quantal) + wheezy|jessie|maya|olivia|nadia|precise|quantal) warning " Debian support is not fully debugged. Assuming you have standard - development packages already working like scipy rvm, the + development packages already working like scipy rvm, the installation should go fine, but this is still a work in progress. Please report issues you have and let us know if you are able to figure @@ -218,7 +236,7 @@ case `uname -s` in Press return to continue or control-C to abort" read dummy - sudo apt-get install git ;; + sudo apt-get install git ;; squeeze|lisa|katya|oneiric|natty|raring) warning " It seems like you're using $distro which has been deprecated. @@ -231,7 +249,7 @@ case `uname -s` in Press return to continue or control-C to abort" read dummy sudo apt-get install git - ;; + ;; *) error "Unsupported distribution - $distro" @@ -283,7 +301,7 @@ clone_repos if [[ -d $BASE/edx-platform/scripts ]]; then output "Installing system-level dependencies" bash $BASE/edx-platform/scripts/install-system-req.sh -else +else error "It appears that our directory structure has changed and somebody failed to update this script. raise an issue on Github and someone should fix it." exit 1 @@ -314,14 +332,14 @@ case `uname -s` in [Ll]inux) warning "Setting up rvm on linux. This is a known pain point. If the script fails here - refer to the following stack overflow question: + refer to the following stack overflow question: http://stackoverflow.com/questions/9056008/installed-ruby-1-9-3-with-rvm-but-command-line-doesnt-show-ruby-v/9056395#9056395" sudo apt-get --purge remove ruby-rvm sudo rm -rf /usr/share/ruby-rvm /etc/rvmrc /etc/profile.d/rvm.sh curl -sL https://get.rvm.io | bash -s stable --ruby --autolibs=enable --auto-dotfiles ;; esac - + # Ensure we have RVM available as a shell function so that it can mess # with the environment and set everything up properly. The RVM install @@ -494,10 +512,11 @@ cd $BASE pip install argcomplete cd $BASE/edx-platform bundle install +rake install_prereqs -mkdir "$BASE/log" || true -mkdir "$BASE/db" || true -mkdir "$BASE/data" || true +mkdir -p "$BASE/log" +mkdir -p "$BASE/db" +mkdir -p "$BASE/data" rake django-admin[syncdb] rake django-admin[migrate] diff --git a/test_root/data/videoalpha/gizmo.mp4 b/test_root/data/videoalpha/gizmo.mp4 new file mode 100644 index 0000000000..1fc478842f Binary files /dev/null and b/test_root/data/videoalpha/gizmo.mp4 differ diff --git a/test_root/data/videoalpha/gizmo.ogv b/test_root/data/videoalpha/gizmo.ogv new file mode 100644 index 0000000000..2c4a447f1f Binary files /dev/null and b/test_root/data/videoalpha/gizmo.ogv differ diff --git a/test_root/data/videoalpha/gizmo.webm b/test_root/data/videoalpha/gizmo.webm new file mode 100644 index 0000000000..95d5031a86 Binary files /dev/null and b/test_root/data/videoalpha/gizmo.webm differ