diff --git a/cms/djangoapps/contentstore/views/tasks.py b/cms/djangoapps/contentstore/tasks.py similarity index 85% rename from cms/djangoapps/contentstore/views/tasks.py rename to cms/djangoapps/contentstore/tasks.py index d0e18e62b8..8b05565eb8 100644 --- a/cms/djangoapps/contentstore/views/tasks.py +++ b/cms/djangoapps/contentstore/tasks.py @@ -4,7 +4,10 @@ This file contains celery tasks for contentstore views from celery.task import task from django.contrib.auth.models import User +import json from xmodule.modulestore.django import modulestore +from xmodule.course_module import CourseFields + from xmodule.modulestore.exceptions import DuplicateCourseError, ItemNotFoundError from course_action_state.models import CourseRerunState from contentstore.utils import initialize_permissions @@ -17,9 +20,10 @@ def rerun_course(source_course_key_string, destination_course_key_string, user_i Reruns a course in a new celery task. """ try: - # deserialize the keys + # deserialize the payload source_course_key = CourseKey.from_string(source_course_key_string) destination_course_key = CourseKey.from_string(destination_course_key_string) + fields = deserialize_fields(fields) if fields else None # use the split modulestore as the store for the rerun course, # as the Mongo modulestore doesn't support multiple runs of the same course. @@ -32,13 +36,11 @@ def rerun_course(source_course_key_string, destination_course_key_string, user_i # update state: Succeeded CourseRerunState.objects.succeeded(course_key=destination_course_key) - return "succeeded" except DuplicateCourseError as exc: # do NOT delete the original course, only update the status CourseRerunState.objects.failed(course_key=destination_course_key, exception=exc) - return "duplicate course" # catch all exceptions so we can update the state and properly cleanup the course. @@ -54,3 +56,10 @@ def rerun_course(source_course_key_string, destination_course_key_string, user_i pass return "exception: " + unicode(exc) + + +def deserialize_fields(json_fields): + fields = json.loads(json_fields) + for field_name, value in fields.iteritems(): + fields[field_name] = getattr(CourseFields, field_name).from_json(value) + return fields diff --git a/cms/djangoapps/contentstore/tests/test_clone_course.py b/cms/djangoapps/contentstore/tests/test_clone_course.py index acd2d93714..25ea5eb6e8 100644 --- a/cms/djangoapps/contentstore/tests/test_clone_course.py +++ b/cms/djangoapps/contentstore/tests/test_clone_course.py @@ -1,9 +1,15 @@ """ Unit tests for cloning a course between the same and different module stores. """ +import json from opaque_keys.edx.locator import CourseLocator -from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore import ModuleStoreEnum, EdxJSONEncoder from contentstore.tests.utils import CourseTestCase +from contentstore.tasks import rerun_course +from contentstore.views.access import has_course_access +from course_action_state.models import CourseRerunState +from course_action_state.managers import CourseRerunUIStateManager +from mock import patch, Mock class CloneCourseTest(CourseTestCase): @@ -39,3 +45,46 @@ class CloneCourseTest(CourseTestCase): ) self.store.clone_course(split_course3_id, split_course4_id, self.user.id) self.assertCoursesEqual(split_course3_id, split_course4_id) + + def test_rerun_course(self): + """ + Unit tests for :meth: `contentstore.tasks.rerun_course` + """ + mongo_course1_id = self.import_and_populate_course() + + # rerun from mongo into split + split_course3_id = CourseLocator( + org="edx3", course="split3", run="rerun_test" + ) + # Mark the action as initiated + fields = {'display_name': 'rerun'} + CourseRerunState.objects.initiated(mongo_course1_id, split_course3_id, self.user, fields['display_name']) + result = rerun_course.delay(unicode(mongo_course1_id), unicode(split_course3_id), self.user.id, + json.dumps(fields, cls=EdxJSONEncoder)) + self.assertEqual(result.get(), "succeeded") + self.assertTrue(has_course_access(self.user, split_course3_id), "Didn't grant access") + rerun_state = CourseRerunState.objects.find_first(course_key=split_course3_id) + self.assertEqual(rerun_state.state, CourseRerunUIStateManager.State.SUCCEEDED) + + # try creating rerunning again to same name and ensure it generates error + result = rerun_course.delay(unicode(mongo_course1_id), unicode(split_course3_id), self.user.id) + self.assertEqual(result.get(), "duplicate course") + # the below will raise an exception if the record doesn't exist + CourseRerunState.objects.find_first( + course_key=split_course3_id, + state=CourseRerunUIStateManager.State.FAILED + ) + + # try to hit the generic exception catch + with patch('xmodule.modulestore.split_mongo.mongo_connection.MongoConnection.insert_course_index', Mock(side_effect=Exception)): + split_course4_id = CourseLocator(org="edx3", course="split3", run="rerun_fail") + fields = {'display_name': 'total failure'} + CourseRerunState.objects.initiated(split_course3_id, split_course4_id, self.user, fields['display_name']) + result = rerun_course.delay(unicode(split_course3_id), unicode(split_course4_id), self.user.id, + json.dumps(fields, cls=EdxJSONEncoder)) + self.assertIn("exception: ", result.get()) + self.assertIsNone(self.store.get_course(split_course4_id), "Didn't delete course after error") + CourseRerunState.objects.find_first( + course_key=split_course4_id, + state=CourseRerunUIStateManager.State.FAILED + ) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 79f778ae9b..946500e299 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -5,6 +5,7 @@ import copy import mock import shutil +import lxml from datetime import timedelta from fs.osfs import OSFS @@ -47,6 +48,9 @@ from student.roles import CourseCreatorRole, CourseInstructorRole from opaque_keys import InvalidKeyError from contentstore.tests.utils import get_url from course_action_state.models import CourseRerunState, CourseRerunUIStateManager + +from unittest import skipIf + from course_action_state.managers import CourseActionStateItemNotFoundError @@ -1580,31 +1584,33 @@ class RerunCourseTest(ContentStoreTestCase): json_resp = parse_json(response) self.assertNotIn('ErrMsg', json_resp) destination_course_key = CourseKey.from_string(json_resp['destination_course_key']) - return destination_course_key - def create_unsucceeded_course_action_html(self, course_key): - """Creates html fragment that is created for the given course_key in the unsucceeded course action section""" - # TODO Update this once the Rerun UI LMS-11011 is implemented. - return '
' + data.ErrMsg + '

'); - $('.new-course-save').addClass('is-disabled'); - } - } - ); + analytics.track('Created a Course', course_info); + CreateCourseUtils.createCourse(course_info, function (errorMessage) { + $('.wrap-error').addClass('is-shown'); + $('#course_creation_error').html('

' + errorMessage + '

'); + $('.new-course-save').addClass('is-disabled'); + }); }; var cancelNewCourse = function (e) { @@ -78,91 +76,20 @@ require(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape"], $cancelButton.bind('click', cancelNewCourse); CancelOnEscape($cancelButton); - // Check that a course (org, number, run) doesn't use any special characters - var validateCourseItemEncoding = function (item) { - var required = validateRequiredField(item); - if (required) { - return required; - } - if ($('.allow-unicode-course-id').val() === 'True'){ - if (/\s/g.test(item)) { - return gettext('Please do not use any spaces in this field.'); - } - } - else{ - if (item !== encodeURIComponent(item)) { - return gettext('Please do not use any spaces or special characters in this field.'); - } - } - return ''; - }; - - // Ensure that org/course_num/run < 65 chars. - var validateTotalCourseItemsLength = function () { - var totalLength = _.reduce( - ['.new-course-org', '.new-course-number', '.new-course-run'], - function (sum, ele) { - return sum + $(ele).val().length; - }, 0 - ); - if (totalLength > 65) { - $('.wrap-error').addClass('is-shown'); - $('#course_creation_error').html('

' + gettext('The combined length of the organization, course number, and course run fields cannot be more than 65 characters.') + '

'); - $('.new-course-save').addClass('is-disabled'); - } - else { - $('.wrap-error').removeClass('is-shown'); - } - }; - - // Handle validation asynchronously - _.each( - ['.new-course-org', '.new-course-number', '.new-course-run'], - function (ele) { - var $ele = $(ele); - $ele.on('keyup', function (event) { - // Don't bother showing "required field" error when - // the user tabs into a new field; this is distracting - // and unnecessary - if (event.keyCode === 9) { - return; - } - var error = validateCourseItemEncoding($ele.val()); - setNewCourseFieldInErr($ele.parent('li'), error); - validateTotalCourseItemsLength(); - }); - } - ); - var $name = $('.new-course-name'); - $name.on('keyup', function () { - var error = validateRequiredField($name.val()); - setNewCourseFieldInErr($name.parent('li'), error); - validateTotalCourseItemsLength(); - }); + CreateCourseUtils.configureHandlers(); }; - var validateRequiredField = function (msg) { - return msg.length === 0 ? gettext('Required field.') : ''; - }; - - var setNewCourseFieldInErr = function (el, msg) { - if(msg) { - el.addClass('error'); - el.children('span.tip-error').addClass('is-showing').removeClass('is-hiding').text(msg); - $('.new-course-save').addClass('is-disabled'); - } - else { - el.removeClass('error'); - el.children('span.tip-error').addClass('is-hiding').removeClass('is-showing'); - // One "error" div is always present, but hidden or shown - if($('.error').length === 1) { - $('.new-course-save').removeClass('is-disabled'); - } - } - }; - - - domReady(function () { + var onReady = function () { $('.new-course-button').bind('click', addNewCourse); - }); + $('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function () { + ViewUtils.reload(); + })); + $('.action-reload').bind('click', ViewUtils.reload); + }; + + domReady(onReady); + + return { + onReady: onReady + }; }); diff --git a/cms/static/js/spec/views/pages/course_outline_spec.js b/cms/static/js/spec/views/pages/course_outline_spec.js index 352c201d9a..a13537051d 100644 --- a/cms/static/js/spec/views/pages/course_outline_spec.js +++ b/cms/static/js/spec/views/pages/course_outline_spec.js @@ -7,7 +7,8 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" getItemsOfType, getItemHeaders, verifyItemsExpanded, expandItemsAndVerifyState, collapseItemsAndVerifyState, createMockCourseJSON, createMockSectionJSON, createMockSubsectionJSON, verifyTypePublishable, mockCourseJSON, mockEmptyCourseJSON, mockSingleSectionCourseJSON, createMockVerticalJSON, - mockOutlinePage = readFixtures('mock/mock-course-outline-page.underscore'); + mockOutlinePage = readFixtures('mock/mock-course-outline-page.underscore'), + mockRerunNotification = readFixtures('mock/mock-course-rerun-notification.underscore'); createMockCourseJSON = function(options, children) { return $.extend(true, {}, { @@ -243,6 +244,18 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" }); }); + describe("Rerun notification", function () { + it("can be dismissed", function () { + appendSetFixtures(mockRerunNotification); + createCourseOutlinePage(this, mockEmptyCourseJSON); + expect($('.wrapper-alert-announcement')).not.toHaveClass('is-hidden'); + $('.dismiss-button').click(); + create_sinon.expectJsonRequest(requests, 'DELETE', 'dummy_dismiss_url'); + create_sinon.respondToDelete(requests); + expect($('.wrapper-alert-announcement')).toHaveClass('is-hidden'); + }); + }); + describe("Button bar", function() { it('can add a section', function() { createCourseOutlinePage(this, mockEmptyCourseJSON); diff --git a/cms/static/js/spec/views/pages/course_rerun_spec.js b/cms/static/js/spec/views/pages/course_rerun_spec.js new file mode 100644 index 0000000000..5a9eb0b87b --- /dev/null +++ b/cms/static/js/spec/views/pages/course_rerun_spec.js @@ -0,0 +1,205 @@ +define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers", "js/views/course_rerun", + "js/views/utils/create_course_utils", "js/views/utils/view_utils", "jquery.simulate"], + function ($, create_sinon, view_helpers, CourseRerunUtils, CreateCourseUtilsFactory, ViewUtils) { + describe("Create course rerun page", function () { + var selectors = { + org: '.rerun-course-org', + number: '.rerun-course-number', + run: '.rerun-course-run', + name: '.rerun-course-name', + tipError: 'span.tip-error', + save: '.rerun-course-save', + cancel: '.rerun-course-cancel', + errorWrapper: '.wrapper-error', + errorMessage: '#course_rerun_error', + error: '.error', + allowUnicode: '.allow-unicode-course-id' + }, + classes = { + shown: 'is-shown', + showing: 'is-showing', + hiding: 'is-hidden', + hidden: 'is-hidden', + error: 'error', + disabled: 'is-disabled', + processing: 'is-processing' + }, + mockCreateCourseRerunHTML = readFixtures('mock/mock-create-course-rerun.underscore'); + + var CreateCourseUtils = CreateCourseUtilsFactory(selectors, classes); + + var fillInFields = function (org, number, run, name) { + $(selectors.org).val(org); + $(selectors.number).val(number); + $(selectors.run).val(run); + $(selectors.name).val(name); + }; + + beforeEach(function () { + view_helpers.installMockAnalytics(); + window.source_course_key = 'test_course_key'; + appendSetFixtures(mockCreateCourseRerunHTML); + CourseRerunUtils.onReady(); + }); + + afterEach(function () { + view_helpers.removeMockAnalytics(); + delete window.source_course_key; + }); + + describe("Field validation", function () { + it("returns a message for an empty string", function () { + var message = CreateCourseUtils.validateRequiredField(''); + expect(message).not.toBe(''); + }); + + it("does not return a message for a non empty string", function () { + var message = CreateCourseUtils.validateRequiredField('edX'); + expect(message).toBe(''); + }); + }); + + describe("Error messages", function () { + var setErrorMessage = function(selector, message) { + var element = $(selector).parent(); + CreateCourseUtils.setNewCourseFieldInErr(element, message); + return element; + }; + + var type = function (input, value) { + input.val(value); + input.simulate("keyup", { keyCode: $.simulate.keyCode.SPACE }); + }; + + it("shows an error message", function () { + var element = setErrorMessage(selectors.org, 'error message'); + expect(element).toHaveClass(classes.error); + expect(element.children(selectors.tipError)).not.toHaveClass(classes.hidden); + expect(element.children(selectors.tipError)).toContainText('error message'); + }); + + it("hides an error message", function () { + var element = setErrorMessage(selectors.org, ''); + expect(element).not.toHaveClass(classes.error); + expect(element.children(selectors.tipError)).toHaveClass(classes.hidden); + }); + + it("disables the save button", function () { + setErrorMessage(selectors.org, 'error message'); + expect($(selectors.save)).toHaveClass(classes.disabled); + }); + + it("enables the save button when all errors are removed", function () { + setErrorMessage(selectors.org, 'error message 1'); + setErrorMessage(selectors.number, 'error message 2'); + expect($(selectors.save)).toHaveClass(classes.disabled); + setErrorMessage(selectors.org, ''); + setErrorMessage(selectors.number, ''); + expect($(selectors.save)).not.toHaveClass(classes.disabled); + }); + + it("does not enable the save button when errors remain", function () { + setErrorMessage(selectors.org, 'error message 1'); + setErrorMessage(selectors.number, 'error message 2'); + expect($(selectors.save)).toHaveClass(classes.disabled); + setErrorMessage(selectors.org, ''); + expect($(selectors.save)).toHaveClass(classes.disabled); + }); + + it("shows an error message when non URL characters are entered", function () { + var input = $(selectors.org); + expect(input.parent()).not.toHaveClass(classes.error); + type(input, "%"); + expect(input.parent()).toHaveClass(classes.error); + }); + + it("does not show an error message when tabbing into a field", function () { + var input = $(selectors.number); + input.val(''); + expect(input.parent()).not.toHaveClass(classes.error); + input.simulate("keyup", { keyCode: $.simulate.keyCode.TAB }); + expect(input.parent()).not.toHaveClass(classes.error); + }); + + it("shows an error message when a required field is empty", function () { + var input = $(selectors.org); + input.val(''); + expect(input.parent()).not.toHaveClass(classes.error); + input.simulate("keyup", { keyCode: $.simulate.keyCode.ENTER }); + expect(input.parent()).toHaveClass(classes.error); + }); + + it("shows an error message when spaces are entered and unicode is allowed", function () { + var input = $(selectors.org); + $(selectors.allowUnicode).val('True'); + expect(input.parent()).not.toHaveClass(classes.error); + type(input, ' '); + expect(input.parent()).toHaveClass(classes.error); + }); + + it("shows an error message when total length exceeds 65 characters", function () { + expect($(selectors.errorWrapper)).not.toHaveClass(classes.shown); + type($(selectors.org), 'ThisIsAVeryLongNameThatWillExceedTheSixtyFiveCharacterLimit'); + type($(selectors.number), 'ThisIsAVeryLongNameThatWillExceedTheSixtyFiveCharacterLimit'); + type($(selectors.run), 'ThisIsAVeryLongNameThatWillExceedTheSixtyFiveCharacterLimit'); + expect($(selectors.errorWrapper)).toHaveClass(classes.shown); + }); + + describe("Name field", function () { + it("does not show an error message when non URL characters are entered", function () { + var input = $(selectors.name); + expect(input.parent()).not.toHaveClass(classes.error); + type(input, "%"); + expect(input.parent()).not.toHaveClass(classes.error); + }); + }); + }); + + it("saves course reruns", function () { + var requests = create_sinon.requests(this); + var redirectSpy = spyOn(ViewUtils, 'redirect') + fillInFields('DemoX', 'DM101', '2014', 'Demo course'); + $(selectors.save).click(); + create_sinon.expectJsonRequest(requests, 'POST', '/course/', { + source_course_key: 'test_course_key', + org: 'DemoX', + number: 'DM101', + run: '2014', + display_name: 'Demo course' + }); + expect($(selectors.save)).toHaveClass(classes.disabled); + expect($(selectors.save)).toHaveClass(classes.processing); + expect($(selectors.cancel)).toHaveClass(classes.hidden); + create_sinon.respondWithJson(requests, { + url: 'dummy_test_url' + }); + expect(redirectSpy).toHaveBeenCalledWith('dummy_test_url'); + }); + + it("displays an error when saving fails", function () { + var requests = create_sinon.requests(this); + fillInFields('DemoX', 'DM101', '2014', 'Demo course'); + $(selectors.save).click(); + create_sinon.respondWithJson(requests, { + ErrMsg: 'error message' + }); + expect($(selectors.errorWrapper)).not.toHaveClass(classes.hidden); + expect($(selectors.errorWrapper)).toContainText('error message'); + expect($(selectors.save)).not.toHaveClass(classes.processing); + expect($(selectors.cancel)).not.toHaveClass(classes.hidden); + }); + + it("does not save if there are validation errors", function () { + var requests = create_sinon.requests(this); + fillInFields('DemoX', 'DM101', '', 'Demo course'); + $(selectors.save).click(); + expect(requests.length).toBe(0); + }); + + it("can be canceled", function () { + var redirectSpy = spyOn(ViewUtils, 'redirect'); + $(selectors.cancel).click(); + expect(redirectSpy).toHaveBeenCalledWith('/course/'); + }); + }); + }); diff --git a/cms/static/js/spec/views/pages/index_spec.js b/cms/static/js/spec/views/pages/index_spec.js new file mode 100644 index 0000000000..fab5b9653e --- /dev/null +++ b/cms/static/js/spec/views/pages/index_spec.js @@ -0,0 +1,65 @@ +define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers", "js/index", + "js/views/utils/view_utils"], + function ($, create_sinon, view_helpers, IndexUtils, ViewUtils) { + describe("Course listing page", function () { + var mockIndexPageHTML = readFixtures('mock/mock-index-page.underscore'), fillInFields; + + var fillInFields = function (org, number, run, name) { + $('.new-course-org').val(org); + $('.new-course-number').val(number); + $('.new-course-run').val(run); + $('.new-course-name').val(name); + }; + + beforeEach(function () { + view_helpers.installMockAnalytics(); + appendSetFixtures(mockIndexPageHTML); + IndexUtils.onReady(); + }); + + afterEach(function () { + view_helpers.removeMockAnalytics(); + delete window.source_course_key; + }); + + it("can dismiss notifications", function () { + var requests = create_sinon.requests(this); + var reloadSpy = spyOn(ViewUtils, 'reload'); + $('.dismiss-button').click(); + create_sinon.expectJsonRequest(requests, 'DELETE', 'dummy_dismiss_url'); + create_sinon.respondToDelete(requests); + expect(reloadSpy).toHaveBeenCalled(); + }); + + it("saves new courses", function () { + var requests = create_sinon.requests(this); + var redirectSpy = spyOn(ViewUtils, 'redirect'); + $('.new-course-button').click() + fillInFields('DemoX', 'DM101', '2014', 'Demo course'); + $('.new-course-save').click(); + create_sinon.expectJsonRequest(requests, 'POST', '/course/', { + org: 'DemoX', + number: 'DM101', + run: '2014', + display_name: 'Demo course' + }); + create_sinon.respondWithJson(requests, { + url: 'dummy_test_url' + }); + expect(redirectSpy).toHaveBeenCalledWith('dummy_test_url'); + }); + + it("displays an error when saving fails", function () { + var requests = create_sinon.requests(this); + $('.new-course-button').click(); + fillInFields('DemoX', 'DM101', '2014', 'Demo course'); + $('.new-course-save').click(); + create_sinon.respondWithJson(requests, { + ErrMsg: 'error message' + }); + expect($('.wrap-error')).toHaveClass('is-shown'); + expect($('#course_creation_error')).toContainText('error message'); + expect($('.new-course-save')).toHaveClass('is-disabled'); + }); + }); + }); diff --git a/cms/static/js/views/course_rerun.js b/cms/static/js/views/course_rerun.js new file mode 100644 index 0000000000..bfa6370d1b --- /dev/null +++ b/cms/static/js/views/course_rerun.js @@ -0,0 +1,87 @@ +define(["domReady", "jquery", "underscore", "js/views/utils/create_course_utils", "js/views/utils/view_utils"], + function (domReady, $, _, CreateCourseUtilsFactory, ViewUtils) { + var CreateCourseUtils = CreateCourseUtilsFactory({ + name: '.rerun-course-name', + org: '.rerun-course-org', + number: '.rerun-course-number', + run: '.rerun-course-run', + save: '.rerun-course-save', + errorWrapper: '.wrapper-error', + errorMessage: '#course_rerun_error', + tipError: 'span.tip-error', + error: '.error', + allowUnicode: '.allow-unicode-course-id' + }, { + shown: 'is-shown', + showing: 'is-showing', + hiding: 'is-hidden', + disabled: 'is-disabled', + error: 'error' + }); + + var saveRerunCourse = function (e) { + e.preventDefault(); + + if (CreateCourseUtils.hasInvalidRequiredFields()) { + return; + } + + var $newCourseForm = $(this).closest('#rerun-course-form'); + var display_name = $newCourseForm.find('.rerun-course-name').val(); + var org = $newCourseForm.find('.rerun-course-org').val(); + var number = $newCourseForm.find('.rerun-course-number').val(); + var run = $newCourseForm.find('.rerun-course-run').val(); + + course_info = { + source_course_key: source_course_key, + org: org, + number: number, + display_name: display_name, + run: run + }; + + analytics.track('Reran a Course', course_info); + CreateCourseUtils.createCourse(course_info, function (errorMessage) { + $('.wrapper-error').addClass('is-shown').removeClass('is-hidden'); + $('#course_rerun_error').html('

' + errorMessage + '

'); + $('.rerun-course-save').addClass('is-disabled').removeClass('is-processing').html(gettext('Create Re-run')); + $('.action-cancel').removeClass('is-hidden'); + }); + + // Go into creating re-run state + $('.rerun-course-save').addClass('is-disabled').addClass('is-processing').html( + '' + gettext('Processing Re-run Request') + ); + $('.action-cancel').addClass('is-hidden'); + }; + + var cancelRerunCourse = function (e) { + e.preventDefault(); + // Clear out existing fields and errors + $('.rerun-course-run').val(''); + $('#course_rerun_error').html(''); + $('wrapper-error').removeClass('is-shown').addClass('is-hidden'); + $('.rerun-course-save').off('click'); + ViewUtils.redirect('/course/'); + }; + + var onReady = function () { + var $cancelButton = $('.rerun-course-cancel'); + var $courseRun = $('.rerun-course-run'); + $courseRun.focus().select(); + $('.rerun-course-save').on('click', saveRerunCourse); + $cancelButton.bind('click', cancelRerunCourse); + $('.cancel-button').bind('click', cancelRerunCourse); + + CreateCourseUtils.configureHandlers(); + }; + + domReady(onReady); + + // Return these functions so that they can be tested + return { + saveRerunCourse: saveRerunCourse, + cancelRerunCourse: cancelRerunCourse, + onReady: onReady + }; + }); diff --git a/cms/static/js/views/overview.js b/cms/static/js/views/overview.js index f33f0c908f..f83d46ee9e 100644 --- a/cms/static/js/views/overview.js +++ b/cms/static/js/views/overview.js @@ -1,7 +1,7 @@ define(["domReady", "jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notification", - "js/utils/cancel_on_escape", "js/utils/date_utils", "js/utils/module"], + "js/utils/cancel_on_escape", "js/utils/date_utils", "js/utils/module", "js/views/utils/view_utils"], function (domReady, $, ui, _, gettext, NotificationView, CancelOnEscape, - DateUtils, ModuleUtils) { + DateUtils, ModuleUtils, ViewUtils) { var modalSelector = '.edit-section-publish-settings'; @@ -222,6 +222,10 @@ define(["domReady", "jquery", "jquery.ui", "underscore", "gettext", "js/views/fe $('.toggle-button-sections').bind('click', toggleSections); $('.expand-collapse').bind('click', toggleSubmodules); + $('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function () { + $('.wrapper-alert-announcement').remove(); + })); + var $body = $('body'); $body.on('click', '.section-published-date .edit-release-date', editSectionPublishDate); $body.on('click', '.edit-section-publish-settings .action-save', saveSetSectionScheduleDate); diff --git a/cms/static/js/views/pages/course_outline.js b/cms/static/js/views/pages/course_outline.js index a227642945..a812eb7b02 100644 --- a/cms/static/js/views/pages/course_outline.js +++ b/cms/static/js/views/pages/course_outline.js @@ -2,8 +2,8 @@ * This page is used to show the user an outline of the course. */ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views/utils/xblock_utils", - "js/views/course_outline"], - function ($, _, gettext, BasePage, XBlockViewUtils, CourseOutlineView) { + "js/views/course_outline", "js/views/utils/view_utils"], + function ($, _, gettext, BasePage, XBlockViewUtils, CourseOutlineView, ViewUtils) { var expandedLocators, CourseOutlinePage; CourseOutlinePage = BasePage.extend({ @@ -25,6 +25,9 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views self.outlineView.handleAddEvent(event); }); this.model.on('change', this.setCollapseExpandVisibility, this); + $('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function () { + $('.wrapper-alert-announcement').removeClass('is-shown').addClass('is-hidden') + })); }, setCollapseExpandVisibility: function() { diff --git a/cms/static/js/views/utils/create_course_utils.js b/cms/static/js/views/utils/create_course_utils.js new file mode 100644 index 0000000000..2c0c3493ac --- /dev/null +++ b/cms/static/js/views/utils/create_course_utils.js @@ -0,0 +1,151 @@ +/** + * Provides utilities for validating courses during creation, for both new courses and reruns. + */ +define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"], + function ($, _, gettext, ViewUtils) { + return function (selectors, classes) { + var validateRequiredField, validateCourseItemEncoding, validateTotalCourseItemsLength, setNewCourseFieldInErr, + hasInvalidRequiredFields, createCourse, validateFilledFields, configureHandlers; + + validateRequiredField = function (msg) { + return msg.length === 0 ? gettext('Required field.') : ''; + }; + + // Check that a course (org, number, run) doesn't use any special characters + validateCourseItemEncoding = function (item) { + var required = validateRequiredField(item); + if (required) { + return required; + } + if ($(selectors.allowUnicode).val() === 'True') { + if (/\s/g.test(item)) { + return gettext('Please do not use any spaces in this field.'); + } + } + else { + if (item !== encodeURIComponent(item)) { + return gettext('Please do not use any spaces or special characters in this field.'); + } + } + return ''; + }; + + // Ensure that org/course_num/run < 65 chars. + validateTotalCourseItemsLength = function () { + var totalLength = _.reduce( + [selectors.org, selectors.number, selectors.run], + function (sum, ele) { + return sum + $(ele).val().length; + }, 0 + ); + if (totalLength > 65) { + $(selectors.errorWrapper).addClass(classes.shown).removeClass(classes.hiding); + $(selectors.errorMessage).html('

' + gettext('The combined length of the organization, course number, and course run fields cannot be more than 65 characters.') + '

'); + $(selectors.save).addClass(classes.disabled); + } + else { + $(selectors.errorWrapper).removeClass(classes.shown).addClass(classes.hiding); + } + }; + + setNewCourseFieldInErr = function (el, msg) { + if (msg) { + el.addClass(classes.error); + el.children(selectors.tipError).addClass(classes.showing).removeClass(classes.hiding).text(msg); + $(selectors.save).addClass(classes.disabled); + } + else { + el.removeClass(classes.error); + el.children(selectors.tipError).addClass(classes.hiding).removeClass(classes.showing); + // One "error" div is always present, but hidden or shown + if ($(selectors.error).length === 1) { + $(selectors.save).removeClass(classes.disabled); + } + } + }; + + // One final check for empty values + hasInvalidRequiredFields = function () { + return _.reduce( + [selectors.name, selectors.org, selectors.number, selectors.run], + function (acc, ele) { + var $ele = $(ele); + var error = validateRequiredField($ele.val()); + setNewCourseFieldInErr($ele.parent(), error); + return error ? true : acc; + }, + false + ); + }; + + createCourse = function (courseInfo, errorHandler) { + $.postJSON( + '/course/', + courseInfo, + function (data) { + if (data.url !== undefined) { + ViewUtils.redirect(data.url); + } else if (data.ErrMsg !== undefined) { + errorHandler(data.ErrMsg); + } + } + ); + }; + + // Ensure that all fields are not empty + validateFilledFields = function () { + return _.reduce( + [selectors.org, selectors.number, selectors.run, selectors.name], + function (acc, ele) { + var $ele = $(ele); + return $ele.val().length !== 0 ? acc : false; + }, + true + ); + }; + + // Handle validation asynchronously + configureHandlers = function () { + _.each( + [selectors.org, selectors.number, selectors.run], + function (ele) { + var $ele = $(ele); + $ele.on('keyup', function (event) { + // Don't bother showing "required field" error when + // the user tabs into a new field; this is distracting + // and unnecessary + if (event.keyCode === 9) { + return; + } + var error = validateCourseItemEncoding($ele.val()); + setNewCourseFieldInErr($ele.parent(), error); + validateTotalCourseItemsLength(); + if (!validateFilledFields()) { + $(selectors.save).addClass(classes.disabled); + } + }); + } + ); + var $name = $(selectors.name); + $name.on('keyup', function () { + var error = validateRequiredField($name.val()); + setNewCourseFieldInErr($name.parent(), error); + validateTotalCourseItemsLength(); + if (!validateFilledFields()) { + $(selectors.save).addClass(classes.disabled); + } + }); + }; + + return { + validateRequiredField: validateRequiredField, + validateCourseItemEncoding: validateCourseItemEncoding, + validateTotalCourseItemsLength: validateTotalCourseItemsLength, + setNewCourseFieldInErr: setNewCourseFieldInErr, + hasInvalidRequiredFields: hasInvalidRequiredFields, + createCourse: createCourse, + validateFilledFields: validateFilledFields, + configureHandlers: configureHandlers + }; + }; + }); diff --git a/cms/static/js/views/utils/view_utils.js b/cms/static/js/views/utils/view_utils.js index 98eab24d96..27d969f523 100644 --- a/cms/static/js/views/utils/view_utils.js +++ b/cms/static/js/views/utils/view_utils.js @@ -5,7 +5,7 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js function ($, _, gettext, NotificationView, PromptView) { var toggleExpandCollapse, showLoadingIndicator, hideLoadingIndicator, confirmThenRunOperation, runOperationShowingMessage, disableElementWhileRunning, getScrollOffset, setScrollOffset, - setScrollTop, redirect, hasChangedAttributes; + setScrollTop, redirect, reload, hasChangedAttributes, deleteNotificationHandler; /** * Toggles the expanded state of the current element. @@ -94,6 +94,21 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js }); }; + /** + * Returns a handler that removes a notification, both dismissing it and deleting it from the database. + * @param callback function to call when deletion succeeds + */ + deleteNotificationHandler = function(callback) { + return function (event) { + event.preventDefault(); + $.ajax({ + url: $(this).data('dismiss-link'), + type: 'DELETE', + success: callback + }); + }; + }; + /** * Performs an animated scroll so that the window has the specified scroll top. * @param scrollTop The desired scroll top for the window. @@ -132,6 +147,13 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js window.location = url; }; + /** + * Reloads the page. This is broken out as its own function for unit testing. + */ + reload = function() { + window.location.reload(); + }; + /** * Returns true if a model has changes to at least one of the specified attributes. * @param model The model in question. @@ -158,10 +180,12 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js 'confirmThenRunOperation': confirmThenRunOperation, 'runOperationShowingMessage': runOperationShowingMessage, 'disableElementWhileRunning': disableElementWhileRunning, + 'deleteNotificationHandler': deleteNotificationHandler, 'setScrollTop': setScrollTop, 'getScrollOffset': getScrollOffset, 'setScrollOffset': setScrollOffset, 'redirect': redirect, + 'reload': reload, 'hasChangedAttributes': hasChangedAttributes }; }); diff --git a/cms/static/sass/_base.scss b/cms/static/sass/_base.scss index b1b57f5ff0..547d9b2e39 100644 --- a/cms/static/sass/_base.scss +++ b/cms/static/sass/_base.scss @@ -240,6 +240,282 @@ p, ul, ol, dl { } } +// ==================== + +// layout - basic +.wrapper-view { + +} + +// ==================== + +// layout - basic page header +.wrapper-mast { + margin: ($baseline*1.5) 0 0 0; + padding: 0 $baseline; + position: relative; + + .mast, .metadata { + @include clearfix(); + position: relative; + max-width: $fg-max-width; + min-width: $fg-min-width; + width: flex-grid(12); + margin: 0 auto $baseline auto; + color: $gray-d2; + } + + .mast { + border-bottom: 1px solid $gray-l4; + padding-bottom: ($baseline/2); + + // layout with actions + .page-header { + width: flex-grid(12); + } + + // layout with actions + &.has-actions { + @include clearfix(); + + .page-header { + float: left; + width: flex-grid(6,12); + margin-right: flex-gutter(); + } + + .nav-actions { + position: relative; + bottom: -($baseline*0.75); + float: right; + width: flex-grid(6,12); + text-align: right; + + .nav-item { + display: inline-block; + vertical-align: top; + margin-right: ($baseline/2); + + &:last-child { + margin-right: 0; + } + } + + // buttons + .button { + padding: ($baseline/4) ($baseline/2) ($baseline/3) ($baseline/2); + } + + .new-button { + + } + + .view-button { + + } + } + } + + // layout with actions + &.has-subtitle { + + .nav-actions { + bottom: -($baseline*1.5); + } + } + + // layout with navigation + &.has-navigation { + + .nav-actions { + bottom: -($baseline*1.5); + } + + .navigation-link { + @extend %cont-truncated; + display: inline-block; + vertical-align: bottom; // correct for extra padding in FF + max-width: 250px; + + &.navigation-current { + @extend %ui-disabled; + color: $gray; + max-width: 250px; + + &:before { + color: $gray; + } + } + } + + .navigation-link:before { + content: " / "; + margin: ($baseline/4); + color: $gray; + + &:hover { + color: $gray; + } + } + + .navigation .navigation-link:first-child:before { + content: ""; + margin: 0; + } + } + } + + // CASE: wizard-based mast + .mast-wizard { + + .page-header-sub { + @extend %t-title4; + color: $gray; + font-weight: 300; + } + + .page-header-super { + @extend %t-title4; + float: left; + width: flex-grid(12,12); + margin-top: ($baseline/2); + border-top: 1px solid $gray-l4; + padding-top: ($baseline/2); + font-weight: 600; + } + } + + // page metadata/action bar + .metadata { + + } +} + +// layout - basic page content +.wrapper-content { + margin: 0; + padding: 0 $baseline; + position: relative; +} + +.content { + @include clearfix(); + @extend %t-copy-base; + max-width: $fg-max-width; + min-width: $fg-min-width; + width: flex-grid(12); + margin: 0 auto; + color: $gray-d2; + + header { + position: relative; + margin-bottom: $baseline; + border-bottom: 1px solid $gray-l4; + padding-bottom: ($baseline/2); + + .title-sub { + @extend %t-copy-sub1; + display: block; + margin: 0; + color: $gray-l2; + } + + .title-1 { + @extend %t-title3; + margin: 0; + padding: 0; + font-weight: 600; + color: $gray-d3; + } + } +} + +.content-primary, .content-supplementary { + @include box-sizing(border-box); +} + +// layout - primary content +.content-primary { + + .title-1 { + @extend %t-title3; + } + + .title-2 { + @extend %t-title4; + margin: 0 0 ($baseline/2) 0; + } + + .title-3 { + @extend %t-title6; + margin: 0 0 ($baseline/2) 0; + } + + header { + @include clearfix(); + + .title-2 { + width: flex-grid(5, 12); + margin: 0 flex-gutter() 0 0; + float: left; + } + + .tip { + @extend %t-copy-sub2; + width: flex-grid(7, 12); + float: right; + margin-top: ($baseline/2); + text-align: right; + color: $gray-l2; + } + } +} + +// layout - supplemental content +.content-supplementary { + + > section { + margin: 0 0 $baseline 0; + } +} + +// ==================== + +// layout - grandfathered +.main-wrapper { + position: relative; + margin: 0 ($baseline*2); +} + +.inner-wrapper { + @include clearfix(); + position: relative; + max-width: 1280px; + margin: auto; + + > article { + clear: both; + } +} + +.main-column { + clear: both; + float: left; + width: 70%; +} + +.sidebar { + float: right; + width: 28%; +} + +.left { + float: left; +} + +.right { + float: right; +} // ==================== diff --git a/cms/static/sass/elements/_controls.scss b/cms/static/sass/elements/_controls.scss index 16434c5410..f5c9eb7434 100644 --- a/cms/static/sass/elements/_controls.scss +++ b/cms/static/sass/elements/_controls.scss @@ -133,6 +133,27 @@ } } +// white secondary button +%btn-secondary-white { + @extend %ui-btn-secondary; + border-color: $white-t2; + color: $white-t3; + + &:hover, &:active { + border-color: $white; + color: $white; + } + + &.current, &.active { + background: $gray-d2; + color: $gray-l5; + + &:hover, &:active { + background: $gray-d2; + } + } +} + // green secondary button %btn-secondary-green { @extend %ui-btn-secondary; @@ -213,17 +234,6 @@ // ==================== -// calls-to-action - -// ==================== - -// specific buttons - view live -%view-live-button { - @extend %t-action4; -} - -// ==================== - // UI: element actions list %actions-list { diff --git a/cms/static/sass/elements/_forms.scss b/cms/static/sass/elements/_forms.scss index 71f9c06488..d0152bdd40 100644 --- a/cms/static/sass/elements/_forms.scss +++ b/cms/static/sass/elements/_forms.scss @@ -138,28 +138,10 @@ form { } } -// ELEM: form wrapper -.wrapper-create-element { - height: 0; - margin-bottom: $baseline; - opacity: 0.0; - pointer-events: none; - overflow: hidden; - - &.animate { - @include transition(opacity $tmg-f1 ease-in-out 0s, height $tmg-f1 ease-in-out 0s); - } - - &.is-shown { - height: auto; // define a specific height for the animating version of this UI to work properly - opacity: 1.0; - pointer-events: auto; - } -} - // ELEM: form // form styling for creating a new content item (course, user, textbook) -form[class^="create-"] { +// TODO: refactor this into a placeholder to extend. +.form-create { @extend %ui-window; .title { @@ -254,12 +236,19 @@ form[class^="create-"] { .tip { @extend %t-copy-sub2; - @include transition(color, 0.15s, ease-in-out); + @include transition(color 0.15s ease-in-out); + + display: block; margin-top: ($baseline/4); color: $gray-l3; } + .tip-note { + display: block; + margin-top: ($baseline/4); + } + .tip-error { display: none; float: none; @@ -366,7 +355,6 @@ form[class^="create-"] { } } - // form - inline xblock name edit on unit, container, outline // TOOD: abstract this out into a Sass placeholder @@ -403,6 +391,25 @@ form[class^="create-"] { } } +// ELEM: form wrapper +.wrapper-create-element { + height: 0; + margin-bottom: $baseline; + opacity: 0.0; + pointer-events: none; + overflow: hidden; + + &.animate { + @include transition(opacity $tmg-f1 ease-in-out 0s, height $tmg-f1 ease-in-out 0s); + } + + &.is-shown { + height: auto; // define a specific height for the animating version of this UI to work properly + opacity: 1.0; + pointer-events: auto; + } +} + // ==================== // forms - grandfathered diff --git a/cms/static/sass/elements/_layout.scss b/cms/static/sass/elements/_layout.scss index 1dbd1b0b10..cc3324805c 100644 --- a/cms/static/sass/elements/_layout.scss +++ b/cms/static/sass/elements/_layout.scss @@ -141,6 +141,26 @@ } } + // CASE: wizard-based mast + .mast-wizard { + + .page-header-sub { + @extend %t-title4; + color: $gray; + font-weight: 300; + } + + .page-header-super { + @extend %t-title4; + float: left; + width: flex-grid(12,12); + margin-top: ($baseline/2); + border-top: 1px solid $gray-l4; + padding-top: ($baseline/2); + font-weight: 600; + } + } + // page metadata/action bar .metadata { diff --git a/cms/static/sass/elements/_system-feedback.scss b/cms/static/sass/elements/_system-feedback.scss index 3326ca4427..37c1df0aa6 100644 --- a/cms/static/sass/elements/_system-feedback.scss +++ b/cms/static/sass/elements/_system-feedback.scss @@ -527,7 +527,7 @@ &.wrapper-alert-warning { box-shadow: 0 1px 1px $white, inset 0 2px 2px $shadow-d1, inset 0 -4px 1px $orange; - [class^="icon"] { + .alert-symbol { color: $orange; } } @@ -535,7 +535,7 @@ &.wrapper-alert-error { box-shadow: 0 1px 1px $white, inset 0 2px 2px $shadow-d1, inset 0 -4px 1px $red-l1; - [class^="icon"] { + .alert-symbol { color: $red-l1; } } @@ -543,7 +543,7 @@ &.wrapper-alert-confirmation { box-shadow: 0 1px 1px $white, inset 0 2px 2px $shadow-d1, inset 0 -4px 1px $green; - [class^="icon"] { + .alert-symbol { color: $green; } } @@ -551,7 +551,7 @@ &.wrapper-alert-announcement { box-shadow: 0 1px 1px $white, inset 0 2px 2px $shadow-d1, inset 0 -4px 1px $blue; - [class^="icon"] { + .alert-symbol { color: $blue; } } @@ -559,7 +559,7 @@ &.wrapper-alert-step-required { box-shadow: 0 1px 1px $white, inset 0 2px 2px $shadow-d1, inset 0 -4px 1px $pink; - [class^="icon"] { + .alert-symbol { color: $pink; } } @@ -579,11 +579,11 @@ @extend %t-strong; } - [class^="icon"], .copy { + .alert-symbol, .copy { float: left; } - [class^="icon"] { + .alert-symbol { @include transition (color 0.50s ease-in-out 0s); @extend %t-icon3; width: flex-grid(1, 12); @@ -605,7 +605,7 @@ // with actions &.has-actions { - [class^="icon"] { + .alert-symbol { width: flex-grid(1, 12); } @@ -667,6 +667,29 @@ background: $gray-d1; } } + + // with dismiss (to sunset action-alert-clos) + .action-dismiss { + + .button { + @extend %btn-secondary-white; + padding:($baseline/4) ($baseline/2); + } + + .icon,.button-copy { + display: inline-block; + vertical-align: middle; + } + + .icon { + @extend %t-icon4; + margin-right: ($baseline/4); + } + + .button-copy { + @extend %t-copy-sub1; + } + } } // ==================== diff --git a/cms/static/sass/elements/_system-help.scss b/cms/static/sass/elements/_system-help.scss index f7e34f2043..0538bf6f7d 100644 --- a/cms/static/sass/elements/_system-help.scss +++ b/cms/static/sass/elements/_system-help.scss @@ -243,6 +243,12 @@ } } + // learn more (aka external help button) + .external-help-button { + @extend %ui-btn-flat-outline; + @extend %sizing; + } + // actions .list-actions { @extend %cont-no-list; diff --git a/cms/static/sass/style-app-extend1.scss b/cms/static/sass/style-app-extend1.scss index bdcf8f79d1..924b7c114f 100644 --- a/cms/static/sass/style-app-extend1.scss +++ b/cms/static/sass/style-app-extend1.scss @@ -38,6 +38,7 @@ @import 'views/dashboard'; @import 'views/export'; @import 'views/index'; +@import 'views/course-create'; @import 'views/import'; @import 'views/outline'; @import 'views/settings'; diff --git a/cms/static/sass/views/_course-create.scss b/cms/static/sass/views/_course-create.scss new file mode 100644 index 0000000000..3b6ce18371 --- /dev/null +++ b/cms/static/sass/views/_course-create.scss @@ -0,0 +1,122 @@ +// studio - views - course creation page +// ==================== + +.view-course-create { + + // basic layout + // -------------------- + .content-primary, .content-supplementary { + @include box-sizing(border-box); + float: left; + } + + .content-primary { + width: flex-grid(9, 12); + margin-right: flex-gutter(); + } + + .content-supplementary { + width: flex-grid(3, 12); + } + + // + + // header/masthead + // -------------------- + .mast .page-header-super { + + .course-original-title-id, .course-original-title { + display: block; + } + + .course-original-title-id { + @extend %t-title5; + } + } + + + // course re-run form + // -------------------- + .rerun-course { + + .row { + @include clearfix(); + margin-bottom: ($baseline*0.75); + } + + .column { + float: left; + width: 48%; + } + + .column:first-child { + margin-right: 4%; + } + + label { + @extend %t-title7; + display: block; + font-weight: 700; + } + + .rerun-course-org, + .rerun-course-number, + .rerun-course-name, + .rerun-course-run { + width: 100%; + } + + .rerun-course-name { + @extend %t-title5; + font-weight: 300; + } + + .rerun-course-save { + @include blue-button; + + .icon { + display: inline-block; + vertical-align: middle; + margin-right: ($baseline/4); + } + } + + .rerun-course-cancel { + @include white-button; + } + + .item-details { + padding-bottom: 0; + } + + .wrap-error { + @include transition(opacity $tmg-f2 ease 0s); + opacity: 0; + } + + .wrap-error.is-shown { + opacity: 1; + } + + .message-status { + display: block; + margin-bottom: 0; + padding: ($baseline*.5) ($baseline*1.5) 8px ($baseline*1.5); + font-weight: bold; + } + + // NOTE: override for modern button styling until all buttons (in _forms.scss) can be converted + .actions { + + .action-primary { + @include blue-button; + @extend %t-action2; + } + + .action-secondary { + @include grey-button; + @extend %t-action2; + } + } + } +} diff --git a/cms/static/sass/views/_dashboard.scss b/cms/static/sass/views/_dashboard.scss index 69ba41fd2b..167cdde7bb 100644 --- a/cms/static/sass/views/_dashboard.scss +++ b/cms/static/sass/views/_dashboard.scss @@ -236,7 +236,7 @@ @extend %t-strong; margin: ($baseline/2); - [class^="icon-"] { + .icon { margin-right: ($baseline/4); } } @@ -294,120 +294,307 @@ // ELEM: course listings .courses { margin: $baseline 0; - } + + .title { + @extend %t-title6; + margin-bottom: $baseline; + border-bottom: 1px solid $gray-l3; + padding-bottom: ($baseline/2); + color: $gray-l2; + } + + .title { + @extend %t-title6; + margin-bottom: $baseline; + border-bottom: 1px solid $gray-l3; + padding-bottom: ($baseline/2); + color: $gray-l2; + } + } .list-courses { margin-top: $baseline; border-radius: 3px; - border: 1px solid $gray; + border: 1px solid $gray-l2; background: $white; - box-shadow: 0 1px 2px $shadow-l1; + box-shadow: 0 1px 1px $shadow-l1; - .course-item { - @include box-sizing(border-box); - width: flex-grid(9, 9); - position: relative; - border-bottom: 1px solid $gray-l1; - padding: $baseline; + li:last-child { + margin-bottom: 0; + } + } - // STATE: hover/focus - &:hover { - background: $paleYellow; - .course-actions .view-live-button { - opacity: 1.0; - pointer-events: auto; - } + // UI: course wrappers (needed for status messages) + .wrapper-course { - .course-title { - color: $orange-d1; - } + // CASE: has status + &.has-status { - .course-metadata { - opacity: 1.0; - } - } - - .course-link, .course-actions { + .course-status { @include box-sizing(border-box); display: inline-block; vertical-align: middle; - } - - // encompassing course link - .course-link { - @extend %ui-depth2; - width: flex-grid(7, 9); - margin-right: flex-gutter(); - } - - // course title - .course-title { - @extend %t-title4; - @extend %t-light; - margin: 0 ($baseline*2) ($baseline/4) 0; - } - - // course metadata - .course-metadata { - @extend %t-copy-sub1; - @include transition(opacity $tmg-f1 ease-in-out 0); - color: $gray; - opacity: 0.75; - - .metadata-item { - display: inline-block; - - &:after { - content: "/"; - margin-left: ($baseline/10); - margin-right: ($baseline/10); - color: $gray-l4; - } - - &:last-child { - - &:after { - content: ""; - margin-left: 0; - margin-right: 0; - } - } - - .label { - @extend %cont-text-sr; - } - } - } - - .course-actions { - @extend %ui-depth3; - position: static; - width: flex-grid(2, 9); + width: flex-grid(3, 9); + padding-right: ($baseline/2); text-align: right; - // view live button - .view-live-button { - @extend %ui-depth3; - @include transition(opacity $tmg-f2 ease-in-out 0); - @include box-sizing(border-box); - padding: ($baseline/2); - opacity: 0.0; - pointer-events: none; + .value { - &:hover { - opacity: 1.0; - pointer-events: auto; + .copy, .icon { + display: inline-block; + vertical-align: middle; + } + + .icon { + @extend %t-icon4; + margin-right: ($baseline/2); + } + + .copy { + @extend %t-copy-sub1; } } + } - &:last-child { - border-bottom: none; + .status-message { + @extend %t-copy-sub1; + background-color: $gray-l5; + box-shadow: 0 2px 2px 0 $shadow inset; + padding: ($baseline*0.75) $baseline; + + &.has-actions { + + .copy, .status-actions { + display: inline-block; + vertical-align: middle; + } + + .copy { + width: 65%; + margin: 0 $baseline 0 0; + } + + .status-actions { + width: 30%; + text-align: right; + + .button { + @extend %btn-secondary-white; + padding:($baseline/4) ($baseline/2); + } + + .icon,.button-copy { + display: inline-block; + vertical-align: middle; + } + + .icon { + @extend %t-icon4; + margin-right: ($baseline/4); + } + + .button-copy { + @extend %t-copy-sub1; + } + } } } } } + // UI: individual course listings + .course-item { + @include box-sizing(border-box); + width: flex-grid(9, 9); + position: relative; + border-bottom: 1px solid $gray-l2; + padding: $baseline; + + // STATE: hover/focus + &:hover { + background: $paleYellow; + + .course-actions { + opacity: 1.0; + pointer-events: auto; + } + + .course-title { + color: $orange-d1; + } + + .course-metadata { + opacity: 1.0; + } + } + + .course-link, .course-actions { + @include box-sizing(border-box); + display: inline-block; + vertical-align: middle; + } + + // encompassing course link + .course-link { + @extend %ui-depth2; + width: flex-grid(6, 9); + margin-right: flex-gutter(); + } + + // course title + .course-title { + @extend %t-title4; + margin: 0 ($baseline*2) ($baseline/4) 0; + font-weight: 300; + } + + // course metadata + .course-metadata { + @extend %t-copy-sub1; + @include transition(opacity $tmg-f1 ease-in-out 0); + color: $gray; + opacity: 0.75; + + .metadata-item { + display: inline-block; + + &:after { + content: "/"; + margin-left: ($baseline/10); + margin-right: ($baseline/10); + color: $gray-l4; + } + + &:last-child { + + &:after { + content: ""; + margin-left: 0; + margin-right: 0; + } + } + + .label { + @extend %cont-text-sr; + } + } + } + + .course-actions { + @include transition(opacity $tmg-f2 ease-in-out 0); + @extend %ui-depth3; + position: static; + width: flex-grid(3, 9); + text-align: right; + opacity: 0; + pointer-events: none; + + .action { + display: inline-block; + vertical-align: middle; + margin-right: ($baseline/2); + + &:last-child { + margin-right: 0; + } + } + + .button { + @extend %t-action3; + } + + // view live button + .view-button { + @include box-sizing(border-box); + padding: ($baseline/2); + } + + // course re-run button + .action-rerun { + margin-right: $baseline; + } + + .rerun-button { + font-weight: 600; + // TODO: sync up button styling and add secondary style here + } + } + + // CASE: is processing + &.is-processing { + + .course-status .value { + color: $gray-l2; + } + } + + // CASE: has an error + &.has-error { + + .course-status { + color: $red; // TODO: abstract this out to an error-based color variable + } + + ~ .status-message { + background: $red-l1; // TODO: abstract this out to an error-based color variable + color: $white; + } + } + + // CASE: last course in listing + &:last-child { + border-bottom: none; + } + } + + // ==================== + + // CASE: courses that are being processed + .courses-processing { + margin-bottom: ($baseline*2); + border-bottom: 1px solid $gray-l3; + padding-bottom: ($baseline*2); + + // TODO: abstract this case out better with normal course listings + .list-courses { + border: none; + background: none; + box-shadow: none; + } + + .wrapper-course { + @extend %ui-window; + position: relative; + } + + .course-item { + border: none; + + // STATE: hover/focus + &:hover { + background: inherit; + + .course-title { + color: inherit; + } + } + + } + + // course details (replacement for course-link when a course cannot be linked) + .course-details { + @extend %ui-depth2; + display: inline-block; + vertical-align: middle; + width: flex-grid(6, 9); + margin-right: flex-gutter(); + } + + } + + // ==================== + // ELEM: new user form .wrapper-create-course { @@ -494,6 +681,5 @@ margin-bottom: 0; padding: ($baseline*.5) ($baseline*1.5) 8px ($baseline*1.5); } - } } diff --git a/cms/templates/base.html b/cms/templates/base.html index 72b8cfcc0e..a0ca8fc724 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -343,7 +343,9 @@ <% online_help_token = self.online_help_token() if hasattr(self, 'online_help_token') else None %> <%include file="widgets/header.html" args="online_help_token=online_help_token" /> -
+
+ <%block name="page_alert"> +
<%block name="content"> diff --git a/cms/templates/course-create-rerun.html b/cms/templates/course-create-rerun.html new file mode 100644 index 0000000000..04bd8044b7 --- /dev/null +++ b/cms/templates/course-create-rerun.html @@ -0,0 +1,158 @@ +<%inherit file="base.html" /> +<%def name="online_help_token()"><% return "course_rerun" %> +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> + +<%block name="title">${_("Create a Course Rerun of:")} +<%block name="bodyclass">is-signedin view-course-create view-course-create-rerun + +<%block name="jsextra"> + + + + + +<%block name="content"> +
+
+
+

+ ${_("Create a re-run of a course")} +

+ + + +

+ ${_("You are creating a re-run from:")} + ${source_course_key.org} ${source_course_key.course} ${source_course_key.run} + ${display_name} +

+
+
+ +
+
+
+
+
+
+

+ ${_("Provide identifying information for this re-run of the course. The original course is not affected in any way by a re-run.")} + ${_("Note: Together, the organization, course number, and course run must uniquely identify this new course instance.")} +

+

+
+ +
+
+ + + +
+
+ ${_("Required Information to Create a re-run of a course")} + +
    +
  1. + + + + ${_("The public display name for the new course. (This name is often the same as the original course name.)")} + + +
  2. +
  3. + + + + ${_("The name of the organization sponsoring the new course. (This name is often the same as the original organization name.)")} + ${_("Note: No spaces or special characters are allowed.")} + + +
  4. + +
  5. +
    + + + + ${_("The unique number that identifies the new course within the organization. (This number is often the same as the original course number.)")} + ${_("Note: No spaces or special characters are allowed.")} + + +
    + +
    + + + + ${_("The term in which the new course will run. (This value is often different than the original course run value.)")} + ${_("Note: No spaces or special characters are allowed.")} + + +
    +
  6. +
+ + +
+
+ +
+ + +
+
+
+ +
+ + + +
+
+ +
+
+ diff --git a/cms/templates/course_outline.html b/cms/templates/course_outline.html index ee22386600..193d239d37 100644 --- a/cms/templates/course_outline.html +++ b/cms/templates/course_outline.html @@ -36,6 +36,31 @@ from contentstore.utils import reverse_usage_url % endfor +<%block name="page_alert"> + %if notification_dismiss_url is not None: +
+
+ + +
+

${_("This course was created as a re-run. Some manual configuration is needed.")}

+ +

${_("Be sure to review and reset all dates (the Course Start Date has been changed to a future date); set up the course team; review course updates and other assets for dated material; and seed the discussions and wiki.")}

+
+ + +
+
+ %endif + + <%block name="content">
diff --git a/cms/templates/index.html b/cms/templates/index.html index 0b37c9ecfc..eed1669c58 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -77,7 +77,7 @@ require(["domReady!", "jquery", "jquery.form", "js/index"], function(doc, $) { % if course_creator_status=='granted':
-
+
- +
@@ -131,35 +131,139 @@ require(["domReady!", "jquery", "jquery.form", "js/index"], function(doc, $) {
% endif + + %if allow_course_reruns and rerun_creator_status and len(in_process_course_actions) > 0: +
+

Courses Being Processed

+ +
    + %for course_info in sorted(in_process_course_actions, key=lambda s: s['display_name'].lower() if s['display_name'] is not None else ''): + + %if course_info['is_in_progress']: +
  • +
    +
    +

    ${course_info['display_name']}

    + + +
    + +
    +
    This re-run processing status:
    +
    + + Configuring as re-run +
    +
    +
    + +
    +

    ${_('The new course will be added to your course list in 5-10 minutes. Return to this page or refresh it to update the course list. The new course will need some manual configuration.')}

    +
    +
  • + %endif + + + + + %if course_info['is_failed']: +
  • +
    +
    +

    ${course_info['display_name']}

    + + +
    + +
    +
    This re-run processing status:
    +
    + + Configuration Error +
    +
    +
    + +
    +

    ${_("A system error occurred while your course was being processed. Please go to the original course to try the re-run again, or contact your PM for assistance.")}

    + + +
    +
  • + %endif + %endfor +
+
+ %endif + %if len(courses) > 0:
diff --git a/cms/templates/js/mock/mock-course-rerun-notification.underscore b/cms/templates/js/mock/mock-course-rerun-notification.underscore new file mode 100644 index 0000000000..1081d7fc19 --- /dev/null +++ b/cms/templates/js/mock/mock-course-rerun-notification.underscore @@ -0,0 +1,24 @@ +
+
+
+ + +
+

This course was created as a re-run. Some manual configuration is needed.

+ +

Be sure to review and reset all dates (the Course Start Date has been changed to a future date); set up the + course team; review course updates and other assets for dated material; and seed the discussions and + wiki.

+
+ + +
+
+
\ No newline at end of file diff --git a/cms/templates/js/mock/mock-create-course-rerun.underscore b/cms/templates/js/mock/mock-create-course-rerun.underscore new file mode 100644 index 0000000000..43b9ff0b26 --- /dev/null +++ b/cms/templates/js/mock/mock-create-course-rerun.underscore @@ -0,0 +1,116 @@ +
+
+
+

+ Create a re-run of a course +

+ + + +

+ You are creating a re-run from: + edX Open_DemoX 2014_T1 + edX Demonstration Course +

+
+
+ +
+
+
+
+
+
+

+ Provide identifying information for this re-run of the course. The original course is not affected in any way by a re-run. + Note: Together, the organization, course number, and course run must uniquely identify this new course instance. +

+

+
+ +
+ + + +
+
+ Required Information to Create a re-run of a course + +
    +
  1. + + + + The public display name for the new course. (This name is often the same as the original course name.) + + +
  2. +
  3. + + + + The name of the organization sponsoring the new course. (This name is often the same as the original organization name.) + Note: No spaces or special characters are allowed. + + +
  4. + +
  5. +
    + + + + The unique number that identifies the new course within the organization. (This number is often the same as the original course number.) + Note: No spaces or special characters are allowed. + + +
    + +
    + + + + The term in which the new course will run. (This value is often different than the original course run value.) + Note: No spaces or special characters are allowed. + + +
    +
  6. +
+ + +
+
+ +
+ + +
+ +
+
+
+
+
+
\ No newline at end of file diff --git a/cms/templates/js/mock/mock-index-page.underscore b/cms/templates/js/mock/mock-index-page.underscore new file mode 100644 index 0000000000..f63f12e571 --- /dev/null +++ b/cms/templates/js/mock/mock-index-page.underscore @@ -0,0 +1,168 @@ +
+
+

My Courses

+ +
+
+ +
+
+
+ +
+

Welcome, user!

+
+

Here are all of the courses you currently have access to in Studio:

+
+
+ +
+
+
+ +
+ +
+

Create a New Course

+ +
+ Required Information to Create a New Course + +
    +
  1. + + + The public display name for your course. This cannot be changed, but you can set a different display name in Advanced Settings later. + +
  2. +
  3. + + + The name of the organization sponsoring the course. Note: This is part of your course URL, so no spaces or special characters are allowed. This cannot be changed, but you can set a different display name in Advanced Settings later. + +
  4. + +
  5. + + + The unique number that identifies your course within your organization. Note: This is part of your course URL, so no spaces or special characters are allowed and it cannot be changed. + +
  6. + +
  7. + + + The term in which your course will run. Note: This is part of your course URL, so no spaces or special characters are allowed and it cannot be changed. + +
  8. +
+ +
+
+ +
+ + + +
+
+
+ + +
+

Courses Being Processed

+ +
    + +
  • +
    +
    +

    Demo Course

    + + +
    + +
    +
    This re-run processing status:
    +
    + + Configuring as re-run +
    +
    +
    + +
    +

    The new course will be added to your course list in 5-10 minutes. Return to this page or refresh it to update the course list. The new course will need some manual configuration.

    +
    +
  • + + + + +
  • +
    +
    +

    Demo Course 2

    + + +
    + +
    +
    This re-run processing status:
    +
    + + Configuration Error +
    +
    +
    + +
    +

    A system error occurred while your course was being processed. Please go to the original course to try the re-run again, or contact your PM for assistance.

    + + +
    +
  • +
+
+
+
+
diff --git a/cms/templates/manage_users.html b/cms/templates/manage_users.html index 53ba31cb46..c6e410e700 100644 --- a/cms/templates/manage_users.html +++ b/cms/templates/manage_users.html @@ -34,7 +34,7 @@
%if allow_actions:
-
+

${_("Add a User to Your Course's Team")}

@@ -147,7 +147,7 @@

${_("Course Team Roles")}

${_("Course team members, or staff, are course co-authors. They have full writing and editing privileges on all course content.")}

-

${_("Admins are course team members who can add and remove other course team members.")}

+

${_("Admins are course team members who can add and remove other course team members.")}

% if user_is_instuctor and len(instructors) == 1: diff --git a/cms/templates/textbooks.html b/cms/templates/textbooks.html index 2a65244c72..7334d28754 100644 --- a/cms/templates/textbooks.html +++ b/cms/templates/textbooks.html @@ -75,7 +75,10 @@ require(["js/models/section", "js/collections/textbook", "js/views/list_textbook

${_("What if my book isn't divided into chapters?")}

${_("If your textbook doesn't have individual chapters, you can upload the entire text as a single chapter and enter a name of your choice in the Chapter Name field.")}

-

${_("Learn More")}

+
+ + diff --git a/cms/templates/ux/reference/course-create-rerun.html b/cms/templates/ux/reference/course-create-rerun.html new file mode 100644 index 0000000000..b05ee661fe --- /dev/null +++ b/cms/templates/ux/reference/course-create-rerun.html @@ -0,0 +1,368 @@ + + +<%inherit file="../../base.html" /> + +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> + +<%block name="title">[template] ${_("Create a Course Rerun of HarvardX SW12.2x T2_2014")} +<%block name="bodyclass">is-signedin view-course-create view-course-create-rerun + +<%block name="content"> +
+ +
+
+

+ ${_("Create a re-run of a course")} +

+ + + +

+ ${_("You are creating a re-run from:")} + HarvardX SW12.2x T2_2014 + China (Part 2): The Creation and End of a Centralized Empire +

+
+
+ +
+
+
+
+
+
+

+ ${_("Provide identifying information for this re-run of the course. The original course is not affected in any way by a re-run.")} + ${_("Note: Together, the organization, course number, and course run must uniquely identify this new course instance.")} +

+

+
+ + + + +
+ + +
+ +
+ +
+
+ ${_("Required Information to Create a re-run of a course")} + +
    +
  1. + + + + ${_("The public display name for the new course. (This name is often the same as the original course name.)")} + + +
  2. +
  3. + + + + ${_("The name of the organization sponsoring the new course. (This name is often the same as the original organization name.)")} + ${_("Note: No spaces or special characters are allowed.")} + + +
  4. + +
  5. +
    + + + + ${_("The unique number that identifies the new course within the organization. (This number is often the same as the original course number.)")} + ${_("Note: No spaces or special characters are allowed.")} + + +
    + +
    + + + + ${_("The term in which the new course will run. (This value is often different than the original course run value.)")} + ${_("Note: No spaces or special characters are allowed.")} + + +
    +
  6. +
+ + +
+
+ +
+ + +
+ +
+ + + + +
+
+
+ + +
+ +
+
+ ${_("Required Information to Create a re-run of a course")} + +
    +
  1. + + + + ${_("The public display name for the new course. (This name is often the same as the original course name.)")} + + +
  2. +
  3. + + + + ${_("The name of the organization sponsoring the new course. (This name is often the same as the original organization name.)")} + ${_("Note: No spaces or special characters are allowed.")} + + +
  4. + +
  5. +
    + + + + ${_("The unique number that identifies the new course within the organization. (This number is often the same as the original course number.)")} + ${_("Note: No spaces or special characters are allowed.")} + + +
    + +
    + + + + ${_("The term in which the new course will run. (This value is often different than the original course run value.)")} + ${_("Note: No spaces or special characters are allowed.")} + + +
    +
  6. +
+ + + +
+
+ +
+ + +
+
+
+ + + + +
+
+
+ + +
+ +
+
+ ${_("Required Information to Create a re-run of a course")} + +
    +
  1. + + + + ${_("The public display name for the new course. (This name is often the same as the original course name.)")} + + Required field. +
  2. +
  3. + + + + ${_("The name of the organization sponsoring the new course. (This name is often the same as the original organization name.)")} + ${_("Note: No spaces or special characters are allowed.")} + + Please do not use any spaces or special characters in this field. +
  4. + +
  5. +
    + + + + ${_("The unique number that identifies the new course within the organization. (This number is often the same as the original course number.)")} + ${_("Note: No spaces or special characters are allowed.")} + + Please do not use any spaces or special characters in this field. +
    + +
    + + + + ${_("The term in which the new course will run. (This value is often different than the original course run value.)")} + ${_("Note: No spaces or special characters are allowed.")} + + Required field. +
    +
  6. +
+ + + +
+
+ +
+ + +
+
+
+ + + + +
+
+
+
+ +
+
+ ${_("Required Information to Create a re-run of a course")} + +
    +
  1. + + + + ${_("The public display name for the new course. (This name is often the same as the original course name.)")} + + +
  2. +
  3. + + + + ${_("The name of the organization sponsoring the new course. (This name is often the same as the original organization name.)")} + ${_("Note: No spaces or special characters are allowed.")} + + +
  4. + +
  5. +
    + + + + ${_("The unique number that identifies the new course within the organization. (This number is often the same as the original course number.)")} + ${_("Note: No spaces or special characters are allowed.")} + + +
    + +
    + + + + ${_("The term in which the new course will run. (This value is often different than the original course run value.)")} + ${_("Note: No spaces or special characters are allowed.")} + + +
    +
  6. +
+ + +
+
+ +
+ +
+
+
+ +
+ + + +
+
+ +
+
+ diff --git a/cms/urls.py b/cms/urls.py index 46e22e3408..ceea5f039f 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -74,6 +74,7 @@ urlpatterns += patterns( ), url(r'^course/{}?$'.format(settings.COURSE_KEY_PATTERN), 'course_handler', name='course_handler'), url(r'^course_notifications/{}/(?P\d+)?$'.format(settings.COURSE_KEY_PATTERN), 'course_notifications_handler'), + url(r'^course_rerun/{}$'.format(settings.COURSE_KEY_PATTERN), 'course_rerun_handler', name='course_rerun_handler'), url(r'^container/{}$'.format(settings.USAGE_KEY_PATTERN), 'container_handler'), url(r'^checklists/{}/(?P\d+)?$'.format(settings.COURSE_KEY_PATTERN), 'checklists_handler'), url(r'^orphan/{}$'.format(settings.COURSE_KEY_PATTERN), 'orphan_handler'), diff --git a/common/djangoapps/course_action_state/managers.py b/common/djangoapps/course_action_state/managers.py index 84ba239213..661f417a69 100644 --- a/common/djangoapps/course_action_state/managers.py +++ b/common/djangoapps/course_action_state/managers.py @@ -113,7 +113,7 @@ class CourseRerunUIStateManager(CourseActionUIStateManager): FAILED = "failed" SUCCEEDED = "succeeded" - def initiated(self, source_course_key, destination_course_key, user): + def initiated(self, source_course_key, destination_course_key, user, display_name): """ To be called when a new rerun is initiated for the given course by the given user. """ @@ -123,6 +123,7 @@ class CourseRerunUIStateManager(CourseActionUIStateManager): user=user, allow_not_found=True, source_course_key=source_course_key, + display_name=display_name, ) def succeeded(self, course_key): diff --git a/common/djangoapps/course_action_state/migrations/0002_add_rerun_display_name.py b/common/djangoapps/course_action_state/migrations/0002_add_rerun_display_name.py new file mode 100644 index 0000000000..92b2cc24a0 --- /dev/null +++ b/common/djangoapps/course_action_state/migrations/0002_add_rerun_display_name.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding field 'CourseRerunState.display_name' + db.add_column('course_action_state_coursererunstate', 'display_name', + self.gf('django.db.models.fields.CharField')(default='', max_length=255, blank=True), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'CourseRerunState.display_name' + db.delete_column('course_action_state_coursererunstate', 'display_name') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'course_action_state.coursererunstate': { + 'Meta': {'unique_together': "(('course_key', 'action'),)", 'object_name': 'CourseRerunState'}, + 'action': ('django.db.models.fields.CharField', [], {'max_length': '100', 'db_index': 'True'}), + 'course_key': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created_time': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'created_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'created_by_user+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}), + 'display_name': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'message': ('django.db.models.fields.CharField', [], {'max_length': '1000'}), + 'should_display': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'source_course_key': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'state': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'updated_time': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'updated_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'updated_by_user+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}) + } + } + + complete_apps = ['course_action_state'] diff --git a/common/djangoapps/course_action_state/models.py b/common/djangoapps/course_action_state/models.py index 83e6231ae2..48ca83f47f 100644 --- a/common/djangoapps/course_action_state/models.py +++ b/common/djangoapps/course_action_state/models.py @@ -109,6 +109,9 @@ class CourseRerunState(CourseActionUIState): # Original course that is being rerun source_course_key = CourseKeyField(max_length=255, db_index=True) + # Display name for destination course + display_name = models.CharField(max_length=255, default="", blank=True) + # MANAGERS # Override the abstract class' manager with a Rerun-specific manager that inherits from the base class' manager. objects = CourseRerunUIStateManager() diff --git a/common/djangoapps/course_action_state/tests/test_rerun_manager.py b/common/djangoapps/course_action_state/tests/test_rerun_manager.py index 92dfb7dcc0..e94a6a1cb2 100644 --- a/common/djangoapps/course_action_state/tests/test_rerun_manager.py +++ b/common/djangoapps/course_action_state/tests/test_rerun_manager.py @@ -17,10 +17,13 @@ class TestCourseRerunStateManager(TestCase): self.source_course_key = CourseLocator("source_org", "source_course_num", "source_run") self.course_key = CourseLocator("test_org", "test_course_num", "test_run") self.created_user = UserFactory() + self.display_name = "destination course name" self.expected_rerun_state = { 'created_user': self.created_user, 'updated_user': self.created_user, 'course_key': self.course_key, + 'source_course_key': self.source_course_key, + "display_name": self.display_name, 'action': CourseRerunUIStateManager.ACTION, 'should_display': True, 'message': "", @@ -53,10 +56,16 @@ class TestCourseRerunStateManager(TestCase): }) self.verify_rerun_state() - def test_rerun_initiated(self): + def initiate_rerun(self): CourseRerunState.objects.initiated( - source_course_key=self.source_course_key, destination_course_key=self.course_key, user=self.created_user + source_course_key=self.source_course_key, + destination_course_key=self.course_key, + user=self.created_user, + display_name=self.display_name, ) + + def test_rerun_initiated(self): + self.initiate_rerun() self.expected_rerun_state.update( {'state': CourseRerunUIStateManager.State.IN_PROGRESS} ) @@ -64,9 +73,7 @@ class TestCourseRerunStateManager(TestCase): def test_rerun_succeeded(self): # initiate - CourseRerunState.objects.initiated( - source_course_key=self.source_course_key, destination_course_key=self.course_key, user=self.created_user - ) + self.initiate_rerun() # set state to succeed CourseRerunState.objects.succeeded(course_key=self.course_key) @@ -80,9 +87,7 @@ class TestCourseRerunStateManager(TestCase): def test_rerun_failed(self): # initiate - CourseRerunState.objects.initiated( - source_course_key=self.source_course_key, destination_course_key=self.course_key, user=self.created_user - ) + self.initiate_rerun() # set state to fail exception = Exception("failure in rerunning") diff --git a/common/djangoapps/static_replace/__init__.py b/common/djangoapps/static_replace/__init__.py index cd2e077444..8396940f8e 100644 --- a/common/djangoapps/static_replace/__init__.py +++ b/common/djangoapps/static_replace/__init__.py @@ -97,7 +97,7 @@ def replace_static_urls(text, data_directory, course_id=None, static_asset_path= Replace /static/$stuff urls either with their correct url as generated by collectstatic, (/static/$md5_hashed_stuff) or by the course-specific content static url /static/$course_data_dir/$stuff, or, if course_namespace is not None, by the - correct url in the contentstore (c4x://) + correct url in the contentstore (/c4x/.. or /asset-loc:..) text: The source text to do the substitution in data_directory: The directory in which course data is stored diff --git a/common/djangoapps/xmodule_modifiers.py b/common/djangoapps/xmodule_modifiers.py index 8381c067b5..5f79014847 100644 --- a/common/djangoapps/xmodule_modifiers.py +++ b/common/djangoapps/xmodule_modifiers.py @@ -185,7 +185,7 @@ def add_staff_markup(user, has_instructor_access, block, view, frag, context): # TODO: make this more general, eg use an XModule attribute instead if isinstance(block, VerticalModule) and (not context or not context.get('child_of_vertical', False)): # check that the course is a mongo backed Studio course before doing work - is_mongo_course = modulestore().get_modulestore_type(block.location.course_key) == ModuleStoreEnum.Type.mongo + is_mongo_course = modulestore().get_modulestore_type(block.location.course_key) != ModuleStoreEnum.Type.xml is_studio_course = block.course_edit_method == "Studio" if is_studio_course and is_mongo_course: diff --git a/common/lib/xmodule/xmodule/contentstore/content.py b/common/lib/xmodule/xmodule/contentstore/content.py index c45a56f391..c3c653125f 100644 --- a/common/lib/xmodule/xmodule/contentstore/content.py +++ b/common/lib/xmodule/xmodule/contentstore/content.py @@ -64,9 +64,6 @@ class StaticContent(object): def get_id(self): return self.location - def get_url_path(self): - return self.location.to_deprecated_string() - @property def data(self): return self._data @@ -108,7 +105,9 @@ class StaticContent(object): assert(isinstance(course_key, CourseKey)) placeholder_id = uuid.uuid4().hex # create a dummy asset location with a fake but unique name. strip off the name, and return it - url_path = unicode(course_key.make_asset_key('asset', placeholder_id).for_branch(None)) + url_path = StaticContent.serialize_asset_key_with_slash( + course_key.make_asset_key('asset', placeholder_id).for_branch(None) + ) return url_path.replace(placeholder_id, '') @staticmethod @@ -133,7 +132,7 @@ class StaticContent(object): # Generate url of urlparse.path component scheme, netloc, orig_path, params, query, fragment = urlparse(path) loc = StaticContent.compute_location(course_id, orig_path) - loc_url = loc.to_deprecated_string() + loc_url = StaticContent.serialize_asset_key_with_slash(loc) # parse the query params for "^/static/" and replace with the location url orig_query = parse_qsl(query) @@ -144,7 +143,7 @@ class StaticContent(object): course_id, query_value[len('/static/'):], ) - new_query_url = new_query.to_deprecated_string() + new_query_url = StaticContent.serialize_asset_key_with_slash(new_query) new_query_list.append((query_name, new_query_url)) else: new_query_list.append((query_name, query_value)) @@ -155,6 +154,17 @@ class StaticContent(object): def stream_data(self): yield self._data + @staticmethod + def serialize_asset_key_with_slash(asset_key): + """ + Legacy code expects the serialized asset key to start w/ a slash; so, do that in one place + :param asset_key: + """ + url = unicode(asset_key) + if not url.startswith('/'): + url = '/' + url # TODO - re-address this once LMS-11198 is tackled. + return url + class StaticContentStream(StaticContent): def __init__(self, loc, name, content_type, stream, last_modified_at=None, thumbnail_location=None, import_path=None, diff --git a/common/lib/xmodule/xmodule/contentstore/mongo.py b/common/lib/xmodule/xmodule/contentstore/mongo.py index 5a04bf91ee..7d8bf949ad 100644 --- a/common/lib/xmodule/xmodule/contentstore/mongo.py +++ b/common/lib/xmodule/xmodule/contentstore/mongo.py @@ -66,7 +66,7 @@ class MongoContentStore(ContentStore): self.delete(content_id) # delete is a noop if the entry doesn't exist; so, don't waste time checking thumbnail_location = content.thumbnail_location.to_deprecated_list_repr() if content.thumbnail_location else None - with self.fs.new_file(_id=content_id, filename=content.get_url_path(), content_type=content.content_type, + with self.fs.new_file(_id=content_id, filename=unicode(content.location), content_type=content.content_type, displayname=content.name, content_son=content_son, thumbnail_location=thumbnail_location, import_path=content.import_path, diff --git a/common/lib/xmodule/xmodule/modulestore/mixed.py b/common/lib/xmodule/xmodule/modulestore/mixed.py index 26fbf769e7..5cb862c50b 100644 --- a/common/lib/xmodule/xmodule/modulestore/mixed.py +++ b/common/lib/xmodule/xmodule/modulestore/mixed.py @@ -393,15 +393,13 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): if source_modulestore == dest_modulestore: return source_modulestore.clone_course(source_course_id, dest_course_id, user_id, fields, **kwargs) - # ensure super's only called once. The delegation above probably calls it; so, don't move - # the invocation above the delegation call - super(MixedModuleStore, self).clone_course(source_course_id, dest_course_id, user_id, fields, **kwargs) - if dest_modulestore.get_modulestore_type() == ModuleStoreEnum.Type.split: split_migrator = SplitMigrator(dest_modulestore, source_modulestore) split_migrator.migrate_mongo_course( source_course_id, user_id, dest_course_id.org, dest_course_id.course, dest_course_id.run, fields, **kwargs ) + # the super handles assets and any other necessities + super(MixedModuleStore, self).clone_course(source_course_id, dest_course_id, user_id, fields, **kwargs) @strip_key def create_item(self, user_id, course_key, block_type, block_id=None, fields=None, **kwargs): diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py index abe9a693f0..05084d85f3 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py @@ -952,11 +952,10 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): In split, other than copying the assets, this is cheap as it merely creates a new version of the existing course. """ - super(SplitMongoModuleStore, self).clone_course(source_course_id, dest_course_id, user_id, fields, **kwargs) source_index = self.get_course_index_info(source_course_id) if source_index is None: raise ItemNotFoundError("Cannot find a course at {0}. Aborting".format(source_course_id)) - return self.create_course( + new_course = self.create_course( dest_course_id.org, dest_course_id.course, dest_course_id.run, user_id, fields=fields, @@ -965,6 +964,9 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): skip_auto_publish=True, **kwargs ) + # don't copy assets until we create the course in case something's awry + super(SplitMongoModuleStore, self).clone_course(source_course_id, dest_course_id, user_id, fields, **kwargs) + return new_course DEFAULT_ROOT_BLOCK_ID = 'course' def create_course( @@ -1794,7 +1796,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): xblock_class = self.mixologist.mix(xblock_class) for field_name, value in fields.iteritems(): - if value: + if value is not None: if isinstance(xblock_class.fields[field_name], Reference): fields[field_name] = value.block_id elif isinstance(xblock_class.fields[field_name], ReferenceList): diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 724efc1989..dee09c0d59 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -118,7 +118,7 @@ def course_image_url(course): url += '/images/course_image.jpg' else: loc = StaticContent.compute_location(course.id, course.course_image) - url = loc.to_deprecated_string() + url = StaticContent.serialize_asset_key_with_slash(loc) return url @@ -360,14 +360,15 @@ def get_cms_block_link(block, page): return u"//{}/{}/{}".format(settings.CMS_BASE, page, block.location) -def get_studio_url(course_key, page): +def get_studio_url(course, page): """ Get the Studio URL of the page that is passed in. + + Args: + course (CourseDescriptor) """ - assert(isinstance(course_key, CourseKey)) - course = get_course_by_id(course_key) is_studio_course = course.course_edit_method == "Studio" - is_mongo_course = modulestore().get_modulestore_type(course_key) == ModuleStoreEnum.Type.mongo + is_mongo_course = modulestore().get_modulestore_type(course.id) != ModuleStoreEnum.Type.xml studio_link = None if is_studio_course and is_mongo_course: studio_link = get_cms_course_link(course, page) diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 350485f2e3..66232198be 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -311,7 +311,7 @@ def index(request, course_id, chapter=None, section=None, u' far, should have gotten a course module for this user') return redirect(reverse('about_course', args=[course_key.to_deprecated_string()])) - studio_url = get_studio_url(course_key, 'course') + studio_url = get_studio_url(course, 'course') context = { 'csrf': csrf(request)['csrf_token'], @@ -419,7 +419,7 @@ def index(request, course_id, chapter=None, section=None, context['section_title'] = section_descriptor.display_name_with_default else: # section is none, so display a message - studio_url = get_studio_url(course_key, 'course') + studio_url = get_studio_url(course, 'course') prev_section = get_current_child(chapter_module) if prev_section is None: # Something went wrong -- perhaps this chapter has no sections visible to the user @@ -553,7 +553,7 @@ def course_info(request, course_id): staff_access = has_access(request.user, 'staff', course) masq = setup_masquerade(request, staff_access) # allow staff to toggle masquerade on info page reverifications = fetch_reverify_banner_info(request, course_key) - studio_url = get_studio_url(course_key, 'course_info') + studio_url = get_studio_url(course, 'course_info') context = { 'request': request, @@ -655,7 +655,7 @@ def course_about(request, course_id): course = get_course_with_access(request.user, 'see_exists', course_key) registered = registered_for_course(course, request.user) staff_access = has_access(request.user, 'staff', course) - studio_url = get_studio_url(course_key, 'settings/details') + studio_url = get_studio_url(course, 'settings/details') if has_access(request.user, 'load', course): course_target = reverse('info', args=[course.id.to_deprecated_string()]) @@ -812,7 +812,7 @@ def _progress(request, course_key, student_id): student = User.objects.prefetch_related("groups").get(id=student.id) courseware_summary = grades.progress_summary(student, request, course) - studio_url = get_studio_url(course_key, 'settings/grading') + studio_url = get_studio_url(course, 'settings/grading') grade_summary = grades.grade(student, request, course) if courseware_summary is None: diff --git a/lms/urls.py b/lms/urls.py index d3d220c289..f363bd2a74 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -180,7 +180,7 @@ if settings.WIKI_ENABLED: # never be returned by a reverse() so they come after the other url patterns url(r'^courses/{}/course_wiki/?$'.format(settings.COURSE_ID_PATTERN), 'course_wiki.views.course_wiki_redirect', name="course_wiki"), - url(r'^courses/(?:[^/]+/[^/]+/[^/]+)/wiki/', include(wiki_pattern())), + url(r'^courses/{}/wiki/'.format(settings.COURSE_ID_PATTERN), include(wiki_pattern())), ) if settings.COURSEWARE_ENABLED: diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index ca886da487..a8eacf20d1 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -10,6 +10,7 @@ bleach==1.4 html5lib==0.999 boto==2.13.3 celery==3.0.19 +cssselect==0.9.1 dealer==0.2.3 distribute>=0.6.28, <0.7 django-babel-underscore==0.1.0 diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 21438cae36..948ea45186 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -18,7 +18,7 @@ -e git+https://github.com/jazkarta/edx-jsme.git@813079fd5218ed275248d2a1fcae2fcbf20a0838#egg=edx-jsme # Our libraries: --e git+https://github.com/edx/XBlock.git@f0e53538be7ce90584a03cc7dd3f06bd43e12ac2#egg=XBlock +-e git+https://github.com/edx/XBlock.git@de7fde7f27b1f4a0bb7b6ea9041cc893021be287#egg=XBlock -e git+https://github.com/edx/codejail.git@71f5c5616e2a73ae8cecd1ff2362774a773d3665#egg=codejail -e git+https://github.com/edx/diff-cover.git@v0.5.0#egg=diff_cover -e git+https://github.com/edx/js-test-tool.git@v0.1.5#egg=js_test_tool