Add the ability to lock assets.

This commit is contained in:
cahrens
2013-09-19 15:50:56 -04:00
parent 78829a37b5
commit c64e04d8c2
13 changed files with 690 additions and 325 deletions

View File

@@ -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')

View File

@@ -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'):

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
"""
def get_asset_location(asset_id):
""" Helper method to get the location (and verify it is valid). """
try:
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)
# 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:
if request.method == 'DELETE':
loc = get_asset_location(asset_id)
# Make sure the item to delete actually exists.
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)
content = contentstore().find(loc)
except NotFoundError:
return JsonResponse(status=404)
# delete the original
contentstore().delete(content.get_id())
# remove from cache
del_cached_content(content.location)
return JsonResponse()
# 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):
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
}