Commit dcc8e956 by Martyn James

Implements SOL-20. Filtering for assets table by asset type

parent c4cd94d7
......@@ -31,8 +31,9 @@ from xmodule.modulestore.exceptions import ItemNotFoundError
__all__ = ['assets_handler']
# pylint: disable=unused-argument
@login_required
@ensure_csrf_cookie
def assets_handler(request, course_key_string=None, asset_key_string=None):
......@@ -99,6 +100,29 @@ def _assets_json(request, course_key):
requested_page = int(request.REQUEST.get('page', 0))
requested_page_size = int(request.REQUEST.get('page_size', 50))
requested_sort = request.REQUEST.get('sort', 'date_added')
requested_filter = request.REQUEST.get('asset_type', '')
requested_file_types = settings.FILES_AND_UPLOAD_TYPE_FILTERS.get(
requested_filter, None)
filter_params = None
if requested_filter:
if requested_filter == 'OTHER':
all_filters = settings.FILES_AND_UPLOAD_TYPE_FILTERS
where = []
for all_filter in all_filters:
extension_filters = all_filters[all_filter]
where.extend(
["JSON.stringify(this.contentType).toUpperCase() != JSON.stringify('{}').toUpperCase()".format(
extension_filter) for extension_filter in extension_filters])
filter_params = {
"$where": ' && '.join(where),
}
else:
where = ["JSON.stringify(this.contentType).toUpperCase() == JSON.stringify('{}').toUpperCase()".format(
req_filter) for req_filter in requested_file_types]
filter_params = {
"$where": ' || '.join(where),
}
sort_direction = DESCENDING
if request.REQUEST.get('direction', '').lower() == 'asc':
sort_direction = ASCENDING
......@@ -112,26 +136,42 @@ def _assets_json(request, course_key):
current_page = max(requested_page, 0)
start = current_page * requested_page_size
assets, total_count = _get_assets_for_page(request, course_key, current_page, requested_page_size, sort)
options = {
'current_page': current_page,
'page_size': requested_page_size,
'sort': sort,
'filter_params': filter_params
}
assets, total_count = _get_assets_for_page(request, course_key, options)
end = start + len(assets)
# If the query is beyond the final page, then re-query the final page so that at least one asset is returned
# If the query is beyond the final page, then re-query the final page so
# that at least one asset is returned
if requested_page > 0 and start >= total_count:
current_page = int(math.floor((total_count - 1) / requested_page_size))
options['current_page'] = current_page = int(math.floor((total_count - 1) / requested_page_size))
start = current_page * requested_page_size
assets, total_count = _get_assets_for_page(request, course_key, current_page, requested_page_size, sort)
assets, total_count = _get_assets_for_page(request, course_key, options)
end = start + len(assets)
asset_json = []
for asset in assets:
asset_location = asset['asset_key']
# note, due to the schema change we may not have a 'thumbnail_location' in the result set
# note, due to the schema change we may not have a 'thumbnail_location'
# in the result set
thumbnail_location = asset.get('thumbnail_location', None)
if thumbnail_location:
thumbnail_location = course_key.make_asset_key('thumbnail', thumbnail_location[4])
thumbnail_location = course_key.make_asset_key(
'thumbnail', thumbnail_location[4])
asset_locked = asset.get('locked', False)
asset_json.append(_get_asset_json(asset['displayname'], asset['uploadDate'], asset_location, thumbnail_location, asset_locked))
asset_json.append(_get_asset_json(
asset['displayname'],
asset['contentType'],
asset['uploadDate'],
asset_location,
thumbnail_location,
asset_locked
))
return JsonResponse({
'start': start,
......@@ -144,14 +184,18 @@ def _assets_json(request, course_key):
})
def _get_assets_for_page(request, course_key, current_page, page_size, sort):
def _get_assets_for_page(request, course_key, options):
"""
Returns the list of assets for the specified page and page size.
"""
current_page = options['current_page']
page_size = options['page_size']
sort = options['sort']
filter_params = options['filter_params'] if options['filter_params'] else None
start = current_page * page_size
return contentstore().get_all_content_for_course(
course_key, start=start, maxresults=page_size, sort=sort
course_key, start=start, maxresults=page_size, sort=sort, filter_params=filter_params
)
......@@ -239,10 +283,16 @@ def _upload_asset(request, course_key):
# 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, locked),
'asset': _get_asset_json(
content.name,
content.content_type,
readback.last_modified_at,
content.location,
content.thumbnail_location,
locked
),
'msg': _('Upload completed')
}
......@@ -305,7 +355,7 @@ def _update_asset(request, course_key, asset_key):
return JsonResponse(modified_asset, status=201)
def _get_asset_json(display_name, date, location, thumbnail_location, locked):
def _get_asset_json(display_name, content_type, date, location, thumbnail_location, locked):
"""
Helper method for formatting the asset information to send to client.
"""
......@@ -313,6 +363,7 @@ def _get_asset_json(display_name, date, location, thumbnail_location, locked):
external_url = settings.LMS_BASE + asset_url
return {
'display_name': display_name,
'content_type': content_type,
'date_added': get_default_time_display(date),
'url': asset_url,
'external_url': external_url,
......
......@@ -34,17 +34,17 @@ class AssetsTestCase(CourseTestCase):
super(AssetsTestCase, self).setUp()
self.url = reverse_course_url('assets_handler', self.course.id)
def upload_asset(self, name="asset-1"):
def upload_asset(self, name="asset-1", extension=".txt"):
"""
Post to the asset upload url
"""
f = self.get_sample_asset(name)
f = self.get_sample_asset(name, extension)
return self.client.post(self.url, {"name": name, "file": f})
def get_sample_asset(self, name):
def get_sample_asset(self, name, extension=".txt"):
"""Returns an in-memory file with the given name for testing"""
f = BytesIO(name)
f.name = name + ".txt"
f.name = name + extension
return f
......@@ -98,20 +98,60 @@ class PaginationTestCase(AssetsTestCase):
self.upload_asset("asset-1")
self.upload_asset("asset-2")
self.upload_asset("asset-3")
self.upload_asset("asset-4", ".odt")
# Verify valid page requests
self.assert_correct_asset_response(self.url, 0, 3, 3)
self.assert_correct_asset_response(self.url + "?page_size=2", 0, 2, 3)
self.assert_correct_asset_response(self.url + "?page_size=2&page=1", 2, 1, 3)
self.assert_correct_asset_response(self.url, 0, 4, 4)
self.assert_correct_asset_response(self.url + "?page_size=2", 0, 2, 4)
self.assert_correct_asset_response(
self.url + "?page_size=2&page=1", 2, 2, 4)
self.assert_correct_sort_response(self.url, 'date_added', 'asc')
self.assert_correct_sort_response(self.url, 'date_added', 'desc')
self.assert_correct_sort_response(self.url, 'display_name', 'asc')
self.assert_correct_sort_response(self.url, 'display_name', 'desc')
self.assert_correct_filter_response(self.url, 'asset_type', '')
self.assert_correct_filter_response(self.url, 'asset_type', 'OTHER')
self.assert_correct_filter_response(
self.url, 'asset_type', 'Documents')
# Verify querying outside the range of valid pages
self.assert_correct_asset_response(self.url + "?page_size=2&page=-1", 0, 2, 3)
self.assert_correct_asset_response(self.url + "?page_size=2&page=2", 2, 1, 3)
self.assert_correct_asset_response(self.url + "?page_size=3&page=1", 0, 3, 3)
self.assert_correct_asset_response(
self.url + "?page_size=2&page=-1", 0, 2, 4)
self.assert_correct_asset_response(
self.url + "?page_size=2&page=2", 2, 2, 4)
self.assert_correct_asset_response(
self.url + "?page_size=3&page=1", 3, 1, 4)
@mock.patch('xmodule.contentstore.mongo.MongoContentStore.get_all_content_for_course')
def test_mocked_filtered_response(self, mock_get_all_content_for_course):
"""
Test the ajax asset interfaces
"""
asset_key = self.course.id.make_asset_key(
AssetMetadata.GENERAL_ASSET_TYPE, 'test.jpg')
upload_date = datetime(2015, 1, 12, 10, 30, tzinfo=UTC)
thumbnail_location = [
'c4x', 'edX', 'toy', 'thumbnail', 'test_thumb.jpg', None]
mock_get_all_content_for_course.return_value = [
[
{
"asset_key": asset_key,
"displayname": "test.jpg",
"contentType": "image/jpg",
"url": "/c4x/A/CS102/asset/test.jpg",
"uploadDate": upload_date,
"id": "/c4x/A/CS102/asset/test.jpg",
"portable_url": "/static/test.jpg",
"thumbnail": None,
"thumbnail_location": thumbnail_location,
"locked": None
}
],
1
]
# Verify valid page requests
self.assert_correct_filter_response(self.url, 'asset_type', 'OTHER')
def assert_correct_asset_response(self, url, expected_start, expected_length, expected_total):
"""
......@@ -128,7 +168,8 @@ class PaginationTestCase(AssetsTestCase):
"""
Get from the url w/ a sort option and ensure items honor that sort
"""
resp = self.client.get(url + '?sort=' + sort + '&direction=' + direction, HTTP_ACCEPT='application/json')
resp = self.client.get(
url + '?sort=' + sort + '&direction=' + direction, HTTP_ACCEPT='application/json')
json_response = json.loads(resp.content)
assets_response = json_response['assets']
name1 = assets_response[0][sort]
......@@ -141,6 +182,29 @@ class PaginationTestCase(AssetsTestCase):
self.assertGreaterEqual(name1, name2)
self.assertGreaterEqual(name2, name3)
def assert_correct_filter_response(self, url, filter_type, filter_value):
"""
Get from the url w/ a filter option and ensure items honor that filter
"""
requested_file_types = settings.FILES_AND_UPLOAD_TYPE_FILTERS.get(
filter_value, None)
resp = self.client.get(
url + '?' + filter_type + '=' + filter_value, HTTP_ACCEPT='application/json')
json_response = json.loads(resp.content)
assets_response = json_response['assets']
if filter_value is not '':
content_types = [asset['content_type'].lower()
for asset in assets_response]
if filter_value is 'OTHER':
all_file_type_extensions = []
for file_type in settings.FILES_AND_UPLOAD_TYPE_FILTERS:
all_file_type_extensions.extend(file_type)
for content_type in content_types:
self.assertNotIn(content_type, all_file_type_extensions)
else:
for content_type in content_types:
self.assertIn(content_type, requested_file_types)
@ddt
class UploadTestCase(AssetsTestCase):
......@@ -229,13 +293,13 @@ class AssetToJsonTestCase(AssetsTestCase):
@override_settings(LMS_BASE="lms_base_url")
def test_basic(self):
upload_date = datetime(2013, 6, 1, 10, 30, tzinfo=UTC)
content_type = 'image/jpg'
course_key = SlashSeparatedCourseKey('org', 'class', 'run')
location = course_key.make_asset_key('asset', 'my_file_name.jpg')
thumbnail_location = course_key.make_asset_key('thumbnail', 'my_file_name_thumb.jpg')
# pylint: disable=protected-access
output = assets._get_asset_json("my_file", upload_date, location, thumbnail_location, True)
output = assets._get_asset_json("my_file", content_type, 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")
......@@ -246,7 +310,7 @@ class AssetToJsonTestCase(AssetsTestCase):
self.assertEquals(output["id"], unicode(location))
self.assertEquals(output['locked'], True)
output = assets._get_asset_json("name", upload_date, location, None, False)
output = assets._get_asset_json("name", content_type, upload_date, location, None, False)
self.assertIsNone(output["thumbnail"])
......@@ -267,6 +331,7 @@ class LockAssetTestCase(AssetsTestCase):
def post_asset_update(lock, course):
""" Helper method for posting asset update. """
content_type = 'application/txt'
upload_date = datetime(2013, 6, 1, 10, 30, tzinfo=UTC)
asset_location = course.id.make_asset_key('asset', 'sample_static.txt')
url = reverse_course_url('assets_handler', course.id, kwargs={'asset_key_string': unicode(asset_location)})
......@@ -274,9 +339,11 @@ class LockAssetTestCase(AssetsTestCase):
resp = self.client.post(
url,
# pylint: disable=protected-access
json.dumps(assets._get_asset_json("sample_static.txt", upload_date, asset_location, None, lock)),
json.dumps(assets._get_asset_json(
"sample_static.txt", content_type, upload_date, asset_location, None, lock)),
"application/json"
)
self.assertEqual(resp.status_code, 201)
return json.loads(resp.content)
......
......@@ -820,5 +820,26 @@ ADVANCED_PROBLEM_TYPES = [
'boilerplate_name': None,
}
]
#date format the api will be formatting the datetime values
API_DATE_FORMAT = '%Y-%m-%d'
# Files and Uploads type filter values
FILES_AND_UPLOAD_TYPE_FILTERS = {
"Images": ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/tiff', 'image/tif', 'image/x-icon'],
"Documents": [
'application/pdf',
'text/plain',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.wordprocessingml.template',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
'application/vnd.openxmlformats-officedocument.presentationml.template',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
'application/msword',
'application/vnd.ms-excel',
'application/vnd.ms-powerpoint',
],
}
......@@ -34,7 +34,7 @@ domReady(function() {
$('.nav-dd .nav-item .title').removeClass('is-selected');
});
$('.nav-dd .nav-item').click(function(e) {
$('.nav-dd .nav-item, .filterable-column .nav-item').click(function(e) {
$subnav = $(this).find('.wrapper-nav-sub');
$title = $(this).find('.title');
......
define(["backbone.paginator", "js/models/asset"], function(BackbonePaginator, AssetModel) {
var AssetCollection = BackbonePaginator.requestPager.extend({
assetType: '',
model : AssetModel,
paginator_core: {
type: 'GET',
......@@ -17,6 +18,7 @@ define(["backbone.paginator", "js/models/asset"], function(BackbonePaginator, As
'page_size': function() { return this.perPage; },
'sort': function() { return this.sortField; },
'direction': function() { return this.sortDirection; },
'asset_type': function() { return this.assetType; },
'format': 'json'
},
......
......@@ -5,12 +5,18 @@ define(["backbone"], function(Backbone) {
var Asset = Backbone.Model.extend({
defaults: {
display_name: "",
content_type: "",
thumbnail: "",
date_added: "",
url: "",
external_url: "",
portable_url: "",
locked: false
},
get_extension: function(){
var name_segments = this.get("display_name").split(".").reverse();
var asset_type = (name_segments.length > 1) ? name_segments[0].toUpperCase() : "";
return asset_type;
}
});
return Asset;
......
......@@ -35,14 +35,14 @@ define(
var makeUploadUrl = function(fileName) {
return "http://www.example.com/test_url/" + fileName;
}
};
var getSentRequests = function() {
return _.filter(
ajaxRequests,
function(request) { return request.readyState > 0; }
);
}
};
_.each(
[
......
......@@ -58,7 +58,9 @@ define([ "jquery", "js/common_helpers/ajax_helpers", "URI",
initialize : function() {
this.registerSortableColumn('name-col', 'Name', 'name', 'asc');
this.registerSortableColumn('date-col', 'Date', 'date', 'desc');
this.registerFilterableColumn('js-asset-type-col', gettext('Type'), 'asset_type');
this.setInitialSortColumn('date-col');
this.setInitialFilterColumn('js-asset-type-col');
}
});
......@@ -183,6 +185,7 @@ define([ "jquery", "js/common_helpers/ajax_helpers", "URI",
it('returns the registered info for a column', function () {
pagingView.registerSortableColumn('test-col', 'Test Column', 'testField', 'asc');
pagingView.registerFilterableColumn('js-asset-type-col', gettext('Type'), 'asset_type');
var sortInfo = pagingView.sortableColumnInfo('test-col');
expect(sortInfo.displayName).toBe('Test Column');
expect(sortInfo.fieldName).toBe('testField');
......
......@@ -20,6 +20,7 @@ var AssetView = BaseView.extend({
url: this.model.get('url'),
external_url: this.model.get('external_url'),
portable_url: this.model.get('portable_url'),
asset_type: this.model.get_extension(),
uniqueId: uniqueId
}));
this.updateLockState();
......
......@@ -10,9 +10,16 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging",
events : {
"click .column-sort-link": "onToggleColumn",
"click .upload-button": "showUploadModal"
"click .upload-button": "showUploadModal",
"click .filterable-column .nav-item": "onFilterColumn",
"click .filterable-column .column-filter-link": "toggleFilterColumn"
},
typeData: ['Images', 'Documents'],
allLabel: 'ALL',
initialize : function(options) {
options = options || {};
......@@ -22,7 +29,9 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging",
this.listenTo(collection, 'destroy', this.handleDestroy);
this.registerSortableColumn('js-asset-name-col', gettext('Name'), 'display_name', 'asc');
this.registerSortableColumn('js-asset-date-col', gettext('Date Added'), 'date_added', 'desc');
this.registerFilterableColumn('js-asset-type-col', gettext('Type'), 'asset_type');
this.setInitialSortColumn('js-asset-date-col');
this.setInitialFilterColumn('js-asset-type-col');
ViewUtils.showLoadingIndicator();
this.setPage(0);
// set default file size for uploads via template var,
......@@ -56,7 +65,7 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging",
ViewUtils.hideLoadingIndicator();
// Create the table
this.$el.html(this.template());
this.$el.html(this.template({typeData: this.typeData}));
tableBody = this.$('#asset-table-body');
this.tableBody = tableBody;
this.pagingHeader = new PagingHeader({view: this, el: $('#asset-paging-header')});
......@@ -74,7 +83,7 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging",
renderPageItems: function() {
var self = this,
assets = this.collection,
hasAssets = assets.length > 0,
hasAssets = this.collection.assetType !== '' || assets.length > 0,
tableBody = this.getTableBody();
tableBody.empty();
if (hasAssets) {
......@@ -106,6 +115,7 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging",
// Switch the sort column back to the default (most recent date added) and show the first page
// so that the new asset is shown at the top of the page.
this.setInitialSortColumn('js-asset-date-col');
this.setInitialFilterColumn('js-asset-type-col');
this.setPage(0);
analytics.track('Uploaded a File', {
......@@ -119,6 +129,11 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging",
this.toggleSortOrder(columnName);
},
onFilterColumn: function(event) {
this.openFilterColumn($(event.currentTarget));
event.stopPropagation();
},
hideModal: function (event) {
if (event) {
event.preventDefault();
......@@ -222,6 +237,65 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging",
$('.upload-modal .progress-fill').html(percentVal);
},
openFilterColumn: function($this) {
this.toggleFilterColumnState($this);
},
toggleFilterColumnState: function(menu, selected) {
var $subnav = menu.find('.wrapper-nav-sub');
var $title = menu.find('.title');
var titleText = $title.find('.type-filter');
var assettype = selected ? selected.data('assetfilter'): false;
if(assettype) {
if(assettype === this.allLabel) {
titleText.text(titleText.data('alllabel'));
}
else {
titleText.text(assettype);
}
}
if ($subnav.hasClass('is-shown')) {
$subnav.removeClass('is-shown');
$title.removeClass('is-selected');
} else {
$title.addClass('is-selected');
$subnav.addClass('is-shown');
}
},
toggleFilterColumn: function(event) {
event.preventDefault();
var $filterColumn = $(event.currentTarget);
this._toggleFilterColumn($filterColumn.data('assetfilter'), $filterColumn.text());
},
_toggleFilterColumn: function(assettype, assettypeLabel) {
var collection = this.collection;
var filterColumn = this.$el.find('.filterable-column');
var resetFilter = filterColumn.find('.reset-filter');
var title = filterColumn.find('.title');
if(assettype === this.allLabel) {
collection.assetType = '';
resetFilter.hide();
title.removeClass('column-selected-link');
}
else {
collection.assetType = assettype;
resetFilter.show();
title.addClass('column-selected-link');
}
this.filterableColumns['js-asset-type-col'].displayName = assettypeLabel;
this.selectFilter('js-asset-type-col');
this.closeFilterPopup(this.$el.find(
'.column-filter-link[data-assetfilter="' + assettype + '"]'));
},
closeFilterPopup: function(element){
var $menu = element.parents('.nav-dd > .nav-item');
this.toggleFilterColumnState($menu, element);
},
displayFinishedUpload: function (resp) {
var asset = resp.asset;
......
......@@ -6,6 +6,10 @@ define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext",
sortableColumns: {},
filterableColumns: {},
filterColumn: '',
initialize: function() {
BaseView.prototype.initialize.call(this);
var collection = this.collection;
......@@ -25,6 +29,51 @@ define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext",
// Do nothing by default
},
nextPage: function() {
var collection = this.collection,
currentPage = collection.currentPage,
lastPage = collection.totalPages - 1;
if (currentPage < lastPage) {
this.setPage(currentPage + 1);
}
},
previousPage: function() {
var collection = this.collection,
currentPage = collection.currentPage;
if (currentPage > 0) {
this.setPage(currentPage - 1);
}
},
registerFilterableColumn: function(columnName, displayName, fieldName) {
this.filterableColumns[columnName] = {
displayName: displayName,
fieldName: fieldName
};
},
filterableColumnInfo: function(filterColumn) {
var filterInfo = this.filterableColumns[filterColumn];
if (!filterInfo) {
throw "Unregistered filter column '" + filterInfo + '"';
}
return filterInfo;
},
filterDisplayName: function() {
var filterColumn = this.filterColumn,
filterInfo = this.filterableColumnInfo(filterColumn);
return filterInfo.displayName;
},
setInitialFilterColumn: function(filterColumn) {
var collection = this.collection,
filtertInfo = this.filterableColumns[filterColumn];
collection.filterField = filtertInfo.fieldName;
this.filterColumn = filterColumn;
},
/**
* Registers information about a column that can be sorted.
* @param columnName The element name of the column.
......@@ -75,6 +124,18 @@ define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext",
}
this.sortColumn = sortColumn;
this.setPage(0);
},
selectFilter: function(filterColumn) {
var collection = this.collection,
filterInfo = this.filterableColumnInfo(filterColumn),
filterField = filterInfo.fieldName,
defaultFilterKey = false;
if (collection.filterField !== filterField) {
collection.filterField = filterField;
}
this.filterColumn = filterColumn;
this.setPage(0);
}
});
return PagingView;
......
......@@ -31,17 +31,40 @@ define(["underscore", "gettext", "js/views/baseview"], function(_, gettext, Base
},
messageHtml: function() {
var message;
if (this.view.collection.sortDirection === 'asc') {
// Translators: sample result: "Showing 0-9 out of 25 total, sorted by Date Added ascending"
message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, sorted by %(sort_name)s ascending');
} else {
// Translators: sample result: "Showing 0-9 out of 25 total, sorted by Date Added descending"
message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, sorted by %(sort_name)s descending');
var message = '';
var asset_type = false;
if (this.view.collection.assetType) {
if (this.view.collection.sortDirection === 'asc') {
// Translators: sample result:
// "Showing 0-9 out of 25 total, filtered by Images, sorted by Date Added ascending"
message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, ' +
'filtered by %(asset_type)s, sorted by %(sort_name)s ascending');
} else {
// Translators: sample result:
// "Showing 0-9 out of 25 total, filtered by Images, sorted by Date Added descending"
message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, ' +
'filtered by %(asset_type)s, sorted by %(sort_name)s descending');
}
asset_type = this.filterNameLabel();
}
else {
if (this.view.collection.sortDirection === 'asc') {
// Translators: sample result:
// "Showing 0-9 out of 25 total, sorted by Date Added ascending"
message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, ' +
'sorted by %(sort_name)s ascending');
} else {
// Translators: sample result:
// "Showing 0-9 out of 25 total, sorted by Date Added descending"
message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, ' +
'sorted by %(sort_name)s descending');
}
}
return '<p>' + interpolate(message, {
current_item_range: this.currentItemRangeLabel(),
total_items_count: this.totalItemsCountLabel(),
asset_type: asset_type,
sort_name: this.sortNameLabel()
}, true) + "</p>";
},
......@@ -75,6 +98,12 @@ define(["underscore", "gettext", "js/views/baseview"], function(_, gettext, Base
}, true);
},
filterNameLabel: function() {
return interpolate('<span class="filter-column">%(filter_name)s</span>', {
filter_name: this.view.filterDisplayName()
}, true);
},
nextPage: function() {
this.view.nextPage();
},
......
......@@ -43,6 +43,351 @@
}
}
.assets-library {
@include clearfix;
.meta-wrap {
margin-bottom: $baseline;
}
.meta {
@extend %t-copy-sub2;
display: inline-block;
vertical-align: top;
width: flex-grid(9, 12);
color: $gray-l1;
.count-current-shown,
.count-total,
.filter-column,
.sort-order {
@extend %t-strong;
}
}
.pagination {
@include clearfix;
display: inline-block;
width: flex-grid(3, 12);
&.pagination-compact {
@include text-align(right);
}
&.pagination-full {
display: block;
width: flex-grid(4, 12);
margin: $baseline auto;
}
.nav-item {
position: relative;
display: inline-block;
}
.nav-link {
@include transition(all $tmg-f2 ease-in-out 0s);
display: block;
padding: ($baseline/4) ($baseline*0.75);
&.previous {
margin-right: ($baseline/2);
}
&.next {
margin-left: ($baseline/2);
}
&:hover {
background-color: $blue;
border-radius: 3px;
color: $white;
}
&.is-disabled {
background-color: transparent;
color: $gray-l2;
pointer-events: none;
}
}
.nav-label {
@extend .sr;
}
.pagination-form,
.current-page,
.page-divider,
.total-pages {
display: inline-block;
}
.current-page,
.page-number-input,
.total-pages {
@extend %t-copy-base;
@extend %t-strong;
width: ($baseline*2.5);
margin: 0 ($baseline*0.75);
padding: ($baseline/4);
text-align: center;
color: $gray;
}
.current-page {
@extend %ui-depth1;
position: absolute;
@include left(-($baseline/4));
}
.page-divider {
@extend %t-title4;
@extend %t-regular;
vertical-align: middle;
color: $gray-l2;
}
.pagination-form {
@extend %ui-depth2;
position: relative;
.page-number-label,
.submit-pagination-form {
@extend .sr;
}
.page-number-input {
@include transition(all $tmg-f2 ease-in-out 0s);
border: 1px solid transparent;
border-bottom: 1px dotted $gray-l2;
border-radius: 0;
box-shadow: none;
background: none;
&:hover {
background-color: $white;
opacity: 0.6;
}
&:focus {
// borrowing the base input focus styles to match overall app
@include linear-gradient($paleYellow, tint($paleYellow, 90%));
opacity: 1.0;
box-shadow: 0 0 3px $shadow-d1 inset;
background-color: $white;
border: 1px solid transparent;
border-radius: 3px;
}
}
}
}
table {
width: 100%;
word-wrap: break-word;
th {
@extend %t-copy-sub2;
background-color: $gray-l5;
padding: 0 ($baseline/2) ($baseline*0.75) ($baseline/2);
vertical-align: middle;
text-align: left;
color: $gray;
.column-sort-link, .column-selected-link {
cursor: pointer;
color: $blue;
}
.current-sort {
@extend %t-strong;
border-bottom: 1px solid $gray-l3;
}
// CASE: embed column
&.embed-col {
padding-left: ($baseline*0.75);
padding-right: ($baseline*0.75);
}
&.nav-dd{
// basic layout - nav items
margin: 0 -($baseline/2);
color: $blue;
cursor: pointer;
.wrapper-nav-sub {
top: 35px;
@extend %ui-depth2;
> ol > .nav-item {
@extend %t-action3;
@extend %t-strong;
display: inline-block;
vertical-align: middle;
&:last-child {
margin-right: 0;
}
}
.nav-sub {
@include text-align(left);
// ui triangle/nub
&:after {
left: $baseline;
margin-left: -10px;
}
&:before {
left: $baseline;
margin-left: -11px;
}
.nav-item {
&.reset-filter{
display:none;
}
a {
color: $gray-d1;
&:hover {
color: $blue-s1;
}
}
}
}
}
}
}
td {
padding: ($baseline/2);
vertical-align: middle;
text-align: left;
}
tbody {
box-shadow: 0 2px 2px $shadow-l1;
border: 1px solid $gray-l4;
background: $white;
tr {
@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-image: url('../images/bg-micro-stripes.png');
background-position: 0 0;
background-repeat: repeat;
}
&:hover {
background-color: $blue-l5;
.date-col,
.embed-col,
.embed-col .embeddable-xml-input {
color: $gray;
}
}
}
.thumb-col {
padding: ($baseline/2) $baseline;
@extend %t-copy-sub2;
color: $gray-l2;
.thumb {
width: 100px;
}
img {
width: 100%;
}
}
.name-col {
.title {
@extend %t-copy-sub1;
display: inline-block;
max-width: 200px;
overflow: hidden;
}
}
.type-col {
@extend %t-copy-sub2;
color: $gray-l2;
}
.date-col {
@include transition(all $tmg-f2 ease-in-out 0s);
@extend %t-copy-sub2;
color: $gray-l2;
}
.embed-col {
@include transition(all $tmg-f2 ease-in-out 0s);
@extend %t-copy-sub2;
padding-left: ($baseline*0.75);
color: $gray-l2;
.label {
display: inline-block;
width: ($baseline*2);
}
.embeddable-xml-input {
@include transition(all $tmg-f2 ease-in-out 0s);
@extend %t-copy-sub2;
box-shadow: none;
border: 1px solid transparent;
background: none;
padding: ($baseline/5);
color: $gray-l2;
&:focus {
background-color: $white;
box-shadow: 0 1px 5px $shadow-l1 inset;
border: 1px solid $gray-l3;
color: $black;
}
}
}
.actions-col {
padding: ($baseline/2);
text-align: center;
}
}
}
}
// UI: assets - calls-to-action
.actions-list {
@extend %actions-list;
......
......@@ -70,10 +70,11 @@
<div class="bit">
<h3 class="title-3">${_("Using File URLs")}</h3>
<p>${_("Use the {em_start}Embed URL{em_end} value to link to the file or image from a component, a course update, or a course handout.").format(em_start='<strong>', em_end="</strong>")}</p>
<p>${_("Use the {em_start}{studio_name} URL{em_end} value to link to the file or image from a component, a course update, or a course handout.").format(studio_name=settings.STUDIO_SHORT_NAME, em_start="<strong>", em_end="</strong>")}</p>
<p>${_("Use the {em_start}External URL{em_end} value to reference the file or image only from outside of your course.").format(em_start='<strong>', em_end="</strong>")}</p>
<p>${_("Click in the Embed URL or External URL column to select the value, then copy it.")}</p>
<p>${_("Use the {em_start}Web URL{em_end} value to reference the file or image only from outside of your course. {em_start}Note:{em_end} If you lock a file, the Web URL no longer works for external access to a file.").format(em_start='<strong>', em_end="</strong>")}</p>
<p>${_("To copy a URL, double click the value in the URL column, then copy the selected text.")}</p>
</div>
<div class="bit external-help">
<a href="${get_online_help_info(online_help_token())['doc_url']}" target="_blank" class="button external-help-button">${_("Learn more about managing files")}</a>
......
<div class="assets-library">
<div id="asset-paging-header"></div>
<table class="assets-table">
<caption class="sr"><%= gettext("List of uploaded files and assets in this course") %></caption>
<colgroup>
<col class="thumb-cols" />
<col class="name-cols" />
<col class="type-cols" />
<col class="date-cols" />
<col class="embed-cols" />
<col class="actions-cols" />
......@@ -13,10 +14,46 @@
<thead>
<tr>
<th class="thumb-col"><%= gettext("Preview") %></th>
<th class="name-col sortable-column"><span class="column-sort-link" id="js-asset-name-col"><%= gettext("Name") %></span></th>
<th class="date-col sortable-column"><span class="column-sort-link" id="js-asset-date-col"><%= gettext("Date Added") %></span></th>
<th class="embed-col"><%= gettext("Embed URL") %></th>
<th class="embed-col"><%= gettext("External URL") %></th>
<th class="name-col sortable-column">
<span class="column-sort-link" id="js-asset-name-col" role="button" tabindex="0">
<%= gettext("Name") %>
<span class="sr"><%= gettext("- Sortable") %></span>
</span>
</th>
<th class="type-col filterable-column nav-dd">
<div id="js-asset-type-col" class="nav-item" role="button" tabindex="0">
<span class="title">
<span class="type-filter" data-alllabel='<%= gettext("Type") %>'><%= gettext("Type") %></span>
<span class="label-prefix sr">Filter</span>
<span class="filter-link"></span>
<i class="fa fa-caret-down ui-toggle-dd" aria-hidden="true"></i>
</span>
<div class="wrapper-nav-sub">
<div class="nav-sub">
<ul>
<li class="nav-item reset-filter">
<a class="column-filter-link" href="" data-assetfilter="ALL">Show All</a>
</li>
<% _.each(typeData, function(type, key){ %>
<li class="nav-item">
<a class="column-filter-link" href="" data-assetfilter="<%= type %>"><%= type %></a>
</li>
<% }) %>
<li class="nav-item">
<a class="column-filter-link" href="" data-assetfilter="OTHER">Other</a>
</li>
</ul>
</div>
</div>
</div>
</th>
<th class="date-col sortable-column">
<span class="column-sort-link" id="js-asset-date-col">
<%= gettext("Date Added") %>
<span class="sr"><%= gettext("- Sortable") %></span>
</span>
</th>
<th class="embed-col"><%= gettext("URL") %></th>
<th class="actions-col"><span class="sr"><%= gettext("Actions") %></span></th>
</tr>
</thead>
......@@ -27,5 +64,5 @@
</div>
<div class="no-asset-content">
<p><%= gettext("You haven't added any assets to this course yet.") %> <a href="#" class="button new-button upload-button"><i class="icon fa fa-plus"></i><%= gettext("Upload your first asset") %></a></p>
<p><%= gettext("You haven't added any assets to this course yet.") %> <a href="#" class="button new-button upload-button"><i class="icon fa fa-plus" aria-hidden="true"></i><%= gettext("Upload your first asset") %></a></p>
</div>
<div class="upload-modal modal" style="display: none;">
<a href="#" class="close-button"><i class="icon fa fa-times-circle"></i> <span class="sr"><%= gettext('close') %></span></a>
<a href="#" class="close-button"><i class="icon fa fa-times-circle" aria-hidden="true"></i> <span class="sr"><%= gettext('close') %></span></a>
<div class="modal-body">
<h1 class="title"><%= gettext("Upload New File") %></h1>
<p class="file-name">
......
<td class="thumb-col">
<div class="thumb">
<% if (thumbnail !== '') { %>
<img src="<%= thumbnail %>">
<img src="<%= thumbnail %>" alt="<%= gettext('No description available') %>">
<% } %>
</div>
</td>
......@@ -10,24 +10,38 @@
<div class="embeddable-xml"></div>
</td>
<td class="type-col">
<%= asset_type %>
</td>
<td class="date-col">
<%= date_added %>
</td>
<td class="embed-col">
<input type="text" class="embeddable-xml-input" value="<%= portable_url %>" readonly dir="ltr">
</td>
<td class="embed-col">
<input type="text" class="embeddable-xml-input" value="<%= external_url %>" readonly>
<ul>
<li class="embed-url">
<label>
<span class="label"><%= gettext('Studio:') %></span>
<input type="text" class="embeddable-xml-input" value="<%= portable_url %>" readonly>
</label>
</li>
<li class="external-url">
<label>
<span class="label"><%= gettext('Web:') %></span>
<input type="text" class="embeddable-xml-input" value="<%= external_url %>" readonly>
</label>
</li>
</ul>
</td>
<td class="actions-col">
<ul class="actions-list">
<li class="action-item action-delete">
<a href="#" data-tooltip="<%= gettext('Delete this asset') %>" class="remove-asset-button action-button"><i class="icon fa fa-times-circle"></i> <span class="sr"><%= gettext('Delete this asset') %></span></a>
<a href="#" data-tooltip="<%= gettext('Delete this asset') %>" class="remove-asset-button action-button"><i class="icon fa fa-times-circle" aria-hidden="true"></i> <span class="sr"><%= gettext('Delete this asset') %></span></a>
</li>
<li class="action-item action-lock">
<label for="<%= uniqueId %>"><span class="sr"><%= gettext('Lock this asset') %></span></label>
<input type="checkbox" id="<%= uniqueId %>" class="lock-checkbox" data-tooltip="<%= gettext('Lock/unlock file') %>" />
<div class="action-button"><i class="icon fa fa-lock"></i><i class="icon fa fa-unlock-alt"></i></div>
<div class="action-button"><i class="icon fa fa-lock"></i><i class="icon fa fa-unlock-alt" aria-hidden="true"></i></div>
</li>
</ul>
</td>
......@@ -104,8 +104,7 @@
</li>
<li class="nav-item nav-course-tools">
<h3 class="title"><span class="label">${_("Tools")}</span> <i class="icon fa fa-caret-down ui-toggle-dd"></i></h3>
<h3 class="title"><span class="label">${_("Tools")}</span> <i class="icon fa fa-caret-down ui-toggle-dd" aria-hidden="true"></i></h3>
<div class="wrapper wrapper-nav-sub">
<div class="nav-sub">
<ul>
......
......@@ -220,7 +220,7 @@ class ContentStore(object):
def find(self, filename):
raise NotImplementedError
def get_all_content_for_course(self, course_key, start=0, maxresults=-1, sort=None):
def get_all_content_for_course(self, course_key, start=0, maxresults=-1, sort=None, filter_params=None):
'''
Returns a list of static assets for a course, followed by the total number of assets.
By default all assets are returned, but start and maxresults can be provided to limit the query.
......
......@@ -172,9 +172,9 @@ class MongoContentStore(ContentStore):
def get_all_content_thumbnails_for_course(self, course_key):
return self._get_all_content_for_course(course_key, get_thumbnails=True)[0]
def get_all_content_for_course(self, course_key, start=0, maxresults=-1, sort=None):
def get_all_content_for_course(self, course_key, start=0, maxresults=-1, sort=None, filter_params=None):
return self._get_all_content_for_course(
course_key, start=start, maxresults=maxresults, get_thumbnails=False, sort=sort
course_key, start=start, maxresults=maxresults, get_thumbnails=False, sort=sort, filter_params=filter_params
)
def remove_redundant_content_for_courses(self):
......@@ -197,7 +197,13 @@ class MongoContentStore(ContentStore):
self.fs_files.remove(query)
return assets_to_delete
def _get_all_content_for_course(self, course_key, get_thumbnails=False, start=0, maxresults=-1, sort=None):
def _get_all_content_for_course(self,
course_key,
get_thumbnails=False,
start=0,
maxresults=-1,
sort=None,
filter_params=None):
'''
Returns a list of all static assets for a course. The return format is a list of asset data dictionary elements.
......@@ -208,15 +214,17 @@ class MongoContentStore(ContentStore):
contentType: The mimetype string of the asset
md5: An md5 hash of the asset content
'''
query = query_for_course(course_key, "asset" if not get_thumbnails else "thumbnail")
find_args = {"sort": sort}
if maxresults > 0:
items = self.fs_files.find(
query_for_course(course_key, "asset" if not get_thumbnails else "thumbnail"),
skip=start, limit=maxresults, sort=sort
)
else:
items = self.fs_files.find(
query_for_course(course_key, "asset" if not get_thumbnails else "thumbnail"), sort=sort
)
find_args.update({
"skip": start,
"limit": maxresults,
})
if filter_params:
query.update(filter_params)
items = self.fs_files.find(query, **find_args)
count = items.count()
assets = list(items)
......
......@@ -333,6 +333,10 @@ class TestMongoModuleStore(TestMongoModuleStoreBase):
location = Location('edX', 'toy', '2012_Fall', 'course', '2012_Fall')
course_content, __ = self.content_store.get_all_content_for_course(location.course_key)
assert_true(len(course_content) > 0)
filter_params = _build_requested_filter('Images')
filtered_course_content, __ = self.content_store.get_all_content_for_course(
location.course_key, filter_params=filter_params)
assert_true(len(filtered_course_content) < len(course_content))
# a bit overkill, could just do for content[0]
for content in course_content:
assert not content.get('locked', False)
......@@ -778,3 +782,35 @@ class TestMongoKeyValueStore(object):
for scope in (Scope.preferences, Scope.user_info, Scope.user_state, Scope.parent):
with assert_raises(InvalidScopeError):
self.kvs.delete(KeyValueStore.Key(scope, None, None, 'foo'))
def _build_requested_filter(requested_filter):
"""
Returns requested filter_params string.
"""
# Files and Uploads type filter values
all_filters = {
"Images": ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/tiff', 'image/tif', 'image/x-icon'],
"Documents": [
'application/pdf',
'text/plain',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.wordprocessingml.template',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
'application/vnd.openxmlformats-officedocument.presentationml.template',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
'application/msword',
'application/vnd.ms-excel',
'application/vnd.ms-powerpoint',
],
}
requested_file_types = all_filters.get(requested_filter, None)
where = ["JSON.stringify(this.contentType).toUpperCase() == JSON.stringify('{}').toUpperCase()".format(
req_filter) for req_filter in requested_file_types]
filter_params = {
"$where": ' || '.join(where),
}
return filter_params
......@@ -2,15 +2,84 @@
The Files and Uploads page for a course in Studio
"""
import urllib
import os
from opaque_keys.edx.locator import CourseLocator
from . import BASE_URL
from .course_page import CoursePage
from bok_choy.javascript import wait_for_js, requirejs
@requirejs('js/views/assets')
class AssetIndexPage(CoursePage):
"""
The Files and Uploads page for a course in Studio
"""
url_path = "assets"
type_filter_element = '#js-asset-type-col'
@property
def url(self):
"""
Construct a URL to the page within the course.
"""
# TODO - is there a better way to make this agnostic to the underlying default module store?
default_store = os.environ.get('DEFAULT_STORE', 'draft')
course_key = CourseLocator(
self.course_info['course_org'],
self.course_info['course_num'],
self.course_info['course_run'],
deprecated=(default_store == 'draft')
)
url = "/".join([BASE_URL, self.url_path, urllib.quote_plus(unicode(course_key))])
return url if url[-1] is '/' else url + '/'
@wait_for_js
def is_browser_on_page(self):
return self.q(css='body.view-uploads').present
@wait_for_js
def type_filter_on_page(self):
"""
Checks that type filter is in table header.
"""
return self.q(css=self.type_filter_element).present
@wait_for_js
def type_filter_header_label_visible(self):
"""
Checks type filter label is added and visible in the pagination header.
"""
return self.q(css='span.filter-column').visible
@wait_for_js
def click_type_filter(self):
"""
Clicks type filter menu.
"""
self.q(css=".filterable-column .nav-item").click()
@wait_for_js
def select_type_filter(self, filter_number):
"""
Selects Type filter from dropdown which filters the results.
Returns False if no filter.
"""
self.wait_for_ajax()
if self.q(css=".filterable-column .nav-item").is_present():
if not self.q(css=self.type_filter_element + " .wrapper-nav-sub").visible:
self.q(css=".filterable-column > .nav-item").first.click()
self.wait_for_element_visibility(
self.type_filter_element + " .wrapper-nav-sub", "Type Filter promise satisfied.")
self.q(css=self.type_filter_element + " .column-filter-link").nth(filter_number).click()
self.wait_for_ajax()
return True
return False
def return_results_set(self):
"""
Returns the asset set from the page
"""
return self.q(css="#asset-table-body tr").results
"""
Acceptance tests for Studio related to the asset index page.
"""
from ...pages.studio.asset_index import AssetIndexPage
from acceptance.tests.studio.base_studio_test import StudioCourseTest
from acceptance.fixtures.base import StudioApiLoginError
class AssetIndexTest(StudioCourseTest):
"""
Tests for the Asset index page.
"""
def setUp(self, is_staff=False):
super(AssetIndexTest, self).setUp()
self.asset_page = AssetIndexPage(
self.browser,
self.course_info['org'],
self.course_info['number'],
self.course_info['run']
)
def populate_course_fixture(self, course_fixture):
"""
Populate the children of the test course fixture.
"""
self.course_fixture.add_asset(['image.jpg', 'textbook.pdf'])
def test_page_existence(self):
"""
Make sure that the page is accessible.
"""
self.asset_page.visit()
def test_type_filter_exists(self):
"""
Make sure type filter is on the page.
"""
self.asset_page.visit()
assert self.asset_page.type_filter_on_page() is True
def test_filter_results(self):
"""
Make sure type filter actually filters the results.
"""
self.asset_page.visit()
all_results = len(self.asset_page.return_results_set())
if self.asset_page.select_type_filter(1):
filtered_results = len(self.asset_page.return_results_set())
assert self.asset_page.type_filter_header_label_visible()
assert all_results > filtered_results
else:
msg = "Could not open select Type filter"
raise StudioApiLoginError(msg)
......@@ -51,6 +51,7 @@ class LibraryEditPageTest(StudioLibraryTest):
Then one XBlock is displayed
And displayed XBlock are second one
"""
self.browser.save_screenshot('library_page')
self.assertEqual(len(self.lib_page.xblocks), 0)
# Create a new block:
......
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