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,35 +137,39 @@ def upload_asset(request, org, course, coursename): ...@@ -136,35 +137,39 @@ 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: try:
loc = StaticContent.get_location_from_path(asset_id) return StaticContent.get_location_from_path(asset_id)
except InvalidLocationError as err: except InvalidLocationError as err:
# return a 'Bad Request' to browser as we have a malformed Location # return a 'Bad Request' to browser as we have a malformed Location
return JsonResponse({"error": err.message}, status=400) return JsonResponse({"error": err.message}, status=400)
# also make sure the item to delete actually exists get_location_and_verify_access(request, org, course, name)
if request.method == 'DELETE':
loc = get_asset_location(asset_id)
# Make sure the item to delete actually exists.
try: try:
content = contentstore().find(loc) content = contentstore().find(loc)
except NotFoundError: except NotFoundError:
...@@ -191,8 +196,16 @@ def update_asset(request, org, course, name, asset_id): ...@@ -191,8 +196,16 @@ def update_asset(request, org, course, name, asset_id):
del_cached_content(content.location) del_cached_content(content.location)
return JsonResponse() 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. 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,91 +3,152 @@ ...@@ -3,91 +3,152 @@
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;
} }
.name-col { a {
font-size: 14px; color: $gray-d1;
&:hover {
color: $blue;
}
} }
.date-col { &.is-locked {
font-size: 12px; background: $gray-l5 url('../img/bg-micro-stripes.png') 0 0 repeat;
.locked a {
background-color: $gray;
color: $white;
&:hover {
background-color: $gray-d3;
}
} }
} }
.thumb-col { &:hover {
width: 100px; background-color: $blue-l5;
.date-col,
.embed-col,
.embed-col .embeddable-xml-input {
color: $gray;
}
} }
.date-col {
width: 220px;
} }
.embed-col { .thumb-cols {
padding: ($baseline/2) $baseline;
width: 100px;
}
.name-cols {
width: 250px; width: 250px;
} }
.delete-col { .date-cols {
width: 20px; width: 100px;
} }
.embeddable-xml-input { .embed-cols {
box-shadow: none; width: 200px;
width: 100%; }
.actions-cols {
width: ($baseline*3);
padding: ($baseline/2);
} }
.thumb-col {
overflow: hidden;
.thumb { .thumb {
width: 100px; width: $baseline*5;
max-height: 80px;
img { img {
width: 100%; width: 100%;
...@@ -95,29 +156,82 @@ body.course.uploads { ...@@ -95,29 +156,82 @@ body.course.uploads {
} }
} }
.pagination { .name-col {
float: right; @extend %t-copy-sub1;
margin: 15px 10px; text-overflow: ellipsis;
ol, li { .title {
display: inline; display: inline-block;
max-width: 200px;
overflow: hidden;
}
} }
a { .date-col {
display: inline-block; @include transition(all $tmg-f2 ease-in-out 0s);
height: 25px; @extend %t-copy-sub2;
padding: 0 4px; color: $gray-l2;
}
.embed-col {
@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;
}
}
}
.actions-col {
text-align: center; text-align: center;
line-height: 25px;
} }
} }
} }
}
.action-item {
display: inline-block;
margin: ($baseline/4) 0 ($baseline/4) ($baseline/4);
.action-button {
@include transition(all $tmg-f2 ease-in-out 0s);
display: block;
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;
}
}
.show-xml { .show-xml {
@include blue-button; @include blue-button;
} }
}
.upload-modal {
.upload-modal {
display: none; display: none;
width: 640px !important; width: 640px !important;
margin-left: -320px !important; margin-left: -320px !important;
...@@ -128,6 +242,13 @@ body.course.uploads { ...@@ -128,6 +242,13 @@ body.course.uploads {
text-align: center; text-align: center;
} }
.title {
@extend %t-title3;
float: none;
margin: ($baseline*2) 0 ($baseline*1.5);
font-weight: 300;
}
.file-input { .file-input {
display: none; display: none;
} }
...@@ -140,10 +261,11 @@ body.course.uploads { ...@@ -140,10 +261,11 @@ body.course.uploads {
.progress-bar { .progress-bar {
display: none; display: none;
width: 350px; width: ($baseline*15);
height: 50px; height: 35px;
margin: 30px auto 10px; margin: ($baseline) auto;
border: 1px solid $blue; border: 1px solid $green;
border-radius: ($baseline*2);
&.loaded { &.loaded {
border-color: #66b93d; border-color: #66b93d;
...@@ -155,31 +277,35 @@ body.course.uploads { ...@@ -155,31 +277,35 @@ body.course.uploads {
} }
.progress-fill { .progress-fill {
@extend %t-copy-sub1;
width: 0%; width: 0%;
height: 50px; height: ($baseline*1.5);
background: $blue; border-radius: ($baseline*2);
background: $green;
padding-top: ($baseline/4);
color: #fff; color: #fff;
line-height: 48px;
}
h1 {
float: none;
margin: 40px 0 30px;
font-size: 34px;
font-weight: 300;
} }
.close-button { .close-button {
@include white-button; @include transition(color $tmg-f2 ease-in-out 0s);
position: absolute; position: absolute;
top: 0; top: 0;
right: 15px; right: 15px;
width: 29px;
height: 29px;
padding: 0 !important; padding: 0 !important;
border-radius: 17px !important; border-radius: 17px !important;
line-height: 29px; line-height: 29px;
text-align: center; text-align: center;
border: none;
background: none;
[class^="icon-"] {
@extend %t-action1;
}
&:hover {
background: none;
color: $blue;
}
} }
.embeddable { .embeddable {
...@@ -203,4 +329,5 @@ body.course.uploads { ...@@ -203,4 +329,5 @@ body.course.uploads {
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,7 +28,7 @@ ...@@ -27,7 +28,7 @@
<%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>
...@@ -43,47 +44,52 @@ ...@@ -43,47 +44,52 @@
</ul> </ul>
</nav> </nav>
</header> </header>
</div> </div>
<div class="main-wrapper"> <div class="wrapper-content wrapper">
<div class="inner-wrapper"> <section class="content">
<div class="page-actions"> <article class="asset-library content-primary" role="main">
<input type="text" class="asset-search-input search wip-box" placeholder="search assets" style="display:none"/>
</div>
<article class="asset-library">
<table> <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> <thead>
<tr> <tr>
<th class="thumb-col"></th> <th class="thumb-col">${_("Preview")}</th>
<th class="name-col">Name</th> <th class="name-col">${_("Name")}</th>
<th class="date-col">Date Added</th> <th class="date-col">${_("Date Added")}</th>
<th class="embed-col">URL</th> <th class="embed-col">${_("URL")}</th>
<th class="delete-col"></th> <th class="actions-col"><span class="sr">${_("Actions")}</span></th>
</tr> </tr>
</thead> </thead>
<tbody id="asset_table_body" > <tbody id="asset_table_body" >
</tbody> </tbody>
</table> </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>
</article> </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>
<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> </div>
</aside>
</section>
</div>
<div class="upload-modal modal"> <div class="upload-modal modal">
<a href="#" class="close-button"><span class="close-icon"></span></a> <a href="#" class="close-button"><i class="icon-remove-sign"></i> <span class="sr">${_('close')}</span></a>
<div class="modal-body"> <div class="modal-body">
<h1>${_("Upload New File")}</h1> <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>
...@@ -98,9 +104,9 @@ ...@@ -98,9 +104,9 @@
<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>
......
...@@ -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