Commit c64e04d8 by cahrens

Add the ability to lock assets.

parent 78829a37
......@@ -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')
......@@ -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'):
......
......@@ -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
......@@ -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
......
......@@ -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)
......@@ -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)
"""
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:
def get_asset_location(asset_id):
""" Helper method to get the location (and verify it is valid). """
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()
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)
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.
"""
......@@ -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
}
......@@ -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 ->
......
......@@ -8,6 +8,6 @@ CMS.Models.Asset = Backbone.Model.extend({
date_added: "",
url: "",
portable_url: "",
is_locked: false
locked: false
}
});
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();
}
});
}
});
......@@ -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;
}
}
}
.name-col {
font-size: 14px;
.thumb-cols {
padding: ($baseline/2) $baseline;
width: 100px;
}
.date-col {
font-size: 12px;
.name-cols {
width: 250px;
}
}
.thumb-col {
width: 100px;
}
.date-cols {
width: 100px;
}
.date-col {
width: 220px;
}
.embed-cols {
width: 200px;
}
.embed-col {
width: 250px;
}
.actions-cols {
width: ($baseline*3);
padding: ($baseline/2);
}
.delete-col {
width: 20px;
}
.thumb-col {
overflow: hidden;
.embeddable-xml-input {
box-shadow: none;
width: 100%;
}
.thumb {
width: $baseline*5;
.thumb {
width: 100px;
max-height: 80px;
img {
width: 100%;
}
}
}
img {
width: 100%;
.name-col {
@extend %t-copy-sub1;
text-overflow: ellipsis;
.title {
display: inline-block;
max-width: 200px;
overflow: hidden;
}
}
}
}
.pagination {
float: right;
margin: 15px 10px;
.date-col {
@include transition(all $tmg-f2 ease-in-out 0s);
@extend %t-copy-sub2;
color: $gray-l2;
}
ol, li {
display: inline;
}
.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;
}
}
}
a {
display: inline-block;
height: 25px;
padding: 0 4px;
text-align: center;
line-height: 25px;
.actions-col {
text-align: center;
}
}
}
}
.show-xml {
@include blue-button;
}
}
.upload-modal {
display: none;
width: 640px !important;
margin-left: -320px !important;
.action-item {
display: inline-block;
margin: ($baseline/4) 0 ($baseline/4) ($baseline/4);
.modal-body {
height: auto !important;
overflow-y: auto !important;
text-align: center;
}
.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;
}
}
.file-input {
display: none;
[class^="icon-"] {
display: inline-block;
vertical-align: bottom;
}
}
.choose-file-button {
.show-xml {
@include blue-button;
padding: 10px 82px 12px;
font-size: 17px;
}
.progress-bar {
.upload-modal {
display: none;
width: 350px;
height: 50px;
margin: 30px auto 10px;
border: 1px solid $blue;
width: 640px !important;
margin-left: -320px !important;
&.loaded {
border-color: #66b93d;
.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;
}
.progress-fill {
background: #66b93d;
.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 {
width: 0%;
height: 50px;
background: $blue;
color: #fff;
line-height: 48px;
}
.progress-fill {
@extend %t-copy-sub1;
width: 0%;
height: ($baseline*1.5);
border-radius: ($baseline*2);
background: $green;
padding-top: ($baseline/4);
color: #fff;
}
h1 {
float: none;
margin: 40px 0 30px;
font-size: 34px;
font-weight: 300;
}
.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;
}
.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;
}
&:hover {
background: none;
color: $blue;
}
}
.embeddable {
display: none;
margin: 30px 0 130px;
.embeddable {
display: none;
margin: 30px 0 130px;
label {
display: block;
margin-bottom: 10px;
font-weight: 700;
label {
display: block;
margin-bottom: 10px;
font-weight: 700;
}
}
}
.embeddable-xml-input {
box-shadow: none;
width: 400px;
}
.embeddable-xml-input {
box-shadow: none;
width: 400px;
}
.copy-button {
@include white-button;
display: none;
margin-bottom: 100px;
.copy-button {
@include white-button;
display: none;
margin-bottom: 100px;
}
}
}
<%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 &amp; Uploads")}</%block>
<%block name="bodyclass">is-signedin course uploads</%block>
<%namespace name='static' file='static_content.html'/>
......@@ -27,80 +28,85 @@
<%block name="content">
<div class="wrapper-mast wrapper">
<div class="wrapper-mast wrapper">
<header class="mast has-actions has-subtitle">
<h1 class="page-header">
<small class="subtitle">${_("Content")}</small>
<span class="sr">&gt; </span>${_("Files &amp; Uploads")}
</h1>
<nav class="nav-actions">
<h3 class="sr">${_("Page Actions")}</h3>
<ul>
<li class="nav-item">
<a href="#" class="button upload-button new-button"><i class="icon-plus"></i> ${_("Upload New File")}</a>
</li>
</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>
<h1 class="page-header">
<small class="subtitle">${_("Content")}</small>
<span class="sr">&gt; </span>${_("Files &amp; Uploads")}
</h1>
<nav class="nav-actions">
<h3 class="sr">${_("Page Actions")}</h3>
<ul>
<li class="nav-item">
<a href="#" class="button upload-button new-button"><i class="icon-plus"></i> ${_("Upload New File")}</a>
</li>
</ul>
</nav>
</article>
</div>
</div>
</header>
</div>
<div class="upload-modal modal">
<a href="#" class="close-button"><span class="close-icon"></span></a>
<div class="modal-body">
<h1>${_("Upload New File")}</h1>
<div class="wrapper-content wrapper">
<section class="content">
<article class="asset-library content-primary" role="main">
<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>
<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 class="embeddable">
<label>URL:</label>
<input type="text" class="embeddable-xml-input" value='' readonly>
</div>
<form class="file-chooser" action="${upload_asset_callback_url}"
method="post" enctype="multipart/form-data">
<a href="#" class="choose-file-button">${_("Choose File")}</a>
<input type="file" class="file-input" name="file" multiple>
method="post" enctype="multipart/form-data">
<a href="#" class="choose-file-button">${_("Choose File")}</a>
<input type="file" class="file-input" name="file" multiple>
</form>
</div>
</div>
</div>
<div class="modal-cover"></div>
<div class="modal-cover"></div>
</%block>
......@@ -108,17 +114,17 @@
<%block name="view_alerts">
<!-- alert: save confirmed with close -->
<div class="wrapper wrapper-alert wrapper-alert-confirmation" role="status">
<div class="alert confirmation">
<i class="icon-ok"></i>
<div class="alert confirmation">
<i class="icon-ok"></i>
<div class="copy">
<h2 class="title title-3">${_('Your file has been deleted.')}</h2>
</div>
<div class="copy">
<h2 class="title title-3">${_('Your file has been deleted.')}</h2>
</div>
<a href="" rel="view" class="action action-alert-close">
<i class="icon-remove-sign"></i>
<span class="label">${_('close alert')}</span>
</a>
</div>
<a href="" rel="view" class="action action-alert-close">
<i class="icon-remove-sign"></i>
<span class="label">${_('close alert')}</span>
</a>
</div>
</div>
</%block>
......@@ -6,7 +6,7 @@
</div>
</td>
<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>
</td>
......@@ -16,7 +16,19 @@
<td class="embed-col">
<input type="text" class="embeddable-xml-input" value="<%= portable_url %>" readonly>
</td>
<td class="delete-col">
<a href="#" data-tooltip="<%= gettext('Delete this asset') %>" class="remove-asset-button"><span
class="delete-icon"></span></a>
<td class="actions-col">
<ul>
<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>
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