diff --git a/cms/djangoapps/contentstore/management/commands/import.py b/cms/djangoapps/contentstore/management/commands/import.py index 1d15f1e7df..a8ec2c2685 100644 --- a/cms/djangoapps/contentstore/management/commands/import.py +++ b/cms/djangoapps/contentstore/management/commands/import.py @@ -26,4 +26,4 @@ class Command(BaseCommand): print "Importing. Data_dir={data}, course_dirs={courses}".format( data=data_dir, courses=course_dirs) - import_from_xml(modulestore(), data_dir, course_dirs, load_error_modules=False) + import_from_xml(modulestore('direct'), data_dir, course_dirs, load_error_modules=False) diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py index 429774c91e..b5dfc3c4fe 100644 --- a/cms/djangoapps/contentstore/tests/tests.py +++ b/cms/djangoapps/contentstore/tests/tests.py @@ -141,8 +141,6 @@ class AuthTestCase(ContentStoreTestCase): """Make sure pages that do require login work.""" auth_pages = ( reverse('index'), - reverse('edit_item'), - reverse('save_item'), ) # These are pages that should just load when the user is logged in @@ -181,6 +179,7 @@ class AuthTestCase(ContentStoreTestCase): TEST_DATA_MODULESTORE = copy.deepcopy(settings.MODULESTORE) TEST_DATA_MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data') +TEST_DATA_MODULESTORE['direct']['OPTIONS']['fs_root'] = path('common/test/data') @override_settings(MODULESTORE=TEST_DATA_MODULESTORE) class EditTestCase(ContentStoreTestCase): @@ -195,17 +194,17 @@ class EditTestCase(ContentStoreTestCase): xmodule.modulestore.django._MODULESTORES = {} xmodule.modulestore.django.modulestore().collection.drop() - def check_edit_item(self, test_course_name): + def check_edit_unit(self, test_course_name): import_from_xml(modulestore(), 'common/test/data/', [test_course_name]) - for descriptor in modulestore().get_items(Location(None, None, None, None, None)): + for descriptor in modulestore().get_items(Location(None, None, 'vertical', None, None)): print "Checking ", descriptor.location.url() print descriptor.__class__, descriptor.location - resp = self.client.get(reverse('edit_item'), {'id': descriptor.location.url()}) + resp = self.client.get(reverse('edit_unit', kwargs={'location': descriptor.location.url()})) self.assertEqual(resp.status_code, 200) - def test_edit_item_toy(self): - self.check_edit_item('toy') + def test_edit_unit_toy(self): + self.check_edit_unit('toy') - def test_edit_item_full(self): - self.check_edit_item('full') + def test_edit_unit_full(self): + self.check_edit_unit('full') diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 4000f011ba..bc33c7d1f6 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -1,6 +1,9 @@ from django.conf import settings from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore +from xmodule.modulestore.draft import DRAFT +from xmodule.modulestore.exceptions import ItemNotFoundError + def get_course_location_for_item(location): ''' @@ -32,16 +35,42 @@ def get_course_location_for_item(location): return location -def get_lms_link_for_item(item): +def get_lms_link_for_item(location): + location = Location(location) if settings.LMS_BASE is not None: lms_link = "{lms_base}/courses/{course_id}/jump_to/{location}".format( lms_base=settings.LMS_BASE, # TODO: These will need to be changed to point to the particular instance of this problem in the particular course - course_id = modulestore().get_containing_courses(item.location)[0].id, - location=item.location, + course_id = modulestore().get_containing_courses(location)[0].id, + location=location, ) else: lms_link = None return lms_link + +class UnitState(object): + draft = 'draft' + private = 'private' + public = 'public' + + +def compute_unit_state(unit): + """ + Returns whether this unit is 'draft', 'public', or 'private'. + + 'draft' content is in the process of being edited, but still has a previous + version visible in the LMS + 'public' content is locked and visible in the LMS + 'private' content is editabled and not visible in the LMS + """ + + if unit.metadata.get('is_draft', False): + try: + modulestore('direct').get_item(unit.location) + return UnitState.draft + except ItemNotFoundError: + return UnitState.private + else: + return UnitState.public diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 190b463383..ba362ec91a 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -1,11 +1,12 @@ from util.json_request import expect_json -import json -import os -import logging -import sys -import mimetypes -import StringIO import exceptions +import json +import logging +import mimetypes +import os +import StringIO +import sys +import time from collections import defaultdict from uuid import uuid4 @@ -43,7 +44,7 @@ from cache_toolbox.core import set_cached_content, get_cached_content, del_cache from auth.authz import is_user_in_course_group_role, get_users_in_course_group_by_role from auth.authz import get_user_by_email, add_user_to_course_group, remove_user_from_course_group from auth.authz import INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME -from .utils import get_course_location_for_item, get_lms_link_for_item +from .utils import get_course_location_for_item, get_lms_link_for_item, compute_unit_state from xmodule.templates import all_templates @@ -154,7 +155,7 @@ def edit_subsection(request, location): item = modulestore().get_item(location) - lms_link = get_lms_link_for_item(item) + lms_link = get_lms_link_for_item(location) # make sure that location references a 'sequential', otherwise return BadRequest if item.location.category != 'sequential': @@ -168,6 +169,7 @@ def edit_subsection(request, location): 'lms_link': lms_link }) + @login_required def edit_unit(request, location): """ @@ -183,7 +185,8 @@ def edit_unit(request, location): item = modulestore().get_item(location) - lms_link = get_lms_link_for_item(item) + # The non-draft location + lms_link = get_lms_link_for_item(item.location._replace(revision=None)) component_templates = defaultdict(list) @@ -210,14 +213,25 @@ def edit_unit(request, location): containing_section_locs = modulestore().get_parent_locations(containing_subsection.location) containing_section = modulestore().get_item(containing_section_locs[0]) + unit_state = compute_unit_state(item) + + try: + published_date = time.strftime('%B %d, %Y', item.metadata.get('published_date')) + except TypeError: + published_date = None + return render_to_response('unit.html', { 'unit': item, + 'unit_location': location, 'components': components, 'component_templates': component_templates, - 'lms_link': lms_link, + 'draft_preview_link': lms_link, + 'published_preview_link': lms_link, 'subsection': containing_subsection, 'section': containing_section, - 'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty') + 'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'), + 'unit_state': unit_state, + 'published_date': published_date, }) @@ -235,7 +249,6 @@ def preview_component(request, location): }) - def user_author_string(user): '''Get an author string for commits by this user. Format: first last . @@ -404,6 +417,13 @@ def get_module_previews(request, descriptor): preview_html.append(module.get_html()) return preview_html + +def _xmodule_recurse(item, action): + for child in item.get_children(): + _xmodule_recurse(child, action) + + action(item) + def _delete_item(item, recurse=False): if recurse: children = item.get_children() @@ -427,8 +447,11 @@ def delete_item(request): item = modulestore().get_item(item_location) - _delete_item(item, delete_children) - + if delete_children: + _xmodule_recurse(item, lambda i: modulestore().delete_item(i.location)) + else: + modulestore().delete_item(item.location) + return HttpResponse() @@ -479,6 +502,51 @@ def save_item(request): return HttpResponse() +@login_required +@expect_json +def create_draft(request): + location = request.POST['id'] + + # check permissions for this user within this course + if not has_access(request.user, location): + raise PermissionDenied() + + # This clones the existing item location to a draft location (the draft is implicit, + # because modulestore is a Draft modulestore) + modulestore().clone_item(location, location) + + return HttpResponse() + +@login_required +@expect_json +def publish_draft(request): + location = request.POST['id'] + + # check permissions for this user within this course + if not has_access(request.user, location): + raise PermissionDenied() + + item = modulestore().get_item(location) + _xmodule_recurse(item, lambda i: modulestore().publish(i.location, request.user.id)) + + return HttpResponse() + + +@login_required +@expect_json +def unpublish_unit(request): + location = request.POST['id'] + + # check permissions for this user within this course + if not has_access(request.user, location): + raise PermissionDenied() + + item = modulestore().get_item(location) + _xmodule_recurse(item, lambda i: modulestore().unpublish(i.location)) + + return HttpResponse() + + @login_required @expect_json def clone_item(request): @@ -503,7 +571,12 @@ def clone_item(request): new_item.metadata['display_name'] = display_name modulestore().update_metadata(new_item.location.url(), new_item.own_metadata) - modulestore().update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()]) + + if parent_location.category not in ('vertical',): + parent_update_modulestore = modulestore('direct') + else: + parent_update_modulestore = modulestore() + parent_update_modulestore.update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()]) return HttpResponse(json.dumps({'id': dest_location.url()})) diff --git a/cms/envs/dev.py b/cms/envs/dev.py index 8401fa5c15..e5548df2d4 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -14,17 +14,23 @@ LOGGING = get_logger_config(ENV_ROOT / "log", tracking_filename="tracking.log", debug=True) +modulestore_options = { + 'default_class': 'xmodule.raw_module.RawDescriptor', + 'host': 'localhost', + 'db': 'xmodule', + 'collection': 'modulestore', + 'fs_root': GITHUB_REPO_ROOT, + 'render_template': 'mitxmako.shortcuts.render_to_string', +} + MODULESTORE = { 'default': { + 'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore', + 'OPTIONS': modulestore_options + }, + 'direct': { 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', - 'OPTIONS': { - 'default_class': 'xmodule.raw_module.RawDescriptor', - 'host': 'localhost', - 'db': 'xmodule', - 'collection': 'modulestore', - 'fs_root': GITHUB_REPO_ROOT, - 'render_template': 'mitxmako.shortcuts.render_to_string', - } + 'OPTIONS': modulestore_options } } diff --git a/cms/envs/test.py b/cms/envs/test.py index 7dcd32caab..217ed0e573 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -38,17 +38,23 @@ STATICFILES_DIRS += [ if os.path.isdir(COMMON_TEST_DATA_ROOT / course_dir) ] +modulestore_options = { + 'default_class': 'xmodule.raw_module.RawDescriptor', + 'host': 'localhost', + 'db': 'test_xmodule', + 'collection': 'modulestore', + 'fs_root': GITHUB_REPO_ROOT, + 'render_template': 'mitxmako.shortcuts.render_to_string', +} + MODULESTORE = { 'default': { 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', - 'OPTIONS': { - 'default_class': 'xmodule.raw_module.RawDescriptor', - 'host': 'localhost', - 'db': 'test_xmodule', - 'collection': 'modulestore', - 'fs_root': GITHUB_REPO_ROOT, - 'render_template': 'mitxmako.shortcuts.render_to_string', - } + 'OPTIONS': modulestore_options + }, + 'direct': { + 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', + 'OPTIONS': modulestore_options } } diff --git a/cms/static/coffee/src/views/module_edit.coffee b/cms/static/coffee/src/views/module_edit.coffee index 2326756dc8..85c099ab9a 100644 --- a/cms/static/coffee/src/views/module_edit.coffee +++ b/cms/static/coffee/src/views/module_edit.coffee @@ -35,9 +35,9 @@ class CMS.Views.ModuleEdit extends Backbone.View return _metadata - cloneTemplate: (template) -> + cloneTemplate: (parent, template) -> $.post("/clone_item", { - parent_location: @$el.parent().data('id') + parent_location: parent template: template }, (data) => @model.set(id: data.id) diff --git a/cms/static/coffee/src/views/unit.coffee b/cms/static/coffee/src/views/unit.coffee index 1180b17471..53eb11d511 100644 --- a/cms/static/coffee/src/views/unit.coffee +++ b/cms/static/coffee/src/views/unit.coffee @@ -5,9 +5,35 @@ class CMS.Views.UnitEdit extends Backbone.View 'click .new-component-templates .new-component-template a': 'saveNewComponent' 'click .new-component-templates .cancel-button': 'closeNewComponent' 'click .new-component-button': 'showNewComponentForm' - 'click .unit-actions .save-button': 'save' + 'click #save-draft': 'saveDraft' + 'click #delete-draft': 'deleteDraft' + 'click #create-draft': 'createDraft' + 'click #publish-draft': 'publishDraft' + 'change #visibility': 'setVisibility' initialize: => + @visibilityView = new CMS.Views.UnitEdit.Visibility( + el: @$('#visibility') + model: @model + ) + + @saveView = new CMS.Views.UnitEdit.SaveDraftButton( + el: @$('#save-draft') + model: @model + ) + + @locationView = new CMS.Views.UnitEdit.LocationState( + el: @$('.section-item.editing a') + model: @model + ) + + @nameView = new CMS.Views.UnitEdit.NameEdit( + el: @$('.unit-name-input') + model: @model + ) + + @model.on('change:state', @render) + @$newComponentItem = @$('.new-component-item') @$newComponentTypePicker = @$('.new-component') @$newComponentTemplatePickers = @$('.new-component-templates') @@ -15,7 +41,13 @@ class CMS.Views.UnitEdit extends Backbone.View @$('.components').sortable( handle: '.drag-handle' - update: (event, ui) => @saveOrder() + update: (event, ui) => @model.set('children', @components()) + helper: 'clone' + opacity: '0.5' + placeholder: 'component-placeholder' + forcePlaceholderSize: true + axis: 'y' + items: '> .component' ) @$('.component').each((idx, element) => @@ -26,10 +58,10 @@ class CMS.Views.UnitEdit extends Backbone.View id: $(element).data('id'), ) ) + update: (event, ui) => @model.set('children', @components()) ) - @model.components = @components() - + # New component creation showNewComponentForm: (event) => event.preventDefault() @$newComponentItem.addClass('adding') @@ -56,21 +88,31 @@ class CMS.Views.UnitEdit extends Backbone.View event.preventDefault() editor = new CMS.Views.ModuleEdit( + onDelete: @deleteComponent model: new CMS.Models.Module() ) @$newComponentItem.before(editor.$el) - editor.cloneTemplate($(event.currentTarget).data('location')) + editor.cloneTemplate( + @$el.data('id'), + $(event.currentTarget).data('location') + ) @closeNewComponent(event) components: => @$('.component').map((idx, el) -> $(el).data('id')).get() - saveOrder: => - @model.save( - children: @components() - ) + wait: (value) => + @$('.unit-body').toggleClass("waiting", value) + + render: => + if @model.hasChanged('state') + @$el.toggleClass("edit-state-#{@model.previous('state')} edit-state-#{@model.get('state')}") + @wait(false) + + saveDraft: => + @model.save() deleteComponent: (event) => $component = $(event.currentTarget).parents('.component') @@ -78,6 +120,94 @@ class CMS.Views.UnitEdit extends Backbone.View id: $component.data('id') }, => $component.remove() - @saveOrder() + @model.set('children', @components()) ) + deleteDraft: (event) -> + @wait(true) + + $.post('/delete_item', { + id: @$el.data('id') + delete_children: true + }, => + window.location.reload() + ) + + createDraft: (event) -> + @wait(true) + + $.post('/create_draft', { + id: @$el.data('id') + }, => + @model.set('state', 'draft') + ) + + publishDraft: (event) -> + @wait(true) + @saveDraft() + + $.post('/publish_draft', { + id: @$el.data('id') + }, => + @model.set('state', 'public') + ) + + setVisibility: (event) -> + if @$('#visibility').val() == 'private' + target_url = '/unpublish_unit' + else + target_url = '/publish_draft' + + @wait(true) + + $.post(target_url, { + id: @$el.data('id') + }, => + @model.set('state', @$('#visibility').val()) + ) + +class CMS.Views.UnitEdit.NameEdit extends Backbone.View + events: + "keyup .unit-display-name-input": "saveName" + + initialize: => + @model.on('change:metadata', @render) + @saveName + + render: => + @$('.unit-display-name-input').val(@model.get('metadata').display_name) + + saveName: => + # Treat the metadata dictionary as immutable + metadata = $.extend({}, @model.get('metadata')) + metadata.display_name = @$('.unit-display-name-input').val() + @model.set('metadata', metadata) + +class CMS.Views.UnitEdit.LocationState extends Backbone.View + initialize: => + @model.on('change:state', @render) + + render: => + @$el.toggleClass("#{@model.previous('state')}-item #{@model.get('state')}-item") + +class CMS.Views.UnitEdit.Visibility extends Backbone.View + initialize: => + @model.on('change:state', @render) + @render() + + render: => + @$el.val(@model.get('state')) + +class CMS.Views.UnitEdit.SaveDraftButton extends Backbone.View + initialize: => + @model.on('change:children', @enable) + @model.on('change:metadata', @enable) + @model.on('sync', @disable) + + @disable() + + disable: => + @$el.addClass('disabled') + + enable: => + @$el.removeClass('disabled') \ No newline at end of file diff --git a/cms/static/sass/_base.scss b/cms/static/sass/_base.scss index fe97c9b975..e57c8338d5 100644 --- a/cms/static/sass/_base.scss +++ b/cms/static/sass/_base.scss @@ -29,6 +29,10 @@ h1 { margin: 36px 6px; } +.waiting { + opacity: 0.1; +} + .page-actions { float: right; margin-top: 42px; diff --git a/cms/static/sass/_cms_mixins.scss b/cms/static/sass/_cms_mixins.scss index c1d8245e36..efe4556b50 100644 --- a/cms/static/sass/_cms_mixins.scss +++ b/cms/static/sass/_cms_mixins.scss @@ -16,6 +16,18 @@ @include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset, 0 0 0 rgba(0, 0, 0, 0)); @include transition(background-color .15s, box-shadow .15s); + &.disabled { + border: 1px solid $lightGrey !important; + border-radius: 3px !important; + background: $lightGrey !important; + color: $darkGrey !important; + pointer-events: none; + cursor: none; + &:hover { + box-shadow: 0 0 0 0 !important; + } + } + &:hover { @include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset, 0 1px 1px rgba(0, 0, 0, .15)); } @@ -161,13 +173,33 @@ background: #fffcf1; } - .draft-item, - .hidden-item, + .draft-item:after, + .public-item:after, + .private-item:after { + margin-left: 3px; + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + } + + .draft-item:after { + content: "- draft"; + } + + .public-item:after { + content: "- public"; + } + + .private-item:after { + content: "- private"; + } + + .public-item, .private-item { color: #a4aab7; } - .has-new-draft-item { + .draft-item { color: #9f7d10; } } diff --git a/cms/static/sass/_graphics.scss b/cms/static/sass/_graphics.scss index 65c827981a..2214c333e0 100644 --- a/cms/static/sass/_graphics.scss +++ b/cms/static/sass/_graphics.scss @@ -117,9 +117,8 @@ } .draft-tag, -.hidden-tag, -.private-tag, -.has-new-draft-tag { +.public-tag, +.private-tag { margin-left: 3px; font-size: 9px; font-weight: 600; @@ -127,7 +126,7 @@ color: #a4aab7; } -.has-new-draft-tag { +.draft-tag { color: #9f7d10; } diff --git a/cms/static/sass/_unit.scss b/cms/static/sass/_unit.scss index 7ed7e3ee7e..e0b789c39e 100644 --- a/cms/static/sass/_unit.scss +++ b/cms/static/sass/_unit.scss @@ -87,7 +87,11 @@ } } - .rendered-component { + &.component-placeholder { + border-color: #6696d7; + } + + .xmodule_display { padding: 40px 20px 20px; } @@ -394,3 +398,37 @@ } } } + +.edit-state-draft { + .visibility { + display: none; + } + + #create-draft { + display: none; + } +} + +.edit-state-public { + #save-draft, + #delete-draft, + #publish-draft, + .component-actions, + .new-component-item, + #published-alert { + display: none; + } + + .drag-handle { + display: none !important; + } +} + +.edit-state-private { + #delete-draft, + #publish-draft, + #published-alert, + #create-draft, { + display: none; + } +} diff --git a/cms/templates/unit.html b/cms/templates/unit.html index 5434c11845..bef3695a8d 100644 --- a/cms/templates/unit.html +++ b/cms/templates/unit.html @@ -8,18 +8,27 @@ new CMS.Views.UnitEdit({ el: $('.main-wrapper'), model: new CMS.Models.Module({ - id: '${unit.location.url()}' + id: '${unit_location}', + state: '${unit_state}' }) }); <%block name="content"> -
+
+
+

You are editing a draft. + % if published_date: + This unit was originally published on ${published_date}. + % endif +

+ Preview the published version +

-
    +
      % for id in components:
    1. % endfor @@ -60,31 +69,26 @@
- - \ No newline at end of file + diff --git a/cms/templates/widgets/units.html b/cms/templates/widgets/units.html index 67e956561c..13bf73fec6 100644 --- a/cms/templates/widgets/units.html +++ b/cms/templates/widgets/units.html @@ -1,4 +1,5 @@ <%! from django.core.urlresolvers import reverse %> +<%! from contentstore.utils import compute_unit_state %>