The V2 libraries project had a few past iterations which were never launched. This commit cleans up pieces from those which we don't need for the real Libraries Relaunch MVP in Sumac: * Remove ENABLE_LIBRARY_AUTHORING_MICROFRONTEND, LIBRARY_AUTHORING_FRONTEND_URL, and REDIRECT_TO_LIBRARY_AUTHORING_MICROFRONTEND, all of which are obsolete now that library authoring has been merged into https://github.com/openedx/frontend-app-authoring. More details on the new Content Libraries configuration settings are here: https://github.com/openedx/frontend-app-authoring/issues/1334 * Remove dangling support for syncing V2 (learning core-backed) library content using the LibraryContentBlock. This code was all based on an older understanding of V2 Content Libraries, where the libraries were smaller and versioned as a whole rather then versioned by-item. Reference to V2 libraries will be done on a per-block basis using the upstream/downstream system, described here: https://github.com/openedx/edx-platform/blob/master/docs/decisions/0020-upstream-downstream.rst It's important that we remove this support now so that OLX course authors don't stuble upon it and use it, which would be buggy and complicate future migrations. * Remove the "mode" parameter from LibraryContentBlock. The only supported mode was and is "random". We will not be adding any further modes. Going forward for V2, we will have an ItemBank block for randomizing items (regardless of source), which can be synthesized with upstream referenced as described above. Existing LibraryContentBlocks will be migrated. * Finally, some renamings: * LibraryContentBlock -> LegacyLibraryContentBlock * LibraryToolsService -> LegacyLibraryToolsService * LibrarySummary -> LegacyLibrarySummary Module names and the old OLX tag (library_content) are unchanged. Closes: https://github.com/openedx/frontend-app-authoring/issues/1115
1120 lines
46 KiB
Python
1120 lines
46 KiB
Python
"""
|
|
MixedModuleStore allows for aggregation between multiple modulestores.
|
|
|
|
In this way, courses can be served up via either SplitMongoModuleStore or MongoModuleStore.
|
|
|
|
"""
|
|
import functools
|
|
import itertools
|
|
import logging
|
|
from contextlib import contextmanager
|
|
|
|
from opaque_keys import InvalidKeyError
|
|
from opaque_keys.edx.keys import CourseKey
|
|
from opaque_keys.edx.locator import LibraryLocator
|
|
from openedx_events.content_authoring.data import CourseData, XBlockData
|
|
from openedx_events.content_authoring.signals import (
|
|
COURSE_CREATED,
|
|
XBLOCK_CREATED,
|
|
XBLOCK_DELETED,
|
|
XBLOCK_PUBLISHED,
|
|
XBLOCK_UPDATED
|
|
)
|
|
|
|
from django.utils.timezone import datetime, timezone
|
|
from xmodule.assetstore import AssetMetadata
|
|
|
|
from . import XMODULE_FIELDS_WITH_USAGE_KEYS, ModuleStoreWriteBase
|
|
from .draft_and_published import ModuleStoreDraftAndPublished
|
|
from .exceptions import DuplicateCourseError, ItemNotFoundError
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
def strip_key(func):
|
|
"""
|
|
A decorator for stripping version and branch information from return values that are, or contain, UsageKeys or
|
|
CourseKeys.
|
|
Additionally, the decorated function is called with an optional 'field_decorator' parameter that can be used
|
|
to strip any location(-containing) fields, which are not directly returned by the function.
|
|
|
|
The behavior can be controlled by passing 'remove_version' and 'remove_branch' booleans to the decorated
|
|
function's kwargs.
|
|
"""
|
|
@functools.wraps(func)
|
|
def inner(*args, **kwargs):
|
|
"""
|
|
Supported kwargs:
|
|
remove_version - If True, calls 'version_agnostic' on all return values, including those in lists and dicts.
|
|
remove_branch - If True, calls 'for_branch(None)' on all return values, including those in lists and dicts.
|
|
Note: The 'field_decorator' parameter passed to the decorated function is a function that honors the
|
|
values of these kwargs.
|
|
"""
|
|
|
|
# remove version and branch, by default
|
|
rem_vers = kwargs.pop('remove_version', True)
|
|
rem_branch = kwargs.pop('remove_branch', True)
|
|
|
|
# helper function for stripping individual values
|
|
def strip_key_func(val):
|
|
"""
|
|
Strips the version and branch information according to the settings of rem_vers and rem_branch.
|
|
Recursively calls this function if the given value has a 'location' attribute.
|
|
"""
|
|
retval = val
|
|
if rem_vers and hasattr(retval, 'version_agnostic'):
|
|
retval = retval.version_agnostic()
|
|
if rem_branch and hasattr(retval, 'for_branch'):
|
|
retval = retval.for_branch(None)
|
|
for field_name in XMODULE_FIELDS_WITH_USAGE_KEYS:
|
|
if hasattr(retval, field_name):
|
|
setattr(retval, field_name, strip_key_func(getattr(retval, field_name)))
|
|
return retval
|
|
|
|
# function for stripping both, collection of, and individual, values
|
|
def strip_key_collection(field_value):
|
|
"""
|
|
Calls strip_key_func for each element in the given value.
|
|
"""
|
|
if rem_vers or rem_branch:
|
|
if isinstance(field_value, list):
|
|
field_value = [strip_key_func(fv) for fv in field_value]
|
|
elif isinstance(field_value, dict):
|
|
for key, val in field_value.items():
|
|
field_value[key] = strip_key_func(val)
|
|
else:
|
|
field_value = strip_key_func(field_value)
|
|
return field_value
|
|
|
|
# call the decorated function
|
|
retval = func(field_decorator=strip_key_collection, *args, **kwargs)
|
|
|
|
# strip the return value
|
|
return strip_key_collection(retval)
|
|
|
|
return inner
|
|
|
|
|
|
def prepare_asides(func):
|
|
"""
|
|
A decorator to handle optional asides param
|
|
"""
|
|
@functools.wraps(func)
|
|
def wrapper(*args, **kwargs):
|
|
"""
|
|
Supported kwargs:
|
|
asides - list with connected asides data for the passed block
|
|
"""
|
|
if 'asides' in kwargs:
|
|
kwargs['asides'] = prepare_asides_to_store(kwargs['asides'])
|
|
return func(*args, **kwargs)
|
|
return wrapper
|
|
|
|
|
|
def prepare_asides_to_store(asides_source):
|
|
"""
|
|
Convert Asides Xblocks objects to the list of dicts (to store this information in MongoDB)
|
|
"""
|
|
asides = None
|
|
if asides_source:
|
|
asides = []
|
|
for asd in asides_source:
|
|
aside_fields = {}
|
|
for asd_field_key, asd_field_val in asd.fields.items():
|
|
aside_fields[asd_field_key] = asd_field_val.read_from(asd)
|
|
asides.append({
|
|
'aside_type': asd.scope_ids.block_type,
|
|
'fields': aside_fields
|
|
})
|
|
return asides
|
|
|
|
|
|
class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
|
|
"""
|
|
ModuleStore knows how to route requests to the right persistence ms
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
contentstore,
|
|
mappings,
|
|
stores,
|
|
i18n_service=None,
|
|
fs_service=None,
|
|
user_service=None,
|
|
create_modulestore_instance=None,
|
|
signal_handler=None,
|
|
**kwargs
|
|
):
|
|
"""
|
|
Initialize a MixedModuleStore. Here we look into our passed in kwargs which should be a
|
|
collection of other modulestore configuration information
|
|
"""
|
|
super().__init__(contentstore, **kwargs)
|
|
|
|
if create_modulestore_instance is None:
|
|
raise ValueError('MixedModuleStore constructor must be passed a create_modulestore_instance function')
|
|
|
|
self.modulestores = []
|
|
self.mappings = {}
|
|
|
|
for course_id, store_name in mappings.items():
|
|
try:
|
|
self.mappings[CourseKey.from_string(course_id)] = store_name
|
|
except InvalidKeyError:
|
|
log.exception("Invalid MixedModuleStore configuration. Unable to parse course_id %r", course_id)
|
|
continue
|
|
|
|
for store_settings in stores:
|
|
key = store_settings['NAME']
|
|
store = create_modulestore_instance(
|
|
store_settings['ENGINE'],
|
|
self.contentstore,
|
|
store_settings.get('DOC_STORE_CONFIG', {}),
|
|
store_settings.get('OPTIONS', {}),
|
|
i18n_service=i18n_service,
|
|
fs_service=fs_service,
|
|
user_service=user_service,
|
|
signal_handler=signal_handler,
|
|
)
|
|
# replace all named pointers to the store into actual pointers
|
|
for course_key, store_name in self.mappings.items():
|
|
if store_name == key:
|
|
self.mappings[course_key] = store
|
|
self.modulestores.append(store)
|
|
|
|
def _clean_locator_for_mapping(self, locator):
|
|
"""
|
|
In order for mapping to work, the locator must be minimal--no version, no branch--
|
|
as we never store one version or one branch in one ms and another in another ms.
|
|
|
|
:param locator: the CourseKey
|
|
"""
|
|
if hasattr(locator, 'version_agnostic'):
|
|
locator = locator.version_agnostic()
|
|
if hasattr(locator, 'branch'):
|
|
locator = locator.replace(branch=None)
|
|
return locator
|
|
|
|
def _get_modulestore_for_courselike(self, locator=None):
|
|
"""
|
|
For a given locator, look in the mapping table and see if it has been pinned
|
|
to a particular modulestore
|
|
|
|
If locator is None, returns the first (ordered) store as the default
|
|
"""
|
|
if locator is not None:
|
|
locator = self._clean_locator_for_mapping(locator)
|
|
mapping = self.mappings.get(locator, None)
|
|
if mapping is not None:
|
|
return mapping
|
|
else:
|
|
if isinstance(locator, LibraryLocator):
|
|
has_locator = lambda store: hasattr(store, 'has_library') and store.has_library(locator)
|
|
else:
|
|
has_locator = lambda store: store.has_course(locator)
|
|
for store in self.modulestores:
|
|
if has_locator(store):
|
|
self.mappings[locator] = store
|
|
return store
|
|
|
|
# return the default store
|
|
return self.default_modulestore
|
|
|
|
def _get_modulestore_by_type(self, modulestore_type):
|
|
"""
|
|
This method should only really be used by tests and migration scripts when necessary.
|
|
Returns the module store as requested by type. The type can be a value from ModuleStoreEnum.Type.
|
|
"""
|
|
for store in self.modulestores:
|
|
if store.get_modulestore_type() == modulestore_type:
|
|
return store
|
|
return None
|
|
|
|
def fill_in_run(self, course_key):
|
|
"""
|
|
Some course_keys are used without runs. This function calls the corresponding
|
|
fill_in_run function on the appropriate modulestore.
|
|
"""
|
|
store = self._get_modulestore_for_courselike(course_key)
|
|
if not hasattr(store, 'fill_in_run'):
|
|
return course_key
|
|
return store.fill_in_run(course_key)
|
|
|
|
def has_item(self, usage_key, **kwargs):
|
|
"""
|
|
Does the course include the xblock who's id is reference?
|
|
"""
|
|
store = self._get_modulestore_for_courselike(usage_key.course_key)
|
|
return store.has_item(usage_key, **kwargs)
|
|
|
|
@strip_key
|
|
def get_item(self, usage_key, depth=0, **kwargs): # lint-amnesty, pylint: disable=arguments-differ
|
|
"""
|
|
see parent doc
|
|
"""
|
|
store = self._get_modulestore_for_courselike(usage_key.course_key)
|
|
return store.get_item(usage_key, depth, **kwargs)
|
|
|
|
@strip_key
|
|
def get_items(self, course_key, **kwargs): # lint-amnesty, pylint: disable=arguments-differ
|
|
"""
|
|
Returns:
|
|
list of XModuleDescriptor instances for the matching items within the course with
|
|
the given course_key
|
|
|
|
NOTE: don't use this to look for courses
|
|
as the course_key is required. Use get_courses.
|
|
|
|
Args:
|
|
course_key (CourseKey): the course identifier
|
|
kwargs:
|
|
settings (dict): fields to look for which have settings scope. Follows same syntax
|
|
and rules as kwargs below
|
|
content (dict): fields to look for which have content scope. Follows same syntax and
|
|
rules as kwargs below.
|
|
qualifiers (dict): what to look for within the course.
|
|
Common qualifiers are ``category`` or any field name. if the target field is a list,
|
|
then it searches for the given value in the list not list equivalence.
|
|
Substring matching pass a regex object.
|
|
For some modulestores, ``name`` is another commonly provided key (Location based stores)
|
|
For some modulestores,
|
|
you can search by ``edited_by``, ``edited_on`` providing either a datetime for == (probably
|
|
useless) or a function accepting one arg to do inequality
|
|
"""
|
|
if not isinstance(course_key, CourseKey):
|
|
raise Exception("Must pass in a course_key when calling get_items()")
|
|
|
|
store = self._get_modulestore_for_courselike(course_key)
|
|
return store.get_items(course_key, **kwargs)
|
|
|
|
@strip_key
|
|
def get_course_summaries(self, **kwargs):
|
|
"""
|
|
Returns a list containing the course information in CourseSummary objects.
|
|
Information contains `location`, `display_name`, `locator` of the courses in this modulestore.
|
|
"""
|
|
course_summaries = {}
|
|
for store in self.modulestores:
|
|
for course_summary in store.get_course_summaries(**kwargs):
|
|
course_id = self._clean_locator_for_mapping(locator=course_summary.id)
|
|
|
|
# Check if course is indeed unique. Save it in result if unique
|
|
if course_id in course_summaries:
|
|
log.warning(
|
|
"Modulestore %s have duplicate courses %s; skipping from result.", store, course_id
|
|
)
|
|
else:
|
|
course_summaries[course_id] = course_summary
|
|
return list(course_summaries.values())
|
|
|
|
@strip_key
|
|
def get_courses(self, **kwargs):
|
|
'''
|
|
Returns a list containing the top level XModuleDescriptors of the courses in this modulestore.
|
|
'''
|
|
courses = {}
|
|
for store in self.modulestores:
|
|
# filter out ones which were fetched from earlier stores but locations may not be ==
|
|
for course in store.get_courses(**kwargs):
|
|
course_id = self._clean_locator_for_mapping(course.id)
|
|
if course_id not in courses:
|
|
# course is indeed unique. save it in result
|
|
courses[course_id] = course
|
|
return list(courses.values())
|
|
|
|
def get_library_keys(self):
|
|
"""
|
|
Returns a list of all unique content library keys in the mixed
|
|
modulestore.
|
|
|
|
Returns: list[LibraryLocator]
|
|
"""
|
|
all_library_keys = set()
|
|
for store in self.modulestores:
|
|
if not hasattr(store, 'get_library_keys'):
|
|
continue
|
|
all_library_keys |= {
|
|
self._clean_locator_for_mapping(library_key)
|
|
for library_key in store.get_library_keys()
|
|
}
|
|
return list(all_library_keys)
|
|
|
|
@strip_key
|
|
def get_library_summaries(self, **kwargs):
|
|
"""
|
|
Returns a list of LegacyLibrarySummary objects.
|
|
Information contains `location`, `display_name`, `locator` of the libraries in this modulestore.
|
|
"""
|
|
library_summaries = {}
|
|
for store in self.modulestores:
|
|
if not hasattr(store, 'get_libraries'):
|
|
continue
|
|
# fetch library summaries and filter out any duplicated entry across/within stores
|
|
for library_summary in store.get_library_summaries(**kwargs):
|
|
library_id = self._clean_locator_for_mapping(library_summary.location)
|
|
if library_id not in library_summaries:
|
|
library_summaries[library_id] = library_summary
|
|
return list(library_summaries.values())
|
|
|
|
@strip_key
|
|
def get_libraries(self, **kwargs):
|
|
"""
|
|
Returns a list containing the top level XBlock of the libraries (LibraryRoot) in this modulestore.
|
|
"""
|
|
libraries = {}
|
|
for store in self.modulestores:
|
|
if not hasattr(store, 'get_libraries'):
|
|
continue
|
|
# filter out ones which were fetched from earlier stores but locations may not be ==
|
|
for library in store.get_libraries(**kwargs):
|
|
library_id = self._clean_locator_for_mapping(library.location)
|
|
if library_id not in libraries:
|
|
# library is indeed unique. save it in result
|
|
libraries[library_id] = library
|
|
return list(libraries.values())
|
|
|
|
def make_course_key(self, org, course, run):
|
|
"""
|
|
Return a valid :class:`~opaque_keys.edx.keys.CourseKey` for this modulestore
|
|
that matches the supplied `org`, `course`, and `run`.
|
|
|
|
This key may represent a course that doesn't exist in this modulestore.
|
|
"""
|
|
# If there is a mapping that match this org/course/run, use that
|
|
for course_id, store in self.mappings.items():
|
|
candidate_key = store.make_course_key(org, course, run)
|
|
if candidate_key == course_id:
|
|
return candidate_key
|
|
|
|
# Otherwise, return the key created by the default store
|
|
return self.default_modulestore.make_course_key(org, course, run)
|
|
|
|
def make_course_usage_key(self, course_key):
|
|
"""
|
|
Return a valid :class:`~opaque_keys.edx.keys.UsageKey` for the modulestore
|
|
that matches the supplied course_key.
|
|
"""
|
|
assert isinstance(course_key, CourseKey)
|
|
store = self._get_modulestore_for_courselike(course_key)
|
|
return store.make_course_usage_key(course_key)
|
|
|
|
@strip_key
|
|
def get_course(self, course_key, depth=0, **kwargs): # lint-amnesty, pylint: disable=arguments-differ
|
|
"""
|
|
returns the course block associated with the course_id. If no such course exists,
|
|
it returns None
|
|
|
|
:param course_key: must be a CourseKey
|
|
"""
|
|
assert isinstance(course_key, CourseKey)
|
|
store = self._get_modulestore_for_courselike(course_key)
|
|
try:
|
|
return store.get_course(course_key, depth=depth, **kwargs)
|
|
except ItemNotFoundError:
|
|
return None
|
|
|
|
@strip_key
|
|
def get_library(self, library_key, depth=0, **kwargs):
|
|
"""
|
|
returns the library block associated with the given key. If no such library exists,
|
|
it returns None
|
|
|
|
:param library_key: must be a LibraryLocator
|
|
"""
|
|
try:
|
|
store = self._verify_modulestore_support(library_key, 'get_library')
|
|
return store.get_library(library_key, depth=depth, **kwargs)
|
|
except NotImplementedError:
|
|
log.exception("Modulestore configured for %s does not have get_library method", library_key)
|
|
return None
|
|
except ItemNotFoundError:
|
|
return None
|
|
|
|
@strip_key
|
|
def has_course(self, course_id, ignore_case=False, **kwargs):
|
|
"""
|
|
returns the course_id of the course if it was found, else None
|
|
Note: we return the course_id instead of a boolean here since the found course may have
|
|
a different id than the given course_id when ignore_case is True.
|
|
|
|
Args:
|
|
* course_id (CourseKey)
|
|
* ignore_case (bool): If True, do a case insensitive search. If
|
|
False, do a case sensitive search
|
|
"""
|
|
assert isinstance(course_id, CourseKey)
|
|
store = self._get_modulestore_for_courselike(course_id)
|
|
return store.has_course(course_id, ignore_case, **kwargs)
|
|
|
|
def delete_course(self, course_key, user_id): # lint-amnesty, pylint: disable=arguments-differ
|
|
"""
|
|
See xmodule.modulestore.__init__.ModuleStoreWrite.delete_course
|
|
"""
|
|
assert isinstance(course_key, CourseKey)
|
|
store = self._get_modulestore_for_courselike(course_key)
|
|
return store.delete_course(course_key, user_id)
|
|
|
|
def save_asset_metadata(self, asset_metadata, user_id, import_only=False):
|
|
"""
|
|
Saves the asset metadata for a particular course's asset.
|
|
|
|
Args:
|
|
asset_metadata (AssetMetadata): data about the course asset data
|
|
user_id (int|long): user ID saving the asset metadata
|
|
import_only (bool): True if importing without editing, False if editing
|
|
|
|
Returns:
|
|
True if info save was successful, else False
|
|
"""
|
|
store = self._get_modulestore_for_courselike(asset_metadata.asset_id.course_key)
|
|
return store.save_asset_metadata(asset_metadata, user_id, import_only)
|
|
|
|
def save_asset_metadata_list(self, asset_metadata_list, user_id, import_only=False):
|
|
"""
|
|
Saves the asset metadata for each asset in a list of asset metadata.
|
|
Optimizes the saving of many assets.
|
|
|
|
Args:
|
|
asset_metadata_list (list(AssetMetadata)): list of data about several course assets
|
|
user_id (int|long): user ID saving the asset metadata
|
|
import_only (bool): True if importing without editing, False if editing
|
|
|
|
Returns:
|
|
True if info save was successful, else False
|
|
"""
|
|
if len(asset_metadata_list) == 0:
|
|
return True
|
|
store = self._get_modulestore_for_courselike(asset_metadata_list[0].asset_id.course_key)
|
|
return store.save_asset_metadata_list(asset_metadata_list, user_id, import_only)
|
|
|
|
@strip_key
|
|
def find_asset_metadata(self, asset_key, **kwargs):
|
|
"""
|
|
Find the metadata for a particular course asset.
|
|
|
|
Args:
|
|
asset_key (AssetKey): locator containing original asset filename
|
|
|
|
Returns:
|
|
asset metadata (AssetMetadata) -or- None if not found
|
|
"""
|
|
store = self._get_modulestore_for_courselike(asset_key.course_key)
|
|
return store.find_asset_metadata(asset_key, **kwargs)
|
|
|
|
@strip_key
|
|
def get_all_asset_metadata(self, course_key, asset_type, start=0, maxresults=-1, sort=None, **kwargs):
|
|
"""
|
|
Returns a list of static assets for a course.
|
|
By default all assets are returned, but start and maxresults can be provided to limit the query.
|
|
|
|
Args:
|
|
course_key (CourseKey): course identifier
|
|
asset_type (str): type of asset, such as 'asset', 'video', etc. If None, return assets of all types.
|
|
start (int): optional - start at this asset number
|
|
maxresults (int): optional - return at most this many, -1 means no limit
|
|
sort (array): optional - None means no sort
|
|
(sort_by (str), sort_order (str))
|
|
sort_by - one of 'uploadDate' or 'displayname'
|
|
sort_order - one of 'ascending' or 'descending'
|
|
|
|
Returns:
|
|
List of AssetMetadata objects.
|
|
"""
|
|
store = self._get_modulestore_for_courselike(course_key)
|
|
return store.get_all_asset_metadata(course_key, asset_type, start, maxresults, sort, **kwargs)
|
|
|
|
def delete_asset_metadata(self, asset_key, user_id):
|
|
"""
|
|
Deletes a single asset's metadata.
|
|
|
|
Arguments:
|
|
asset_id (AssetKey): locator containing original asset filename
|
|
user_id (int_long): user deleting the metadata
|
|
|
|
Returns:
|
|
Number of asset metadata entries deleted (0 or 1)
|
|
"""
|
|
store = self._get_modulestore_for_courselike(asset_key.course_key)
|
|
return store.delete_asset_metadata(asset_key, user_id)
|
|
|
|
def copy_all_asset_metadata(self, source_course_key, dest_course_key, user_id):
|
|
"""
|
|
Copy all the course assets from source_course_key to dest_course_key.
|
|
|
|
Arguments:
|
|
source_course_key (CourseKey): identifier of course to copy from
|
|
dest_course_key (CourseKey): identifier of course to copy to
|
|
user_id (int|long): user copying the asset metadata
|
|
"""
|
|
source_store = self._get_modulestore_for_courselike(source_course_key)
|
|
dest_store = self._get_modulestore_for_courselike(dest_course_key)
|
|
if source_store != dest_store:
|
|
with self.bulk_operations(dest_course_key):
|
|
# Get all the asset metadata in the source course.
|
|
all_assets = source_store.get_all_asset_metadata(source_course_key, 'asset')
|
|
# Store it all in the dest course.
|
|
for asset in all_assets:
|
|
new_asset_key = dest_course_key.make_asset_key('asset', asset.asset_id.path)
|
|
copied_asset = AssetMetadata(new_asset_key)
|
|
copied_asset.from_storable(asset.to_storable())
|
|
dest_store.save_asset_metadata(copied_asset, user_id)
|
|
else:
|
|
# Courses in the same modulestore can be handled by the modulestore itself.
|
|
source_store.copy_all_asset_metadata(source_course_key, dest_course_key, user_id)
|
|
|
|
def set_asset_metadata_attr(self, asset_key, attr, value, user_id):
|
|
"""
|
|
Add/set the given attr on the asset at the given location. Value can be any type which pymongo accepts.
|
|
|
|
Arguments:
|
|
asset_key (AssetKey): asset identifier
|
|
attr (str): which attribute to set
|
|
value: the value to set it to (any type pymongo accepts such as datetime, number, string)
|
|
user_id: (int|long): user setting the attribute
|
|
|
|
Raises:
|
|
NotFoundError if no such item exists
|
|
AttributeError is attr is one of the build in attrs.
|
|
"""
|
|
store = self._get_modulestore_for_courselike(asset_key.course_key)
|
|
return store.set_asset_metadata_attrs(asset_key, {attr: value}, user_id)
|
|
|
|
def set_asset_metadata_attrs(self, asset_key, attr_dict, user_id): # lint-amnesty, pylint: disable=arguments-differ
|
|
"""
|
|
Add/set the given dict of attrs on the asset at the given location. Value can be any type which pymongo accepts.
|
|
|
|
Arguments:
|
|
asset_key (AssetKey): asset identifier
|
|
attr_dict (dict): attribute/value pairs to set
|
|
user_id: (int|long): user setting the attributes
|
|
|
|
Raises:
|
|
NotFoundError if no such item exists
|
|
AttributeError is attr is one of the build in attrs.
|
|
"""
|
|
store = self._get_modulestore_for_courselike(asset_key.course_key)
|
|
return store.set_asset_metadata_attrs(asset_key, attr_dict, user_id)
|
|
|
|
@strip_key
|
|
def get_parent_location(self, location, **kwargs): # lint-amnesty, pylint: disable=arguments-differ
|
|
"""
|
|
returns the parent locations for a given location
|
|
"""
|
|
store = self._get_modulestore_for_courselike(location.course_key)
|
|
return store.get_parent_location(location, **kwargs)
|
|
|
|
def get_block_original_usage(self, usage_key):
|
|
"""
|
|
If a block was inherited into another structure using copy_from_template,
|
|
this will return the original block usage locator from which the
|
|
copy was inherited.
|
|
"""
|
|
try:
|
|
store = self._verify_modulestore_support(usage_key.course_key, 'get_block_original_usage')
|
|
return store.get_block_original_usage(usage_key)
|
|
except NotImplementedError:
|
|
return None, None
|
|
|
|
def get_modulestore_type(self, course_id):
|
|
"""
|
|
Returns a type which identifies which modulestore is servicing the given course_id.
|
|
The return can be one of:
|
|
"xml" (for XML based courses),
|
|
"mongo" for old-style MongoDB backed courses,
|
|
"split" for new-style split MongoDB backed courses.
|
|
"""
|
|
return self._get_modulestore_for_courselike(course_id).get_modulestore_type()
|
|
|
|
@strip_key
|
|
def get_orphans(self, course_key, **kwargs):
|
|
"""
|
|
Get all of the xblocks in the given course which have no parents and are not of types which are
|
|
usually orphaned. NOTE: may include xblocks which still have references via xblocks which don't
|
|
use children to point to their dependents.
|
|
"""
|
|
store = self._get_modulestore_for_courselike(course_key)
|
|
return store.get_orphans(course_key, **kwargs)
|
|
|
|
def get_errored_courses(self):
|
|
"""
|
|
Return a dictionary of course_dir -> [(msg, exception_str)], for each
|
|
course_dir where course loading failed.
|
|
"""
|
|
errs = {}
|
|
for store in self.modulestores:
|
|
errs.update(store.get_errored_courses())
|
|
return errs
|
|
|
|
@strip_key
|
|
def create_course(self, org, course, run, user_id, **kwargs): # lint-amnesty, pylint: disable=arguments-differ
|
|
"""
|
|
Creates and returns the course.
|
|
|
|
Args:
|
|
org (str): the organization that owns the course
|
|
course (str): the name of the course
|
|
run (str): the name of the run
|
|
user_id: id of the user creating the course
|
|
fields (dict): Fields to set on the course at initialization
|
|
kwargs: Any optional arguments understood by a subset of modulestores to customize instantiation
|
|
|
|
Returns: a CourseBlock
|
|
"""
|
|
# first make sure an existing course doesn't already exist in the mapping
|
|
course_key = self.make_course_key(org, course, run)
|
|
|
|
log.info('Creating course run %s...', course_key)
|
|
if course_key in self.mappings and self.mappings[course_key].has_course(course_key):
|
|
log.error('Cannot create course run %s. It already exists!', course_key)
|
|
raise DuplicateCourseError(course_key, course_key)
|
|
|
|
# create the course
|
|
store = self._verify_modulestore_support(None, 'create_course')
|
|
course = store.create_course(org, course, run, user_id, **kwargs)
|
|
log.info('Course run %s created successfully!', course_key)
|
|
|
|
# add new course to the mapping
|
|
self.mappings[course_key] = store
|
|
|
|
# .. event_implemented_name: COURSE_CREATED
|
|
COURSE_CREATED.send_event(
|
|
time=datetime.now(timezone.utc),
|
|
course=CourseData(
|
|
course_key=course_key,
|
|
)
|
|
)
|
|
|
|
return course
|
|
|
|
@strip_key
|
|
def create_library(self, org, library, user_id, fields, **kwargs):
|
|
"""
|
|
Creates and returns a new library.
|
|
|
|
Args:
|
|
org (str): the organization that owns the course
|
|
library (str): the code/number/name of the library
|
|
user_id: id of the user creating the course
|
|
fields (dict): Fields to set on the course at initialization - e.g. display_name
|
|
kwargs: Any optional arguments understood by a subset of modulestores to customize instantiation
|
|
|
|
Returns: a LibraryRoot
|
|
"""
|
|
# first make sure an existing course/lib doesn't already exist in the mapping
|
|
lib_key = LibraryLocator(org=org, library=library)
|
|
if lib_key in self.mappings:
|
|
raise DuplicateCourseError(lib_key, lib_key)
|
|
|
|
# create the library
|
|
store = self._verify_modulestore_support(None, 'create_library')
|
|
library = store.create_library(org, library, user_id, fields, **kwargs)
|
|
|
|
# add new library to the mapping
|
|
self.mappings[lib_key] = store
|
|
|
|
return library
|
|
|
|
@strip_key
|
|
def clone_course(self, source_course_id, dest_course_id, user_id, fields=None, **kwargs):
|
|
"""
|
|
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_courselike(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_courselike(dest_course_id)
|
|
if source_modulestore == dest_modulestore:
|
|
return source_modulestore.clone_course(source_course_id, dest_course_id, user_id, fields, **kwargs)
|
|
|
|
raise NotImplementedError("No code for cloning from {} to {}".format(
|
|
source_modulestore, dest_modulestore
|
|
))
|
|
|
|
@strip_key
|
|
@prepare_asides
|
|
def create_item(self, user_id, course_key, block_type, block_id=None, fields=None, **kwargs):
|
|
"""
|
|
Creates and saves a new item in a course.
|
|
|
|
Returns the newly created item.
|
|
|
|
Args:
|
|
user_id: ID of the user creating and saving the xmodule
|
|
course_key: A :class:`~opaque_keys.edx.CourseKey` identifying which course to create
|
|
this item in
|
|
block_type: The typo of block to create
|
|
block_id: a unique identifier for the new item. If not supplied,
|
|
a new identifier will be generated
|
|
fields (dict): A dictionary specifying initial values for some or all fields
|
|
in the newly created block
|
|
"""
|
|
modulestore = self._verify_modulestore_support(course_key, 'create_item')
|
|
xblock = modulestore.create_item(user_id, course_key, block_type, block_id=block_id, fields=fields, **kwargs)
|
|
|
|
def send_created_event():
|
|
# .. event_implemented_name: XBLOCK_CREATED
|
|
XBLOCK_CREATED.send_event(
|
|
time=datetime.now(timezone.utc),
|
|
xblock_info=XBlockData(
|
|
usage_key=xblock.location.for_branch(None),
|
|
block_type=block_type,
|
|
version=xblock.location
|
|
)
|
|
)
|
|
|
|
modulestore.on_commit_changes_to(course_key, send_created_event)
|
|
return xblock
|
|
|
|
@strip_key
|
|
@prepare_asides
|
|
def create_child(self, user_id, parent_usage_key, block_type, block_id=None, fields=None, **kwargs):
|
|
"""
|
|
Creates and saves a new xblock that is a child of the specified block
|
|
|
|
Returns the newly created item.
|
|
|
|
Args:
|
|
user_id: ID of the user creating and saving the xmodule
|
|
parent_usage_key: a :class:`~opaque_key.edx.UsageKey` identifying the
|
|
block that this item should be parented under
|
|
block_type: The typo of block to create
|
|
block_id: a unique identifier for the new item. If not supplied,
|
|
a new identifier will be generated
|
|
fields (dict): A dictionary specifying initial values for some or all fields
|
|
in the newly created block
|
|
"""
|
|
course_key = parent_usage_key.course_key
|
|
store = self._verify_modulestore_support(course_key, 'create_child')
|
|
xblock = store.create_child(
|
|
user_id, parent_usage_key, block_type, block_id=block_id, fields=fields, **kwargs
|
|
)
|
|
|
|
def send_created_event():
|
|
# .. event_implemented_name: XBLOCK_CREATED
|
|
XBLOCK_CREATED.send_event(
|
|
time=datetime.now(timezone.utc),
|
|
xblock_info=XBlockData(
|
|
usage_key=xblock.location.for_branch(None),
|
|
block_type=block_type,
|
|
version=xblock.location
|
|
)
|
|
)
|
|
|
|
store.on_commit_changes_to(course_key, send_created_event)
|
|
return xblock
|
|
|
|
@strip_key
|
|
@prepare_asides
|
|
def import_xblock(self, user_id, course_key, block_type, block_id, fields=None, runtime=None, **kwargs):
|
|
"""
|
|
See :py:meth `ModuleStoreDraftAndPublished.import_xblock`
|
|
|
|
Defer to the course's modulestore if it supports this method
|
|
"""
|
|
store = self._verify_modulestore_support(course_key, 'import_xblock')
|
|
return store.import_xblock(user_id, course_key, block_type, block_id, fields, runtime, **kwargs)
|
|
|
|
@strip_key
|
|
def copy_from_template(self, source_keys, dest_key, user_id, **kwargs): # lint-amnesty, pylint: disable=unused-argument
|
|
"""
|
|
See :py:meth `SplitMongoModuleStore.copy_from_template`
|
|
"""
|
|
store = self._verify_modulestore_support(dest_key.course_key, 'copy_from_template')
|
|
return store.copy_from_template(source_keys, dest_key, user_id)
|
|
|
|
@strip_key
|
|
@prepare_asides
|
|
def update_item(self, xblock, user_id, allow_not_found=False, **kwargs): # lint-amnesty, pylint: disable=arguments-differ
|
|
"""
|
|
Update the xblock persisted to be the same as the given for all types of fields
|
|
(content, children, and metadata) attribute the change to the given user.
|
|
"""
|
|
course_key = xblock.location.course_key
|
|
store = self._verify_modulestore_support(course_key, 'update_item')
|
|
xblock = store.update_item(xblock, user_id, allow_not_found, **kwargs)
|
|
|
|
def send_updated_event():
|
|
# .. event_implemented_name: XBLOCK_UPDATED
|
|
XBLOCK_UPDATED.send_event(
|
|
time=datetime.now(timezone.utc),
|
|
xblock_info=XBlockData(
|
|
usage_key=xblock.location.for_branch(None),
|
|
block_type=xblock.location.block_type,
|
|
version=xblock.location
|
|
)
|
|
)
|
|
|
|
store.on_commit_changes_to(course_key, send_updated_event)
|
|
return xblock
|
|
|
|
@strip_key
|
|
def delete_item(self, location, user_id, **kwargs): # lint-amnesty, pylint: disable=arguments-differ
|
|
"""
|
|
Delete the given item from persistence. kwargs allow modulestore specific parameters.
|
|
"""
|
|
course_key = location.course_key
|
|
store = self._verify_modulestore_support(course_key, 'delete_item')
|
|
item = store.delete_item(location, user_id=user_id, **kwargs)
|
|
|
|
def send_deleted_event():
|
|
# .. event_implemented_name: XBLOCK_DELETED
|
|
XBLOCK_DELETED.send_event(
|
|
time=datetime.now(timezone.utc),
|
|
xblock_info=XBlockData(
|
|
usage_key=location,
|
|
block_type=location.block_type,
|
|
)
|
|
)
|
|
|
|
store.on_commit_changes_to(course_key, send_deleted_event)
|
|
return item
|
|
|
|
def revert_to_published(self, location, user_id):
|
|
"""
|
|
Reverts an item to its last published version (recursively traversing all of its descendants).
|
|
If no published version exists, an InvalidVersionError is thrown.
|
|
|
|
If a published version exists but there is no draft version of this item or any of its descendants, this
|
|
method is a no-op.
|
|
|
|
:raises InvalidVersionError: if no published version exists for the location specified
|
|
"""
|
|
store = self._verify_modulestore_support(location.course_key, 'revert_to_published')
|
|
return store.revert_to_published(location, user_id)
|
|
|
|
def reset_course_to_version(self, course_key, version_guid, user_id):
|
|
"""
|
|
Resets the content of a course at `course_key` to a version specified by `version_guid`.
|
|
|
|
:raises NotImplementedError: if not supported by store.
|
|
"""
|
|
store = self._verify_modulestore_support(course_key, 'reset_course_to_version')
|
|
return store.reset_course_to_version(
|
|
course_key=course_key,
|
|
version_guid=version_guid,
|
|
user_id=user_id,
|
|
)
|
|
|
|
def close_all_connections(self):
|
|
"""
|
|
Close all db connections
|
|
"""
|
|
for modulestore in self.modulestores:
|
|
modulestore.close_connections()
|
|
|
|
def _drop_database(self, database=True, collections=True, connections=True):
|
|
"""
|
|
A destructive operation to drop the underlying database and close all connections.
|
|
Intended to be used by test code for cleanup.
|
|
|
|
If database is True, then this should drop the entire database.
|
|
Otherwise, if collections is True, then this should drop all of the collections used
|
|
by this modulestore.
|
|
Otherwise, the modulestore should remove all data from the collections.
|
|
|
|
If connections is True, then close the connection to the database as well.
|
|
"""
|
|
for modulestore in self.modulestores:
|
|
# drop database if the store supports it (read-only stores do not)
|
|
if hasattr(modulestore, '_drop_database'):
|
|
modulestore._drop_database(database, collections, connections) # pylint: disable=protected-access
|
|
|
|
@strip_key
|
|
def create_xblock(self, runtime, course_key, block_type, block_id=None, fields=None, **kwargs):
|
|
"""
|
|
Create the new xblock but don't save it. Returns the new block.
|
|
|
|
Args:
|
|
runtime: :py:class `xblock.runtime` from another xblock in the same course. Providing this
|
|
significantly speeds up processing (inheritance and subsequent persistence)
|
|
course_key: :py:class `opaque_keys.CourseKey`
|
|
block_type: :py:class `string`: the string identifying the xblock type
|
|
block_id: the string uniquely identifying the block within the given course
|
|
fields: :py:class `dict` field_name, value pairs for initializing the xblock fields. Values
|
|
should be the pythonic types not the json serialized ones.
|
|
"""
|
|
store = self._verify_modulestore_support(course_key, 'create_xblock')
|
|
return store.create_xblock(runtime, course_key, block_type, block_id, fields or {}, **kwargs)
|
|
|
|
@strip_key
|
|
def get_courses_for_wiki(self, wiki_slug, **kwargs):
|
|
"""
|
|
Return the list of courses which use this wiki_slug
|
|
:param wiki_slug: the course wiki root slug
|
|
:return: list of course keys
|
|
"""
|
|
courses = []
|
|
for modulestore in self.modulestores:
|
|
courses.extend(modulestore.get_courses_for_wiki(wiki_slug, **kwargs))
|
|
return courses
|
|
|
|
def heartbeat(self):
|
|
"""
|
|
Delegate to each modulestore and package the results for the caller.
|
|
"""
|
|
# could be done in parallel threads if needed
|
|
return dict(
|
|
itertools.chain.from_iterable(
|
|
store.heartbeat().items()
|
|
for store in self.modulestores
|
|
)
|
|
)
|
|
|
|
def has_published_version(self, xblock):
|
|
"""
|
|
Returns whether this xblock is draft, public, or private.
|
|
|
|
Returns:
|
|
PublishState.draft - content is in the process of being edited, but still has a previous
|
|
version deployed to LMS
|
|
PublishState.public - content is locked and deployed to LMS
|
|
PublishState.private - content is editable and not deployed to LMS
|
|
"""
|
|
course_id = xblock.scope_ids.usage_id.course_key
|
|
store = self._get_modulestore_for_courselike(course_id)
|
|
return store.has_published_version(xblock)
|
|
|
|
@strip_key
|
|
def publish(self, location, user_id, **kwargs):
|
|
"""
|
|
Save a current draft to the underlying modulestore
|
|
Returns the newly published item.
|
|
"""
|
|
store = self._verify_modulestore_support(location.course_key, 'publish')
|
|
item = store.publish(location, user_id, **kwargs)
|
|
# .. event_implemented_name: XBLOCK_PUBLISHED
|
|
XBLOCK_PUBLISHED.send_event(
|
|
time=datetime.now(timezone.utc),
|
|
xblock_info=XBlockData(
|
|
usage_key=location,
|
|
block_type=location.block_type,
|
|
)
|
|
)
|
|
return item
|
|
|
|
@strip_key
|
|
def unpublish(self, location, user_id, **kwargs):
|
|
"""
|
|
Save a current draft to the underlying modulestore
|
|
Returns the newly unpublished item.
|
|
"""
|
|
store = self._verify_modulestore_support(location.course_key, 'unpublish')
|
|
return store.unpublish(location, user_id, **kwargs)
|
|
|
|
def convert_to_draft(self, location, user_id):
|
|
"""
|
|
Create a copy of the source and mark its revision as draft.
|
|
Note: This method is to support the Mongo Modulestore and may be deprecated.
|
|
|
|
:param location: the location of the source (its revision must be None)
|
|
"""
|
|
store = self._verify_modulestore_support(location.course_key, 'convert_to_draft')
|
|
return store.convert_to_draft(location, user_id)
|
|
|
|
def has_changes(self, xblock):
|
|
"""
|
|
Checks if the given block has unpublished changes
|
|
:param xblock: the block to check
|
|
:return: True if the draft and published versions differ
|
|
"""
|
|
store = self._verify_modulestore_support(xblock.location.course_key, 'has_changes')
|
|
return store.has_changes(xblock)
|
|
|
|
def check_supports(self, course_key, method):
|
|
"""
|
|
Verifies that the modulestore for a particular course supports a feature.
|
|
Returns True/false based on this.
|
|
"""
|
|
try:
|
|
self._verify_modulestore_support(course_key, method)
|
|
return True
|
|
except NotImplementedError:
|
|
return False
|
|
|
|
def _verify_modulestore_support(self, course_key, method):
|
|
"""
|
|
Finds and returns the store that contains the course for the given location, and verifying
|
|
that the store supports the given method.
|
|
|
|
Raises NotImplementedError if the found store does not support the given method.
|
|
"""
|
|
store = self._get_modulestore_for_courselike(course_key)
|
|
if hasattr(store, method):
|
|
return store
|
|
else:
|
|
raise NotImplementedError(f"Cannot call {method} on store {store}")
|
|
|
|
@property
|
|
def default_modulestore(self):
|
|
"""
|
|
Return the default modulestore
|
|
"""
|
|
thread_local_default_store = getattr(self.thread_cache, 'default_store', None)
|
|
if thread_local_default_store:
|
|
# return the thread-local cache, if found
|
|
return thread_local_default_store
|
|
else:
|
|
# else return the default store
|
|
return self.modulestores[0]
|
|
|
|
@contextmanager
|
|
def default_store(self, store_type):
|
|
"""
|
|
A context manager for temporarily changing the default store in the Mixed modulestore to the given store type
|
|
"""
|
|
# find the store corresponding to the given type
|
|
store = next((store for store in self.modulestores if store.get_modulestore_type() == store_type), None)
|
|
if not store:
|
|
raise Exception(f"Cannot find store of type {store_type}")
|
|
|
|
prev_thread_local_store = getattr(self.thread_cache, 'default_store', None)
|
|
try:
|
|
self.thread_cache.default_store = store
|
|
yield
|
|
finally:
|
|
self.thread_cache.default_store = prev_thread_local_store
|
|
|
|
@contextmanager
|
|
def branch_setting(self, branch_setting, course_id=None):
|
|
"""
|
|
A context manager for temporarily setting the branch value for the given course' store
|
|
to the given branch_setting. If course_id is None, the default store is used.
|
|
"""
|
|
store = self._verify_modulestore_support(course_id, 'branch_setting')
|
|
previous_thread_branch_setting = getattr(self.thread_cache, 'branch_setting', None)
|
|
try:
|
|
self.thread_cache.branch_setting = branch_setting
|
|
with store.branch_setting(branch_setting, course_id):
|
|
yield
|
|
finally:
|
|
self.thread_cache.branch_setting = previous_thread_branch_setting
|
|
|
|
@contextmanager
|
|
def bulk_operations(self, course_id, emit_signals=True, ignore_case=False):
|
|
"""
|
|
A context manager for notifying the store of bulk operations.
|
|
If course_id is None, the default store is used.
|
|
"""
|
|
store = self._get_modulestore_for_courselike(course_id)
|
|
with store.bulk_operations(course_id, emit_signals, ignore_case):
|
|
yield
|
|
|
|
def ensure_indexes(self):
|
|
"""
|
|
Ensure that all appropriate indexes are created that are needed by this modulestore, or raise
|
|
an exception if unable to.
|
|
|
|
This method is intended for use by tests and administrative commands, and not
|
|
to be run during server startup.
|
|
"""
|
|
for store in self.modulestores:
|
|
store.ensure_indexes()
|