Add pagination to Studio's Files and Uploads page

These changes implement STUD-813. The commit consists of the
following logical changes:
 - a REST API has been implemented for a course's assets
 - the page itself now fetches the assets client-side
 - the Backbone.Paginator library is used to support pagination
 - the AssetCollection has been refactored to extend
   Backbone.Paginator.requestPager so that it can be paged
 - an abstract PagingView class has been added to generalize
   the communication with a paging REST API
 - the AssetsView has been reimplemented to extend PagingView
 - two new child views have been added:
   - PagingHeader: the paging controls above the list of assets
   - PagingFooter: the paging controls below the assets
This commit is contained in:
Andy Armstrong
2013-12-05 11:48:34 -05:00
parent 5dccff518c
commit f9c45586a4
32 changed files with 1214 additions and 220 deletions

View File

@@ -41,7 +41,7 @@ class AssetsTestCase(CourseTestCase):
class AssetsToyCourseTestCase(CourseTestCase):
"""
Tests the assets returned from assets_handler (full page content) for the toy test course.
Tests the assets returned from assets_handler for the toy test course.
"""
def test_toy_assets(self):
module_store = modulestore('direct')
@@ -56,10 +56,17 @@ class AssetsToyCourseTestCase(CourseTestCase):
location = loc_mapper().translate_location(course.location.course_id, course.location, False, True)
url = location.url_reverse('assets/', '')
resp = self.client.get(url, HTTP_ACCEPT='text/html')
# Test a small portion of the asset data passed to the client.
self.assertContains(resp, "new AssetCollection([{")
self.assertContains(resp, "/c4x/edX/toy/asset/handouts_sample_handout.txt")
self.assert_correct_asset_response(url, 0, 3, 3)
self.assert_correct_asset_response(url + "?page_size=2", 0, 2, 3)
self.assert_correct_asset_response(url + "?page_size=2&page=1", 2, 1, 3)
def assert_correct_asset_response(self, url, expected_start, expected_length, expected_total):
resp = self.client.get(url, HTTP_ACCEPT='application/json')
json_response = json.loads(resp.content)
assets = json_response['assets']
self.assertEquals(json_response['start'], expected_start)
self.assertEquals(len(assets), expected_length)
self.assertEquals(json_response['totalCount'], expected_total)
class UploadTestCase(CourseTestCase):
@@ -82,10 +89,6 @@ class UploadTestCase(CourseTestCase):
resp = self.client.post(self.url, {"name": "file.txt"}, "application/json")
self.assertEquals(resp.status_code, 400)
def test_get(self):
with self.assertRaises(NotImplementedError):
self.client.get(self.url)
class AssetToJsonTestCase(TestCase):
"""
@@ -163,80 +166,3 @@ class LockAssetTestCase(CourseTestCase):
resp_asset = post_asset_update(False)
self.assertFalse(resp_asset['locked'])
verify_asset_locked_state(False)
class TestAssetIndex(CourseTestCase):
"""
Test getting asset lists via http (Note, the assets don't actually exist)
"""
def setUp(self):
"""
Create fake asset entries for the other tests to use
"""
super(TestAssetIndex, self).setUp()
self.entry_filter = self.create_asset_entries(contentstore(), 100)
location = loc_mapper().translate_location(self.course.location.course_id, self.course.location, False, True)
self.url = location.url_reverse('assets/', '')
def tearDown(self):
"""
Get rid of the entries
"""
contentstore().fs_files.remove(self.entry_filter)
def create_asset_entries(self, cstore, number):
"""
Create the fake entries
"""
course_filter = Location(
XASSET_LOCATION_TAG, category='asset', course=self.course.location.course, org=self.course.location.org
)
# purge existing entries (a bit brutal but hopefully tests are independent enuf to not trip on this)
cstore.fs_files.remove(location_to_query(course_filter))
base_entry = {
'displayname': 'foo.jpg',
'chunkSize': 262144,
'length': 0,
'uploadDate': datetime(2012, 1, 2, 0, 0),
'contentType': 'image/jpeg',
}
for i in range(number):
base_entry['displayname'] = '{:03x}.jpeg'.format(i)
base_entry['uploadDate'] += timedelta(hours=i)
base_entry['_id'] = course_filter.replace(name=base_entry['displayname']).dict()
cstore.fs_files.insert(base_entry)
return course_filter.dict()
ASSET_LIST_RE = re.compile(r'AssetCollection\((.*)\);$', re.MULTILINE)
def check_page_content(self, resp_content, entry_count, last_date=None):
"""
:param entry_count:
:param last_date:
"""
match = self.ASSET_LIST_RE.search(resp_content)
asset_list = json.loads(match.group(1))
self.assertEqual(len(asset_list), entry_count)
for row in asset_list:
datetext = row['date_added']
parsed_date = datetime.strptime(datetext, "%b %d, %Y at %H:%M UTC")
if last_date is None:
last_date = parsed_date
else:
self.assertGreaterEqual(last_date, parsed_date)
return last_date
def test_query_assets(self):
"""
The actual test
"""
# get all
resp = self.client.get(self.url, HTTP_ACCEPT='text/html')
self.check_page_content(resp.content, 100)
# get first page of 10
resp = self.client.get(self.url + "?max=10", HTTP_ACCEPT='text/html')
last_date = self.check_page_content(resp.content, 10)
# get next of 20
resp = self.client.get(self.url + "?start=10&max=20", HTTP_ACCEPT='text/html')
self.check_page_content(resp.content, 20, last_date)

View File

@@ -176,7 +176,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
Lock an arbitrary asset in the course
:param course_location:
"""
course_assets = content_store.get_all_content_for_course(course_location)
course_assets,__ = content_store.get_all_content_for_course(course_location)
self.assertGreater(len(course_assets), 0, "No assets to lock")
content_store.set_attr(course_assets[0]['_id'], 'locked', True)
return course_assets[0]['_id']
@@ -585,7 +585,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
self.assertIsNotNone(course)
# make sure we have some assets in our contentstore
all_assets = content_store.get_all_content_for_course(course_location)
all_assets,__ = content_store.get_all_content_for_course(course_location)
self.assertGreater(len(all_assets), 0)
# make sure we have some thumbnails in our contentstore
@@ -698,7 +698,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
# make sure there's something in the trashcan
course_location = CourseDescriptor.id_to_location('edX/toy/6.002_Spring_2012')
all_assets = trash_store.get_all_content_for_course(course_location)
all_assets,__ = trash_store.get_all_content_for_course(course_location)
self.assertGreater(len(all_assets), 0)
# make sure we have some thumbnails in our trashcan
@@ -713,8 +713,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
empty_asset_trashcan([course_location])
# make sure trashcan is empty
all_assets = trash_store.get_all_content_for_course(course_location)
all_assets,count = trash_store.get_all_content_for_course(course_location)
self.assertEqual(len(all_assets), 0)
self.assertEqual(count, 0)
all_thumbnails = trash_store.get_all_content_thumbnails_for_course(course_location)
self.assertEqual(len(all_thumbnails), 0)
@@ -923,8 +924,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
self.assertEqual(len(items), 0)
# assert that all content in the asset library is also deleted
assets = content_store.get_all_content_for_course(location)
assets,count = content_store.get_all_content_for_course(location)
self.assertEqual(len(assets), 0)
self.assertEqual(count, 0)
def verify_content_existence(self, store, root_dir, location, dirname, category_name, filename_suffix=''):
filesystem = OSFS(root_dir / 'test_export')

View File

@@ -84,9 +84,10 @@ class ContentStoreImportNoStaticTest(ModuleStoreTestCase):
_, content_store, course, course_location = self.load_test_import_course()
# make sure we have ONE asset in our contentstore ("should_be_imported.html")
all_assets = content_store.get_all_content_for_course(course_location)
all_assets,count = content_store.get_all_content_for_course(course_location)
print "len(all_assets)=%d" % len(all_assets)
self.assertEqual(len(all_assets), 1)
self.assertEqual(count, 1)
content = None
try:
@@ -114,9 +115,10 @@ class ContentStoreImportNoStaticTest(ModuleStoreTestCase):
module_store.get_item(course_location)
# make sure we have NO assets in our contentstore
all_assets = content_store.get_all_content_for_course(course_location)
all_assets,count = content_store.get_all_content_for_course(course_location)
print "len(all_assets)=%d" % len(all_assets)
self.assertEqual(len(all_assets), 0)
self.assertEqual(count, 0)
def test_no_static_link_rewrites_on_import(self):
module_store = modulestore('direct')

View File

@@ -41,9 +41,10 @@ def assets_handler(request, tag=None, package_id=None, branch=None, version_guid
deleting assets, and changing the "locked" state of an asset.
GET
html: return html page of all course assets (note though that a range of assets can be requested using start
and max query parameters)
json: not currently supported
html: return html page which will show all course assets. Note that only the asset container
is returned and that the actual assets are filled in with a client-side request.
json: returns a page of assets. A page parameter specifies the desired page, and the
optional page_size parameter indicates the number of items per page (defaults to 50).
POST
json: create (or update?) an asset. The only updating that can be done is changing the lock state.
PUT
@@ -55,9 +56,10 @@ def assets_handler(request, tag=None, package_id=None, branch=None, version_guid
if not has_access(request.user, location):
raise PermissionDenied()
if 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'):
response_format = request.REQUEST.get('format', 'html')
if response_format == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'):
if request.method == 'GET':
raise NotImplementedError('coming soon')
return _assets_json(request, location)
else:
return _update_asset(request, location, asset_id)
elif request.method == 'GET': # assume html
@@ -73,22 +75,32 @@ def _asset_index(request, location):
Supports start (0-based index into the list of assets) and max query parameters.
"""
old_location = loc_mapper().translate_locator_to_location(location)
course_module = modulestore().get_item(old_location)
maxresults = request.REQUEST.get('max', None)
start = request.REQUEST.get('start', None)
return render_to_response('asset_index.html', {
'context_course': course_module,
'asset_callback_url': location.url_reverse('assets/', '')
})
def _assets_json(request, location):
"""
Display an editable asset library.
Supports start (0-based index into the list of assets) and max query parameters.
"""
requested_page = int(request.REQUEST.get('page', 0))
requested_page_size = int(request.REQUEST.get('page_size', 50))
current_page = max(requested_page, 0)
start = current_page * requested_page_size
old_location = loc_mapper().translate_locator_to_location(location)
course_reference = StaticContent.compute_location(old_location.org, old_location.course, old_location.name)
if maxresults is not None:
maxresults = int(maxresults)
start = int(start) if start else 0
assets = contentstore().get_all_content_for_course(
course_reference, start=start, maxresults=maxresults,
sort=[('uploadDate', DESCENDING)]
)
else:
assets = contentstore().get_all_content_for_course(
course_reference, sort=[('uploadDate', DESCENDING)]
)
assets, total_count = contentstore().get_all_content_for_course(
course_reference, start=start, maxresults=requested_page_size, sort=[('uploadDate', DESCENDING)]
)
end = start + len(assets)
asset_json = []
for asset in assets:
@@ -101,10 +113,13 @@ def _asset_index(request, 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,
'asset_list': json.dumps(asset_json),
'asset_callback_url': location.url_reverse('assets/', '')
return JsonResponse({
'start': start,
'end': end,
'page': current_page,
'pageSize': requested_page_size,
'totalCount': total_count,
'assets': asset_json
})