Commit c64e04d8 by cahrens

Add the ability to lock assets.

parent 78829a37
...@@ -18,6 +18,8 @@ logger = getLogger(__name__) ...@@ -18,6 +18,8 @@ logger = getLogger(__name__)
from terrain.browser import reset_data from terrain.browser import reset_data
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
PASSWORD = 'test'
EMAIL_EXTENSION = '@edx.org'
@step('I (?:visit|access|open) the Studio homepage$') @step('I (?:visit|access|open) the Studio homepage$')
...@@ -300,3 +302,48 @@ def upload_file(filename): ...@@ -300,3 +302,48 @@ def upload_file(filename):
world.browser.attach_file('file', os.path.abspath(path)) world.browser.attach_file('file', os.path.abspath(path))
button_css = '.upload-dialog .action-upload' button_css = '.upload-dialog .action-upload'
world.css_click(button_css) 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')
...@@ -2,14 +2,10 @@ ...@@ -2,14 +2,10 @@
#pylint: disable=W0621 #pylint: disable=W0621
from lettuce import world, step from lettuce import world, step
from common import create_studio_user from common import EMAIL_EXTENSION
from django.contrib.auth.models import Group
from auth.authz import get_course_groupname_for_role, get_user_by_email from auth.authz import get_course_groupname_for_role, get_user_by_email
from nose.tools import assert_true, assert_in # pylint: disable=E0611 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') @step(u'(I am viewing|s?he views) the course team settings')
def view_grading_settings(_step, whom): def view_grading_settings(_step, whom):
...@@ -18,24 +14,6 @@ def view_grading_settings(_step, whom): ...@@ -18,24 +14,6 @@ def view_grading_settings(_step, whom):
world.css_click(link_css) 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') @step(u'I add "([^"]*)" to the course team')
def add_other_user(_step, name): def add_other_user(_step, name):
new_user_css = 'a.create-user-button' new_user_css = 'a.create-user-button'
...@@ -89,25 +67,6 @@ def remove_course_team_admin(_step, outer_capture, name): ...@@ -89,25 +67,6 @@ def remove_course_team_admin(_step, outer_capture, name):
world.css_click(admin_btn_css) 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'I( do not)? see the course on my page')
@step(u's?he does( not)? see the course on (his|her) page') @step(u's?he does( not)? see the course on (his|her) page')
def see_course(_step, do_not_see, gender='self'): def see_course(_step, do_not_see, gender='self'):
......
...@@ -58,3 +58,59 @@ Feature: CMS.Upload Files ...@@ -58,3 +58,59 @@ Feature: CMS.Upload Files
And I reload the page And I reload the page
And I upload the file "test" And I upload the file "test"
Then I can download the correct "test" file 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
...@@ -11,6 +11,7 @@ from nose.tools import assert_equal, assert_not_equal # pylint: disable=E0611 ...@@ -11,6 +11,7 @@ from nose.tools import assert_equal, assert_not_equal # pylint: disable=E0611
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT 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') @step(u'I go to the files and uploads page')
...@@ -59,8 +60,7 @@ def check_not_there(_step, file_name): ...@@ -59,8 +60,7 @@ def check_not_there(_step, file_name):
# the only file that was uploaded, our success criteria # the only file that was uploaded, our success criteria
# will be that there are no files. # will be that there are no files.
# In the future we can refactor if necessary. # In the future we can refactor if necessary.
names_css = 'td.name-col > a.filename' assert(world.is_css_not_present(ASSET_NAMES_CSS))
assert(world.is_css_not_present(names_css))
@step(u'I should see the file "([^"]*)" was uploaded$') @step(u'I should see the file "([^"]*)" was uploaded$')
...@@ -88,11 +88,10 @@ def delete_file(_step, file_name): ...@@ -88,11 +88,10 @@ def delete_file(_step, file_name):
@step(u'I should see only one "([^"]*)"$') @step(u'I should see only one "([^"]*)"$')
def no_duplicate(_step, file_name): def no_duplicate(_step, file_name):
names_css = 'td.name-col > a.filename' all_names = world.css_find(ASSET_NAMES_CSS)
all_names = world.css_find(names_css)
only_one = False only_one = False
for i in range(len(all_names)): 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 only_one = not only_one
assert only_one assert only_one
...@@ -106,16 +105,67 @@ def check_download(_step, file_name): ...@@ -106,16 +105,67 @@ def check_download(_step, file_name):
downloaded_text = r.text downloaded_text = r.text
assert cur_text == downloaded_text assert cur_text == downloaded_text
#resetting the file back to its original state #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: 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 "([^"]*)"$') @step(u'I modify "([^"]*)"$')
def modify_upload(_step, file_name): def modify_upload(_step, file_name):
new_text = ''.join(random.choice(string.ascii_uppercase + string.digits) for x in range(10)) new_text = ''.join(random.choice(string.ascii_uppercase + string.digits) for x in range(10))
path = os.path.join(TEST_ROOT, 'uploads/', file_name) _write_test_file(file_name, new_text)
with open(os.path.abspath(path), 'w') as cur_file:
cur_file.write(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') @step('I see a confirmation that the file was deleted')
...@@ -125,10 +175,9 @@ def i_see_a_delete_confirmation(_step): ...@@ -125,10 +175,9 @@ def i_see_a_delete_confirmation(_step):
def get_index(file_name): def get_index(file_name):
names_css = 'td.name-col > a.filename' all_names = world.css_find(ASSET_NAMES_CSS)
all_names = world.css_find(names_css)
for i in range(len(all_names)): 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 i
return -1 return -1
......
...@@ -18,6 +18,7 @@ from xmodule.modulestore import Location ...@@ -18,6 +18,7 @@ from xmodule.modulestore import Location
from xmodule.contentstore.django import contentstore from xmodule.contentstore.django import contentstore
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.xml_importer import import_from_xml from xmodule.modulestore.xml_importer import import_from_xml
import json
class AssetsTestCase(CourseTestCase): class AssetsTestCase(CourseTestCase):
def setUp(self): def setUp(self):
...@@ -92,7 +93,7 @@ class AssetToJsonTestCase(TestCase): ...@@ -92,7 +93,7 @@ class AssetToJsonTestCase(TestCase):
location = Location(['i4x', 'foo', 'bar', 'asset', 'my_file_name.jpg']) location = Location(['i4x', 'foo', 'bar', 'asset', 'my_file_name.jpg'])
thumbnail_location = Location(['i4x', 'foo', 'bar', 'asset', 'my_file_name_thumb.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["display_name"], "my_file")
self.assertEquals(output["date_added"], "Jun 01, 2013 at 10:30 UTC") self.assertEquals(output["date_added"], "Jun 01, 2013 at 10:30 UTC")
...@@ -100,6 +101,48 @@ class AssetToJsonTestCase(TestCase): ...@@ -100,6 +101,48 @@ class AssetToJsonTestCase(TestCase):
self.assertEquals(output["portable_url"], "/static/my_file_name.jpg") 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["thumbnail"], "/i4x/foo/bar/asset/my_file_name_thumb.jpg")
self.assertEquals(output["id"], output["url"]) 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"]) 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)
...@@ -60,7 +60,8 @@ def asset_index(request, org, course, name): ...@@ -60,7 +60,8 @@ def asset_index(request, org, course, name):
_thumbnail_location = asset.get('thumbnail_location', None) _thumbnail_location = asset.get('thumbnail_location', None)
thumbnail_location = Location(_thumbnail_location) if _thumbnail_location is not None else 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', { return render_to_response('asset_index.html', {
'context_course': course_module, 'context_course': course_module,
...@@ -136,63 +137,75 @@ def upload_asset(request, org, course, coursename): ...@@ -136,63 +137,75 @@ def upload_asset(request, org, course, coursename):
# readback the saved content - we need the database timestamp # readback the saved content - we need the database timestamp
readback = contentstore().find(content.location) readback = contentstore().find(content.location)
locked = getattr(content, 'locked', False)
response_payload = { 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') 'msg': _('Upload completed')
} }
return JsonResponse(response_payload) return JsonResponse(response_payload)
@require_http_methods(("DELETE",)) @require_http_methods(("DELETE", "POST", "PUT"))
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
def update_asset(request, org, course, name, asset_id): def update_asset(request, org, course, name, asset_id):
""" """
restful CRUD operations for a course asset. 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 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) asset_id: the URL of the asset (used by Backbone as the id)
""" """
get_location_and_verify_access(request, org, course, name) def get_asset_location(asset_id):
""" Helper method to get the location (and verify it is valid). """
# 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:
try: try:
thumbnail_content = contentstore().find(content.thumbnail_location) return StaticContent.get_location_from_path(asset_id)
contentstore('trashcan').save(thumbnail_content) except InvalidLocationError as err:
# hard delete thumbnail from origin # return a 'Bad Request' to browser as we have a malformed Location
contentstore().delete(thumbnail_content.get_id()) return JsonResponse({"error": err.message}, status=400)
# 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()
get_location_and_verify_access(request, org, course, name)
def _get_asset_json(display_name, date, location, thumbnail_location): if request.method == 'DELETE':
loc = get_asset_location(asset_id)
# 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:
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, locked):
""" """
Helper method for formatting the asset information to send to client. 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): ...@@ -203,6 +216,7 @@ def _get_asset_json(display_name, date, location, thumbnail_location):
'url': asset_url, 'url': asset_url,
'portable_url': StaticContent.get_static_path_from_location(location), '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, 'thumbnail': StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_location is not None else None,
'locked': locked,
# Needed for Backbone delete/update. # Needed for Backbone delete/update.
'id': asset_url 'id': asset_url
} }
...@@ -8,6 +8,8 @@ describe "CMS.Views.Asset", -> ...@@ -8,6 +8,8 @@ describe "CMS.Views.Asset", ->
appendSetFixtures(sandbox({id: "page-prompt"})) 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'}) @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, "destroy").andCallThrough()
spyOn(@model, "save").andCallThrough()
@collection = new CMS.Models.AssetCollection([@model]) @collection = new CMS.Models.AssetCollection([@model])
@collection.url = "update-asset-url" @collection.url = "update-asset-url"
@view = new CMS.Views.Asset({model: @model}) @view = new CMS.Views.Asset({model: @model})
...@@ -35,7 +37,10 @@ describe "CMS.Views.Asset", -> ...@@ -35,7 +37,10 @@ describe "CMS.Views.Asset", ->
@xhr = sinon.useFakeXMLHttpRequest() @xhr = sinon.useFakeXMLHttpRequest()
@xhr.onCreate = (xhr) -> requests.push(xhr) @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) @savingSpies.show.andReturn(@savingSpies)
afterEach -> afterEach ->
...@@ -49,13 +54,13 @@ describe "CMS.Views.Asset", -> ...@@ -49,13 +54,13 @@ describe "CMS.Views.Asset", ->
# AJAX request has been sent, but not yet returned # AJAX request has been sent, but not yet returned
expect(@model.destroy).toHaveBeenCalled() expect(@model.destroy).toHaveBeenCalled()
expect(@requests.length).toEqual(1) expect(@requests.length).toEqual(1)
expect(@savingSpies.constructor).not.toHaveBeenCalled() expect(@confirmationSpies.constructor).not.toHaveBeenCalled()
expect(@collection.contains(@model)).toBeTruthy() expect(@collection.contains(@model)).toBeTruthy()
# return a success response # return a success response
@requests[0].respond(200) @requests[0].respond(200)
expect(@savingSpies.constructor).toHaveBeenCalled() expect(@confirmationSpies.constructor).toHaveBeenCalled()
expect(@savingSpies.show).toHaveBeenCalled() expect(@confirmationSpies.show).toHaveBeenCalled()
savingOptions = @savingSpies.constructor.mostRecentCall.args[0] savingOptions = @confirmationSpies.constructor.mostRecentCall.args[0]
expect(savingOptions.title).toMatch("Your file has been deleted.") expect(savingOptions.title).toMatch("Your file has been deleted.")
expect(@collection.contains(@model)).toBeFalsy() expect(@collection.contains(@model)).toBeFalsy()
...@@ -68,9 +73,31 @@ describe "CMS.Views.Asset", -> ...@@ -68,9 +73,31 @@ describe "CMS.Views.Asset", ->
expect(@model.destroy).toHaveBeenCalled() expect(@model.destroy).toHaveBeenCalled()
# return an error response # return an error response
@requests[0].respond(404) @requests[0].respond(404)
expect(@savingSpies.constructor).not.toHaveBeenCalled() expect(@confirmationSpies.constructor).not.toHaveBeenCalled()
expect(@collection.contains(@model)).toBeTruthy() 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", -> describe "CMS.Views.Assets", ->
beforeEach -> beforeEach ->
......
...@@ -8,6 +8,6 @@ CMS.Models.Asset = Backbone.Model.extend({ ...@@ -8,6 +8,6 @@ CMS.Models.Asset = Backbone.Model.extend({
date_added: "", date_added: "",
url: "", url: "",
portable_url: "", portable_url: "",
is_locked: false locked: false
} }
}); });
CMS.Views.Asset = Backbone.View.extend({ CMS.Views.Asset = Backbone.View.extend({
initialize: function() { initialize: function() {
this.template = _.template($("#asset-tpl").text()); this.template = _.template($("#asset-tpl").text());
this.listenTo(this.model, "change", this.render);
}, },
tagName: "tr", tagName: "tr",
events: { events: {
"click .remove-asset-button": "confirmDelete" "click .remove-asset-button": "confirmDelete",
"click .lock-asset-button": "lockAsset"
}, },
render: function() { render: function() {
this.$el.removeClass();
this.$el.html(this.template({ this.$el.html(this.template({
display_name: this.model.get('display_name'), display_name: this.model.get('display_name'),
thumbnail: this.model.get('thumbnail'), thumbnail: this.model.get('thumbnail'),
date_added: this.model.get('date_added'), date_added: this.model.get('date_added'),
url: this.model.get('url'), 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; return this;
}, },
confirmDelete: function(e) { confirmDelete: function(e) {
if(e && e.preventDefault) { e.preventDefault(); } if(e && e.preventDefault) { e.preventDefault(); }
var asset = this.model, collection = this.model.collection; var asset = this.model;
new CMS.Views.Prompt.Warning({ new CMS.Views.Prompt.Warning({
title: gettext("Delete File Confirmation"), title: gettext("Delete File Confirmation"),
message: gettext("Are you sure you wish to delete this item. It cannot be reversed!\n\nAlso any content that links/refers to this item will no longer work (e.g. broken images and/or links)"), 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({ ...@@ -53,5 +64,19 @@ CMS.Views.Asset = Backbone.View.extend({
] ]
} }
}).show(); }).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();
}
});
} }
}); });
...@@ -3,204 +3,331 @@ ...@@ -3,204 +3,331 @@
body.course.uploads { 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 { .nav-actions {
.icon-cloud-upload { .icon-cloud-upload {
@include font-size(16); @extend %t-copy;
vertical-align: bottom; vertical-align: bottom;
margin-right: ($baseline/5); margin-right: ($baseline/5);
} }
} }
input.asset-search-input {
float: left;
width: 260px;
background-color: #fff;
}
.asset-library { .asset-library {
@include clearfix; @include clearfix;
table { table {
width: 100%; width: 100%;
border-radius: 3px 3px 0 0; border-top: 5px solid $gray-l4;
border: 1px solid #c5cad4; word-wrap: break-word;
thead tr {
}
td,
th { th {
padding: 10px 20px; @extend %t-copy-sub2;
background-color: $gray-l5;
color: $gray;
padding: ($baseline*.75) $baseline;
text-align: left; text-align: left;
vertical-align: middle; vertical-align: middle;
} }
thead th { td {
@include linear-gradient(top, transparent, rgba(0, 0, 0, .1)); padding: ($baseline/2);
background-color: #ced2db; text-align: left;
font-size: 12px; vertical-align: middle;
font-weight: 700;
text-shadow: 0 1px 0 rgba(255, 255, 255, .5);
} }
tbody { tbody {
background: #fff; box-shadow: 0 2px 5px $shadow;
border: 1px solid $gray-l4;
background: $white;
tr { tr {
border-top: 1px solid #c5cad4; @include transition(all $tmg-f2 ease-in-out 0s);
border-top: 1px solid $gray-l4;
&:first-child { &:first-child {
border-top: none; 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;
}
}
} }
.name-col { .thumb-cols {
font-size: 14px; padding: ($baseline/2) $baseline;
width: 100px;
} }
.date-col { .name-cols {
font-size: 12px; width: 250px;
} }
}
.thumb-col { .date-cols {
width: 100px; width: 100px;
} }
.date-col { .embed-cols {
width: 220px; width: 200px;
} }
.embed-col { .actions-cols {
width: 250px; width: ($baseline*3);
} padding: ($baseline/2);
}
.delete-col { .thumb-col {
width: 20px; overflow: hidden;
}
.embeddable-xml-input { .thumb {
box-shadow: none; width: $baseline*5;
width: 100%;
}
.thumb { img {
width: 100px; width: 100%;
max-height: 80px; }
}
}
img { .name-col {
width: 100%; @extend %t-copy-sub1;
text-overflow: ellipsis;
.title {
display: inline-block;
max-width: 200px;
overflow: hidden;
}
} }
}
}
.pagination { .date-col {
float: right; @include transition(all $tmg-f2 ease-in-out 0s);
margin: 15px 10px; @extend %t-copy-sub2;
color: $gray-l2;
}
ol, li { .embed-col {
display: inline; @include transition(all $tmg-f2 ease-in-out 0s);
} padding-left: ($baseline*.75);
color: $gray-l2;
.embeddable-xml-input {
@extend %t-copy-sub2;
box-shadow: none;
border: none;
background: none;
width: 100%;
color: $gray-l2;
&:focus {
background-color: $white;
box-shadow: 0 1px 5px $shadow-l1 inset;
border: 1px solid $gray-l3;
}
}
}
a { .actions-col {
display: inline-block; text-align: center;
height: 25px; }
padding: 0 4px;
text-align: center;
line-height: 25px;
} }
} }
} }
.show-xml {
@include blue-button;
}
}
.upload-modal { .action-item {
display: none; display: inline-block;
width: 640px !important; margin: ($baseline/4) 0 ($baseline/4) ($baseline/4);
margin-left: -320px !important;
.modal-body { .action-button {
height: auto !important; @include transition(all $tmg-f2 ease-in-out 0s);
overflow-y: auto !important; display: block;
text-align: center; height: ($baseline*1.5);
} width: ($baseline*1.5);
border-radius: 3px;
color: $gray-l3;
&:hover {
background-color: $gray-l3;
color: $gray-l6;
}
}
.file-input { [class^="icon-"] {
display: none; display: inline-block;
vertical-align: bottom;
}
} }
.choose-file-button {
.show-xml {
@include blue-button; @include blue-button;
padding: 10px 82px 12px;
font-size: 17px;
} }
.progress-bar {
.upload-modal {
display: none; display: none;
width: 350px; width: 640px !important;
height: 50px; margin-left: -320px !important;
margin: 30px auto 10px;
border: 1px solid $blue;
&.loaded { .modal-body {
border-color: #66b93d; 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;
}
.progress-fill { .file-input {
background: #66b93d; 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 { .progress-fill {
width: 0%; @extend %t-copy-sub1;
height: 50px; width: 0%;
background: $blue; height: ($baseline*1.5);
color: #fff; border-radius: ($baseline*2);
line-height: 48px; background: $green;
} padding-top: ($baseline/4);
color: #fff;
}
h1 { .close-button {
float: none; @include transition(color $tmg-f2 ease-in-out 0s);
margin: 40px 0 30px; position: absolute;
font-size: 34px; top: 0;
font-weight: 300; right: 15px;
} padding: 0 !important;
border-radius: 17px !important;
line-height: 29px;
text-align: center;
border: none;
background: none;
[class^="icon-"] {
@extend %t-action1;
}
.close-button { &:hover {
@include white-button; background: none;
position: absolute; color: $blue;
top: 0; }
right: 15px; }
width: 29px;
height: 29px;
padding: 0 !important;
border-radius: 17px !important;
line-height: 29px;
text-align: center;
}
.embeddable { .embeddable {
display: none; display: none;
margin: 30px 0 130px; margin: 30px 0 130px;
label { label {
display: block; display: block;
margin-bottom: 10px; margin-bottom: 10px;
font-weight: 700; font-weight: 700;
}
} }
}
.embeddable-xml-input { .embeddable-xml-input {
box-shadow: none; box-shadow: none;
width: 400px; width: 400px;
} }
.copy-button { .copy-button {
@include white-button; @include white-button;
display: none; display: none;
margin-bottom: 100px; margin-bottom: 100px;
}
} }
} }
<%inherit file="base.html" /> <%inherit file="base.html" />
<%! from django.core.urlresolvers import reverse %> <%! from django.core.urlresolvers import reverse %>
<%! from django.utils.translation import ugettext as _ %> <%! from django.utils.translation import ugettext as _ %>
<%block name="bodyclass">is-signedin course uploads</%block>
<%block name="title">${_("Files &amp; Uploads")}</%block> <%block name="title">${_("Files &amp; Uploads")}</%block>
<%block name="bodyclass">is-signedin course uploads</%block>
<%namespace name='static' file='static_content.html'/> <%namespace name='static' file='static_content.html'/>
...@@ -27,80 +28,85 @@ ...@@ -27,80 +28,85 @@
<%block name="content"> <%block name="content">
<div class="wrapper-mast wrapper"> <div class="wrapper-mast wrapper">
<header class="mast has-actions has-subtitle"> <header class="mast has-actions has-subtitle">
<h1 class="page-header"> <h1 class="page-header">
<small class="subtitle">${_("Content")}</small> <small class="subtitle">${_("Content")}</small>
<span class="sr">&gt; </span>${_("Files &amp; Uploads")} <span class="sr">&gt; </span>${_("Files &amp; Uploads")}
</h1> </h1>
<nav class="nav-actions"> <nav class="nav-actions">
<h3 class="sr">${_("Page Actions")}</h3> <h3 class="sr">${_("Page Actions")}</h3>
<ul> <ul>
<li class="nav-item"> <li class="nav-item">
<a href="#" class="button upload-button new-button"><i class="icon-plus"></i> ${_("Upload New File")}</a> <a href="#" class="button upload-button new-button"><i class="icon-plus"></i> ${_("Upload New File")}</a>
</li> </li>
</ul> </ul>
</nav>
</header>
</div>
<div class="main-wrapper">
<div class="inner-wrapper">
<div class="page-actions">
<input type="text" class="asset-search-input search wip-box" placeholder="search assets" style="display:none"/>
</div>
<article class="asset-library">
<table>
<thead>
<tr>
<th class="thumb-col"></th>
<th class="name-col">Name</th>
<th class="date-col">Date Added</th>
<th class="embed-col">URL</th>
<th class="delete-col"></th>
</tr>
</thead>
<tbody id="asset_table_body" >
</tbody>
</table>
<nav class="pagination wip-box">
Page:
<ol class="pages">
<li>1</li>
<li><a href="#">2</a></li>
<li><a href="#">3</a></li>
<li><a href="#">4</a></li>
<li><a href="#">5</a></li>
</ol>
<a href="#" class="next">»</a>
</nav> </nav>
</article> </header>
</div> </div>
</div>
<div class="upload-modal modal"> <div class="wrapper-content wrapper">
<a href="#" class="close-button"><span class="close-icon"></span></a> <section class="content">
<div class="modal-body"> <article class="asset-library content-primary" role="main">
<h1>${_("Upload New File")}</h1> <table>
<caption class="sr">${_("List of uploaded files and assets in this course")}</caption>
<colgroup>
<col class="thumb-cols" />
<col class="name-cols" />
<col class="date-cols" />
<col class="embed-cols" />
<col class="actions-cols" />
</colgroup>
<thead>
<tr>
<th class="thumb-col">${_("Preview")}</th>
<th class="name-col">${_("Name")}</th>
<th class="date-col">${_("Date Added")}</th>
<th class="embed-col">${_("URL")}</th>
<th class="actions-col"><span class="sr">${_("Actions")}</span></th>
</tr>
</thead>
<tbody id="asset_table_body" >
</tbody>
</table>
</article>
<aside class="content-supplementary" role="complimentary">
<div class="bit">
<h3 class="title-3">${_("What files are included here?")}</h3>
<p>${_("Any file you upload to the course will be listed here, including your course image, textbook chapters, and any files you add directly to this page.")}</p>
</div>
<div class="bit">
<h3 class="title-3">${_("What can I do on this page?")}</h3>
<p>${_("You can click the file name to view or download the file, upload a new file, delete a file, and lock a file to prevent people who are not enrolled from accessing that specific file. You can also copy the location (URL) of a file to use elsewhere in your course.")}</p>
</div>
</aside>
</section>
</div>
<div class="upload-modal modal">
<a href="#" class="close-button"><i class="icon-remove-sign"></i> <span class="sr">${_('close')}</span></a>
<div class="modal-body">
<h1 class="title">${_("Upload New File")}</h1>
<p class="file-name"></a> <p class="file-name"></a>
<div class="progress-bar"> <div class="progress-bar">
<div class="progress-fill"></div> <div class="progress-fill"></div>
</div>
<div class="embeddable">
<label>URL:</label>
<input type="text" class="embeddable-xml-input" value='' readonly>
</div> </div>
<div class="embeddable">
<label>URL:</label>
<input type="text" class="embeddable-xml-input" value='' readonly>
</div>
<form class="file-chooser" action="${upload_asset_callback_url}" <form class="file-chooser" action="${upload_asset_callback_url}"
method="post" enctype="multipart/form-data"> method="post" enctype="multipart/form-data">
<a href="#" class="choose-file-button">${_("Choose File")}</a> <a href="#" class="choose-file-button">${_("Choose File")}</a>
<input type="file" class="file-input" name="file" multiple> <input type="file" class="file-input" name="file" multiple>
</form> </form>
</div>
</div> </div>
</div>
<div class="modal-cover"></div> <div class="modal-cover"></div>
</%block> </%block>
...@@ -108,17 +114,17 @@ ...@@ -108,17 +114,17 @@
<%block name="view_alerts"> <%block name="view_alerts">
<!-- alert: save confirmed with close --> <!-- alert: save confirmed with close -->
<div class="wrapper wrapper-alert wrapper-alert-confirmation" role="status"> <div class="wrapper wrapper-alert wrapper-alert-confirmation" role="status">
<div class="alert confirmation"> <div class="alert confirmation">
<i class="icon-ok"></i> <i class="icon-ok"></i>
<div class="copy"> <div class="copy">
<h2 class="title title-3">${_('Your file has been deleted.')}</h2> <h2 class="title title-3">${_('Your file has been deleted.')}</h2>
</div> </div>
<a href="" rel="view" class="action action-alert-close"> <a href="" rel="view" class="action action-alert-close">
<i class="icon-remove-sign"></i> <i class="icon-remove-sign"></i>
<span class="label">${_('close alert')}</span> <span class="label">${_('close alert')}</span>
</a> </a>
</div> </div>
</div> </div>
</%block> </%block>
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
</div> </div>
</td> </td>
<td class="name-col"> <td class="name-col">
<a data-tooltip="<%= gettext('Open/download this file') %>" href="<%= url %>" class="filename"><%= display_name %></a> <span class="title"><a data-tooltip="<%= gettext('Open/download this file') %>" href="<%= url %>" class="filename"><%= display_name %></a></span>
<div class="embeddable-xml"></div> <div class="embeddable-xml"></div>
</td> </td>
...@@ -16,7 +16,19 @@ ...@@ -16,7 +16,19 @@
<td class="embed-col"> <td class="embed-col">
<input type="text" class="embeddable-xml-input" value="<%= portable_url %>" readonly> <input type="text" class="embeddable-xml-input" value="<%= portable_url %>" readonly>
</td> </td>
<td class="delete-col"> <td class="actions-col">
<a href="#" data-tooltip="<%= gettext('Delete this asset') %>" class="remove-asset-button"><span <ul>
class="delete-icon"></span></a> <li class="action-item">
<a href="#" data-tooltip="<%= gettext('Delete this asset') %>" class="remove-asset-button action-button"><i class="icon-remove-sign"></i> <span class="sr"><%= gettext('Delete this asset') %></span></a>
</li>
<% if (locked) { %>
<li class="action-item locked">
<a href="#" data-tooltip="<%= gettext('Unlock this asset') %>" class="lock-asset-button action-button"><i class="icon-lock"></i> <span class="sr"><%= gettext('Unlock this asset') %></span></a>
</li>
<% } else { %>
<li class="action-item">
<a href="#" data-tooltip="<%= gettext('Lock this asset') %>" class="lock-asset-button action-button"><i class="icon-unlock-alt"></i> <span class="sr"><%= gettext('Lock this asset') %></span></a>
</li>
<% }%>
</ul>
</td> </td>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment