diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py
index eb666d1837..76c56a5fdc 100644
--- a/cms/djangoapps/contentstore/features/common.py
+++ b/cms/djangoapps/contentstore/features/common.py
@@ -18,6 +18,8 @@ logger = getLogger(__name__)
from terrain.browser import reset_data
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
+PASSWORD = 'test'
+EMAIL_EXTENSION = '@edx.org'
@step('I (?:visit|access|open) the Studio homepage$')
@@ -300,3 +302,48 @@ def upload_file(filename):
world.browser.attach_file('file', os.path.abspath(path))
button_css = '.upload-dialog .action-upload'
world.css_click(button_css)
+
+
+@step(u'"([^"]*)" logs in$')
+def other_user_login(step, name):
+ step.given('I log out')
+ world.visit('/')
+
+ signin_css = 'a.action-signin'
+ world.is_css_present(signin_css)
+ world.css_click(signin_css)
+
+ def fill_login_form():
+ login_form = world.browser.find_by_css('form#login_form')
+ login_form.find_by_name('email').fill(name + EMAIL_EXTENSION)
+ login_form.find_by_name('password').fill(PASSWORD)
+ login_form.find_by_name('submit').click()
+ world.retry_on_exception(fill_login_form)
+ assert_true(world.is_css_present('.new-course-button'))
+ world.scenario_dict['USER'] = get_user_by_email(name + EMAIL_EXTENSION)
+
+
+@step(u'the user "([^"]*)" exists( as a course (admin|staff member|is_staff))?$')
+def create_other_user(_step, name, has_extra_perms, role_name):
+ email = name + EMAIL_EXTENSION
+ user = create_studio_user(uname=name, password=PASSWORD, email=email)
+ if has_extra_perms:
+ if role_name == "is_staff":
+ user.is_staff = True
+ else:
+ if role_name == "admin":
+ # admins get staff privileges, as well
+ roles = ("staff", "instructor")
+ else:
+ roles = ("staff",)
+ location = world.scenario_dict["COURSE"].location
+ for role in roles:
+ groupname = get_course_groupname_for_role(location, role)
+ group, __ = Group.objects.get_or_create(name=groupname)
+ user.groups.add(group)
+ user.save()
+
+
+@step('I log out')
+def log_out(_step):
+ world.visit('logout')
diff --git a/cms/djangoapps/contentstore/features/course-team.py b/cms/djangoapps/contentstore/features/course-team.py
index 91049b029e..fd39c1ae3a 100644
--- a/cms/djangoapps/contentstore/features/course-team.py
+++ b/cms/djangoapps/contentstore/features/course-team.py
@@ -2,14 +2,10 @@
#pylint: disable=W0621
from lettuce import world, step
-from common import create_studio_user
-from django.contrib.auth.models import Group
+from common import EMAIL_EXTENSION
from auth.authz import get_course_groupname_for_role, get_user_by_email
from nose.tools import assert_true, assert_in # pylint: disable=E0611
-PASSWORD = 'test'
-EMAIL_EXTENSION = '@edx.org'
-
@step(u'(I am viewing|s?he views) the course team settings')
def view_grading_settings(_step, whom):
@@ -18,24 +14,6 @@ def view_grading_settings(_step, whom):
world.css_click(link_css)
-@step(u'the user "([^"]*)" exists( as a course (admin|staff member))?$')
-def create_other_user(_step, name, has_extra_perms, role_name):
- email = name + EMAIL_EXTENSION
- user = create_studio_user(uname=name, password=PASSWORD, email=email)
- if has_extra_perms:
- location = world.scenario_dict["COURSE"].location
- if role_name == "admin":
- # admins get staff privileges, as well
- roles = ("staff", "instructor")
- else:
- roles = ("staff",)
- for role in roles:
- groupname = get_course_groupname_for_role(location, role)
- group, __ = Group.objects.get_or_create(name=groupname)
- user.groups.add(group)
- user.save()
-
-
@step(u'I add "([^"]*)" to the course team')
def add_other_user(_step, name):
new_user_css = 'a.create-user-button'
@@ -89,25 +67,6 @@ def remove_course_team_admin(_step, outer_capture, name):
world.css_click(admin_btn_css)
-@step(u'"([^"]*)" logs in$')
-def other_user_login(_step, name):
- world.visit('logout')
- world.visit('/')
-
- signin_css = 'a.action-signin'
- world.is_css_present(signin_css)
- world.css_click(signin_css)
-
- def fill_login_form():
- login_form = world.browser.find_by_css('form#login_form')
- login_form.find_by_name('email').fill(name + EMAIL_EXTENSION)
- login_form.find_by_name('password').fill(PASSWORD)
- login_form.find_by_name('submit').click()
- world.retry_on_exception(fill_login_form)
- assert_true(world.is_css_present('.new-course-button'))
- world.scenario_dict['USER'] = get_user_by_email(name + EMAIL_EXTENSION)
-
-
@step(u'I( do not)? see the course on my page')
@step(u's?he does( not)? see the course on (his|her) page')
def see_course(_step, do_not_see, gender='self'):
diff --git a/cms/djangoapps/contentstore/features/upload.feature b/cms/djangoapps/contentstore/features/upload.feature
index fed8c65ca1..6a9dfda143 100644
--- a/cms/djangoapps/contentstore/features/upload.feature
+++ b/cms/djangoapps/contentstore/features/upload.feature
@@ -58,3 +58,59 @@ Feature: CMS.Upload Files
And I reload the page
And I upload the file "test"
Then I can download the correct "test" file
+
+ # Uploading isn't working on safari with sauce labs
+ @skip_safari
+ Scenario: Users can lock assets through asset index
+ Given I have opened a new course in studio
+ And I go to the files and uploads page
+ When I upload the file "test"
+ And I lock "test"
+ Then "test" is locked
+ And I see a "saving" notification
+ And I reload the page
+ Then "test" is locked
+
+ # Uploading isn't working on safari with sauce labs
+ @skip_safari
+ Scenario: Users can unlock assets through asset index
+ Given I have opened a course with a locked asset "test"
+ And I unlock "test"
+ Then "test" is unlocked
+ And I see a "saving" notification
+ And I reload the page
+ Then "test" is unlocked
+
+ # Uploading isn't working on safari with sauce labs
+ @skip_safari
+ Scenario: Locked assets can't be viewed if logged in as unregistered user
+ Given I have opened a course with a locked asset "locked.html"
+# Then the asset "locked.html" is viewable
+ And the user "bob" exists
+ And "bob" logs in
+ Then the asset "locked.html" is protected
+
+ # Uploading isn't working on safari with sauce labs
+ @skip_safari
+ Scenario: Locked assets can't be viewed if logged out
+ Given I have opened a course with a locked asset "locked.html"
+ And I log out
+ Then the asset "locked.html" is protected
+
+ # Uploading isn't working on safari with sauce labs
+ @skip_safari
+ Scenario: Locked assets can be viewed with is_staff account
+ Given I have opened a course with a locked asset "locked.html"
+ And the user "staff" exists as a course is_staff
+# Then the asset "locked.html" is viewable
+
+ # Uploading isn't working on safari with sauce labs
+ @skip_safari
+ Scenario: Unlocked assets can be viewed by anyone
+ Given I have opened a course with a unlocked asset "unlocked.html"
+ Then the asset "unlocked.html" is viewable
+ And the user "bob" exists
+ And "bob" logs in
+ Then the asset "unlocked.html" is viewable
+ And I log out
+ Then the asset "unlocked.html" is viewable
diff --git a/cms/djangoapps/contentstore/features/upload.py b/cms/djangoapps/contentstore/features/upload.py
index d0a4e9f366..18ad757c2f 100644
--- a/cms/djangoapps/contentstore/features/upload.py
+++ b/cms/djangoapps/contentstore/features/upload.py
@@ -11,6 +11,7 @@ from nose.tools import assert_equal, assert_not_equal # pylint: disable=E0611
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
+ASSET_NAMES_CSS = 'td.name-col > span.title > a.filename'
@step(u'I go to the files and uploads page')
@@ -59,8 +60,7 @@ def check_not_there(_step, file_name):
# the only file that was uploaded, our success criteria
# will be that there are no files.
# In the future we can refactor if necessary.
- names_css = 'td.name-col > a.filename'
- assert(world.is_css_not_present(names_css))
+ assert(world.is_css_not_present(ASSET_NAMES_CSS))
@step(u'I should see the file "([^"]*)" was uploaded$')
@@ -88,11 +88,10 @@ def delete_file(_step, file_name):
@step(u'I should see only one "([^"]*)"$')
def no_duplicate(_step, file_name):
- names_css = 'td.name-col > a.filename'
- all_names = world.css_find(names_css)
+ all_names = world.css_find(ASSET_NAMES_CSS)
only_one = False
for i in range(len(all_names)):
- if file_name == world.css_html(names_css, index=i):
+ if file_name == world.css_html(ASSET_NAMES_CSS, index=i):
only_one = not only_one
assert only_one
@@ -106,16 +105,67 @@ def check_download(_step, file_name):
downloaded_text = r.text
assert cur_text == downloaded_text
#resetting the file back to its original state
+ _write_test_file(file_name, "This is an arbitrary file for testing uploads")
+
+
+def _write_test_file(file_name, text):
+ path = os.path.join(TEST_ROOT, 'uploads/', file_name)
+ #resetting the file back to its original state
with open(os.path.abspath(path), 'w') as cur_file:
- cur_file.write("This is an arbitrary file for testing uploads")
+ cur_file.write(text)
@step(u'I modify "([^"]*)"$')
def modify_upload(_step, file_name):
new_text = ''.join(random.choice(string.ascii_uppercase + string.digits) for x in range(10))
- path = os.path.join(TEST_ROOT, 'uploads/', file_name)
- with open(os.path.abspath(path), 'w') as cur_file:
- cur_file.write(new_text)
+ _write_test_file(file_name, new_text)
+
+
+@step(u'I (lock|unlock) "([^"]*)"')
+def lock_unlock_file(_step, _lock_state, file_name):
+ index = get_index(file_name)
+ assert index != -1
+ lock_css = "a.lock-asset-button"
+ world.css_click(lock_css, index=index)
+
+
+@step(u'Then "([^"]*)" is (locked|unlocked)')
+def verify_lock_unlock_file(_step, file_name, lock_state):
+ index = get_index(file_name)
+ assert index != -1
+ lock_css = "a.lock-asset-button"
+ text = (world.css_find(lock_css)[index]).text
+ if lock_state == "locked":
+ assert_equal("Unlock this asset", text)
+ else:
+ assert_equal("Lock this asset", text)
+
+
+@step(u'I have opened a course with a (locked|unlocked) asset "([^"]*)"')
+def open_course_with_locked(step, lock_state, file_name):
+ step.given('I have opened a new course in studio')
+ step.given('I go to the files and uploads page')
+ _write_test_file(file_name, "test file")
+ step.given('I upload the file "' + file_name + '"')
+ if lock_state == "locked":
+ step.given('I lock "' + file_name + '"')
+ step.given('I reload the page')
+
+
+@step(u'Then the asset "([^"]*)" is (viewable|protected)')
+def view_asset(step, file_name, status):
+ url = '/c4x/MITx/999/asset/' + file_name
+ if status == 'viewable':
+ world.visit(url)
+ assert world.css_text('body') == 'test file'
+ else:
+ error_thrown = False
+ try:
+ world.visit(url)
+ except Exception as e:
+ assert e.status_code == 403
+ error_thrown = True
+ assert error_thrown
@step('I see a confirmation that the file was deleted')
@@ -125,10 +175,9 @@ def i_see_a_delete_confirmation(_step):
def get_index(file_name):
- names_css = 'td.name-col > a.filename'
- all_names = world.css_find(names_css)
+ all_names = world.css_find(ASSET_NAMES_CSS)
for i in range(len(all_names)):
- if file_name == world.css_html(names_css, index=i):
+ if file_name == world.css_html(ASSET_NAMES_CSS, index=i):
return i
return -1
diff --git a/cms/djangoapps/contentstore/tests/test_assets.py b/cms/djangoapps/contentstore/tests/test_assets.py
index 7dd266b3eb..55613ea362 100644
--- a/cms/djangoapps/contentstore/tests/test_assets.py
+++ b/cms/djangoapps/contentstore/tests/test_assets.py
@@ -18,6 +18,7 @@ from xmodule.modulestore import Location
from xmodule.contentstore.django import contentstore
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.xml_importer import import_from_xml
+import json
class AssetsTestCase(CourseTestCase):
def setUp(self):
@@ -92,7 +93,7 @@ class AssetToJsonTestCase(TestCase):
location = Location(['i4x', 'foo', 'bar', 'asset', 'my_file_name.jpg'])
thumbnail_location = Location(['i4x', 'foo', 'bar', 'asset', 'my_file_name_thumb.jpg'])
- output = assets._get_asset_json("my_file", upload_date, location, thumbnail_location)
+ output = assets._get_asset_json("my_file", upload_date, location, thumbnail_location, True)
self.assertEquals(output["display_name"], "my_file")
self.assertEquals(output["date_added"], "Jun 01, 2013 at 10:30 UTC")
@@ -100,6 +101,48 @@ class AssetToJsonTestCase(TestCase):
self.assertEquals(output["portable_url"], "/static/my_file_name.jpg")
self.assertEquals(output["thumbnail"], "/i4x/foo/bar/asset/my_file_name_thumb.jpg")
self.assertEquals(output["id"], output["url"])
+ self.assertEquals(output['locked'], True)
- output = assets._get_asset_json("name", upload_date, location, None)
+ output = assets._get_asset_json("name", upload_date, location, None, False)
self.assertIsNone(output["thumbnail"])
+
+
+class LockAssetTestCase(CourseTestCase):
+ """
+ Unit test for locking and unlocking an asset.
+ """
+
+ def test_locking(self):
+ """
+ Tests a simple locking and unlocking of an asset in the toy course.
+ """
+ def verify_asset_locked_state(locked):
+ """ Helper method to verify lock state in the contentstore """
+ asset_location = StaticContent.get_location_from_path('/c4x/edX/toy/asset/sample_static.txt')
+ content = contentstore().find(asset_location)
+ self.assertEqual(content.locked, locked)
+
+ def post_asset_update(lock):
+ """ Helper method for posting asset update. """
+ upload_date = datetime(2013, 6, 1, 10, 30, tzinfo=UTC)
+ location = Location(['c4x', 'edX', 'toy', 'asset', 'sample_static.txt'])
+ url = reverse('update_asset', kwargs={'org': 'edX', 'course': 'toy', 'name': '2012_Fall'})
+
+ resp = self.client.post(url, json.dumps(assets._get_asset_json("sample_static.txt", upload_date, location, None, lock)), "application/json")
+ self.assertEqual(resp.status_code, 201)
+ return json.loads(resp.content)
+
+ # Load the toy course.
+ module_store = modulestore('direct')
+ import_from_xml(module_store, 'common/test/data/', ['toy'], static_content_store=contentstore(), verbose=True)
+ verify_asset_locked_state(False)
+
+ # Lock the asset
+ resp_asset = post_asset_update(True)
+ self.assertTrue(resp_asset['locked'])
+ verify_asset_locked_state(True)
+
+ # Unlock the asset
+ resp_asset = post_asset_update(False)
+ self.assertFalse(resp_asset['locked'])
+ verify_asset_locked_state(False)
diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py
index 3d5704b869..ea2ee16969 100644
--- a/cms/djangoapps/contentstore/views/assets.py
+++ b/cms/djangoapps/contentstore/views/assets.py
@@ -60,7 +60,8 @@ def asset_index(request, org, course, name):
_thumbnail_location = asset.get('thumbnail_location', None)
thumbnail_location = Location(_thumbnail_location) if _thumbnail_location is not None else None
- asset_json.append(_get_asset_json(asset['displayname'], asset['uploadDate'], asset_location, thumbnail_location))
+ asset_locked = asset.get('locked', False)
+ asset_json.append(_get_asset_json(asset['displayname'], asset['uploadDate'], asset_location, thumbnail_location, asset_locked))
return render_to_response('asset_index.html', {
'context_course': course_module,
@@ -136,63 +137,75 @@ def upload_asset(request, org, course, coursename):
# readback the saved content - we need the database timestamp
readback = contentstore().find(content.location)
+ locked = getattr(content, 'locked', False)
response_payload = {
- 'asset': _get_asset_json(content.name, readback.last_modified_at, content.location, content.thumbnail_location),
+ 'asset': _get_asset_json(content.name, readback.last_modified_at, content.location, content.thumbnail_location, locked),
'msg': _('Upload completed')
}
return JsonResponse(response_payload)
-@require_http_methods(("DELETE",))
+@require_http_methods(("DELETE", "POST", "PUT"))
@login_required
@ensure_csrf_cookie
def update_asset(request, org, course, name, asset_id):
"""
restful CRUD operations for a course asset.
- Currently only the DELETE method is implemented.
+ Currently only DELETE, POST, and PUT methods are implemented.
org, course, name: Attributes of the Location for the item to edit
asset_id: the URL of the asset (used by Backbone as the id)
"""
+ def get_asset_location(asset_id):
+ """ Helper method to get the location (and verify it is valid). """
+ try:
+ return StaticContent.get_location_from_path(asset_id)
+ except InvalidLocationError as err:
+ # return a 'Bad Request' to browser as we have a malformed Location
+ return JsonResponse({"error": err.message}, status=400)
+
get_location_and_verify_access(request, org, course, name)
- # make sure the location is valid
- try:
- loc = StaticContent.get_location_from_path(asset_id)
- except InvalidLocationError as err:
- # return a 'Bad Request' to browser as we have a malformed Location
- return JsonResponse({"error": err.message}, status=400)
-
- # also make sure the item to delete actually exists
- try:
- content = contentstore().find(loc)
- except NotFoundError:
- return JsonResponse(status=404)
-
- # ok, save the content into the trashcan
- contentstore('trashcan').save(content)
-
- # see if there is a thumbnail as well, if so move that as well
- if content.thumbnail_location is not None:
+ if request.method == 'DELETE':
+ loc = get_asset_location(asset_id)
+ # Make sure the item to delete actually exists.
try:
- thumbnail_content = contentstore().find(content.thumbnail_location)
- contentstore('trashcan').save(thumbnail_content)
- # hard delete thumbnail from origin
- contentstore().delete(thumbnail_content.get_id())
- # remove from any caching
- del_cached_content(thumbnail_content.location)
- except:
- logging.warning('Could not delete thumbnail: ' + content.thumbnail_location)
+ content = contentstore().find(loc)
+ except NotFoundError:
+ return JsonResponse(status=404)
- # delete the original
- contentstore().delete(content.get_id())
- # remove from cache
- del_cached_content(content.location)
- return JsonResponse()
+ # ok, save the content into the trashcan
+ contentstore('trashcan').save(content)
+
+ # see if there is a thumbnail as well, if so move that as well
+ if content.thumbnail_location is not None:
+ try:
+ thumbnail_content = contentstore().find(content.thumbnail_location)
+ contentstore('trashcan').save(thumbnail_content)
+ # hard delete thumbnail from origin
+ contentstore().delete(thumbnail_content.get_id())
+ # remove from any caching
+ del_cached_content(thumbnail_content.location)
+ except:
+ logging.warning('Could not delete thumbnail: ' + content.thumbnail_location)
+
+ # delete the original
+ contentstore().delete(content.get_id())
+ # remove from cache
+ del_cached_content(content.location)
+ return JsonResponse()
+
+ elif request.method in ('PUT', 'POST'):
+ # We don't support creation of new assets through this
+ # method-- just changing the locked state.
+ modified_asset = json.loads(request.body)
+ asset_id = modified_asset['url']
+ contentstore().set_attr(get_asset_location(asset_id), 'locked', modified_asset['locked'])
+ return JsonResponse(modified_asset, status=201)
-def _get_asset_json(display_name, date, location, thumbnail_location):
+def _get_asset_json(display_name, date, location, thumbnail_location, locked):
"""
Helper method for formatting the asset information to send to client.
"""
@@ -203,6 +216,7 @@ def _get_asset_json(display_name, date, location, thumbnail_location):
'url': asset_url,
'portable_url': StaticContent.get_static_path_from_location(location),
'thumbnail': StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_location is not None else None,
+ 'locked': locked,
# Needed for Backbone delete/update.
'id': asset_url
}
diff --git a/cms/static/coffee/spec/views/assets_spec.coffee b/cms/static/coffee/spec/views/assets_spec.coffee
index f4f67f682c..dd06491af5 100644
--- a/cms/static/coffee/spec/views/assets_spec.coffee
+++ b/cms/static/coffee/spec/views/assets_spec.coffee
@@ -8,6 +8,8 @@ describe "CMS.Views.Asset", ->
appendSetFixtures(sandbox({id: "page-prompt"}))
@model = new CMS.Models.Asset({display_name: "test asset", url: 'actual_asset_url', portable_url: 'portable_url', date_added: 'date', thumbnail: null, id: 'id'})
spyOn(@model, "destroy").andCallThrough()
+ spyOn(@model, "save").andCallThrough()
+
@collection = new CMS.Models.AssetCollection([@model])
@collection.url = "update-asset-url"
@view = new CMS.Views.Asset({model: @model})
@@ -35,7 +37,10 @@ describe "CMS.Views.Asset", ->
@xhr = sinon.useFakeXMLHttpRequest()
@xhr.onCreate = (xhr) -> requests.push(xhr)
- @savingSpies = spyOnConstructor(CMS.Views.Notification, "Confirmation", ["show"])
+ @confirmationSpies = spyOnConstructor(CMS.Views.Notification, "Confirmation", ["show"])
+ @confirmationSpies.show.andReturn(@confirmationSpies)
+
+ @savingSpies = spyOnConstructor(CMS.Views.Notification, "Mini", ["show", "hide"])
@savingSpies.show.andReturn(@savingSpies)
afterEach ->
@@ -49,13 +54,13 @@ describe "CMS.Views.Asset", ->
# AJAX request has been sent, but not yet returned
expect(@model.destroy).toHaveBeenCalled()
expect(@requests.length).toEqual(1)
- expect(@savingSpies.constructor).not.toHaveBeenCalled()
+ expect(@confirmationSpies.constructor).not.toHaveBeenCalled()
expect(@collection.contains(@model)).toBeTruthy()
# return a success response
@requests[0].respond(200)
- expect(@savingSpies.constructor).toHaveBeenCalled()
- expect(@savingSpies.show).toHaveBeenCalled()
- savingOptions = @savingSpies.constructor.mostRecentCall.args[0]
+ expect(@confirmationSpies.constructor).toHaveBeenCalled()
+ expect(@confirmationSpies.show).toHaveBeenCalled()
+ savingOptions = @confirmationSpies.constructor.mostRecentCall.args[0]
expect(savingOptions.title).toMatch("Your file has been deleted.")
expect(@collection.contains(@model)).toBeFalsy()
@@ -68,9 +73,31 @@ describe "CMS.Views.Asset", ->
expect(@model.destroy).toHaveBeenCalled()
# return an error response
@requests[0].respond(404)
- expect(@savingSpies.constructor).not.toHaveBeenCalled()
+ expect(@confirmationSpies.constructor).not.toHaveBeenCalled()
expect(@collection.contains(@model)).toBeTruthy()
+ it "should lock the asset on confirmation", ->
+ @view.render().$(".lock-asset-button").click()
+ # AJAX request has been sent, but not yet returned
+ expect(@model.save).toHaveBeenCalled()
+ expect(@requests.length).toEqual(1)
+ expect(@savingSpies.constructor).toHaveBeenCalled()
+ expect(@savingSpies.show).toHaveBeenCalled()
+ savingOptions = @savingSpies.constructor.mostRecentCall.args[0]
+ expect(savingOptions.title).toMatch("Saving...")
+ expect(@model.get("locked")).toBeFalsy()
+ # return a success response
+ @requests[0].respond(200)
+ expect(@savingSpies.hide).toHaveBeenCalled()
+ expect(@model.get("locked")).toBeTruthy()
+
+ it "should not lock the asset if server errors", ->
+ @view.render().$(".lock-asset-button").click()
+ # return an error response
+ @requests[0].respond(404)
+ # Don't call hide because that closes the notification showing the server error.
+ expect(@savingSpies.hide).not.toHaveBeenCalled()
+ expect(@model.get("locked")).toBeFalsy()
describe "CMS.Views.Assets", ->
beforeEach ->
diff --git a/cms/static/img/bg-micro-stripes.png b/cms/static/img/bg-micro-stripes.png
new file mode 100644
index 0000000000..4987c73b6e
Binary files /dev/null and b/cms/static/img/bg-micro-stripes.png differ
diff --git a/cms/static/js/models/asset.js b/cms/static/js/models/asset.js
index 080f5a5e5e..6aad5135cf 100644
--- a/cms/static/js/models/asset.js
+++ b/cms/static/js/models/asset.js
@@ -8,6 +8,6 @@ CMS.Models.Asset = Backbone.Model.extend({
date_added: "",
url: "",
portable_url: "",
- is_locked: false
+ locked: false
}
});
diff --git a/cms/static/js/views/asset_view.js b/cms/static/js/views/asset_view.js
index c11609e680..0a2569a669 100644
--- a/cms/static/js/views/asset_view.js
+++ b/cms/static/js/views/asset_view.js
@@ -1,27 +1,38 @@
CMS.Views.Asset = Backbone.View.extend({
initialize: function() {
this.template = _.template($("#asset-tpl").text());
+ this.listenTo(this.model, "change", this.render);
},
tagName: "tr",
events: {
- "click .remove-asset-button": "confirmDelete"
+ "click .remove-asset-button": "confirmDelete",
+ "click .lock-asset-button": "lockAsset"
},
render: function() {
+ this.$el.removeClass();
+
this.$el.html(this.template({
display_name: this.model.get('display_name'),
thumbnail: this.model.get('thumbnail'),
date_added: this.model.get('date_added'),
url: this.model.get('url'),
- portable_url: this.model.get('portable_url')}));
+ portable_url: this.model.get('portable_url'),
+ locked: this.model.get('locked')}));
+
+ // Add a class of "locked" to the tr element if appropriate.
+ if (this.model.get('locked')) {
+ this.$el.addClass('is-locked');
+ }
+
return this;
},
confirmDelete: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
- var asset = this.model, collection = this.model.collection;
+ var asset = this.model;
new CMS.Views.Prompt.Warning({
title: gettext("Delete File Confirmation"),
message: gettext("Are you sure you wish to delete this item. It cannot be reversed!\n\nAlso any content that links/refers to this item will no longer work (e.g. broken images and/or links)"),
@@ -53,5 +64,19 @@ CMS.Views.Asset = Backbone.View.extend({
]
}
}).show();
+ },
+
+ lockAsset: function(e) {
+ if(e && e.preventDefault) { e.preventDefault(); }
+ var asset = this.model;
+ var saving = new CMS.Views.Notification.Mini({
+ title: gettext("Saving…")
+ }).show();
+ asset.save({'locked': !asset.get('locked')}, {
+ wait: true, // This means we won't re-render until we get back the success state.
+ success: function() {
+ saving.hide();
+ }
+ });
}
});
diff --git a/cms/static/sass/views/_assets.scss b/cms/static/sass/views/_assets.scss
index a293b1846b..7c24224714 100644
--- a/cms/static/sass/views/_assets.scss
+++ b/cms/static/sass/views/_assets.scss
@@ -3,204 +3,331 @@
body.course.uploads {
+ .content-primary, .content-supplementary {
+ @include box-sizing(border-box);
+ float: left;
+ }
+
+ .content-primary {
+ width: flex-grid(9, 12);
+ margin-right: flex-gutter();
+
+ .no-assets-content {
+ @extend %ui-well;
+ padding: ($baseline*2);
+ background-color: $gray-l4;
+ text-align: center;
+ color: $gray;
+
+ .new-button {
+ @extend %t-copy-sub1;
+ margin-left: $baseline;
+
+ [class^="icon-"] {
+ margin-right: ($baseline/2);
+ }
+ }
+ }
+ }
+
+ .content-supplementary {
+ width: flex-grid(3, 12);
+ }
.nav-actions {
.icon-cloud-upload {
- @include font-size(16);
+ @extend %t-copy;
vertical-align: bottom;
margin-right: ($baseline/5);
}
}
- input.asset-search-input {
- float: left;
- width: 260px;
- background-color: #fff;
- }
-
-
.asset-library {
@include clearfix;
table {
width: 100%;
- border-radius: 3px 3px 0 0;
- border: 1px solid #c5cad4;
+ border-top: 5px solid $gray-l4;
+ word-wrap: break-word;
+
+
+ thead tr {
+ }
- td,
th {
- padding: 10px 20px;
+ @extend %t-copy-sub2;
+ background-color: $gray-l5;
+ color: $gray;
+ padding: ($baseline*.75) $baseline;
text-align: left;
vertical-align: middle;
}
- thead th {
- @include linear-gradient(top, transparent, rgba(0, 0, 0, .1));
- background-color: #ced2db;
- font-size: 12px;
- font-weight: 700;
- text-shadow: 0 1px 0 rgba(255, 255, 255, .5);
+ td {
+ padding: ($baseline/2);
+ text-align: left;
+ vertical-align: middle;
}
tbody {
- background: #fff;
+ box-shadow: 0 2px 5px $shadow;
+ border: 1px solid $gray-l4;
+ background: $white;
tr {
- border-top: 1px solid #c5cad4;
+ @include transition(all $tmg-f2 ease-in-out 0s);
+ border-top: 1px solid $gray-l4;
&:first-child {
border-top: none;
}
+
+ &:nth-child(odd) {
+ background-color: $gray-l6;
+ }
+
+ a {
+ color: $gray-d1;
+
+ &:hover {
+ color: $blue;
+ }
+ }
+
+ &.is-locked {
+ background: $gray-l5 url('../img/bg-micro-stripes.png') 0 0 repeat;
+
+ .locked a {
+ background-color: $gray;
+ color: $white;
+
+ &:hover {
+ background-color: $gray-d3;
+ }
+ }
+ }
+
+ &:hover {
+ background-color: $blue-l5;
+
+ .date-col,
+ .embed-col,
+ .embed-col .embeddable-xml-input {
+ color: $gray;
+ }
+ }
+
+
+ }
+
+ .thumb-cols {
+ padding: ($baseline/2) $baseline;
+ width: 100px;
+ }
+
+ .name-cols {
+ width: 250px;
+ }
+
+ .date-cols {
+ width: 100px;
+ }
+
+ .embed-cols {
+ width: 200px;
+ }
+
+ .actions-cols {
+ width: ($baseline*3);
+ padding: ($baseline/2);
+ }
+
+ .thumb-col {
+ overflow: hidden;
+
+ .thumb {
+ width: $baseline*5;
+
+ img {
+ width: 100%;
+ }
+ }
}
.name-col {
- font-size: 14px;
+ @extend %t-copy-sub1;
+ text-overflow: ellipsis;
+
+ .title {
+ display: inline-block;
+ max-width: 200px;
+ overflow: hidden;
+ }
}
.date-col {
- font-size: 12px;
+ @include transition(all $tmg-f2 ease-in-out 0s);
+ @extend %t-copy-sub2;
+ color: $gray-l2;
}
- }
- .thumb-col {
- width: 100px;
- }
+ .embed-col {
+ @include transition(all $tmg-f2 ease-in-out 0s);
+ padding-left: ($baseline*.75);
+ color: $gray-l2;
- .date-col {
- width: 220px;
- }
+ .embeddable-xml-input {
+ @extend %t-copy-sub2;
+ box-shadow: none;
+ border: none;
+ background: none;
+ width: 100%;
+ color: $gray-l2;
- .embed-col {
- width: 250px;
- }
+ &:focus {
+ background-color: $white;
+ box-shadow: 0 1px 5px $shadow-l1 inset;
+ border: 1px solid $gray-l3;
+ }
+ }
+ }
- .delete-col {
- width: 20px;
- }
-
- .embeddable-xml-input {
- box-shadow: none;
- width: 100%;
- }
-
- .thumb {
- width: 100px;
- max-height: 80px;
-
- img {
- width: 100%;
+ .actions-col {
+ text-align: center;
}
}
}
-
- .pagination {
- float: right;
- margin: 15px 10px;
-
- ol, li {
- display: inline;
- }
-
- a {
- display: inline-block;
- height: 25px;
- padding: 0 4px;
- text-align: center;
- line-height: 25px;
- }
- }
- }
- .show-xml {
- @include blue-button;
- }
-}
-
-.upload-modal {
- display: none;
- width: 640px !important;
- margin-left: -320px !important;
-
- .modal-body {
- height: auto !important;
- overflow-y: auto !important;
- text-align: center;
}
- .file-input {
- display: none;
- }
+ .action-item {
+ display: inline-block;
+ margin: ($baseline/4) 0 ($baseline/4) ($baseline/4);
- .choose-file-button {
- @include blue-button;
- padding: 10px 82px 12px;
- font-size: 17px;
- }
-
- .progress-bar {
- display: none;
- width: 350px;
- height: 50px;
- margin: 30px auto 10px;
- border: 1px solid $blue;
-
- &.loaded {
- border-color: #66b93d;
-
- .progress-fill {
- background: #66b93d;
- }
- }
- }
-
- .progress-fill {
- width: 0%;
- height: 50px;
- background: $blue;
- color: #fff;
- line-height: 48px;
- }
-
- h1 {
- float: none;
- margin: 40px 0 30px;
- font-size: 34px;
- font-weight: 300;
- }
-
- .close-button {
- @include white-button;
- position: absolute;
- top: 0;
- right: 15px;
- width: 29px;
- height: 29px;
- padding: 0 !important;
- border-radius: 17px !important;
- line-height: 29px;
- text-align: center;
- }
-
- .embeddable {
- display: none;
- margin: 30px 0 130px;
-
- label {
+ .action-button {
+ @include transition(all $tmg-f2 ease-in-out 0s);
display: block;
- margin-bottom: 10px;
- font-weight: 700;
+ height: ($baseline*1.5);
+ width: ($baseline*1.5);
+ border-radius: 3px;
+ color: $gray-l3;
+
+ &:hover {
+ background-color: $gray-l3;
+ color: $gray-l6;
+ }
+ }
+
+ [class^="icon-"] {
+ display: inline-block;
+ vertical-align: bottom;
}
}
- .embeddable-xml-input {
- box-shadow: none;
- width: 400px;
+
+ .show-xml {
+ @include blue-button;
}
- .copy-button {
- @include white-button;
+
+ .upload-modal {
display: none;
- margin-bottom: 100px;
+ width: 640px !important;
+ margin-left: -320px !important;
+
+ .modal-body {
+ height: auto !important;
+ overflow-y: auto !important;
+ text-align: center;
+ }
+
+ .title {
+ @extend %t-title3;
+ float: none;
+ margin: ($baseline*2) 0 ($baseline*1.5);
+ font-weight: 300;
+ }
+
+ .file-input {
+ display: none;
+ }
+
+ .choose-file-button {
+ @include blue-button;
+ padding: 10px 82px 12px;
+ font-size: 17px;
+ }
+
+ .progress-bar {
+ display: none;
+ width: ($baseline*15);
+ height: 35px;
+ margin: ($baseline) auto;
+ border: 1px solid $green;
+ border-radius: ($baseline*2);
+
+ &.loaded {
+ border-color: #66b93d;
+
+ .progress-fill {
+ background: #66b93d;
+ }
+ }
+ }
+
+ .progress-fill {
+ @extend %t-copy-sub1;
+ width: 0%;
+ height: ($baseline*1.5);
+ border-radius: ($baseline*2);
+ background: $green;
+ padding-top: ($baseline/4);
+ color: #fff;
+ }
+
+ .close-button {
+ @include transition(color $tmg-f2 ease-in-out 0s);
+ position: absolute;
+ top: 0;
+ right: 15px;
+ padding: 0 !important;
+ border-radius: 17px !important;
+ line-height: 29px;
+ text-align: center;
+ border: none;
+ background: none;
+
+ [class^="icon-"] {
+ @extend %t-action1;
+ }
+
+ &:hover {
+ background: none;
+ color: $blue;
+ }
+ }
+
+ .embeddable {
+ display: none;
+ margin: 30px 0 130px;
+
+ label {
+ display: block;
+ margin-bottom: 10px;
+ font-weight: 700;
+ }
+ }
+
+ .embeddable-xml-input {
+ box-shadow: none;
+ width: 400px;
+ }
+
+ .copy-button {
+ @include white-button;
+ display: none;
+ margin-bottom: 100px;
+ }
}
}
diff --git a/cms/templates/asset_index.html b/cms/templates/asset_index.html
index 790266fb30..4dc43487be 100644
--- a/cms/templates/asset_index.html
+++ b/cms/templates/asset_index.html
@@ -1,8 +1,9 @@
<%inherit file="base.html" />
<%! from django.core.urlresolvers import reverse %>
<%! from django.utils.translation import ugettext as _ %>
-<%block name="bodyclass">is-signedin course uploads%block>
+
<%block name="title">${_("Files & Uploads")}%block>
+<%block name="bodyclass">is-signedin course uploads%block>
<%namespace name='static' file='static_content.html'/>
@@ -27,80 +28,85 @@
<%block name="content">
-
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
- |
- Name |
- Date Added |
- URL |
- |
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
${_("Upload New File")}
+
+
+
+
+ ${_("List of uploaded files and assets in this course")}
+
+
+
+
+
+
+
+
+
+ | ${_("Preview")} |
+ ${_("Name")} |
+ ${_("Date Added")} |
+ ${_("URL")} |
+ ${_("Actions")} |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
${_('close')}
+
+
${_("Upload New File")}
+
+
+
-
-
-
-
-
+
-
+
%block>
@@ -108,17 +114,17 @@
<%block name="view_alerts">
-
%block>
diff --git a/cms/templates/js/asset.underscore b/cms/templates/js/asset.underscore
index a724ca8898..4116189a6f 100644
--- a/cms/templates/js/asset.underscore
+++ b/cms/templates/js/asset.underscore
@@ -6,7 +6,7 @@
- <%= display_name %>
+ <%= display_name %>
|
@@ -16,7 +16,19 @@
|
-
-
+ |
+
|