Asset support in split
LMS-2876
This commit is contained in:
@@ -50,10 +50,15 @@ class StaticContentServer(object):
|
||||
if getattr(content, "locked", False):
|
||||
if not hasattr(request, "user") or not request.user.is_authenticated():
|
||||
return HttpResponseForbidden('Unauthorized')
|
||||
if not request.user.is_staff and not CourseEnrollment.is_enrolled_by_partial(
|
||||
if not request.user.is_staff:
|
||||
if getattr(loc, 'deprecated', False) and not CourseEnrollment.is_enrolled_by_partial(
|
||||
request.user, loc.course_key
|
||||
):
|
||||
return HttpResponseForbidden('Unauthorized')
|
||||
):
|
||||
return HttpResponseForbidden('Unauthorized')
|
||||
if not getattr(loc, 'deprecated', False) and not CourseEnrollment.is_enrolled(
|
||||
request.user, loc.course_key
|
||||
):
|
||||
return HttpResponseForbidden('Unauthorized')
|
||||
|
||||
# convert over the DB persistent last modified timestamp to a HTTP compatible
|
||||
# timestamp, so we can simply compare the strings
|
||||
|
||||
@@ -4,8 +4,6 @@ Tests for StaticContentServer
|
||||
import copy
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
from path import path
|
||||
from pymongo import MongoClient
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.conf import settings
|
||||
@@ -74,7 +72,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
def tearDown(self):
|
||||
|
||||
MongoClient().drop_database(TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'])
|
||||
contentstore().drop_database()
|
||||
_CONTENTSTORE.clear()
|
||||
|
||||
def test_unlocked_asset(self):
|
||||
|
||||
@@ -186,11 +186,10 @@ def reset_databases(scenario):
|
||||
whereas modulestore data is in unique collection names. This data is created implicitly during the scenarios.
|
||||
If no data is created during the test, these lines equivilently do nothing.
|
||||
'''
|
||||
mongo = MongoClient()
|
||||
mongo.drop_database(settings.CONTENTSTORE['DOC_STORE_CONFIG']['db'])
|
||||
modulestore = xmodule.modulestore.django.modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.mongo)
|
||||
modulestore.contentstore.drop_database()
|
||||
_CONTENTSTORE.clear()
|
||||
|
||||
modulestore = xmodule.modulestore.django.modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.mongo)
|
||||
modulestore.collection.drop()
|
||||
xmodule.modulestore.django.clear_existing_modulestores()
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ import StringIO
|
||||
from urlparse import urlparse, urlunparse, parse_qsl
|
||||
from urllib import urlencode
|
||||
|
||||
from opaque_keys.edx.locations import AssetLocation, SlashSeparatedCourseKey
|
||||
from .django import contentstore
|
||||
from opaque_keys.edx.locations import AssetLocation
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from PIL import Image
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ class StaticContent(object):
|
||||
return self.location
|
||||
|
||||
def get_url_path(self):
|
||||
return self.location.to_deprecated_string()
|
||||
return self._key_to_string(self.location)
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
@@ -103,14 +103,16 @@ class StaticContent(object):
|
||||
if course_key is None:
|
||||
return None
|
||||
|
||||
assert(isinstance(course_key, SlashSeparatedCourseKey))
|
||||
return course_key.make_asset_key('asset', '').to_deprecated_string()
|
||||
assert(isinstance(course_key, CourseKey))
|
||||
return StaticContent._key_to_string(course_key.make_asset_key('asset', ''))
|
||||
|
||||
@staticmethod
|
||||
def get_location_from_path(path):
|
||||
"""
|
||||
Generate an AssetKey for the given path (old c4x/org/course/asset/name syntax)
|
||||
"""
|
||||
# TODO OpaqueKey - change to from_string once opaque keys lands
|
||||
# return AssetLocation.from_string(path)
|
||||
return AssetLocation.from_deprecated_string(path)
|
||||
|
||||
@staticmethod
|
||||
@@ -122,7 +124,7 @@ class StaticContent(object):
|
||||
# Generate url of urlparse.path component
|
||||
scheme, netloc, orig_path, params, query, fragment = urlparse(path)
|
||||
loc = StaticContent.compute_location(course_id, orig_path)
|
||||
loc_url = loc.to_deprecated_string()
|
||||
loc_url = StaticContent._key_to_string(loc)
|
||||
|
||||
# parse the query params for "^/static/" and replace with the location url
|
||||
orig_query = parse_qsl(query)
|
||||
@@ -133,7 +135,7 @@ class StaticContent(object):
|
||||
course_id,
|
||||
query_value[len('/static/'):],
|
||||
)
|
||||
new_query_url = new_query.to_deprecated_string()
|
||||
new_query_url = StaticContent._key_to_string(new_query)
|
||||
new_query_list.append((query_name, new_query_url))
|
||||
else:
|
||||
new_query_list.append((query_name, query_value))
|
||||
@@ -144,6 +146,15 @@ class StaticContent(object):
|
||||
def stream_data(self):
|
||||
yield self._data
|
||||
|
||||
@staticmethod
|
||||
def _key_to_string(key):
|
||||
"""Converts the given key to a string, honoring the deprecated flag."""
|
||||
# TODO OpaqueKey - remove deprecated check once opaque keys lands
|
||||
if getattr(key, 'deprecated', False):
|
||||
return key.to_deprecated_string()
|
||||
else:
|
||||
return unicode(key)
|
||||
|
||||
|
||||
class StaticContentStream(StaticContent):
|
||||
def __init__(self, loc, name, content_type, stream, last_modified_at=None, thumbnail_location=None, import_path=None,
|
||||
@@ -213,6 +224,12 @@ class ContentStore(object):
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def copy_all_course_assets(self, source_course_key, dest_course_key):
|
||||
"""
|
||||
Copy all the course assets from source_course_key to dest_course_key
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def generate_thumbnail(self, content, tempfile_path=None):
|
||||
thumbnail_content = None
|
||||
# use a naming convention to associate originals with the thumbnail
|
||||
@@ -248,7 +265,7 @@ class ContentStore(object):
|
||||
thumbnail_content = StaticContent(thumbnail_file_location, thumbnail_name,
|
||||
'image/jpeg', thumbnail_file)
|
||||
|
||||
contentstore().save(thumbnail_content)
|
||||
self.save(thumbnail_content)
|
||||
|
||||
except Exception, e:
|
||||
# log and continue as thumbnails are generally considered as optional
|
||||
|
||||
@@ -2,7 +2,6 @@ import pymongo
|
||||
import gridfs
|
||||
from gridfs.errors import NoFile
|
||||
|
||||
from xmodule.modulestore.mongo.base import location_to_query, MongoModuleStore
|
||||
from xmodule.contentstore.content import XASSET_LOCATION_TAG
|
||||
|
||||
import logging
|
||||
@@ -13,10 +12,11 @@ from fs.osfs import OSFS
|
||||
import os
|
||||
import json
|
||||
from bson.son import SON
|
||||
from opaque_keys.edx.locations import AssetLocation
|
||||
from opaque_keys.edx.locations import AssetLocation, SlashSeparatedCourseKey
|
||||
|
||||
|
||||
class MongoContentStore(ContentStore):
|
||||
|
||||
# pylint: disable=W0613
|
||||
def __init__(self, host, db, port=27017, user=None, password=None, bucket='fs', collection=None, **kwargs):
|
||||
"""
|
||||
@@ -42,15 +42,29 @@ class MongoContentStore(ContentStore):
|
||||
|
||||
self.fs_files = _db[bucket + ".files"] # the underlying collection GridFS uses
|
||||
|
||||
def save(self, content):
|
||||
content_id = self.asset_db_key(content.location)
|
||||
# TODO OpaqueKey - remove after merge of opaque urls
|
||||
if not hasattr(AssetLocation, 'deprecated'):
|
||||
setattr(AssetLocation, 'deprecated', True)
|
||||
setattr(SlashSeparatedCourseKey, 'deprecated', True)
|
||||
|
||||
# Seems like with the GridFS we can't update existing ID's we have to do a delete/add pair
|
||||
self.delete(content_id)
|
||||
def drop_database(self):
|
||||
"""
|
||||
Only for use by test code. Removes the database!
|
||||
"""
|
||||
self.fs_files.database.connection.close()
|
||||
self.fs_files.database.connection.drop_database(self.fs_files.database)
|
||||
|
||||
def save(self, content):
|
||||
content_id, content_son = self.asset_db_key(content.location)
|
||||
|
||||
# The way to version files in gridFS is to not use the file id as the _id but just as the filename.
|
||||
# Then you can upload as many versions as you like and access by date or version. Because we use
|
||||
# the location as the _id, we must delete before adding (there's no replace method in gridFS)
|
||||
self.delete(content_id) # delete is a noop if the entry doesn't exist; so, don't waste time checking
|
||||
|
||||
thumbnail_location = content.thumbnail_location.to_deprecated_list_repr() if content.thumbnail_location else None
|
||||
with self.fs.new_file(_id=content_id, filename=content.get_url_path(), content_type=content.content_type,
|
||||
displayname=content.name,
|
||||
displayname=content.name, content_son=content_son,
|
||||
thumbnail_location=thumbnail_location,
|
||||
import_path=content.import_path,
|
||||
# getattr b/c caching may mean some pickled instances don't have attr
|
||||
@@ -65,12 +79,13 @@ class MongoContentStore(ContentStore):
|
||||
|
||||
def delete(self, location_or_id):
|
||||
if isinstance(location_or_id, AssetLocation):
|
||||
location_or_id = self.asset_db_key(location_or_id)
|
||||
location_or_id, __ = self.asset_db_key(location_or_id)
|
||||
|
||||
# Deletes of non-existent files are considered successful
|
||||
self.fs.delete(location_or_id)
|
||||
|
||||
def find(self, location, throw_on_not_found=True, as_stream=False):
|
||||
content_id = self.asset_db_key(location)
|
||||
content_id, __ = self.asset_db_key(location)
|
||||
|
||||
try:
|
||||
if as_stream:
|
||||
@@ -101,21 +116,6 @@ class MongoContentStore(ContentStore):
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_stream(self, location):
|
||||
content_id = self.asset_db_key(location)
|
||||
try:
|
||||
handle = self.fs.get(content_id)
|
||||
except NoFile:
|
||||
raise NotFoundError()
|
||||
|
||||
return handle
|
||||
|
||||
def close_stream(self, handle):
|
||||
try:
|
||||
handle.close()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
|
||||
def export(self, location, output_directory):
|
||||
content = self.find(location)
|
||||
|
||||
@@ -145,7 +145,9 @@ class MongoContentStore(ContentStore):
|
||||
assets, __ = self.get_all_content_for_course(course_key)
|
||||
|
||||
for asset in assets:
|
||||
asset_location = AssetLocation._from_deprecated_son(asset['_id'], course_key.run) # pylint: disable=protected-access
|
||||
asset_id = asset.get('content_son', asset['_id'])
|
||||
# assuming course_key's deprecated flag is controlling rather than presence or absence of 'run' in _id
|
||||
asset_location = course_key.make_asset_key(asset_id['category'], asset_id['name'])
|
||||
# TODO: On 6/19/14, I had to put a try/except around this
|
||||
# to export a course. The course failed on JSON files in
|
||||
# the /static/ directory placed in it with an import.
|
||||
@@ -190,18 +192,15 @@ class MongoContentStore(ContentStore):
|
||||
|
||||
]
|
||||
'''
|
||||
course_filter = course_key.make_asset_key(
|
||||
"asset" if not get_thumbnails else "thumbnail",
|
||||
None
|
||||
)
|
||||
# 'borrow' the function 'location_to_query' from the Mongo modulestore implementation
|
||||
if maxresults > 0:
|
||||
items = self.fs_files.find(
|
||||
location_to_query(course_filter, wildcard=True, tag=XASSET_LOCATION_TAG),
|
||||
query_for_course(course_key, "asset" if not get_thumbnails else "thumbnail"),
|
||||
skip=start, limit=maxresults, sort=sort
|
||||
)
|
||||
else:
|
||||
items = self.fs_files.find(location_to_query(course_filter, wildcard=True, tag=XASSET_LOCATION_TAG), sort=sort)
|
||||
items = self.fs_files.find(
|
||||
query_for_course(course_key, "asset" if not get_thumbnails else "thumbnail"), sort=sort
|
||||
)
|
||||
count = items.count()
|
||||
return list(items), count
|
||||
|
||||
@@ -243,7 +242,7 @@ class MongoContentStore(ContentStore):
|
||||
for attr in attr_dict.iterkeys():
|
||||
if attr in ['_id', 'md5', 'uploadDate', 'length']:
|
||||
raise AttributeError("{} is a protected attribute.".format(attr))
|
||||
asset_db_key = self.asset_db_key(location)
|
||||
asset_db_key, __ = self.asset_db_key(location)
|
||||
# catch upsert error and raise NotFoundError if asset doesn't exist
|
||||
result = self.fs_files.update({'_id': asset_db_key}, {"$set": attr_dict}, upsert=False)
|
||||
if not result.get('updatedExisting', True):
|
||||
@@ -259,30 +258,117 @@ class MongoContentStore(ContentStore):
|
||||
|
||||
:param location: a c4x asset location
|
||||
"""
|
||||
asset_db_key = self.asset_db_key(location)
|
||||
asset_db_key, __ = self.asset_db_key(location)
|
||||
item = self.fs_files.find_one({'_id': asset_db_key})
|
||||
if item is None:
|
||||
raise NotFoundError(asset_db_key)
|
||||
return item
|
||||
|
||||
def copy_all_course_assets(self, source_course_key, dest_course_key):
|
||||
"""
|
||||
See :meth:`.ContentStore.copy_all_course_assets`
|
||||
|
||||
This implementation fairly expensively copies all of the data
|
||||
"""
|
||||
source_query = query_for_course(source_course_key)
|
||||
# it'd be great to figure out how to do all of this on the db server and not pull the bits over
|
||||
for asset in self.fs_files.find(source_query):
|
||||
asset_key = self.make_id_son(asset)
|
||||
# don't convert from string until fs access
|
||||
source_content = self.fs.get(asset_key)
|
||||
if isinstance(asset_key, basestring):
|
||||
asset_key = AssetLocation.from_string(asset_key)
|
||||
__, asset_key = self.asset_db_key(asset_key)
|
||||
asset_key['org'] = dest_course_key.org
|
||||
asset_key['course'] = dest_course_key.course
|
||||
if getattr(dest_course_key, 'deprecated', False): # remove the run if exists
|
||||
if 'run' in asset_key:
|
||||
del asset_key['run']
|
||||
asset_id = asset_key
|
||||
else: # add the run, since it's the last field, we're golden
|
||||
asset_key['run'] = dest_course_key.run
|
||||
asset_id = unicode(dest_course_key.make_asset_key(asset_key['category'], asset_key['name']))
|
||||
|
||||
self.fs.put(
|
||||
source_content.read(),
|
||||
_id=asset_id, filename=asset['filename'], content_type=asset['contentType'],
|
||||
displayname=asset['displayname'], content_son=asset_key,
|
||||
# thumbnail is not technically correct but will be functionally correct as the code
|
||||
# only looks at the name which is not course relative.
|
||||
thumbnail_location=asset['thumbnail_location'],
|
||||
import_path=asset['import_path'],
|
||||
# getattr b/c caching may mean some pickled instances don't have attr
|
||||
locked=asset.get('locked', False)
|
||||
)
|
||||
|
||||
def delete_all_course_assets(self, course_key):
|
||||
"""
|
||||
Delete all assets identified via this course_key. Dangerous operation which may remove assets
|
||||
referenced by other runs or other courses.
|
||||
:param course_key:
|
||||
"""
|
||||
course_query = MongoModuleStore._course_key_to_son(course_key, tag=XASSET_LOCATION_TAG) # pylint: disable=protected-access
|
||||
course_query = query_for_course(course_key)
|
||||
matching_assets = self.fs_files.find(course_query)
|
||||
for asset in matching_assets:
|
||||
self.fs.delete(asset['_id'])
|
||||
asset_key = self.make_id_son(asset)
|
||||
self.fs.delete(asset_key)
|
||||
|
||||
@staticmethod
|
||||
def asset_db_key(location):
|
||||
# codifying the original order which pymongo used for the dicts coming out of location_to_dict
|
||||
# stability of order is more important than sanity of order as any changes to order make things
|
||||
# unfindable
|
||||
ordered_key_fields = ['category', 'name', 'course', 'tag', 'org', 'revision']
|
||||
|
||||
@classmethod
|
||||
def asset_db_key(cls, location):
|
||||
"""
|
||||
Returns the database query to find the given asset location.
|
||||
Returns the database _id and son structured lookup to find the given asset location.
|
||||
"""
|
||||
# codifying the original order which pymongo used for the dicts coming out of location_to_dict
|
||||
# stability of order is more important than sanity of order as any changes to order make things
|
||||
# unfindable
|
||||
ordered_key_fields = ['category', 'name', 'course', 'tag', 'org', 'revision']
|
||||
return SON((field_name, getattr(location, field_name)) for field_name in ordered_key_fields)
|
||||
dbkey = SON((field_name, getattr(location, field_name)) for field_name in cls.ordered_key_fields)
|
||||
if getattr(location, 'deprecated', False):
|
||||
content_id = dbkey
|
||||
else:
|
||||
# NOTE, there's no need to state that run doesn't exist in the negative case b/c access via
|
||||
# SON requires equivalence (same keys and values in exact same order)
|
||||
dbkey['run'] = location.run
|
||||
content_id = unicode(location)
|
||||
return content_id, dbkey
|
||||
|
||||
def make_id_son(self, fs_entry):
|
||||
"""
|
||||
Change the _id field in fs_entry into the properly ordered SON or string
|
||||
Args:
|
||||
fs_entry: the element returned by self.fs_files.find
|
||||
"""
|
||||
_id_field = fs_entry.get('_id', fs_entry)
|
||||
if isinstance(_id_field, basestring):
|
||||
return _id_field
|
||||
dbkey = SON((field_name, _id_field.get(field_name)) for field_name in self.ordered_key_fields)
|
||||
if 'run' in _id_field:
|
||||
# NOTE, there's no need to state that run doesn't exist in the negative case b/c access via
|
||||
# SON requires equivalence (same keys and values in exact same order)
|
||||
dbkey['run'] = _id_field['run']
|
||||
fs_entry['_id'] = dbkey
|
||||
return dbkey
|
||||
|
||||
|
||||
def query_for_course(course_key, category=None):
|
||||
"""
|
||||
Construct a SON object that will query for all assets possibly limited to the given type
|
||||
(thumbnail v assets) in the course using the index in mongo_indexes.md
|
||||
"""
|
||||
if getattr(course_key, 'deprecated', False):
|
||||
prefix = '_id'
|
||||
else:
|
||||
prefix = 'content_son'
|
||||
dbkey = SON([
|
||||
('{}.tag'.format(prefix), XASSET_LOCATION_TAG),
|
||||
('{}.org'.format(prefix), course_key.org),
|
||||
('{}.course'.format(prefix), course_key.course),
|
||||
])
|
||||
if category:
|
||||
dbkey['{}.category'.format(prefix)] = category
|
||||
if getattr(course_key, 'deprecated', False):
|
||||
dbkey['{}.run'.format(prefix)] = {'$exists': False}
|
||||
else:
|
||||
dbkey['{}.run'.format(prefix)] = course_key.run
|
||||
return dbkey
|
||||
|
||||
@@ -329,6 +329,23 @@ class ModuleStoreWrite(ModuleStoreRead):
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def clone_course(self, source_course_id, dest_course_id, user_id):
|
||||
"""
|
||||
Sets up source_course_id to point a course with the same content as the desct_course_id. This
|
||||
operation may be cheap or expensive. It may have to copy all assets and all xblock content or
|
||||
merely setup new pointers.
|
||||
|
||||
Backward compatibility: this method used to require in some modulestores that dest_course_id
|
||||
pointed to an empty but already created course. Implementers should support this or should
|
||||
enable creating the course from scratch.
|
||||
|
||||
Raises:
|
||||
ItemNotFoundError: if the source course doesn't exist (or any of its xblocks aren't found)
|
||||
DuplicateItemError: if the destination course already exists (with content in some cases)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_course(self, course_key, user_id=None):
|
||||
"""
|
||||
@@ -434,8 +451,10 @@ class ModuleStoreWriteBase(ModuleStoreReadBase, ModuleStoreWrite):
|
||||
'''
|
||||
Implement interface functionality that can be shared.
|
||||
'''
|
||||
def __init__(self, **kwargs):
|
||||
def __init__(self, contentstore, **kwargs):
|
||||
super(ModuleStoreWriteBase, self).__init__(**kwargs)
|
||||
|
||||
self.contentstore = contentstore
|
||||
# TODO: Don't have a runtime just to generate the appropriate mixin classes (cpennington)
|
||||
# This is only used by partition_fields_by_scope, which is only needed because
|
||||
# the split mongo store is used for item creation as well as item persistence
|
||||
@@ -501,6 +520,16 @@ class ModuleStoreWriteBase(ModuleStoreReadBase, ModuleStoreWrite):
|
||||
self.update_item(new_object, user_id, allow_not_found=True)
|
||||
return new_object
|
||||
|
||||
def clone_course(self, source_course_id, dest_course_id, user_id):
|
||||
"""
|
||||
This base method just copies the assets. The lower level impls must do the actual cloning of
|
||||
content.
|
||||
"""
|
||||
# copy the assets
|
||||
self.contentstore.copy_all_course_assets(source_course_id, dest_course_id)
|
||||
super(ModuleStoreWriteBase, self).clone_course(source_course_id, dest_course_id, user_id)
|
||||
return dest_course_id
|
||||
|
||||
|
||||
def only_xmodules(identifier, entry_points):
|
||||
"""Only use entry_points that are supplied by the xmodule package"""
|
||||
|
||||
@@ -17,6 +17,7 @@ import threading
|
||||
from xmodule.modulestore.loc_mapper_store import LocMapperStore
|
||||
from xmodule.util.django import get_current_request_hostname
|
||||
import xmodule.modulestore # pylint: disable=unused-import
|
||||
from xmodule.contentstore.django import contentstore
|
||||
|
||||
# We may not always have the request_cache module available
|
||||
try:
|
||||
@@ -37,7 +38,7 @@ def load_function(path):
|
||||
return getattr(import_module(module_path), name)
|
||||
|
||||
|
||||
def create_modulestore_instance(engine, doc_store_config, options, i18n_service=None):
|
||||
def create_modulestore_instance(engine, content_store, doc_store_config, options, i18n_service=None):
|
||||
"""
|
||||
This will return a new instance of a modulestore given an engine and options
|
||||
"""
|
||||
@@ -62,6 +63,7 @@ def create_modulestore_instance(engine, doc_store_config, options, i18n_service=
|
||||
metadata_inheritance_cache = get_cache('default')
|
||||
|
||||
return class_(
|
||||
contentstore=content_store,
|
||||
metadata_inheritance_cache_subsystem=metadata_inheritance_cache,
|
||||
request_cache=request_cache,
|
||||
xblock_mixins=getattr(settings, 'XBLOCK_MIXINS', ()),
|
||||
@@ -85,6 +87,7 @@ def modulestore():
|
||||
if _MIXED_MODULESTORE is None:
|
||||
_MIXED_MODULESTORE = create_modulestore_instance(
|
||||
settings.MODULESTORE['default']['ENGINE'],
|
||||
contentstore(),
|
||||
settings.MODULESTORE['default'].get('DOC_STORE_CONFIG', {}),
|
||||
settings.MODULESTORE['default'].get('OPTIONS', {})
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ from contextlib import contextmanager
|
||||
from opaque_keys import InvalidKeyError
|
||||
|
||||
from . import ModuleStoreWriteBase
|
||||
from xmodule.modulestore import PublishState
|
||||
from xmodule.modulestore import PublishState, ModuleStoreEnum, split_migrator
|
||||
from xmodule.modulestore.django import create_modulestore_instance, loc_mapper
|
||||
from opaque_keys.edx.locator import CourseLocator, BlockUsageLocator
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
@@ -29,12 +29,12 @@ class MixedModuleStore(ModuleStoreWriteBase):
|
||||
"""
|
||||
ModuleStore knows how to route requests to the right persistence ms
|
||||
"""
|
||||
def __init__(self, mappings, stores, i18n_service=None, **kwargs):
|
||||
def __init__(self, contentstore, mappings, stores, i18n_service=None, **kwargs):
|
||||
"""
|
||||
Initialize a MixedModuleStore. Here we look into our passed in kwargs which should be a
|
||||
collection of other modulestore configuration information
|
||||
"""
|
||||
super(MixedModuleStore, self).__init__(**kwargs)
|
||||
super(MixedModuleStore, self).__init__(contentstore, **kwargs)
|
||||
|
||||
self.modulestores = []
|
||||
self.mappings = {}
|
||||
@@ -61,6 +61,7 @@ class MixedModuleStore(ModuleStoreWriteBase):
|
||||
]
|
||||
store = create_modulestore_instance(
|
||||
store_settings['ENGINE'],
|
||||
self.contentstore,
|
||||
store_settings.get('DOC_STORE_CONFIG', {}),
|
||||
store_settings.get('OPTIONS', {}),
|
||||
i18n_service=i18n_service,
|
||||
@@ -295,6 +296,36 @@ class MixedModuleStore(ModuleStoreWriteBase):
|
||||
|
||||
return store.create_course(org, offering, user_id, fields, **kwargs)
|
||||
|
||||
def clone_course(self, source_course_id, dest_course_id, user_id):
|
||||
"""
|
||||
See the superclass for the general documentation.
|
||||
|
||||
If cloning w/in a store, delegates to that store's clone_course which, in order to be self-
|
||||
sufficient, should handle the asset copying (call the same method as this one does)
|
||||
If cloning between stores,
|
||||
* copy the assets
|
||||
* migrate the courseware
|
||||
"""
|
||||
source_modulestore = self._get_modulestore_for_courseid(source_course_id)
|
||||
# for a temporary period of time, we may want to hardcode dest_modulestore as split if there's a split
|
||||
# to have only course re-runs go to split. This code, however, uses the config'd priority
|
||||
dest_modulestore = self._get_modulestore_for_courseid(dest_course_id)
|
||||
if source_modulestore == dest_modulestore:
|
||||
return source_modulestore.clone_course(source_course_id, dest_course_id, user_id)
|
||||
|
||||
# ensure super's only called once. The delegation above probably calls it; so, don't move
|
||||
# the invocation above the delegation call
|
||||
super(MixedModuleStore, self).clone_course(source_course_id, dest_course_id, user_id)
|
||||
|
||||
if dest_modulestore.get_modulestore_type() == ModuleStoreEnum.Type.split:
|
||||
if not hasattr(self, 'split_migrator'):
|
||||
self.split_migrator = split_migrator.SplitMigrator(
|
||||
dest_modulestore, source_modulestore, loc_mapper()
|
||||
)
|
||||
self.split_migrator.migrate_mongo_course(
|
||||
source_course_id, user_id, dest_course_id.org, dest_course_id.offering
|
||||
)
|
||||
|
||||
def create_item(self, course_or_parent_loc, category, user_id=None, **kwargs):
|
||||
"""
|
||||
Create and return the item. If parent_loc is a specific location v a course id,
|
||||
@@ -460,6 +491,24 @@ class MixedModuleStore(ModuleStoreWriteBase):
|
||||
else:
|
||||
raise NotImplementedError(u"Cannot call {} on store {}".format(method, store))
|
||||
|
||||
@contextmanager
|
||||
def set_default_store(self, store_type):
|
||||
"""
|
||||
A context manager for temporarily changing the default store in the Mixed modulestore
|
||||
"""
|
||||
previous_store_list = self.modulestores
|
||||
found = False
|
||||
try:
|
||||
for i, store in enumerate(self.modulestores):
|
||||
if store.get_modulestore_type() == store_type:
|
||||
self.modulestores.insert(0, self.modulestores.pop(i))
|
||||
found = True
|
||||
yield
|
||||
if not found:
|
||||
raise Exception(u"Cannot find store of type {}".format(store_type))
|
||||
finally:
|
||||
self.modulestores = previous_store_list
|
||||
|
||||
|
||||
@contextmanager
|
||||
def store_branch_setting(store, branch_setting):
|
||||
|
||||
@@ -332,12 +332,12 @@ class MongoModuleStore(ModuleStoreWriteBase):
|
||||
"""
|
||||
A Mongodb backed ModuleStore
|
||||
"""
|
||||
reference_type = Location
|
||||
reference_type = SlashSeparatedCourseKey
|
||||
|
||||
# TODO (cpennington): Enable non-filesystem filestores
|
||||
# pylint: disable=C0103
|
||||
# pylint: disable=W0201
|
||||
def __init__(self, doc_store_config, fs_root, render_template,
|
||||
def __init__(self, contentstore, doc_store_config, fs_root, render_template,
|
||||
default_class=None,
|
||||
error_tracker=null_error_tracker,
|
||||
i18n_service=None,
|
||||
@@ -346,7 +346,7 @@ class MongoModuleStore(ModuleStoreWriteBase):
|
||||
:param doc_store_config: must have a host, db, and collection entries. Other common entries: port, tz_aware.
|
||||
"""
|
||||
|
||||
super(MongoModuleStore, self).__init__(**kwargs)
|
||||
super(MongoModuleStore, self).__init__(contentstore, **kwargs)
|
||||
|
||||
def do_connection(
|
||||
db, collection, host, port=27017, tz_aware=True, user=None, password=None, **kwargs
|
||||
@@ -857,7 +857,6 @@ class MongoModuleStore(ModuleStoreWriteBase):
|
||||
Raises:
|
||||
InvalidLocationError: If a course with the same org and offering already exists
|
||||
"""
|
||||
|
||||
course, _, run = offering.partition('/')
|
||||
course_id = SlashSeparatedCourseKey(org, course, run)
|
||||
|
||||
|
||||
@@ -7,15 +7,21 @@ and otherwise returns i4x://org/course/cat/name).
|
||||
"""
|
||||
|
||||
import pymongo
|
||||
import logging
|
||||
|
||||
from opaque_keys.edx.locations import Location
|
||||
from xmodule.exceptions import InvalidVersionError
|
||||
from xmodule.modulestore import PublishState, ModuleStoreEnum
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateItemError, InvalidBranchSetting
|
||||
from xmodule.modulestore.exceptions import (
|
||||
ItemNotFoundError, DuplicateItemError, InvalidBranchSetting, DuplicateCourseError
|
||||
)
|
||||
from xmodule.modulestore.mongo.base import (
|
||||
MongoModuleStore, MongoRevisionKey, as_draft, as_published,
|
||||
DIRECT_ONLY_CATEGORIES, SORT_REVISION_FAVOR_DRAFT
|
||||
)
|
||||
from opaque_keys.edx.locations import Location
|
||||
from xmodule.modulestore.store_utilities import rewrite_nonportable_content_links
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def wrap_draft(item):
|
||||
@@ -138,6 +144,73 @@ class DraftModuleStore(MongoModuleStore):
|
||||
del key['_id.revision']
|
||||
return self.collection.find(key).count() > 0
|
||||
|
||||
def clone_course(self, source_course_id, dest_course_id, user_id):
|
||||
"""
|
||||
Only called if cloning within this store or if env doesn't set up mixed.
|
||||
* copy the courseware
|
||||
"""
|
||||
# check to see if the source course is actually there
|
||||
if not self.has_course(source_course_id):
|
||||
raise ItemNotFoundError("Cannot find a course at {0}. Aborting".format(source_course_id))
|
||||
|
||||
# verify that the dest_location really is an empty course
|
||||
# b/c we don't want the payload, I'm copying the guts of get_items here
|
||||
query = self._course_key_to_son(dest_course_id)
|
||||
query['_id.category'] = {'$nin': ['course', 'about']}
|
||||
if self.collection.find(query).limit(1).count() > 0:
|
||||
raise DuplicateCourseError(
|
||||
dest_course_id,
|
||||
"Course at destination {0} is not an empty course. You can only clone into an empty course. Aborting...".format(
|
||||
dest_course_id
|
||||
)
|
||||
)
|
||||
|
||||
# clone the assets
|
||||
super(DraftModuleStore, self).clone_course(source_course_id, dest_course_id, user_id)
|
||||
|
||||
# get the whole old course
|
||||
new_course = self.get_course(dest_course_id)
|
||||
if new_course is None:
|
||||
# create_course creates the about overview
|
||||
new_course = self.create_course(dest_course_id.org, dest_course_id.offering, user_id)
|
||||
|
||||
# Get all modules under this namespace which is (tag, org, course) tuple
|
||||
modules = self.get_items(source_course_id, revision=ModuleStoreEnum.RevisionOption.published_only)
|
||||
self._clone_modules(modules, dest_course_id, user_id)
|
||||
course_location = dest_course_id.make_usage_key('course', dest_course_id.run)
|
||||
self.publish(course_location, user_id)
|
||||
|
||||
modules = self.get_items(source_course_id, revision=ModuleStoreEnum.RevisionOption.draft_only)
|
||||
self._clone_modules(modules, dest_course_id, user_id)
|
||||
|
||||
return True
|
||||
|
||||
def _clone_modules(self, modules, dest_course_id, user_id):
|
||||
"""Clones each module into the given course"""
|
||||
for module in modules:
|
||||
original_loc = module.location
|
||||
module.location = module.location.map_into_course(dest_course_id)
|
||||
if module.location.category == 'course':
|
||||
module.location = module.location.replace(name=module.location.run)
|
||||
|
||||
log.info("Cloning module %s to %s....", original_loc, module.location)
|
||||
|
||||
if 'data' in module.fields and module.fields['data'].is_set_on(module) and isinstance(module.data, basestring):
|
||||
module.data = rewrite_nonportable_content_links(
|
||||
original_loc.course_key, dest_course_id, module.data
|
||||
)
|
||||
|
||||
# repoint children
|
||||
if module.has_children:
|
||||
new_children = []
|
||||
for child_loc in module.children:
|
||||
child_loc = child_loc.map_into_course(dest_course_id)
|
||||
new_children.append(child_loc)
|
||||
|
||||
module.children = new_children
|
||||
|
||||
self.update_item(module, user_id, allow_not_found=True)
|
||||
|
||||
def _get_raw_parent_locations(self, location, key_revision):
|
||||
"""
|
||||
Get the parents but don't unset the revision in their locations.
|
||||
|
||||
@@ -15,10 +15,9 @@ class SplitMigrator(object):
|
||||
Copies courses from old mongo to split mongo and sets up location mapping so any references to the old
|
||||
name will be able to find the new elements.
|
||||
"""
|
||||
def __init__(self, split_modulestore, direct_modulestore, draft_modulestore, loc_mapper):
|
||||
def __init__(self, split_modulestore, draft_modulestore, loc_mapper):
|
||||
super(SplitMigrator, self).__init__()
|
||||
self.split_modulestore = split_modulestore
|
||||
self.direct_modulestore = direct_modulestore
|
||||
self.draft_modulestore = draft_modulestore
|
||||
self.loc_mapper = loc_mapper
|
||||
|
||||
@@ -43,7 +42,7 @@ class SplitMigrator(object):
|
||||
# locations are in location, children, conditionals, course.tab
|
||||
|
||||
# create the course: set fields to explicitly_set for each scope, id_root = new_course_locator, master_branch = 'production'
|
||||
original_course = self.direct_modulestore.get_course(course_key)
|
||||
original_course = self.draft_modulestore.get_course(course_key)
|
||||
new_course_root_locator = self.loc_mapper.translate_location(original_course.location)
|
||||
new_course = self.split_modulestore.create_course(
|
||||
new_course_root_locator.org, new_course_root_locator.offering, user.id,
|
||||
@@ -65,7 +64,7 @@ class SplitMigrator(object):
|
||||
|
||||
# iterate over published course elements. Wildcarding rather than descending b/c some elements are orphaned (e.g.,
|
||||
# course about pages, conditionals)
|
||||
for module in self.direct_modulestore.get_items(course_key):
|
||||
for module in self.draft_modulestore.get_items(course_key, revision=ModuleStoreEnum.RevisionOption.published_only):
|
||||
# don't copy the course again. No drafts should get here
|
||||
if module.location != old_course_loc:
|
||||
# create split_xblock using split.create_item
|
||||
|
||||
@@ -105,7 +105,8 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
reference_type = Locator
|
||||
def __init__(self, doc_store_config, fs_root, render_template,
|
||||
|
||||
def __init__(self, contentstore, doc_store_config, fs_root, render_template,
|
||||
default_class=None,
|
||||
error_tracker=null_error_tracker,
|
||||
loc_mapper=None,
|
||||
@@ -115,7 +116,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
:param doc_store_config: must have a host, db, and collection entries. Other common entries: port, tz_aware.
|
||||
"""
|
||||
|
||||
super(SplitMongoModuleStore, self).__init__(**kwargs)
|
||||
super(SplitMongoModuleStore, self).__init__(contentstore, **kwargs)
|
||||
self.loc_mapper = loc_mapper
|
||||
|
||||
self.db_connection = MongoConnection(**doc_store_config)
|
||||
@@ -870,6 +871,20 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
# reconstruct the new_item from the cache
|
||||
return self.get_item(item_loc)
|
||||
|
||||
def clone_course(self, source_course_id, dest_course_id, user_id):
|
||||
"""
|
||||
See :meth: `.ModuleStoreWrite.clone_course` for documentation.
|
||||
|
||||
In split, other than copying the assets, this is cheap as it merely creates a new version of the
|
||||
existing course.
|
||||
"""
|
||||
super(SplitMongoModuleStore, self).clone_course(source_course_id, dest_course_id, user_id)
|
||||
source_index = self.get_course_index_info(source_course_id)
|
||||
return self.create_course(
|
||||
dest_course_id.org, dest_course_id.offering, user_id, fields=None, # override start_date?
|
||||
versions_dict=source_index['versions']
|
||||
)
|
||||
|
||||
def create_course(
|
||||
self, org, offering, user_id, fields=None,
|
||||
master_branch=ModuleStoreEnum.BranchName.draft, versions_dict=None, root_category='course',
|
||||
|
||||
@@ -2,7 +2,6 @@ import re
|
||||
import logging
|
||||
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
|
||||
|
||||
def _prefix_only_url_replace_regex(prefix):
|
||||
@@ -88,91 +87,6 @@ def rewrite_nonportable_content_links(source_course_id, dest_course_id, text):
|
||||
return text
|
||||
|
||||
|
||||
def _clone_modules(modulestore, modules, source_course_id, dest_course_id, user_id):
|
||||
for module in modules:
|
||||
original_loc = module.location
|
||||
module.location = module.location.map_into_course(dest_course_id)
|
||||
if module.location.category == 'course':
|
||||
module.location = module.location.replace(name=module.location.run)
|
||||
|
||||
print "Cloning module {0} to {1}....".format(original_loc, module.location)
|
||||
|
||||
if 'data' in module.fields and module.fields['data'].is_set_on(module) and isinstance(module.data, basestring):
|
||||
module.data = rewrite_nonportable_content_links(
|
||||
source_course_id, dest_course_id, module.data
|
||||
)
|
||||
|
||||
# repoint children
|
||||
if module.has_children:
|
||||
new_children = []
|
||||
for child_loc in module.children:
|
||||
child_loc = child_loc.map_into_course(dest_course_id)
|
||||
new_children.append(child_loc)
|
||||
|
||||
module.children = new_children
|
||||
|
||||
modulestore.update_item(module, user_id, allow_not_found=True)
|
||||
|
||||
|
||||
def clone_course(modulestore, contentstore, source_course_id, dest_course_id, user_id):
|
||||
# check to see if the dest_location exists as an empty course
|
||||
# we need an empty course because the app layers manage the permissions and users
|
||||
if not modulestore.has_course(dest_course_id):
|
||||
raise Exception(u"An empty course at {0} must have already been created. Aborting...".format(dest_course_id))
|
||||
|
||||
# verify that the dest_location really is an empty course, which means only one with an optional 'overview'
|
||||
dest_modules = modulestore.get_items(dest_course_id)
|
||||
|
||||
for module in dest_modules:
|
||||
if module.location.category == 'course' or (
|
||||
module.location.category == 'about' and module.location.name == 'overview'
|
||||
):
|
||||
continue
|
||||
# only course and about overview allowed
|
||||
raise Exception("Course at destination {0} is not an empty course. You can only clone into an empty course. Aborting...".format(dest_course_id))
|
||||
|
||||
# check to see if the source course is actually there
|
||||
if not modulestore.has_course(source_course_id):
|
||||
raise Exception("Cannot find a course at {0}. Aborting".format(source_course_id))
|
||||
|
||||
# Get all modules under this namespace which is (tag, org, course) tuple
|
||||
modules = modulestore.get_items(source_course_id, revision=ModuleStoreEnum.RevisionOption.published_only)
|
||||
_clone_modules(modulestore, modules, source_course_id, dest_course_id, user_id)
|
||||
course_location = dest_course_id.make_usage_key('course', dest_course_id.run)
|
||||
modulestore.publish(course_location, user_id)
|
||||
|
||||
modules = modulestore.get_items(source_course_id, revision=ModuleStoreEnum.RevisionOption.draft_only)
|
||||
_clone_modules(modulestore, modules, source_course_id, dest_course_id, user_id)
|
||||
|
||||
# now iterate through all of the assets and clone them
|
||||
# first the thumbnails
|
||||
thumb_keys = contentstore.get_all_content_thumbnails_for_course(source_course_id)
|
||||
for thumb_key in thumb_keys:
|
||||
content = contentstore.find(thumb_key)
|
||||
content.location = content.location.map_into_course(dest_course_id)
|
||||
|
||||
print "Cloning thumbnail {0} to {1}".format(thumb_key, content.location)
|
||||
|
||||
contentstore.save(content)
|
||||
|
||||
# now iterate through all of the assets, also updating the thumbnail pointer
|
||||
|
||||
asset_keys, __ = contentstore.get_all_content_for_course(source_course_id)
|
||||
for asset_key in asset_keys:
|
||||
content = contentstore.find(asset_key)
|
||||
content.location = content.location.map_into_course(dest_course_id)
|
||||
|
||||
# be sure to update the pointer to the thumbnail
|
||||
if content.thumbnail_location is not None:
|
||||
content.thumbnail_location = content.thumbnail_location.map_into_course(dest_course_id)
|
||||
|
||||
print "Cloning asset {0} to {1}".format(asset_key, content.location)
|
||||
|
||||
contentstore.save(content)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def delete_course(modulestore, contentstore, course_key, commit=False):
|
||||
"""
|
||||
This method will actually do the work to delete all content in a course in a MongoDB backed
|
||||
|
||||
@@ -7,7 +7,6 @@ from django.test import TestCase
|
||||
from xmodule.modulestore.django import (
|
||||
modulestore, clear_existing_modulestores, loc_mapper)
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.contentstore.django import contentstore
|
||||
|
||||
|
||||
def mixed_store_config(data_dir, mappings):
|
||||
@@ -160,10 +159,8 @@ class ModuleStoreTestCase(TestCase):
|
||||
connection.drop_database(store.db.name)
|
||||
connection.close()
|
||||
|
||||
if contentstore().fs_files:
|
||||
db = contentstore().fs_files.database
|
||||
db.connection.drop_database(db)
|
||||
db.connection.close()
|
||||
if hasattr(store, 'contentstore'):
|
||||
store.contentstore.drop_database()
|
||||
|
||||
location_mapper = loc_mapper()
|
||||
if location_mapper.db:
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
Test contentstore.mongo functionality
|
||||
"""
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
import unittest
|
||||
import mimetypes
|
||||
from tempfile import mkdtemp
|
||||
import path
|
||||
import shutil
|
||||
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey, AssetLocation
|
||||
from xmodule.tests import DATA_DIR
|
||||
from xmodule.contentstore.mongo import MongoContentStore
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.exceptions import NotFoundError
|
||||
import ddt
|
||||
from __builtin__ import delattr
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
HOST = 'localhost'
|
||||
PORT = 27017
|
||||
DB = 'test_mongo_%s' % uuid4().hex[:5]
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestContentstore(unittest.TestCase):
|
||||
"""
|
||||
Test the methods in contentstore.mongo using deprecated and non-deprecated keys
|
||||
"""
|
||||
|
||||
# don't use these 2 class vars as they restore behavior once the tests are done
|
||||
asset_deprecated = None
|
||||
ssck_deprecated = None
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
"""
|
||||
Restores deprecated values
|
||||
"""
|
||||
if cls.asset_deprecated is not None:
|
||||
setattr(AssetLocation, 'deprecated', cls.asset_deprecated)
|
||||
else:
|
||||
delattr(AssetLocation, 'deprecated')
|
||||
if cls.ssck_deprecated is not None:
|
||||
setattr(SlashSeparatedCourseKey, 'deprecated', cls.ssck_deprecated)
|
||||
else:
|
||||
delattr(SlashSeparatedCourseKey, 'deprecated')
|
||||
return super(TestContentstore, cls).tearDownClass()
|
||||
|
||||
def set_up_assets(self, deprecated):
|
||||
"""
|
||||
Setup contentstore w/ proper overriding of deprecated.
|
||||
"""
|
||||
# since MongoModuleStore and MongoContentStore are basically assumed to be together, create this class
|
||||
# as well
|
||||
self.contentstore = MongoContentStore(HOST, DB, port=PORT)
|
||||
self.addCleanup(self.contentstore.drop_database)
|
||||
|
||||
setattr(AssetLocation, 'deprecated', deprecated)
|
||||
setattr(SlashSeparatedCourseKey, 'deprecated', deprecated)
|
||||
|
||||
self.course1_key = SlashSeparatedCourseKey('test', 'asset_test', '2014_07')
|
||||
self.course2_key = SlashSeparatedCourseKey('test', 'asset_test2', '2014_07')
|
||||
|
||||
self.course1_files = ['contains.sh', 'picture1.jpg', 'picture2.jpg']
|
||||
self.course2_files = ['picture1.jpg', 'picture3.jpg', 'door_2.ogg']
|
||||
|
||||
def load_assets(course_key, files):
|
||||
locked = False
|
||||
for filename in files:
|
||||
asset_key = course_key.make_asset_key('asset', filename)
|
||||
self.save_asset(filename, asset_key, filename, locked)
|
||||
locked = not locked
|
||||
|
||||
load_assets(self.course1_key, self.course1_files)
|
||||
load_assets(self.course2_key, self.course2_files)
|
||||
|
||||
def save_asset(self, filename, asset_key, displayname, locked):
|
||||
"""
|
||||
Load and save the given file.
|
||||
"""
|
||||
with open("{}/static/{}".format(DATA_DIR, filename), "rb") as f:
|
||||
content = StaticContent(
|
||||
asset_key, displayname, mimetypes.guess_type(filename)[0], f.read(),
|
||||
locked=locked
|
||||
)
|
||||
self.contentstore.save(content)
|
||||
|
||||
@ddt.data(True, False)
|
||||
def test_delete(self, deprecated):
|
||||
"""
|
||||
Test that deleting assets works
|
||||
"""
|
||||
self.set_up_assets(deprecated)
|
||||
asset_key = self.course1_key.make_asset_key('asset', self.course1_files[0])
|
||||
self.contentstore.delete(asset_key)
|
||||
|
||||
with self.assertRaises(NotFoundError):
|
||||
self.contentstore.find(asset_key)
|
||||
|
||||
# ensure deleting a non-existent file is a noop
|
||||
self.contentstore.delete(asset_key)
|
||||
|
||||
@ddt.data(True, False)
|
||||
def test_find(self, deprecated):
|
||||
"""
|
||||
Test using find
|
||||
"""
|
||||
self.set_up_assets(deprecated)
|
||||
asset_key = self.course1_key.make_asset_key('asset', self.course1_files[0])
|
||||
self.assertIsNotNone(self.contentstore.find(asset_key), "Could not find {}".format(asset_key))
|
||||
|
||||
self.assertIsNotNone(self.contentstore.find(asset_key, as_stream=True), "Could not find {}".format(asset_key))
|
||||
|
||||
unknown_asset = self.course1_key.make_asset_key('asset', 'no_such_file.gif')
|
||||
with self.assertRaises(NotFoundError):
|
||||
self.contentstore.find(unknown_asset)
|
||||
self.assertIsNone(
|
||||
self.contentstore.find(unknown_asset, throw_on_not_found=False),
|
||||
"Found unknown asset {}".format(unknown_asset)
|
||||
)
|
||||
|
||||
@ddt.data(True, False)
|
||||
def test_export_for_course(self, deprecated):
|
||||
"""
|
||||
Test export
|
||||
"""
|
||||
self.set_up_assets(deprecated)
|
||||
root_dir = path.path(mkdtemp())
|
||||
try:
|
||||
self.contentstore.export_all_for_course(
|
||||
self.course1_key, root_dir,
|
||||
path.path(root_dir / "policy.json"),
|
||||
)
|
||||
for filename in self.course1_files:
|
||||
filepath = path.path(root_dir / filename)
|
||||
self.assertTrue(filepath.isfile(), "{} is not a file".format(filepath))
|
||||
for filename in self.course2_files:
|
||||
if filename not in self.course1_files:
|
||||
filepath = path.path(root_dir / filename)
|
||||
self.assertFalse(filepath.isfile(), "{} is unexpected exported a file".format(filepath))
|
||||
finally:
|
||||
shutil.rmtree(root_dir)
|
||||
|
||||
@ddt.data(True, False)
|
||||
def test_get_all_content(self, deprecated):
|
||||
"""
|
||||
Test get_all_content_for_course
|
||||
"""
|
||||
self.set_up_assets(deprecated)
|
||||
course1_assets, count = self.contentstore.get_all_content_for_course(self.course1_key)
|
||||
self.assertEqual(count, len(self.course1_files), course1_assets)
|
||||
for asset in course1_assets:
|
||||
if deprecated:
|
||||
parsed = AssetLocation.from_deprecated_string(asset['filename'])
|
||||
else:
|
||||
parsed = AssetLocation.from_string(asset['filename'])
|
||||
self.assertIn(parsed.name, self.course1_files)
|
||||
|
||||
course1_assets, __ = self.contentstore.get_all_content_for_course(self.course1_key, 1, 1)
|
||||
self.assertEqual(len(course1_assets), 1, course1_assets)
|
||||
|
||||
fake_course = SlashSeparatedCourseKey('test', 'fake', 'non')
|
||||
course_assets, count = self.contentstore.get_all_content_for_course(fake_course)
|
||||
self.assertEqual(count, 0)
|
||||
self.assertEqual(course_assets, [])
|
||||
|
||||
@ddt.data(True, False)
|
||||
def test_attrs(self, deprecated):
|
||||
"""
|
||||
Test setting and getting attrs
|
||||
"""
|
||||
self.set_up_assets(deprecated)
|
||||
for filename in self.course1_files:
|
||||
asset_key = self.course1_key.make_asset_key('asset', filename)
|
||||
prelocked = self.contentstore.get_attr(asset_key, 'locked', False)
|
||||
self.contentstore.set_attr(asset_key, 'locked', not prelocked)
|
||||
self.assertEqual(self.contentstore.get_attr(asset_key, 'locked', False), not prelocked)
|
||||
|
||||
@ddt.data(True, False)
|
||||
def test_copy_assets(self, deprecated):
|
||||
"""
|
||||
copy_all_course_assets
|
||||
"""
|
||||
self.set_up_assets(deprecated)
|
||||
dest_course = SlashSeparatedCourseKey('test', 'destination', 'copy')
|
||||
self.contentstore.copy_all_course_assets(self.course1_key, dest_course)
|
||||
for filename in self.course1_files:
|
||||
asset_key = self.course1_key.make_asset_key('asset', filename)
|
||||
dest_key = dest_course.make_asset_key('asset', filename)
|
||||
source = self.contentstore.find(asset_key)
|
||||
copied = self.contentstore.find(dest_key)
|
||||
for propname in ['name', 'content_type', 'length', 'locked']:
|
||||
self.assertEqual(getattr(source, propname), getattr(copied, propname))
|
||||
|
||||
__, count = self.contentstore.get_all_content_for_course(dest_course)
|
||||
self.assertEqual(count, len(self.course1_files))
|
||||
|
||||
@ddt.data(True, False)
|
||||
def test_delete_assets(self, deprecated):
|
||||
"""
|
||||
delete_all_course_assets
|
||||
"""
|
||||
self.set_up_assets(deprecated)
|
||||
self.contentstore.delete_all_course_assets(self.course1_key)
|
||||
__, count = self.contentstore.get_all_content_for_course(self.course1_key)
|
||||
self.assertEqual(count, 0)
|
||||
# ensure it didn't remove any from other course
|
||||
__, count = self.contentstore.get_all_content_for_course(self.course2_key)
|
||||
self.assertEqual(count, len(self.course2_files))
|
||||
@@ -210,7 +210,7 @@ class TestMixedModuleStore(LocMapperSetupSansDjango):
|
||||
if index > 0:
|
||||
store_configs[index], store_configs[0] = store_configs[0], store_configs[index]
|
||||
break
|
||||
self.store = MixedModuleStore(**self.options)
|
||||
self.store = MixedModuleStore(None, **self.options)
|
||||
self.addCleanup(self.store.close_all_connections)
|
||||
|
||||
# convert to CourseKeys
|
||||
@@ -518,7 +518,7 @@ def load_function(path):
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def create_modulestore_instance(engine, doc_store_config, options, i18n_service=None):
|
||||
def create_modulestore_instance(engine, contentstore, doc_store_config, options, i18n_service=None):
|
||||
"""
|
||||
This will return a new instance of a modulestore given an engine and options
|
||||
"""
|
||||
@@ -526,6 +526,7 @@ def create_modulestore_instance(engine, doc_store_config, options, i18n_service=
|
||||
|
||||
return class_(
|
||||
doc_store_config=doc_store_config,
|
||||
contentstore=contentstore,
|
||||
branch_setting_func=lambda: ModuleStoreEnum.Branch.draft_preferred,
|
||||
**options
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# pylint: disable=E1101
|
||||
# pylint: disable=W0212
|
||||
# pylint: disable=E0611
|
||||
from nose.tools import assert_equals, assert_raises, \
|
||||
assert_not_equals, assert_false, assert_true, assert_greater, assert_is_instance, assert_is_none
|
||||
@@ -101,6 +103,7 @@ class TestMongoModuleStore(unittest.TestCase):
|
||||
# Also test draft store imports
|
||||
#
|
||||
draft_store = DraftModuleStore(
|
||||
content_store,
|
||||
doc_store_config, FS_ROOT, RENDER_TEMPLATE,
|
||||
default_class=DEFAULT_CLASS,
|
||||
branch_setting_func=lambda: ModuleStoreEnum.Branch.draft_preferred
|
||||
@@ -145,6 +148,7 @@ class TestMongoModuleStore(unittest.TestCase):
|
||||
|
||||
def test_mongo_modulestore_type(self):
|
||||
store = MongoModuleStore(
|
||||
None,
|
||||
{'host': HOST, 'db': DB, 'collection': COLLECTION},
|
||||
FS_ROOT, RENDER_TEMPLATE, default_class=DEFAULT_CLASS
|
||||
)
|
||||
@@ -289,7 +293,7 @@ class TestMongoModuleStore(unittest.TestCase):
|
||||
# a bit overkill, could just do for content[0]
|
||||
for content in course_content:
|
||||
assert not content.get('locked', False)
|
||||
asset_key = AssetLocation._from_deprecated_son(content['_id'], location.run)
|
||||
asset_key = AssetLocation._from_deprecated_son(content.get('content_son', content['_id']), location.run)
|
||||
assert not TestMongoModuleStore.content_store.get_attr(asset_key, 'locked', False)
|
||||
attrs = TestMongoModuleStore.content_store.get_attrs(asset_key)
|
||||
assert_in('uploadDate', attrs)
|
||||
@@ -302,7 +306,10 @@ class TestMongoModuleStore(unittest.TestCase):
|
||||
TestMongoModuleStore.content_store.set_attrs(asset_key, {'miscel': 99})
|
||||
assert_equals(TestMongoModuleStore.content_store.get_attr(asset_key, 'miscel'), 99)
|
||||
|
||||
asset_key = AssetLocation._from_deprecated_son(course_content[0]['_id'], location.run)
|
||||
asset_key = AssetLocation._from_deprecated_son(
|
||||
course_content[0].get('content_son', course_content[0]['_id']),
|
||||
location.run
|
||||
)
|
||||
assert_raises(
|
||||
AttributeError, TestMongoModuleStore.content_store.set_attr, asset_key,
|
||||
'md5', 'ff1532598830e3feac91c2449eaa60d6'
|
||||
|
||||
@@ -23,7 +23,7 @@ class TestMigration(SplitWMongoCourseBoostrapper):
|
||||
# pylint: disable=W0142
|
||||
self.loc_mapper = LocMapperStore(test_location_mapper.TrivialCache(), **self.db_config)
|
||||
self.split_mongo.loc_mapper = self.loc_mapper
|
||||
self.migrator = SplitMigrator(self.split_mongo, self.old_mongo, self.draft_mongo, self.loc_mapper)
|
||||
self.migrator = SplitMigrator(self.split_mongo, self.draft_mongo, self.loc_mapper)
|
||||
|
||||
def tearDown(self):
|
||||
dbref = self.loc_mapper.db
|
||||
|
||||
@@ -1759,6 +1759,7 @@ def modulestore():
|
||||
|
||||
# pylint: disable=W0142
|
||||
SplitModuleTest.modulestore = class_(
|
||||
None, # contentstore
|
||||
SplitModuleTest.MODULESTORE['DOC_STORE_CONFIG'],
|
||||
**options
|
||||
)
|
||||
|
||||
@@ -49,14 +49,15 @@ class SplitWMongoCourseBoostrapper(unittest.TestCase):
|
||||
self.userid = random.getrandbits(32)
|
||||
super(SplitWMongoCourseBoostrapper, self).setUp()
|
||||
self.split_mongo = SplitMongoModuleStore(
|
||||
None,
|
||||
self.db_config,
|
||||
**self.modulestore_options
|
||||
)
|
||||
self.addCleanup(self.split_mongo.db.connection.close)
|
||||
self.addCleanup(self.tear_down_split)
|
||||
self.old_mongo = MongoModuleStore(self.db_config, **self.modulestore_options)
|
||||
self.old_mongo = MongoModuleStore(None, self.db_config, **self.modulestore_options)
|
||||
self.draft_mongo = DraftMongoModuleStore(
|
||||
self.db_config, branch_setting_func=lambda: ModuleStoreEnum.Branch.draft_preferred, **self.modulestore_options
|
||||
None, self.db_config, branch_setting_func=lambda: ModuleStoreEnum.Branch.draft_preferred, **self.modulestore_options
|
||||
)
|
||||
self.addCleanup(self.tear_down_mongo)
|
||||
self.old_course_key = None
|
||||
|
||||
@@ -85,6 +85,7 @@ def modulestore():
|
||||
|
||||
# pylint: disable=W0142
|
||||
ModuleStoreNoSettings.modulestore = class_(
|
||||
None, # contentstore
|
||||
ModuleStoreNoSettings.MODULESTORE['DOC_STORE_CONFIG'],
|
||||
**options
|
||||
)
|
||||
|
||||
@@ -19,15 +19,14 @@ from xmodule.errortracker import make_error_tracker, exc_info_to_str
|
||||
from xmodule.mako_module import MakoDescriptorSystem
|
||||
from xmodule.x_module import XMLParsingSystem, policy_key
|
||||
from xmodule.modulestore.xml_exporter import DEFAULT_CONTENT_FIELDS
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore import ModuleStoreEnum, ModuleStoreReadBase
|
||||
from xmodule.tabs import CourseTabList
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey, Location
|
||||
|
||||
from xblock.field_data import DictFieldData
|
||||
from xblock.runtime import DictKeyValueStore, IdGenerator
|
||||
|
||||
from . import ModuleStoreReadBase, Location, ModuleStoreEnum
|
||||
|
||||
from .exceptions import ItemNotFoundError
|
||||
from .inheritance import compute_inherited_metadata, inheriting_field_data
|
||||
@@ -720,7 +719,7 @@ class XMLModuleStore(ModuleStoreReadBase):
|
||||
except KeyError:
|
||||
raise ItemNotFoundError(usage_key)
|
||||
|
||||
def get_items(self, course_id, settings=None, content=None, **kwargs):
|
||||
def get_items(self, course_id, settings=None, content=None, revision=None, **kwargs):
|
||||
"""
|
||||
Returns:
|
||||
list of XModuleDescriptor instances for the matching items within the course with
|
||||
@@ -745,6 +744,9 @@ class XMLModuleStore(ModuleStoreReadBase):
|
||||
you can search dates by providing either a datetime for == (probably
|
||||
useless) or a tuple (">"|"<" datetime) for after or before, etc.
|
||||
"""
|
||||
if revision == ModuleStoreEnum.RevisionOption.draft_only:
|
||||
return []
|
||||
|
||||
items = []
|
||||
|
||||
category = kwargs.pop('category', None)
|
||||
|
||||
@@ -500,8 +500,7 @@ class Transcript(object):
|
||||
Delete asset by location and filename.
|
||||
"""
|
||||
try:
|
||||
content = Transcript.get_asset(location, filename)
|
||||
contentstore().delete(content.get_id())
|
||||
contentstore().delete(Transcript.asset_location(location, filename))
|
||||
log.info("Transcript asset %s was removed from store.", filename)
|
||||
except NotFoundError:
|
||||
pass
|
||||
|
||||
2
common/test/data/static/contains.sh
Normal file
2
common/test/data/static/contains.sh
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env zsh
|
||||
git log --all ^opaque-keys-merge-base --format=%H $1 | while read f; do git branch --contains $f; done | sort -u
|
||||
BIN
common/test/data/static/door_2.ogg
Normal file
BIN
common/test/data/static/door_2.ogg
Normal file
Binary file not shown.
BIN
common/test/data/static/picture1.jpg
Normal file
BIN
common/test/data/static/picture1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 357 KiB |
BIN
common/test/data/static/picture2.jpg
Normal file
BIN
common/test/data/static/picture2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 414 KiB |
BIN
common/test/data/static/picture3.jpg
Normal file
BIN
common/test/data/static/picture3.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
Reference in New Issue
Block a user