Make course ids and usage ids opaque to LMS and Studio [partial commit]

This commit updates common/lib/xmodule.

These keys are now objects with a limited interface, and the particular
internal representation is managed by the data storage layer (the
modulestore).

For the LMS, there should be no outward-facing changes to the system.
The keys are, for now, a change to internal representation only. For
Studio, the new serialized form of the keys is used in urls, to allow
for further migration in the future.

Co-Author: Andy Armstrong <andya@edx.org>
Co-Author: Christina Roberts <christina@edx.org>
Co-Author: David Baumgold <db@edx.org>
Co-Author: Diana Huang <dkh@edx.org>
Co-Author: Don Mitchell <dmitchell@edx.org>
Co-Author: Julia Hansbrough <julia@edx.org>
Co-Author: Nimisha Asthagiri <nasthagiri@edx.org>
Co-Author: Sarina Canelake <sarina@edx.org>

[LMS-2370]
This commit is contained in:
Calen Pennington
2014-04-30 10:17:45 -04:00
parent 7852906ce0
commit d654798856
79 changed files with 3792 additions and 4055 deletions

View File

@@ -67,5 +67,17 @@ setup(
'console_scripts': [
'xmodule_assets = xmodule.static_content:main',
],
'course_key': [
'slashes = xmodule.modulestore.locations:SlashSeparatedCourseKey',
'course-locator = xmodule.modulestore.locator:CourseLocator',
],
'usage_key': [
'location = xmodule.modulestore.locations:Location',
'edx = xmodule.modulestore.locator:BlockUsageLocator',
],
'asset_key': [
'asset-location = xmodule.modulestore.locations:AssetLocation',
'edx = xmodule.modulestore.locator:BlockUsageLocator',
],
},
)

View File

@@ -67,7 +67,7 @@ class ABTestModule(ABTestFields, XModule):
def get_child_descriptors(self):
active_locations = set(self.group_content[self.group])
return [desc for desc in self.descriptor.get_children() if desc.location.url() in active_locations]
return [desc for desc in self.descriptor.get_children() if desc.location.to_deprecated_string() in active_locations]
def displayable_items(self):
# Most modules return "self" as the displayable_item. We never display ourself

View File

@@ -207,7 +207,7 @@ class CapaMixin(CapaFields):
# Need the problem location in openendedresponse to send out. Adding
# it to the system here seems like the least clunky way to get it
# there.
self.runtime.set('location', self.location.url())
self.runtime.set('location', self.location.to_deprecated_string())
try:
# TODO (vshnayder): move as much as possible of this work and error
@@ -225,7 +225,7 @@ class CapaMixin(CapaFields):
except Exception as err: # pylint: disable=broad-except
msg = u'cannot create LoncapaProblem {loc}: {err}'.format(
loc=self.location.url(), err=err)
loc=self.location.to_deprecated_string(), err=err)
# TODO (vshnayder): do modules need error handlers too?
# We shouldn't be switching on DEBUG.
if self.runtime.DEBUG:
@@ -239,7 +239,7 @@ class CapaMixin(CapaFields):
# create a dummy problem with error message instead of failing
problem_text = (u'<problem><text><span class="inline-error">'
u'Problem {url} has an error:</span>{msg}</text></problem>'.format(
url=self.location.url(),
url=self.location.to_deprecated_string(),
msg=msg)
)
self.lcp = self.new_lcp(self.get_state_for_lcp(), text=problem_text)
@@ -259,7 +259,7 @@ class CapaMixin(CapaFields):
self.seed = 1
elif self.rerandomize == "per_student" and hasattr(self.runtime, 'seed'):
# see comment on randomization_bin
self.seed = randomization_bin(self.runtime.seed, self.location.url)
self.seed = randomization_bin(self.runtime.seed, unicode(self.location).encode('utf-8'))
else:
self.seed = struct.unpack('i', os.urandom(4))[0]
@@ -370,7 +370,7 @@ class CapaMixin(CapaFields):
progress = self.get_progress()
return self.runtime.render_template('problem_ajax.html', {
'element_id': self.location.html_id(),
'id': self.id,
'id': self.location.to_deprecated_string(),
'ajax_url': self.runtime.ajax_url,
'progress_status': Progress.to_js_status_str(progress),
'progress_detail': Progress.to_js_detail_str(progress),
@@ -510,7 +510,7 @@ class CapaMixin(CapaFields):
msg = (
u'[courseware.capa.capa_module] <font size="+1" color="red">'
u'Failed to generate HTML for problem {url}</font>'.format(
url=cgi.escape(self.location.url()))
url=cgi.escape(self.location.to_deprecated_string()))
)
msg += u'<p>Error:</p><p><pre>{msg}</pre></p>'.format(msg=cgi.escape(err.message))
msg += u'<p><pre>{tb}</pre></p>'.format(tb=cgi.escape(traceback.format_exc()))
@@ -598,7 +598,7 @@ class CapaMixin(CapaFields):
context = {
'problem': content,
'id': self.id,
'id': self.location.to_deprecated_string(),
'check_button': check_button,
'check_button_checking': check_button_checking,
'reset_button': self.should_show_reset_button(),
@@ -763,7 +763,7 @@ class CapaMixin(CapaFields):
Returns the answers: {'answers' : answers}
"""
event_info = dict()
event_info['problem_id'] = self.location.url()
event_info['problem_id'] = self.location.to_deprecated_string()
self.track_function_unmask('showanswer', event_info)
if not self.answer_available():
raise NotFoundError('Answer is not available')
@@ -906,7 +906,7 @@ class CapaMixin(CapaFields):
"""
event_info = dict()
event_info['state'] = self.lcp.get_state()
event_info['problem_id'] = self.location.url()
event_info['problem_id'] = self.location.to_deprecated_string()
answers = self.make_dict_of_responses(data)
answers_without_files = convert_files_to_filenames(answers)
@@ -1218,7 +1218,7 @@ class CapaMixin(CapaFields):
Returns the error messages for exceptions occurring while performing
the rescoring, rather than throwing them.
"""
event_info = {'state': self.lcp.get_state(), 'problem_id': self.location.url()}
event_info = {'state': self.lcp.get_state(), 'problem_id': self.location.to_deprecated_string()}
_ = self.runtime.service(self, "i18n").ugettext
@@ -1293,7 +1293,7 @@ class CapaMixin(CapaFields):
"""
event_info = dict()
event_info['state'] = self.lcp.get_state()
event_info['problem_id'] = self.location.url()
event_info['problem_id'] = self.location.to_deprecated_string()
answers = self.make_dict_of_responses(data)
event_info['answers'] = answers
@@ -1346,7 +1346,7 @@ class CapaMixin(CapaFields):
"""
event_info = dict()
event_info['old_state'] = self.lcp.get_state()
event_info['problem_id'] = self.location.url()
event_info['problem_id'] = self.location.to_deprecated_string()
_ = self.runtime.service(self, "i18n").ugettext
if self.closed():

View File

@@ -9,7 +9,6 @@ from lxml import etree
from pkg_resources import resource_string
from xmodule.x_module import XModule
from xmodule.modulestore import Location
from xmodule.seq_module import SequenceDescriptor
from xblock.fields import Scope, ReferenceList
from xmodule.modulestore.exceptions import ItemNotFoundError
@@ -144,7 +143,6 @@ class ConditionalModule(ConditionalFields, XModule):
return self.system.render_template('conditional_ajax.html', {
'element_id': self.location.html_id(),
'id': self.id,
'ajax_url': self.system.ajax_url,
'depends': ';'.join(self.required_html_ids)
})
@@ -199,20 +197,14 @@ class ConditionalDescriptor(ConditionalFields, SequenceDescriptor):
# substitution can be done.
if not self.sources_list:
if 'sources' in self.xml_attributes and isinstance(self.xml_attributes['sources'], basestring):
sources = ConditionalDescriptor.parse_sources(self.xml_attributes)
self.sources_list = sources
self.sources_list = ConditionalDescriptor.parse_sources(self.xml_attributes)
@staticmethod
def parse_sources(xml_element):
""" Parse xml_element 'sources' attr and return a list of location strings. """
result = []
sources = xml_element.get('sources')
if sources:
locations = [location.strip() for location in sources.split(';')]
for location in locations:
if Location.is_valid(location): # Check valid location url.
result.append(location)
return result
return [location.strip() for location in sources.split(';')]
def get_required_module_descriptors(self):
"""Returns a list of XModuleDescriptor instances upon
@@ -221,7 +213,7 @@ class ConditionalDescriptor(ConditionalFields, SequenceDescriptor):
descriptors = []
for location in self.sources_list:
try:
descriptor = self.system.load_item(Location(location))
descriptor = self.system.load_item(location)
descriptors.append(descriptor)
except ItemNotFoundError:
msg = "Invalid module by location."
@@ -238,7 +230,7 @@ class ConditionalDescriptor(ConditionalFields, SequenceDescriptor):
if child.tag == 'show':
locations = ConditionalDescriptor.parse_sources(child)
for location in locations:
children.append(Location(location))
children.append(location)
show_tag_list.append(location)
else:
try:
@@ -251,22 +243,18 @@ class ConditionalDescriptor(ConditionalFields, SequenceDescriptor):
return {'show_tag_list': show_tag_list}, children
def definition_to_xml(self, resource_fs):
def to_string(string_list):
""" Convert List of strings to a single string with "; " as the separator. """
return "; ".join(string_list)
xml_object = etree.Element(self._tag_name)
for child in self.get_children():
location = str(child.location)
if location not in self.show_tag_list:
if child.location not in self.show_tag_list:
self.runtime.add_block_as_child_node(child, xml_object)
if self.show_tag_list:
show_str = u'<{tag_name} sources="{sources}" />'.format(
tag_name='show', sources=to_string(self.show_tag_list))
tag_name='show', sources=';'.join(location.to_deprecated_string() for location in self.show_tag_list))
xml_object.append(etree.fromstring(show_str))
# Overwrite the original sources attribute with the value from sources_list, as
# Locations may have been changed to Locators.
self.xml_attributes['sources'] = to_string(self.sources_list)
stringified_sources_list = map(lambda loc: loc.to_deprecated_string(), self.sources_list)
self.xml_attributes['sources'] = ';'.join(stringified_sources_list)
return xml_object

View File

@@ -1,3 +1,5 @@
import bson.son
import re
XASSET_LOCATION_TAG = 'c4x'
XASSET_SRCREF_PREFIX = 'xasset:'
@@ -8,7 +10,7 @@ import logging
import StringIO
from urlparse import urlparse, urlunparse
from xmodule.modulestore import Location
from xmodule.modulestore.locations import AssetLocation, SlashSeparatedCourseKey
from .django import contentstore
from PIL import Image
@@ -22,7 +24,7 @@ class StaticContent(object):
self._data = data
self.length = length
self.last_modified_at = last_modified_at
self.thumbnail_location = Location(thumbnail_location) if thumbnail_location is not None else None
self.thumbnail_location = thumbnail_location
# optional information about where this file was imported from. This is needed to support import/export
# cycles
self.import_path = import_path
@@ -39,44 +41,48 @@ class StaticContent(object):
extension=XASSET_THUMBNAIL_TAIL_NAME,)
@staticmethod
def compute_location(org, course, name, revision=None, is_thumbnail=False):
name = name.replace('/', '_')
return Location([XASSET_LOCATION_TAG, org, course, 'asset' if not is_thumbnail else 'thumbnail',
Location.clean_keeping_underscores(name), revision])
def compute_location(course_key, path, revision=None, is_thumbnail=False):
"""
Constructs a location object for static content.
- course_key: the course that this asset belongs to
- path: is the name of the static asset
- revision: is the object's revision information
- is_tumbnail: is whether or not we want the thumbnail version of this
asset
"""
path = path.replace('/', '_')
return AssetLocation(
course_key.org, course_key.course, course_key.run,
'asset' if not is_thumbnail else 'thumbnail',
AssetLocation.clean_keeping_underscores(path),
revision
)
def get_id(self):
return StaticContent.get_id_from_location(self.location)
def get_url_path(self):
return StaticContent.get_url_path_from_location(self.location)
return self.location.to_deprecated_string()
@property
def data(self):
return self._data
@staticmethod
def get_url_path_from_location(location):
if location is not None:
return u"/{tag}/{org}/{course}/{category}/{name}".format(**location.dict())
else:
return None
ASSET_URL_RE = re.compile(r"""
/?c4x/
(?P<org>[^/]+)/
(?P<course>[^/]+)/
(?P<category>[^/]+)/
(?P<name>[^/]+)
""", re.VERBOSE | re.IGNORECASE)
@staticmethod
def is_c4x_path(path_string):
"""
Returns a boolean if a path is believed to be a c4x link based on the leading element
"""
return path_string.startswith(u'/{0}/'.format(XASSET_LOCATION_TAG))
@staticmethod
def renamespace_c4x_path(path_string, target_location):
"""
Returns an updated string which incorporates a new org/course in order to remap an asset path
to a new namespace
"""
location = StaticContent.get_location_from_path(path_string)
location = location.replace(org=target_location.org, course=target_location.course)
return StaticContent.get_url_path_from_location(location)
return StaticContent.ASSET_URL_RE.match(path_string) is not None
@staticmethod
def get_static_path_from_location(location):
@@ -88,28 +94,35 @@ class StaticContent(object):
the actual /c4x/... path which the client needs to reference static content
"""
if location is not None:
return u"/static/{name}".format(**location.dict())
return u"/static/{name}".format(name=location.name)
else:
return None
@staticmethod
def get_base_url_path_for_course_assets(loc):
if loc is not None:
return u"/c4x/{org}/{course}/asset".format(**loc.dict())
def get_base_url_path_for_course_assets(course_key):
if course_key is None:
return None
assert(isinstance(course_key, SlashSeparatedCourseKey))
return course_key.make_asset_key('asset', '').to_deprecated_string()
@staticmethod
def get_id_from_location(location):
return {'tag': location.tag, 'org': location.org, 'course': location.course,
'category': location.category, 'name': location.name,
'revision': location.revision}
"""
Get the doc store's primary key repr for this location
"""
return bson.son.SON([
('tag', 'c4x'), ('org', location.org), ('course', location.course),
('category', location.category), ('name', location.name),
('revision', location.revision),
])
@staticmethod
def get_location_from_path(path):
# remove leading / character if it is there one
if path.startswith('/'):
path = path[1:]
return Location(path.split('/'))
"""
Generate an AssetKey for the given path (old c4x/org/course/asset/name syntax)
"""
return AssetLocation.from_deprecated_string(path)
@staticmethod
def convert_legacy_static_url_with_course_id(path, course_id):
@@ -117,12 +130,10 @@ class StaticContent(object):
Returns a path to a piece of static content when we are provided with a filepath and
a course_id
"""
# Generate url of urlparse.path component
scheme, netloc, orig_path, params, query, fragment = urlparse(path)
course_id_dict = Location.parse_course_id(course_id)
loc = StaticContent.compute_location(course_id_dict['org'], course_id_dict['course'], orig_path)
loc_url = StaticContent.get_url_path_from_location(loc)
loc = StaticContent.compute_location(course_id, orig_path)
loc_url = loc.to_deprecated_string()
# Reconstruct with new path
return urlunparse((scheme, netloc, loc_url, params, query, fragment))
@@ -167,7 +178,7 @@ class ContentStore(object):
def find(self, filename):
raise NotImplementedError
def get_all_content_for_course(self, location, start=0, maxresults=-1, sort=None):
def get_all_content_for_course(self, course_key, start=0, maxresults=-1, sort=None):
'''
Returns a list of static assets for a course, followed by the total number of assets.
By default all assets are returned, but start and maxresults can be provided to limit the query.
@@ -192,13 +203,21 @@ class ContentStore(object):
'''
raise NotImplementedError
def delete_all_course_assets(self, course_key):
"""
Delete all of the assets which use this course_key as an identifier
:param 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
thumbnail_name = StaticContent.generate_thumbnail_name(content.location.name)
thumbnail_file_location = StaticContent.compute_location(content.location.org, content.location.course,
thumbnail_name, is_thumbnail=True)
thumbnail_file_location = StaticContent.compute_location(
content.location.course_key, thumbnail_name, is_thumbnail=True
)
# if we're uploading an image, then let's generate a thumbnail so that we can
# serve it up when needed without having to rescale on the fly

View File

@@ -2,8 +2,7 @@ import pymongo
import gridfs
from gridfs.errors import NoFile
from xmodule.modulestore import Location
from xmodule.modulestore.mongo.base import location_to_query
from xmodule.modulestore.mongo.base import location_to_query, MongoModuleStore, location_to_son
from xmodule.contentstore.content import XASSET_LOCATION_TAG
import logging
@@ -13,6 +12,8 @@ from xmodule.exceptions import NotFoundError
from fs.osfs import OSFS
import os
import json
import bson.son
from xmodule.modulestore.locations import AssetLocation
class MongoContentStore(ContentStore):
@@ -28,6 +29,7 @@ class MongoContentStore(ContentStore):
pymongo.MongoClient(
host=host,
port=port,
document_class=bson.son.SON,
**kwargs
),
db
@@ -46,8 +48,10 @@ class MongoContentStore(ContentStore):
# Seems like with the GridFS we can't update existing ID's we have to do a delete/add pair
self.delete(content_id)
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, thumbnail_location=content.thumbnail_location,
displayname=content.name,
thumbnail_location=thumbnail_location,
import_path=content.import_path,
# getattr b/c caching may mean some pickled instances don't have attr
locked=getattr(content, 'locked', False)) as fp:
@@ -62,23 +66,40 @@ class MongoContentStore(ContentStore):
def delete(self, content_id):
if self.fs.exists({"_id": content_id}):
self.fs.delete(content_id)
assert not self.fs.exists({"_id": content_id})
def find(self, location, throw_on_not_found=True, as_stream=False):
content_id = StaticContent.get_id_from_location(location)
# Use slow attr based lookup b/c we weren't careful to control the key order in _id before
content_id = {u'_id.{}'.format(key): value for key, value in content_id.iteritems()}
fs_pointer = self.fs_files.find_one(content_id, fields={'_id': 1})
if fs_pointer is None:
if throw_on_not_found:
raise NotFoundError()
else:
return None
content_id = fs_pointer['_id']
try:
if as_stream:
fp = self.fs.get(content_id)
thumbnail_location = getattr(fp, 'thumbnail_location', None)
if thumbnail_location:
thumbnail_location = location.course_key.make_asset_key('thumbnail', thumbnail_location[4])
return StaticContentStream(
location, fp.displayname, fp.content_type, fp, last_modified_at=fp.uploadDate,
thumbnail_location=getattr(fp, 'thumbnail_location', None),
thumbnail_location=thumbnail_location,
import_path=getattr(fp, 'import_path', None),
length=fp.length, locked=getattr(fp, 'locked', False)
)
else:
with self.fs.get(content_id) as fp:
thumbnail_location = getattr(fp, 'thumbnail_location', None)
if thumbnail_location:
thumbnail_location = location.course_key.make_asset_key('thumbnail', thumbnail_location[4])
return StaticContent(
location, fp.displayname, fp.content_type, fp.read(), last_modified_at=fp.uploadDate,
thumbnail_location=getattr(fp, 'thumbnail_location', None),
thumbnail_location=thumbnail_location,
import_path=getattr(fp, 'import_path', None),
length=fp.length, locked=getattr(fp, 'locked', False)
)
@@ -90,8 +111,12 @@ class MongoContentStore(ContentStore):
def get_stream(self, location):
content_id = StaticContent.get_id_from_location(location)
# use slow attr based lookup because we weren't careful to control the key order in _id before
content_id = {u'_id.{}'.format(key): value for key, value in content_id.iteritems()}
fs_pointer = self.fs_files.find_one(content_id, fields={'_id': 1})
try:
handle = self.fs.get(content_id)
handle = self.fs.get(fs_pointer['_id'])
except NoFile:
raise NotFoundError()
@@ -100,7 +125,7 @@ class MongoContentStore(ContentStore):
def close_stream(self, handle):
try:
handle.close()
except Exception:
except Exception: # pylint: disable=broad-except
pass
def export(self, location, output_directory):
@@ -117,21 +142,22 @@ class MongoContentStore(ContentStore):
with disk_fs.open(content.name, 'wb') as asset_file:
asset_file.write(content.data)
def export_all_for_course(self, course_location, output_directory, assets_policy_file):
def export_all_for_course(self, course_key, output_directory, assets_policy_file):
"""
Export all of this course's assets to the output_directory. Export all of the assets'
attributes to the policy file.
:param course_location: the Location of type 'course'
:param output_directory: the directory under which to put all the asset files
:param assets_policy_file: the filename for the policy file which should be in the same
directory as the other policy files.
Args:
course_key (CourseKey): the :class:`CourseKey` identifying the course
output_directory: the directory under which to put all the asset files
assets_policy_file: the filename for the policy file which should be in the same
directory as the other policy files.
"""
policy = {}
assets, __ = self.get_all_content_for_course(course_location)
assets, __ = self.get_all_content_for_course(course_key)
for asset in assets:
asset_location = Location(asset['_id'])
asset_location = AssetLocation._from_deprecated_son(asset['_id'], course_key.run) # pylint: disable=protected-access
self.export(asset_location, output_directory)
for attr, value in asset.iteritems():
if attr not in ['_id', 'md5', 'uploadDate', 'length', 'chunkSize']:
@@ -140,15 +166,15 @@ class MongoContentStore(ContentStore):
with open(assets_policy_file, 'w') as f:
json.dump(policy, f)
def get_all_content_thumbnails_for_course(self, location):
return self._get_all_content_for_course(location, get_thumbnails=True)[0]
def get_all_content_thumbnails_for_course(self, course_key):
return self._get_all_content_for_course(course_key, get_thumbnails=True)[0]
def get_all_content_for_course(self, location, start=0, maxresults=-1, sort=None):
def get_all_content_for_course(self, course_key, start=0, maxresults=-1, sort=None):
return self._get_all_content_for_course(
location, start=start, maxresults=maxresults, get_thumbnails=False, sort=sort
course_key, start=start, maxresults=maxresults, get_thumbnails=False, sort=sort
)
def _get_all_content_for_course(self, location, get_thumbnails=False, start=0, maxresults=-1, sort=None):
def _get_all_content_for_course(self, course_key, get_thumbnails=False, start=0, maxresults=-1, sort=None):
'''
Returns a list of all static assets for a course. The return format is a list of dictionary elements. Example:
@@ -168,20 +194,22 @@ class MongoContentStore(ContentStore):
]
'''
course_filter = Location(XASSET_LOCATION_TAG, category="asset" if not get_thumbnails else "thumbnail",
course=location.course, org=location.org)
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),
location_to_query(course_filter, wildcard=True, tag=XASSET_LOCATION_TAG),
skip=start, limit=maxresults, sort=sort
)
else:
items = self.fs_files.find(location_to_query(course_filter), sort=sort)
items = self.fs_files.find(location_to_query(course_filter, wildcard=True, tag=XASSET_LOCATION_TAG), sort=sort)
count = items.count()
return list(items), count
def set_attr(self, location, attr, value=True):
def set_attr(self, asset_key, attr, value=True):
"""
Add/set the given attr on the asset at the given location. Does not allow overwriting gridFS built in
attrs such as _id, md5, uploadDate, length. Value can be any type which pymongo accepts.
@@ -191,11 +219,11 @@ class MongoContentStore(ContentStore):
Raises NotFoundError if no such item exists
Raises AttributeError is attr is one of the build in attrs.
:param location: a c4x asset location
:param asset_key: an AssetKey
:param attr: which attribute to set
:param value: the value to set it to (any type pymongo accepts such as datetime, number, string)
"""
self.set_attrs(location, {attr: value})
self.set_attrs(asset_key, {attr: value})
def get_attr(self, location, attr, default=None):
"""
@@ -216,15 +244,13 @@ class MongoContentStore(ContentStore):
:param location: a c4x asset location
"""
# raises exception if location is not fully specified
Location.ensure_fully_specified(location)
for attr in attr_dict.iterkeys():
if attr in ['_id', 'md5', 'uploadDate', 'length']:
raise AttributeError("{} is a protected attribute.".format(attr))
item = self.fs_files.find_one(location_to_query(location))
if item is None:
raise NotFoundError()
self.fs_files.update({"_id": item["_id"]}, {"$set": attr_dict})
asset_db_key = {'_id': location_to_son(location, tag=XASSET_LOCATION_TAG)}
if self.fs_files.find(asset_db_key).count() == 0:
raise NotFoundError(asset_db_key)
self.fs_files.update(asset_db_key, {"$set": attr_dict})
def get_attrs(self, location):
"""
@@ -236,7 +262,18 @@ class MongoContentStore(ContentStore):
:param location: a c4x asset location
"""
item = self.fs_files.find_one(location_to_query(location))
item = self.fs_files.find_one({'_id': location_to_son(location, tag=XASSET_LOCATION_TAG)})
if item is None:
raise NotFoundError()
return item
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
matching_assets = self.fs_files.find(course_query)
for asset in matching_assets:
self.fs.delete(asset['_id'])

View File

@@ -1,4 +1,3 @@
from xmodule.modulestore import Location
from xmodule.contentstore.content import StaticContent
from .django import contentstore
@@ -13,18 +12,14 @@ def empty_asset_trashcan(course_locs):
# first delete all of the thumbnails
thumbs = store.get_all_content_thumbnails_for_course(course_loc)
for thumb in thumbs:
thumb_loc = Location(thumb["_id"])
id = StaticContent.get_id_from_location(thumb_loc)
print "Deleting {0}...".format(id)
store.delete(id)
print "Deleting {0}...".format(thumb)
store.delete(thumb['_id'])
# then delete all of the assets
assets, __ = store.get_all_content_for_course(course_loc)
for asset in assets:
asset_loc = Location(asset["_id"])
id = StaticContent.get_id_from_location(asset_loc)
print "Deleting {0}...".format(id)
store.delete(id)
print "Deleting {0}...".format(asset)
store.delete(asset['_id'])
def restore_asset_from_trashcan(location):

View File

@@ -438,7 +438,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
if isinstance(self.location, Location):
self.wiki_slug = self.location.course
elif isinstance(self.location, CourseLocator):
self.wiki_slug = self.location.package_id or self.display_name
self.wiki_slug = self.id.offering or self.display_name
if self.due_date_display_format is None and self.show_timezone is False:
# For existing courses with show_timezone set to False (and no due_date_display_format specified),
@@ -810,32 +810,10 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
def make_id(org, course, url_name):
return '/'.join([org, course, url_name])
@staticmethod
def id_to_location(course_id):
'''Convert the given course_id (org/course/name) to a location object.
Throws ValueError if course_id is of the wrong format.
'''
course_id_dict = Location.parse_course_id(course_id)
course_id_dict['tag'] = 'i4x'
course_id_dict['category'] = 'course'
return Location(course_id_dict)
@staticmethod
def location_to_id(location):
'''Convert a location of a course to a course_id. If location category
is not "course", raise a ValueError.
location: something that can be passed to Location
'''
loc = Location(location)
if loc.category != "course":
raise ValueError("{0} is not a course location".format(loc))
return "/".join([loc.org, loc.course, loc.name])
@property
def id(self):
"""Return the course_id for this course"""
return self.location_to_id(self.location)
return self.location.course_key
@property
def start_date_text(self):

View File

@@ -11,7 +11,6 @@ import sys
from lxml import etree
from xmodule.x_module import XModule, XModuleDescriptor
from xmodule.errortracker import exc_info_to_str
from xmodule.modulestore import Location
from xblock.fields import String, Scope, ScopeIds
from xblock.field_data import DictFieldData
@@ -81,7 +80,6 @@ class ErrorDescriptor(ErrorFields, XModuleDescriptor):
@classmethod
def _construct(cls, system, contents, error_msg, location):
location = Location(location)
if error_msg is None:
# this string is not marked for translation because we don't have

View File

@@ -108,7 +108,7 @@ class FolditModule(FolditFields, XModule):
from foldit.models import Score
if courses is None:
courses = [self.location.course_id]
courses = [self.location.course_key]
leaders = [(leader['username'], leader['score']) for leader in Score.get_tops_n(10, course_list=courses)]
leaders.sort(key=lambda x: -x[1])

View File

@@ -121,7 +121,7 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor):
# Add some specific HTML rendering context when editing HTML modules where we pass
# the root /c4x/ url for assets. This allows client-side substitutions to occur.
_context.update({
'base_asset_url': StaticContent.get_base_url_path_for_course_assets(self.location) + '/',
'base_asset_url': StaticContent.get_base_url_path_for_course_assets(self.location.course_key),
'enable_latex_compiler': self.use_latex_compiler,
'editor': self.editor
})

View File

@@ -347,9 +347,7 @@ class LTIModule(LTIFields, XModule):
"""
Return course by course id.
"""
course_location = CourseDescriptor.id_to_location(self.course_id)
course = self.descriptor.runtime.modulestore.get_item(course_location)
return course
return self.descriptor.runtime.modulestore.get_course(self.course_id)
@property
def context_id(self):
@@ -359,7 +357,7 @@ class LTIModule(LTIFields, XModule):
context_id is an opaque identifier that uniquely identifies the context (e.g., a course)
that contains the link being launched.
"""
return self.course_id
return self.course_id.to_deprecated_string()
@property
def role(self):

View File

@@ -6,7 +6,7 @@ that are stored in a database an accessible using their Location as an identifie
import logging
import re
from collections import namedtuple
from collections import namedtuple, defaultdict
import collections
from abc import ABCMeta, abstractmethod
@@ -14,8 +14,13 @@ from xblock.plugin import default_select
from .exceptions import InvalidLocationError, InsufficientSpecificationError
from xmodule.errortracker import make_error_tracker
from xmodule.modulestore.keys import CourseKey, UsageKey
from xmodule.modulestore.locations import Location # For import backwards compatibility
from opaque_keys import InvalidKeyError
from xmodule.modulestore.locations import SlashSeparatedCourseKey
from xblock.runtime import Mixologist
from xblock.core import XBlock
import datetime
log = logging.getLogger('edx.modulestore')
@@ -23,286 +28,6 @@ SPLIT_MONGO_MODULESTORE_TYPE = 'split'
MONGO_MODULESTORE_TYPE = 'mongo'
XML_MODULESTORE_TYPE = 'xml'
URL_RE = re.compile("""
(?P<tag>[^:]+)://?
(?P<org>[^/]+)/
(?P<course>[^/]+)/
(?P<category>[^/]+)/
(?P<name>[^@]+)
(@(?P<revision>[^/]+))?
""", re.VERBOSE)
# TODO (cpennington): We should decide whether we want to expand the
# list of valid characters in a location
INVALID_CHARS = re.compile(r"[^\w.%-]", re.UNICODE)
# Names are allowed to have colons.
INVALID_CHARS_NAME = re.compile(r"[^\w.:%-]", re.UNICODE)
# html ids can contain word chars and dashes
INVALID_HTML_CHARS = re.compile(r"[^\w-]", re.UNICODE)
_LocationBase = namedtuple('LocationBase', 'tag org course category name revision')
def _check_location_part(val, regexp):
"""
Check that `regexp` doesn't match inside `val`. If it does, raise an exception
Args:
val (string): The value to check
regexp (re.RegexObject): The regular expression specifying invalid characters
Raises:
InvalidLocationError: Raised if any invalid character is found in `val`
"""
if val is not None and regexp.search(val) is not None:
raise InvalidLocationError("Invalid characters in {!r}.".format(val))
class Location(_LocationBase):
'''
Encodes a location.
Locations representations of URLs of the
form {tag}://{org}/{course}/{category}/{name}[@{revision}]
However, they can also be represented as dictionaries (specifying each component),
tuples or lists (specified in order), or as strings of the url
'''
__slots__ = ()
@staticmethod
def _clean(value, invalid):
"""
invalid should be a compiled regexp of chars to replace with '_'
"""
return re.sub('_+', '_', invalid.sub('_', value))
@staticmethod
def clean(value):
"""
Return value, made into a form legal for locations
"""
return Location._clean(value, INVALID_CHARS)
@staticmethod
def clean_keeping_underscores(value):
"""
Return value, replacing INVALID_CHARS, but not collapsing multiple '_' chars.
This for cleaning asset names, as the YouTube ID's may have underscores in them, and we need the
transcript asset name to match. In the future we may want to change the behavior of _clean.
"""
return INVALID_CHARS.sub('_', value)
@staticmethod
def clean_for_url_name(value):
"""
Convert value into a format valid for location names (allows colons).
"""
return Location._clean(value, INVALID_CHARS_NAME)
@staticmethod
def clean_for_html(value):
"""
Convert a string into a form that's safe for use in html ids, classes, urls, etc.
Replaces all INVALID_HTML_CHARS with '_', collapses multiple '_' chars
"""
return Location._clean(value, INVALID_HTML_CHARS)
@staticmethod
def is_valid(value):
'''
Check if the value is a valid location, in any acceptable format.
'''
try:
Location(value)
except InvalidLocationError:
return False
return True
@staticmethod
def ensure_fully_specified(location):
'''Make sure location is valid, and fully specified. Raises
InvalidLocationError or InsufficientSpecificationError if not.
returns a Location object corresponding to location.
'''
loc = Location(location)
for key, val in loc.dict().iteritems():
if key != 'revision' and val is None:
raise InsufficientSpecificationError(location)
return loc
def __new__(_cls, loc_or_tag=None, org=None, course=None, category=None,
name=None, revision=None):
"""
Create a new location that is a clone of the specifed one.
location - Can be any of the following types:
string: should be of the form
{tag}://{org}/{course}/{category}/{name}[@{revision}]
list: should be of the form [tag, org, course, category, name, revision]
dict: should be of the form {
'tag': tag,
'org': org,
'course': course,
'category': category,
'name': name,
'revision': revision,
}
Location: another Location object
In both the dict and list forms, the revision is optional, and can be
ommitted.
Components must be composed of alphanumeric characters, or the
characters '_', '-', and '.'. The name component is additionally allowed to have ':',
which is interpreted specially for xml storage.
Components may be set to None, which may be interpreted in some contexts
to mean wildcard selection.
"""
if (org is None and course is None and category is None and name is None and revision is None):
location = loc_or_tag
else:
location = (loc_or_tag, org, course, category, name, revision)
if location is None:
return _LocationBase.__new__(_cls, *([None] * 6))
def check_dict(dict_):
# Order matters, so flatten out into a list
keys = ['tag', 'org', 'course', 'category', 'name', 'revision']
list_ = [dict_[k] for k in keys]
check_list(list_)
def check_list(list_):
list_ = list(list_)
for val in list_[:4] + [list_[5]]:
_check_location_part(val, INVALID_CHARS)
# names allow colons
_check_location_part(list_[4], INVALID_CHARS_NAME)
if isinstance(location, Location):
return location
elif isinstance(location, basestring):
match = URL_RE.match(location)
if match is None:
log.debug(u"location %r doesn't match URL", location)
raise InvalidLocationError(location)
groups = match.groupdict()
check_dict(groups)
return _LocationBase.__new__(_cls, **groups)
elif isinstance(location, (list, tuple)):
if len(location) not in (5, 6):
log.debug(u'location has wrong length')
raise InvalidLocationError(location)
if len(location) == 5:
args = tuple(location) + (None,)
else:
args = tuple(location)
check_list(args)
return _LocationBase.__new__(_cls, *args)
elif isinstance(location, dict):
kwargs = dict(location)
kwargs.setdefault('revision', None)
check_dict(kwargs)
return _LocationBase.__new__(_cls, **kwargs)
else:
raise InvalidLocationError(location)
def url(self):
"""
Return a string containing the URL for this location
"""
url = u"{0.tag}://{0.org}/{0.course}/{0.category}/{0.name}".format(self)
if self.revision:
url += u"@{rev}".format(rev=self.revision) # pylint: disable=E1101
return url
def html_id(self):
"""
Return a string with a version of the location that is safe for use in
html id attributes
"""
id_string = u"-".join(v for v in self.list() if v is not None)
return Location.clean_for_html(id_string)
def dict(self):
"""
Return an OrderedDict of this locations keys and values. The order is
tag, org, course, category, name, revision
"""
return self._asdict()
def list(self):
return list(self)
def __str__(self):
return str(self.url().encode("utf-8"))
def __unicode__(self):
return self.url()
def __repr__(self):
return "Location%s" % repr(tuple(self))
@property
def course_id(self):
"""
Return the ID of the Course that this item belongs to by looking
at the location URL hierachy.
Throws an InvalidLocationError is this location does not represent a course.
"""
if self.category != 'course':
raise InvalidLocationError(u'Cannot call course_id for {0} because it is not of category course'.format(self))
return "/".join([self.org, self.course, self.name])
COURSE_ID_RE = re.compile("""
(?P<org>[^/]+)/
(?P<course>[^/]+)/
(?P<name>.*)
""", re.VERBOSE)
@staticmethod
def parse_course_id(course_id):
"""
Given a org/course/name course_id, return a dict of {"org": org, "course": course, "name": name}
If the course_id is not of the right format, raise ValueError
"""
match = Location.COURSE_ID_RE.match(course_id)
if match is None:
raise ValueError("{} is not of form ORG/COURSE/NAME".format(course_id))
return match.groupdict()
def _replace(self, **kwargs):
"""
Return a new :class:`Location` with values replaced
by the values specified in `**kwargs`
"""
for name, value in kwargs.iteritems():
if name == 'name':
_check_location_part(value, INVALID_CHARS_NAME)
else:
_check_location_part(value, INVALID_CHARS)
# namedtuple is an old-style class, so don't use super
return _LocationBase._replace(self, **kwargs)
def replace(self, **kwargs):
'''
Expose a public method for replacing location elements
'''
return self._replace(**kwargs)
class ModuleStoreRead(object):
"""
@@ -313,14 +38,14 @@ class ModuleStoreRead(object):
__metaclass__ = ABCMeta
@abstractmethod
def has_item(self, course_id, location):
def has_item(self, usage_key):
"""
Returns True if location exists in this ModuleStore.
Returns True if usage_key exists in this ModuleStore.
"""
pass
@abstractmethod
def get_item(self, location, depth=0):
def get_item(self, usage_key, depth=0):
"""
Returns an XModuleDescriptor instance for the item at location.
@@ -330,7 +55,7 @@ class ModuleStoreRead(object):
If no object is found at that location, raises
xmodule.modulestore.exceptions.ItemNotFoundError
location: Something that can be passed to Location
usage_key: A :class:`.UsageKey` subclass instance
depth (int): An argument that some module stores may use to prefetch
descendents of the queried modules for more efficient results later
@@ -340,23 +65,16 @@ class ModuleStoreRead(object):
pass
@abstractmethod
def get_instance(self, course_id, location, depth=0):
"""
Get an instance of this location, with policy for course_id applied.
TODO (vshnayder): this may want to live outside the modulestore eventually
"""
pass
@abstractmethod
def get_item_errors(self, location):
def get_course_errors(self, course_key):
"""
Return a list of (msg, exception-or-None) errors that the modulestore
encountered when loading the item at location.
location : something that can be passed to Location
encountered when loading the course at course_id.
Raises the same exceptions as get_item if the location isn't found or
isn't fully specified.
Args:
course_key (:class:`.CourseKey`): The course to check for errors
"""
pass
@@ -376,6 +94,68 @@ class ModuleStoreRead(object):
"""
pass
def _block_matches(self, fields_or_xblock, qualifiers):
'''
Return True or False depending on whether the field value (block contents)
matches the qualifiers as per get_items. Note, only finds directly set not
inherited nor default value matches.
For substring matching pass a regex object.
for arbitrary function comparison such as date time comparison, pass
the function as in start=lambda x: x < datetime.datetime(2014, 1, 1, 0, tzinfo=pytz.UTC)
Args:
fields_or_xblock (dict or XBlock): either the json blob (from the db or get_explicitly_set_fields)
or the xblock.fields() value or the XBlock from which to get those values
qualifiers (dict): field: searchvalue pairs.
'''
if isinstance(fields_or_xblock, XBlock):
fields = fields_or_xblock.fields
xblock = fields_or_xblock
is_xblock = True
else:
fields = fields_or_xblock
is_xblock = False
def _is_set_on(key):
"""
Is this key set in fields? (return tuple of boolean and value). A helper which can
handle fields either being the json doc or xblock fields. Is inner function to restrict
use and to access local vars.
"""
if key not in fields:
return False, None
field = fields[key]
if is_xblock:
return field.is_set_on(fields_or_xblock), getattr(xblock, key)
else:
return True, field
for key, criteria in qualifiers.iteritems():
is_set, value = _is_set_on(key)
if not is_set:
return False
if not self._value_matches(value, criteria):
return False
return True
def _value_matches(self, target, criteria):
'''
helper for _block_matches: does the target (field value) match the criteria?
If target is a list, do any of the list elements meet the criteria
If the criteria is a regex, does the target match it?
If the criteria is a function, does invoking it on the target yield something truthy?
Otherwise, is the target == criteria
'''
if isinstance(target, list):
return any(self._value_matches(ele, criteria) for ele in target)
elif isinstance(criteria, re._pattern_type):
return criteria.search(target) is not None
elif callable(criteria):
return criteria(target)
else:
return criteria == target
@abstractmethod
def get_courses(self):
'''
@@ -385,14 +165,26 @@ class ModuleStoreRead(object):
pass
@abstractmethod
def get_course(self, course_id):
def get_course(self, course_id, depth=None):
'''
Look for a specific course id. Returns the course descriptor, or None if not found.
Look for a specific course by its id (:class:`CourseKey`).
Returns the course descriptor, or None if not found.
'''
pass
@abstractmethod
def get_parent_locations(self, location, course_id):
def has_course(self, course_id, ignore_case=False):
'''
Look for a specific course id. Returns whether it exists.
Args:
course_id (CourseKey):
ignore_case (boolean): some modulestores are case-insensitive. Use this flag
to search for whether a potentially conflicting course exists in that case.
'''
pass
@abstractmethod
def get_parent_locations(self, location):
'''Find all locations that are the parents of this location in this
course. Needed for path_to_location().
@@ -401,7 +193,7 @@ class ModuleStoreRead(object):
pass
@abstractmethod
def get_orphans(self, course_location, branch):
def get_orphans(self, course_key):
"""
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
@@ -445,7 +237,7 @@ class ModuleStoreWrite(ModuleStoreRead):
:param force: fork the structure and don't update the course draftVersion if there's a version
conflict (only applicable to version tracking and conflict detecting persistence stores)
:raises VersionConflictError: if package_id and version_guid given and the current
:raises VersionConflictError: if org, offering, and version_guid given and the current
version head != version_guid and force is not True. (only applicable to version tracking stores)
"""
pass
@@ -461,11 +253,39 @@ class ModuleStoreWrite(ModuleStoreRead):
:param force: fork the structure and don't update the course draftVersion if there's a version
conflict (only applicable to version tracking and conflict detecting persistence stores)
:raises VersionConflictError: if package_id and version_guid given and the current
:raises VersionConflictError: if org, offering, and version_guid given and the current
version head != version_guid and force is not True. (only applicable to version tracking stores)
"""
pass
@abstractmethod
def create_course(self, org, offering, user_id=None, fields=None, **kwargs):
"""
Creates and returns the course.
Args:
org (str): the organization that owns the course
offering (str): the name of the course offering
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 CourseDescriptor
"""
pass
@abstractmethod
def delete_course(self, course_key, user_id=None):
"""
Deletes the course. It may be a soft or hard delete. It may or may not remove the xblock definitions
depending on the persistence layer and how tightly bound the xblocks are to the course.
Args:
course_key (CourseKey): which course to delete
user_id: id of the user deleting the course
"""
pass
class ModuleStoreReadBase(ModuleStoreRead):
'''
@@ -477,7 +297,7 @@ class ModuleStoreReadBase(ModuleStoreRead):
self,
doc_store_config=None, # ignore if passed up
metadata_inheritance_cache_subsystem=None, request_cache=None,
modulestore_update_signal=None, xblock_mixins=(), xblock_select=None,
xblock_mixins=(), xblock_select=None,
# temporary parms to enable backward compatibility. remove once all envs migrated
db=None, collection=None, host=None, port=None, tz_aware=True, user=None, password=None,
# allow lower level init args to pass harmlessly
@@ -486,38 +306,22 @@ class ModuleStoreReadBase(ModuleStoreRead):
'''
Set up the error-tracking logic.
'''
self._location_errors = {} # location -> ErrorLog
self._course_errors = defaultdict(make_error_tracker) # location -> ErrorLog
self.metadata_inheritance_cache_subsystem = metadata_inheritance_cache_subsystem
self.modulestore_update_signal = modulestore_update_signal
self.request_cache = request_cache
self.xblock_mixins = xblock_mixins
self.xblock_select = xblock_select
def _get_errorlog(self, location):
def get_course_errors(self, course_key):
"""
If we already have an errorlog for this location, return it. Otherwise,
create one.
"""
location = Location(location)
if location not in self._location_errors:
self._location_errors[location] = make_error_tracker()
return self._location_errors[location]
def get_item_errors(self, location):
"""
Return list of errors for this location, if any. Raise the same
errors as get_item if location isn't present.
NOTE: For now, the only items that track errors are CourseDescriptors in
the xml datastore. This will return an empty list for all other items
and datastores.
Return list of errors for this :class:`.CourseKey`, if any. Raise the same
errors as get_item if course_key isn't present.
"""
# check that item is present and raise the promised exceptions if needed
# TODO (vshnayder): post-launch, make errors properties of items
# self.get_item(location)
errorlog = self._get_errorlog(location)
return errorlog.errors
assert(isinstance(course_key, CourseKey))
return self._course_errors[course_key].errors
def get_errored_courses(self):
"""
@@ -528,42 +332,36 @@ class ModuleStoreReadBase(ModuleStoreRead):
"""
return {}
def get_course(self, course_id):
"""Default impl--linear search through course list"""
for c in self.get_courses():
if c.id == course_id:
return c
def get_course(self, course_id, depth=None):
"""
See ModuleStoreRead.get_course
Default impl--linear search through course list
"""
assert(isinstance(course_id, CourseKey))
for course in self.get_courses():
if course.id == course_id:
return course
return None
def update_item(self, xblock, user_id=None, allow_not_found=False, force=False):
def has_course(self, course_id, ignore_case=False):
"""
Update the given xblock's persisted repr. Pass the user's unique id which the persistent store
should save with the update if it has that ability.
:param allow_not_found: whether this method should raise an exception if the given xblock
has not been persisted before.
:param force: fork the structure and don't update the course draftVersion if there's a version
conflict (only applicable to version tracking and conflict detecting persistence stores)
:raises VersionConflictError: if package_id and version_guid given and the current
version head != version_guid and force is not True. (only applicable to version tracking stores)
Look for a specific course id. Returns whether it exists.
Args:
course_id (CourseKey):
ignore_case (boolean): some modulestores are case-insensitive. Use this flag
to search for whether a potentially conflicting course exists in that case.
"""
raise NotImplementedError
# linear search through list
assert(isinstance(course_id, CourseKey))
if ignore_case:
return any(
(c.id.org.lower() == course_id.org.lower() and c.id.offering.lower() == course_id.offering.lower())
for c in self.get_courses()
)
else:
return any(c.id == course_id for c in self.get_courses())
def delete_item(self, location, user_id=None, delete_all_versions=False, delete_children=False, force=False):
"""
Delete an item from persistence. Pass the user's unique id which the persistent store
should save with the update if it has that ability.
:param delete_all_versions: removes both the draft and published version of this item from
the course if using draft and old mongo. Split may or may not implement this.
:param force: fork the structure and don't update the course draftVersion if there's a version
conflict (only applicable to version tracking and conflict detecting persistence stores)
:raises VersionConflictError: if package_id and version_guid given and the current
version head != version_guid and force is not True. (only applicable to version tracking stores)
"""
raise NotImplementedError
class ModuleStoreWriteBase(ModuleStoreReadBase, ModuleStoreWrite):
'''
@@ -592,6 +390,36 @@ class ModuleStoreWriteBase(ModuleStoreReadBase, ModuleStoreWrite):
result[field.scope][field_name] = value
return result
def update_item(self, xblock, user_id=None, allow_not_found=False, force=False):
"""
Update the given xblock's persisted repr. Pass the user's unique id which the persistent store
should save with the update if it has that ability.
:param allow_not_found: whether this method should raise an exception if the given xblock
has not been persisted before.
:param force: fork the structure and don't update the course draftVersion if there's a version
conflict (only applicable to version tracking and conflict detecting persistence stores)
:raises VersionConflictError: if org, offering, and version_guid given and the current
version head != version_guid and force is not True. (only applicable to version tracking stores)
"""
raise NotImplementedError
def delete_item(self, location, user_id=None, delete_all_versions=False, delete_children=False, force=False):
"""
Delete an item from persistence. Pass the user's unique id which the persistent store
should save with the update if it has that ability.
:param delete_all_versions: removes both the draft and published version of this item from
the course if using draft and old mongo. Split may or may not implement this.
:param force: fork the structure and don't update the course draftVersion if there's a version
conflict (only applicable to version tracking and conflict detecting persistence stores)
:raises VersionConflictError: if org, offering, and version_guid given and the current
version head != version_guid and force is not True. (only applicable to version tracking stores)
"""
raise NotImplementedError
def only_xmodules(identifier, entry_points):
"""Only use entry_points that are supplied by the xmodule package"""

View File

@@ -66,7 +66,6 @@ def create_modulestore_instance(engine, doc_store_config, options, i18n_service=
return class_(
metadata_inheritance_cache_subsystem=metadata_inheritance_cache,
request_cache=request_cache,
modulestore_update_signal=Signal(providing_args=['modulestore', 'course_id', 'location']),
xblock_mixins=getattr(settings, 'XBLOCK_MIXINS', ()),
xblock_select=getattr(settings, 'XBLOCK_SELECT_FUNCTION', None),
doc_store_config=doc_store_config,

View File

@@ -37,6 +37,13 @@ class DuplicateItemError(Exception):
self.store = store
self.collection = collection
def __str__(self, *args, **kwargs):
"""
Print info about what's duplicated
"""
return '{0.store}[{0.collection}] already has {0.element_id}'.format(
self, Exception.__str__(self, *args, **kwargs)
)
class VersionConflictError(Exception):
"""

View File

@@ -5,11 +5,12 @@ from random import randint
import re
import pymongo
import bson.son
import urllib
from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError
from xmodule.modulestore.locator import BlockUsageLocator, CourseLocator
from xmodule.modulestore import Location
import urllib
from xmodule.modulestore.locations import SlashSeparatedCourseKey
from xmodule.modulestore.keys import UsageKey
class LocMapperStore(object):
@@ -27,6 +28,7 @@ class LocMapperStore(object):
or dominant store, but that's not a requirement. This store creates its own connection.
'''
SCHEMA_VERSION = 1
def __init__(
self, cache, host, db, collection, port=27017, user=None, password=None,
**kwargs
@@ -39,6 +41,7 @@ class LocMapperStore(object):
host=host,
port=port,
tz_aware=True,
document_class=bson.son.SON,
**kwargs
),
db
@@ -51,31 +54,21 @@ class LocMapperStore(object):
self.cache = cache
# location_map functions
def create_map_entry(self, course_location, package_id=None, draft_branch='draft', prod_branch='published',
def create_map_entry(self, course_key, org=None, offering=None, draft_branch='draft', prod_branch='published',
block_map=None):
"""
Add a new entry to map this course_location to the new style CourseLocator.package_id. If package_id is not
provided, it creates the default map of using org.course.name from the location if
the location.category = 'course'; otherwise, it uses org.course.
Add a new entry to map this SlashSeparatedCourseKey to the new style CourseLocator.org & offering. If
org and offering are not provided, it defaults them based on course_key.
You can create more than one mapping to the
same package_id target. In that case, the reverse translate will be arbitrary (no guarantee of which wins).
The use
case for more than one mapping is to map both org/course/run and org/course to the same new package_id thus
making a default for org/course. When querying for just org/course, the translator will prefer any entry
which does not have a name in the _id; otherwise, it will return an arbitrary match.
WARNING: Exactly 1 CourseLocator key should index a given SlashSeparatedCourseKey.
We provide no mechanism to enforce this assertion.
Note: the opposite is not true. That is, it never makes sense to use 2 different CourseLocator.package_id
keys to index the same old Locator org/course/.. pattern. There's no checking to ensure you don't do this.
NOTE: if there's already an entry w the given course_location, this may either overwrite that entry or
NOTE: if there's already an entry w the given course_key, this may either overwrite that entry or
throw an error depending on how mongo is configured.
:param course_location: a Location preferably whose category is 'course'. Unlike the other
map methods, this one doesn't take the old-style course_id. It should be called with
a course location not a block location; however, if called w/ a non-course Location, it creates
a "default" map for the org/course pair to a new package_id.
:param package_id: the CourseLocator style package_id
:param course_key (SlashSeparatedCourseKey): a SlashSeparatedCourseKey
:param org (string): the CourseLocator style org
:param offering (string): the CourseLocator offering
:param draft_branch: the branch name to assign for drafts. This is hardcoded because old mongo had
a fixed notion that there was 2 and only 2 versions for modules: draft and production. The old mongo
did not, however, require that a draft version exist. The new one, however, does require a draft to
@@ -85,54 +78,49 @@ class LocMapperStore(object):
to publish).
:param block_map: an optional map to specify preferred names for blocks where the keys are the
Location block names and the values are the BlockUsageLocator.block_id.
Returns:
:class:`CourseLocator` representing the new id for the course
Raises:
ValueError if one and only one of org and offering is provided. Provide either both or neither.
"""
if package_id is None:
if course_location.category == 'course':
package_id = u"{0.org}.{0.course}.{0.name}".format(course_location)
else:
package_id = u"{0.org}.{0.course}".format(course_location)
# very like _interpret_location_id but w/o the _id
location_id = self._construct_location_son(
course_location.org, course_location.course,
course_location.name if course_location.category == 'course' else None
)
# create location id with lower case
location_id_lower = self._construct_lower_location_son(
course_location.org, course_location.course,
course_location.name if course_location.category == 'course' else None
)
if org is None and offering is None:
assert(isinstance(course_key, SlashSeparatedCourseKey))
org = course_key.org
offering = u"{0.course}.{0.run}".format(course_key)
elif org is None or offering is None:
raise ValueError(
u"Either supply both org and offering or neither. Not just one: {}, {}".format(org, offering)
)
try:
self.location_map.insert({
'_id': location_id,
'lower_id': location_id_lower,
'course_id': package_id,
'lower_course_id': package_id.lower(),
'draft_branch': draft_branch,
'prod_branch': prod_branch,
'block_map': block_map or {},
})
except pymongo.errors.DuplicateKeyError:
# update old entry with 'lower_id' and 'lower_course_id'
location_update = {'lower_id': location_id_lower, 'lower_course_id': package_id.lower()}
self.location_map.update({'_id': location_id}, {'$set': location_update})
# very like _interpret_location_id but using mongo subdoc lookup (more performant)
course_son = self._construct_course_son(course_key)
return package_id
self.location_map.insert({
'_id': course_son,
'org': org,
'lower_org': org.lower(),
'offering': offering,
'lower_offering': offering.lower(),
'draft_branch': draft_branch,
'prod_branch': prod_branch,
'block_map': block_map or {},
'schema': self.SCHEMA_VERSION,
})
def translate_location(self, old_style_course_id, location, published=True,
return CourseLocator(org, offering)
def translate_location(self, location, published=True,
add_entry_if_missing=True, passed_block_id=None):
"""
Translate the given module location to a Locator. If the mapping has the run id in it, then you
should provide old_style_course_id with that run id in it to disambiguate the mapping if there exists more
than one entry in the mapping table for the org.course.
Translate the given module location to a Locator.
The rationale for auto adding entries was that there should be a reasonable default translation
if the code just trips into this w/o creating translations. The downfall is that ambiguous course
locations may generate conflicting block_ids.
if the code just trips into this w/o creating translations.
Will raise ItemNotFoundError if there's no mapping and add_entry_if_missing is False.
:param old_style_course_id: the course_id used in old mongo not the new one (optional, will use location)
:param location: a Location pointing to a module
:param published: a boolean to indicate whether the caller wants the draft or published branch.
:param add_entry_if_missing: a boolean as to whether to raise ItemNotFoundError or to create an entry if
@@ -144,43 +132,32 @@ class LocMapperStore(object):
NOTE: unlike old mongo, draft branches contain the whole course; so, it applies to all category
of locations including course.
"""
location_id = self._interpret_location_course_id(old_style_course_id, location)
if old_style_course_id is None:
old_style_course_id = self._generate_location_course_id(location_id)
course_son = self._interpret_location_course_id(location.course_key)
cached_value = self._get_locator_from_cache(old_style_course_id, location, published)
cached_value = self._get_locator_from_cache(location, published)
if cached_value:
return cached_value
maps = self.location_map.find(location_id)
maps = list(maps)
if len(maps) == 0:
entry = self.location_map.find_one(course_son)
if entry is None:
if add_entry_if_missing:
# create a new map
course_location = location.replace(category='course', name=location_id['_id']['name'])
self.create_map_entry(course_location)
entry = self.location_map.find_one(location_id)
self.create_map_entry(location.course_key)
entry = self.location_map.find_one(course_son)
else:
raise ItemNotFoundError(location)
elif len(maps) == 1:
entry = maps[0]
else:
# find entry w/o name, if any; otherwise, pick arbitrary
entry = maps[0]
for item in maps:
if 'name' not in item['_id']:
entry = item
break
entry = self._migrate_if_necessary([entry])[0]
block_id = entry['block_map'].get(self.encode_key_for_mongo(location.name))
if block_id is None:
if add_entry_if_missing:
block_id = self._add_to_block_map(
location, location_id, entry['block_map'], passed_block_id
location, course_son, entry['block_map'], passed_block_id
)
else:
raise ItemNotFoundError(location)
elif isinstance(block_id, dict):
else:
# jump_to_id uses a None category.
if location.category is None:
if len(block_id) == 1:
@@ -191,22 +168,29 @@ class LocMapperStore(object):
elif location.category in block_id:
block_id = block_id[location.category]
elif add_entry_if_missing:
block_id = self._add_to_block_map(location, location_id, entry['block_map'])
block_id = self._add_to_block_map(location, course_son, entry['block_map'])
else:
raise ItemNotFoundError(location)
else:
raise InvalidLocationError()
prod_course_locator = CourseLocator(
org=entry['org'],
offering=entry['offering'],
branch=entry['prod_branch']
)
published_usage = BlockUsageLocator(
package_id=entry['course_id'], branch=entry['prod_branch'], block_id=block_id)
prod_course_locator,
block_id=block_id
)
draft_usage = BlockUsageLocator(
package_id=entry['course_id'], branch=entry['draft_branch'], block_id=block_id)
prod_course_locator.for_branch(entry['draft_branch']),
block_id=block_id
)
if published:
result = published_usage
else:
result = draft_usage
self._cache_location_map_entry(old_style_course_id, location, published_usage, draft_usage)
self._cache_location_map_entry(location, published_usage, draft_usage)
return result
def translate_locator_to_location(self, locator, get_course=False, lower_only=False):
@@ -217,18 +201,22 @@ class LocMapperStore(object):
the block's block_id was previously stored in the
map (a side effect of translate_location or via add|update_block_location).
If get_course, then rather than finding the map for this locator, it finds the 'course' root
for the mapped course.
If there are no matches, it returns None.
If there's more than one location to locator mapping to the same package_id, it looks for the first
one with a mapping for the block block_id and picks that arbitrary course location.
:param locator: a BlockUsageLocator
Args:
locator: a BlockUsageLocator to translate
get_course: rather than finding the map for this locator, returns the CourseKey
for the mapped course.
lower_only: (obsolete?) the locator's fields are lowercased and not the actual case
for the identifier (e.g., came from a sql db which lowercases all ids). Find the actual
case Location for the desired object
"""
if get_course:
cached_value = self._get_course_location_from_cache(locator.package_id, lower_only)
cached_value = self._get_course_location_from_cache(
# if locator is already a course_key it won't have course_key attr
getattr(locator, 'course_key', locator),
lower_only
)
else:
cached_value = self._get_location_from_cache(locator)
if cached_value:
@@ -237,90 +225,118 @@ class LocMapperStore(object):
# This does not require that the course exist in any modulestore
# only that it has a mapping entry.
if lower_only:
maps = self.location_map.find({'lower_course_id': locator.package_id.lower()})
else:
maps = self.location_map.find({'course_id': locator.package_id})
# look for one which maps to this block block_id
if maps.count() == 0:
return None
result = None
for candidate in maps:
if get_course and 'name' in candidate['_id']:
candidate_id = candidate['_id']
return Location(
'i4x', candidate_id['org'], candidate_id['course'], 'course', candidate_id['name']
# migrate any records which don't have the lower_org and lower_offering fields as
# this won't be able to find what it wants. (only needs to be run once ever per db,
# I'm not sure how to control that, but I'm putting some check here for once per launch)
if not getattr(self, 'lower_offering_migrated', False):
obsolete = self.location_map.find(
{'lower_org': {"$exists": False}, "lower_offering": {"$exists": False}, }
)
old_course_id = self._generate_location_course_id(candidate['_id'])
for old_name, cat_to_usage in candidate['block_map'].iteritems():
for category, block_id in cat_to_usage.iteritems():
# cache all entries and then figure out if we have the one we want
# Always return revision=None because the
# old draft module store wraps locations as draft before
# trying to access things.
location = Location(
'i4x',
candidate['_id']['org'],
candidate['_id']['course'],
category,
self.decode_key_from_mongo(old_name),
None)
self._migrate_if_necessary(obsolete)
setattr(self, 'lower_offering_migrated', True)
if lower_only:
candidate_key = "lower_course_id"
else:
candidate_key = "course_id"
entry = self.location_map.find_one(bson.son.SON([
('lower_org', locator.org.lower()),
('lower_offering', locator.offering.lower()),
]))
else:
# migrate any records which don't have the lower_org and lower_offering fields as
# this won't be able to find what it wants. (only needs to be run once ever per db,
# I'm not sure how to control that, but I'm putting some check here for once per launch)
if not getattr(self, 'offering_migrated', False):
obsolete = self.location_map.find(
{'org': {"$exists": False}, "offering": {"$exists": False}, }
)
self._migrate_if_necessary(obsolete)
setattr(self, 'offering_migrated', True)
published_locator = BlockUsageLocator(
candidate[candidate_key], branch=candidate['prod_branch'], block_id=block_id
)
draft_locator = BlockUsageLocator(
candidate[candidate_key], branch=candidate['draft_branch'], block_id=block_id
)
self._cache_location_map_entry(old_course_id, location, published_locator, draft_locator)
entry = self.location_map.find_one(bson.son.SON([
('org', locator.org),
('lower_offering', locator.offering),
]))
# look for one which maps to this block block_id
if entry is None:
return None
old_course_id = self._generate_location_course_id(entry['_id'])
if get_course:
return old_course_id
for old_name, cat_to_usage in entry['block_map'].iteritems():
for category, block_id in cat_to_usage.iteritems():
# cache all entries and then figure out if we have the one we want
# Always return revision=None because the
# old draft module store wraps locations as draft before
# trying to access things.
location = old_course_id.make_usage_key(
category,
self.decode_key_from_mongo(old_name)
)
if lower_only:
entry_org = "lower_org"
entry_offering = "lower_offering"
else:
entry_org = "org"
entry_offering = "offering"
published_locator = BlockUsageLocator(
CourseLocator(
org=entry[entry_org], offering=entry[entry_offering],
branch=entry['prod_branch']
),
block_id=block_id
)
draft_locator = BlockUsageLocator(
CourseLocator(
org=entry[entry_org], offering=entry[entry_offering],
branch=entry['draft_branch']
),
block_id=block_id
)
self._cache_location_map_entry(location, published_locator, draft_locator)
if block_id == locator.block_id:
return location
if get_course and category == 'course':
result = location
elif not get_course and block_id == locator.block_id:
result = location
if result is not None:
return result
return None
def translate_location_to_course_locator(self, old_style_course_id, location, published=True, lower_only=False):
def translate_location_to_course_locator(self, course_key, published=True):
"""
Used when you only need the CourseLocator and not a full BlockUsageLocator. Probably only
useful for get_items which wildcards name or category.
:param course_id: old style course id
:param course_key: a CourseKey or a UsageKey
:param published: a boolean representing whether or not we should return the published or draft version
Returns a Courselocator
"""
cached = self._get_course_locator_from_cache(old_style_course_id, published)
if isinstance(course_key, UsageKey):
course_key = course_key.course_key
cached = self._get_course_locator_from_cache(course_key, published)
if cached:
return cached
location_id = self._interpret_location_course_id(old_style_course_id, location, lower_only)
course_son = self._interpret_location_course_id(course_key)
maps = self.location_map.find(location_id)
maps = list(maps)
if len(maps) == 0:
raise ItemNotFoundError(location)
elif len(maps) == 1:
entry = maps[0]
else:
# find entry w/o name, if any; otherwise, pick arbitrary
entry = maps[0]
for item in maps:
if 'name' not in item['_id']:
entry = item
break
published_course_locator = CourseLocator(package_id=entry['course_id'], branch=entry['prod_branch'])
draft_course_locator = CourseLocator(package_id=entry['course_id'], branch=entry['draft_branch'])
self._cache_course_locator(old_style_course_id, published_course_locator, draft_course_locator)
entry = self.location_map.find_one(course_son)
if entry is None:
raise ItemNotFoundError(course_key)
published_course_locator = CourseLocator(
org=entry['org'], offering=entry['offering'], branch=entry['prod_branch']
)
draft_course_locator = CourseLocator(
org=entry['org'], offering=entry['offering'], branch=entry['draft_branch']
)
self._cache_course_locator(course_key, published_course_locator, draft_course_locator)
if published:
return published_course_locator
else:
return draft_course_locator
def _add_to_block_map(self, location, location_id, block_map, block_id=None):
def _add_to_block_map(self, location, course_son, block_map, block_id=None):
'''add the given location to the block_map and persist it'''
if block_id is None:
if self._block_id_is_guid(location.name):
@@ -335,63 +351,33 @@ class LocMapperStore(object):
block_id = self._verify_uniqueness(location.name, block_map)
encoded_location_name = self.encode_key_for_mongo(location.name)
block_map.setdefault(encoded_location_name, {})[location.category] = block_id
self.location_map.update(location_id, {'$set': {'block_map': block_map}})
self.location_map.update(course_son, {'$set': {'block_map': block_map}})
return block_id
def _interpret_location_course_id(self, course_id, location, lower_only=False):
def _interpret_location_course_id(self, course_key):
"""
Take the old style course id (org/course/run) and return a dict w/ a SON for querying the mapping table.
If the course_id is empty, it uses location, but this may result in an inadequate id.
Take a CourseKey and return a SON for querying the mapping table.
:param course_id: old style 'org/course/run' id from Location.course_id where Location.category = 'course'
:param location: a Location object which may be to a module or a course. Provides partial info
if course_id is omitted.
:param course_key: a CourseKey object for a course.
"""
if course_id:
# re doesn't allow ?P<_id.org> and ilk
matched = re.match(r'([^/]+)/([^/]+)/([^/]+)', course_id)
if lower_only:
return {'lower_id': self._construct_lower_location_son(*matched.groups())}
return {'_id': self._construct_location_son(*matched.groups())}
if location.category == 'course':
if lower_only:
return {'lower_id': self._construct_lower_location_son(location.org, location.course, location.name)}
return {'_id': self._construct_location_son(location.org, location.course, location.name)}
else:
return bson.son.SON([('_id.org', location.org), ('_id.course', location.course)])
return {'_id': self._construct_course_son(course_key)}
def _generate_location_course_id(self, entry_id):
"""
Generate a Location course_id for the given entry's id.
Generate a CourseKey for the given entry's id.
"""
# strip id envelope if any
entry_id = entry_id.get('_id', entry_id)
if entry_id.get('name', False):
return u'{0[org]}/{0[course]}/{0[name]}'.format(entry_id)
elif entry_id.get('_id.org', False):
# the odd format one
return u'{0[_id.org]}/{0[_id.course]}'.format(entry_id)
else:
return u'{0[org]}/{0[course]}'.format(entry_id)
return SlashSeparatedCourseKey(entry_id['org'], entry_id['course'], entry_id['name'])
def _construct_location_son(self, org, course, name=None):
def _construct_course_son(self, course_key):
"""
Construct the SON needed to repr the location for either a query or an insertion
Construct the SON needed to repr the course_key for either a query or an insertion
"""
if name:
return bson.son.SON([('org', org), ('course', course), ('name', name)])
else:
return bson.son.SON([('org', org), ('course', course)])
def _construct_lower_location_son(self, org, course, name=None):
"""
Construct the SON needed to represent the location with lower case
"""
if name is not None:
name = name.lower()
return self._construct_location_son(org.lower(), course.lower(), name)
assert(isinstance(course_key, SlashSeparatedCourseKey))
return bson.son.SON([
('org', course_key.org),
('course', course_key.course),
('name', course_key.run)
])
def _block_id_is_guid(self, name):
"""
@@ -434,11 +420,11 @@ class LocMapperStore(object):
"""
return urllib.unquote(fieldname)
def _get_locator_from_cache(self, old_course_id, location, published):
def _get_locator_from_cache(self, location, published):
"""
See if the location x published pair is in the cache. If so, return the mapped locator.
"""
entry = self.cache.get(u'{}+{}'.format(old_course_id, location.url()))
entry = self.cache.get(u'{}+{}'.format(location.course_key, location))
if entry is not None:
if published:
return entry[0]
@@ -452,12 +438,12 @@ class LocMapperStore(object):
"""
if not old_course_id:
return None
entry = self.cache.get(old_course_id)
entry = self.cache.get(unicode(old_course_id))
if entry is not None:
if published:
return entry[0].as_course_locator()
return entry[0].course_key
else:
return entry[1].as_course_locator()
return entry[1].course_key
def _get_location_from_cache(self, locator):
"""
@@ -483,9 +469,9 @@ class LocMapperStore(object):
"""
if not old_course_id:
return
self.cache.set(old_course_id, (published_course_locator, draft_course_locator))
self.cache.set(unicode(old_course_id), (published_course_locator, draft_course_locator))
def _cache_location_map_entry(self, old_course_id, location, published_usage, draft_usage):
def _cache_location_map_entry(self, location, published_usage, draft_usage):
"""
Cache the mapping from location to the draft and published Locators in entry.
Also caches the inverse. If the location is category=='course', it caches it for
@@ -497,25 +483,48 @@ class LocMapperStore(object):
setmany[u'courseIdLower+{}'.format(published_usage.package_id.lower())] = location
setmany[unicode(published_usage)] = location
setmany[unicode(draft_usage)] = location
setmany[u'{}+{}'.format(old_course_id, location.url())] = (published_usage, draft_usage)
setmany[old_course_id] = (published_usage, draft_usage)
setmany[unicode(location)] = (published_usage, draft_usage)
setmany[unicode(location.course_key)] = (published_usage, draft_usage)
self.cache.set_many(setmany)
def delete_course_mapping(self, course_location):
def delete_course_mapping(self, course_key):
"""
Remove provided course location from loc_mapper and cache.
:param course_location: a Location whose category is 'course'.
:param course_key: a CourseKey for the course we wish to delete
"""
course_locator = self.translate_location(course_location.course_id, course_location)
course_locator_draft = self.translate_location(
course_location.course_id, course_location, published=False
)
self.location_map.remove(self._interpret_location_course_id(course_key))
self.location_map.remove({'course_id': course_locator.package_id})
self._delete_cache_location_map_entry(
course_location.course_id, course_location, course_locator, course_locator_draft
)
# Remove the location of course (draft and published) from cache
cached_key = self.cache.get(unicode(course_key))
if cached_key:
delete_keys = []
published_locator = unicode(cached_key[0].course_key)
course_location = self._course_location_from_cache(published_locator)
delete_keys.append(u'courseId+{}'.format(published_locator))
delete_keys.append(u'courseIdLower+{}'.format(unicode(cached_key[0].course_key).lower()))
delete_keys.append(published_locator)
delete_keys.append(unicode(cached_key[1].course_key))
delete_keys.append(unicode(course_location))
delete_keys.append(unicode(course_key))
self.cache.delete_many(delete_keys)
def _migrate_if_necessary(self, entries):
"""
Run the entries through any applicable schema updates and return the updated entries
"""
entries = [
self._migrate[entry.get('schema', 0)](self, entry)
for entry in entries
]
return entries
def _entry_id_to_son(self, entry_id):
return bson.son.SON([
('org', entry_id['org']),
('course', entry_id['course']),
('name', entry_id['name'])
])
def _delete_cache_location_map_entry(self, old_course_id, location, published_usage, draft_usage):
"""
@@ -528,6 +537,50 @@ class LocMapperStore(object):
delete_keys.append(unicode(published_usage))
delete_keys.append(unicode(draft_usage))
delete_keys.append(u'{}+{}'.format(old_course_id, location.url()))
delete_keys.append(u'{}+{}'.format(old_course_id, location.to_deprecated_string()))
delete_keys.append(old_course_id)
self.cache.delete_many(delete_keys)
def _migrate_top(self, entry, updated=False):
"""
Current version, so a no data change until next update. But since it's the top
it's responsible for persisting the record if it changed.
"""
if updated:
entry['schema'] = self.SCHEMA_VERSION
entry_id = self._entry_id_to_son(entry['_id'])
self.location_map.update({'_id': entry_id}, entry)
return entry
def _migrate_0(self, entry):
"""
If entry had an '_id' without a run, remove the whole record.
Add fields: schema, org, offering, lower_org, and lower_offering
Remove: course_id, lower_course_id
:param entry:
"""
if 'name' not in entry['_id']:
entry_id = entry['_id']
entry_id = bson.son.SON([
('org', entry_id['org']),
('course', entry_id['course']),
])
self.location_map.remove({'_id': entry_id})
return None
# add schema, org, offering, etc, remove old fields
entry['schema'] = 0
entry.pop('course_id', None)
entry.pop('lower_course_id', None)
old_course_id = SlashSeparatedCourseKey(entry['_id']['org'], entry['_id']['course'], entry['_id']['name'])
entry['org'] = old_course_id.org
entry['lower_org'] = old_course_id.org.lower()
entry['offering'] = old_course_id.offering.replace('/', '+')
entry['lower_offering'] = entry['offering'].lower()
return self._migrate_1(entry, True)
# insert new migrations just before _migrate_top. _migrate_top sets the schema version and
# saves the record
_migrate = [_migrate_0, _migrate_top]

View File

@@ -5,17 +5,23 @@ Identifier for course resources.
from __future__ import absolute_import
import logging
import inspect
from abc import ABCMeta, abstractmethod
import re
from abc import abstractmethod
from bson.objectid import ObjectId
from bson.errors import InvalidId
from xmodule.modulestore.exceptions import InsufficientSpecificationError, OverSpecificationError
from opaque_keys import OpaqueKey, InvalidKeyError
from .parsers import parse_url, parse_package_id, parse_block_ref
from .parsers import BRANCH_PREFIX, BLOCK_PREFIX, VERSION_PREFIX
import re
from xmodule.modulestore import Location
from xmodule.modulestore.keys import CourseKey, UsageKey
from xmodule.modulestore.parsers import (
parse_url,
parse_block_ref,
BRANCH_PREFIX,
BLOCK_PREFIX,
VERSION_PREFIX,
ALLOWED_ID_RE)
log = logging.getLogger(__name__)
@@ -32,41 +38,21 @@ class LocalId(object):
return "localid_{}".format(self.block_id or id(self))
class Locator(object):
class Locator(OpaqueKey):
"""
A locator is like a URL, it refers to a course resource.
Locator is an abstract base class: do not instantiate
"""
__metaclass__ = ABCMeta
@abstractmethod
def url(self):
"""
Return a string containing the URL for this location. Raises
InsufficientSpecificationError if the instance doesn't have a
InvalidKeyError if the instance doesn't have a
complete enough specification to generate a url
"""
raise InsufficientSpecificationError()
def __eq__(self, other):
return self.__dict__ == other.__dict__
def __hash__(self):
"""
Hash on contents.
"""
return hash(unicode(self))
def __repr__(self):
'''
repr(self) returns something like this: CourseLocator("mit.eecs.6002x")
'''
classname = self.__class__.__name__
if classname.find('.') != -1:
classname = classname.split['.'][-1]
return '%s("%s")' % (classname, unicode(self))
raise NotImplementedError()
def __str__(self):
'''
@@ -74,73 +60,14 @@ class Locator(object):
'''
return unicode(self).encode('utf-8')
def __unicode__(self):
'''
unicode(self) returns something like this: "mit.eecs.6002x"
'''
return unicode(self).encode('utf-8')
@abstractmethod
def version(self):
"""
Returns the ObjectId referencing this specific location.
Raises InsufficientSpecificationError if the instance
Raises InvalidKeyError if the instance
doesn't have a complete enough specification.
"""
raise InsufficientSpecificationError()
def set_property(self, property_name, new):
"""
Initialize property to new value.
If property has already been initialized to a different value, raise an exception.
"""
current = getattr(self, property_name)
if current and current != new:
raise OverSpecificationError('%s cannot be both %s and %s' %
(property_name, current, new))
setattr(self, property_name, new)
@staticmethod
def to_locator_or_location(location):
"""
Convert the given locator like thing to the appropriate type of object, or, if already
that type, just return it. Returns an old Location, BlockUsageLocator,
or DefinitionLocator.
:param location: can be a Location, Locator, string, tuple, list, or dict.
"""
if isinstance(location, (Location, Locator)):
return location
if isinstance(location, basestring):
return Locator.parse_url(location)
if isinstance(location, (list, tuple)):
return Location(location)
if isinstance(location, dict) and 'name' in location:
return Location(location)
if isinstance(location, dict):
return BlockUsageLocator(**location)
raise ValueError(location)
URL_TAG_RE = re.compile(r'^(\w+)://')
@staticmethod
def parse_url(url):
"""
Parse the url into one of the Locator types (must have a tag indicating type)
Return the new instance. Supports i4x, cvx, edx, defx
:param url: the url to parse
"""
parsed = Locator.URL_TAG_RE.match(url)
if parsed is None:
raise ValueError(parsed)
parsed = parsed.group(1)
if parsed in ['i4x', 'c4x']:
return Location(url)
elif parsed == 'edx':
return BlockUsageLocator(url)
elif parsed == 'defx':
return DefinitionLocator(url)
return None
raise NotImplementedError()
@classmethod
def as_object_id(cls, value):
@@ -154,60 +81,203 @@ class Locator(object):
raise ValueError('"%s" is not a valid version_guid' % value)
class CourseLocator(Locator):
class BlockLocatorBase(Locator):
# Token separating org from offering
ORG_SEPARATOR = '+'
def version(self):
"""
Returns the ObjectId referencing this specific location.
"""
return self.version_guid
def url(self):
"""
Return a string containing the URL for this location.
"""
return self.NAMESPACE_SEPARATOR.join([self.CANONICAL_NAMESPACE, self._to_string()])
@classmethod
def _parse_url(cls, url):
"""
url must be a string beginning with 'edx:' and containing
either a valid version_guid or org & offering (with optional branch), or both.
"""
if not isinstance(url, basestring):
raise TypeError('%s is not an instance of basestring' % url)
parse = parse_url(url)
if not parse:
raise InvalidKeyError(cls, url)
if parse['version_guid']:
parse['version_guid'] = cls.as_object_id(parse['version_guid'])
return parse
@property
def package_id(self):
if self.org and self.offering:
return u'{}{}{}'.format(self.org, self.ORG_SEPARATOR, self.offering)
else:
return None
class CourseLocator(BlockLocatorBase, CourseKey):
"""
Examples of valid CourseLocator specifications:
CourseLocator(version_guid=ObjectId('519665f6223ebd6980884f2b'))
CourseLocator(package_id='mit.eecs.6002x')
CourseLocator(package_id='mit.eecs.6002x/branch/published')
CourseLocator(package_id='mit.eecs.6002x', branch='published')
CourseLocator(url='edx://version/519665f6223ebd6980884f2b')
CourseLocator(url='edx://mit.eecs.6002x')
CourseLocator(url='edx://mit.eecs.6002x/branch/published')
CourseLocator(url='edx://mit.eecs.6002x/branch/published/version/519665f6223ebd6980884f2b')
CourseLocator(org='mit.eecs', offering='6.002x')
CourseLocator(org='mit.eecs', offering='6002x', branch = 'published')
CourseLocator.from_string('edx:version/519665f6223ebd6980884f2b')
CourseLocator.from_string('version/519665f6223ebd6980884f2b')
CourseLocator.from_string('edx:mit.eecs+6002x')
CourseLocator.from_string('mit.eecs+6002x')
CourseLocator.from_string('edx:mit.eecs+6002x/branch/published')
CourseLocator.from_string('edx:mit.eecs+6002x/branch/published/version/519665f6223ebd6980884f2b')
CourseLocator.from_string('mit.eecs+6002x/branch/published/version/519665f6223ebd6980884f2b')
Should have at lease a specific package_id (id for the course as if it were a project w/
Should have at least a specific org & offering (id for the course as if it were a project w/
versions) with optional 'branch',
or version_guid (which points to a specific version). Can contain both in which case
the persistence layer may raise exceptions if the given version != the current such version
of the course.
"""
CANONICAL_NAMESPACE = 'course-locator'
KEY_FIELDS = ('org', 'offering', 'branch', 'version_guid')
# Default values
version_guid = None
package_id = None
branch = None
# stubs to fake out the abstractproperty class instrospection and allow treatment as attrs in instances
org = None
offering = None
def __init__(self, url=None, version_guid=None, package_id=None, branch=None):
def __init__(self, org=None, offering=None, branch=None, version_guid=None):
"""
Construct a CourseLocator
Caller may provide url (but no other parameters).
Caller may provide version_guid (but no other parameters).
Caller may provide package_id (optionally provide branch).
Resulting CourseLocator will have either a version_guid property
or a package_id (with optional branch) property, or both.
version_guid must be an instance of bson.objectid.ObjectId or None
url, package_id, and branch must be strings or None
Args:
version_guid (string or ObjectId): optional unique id for the version
org, offering (string): the standard definition. Optional only if version_guid given
branch (string): the branch such as 'draft', 'published', 'staged', 'beta'
"""
self._validate_args(url, version_guid, package_id)
if url:
self.init_from_url(url)
if version_guid:
self.init_from_version_guid(version_guid)
if package_id or branch:
self.init_from_package_id(package_id, branch)
if self.version_guid is None and self.package_id is None:
raise ValueError("Either version_guid or package_id should be set: {}".format(url))
version_guid = self.as_object_id(version_guid)
def __unicode__(self):
if not all(field is None or ALLOWED_ID_RE.match(field) for field in [org, offering, branch]):
raise InvalidKeyError(self.__class__, [org, offering, branch])
super(CourseLocator, self).__init__(
org=org,
offering=offering,
branch=branch,
version_guid=version_guid
)
if self.version_guid is None and self.org is None and self.offering is None:
raise InvalidKeyError(self.__class__, "Either version_guid or org and offering should be set")
@classmethod
def _from_string(cls, serialized):
"""
Return a CourseLocator parsing the given serialized string
:param serialized: matches the string to a CourseLocator
"""
kwargs = cls._parse_url(serialized)
try:
return cls(**{key: kwargs.get(key) for key in cls.KEY_FIELDS})
except ValueError:
raise InvalidKeyError(cls, "Either version_guid or org and offering should be set: {}".format(serialized))
def is_fully_specified(self):
"""
Returns True if either version_guid is specified, or org+offering+branch
are specified.
This should always return True, since this should be validated in the constructor.
"""
return (
self.version_guid is not None or
(self.org is not None and self.offering is not None and self.branch is not None)
)
def html_id(self):
"""
Generate a discussion group id based on course
To make compatible with old Location object functionality. I don't believe this behavior fits at this
place, but I have no way to override. We should clearly define the purpose and restrictions of this
(e.g., I'm assuming periods are fine).
"""
return unicode(self)
def make_usage_key(self, block_type, block_id):
return BlockUsageLocator(
course_key=self,
block_id=block_id
)
def make_asset_key(self, asset_type, path):
raise NotImplementedError()
def version_agnostic(self):
"""
We don't care if the locator's version is not the current head; so, avoid version conflict
by reducing info.
Returns a copy of itself without any version info.
:raises: ValueError if the block locator has no org & offering
"""
return CourseLocator(
org=self.org,
offering=self.offering,
branch=self.branch,
version_guid=None
)
def course_agnostic(self):
"""
We only care about the locator's version not its course.
Returns a copy of itself without any course info.
:raises: ValueError if the block locator has no version_guid
"""
return CourseLocator(
org=None,
offering=None,
branch=None,
version_guid=self.version_guid
)
def for_branch(self, branch):
"""
Return a new CourseLocator for another branch of the same course (also version agnostic)
"""
if self.org is None:
raise InvalidKeyError(self.__class__, "Branches must have full course ids not just versions")
return CourseLocator(
org=self.org,
offering=self.offering,
branch=branch,
version_guid=None
)
def for_version(self, version_guid):
"""
Return a new CourseLocator for another version of the same course and branch. Usually used
when the head is updated (and thus the course x branch now points to this version)
"""
return CourseLocator(
org=self.org,
offering=self.offering,
branch=self.branch,
version_guid=version_guid
)
def _to_string(self):
"""
Return a string representing this location.
"""
parts = []
if self.package_id:
if self.offering:
parts.append(unicode(self.package_id))
if self.branch:
parts.append(u"{prefix}{branch}".format(prefix=BRANCH_PREFIX, branch=self.branch))
@@ -215,60 +285,150 @@ class CourseLocator(Locator):
parts.append(u"{prefix}{guid}".format(prefix=VERSION_PREFIX, guid=self.version_guid))
return u"/".join(parts)
def url(self):
"""
Return a string containing the URL for this location.
"""
return u'edx://' + unicode(self)
def _validate_args(self, url, version_guid, package_id):
class BlockUsageLocator(BlockLocatorBase, UsageKey): # TODO implement UsageKey methods
"""
Encodes a location.
Locations address modules (aka blocks) which are definitions situated in a
course instance. Thus, a Location must identify the course and the occurrence of
the defined element in the course. Courses can be a version of an offering, the
current draft head, or the current production version.
Locators can contain both a version and a org + offering w/ branch. The split mongo functions
may raise errors if these conflict w/ the current db state (i.e., the course's branch !=
the version_guid)
Locations can express as urls as well as dictionaries. They consist of
package_identifier: course_guid | version_guid
block : guid
branch : string
"""
CANONICAL_NAMESPACE = 'edx'
KEY_FIELDS = ('course_key', 'block_id')
# fake out class instrospection as this is an attr in this class's instances
course_key = None
def __init__(self, course_key, block_id):
"""
Validate provided arguments. Internal use only which is why it checks for each
arg and doesn't use keyword
Construct a BlockUsageLocator
"""
if not any((url, version_guid, package_id)):
raise InsufficientSpecificationError("Must provide one of url, version_guid, package_id")
block_id = self._parse_block_ref(block_id)
if block_id is None:
raise InvalidKeyError(self.__class__, "Missing block id")
super(BlockUsageLocator, self).__init__(course_key=course_key, block_id=block_id)
@classmethod
def _from_string(cls, serialized):
"""
Requests CourseLocator to deserialize its part and then adds the local deserialization of block
"""
course_key = CourseLocator._from_string(serialized)
parsed_parts = parse_url(serialized)
block_id = parsed_parts.get('block_id')
if block_id is None:
raise InvalidKeyError(cls, serialized)
return cls(course_key, block_id)
def version_agnostic(self):
"""
We don't care if the locator's version is not the current head; so, avoid version conflict
by reducing info.
Returns a copy of itself without any version info.
:raises: ValueError if the block locator has no org and offering
"""
return BlockUsageLocator(
course_key=self.course_key.version_agnostic(),
block_id=self.block_id
)
def course_agnostic(self):
"""
We only care about the locator's version not its course.
Returns a copy of itself without any course info.
:raises: ValueError if the block locator has no version_guid
"""
return BlockUsageLocator(
course_key=self.course_key.course_agnostic(),
block_id=self.block_id
)
def for_branch(self, branch):
"""
Return a UsageLocator for the same block in a different branch of the course.
"""
return BlockUsageLocator(
self.course_key.for_branch(branch),
block_id=self.block_id
)
@classmethod
def _parse_block_ref(cls, block_ref):
if isinstance(block_ref, LocalId):
return block_ref
else:
parse = parse_block_ref(block_ref)
if not parse:
raise InvalidKeyError(cls, block_ref)
return parse.get('block_id')
@property
def definition_key(self):
raise NotImplementedError()
@property
def org(self):
return self.course_key.org
@property
def offering(self):
return self.course_key.offering
@property
def package_id(self):
return self.course_key.package_id
@property
def branch(self):
return self.course_key.branch
@property
def version_guid(self):
return self.course_key.version_guid
@property
def name(self):
"""
The ambiguously named field from Location which code expects to find
"""
return self.block_id
def is_fully_specified(self):
"""
Returns True if either version_guid is specified, or package_id+branch
are specified.
This should always return True, since this should be validated in the constructor.
"""
return (self.version_guid is not None or
(self.package_id is not None and self.branch is not None))
return self.course_key.is_fully_specified()
def set_package_id(self, new):
@classmethod
def make_relative(cls, course_locator, block_id):
"""
Initialize package_id to new value.
If package_id has already been initialized to a different value, raise an exception.
Return a new instance which has the given block_id in the given course
:param course_locator: may be a BlockUsageLocator in the same snapshot
"""
self.set_property('package_id', new)
if hasattr(course_locator, 'course_key'):
course_locator = course_locator.course_key
return BlockUsageLocator(
course_key=course_locator,
block_id=block_id
)
def set_branch(self, new):
def map_into_course(self, course_key):
"""
Initialize branch to new value.
If branch has already been initialized to a different value, raise an exception.
Return a new instance which has the this block_id in the given course
:param course_key: a CourseKey object representing the new course to map into
"""
self.set_property('branch', new)
def set_version_guid(self, new):
"""
Initialize version_guid to new value.
If version_guid has already been initialized to a different value, raise an exception.
"""
self.set_property('version_guid', new)
def as_course_locator(self):
"""
Returns a copy of itself (downcasting) as a CourseLocator.
The copy has the same CourseLocator fields as the original.
The copy does not include subclass information, such as
a block_id (a property of BlockUsageLocator).
"""
return CourseLocator(package_id=self.package_id,
version_guid=self.version_guid,
branch=self.branch)
return BlockUsageLocator.make_relative(course_key, self.block_id)
def url_reverse(self, prefix, postfix=''):
"""
@@ -289,71 +449,15 @@ class CourseLocator(Locator):
postfix = ''
return prefix + unicode(self) + postfix
def init_from_url(self, url):
def _to_string(self):
"""
url must be a string beginning with 'edx://' and containing
either a valid version_guid or package_id (with optional branch), or both.
Return a string representing this location.
"""
if isinstance(url, Locator):
parse = url.__dict__
elif not isinstance(url, basestring):
raise TypeError('%s is not an instance of basestring' % url)
else:
parse = parse_url(url, tag_optional=True)
if not parse:
raise ValueError('Could not parse "%s" as a url' % url)
self._set_value(
parse, 'version_guid', lambda (new_guid): self.set_version_guid(self.as_object_id(new_guid))
return u"{course_key}/{BLOCK_PREFIX}{block_id}".format(
course_key=self.course_key._to_string(),
BLOCK_PREFIX=BLOCK_PREFIX,
block_id=self.block_id
)
self._set_value(parse, 'package_id', self.set_package_id)
self._set_value(parse, 'branch', self.set_branch)
def init_from_version_guid(self, version_guid):
"""
version_guid must be an instance of bson.objectid.ObjectId,
or able to be cast as one.
If it's a string, attempt to cast it as an ObjectId first.
"""
version_guid = self.as_object_id(version_guid)
if not isinstance(version_guid, ObjectId):
raise TypeError('%s is not an instance of ObjectId' % version_guid)
self.set_version_guid(version_guid)
def init_from_package_id(self, package_id, explicit_branch=None):
"""
package_id is a CourseLocator or a string like 'mit.eecs.6002x' or 'mit.eecs.6002x/branch/published'.
Revision (optional) is a string like 'published'.
It may be provided explicitly (explicit_branch) or embedded into package_id.
If branch is part of package_id (".../branch/published"), parse it out separately.
If branch is provided both ways, that's ok as long as they are the same value.
If a block ('/block/HW3') is a part of package_id, it is ignored.
"""
if package_id:
if isinstance(package_id, CourseLocator):
package_id = package_id.package_id
if not package_id:
raise ValueError("%s does not have a valid package_id" % package_id)
parse = parse_package_id(package_id)
if not parse or parse['package_id'] is None:
raise ValueError('Could not parse "%s" as a package_id' % package_id)
self.set_package_id(parse['package_id'])
rev = parse['branch']
if rev:
self.set_branch(rev)
if explicit_branch:
self.set_branch(explicit_branch)
def version(self):
"""
Returns the ObjectId referencing this specific location.
"""
return self.version_guid
def html_id(self):
"""
@@ -363,179 +467,31 @@ class CourseLocator(Locator):
place, but I have no way to override. We should clearly define the purpose and restrictions of this
(e.g., I'm assuming periods are fine).
"""
return self.package_id
def _set_value(self, parse, key, setter):
"""
Helper method that gets a value out of the dict returned by parse,
and then sets the corresponding bit of information in this locator
(via the supplied lambda 'setter'), unless the value is None.
"""
value = parse.get(key, None)
if value:
setter(value)
class BlockUsageLocator(CourseLocator):
"""
Encodes a location.
Locations address modules (aka blocks) which are definitions situated in a
course instance. Thus, a Location must identify the course and the occurrence of
the defined element in the course. Courses can be a version of an offering, the
current draft head, or the current production version.
Locators can contain both a version and a package_id w/ branch. The split mongo functions
may raise errors if these conflict w/ the current db state (i.e., the course's branch !=
the version_guid)
Locations can express as urls as well as dictionaries. They consist of
package_identifier: course_guid | version_guid
block : guid
branch : string
"""
# Default value
block_id = None
def __init__(self, url=None, version_guid=None, package_id=None,
branch=None, block_id=None):
"""
Construct a BlockUsageLocator
Caller may provide url, version_guid, or package_id, and optionally provide branch.
The block_id may be specified, either explictly or as part of
the url or package_id. If omitted, the locator is created but it
has not yet been initialized.
Resulting BlockUsageLocator will have a block_id property.
It will have either a version_guid property or a package_id (with optional branch) property, or both.
version_guid must be an instance of bson.objectid.ObjectId or None
url, package_id, branch, and block_id must be strings or None
"""
self._validate_args(url, version_guid, package_id)
if url:
self.init_block_ref_from_str(url)
if package_id:
self.init_block_ref_from_package_id(package_id)
if block_id:
self.init_block_ref(block_id)
super(BlockUsageLocator, self).__init__(
url=url,
version_guid=version_guid,
package_id=package_id,
branch=branch
)
def is_initialized(self):
"""
Returns True if block_id has been initialized, else returns False
"""
return self.block_id is not None
def version_agnostic(self):
"""
We don't care if the locator's version is not the current head; so, avoid version conflict
by reducing info.
Returns a copy of itself without any version info.
:raises: ValueError if the block locator has no package_id
"""
return BlockUsageLocator(package_id=self.package_id,
branch=self.branch,
block_id=self.block_id)
def course_agnostic(self):
"""
We only care about the locator's version not its course.
Returns a copy of itself without any course info.
:raises: ValueError if the block locator has no version_guid
"""
return BlockUsageLocator(version_guid=self.version_guid,
block_id=self.block_id)
def set_block_id(self, new):
"""
Initialize block_id to new value.
If block_id has already been initialized to a different value, raise an exception.
"""
self.set_property('block_id', new)
def init_block_ref(self, block_ref):
if isinstance(block_ref, LocalId):
self.set_block_id(block_ref)
else:
parse = parse_block_ref(block_ref)
if not parse:
raise ValueError('Could not parse "%s" as a block_ref' % block_ref)
self.set_block_id(parse['block'])
def init_block_ref_from_str(self, value):
"""
Create a block locator from the given string which may be a url or just the repr (no tag)
"""
if hasattr(value, 'block_id'):
self.init_block_ref(value.block_id)
return
if not isinstance(value, basestring):
return None
parse = parse_url(value, tag_optional=True)
if parse is None:
raise ValueError('Could not parse "%s" as a url' % value)
self._set_value(parse, 'block', self.set_block_id)
def init_block_ref_from_package_id(self, package_id):
if isinstance(package_id, CourseLocator):
package_id = package_id.package_id
assert package_id, "%s does not have a valid package_id"
parse = parse_package_id(package_id)
if parse is None:
raise ValueError('Could not parse "%s" as a package_id' % package_id)
self._set_value(parse, 'block', self.set_block_id)
@classmethod
def make_relative(cls, course_locator, block_id):
"""
Return a new instance which has the given block_id in the given course
:param course_locator: may be a BlockUsageLocator in the same snapshot
"""
return BlockUsageLocator(
package_id=course_locator.package_id,
version_guid=course_locator.version_guid,
branch=course_locator.branch,
block_id=block_id
)
def __unicode__(self):
"""
Return a string representing this location.
"""
rep = super(BlockUsageLocator, self).__unicode__()
return rep + '/' + BLOCK_PREFIX + unicode(self.block_id)
return re.sub('[^\w-]', '-', self._to_string())
class DefinitionLocator(Locator):
"""
Container for how to locate a description (the course-independent content).
"""
CANONICAL_NAMESPACE = 'defx'
KEY_FIELDS = ('definition_id',)
URL_RE = re.compile(r'^defx:' + VERSION_PREFIX + '([^/]+)$', re.IGNORECASE)
URL_RE = re.compile(r'^defx://' + VERSION_PREFIX + '([^/]+)$', re.IGNORECASE)
def __init__(self, definition_id):
if isinstance(definition_id, LocalId):
self.definition_id = definition_id
super(DefinitionLocator, self).__init__(definition_id)
elif isinstance(definition_id, basestring):
regex_match = self.URL_RE.match(definition_id)
if regex_match is not None:
self.definition_id = self.as_object_id(regex_match.group(1))
super(DefinitionLocator, self).__init__(self.as_object_id(regex_match.group(1)))
else:
self.definition_id = self.as_object_id(definition_id)
super(DefinitionLocator, self).__init__(self.as_object_id(definition_id))
else:
self.definition_id = self.as_object_id(definition_id)
super(DefinitionLocator, self).__init__(self.as_object_id(definition_id))
def __unicode__(self):
def _to_string(self):
'''
Return a string representing this location.
unicode(self) returns something like this: "version/519665f6223ebd6980884f2b"
@@ -545,9 +501,9 @@ class DefinitionLocator(Locator):
def url(self):
"""
Return a string containing the URL for this location.
url(self) returns something like this: 'defx://version/519665f6223ebd6980884f2b'
url(self) returns something like this: 'defx:version/519665f6223ebd6980884f2b'
"""
return u'defx://' + unicode(self)
return u'defx:' + self._to_string()
def version(self):
"""

View File

@@ -6,16 +6,18 @@ In this way, courses can be served up both - say - XMLModuleStore or MongoModule
"""
import logging
from uuid import uuid4
from opaque_keys import InvalidKeyError
from . import ModuleStoreWriteBase
from xmodule.modulestore.django import create_modulestore_instance, loc_mapper
from xmodule.modulestore import Location, SPLIT_MONGO_MODULESTORE_TYPE, XML_MODULESTORE_TYPE
from xmodule.modulestore.locator import CourseLocator, Locator
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
from uuid import uuid4
from xmodule.modulestore import Location, XML_MODULESTORE_TYPE
from xmodule.modulestore.locator import CourseLocator, Locator, BlockUsageLocator
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.keys import CourseKey, UsageKey
from xmodule.modulestore.mongo.base import MongoModuleStore
from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore
from xmodule.exceptions import UndefinedContext
from xmodule.modulestore.locations import SlashSeparatedCourseKey
log = logging.getLogger(__name__)
@@ -32,7 +34,13 @@ class MixedModuleStore(ModuleStoreWriteBase):
super(MixedModuleStore, self).__init__(**kwargs)
self.modulestores = {}
self.mappings = mappings
self.mappings = {}
for course_id, store_name in mappings.iteritems():
try:
self.mappings[CourseKey.from_string(course_id)] = store_name
except InvalidKeyError:
self.mappings[SlashSeparatedCourseKey.from_deprecated_string(course_id)] = store_name
if 'default' not in stores:
raise Exception('Missing a default modulestore in the MixedModuleStore __init__ method.')
@@ -43,7 +51,7 @@ class MixedModuleStore(ModuleStoreWriteBase):
# restrict xml to only load courses in mapping
store['OPTIONS']['course_ids'] = [
course_id
for course_id, store_key in self.mappings.iteritems()
for course_id, store_key in mappings.iteritems()
if store_key == key
]
self.modulestores[key] = create_modulestore_instance(
@@ -53,10 +61,6 @@ class MixedModuleStore(ModuleStoreWriteBase):
store['OPTIONS'],
i18n_service=i18n_service,
)
# If and when locations can identify their course, we won't need
# these loc maps. They're needed for figuring out which store owns these locations.
if is_xml:
self.ensure_loc_maps_exist(key)
def _get_modulestore_for_courseid(self, course_id):
"""
@@ -69,54 +73,50 @@ class MixedModuleStore(ModuleStoreWriteBase):
mapping = self.mappings.get(course_id, 'default')
return self.modulestores[mapping]
def has_item(self, course_id, reference):
def has_item(self, usage_key):
"""
Does the course include the xblock who's id is reference?
:param course_id: a course_id or package_id (slashed or dotted)
:param reference: a Location or BlockUsageLocator
"""
store = self._get_modulestore_for_courseid(course_id)
return store.has_item(course_id, reference)
store = self._get_modulestore_for_courseid(usage_key.course_key)
return store.has_item(usage_key)
def get_item(self, location, depth=0):
def get_item(self, usage_key, depth=0):
"""
This method is explicitly not implemented as we need a course_id to disambiguate
We should be able to fix this when the data-model rearchitecting is done
"""
# Although we shouldn't have both get_item and get_instance imho
raise NotImplementedError
store = self._get_modulestore_for_courseid(usage_key.course_key)
return store.get_item(usage_key, depth)
def get_instance(self, course_id, location, depth=0):
store = self._get_modulestore_for_courseid(course_id)
return store.get_instance(course_id, location, depth)
def get_items(self, location, course_id=None, depth=0, qualifiers=None):
def get_items(self, course_key, settings=None, content=None, **kwargs):
"""
Returns a list of XModuleDescriptor instances for the items
that match location. Any element of location that is None is treated
as a wildcard that matches any value. NOTE: don't use this to look for courses
as the course_id is required. Use get_courses.
Returns:
list of XModuleDescriptor instances for the matching items within the course with
the given course_key
location: either a Location possibly w/ None as wildcards for category or name or
a Locator with at least a package_id and branch but possibly no block_id.
NOTE: don't use this to look for courses
as the course_key is required. Use get_courses.
depth: An argument that some module stores may use to prefetch
descendants of the queried modules for more efficient results later
in the request. The depth is counted in the number of calls to
get_children() to cache. None indicates to cache all descendants
Args:
course_key (CourseKey): the course identifier
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.
kwargs (key=value): 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 (course_id or hasattr(location, 'package_id')):
raise Exception("Must pass in a course_id when calling get_items()")
if not isinstance(course_key, CourseKey):
raise Exception("Must pass in a course_key when calling get_items()")
store = self._get_modulestore_for_courseid(course_id or getattr(location, 'package_id'))
return store.get_items(location, course_id, depth, qualifiers)
def _get_course_id_from_course_location(self, course_location):
"""
Get the proper course_id based on the type of course_location
"""
return getattr(course_location, 'course_id', None) or getattr(course_location, 'package_id', None)
store = self._get_modulestore_for_courseid(course_key)
return store.get_items(course_key, settings, content, **kwargs)
def get_courses(self):
'''
@@ -141,7 +141,8 @@ class MixedModuleStore(ModuleStoreWriteBase):
try:
# if there's no existing mapping, then the course can't have been in split
course_locator = loc_mapper().translate_location(
course.location.course_id, course.location, add_entry_if_missing=False
course.location,
add_entry_if_missing=False
)
if unicode(course_locator) not in courses:
courses[course_location] = course
@@ -152,27 +153,47 @@ class MixedModuleStore(ModuleStoreWriteBase):
return courses.values()
def get_course(self, course_id):
def get_course(self, course_key, depth=None):
"""
returns the course module associated with the course_id. If no such course exists,
it returns None
:param course_id: must be either a string course_id or a CourseLocator
:param course_key: must be a CourseKey
"""
store = self._get_modulestore_for_courseid(
course_id.package_id if hasattr(course_id, 'package_id') else course_id
)
assert(isinstance(course_key, CourseKey))
store = self._get_modulestore_for_courseid(course_key)
try:
return store.get_course(course_id)
return store.get_course(course_key, depth=depth)
except ItemNotFoundError:
return None
def get_parent_locations(self, location, course_id):
def has_course(self, course_id, ignore_case=False):
"""
returns the parent locations for a given location and course_id
returns whether the course exists
Args:
* course_id (CourseKey)
* ignore_case (bool): Tf True, do a case insensitive search. If
False, do a case sensitive search
"""
assert(isinstance(course_id, CourseKey))
store = self._get_modulestore_for_courseid(course_id)
return store.get_parent_locations(location, course_id)
return store.has_course(course_id, ignore_case)
def delete_course(self, course_key, user_id=None):
"""
Remove the given course from its modulestore.
"""
assert(isinstance(course_key, CourseKey))
store = self._get_modulestore_for_courseid(course_key)
return store.delete_course(course_key, user_id)
def get_parent_locations(self, location):
"""
returns the parent locations for a given location
"""
store = self._get_modulestore_for_courseid(location.course_key)
return store.get_parent_locations(location)
def get_modulestore_type(self, course_id):
"""
@@ -184,15 +205,14 @@ class MixedModuleStore(ModuleStoreWriteBase):
"""
return self._get_modulestore_for_courseid(course_id).get_modulestore_type(course_id)
def get_orphans(self, course_location, branch):
def get_orphans(self, course_key):
"""
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.
"""
course_id = self._get_course_id_from_course_location(course_location)
store = self._get_modulestore_for_courseid(course_id)
return store.get_orphans(course_location, branch)
store = self._get_modulestore_for_courseid(course_key)
return store.get_orphans(course_key)
def get_errored_courses(self):
"""
@@ -204,106 +224,26 @@ class MixedModuleStore(ModuleStoreWriteBase):
errs.update(store.get_errored_courses())
return errs
def _get_course_id_from_block(self, block, store):
"""
Get the course_id from the block or from asking its store. Expensive.
"""
try:
return block.course_id
except UndefinedContext:
pass
try:
course = store._get_course_for_item(block.scope_ids.usage_id)
if course is not None:
return course.scope_ids.usage_id.course_id
except Exception: # sorry, that method just raises vanilla Exception if it doesn't find course
pass
def _infer_course_id_try(self, location):
"""
Create, Update, Delete operations don't require a fully-specified course_id, but
there's no complete & sound general way to compute the course_id except via the
proper modulestore. This method attempts several sound but not complete methods.
:param location: an old style Location
"""
if isinstance(location, CourseLocator):
return location.package_id
elif isinstance(location, basestring):
try:
location = Location(location)
except InvalidLocationError:
# try to parse as a course_id
try:
Location.parse_course_id(location)
# it's already a course_id
return location
except ValueError:
# cannot interpret the location
return None
# location is a Location at this point
if location.category == 'course': # easiest case
return location.course_id
# try finding in loc_mapper
try:
# see if the loc mapper knows the course id (requires double translation)
locator = loc_mapper().translate_location_to_course_locator(None, location)
location = loc_mapper().translate_locator_to_location(locator, get_course=True)
return location.course_id
except ItemNotFoundError:
pass
# expensive query against all location-based modulestores to look for location.
for store in self.modulestores.itervalues():
if isinstance(location, store.reference_type):
try:
xblock = store.get_item(location)
course_id = self._get_course_id_from_block(xblock, store)
if course_id is not None:
return course_id
except NotImplementedError:
blocks = store.get_items(location)
if len(blocks) == 1:
block = blocks[0]
try:
return block.course_id
except UndefinedContext:
pass
except ItemNotFoundError:
pass
# if we get here, it must be in a Locator based store, but we won't be able to find
# it.
return None
def create_course(self, course_id, user_id=None, store_name='default', **kwargs):
def create_course(self, org, offering, user_id=None, fields=None, store_name='default', **kwargs):
"""
Creates and returns the course.
:param org: the org
:param fields: a dict of xblock field name - value pairs for the course module.
:param metadata: the old way of setting fields by knowing which ones are scope.settings v scope.content
:param definition_data: the complement to metadata which is also a subset of fields
:returns: course xblock
Args:
org (str): the organization that owns the course
offering (str): the name of the course offering
user_id: id of the user creating the course
fields (dict): Fields to set on the course at initialization
store_name (str): the name of the modulestore that we will create this course within
kwargs: Any optional arguments understood by a subset of modulestores to customize instantiation
Returns: a CourseDescriptor
"""
store = self.modulestores[store_name]
if not hasattr(store, 'create_course'):
raise NotImplementedError(u"Cannot create a course on store %s" % store_name)
if store.get_modulestore_type(course_id) == SPLIT_MONGO_MODULESTORE_TYPE:
try:
course_dict = Location.parse_course_id(course_id)
org = course_dict['org']
course_id = "{org}.{course}.{name}".format(**course_dict)
except ValueError:
org = None
org = kwargs.pop('org', org)
fields = kwargs.pop('fields', {})
fields.update(kwargs.pop('metadata', {}))
fields.update(kwargs.pop('definition_data', {}))
course = store.create_course(course_id, org, user_id, fields=fields, **kwargs)
else: # assume mongo
course = store.create_course(course_id, **kwargs)
return course
return store.create_course(org, offering, user_id, fields, **kwargs)
def create_item(self, course_or_parent_loc, category, user_id=None, **kwargs):
"""
@@ -311,46 +251,30 @@ class MixedModuleStore(ModuleStoreWriteBase):
it installs the new item as a child of the parent (if the parent_loc is a specific
xblock reference).
:param course_or_parent_loc: Can be a course_id (org/course/run), CourseLocator,
Location, or BlockUsageLocator but must be what the persistence modulestore expects
:param course_or_parent_loc: Can be a CourseKey or UsageKey
:param category (str): The block_type of the item we are creating
"""
# find the store for the course
course_id = self._infer_course_id_try(course_or_parent_loc)
if course_id is None:
raise ItemNotFoundError(u"Cannot find modulestore for %s" % course_or_parent_loc)
course_id = getattr(course_or_parent_loc, 'course_key', course_or_parent_loc)
store = self._get_modulestore_for_courseid(course_id)
location = kwargs.pop('location', None)
# invoke its create_item
if isinstance(store, MongoModuleStore):
block_id = kwargs.pop('block_id', getattr(location, 'name', uuid4().hex))
# convert parent loc if it's legit
if isinstance(course_or_parent_loc, basestring):
parent_loc = None
if location is None:
loc_dict = Location.parse_course_id(course_id)
loc_dict['name'] = block_id
location = Location(category=category, **loc_dict)
else:
parent_loc = course_or_parent_loc
# must have a legitimate location, compute if appropriate
if location is None:
location = parent_loc.replace(category=category, name=block_id)
parent_loc = course_or_parent_loc if isinstance(course_or_parent_loc, UsageKey) else None
# must have a legitimate location, compute if appropriate
if location is None:
location = course_id.make_usage_key(category, block_id)
# do the actual creation
xblock = store.create_and_save_xmodule(location, **kwargs)
# don't forget to attach to parent
if parent_loc is not None and not 'detached' in xblock._class_tags:
parent = store.get_item(parent_loc)
parent.children.append(location.url())
parent.children.append(location)
store.update_item(parent)
elif isinstance(store, SplitMongoModuleStore):
if isinstance(course_or_parent_loc, basestring): # course_id
course_or_parent_loc = loc_mapper().translate_location_to_course_locator(
# hardcode draft version until we figure out how we're handling branches from app
course_or_parent_loc, None, published=False
)
elif not isinstance(course_or_parent_loc, CourseLocator):
if not isinstance(course_or_parent_loc, (CourseLocator, BlockUsageLocator)):
raise ValueError(u"Cannot create a child of {} in split. Wrong repr.".format(course_or_parent_loc))
# split handles all the fields in one dict not separated by scope
@@ -370,9 +294,7 @@ class MixedModuleStore(ModuleStoreWriteBase):
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_id = self._infer_course_id_try(xblock.scope_ids.usage_id)
if course_id is None:
raise ItemNotFoundError(u"Cannot find modulestore for %s" % xblock.scope_ids.usage_id)
course_id = xblock.scope_ids.usage_id.course_key
store = self._get_modulestore_for_courseid(course_id)
return store.update_item(xblock, user_id)
@@ -380,9 +302,7 @@ class MixedModuleStore(ModuleStoreWriteBase):
"""
Delete the given item from persistence. kwargs allow modulestore specific parameters.
"""
course_id = self._infer_course_id_try(location)
if course_id is None:
raise ItemNotFoundError(u"Cannot find modulestore for %s" % location)
course_id = location.course_key
store = self._get_modulestore_for_courseid(course_id)
return store.delete_item(location, user_id=user_id, **kwargs)
@@ -396,21 +316,6 @@ class MixedModuleStore(ModuleStoreWriteBase):
elif hasattr(mstore, 'db'):
mstore.db.connection.close()
def ensure_loc_maps_exist(self, store_name):
"""
Ensure location maps exist for every course in the modulestore whose
name is the given name (mostly used for 'xml'). It creates maps for any
missing ones.
NOTE: will only work if the given store is Location based. If it's not,
it raises NotImplementedError
"""
store = self.modulestores[store_name]
if store.reference_type != Location:
raise ValueError(u"Cannot create maps from %s" % store.reference_type)
for course in store.get_courses():
loc_mapper().translate_location(course.location.course_id, course.location)
def get_courses_for_wiki(self, wiki_slug):
"""
Return the list of courses which use this wiki_slug

View File

@@ -8,7 +8,7 @@ structure:
'_id': <location.as_dict>,
'metadata': <dict containing all Scope.settings fields>
'definition': <dict containing all Scope.content fields>
'definition.children': <list of all child location.url()s>
'definition.children': <list of all child location.to_deprecated_string()s>
}
"""
@@ -16,26 +16,27 @@ import pymongo
import sys
import logging
import copy
import re
from bson.son import SON
from fs.osfs import OSFS
from itertools import repeat
from path import path
from importlib import import_module
from xmodule.errortracker import null_error_tracker, exc_info_to_str
from xmodule.mako_module import MakoDescriptorSystem
from xmodule.error_module import ErrorDescriptor
from xmodule.html_module import AboutDescriptor
from xblock.runtime import KvsFieldData
from xblock.exceptions import InvalidScopeError
from xblock.fields import Scope, ScopeIds
from xblock.fields import Scope, ScopeIds, Reference, ReferenceList, ReferenceValueDict
from xmodule.modulestore import ModuleStoreWriteBase, Location, MONGO_MODULESTORE_TYPE
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
from xmodule.modulestore.inheritance import own_metadata, InheritanceMixin, inherit_metadata, InheritanceKeyValueStore
from xmodule.modulestore.xml import LocationReader
from xmodule.tabs import StaticTab, CourseTabList
from xblock.core import XBlock
from xmodule.modulestore.locations import SlashSeparatedCourseKey
log = logging.getLogger(__name__)
@@ -52,6 +53,7 @@ class InvalidWriteError(Exception):
Raised to indicate that writing to a particular key
in the KeyValueStore is disabled
"""
pass
class MongoKeyValueStore(InheritanceKeyValueStore):
@@ -120,7 +122,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
TODO (cdodge) when the 'split module store' work has been completed we can remove all
references to metadata_inheritance_tree
"""
def __init__(self, modulestore, module_data, default_class, cached_metadata, **kwargs):
def __init__(self, modulestore, course_key, module_data, default_class, cached_metadata, **kwargs):
"""
modulestore: the module store that can be used to retrieve additional modules
@@ -138,7 +140,6 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
MakoDescriptorSystem
"""
super(CachingDescriptorSystem, self).__init__(
id_reader=LocationReader(),
field_data=None,
load_item=self.load_item,
**kwargs
@@ -149,14 +150,14 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
self.default_class = default_class
# cdodge: other Systems have a course_id attribute defined. To keep things consistent, let's
# define an attribute here as well, even though it's None
self.course_id = None
self.course_id = course_key
self.cached_metadata = cached_metadata
def load_item(self, location):
"""
Return an XModule instance for the specified location
"""
location = Location(location)
assert isinstance(location, Location)
json_data = self.module_data.get(location)
if json_data is None:
module = self.modulestore.get_item(location)
@@ -170,6 +171,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
category = json_data['location']['category']
class_ = self.load_block_type(category)
definition = json_data.get('definition', {})
metadata = json_data.get('metadata', {})
for old_name, new_name in getattr(class_, 'metadata_translations', {}).items():
@@ -177,9 +179,19 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
metadata[new_name] = metadata[old_name]
del metadata[old_name]
children = [
location.course_key.make_usage_key_from_deprecated_string(childloc)
for childloc in definition.get('children', [])
]
data = definition.get('data', {})
if isinstance(data, basestring):
data = {'data': data}
mixed_class = self.mixologist.mix(class_)
data = self._convert_reference_fields_to_keys(mixed_class, location.course_key, data)
metadata = self._convert_reference_fields_to_keys(mixed_class, location.course_key, metadata)
kvs = MongoKeyValueStore(
definition.get('data', {}),
definition.get('children', []),
data,
children,
metadata,
)
@@ -193,7 +205,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
# Convert the serialized fields values in self.cached_metadata
# to python values
metadata_to_inherit = self.cached_metadata.get(non_draft_loc.url(), {})
metadata_to_inherit = self.cached_metadata.get(non_draft_loc.to_deprecated_string(), {})
inherit_metadata(module, metadata_to_inherit)
# decache any computed pending field settings
module.save()
@@ -203,31 +215,58 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
return ErrorDescriptor.from_json(
json_data,
self,
json_data['location'],
location,
error_msg=exc_info_to_str(sys.exc_info())
)
def _convert_reference_fields_to_keys(self, class_, course_key, jsonfields):
"""
Find all fields of type reference and convert the payload into UsageKeys
:param class_: the XBlock class
:param course_key: a CourseKey object for the given course
:param jsonfields: a dict of the jsonified version of the fields
"""
for field_name, value in jsonfields.iteritems():
if value:
field = class_.fields.get(field_name)
if field is None:
continue
elif isinstance(field, Reference):
jsonfields[field_name] = course_key.make_usage_key_from_deprecated_string(value)
elif isinstance(field, ReferenceList):
jsonfields[field_name] = [
course_key.make_usage_key_from_deprecated_string(ele) for ele in value
]
elif isinstance(field, ReferenceValueDict):
for key, subvalue in value.iteritems():
assert isinstance(subvalue, basestring)
value[key] = course_key.make_usage_key_from_deprecated_string(subvalue)
return jsonfields
def namedtuple_to_son(namedtuple, prefix=''):
def location_to_son(location, prefix='', tag='i4x'):
"""
Converts a namedtuple into a SON object with the same key order
Converts a location into a SON object with the same key order
"""
son = SON()
# pylint: disable=protected-access
for idx, field_name in enumerate(namedtuple._fields):
son[prefix + field_name] = namedtuple[idx]
son = SON({prefix + 'tag': tag})
for field_name in location.KEY_FIELDS:
# Filter the run, because the existing data doesn't have it stored
if field_name != 'run':
son[prefix + field_name] = getattr(location, field_name)
return son
def location_to_query(location, wildcard=True):
# The only thing using this w/ wildcards is contentstore.mongo for asset retrieval
def location_to_query(location, wildcard=True, tag='i4x'):
"""
Takes a Location and returns a SON object that will query for that location.
Takes a Location and returns a SON object that will query for that location by subfields
rather than subdoc. Note: location_to_son is faster in mongo as it does subdoc equivalence.
Fields in location that are None are ignored in the query
If `wildcard` is True, then a None in a location is treated as a wildcard
query. Otherwise, it is searched for literally
"""
query = namedtuple_to_son(Location(location), prefix='_id.')
query = location_to_son(location, prefix='_id.', tag=tag)
if wildcard:
for key, value in query.items():
@@ -239,11 +278,6 @@ def location_to_query(location, wildcard=True):
return query
def metadata_cache_key(location):
"""Turn a `Location` into a useful cache key."""
return u"{0.org}/{0.course}".format(location)
class MongoModuleStore(ModuleStoreWriteBase):
"""
A Mongodb backed ModuleStore
@@ -275,6 +309,8 @@ class MongoModuleStore(ModuleStoreWriteBase):
host=host,
port=port,
tz_aware=tz_aware,
# deserialize dicts as SONs
document_class=SON,
**kwargs
),
db
@@ -289,14 +325,6 @@ class MongoModuleStore(ModuleStoreWriteBase):
# Force mongo to report errors, at the expense of performance
self.collection.write_concern = {'w': 1}
# Force mongo to maintain an index over _id.* that is in the same order
# that is used when querying by a location
# pylint: disable=no-member, protected_access
self.collection.ensure_index(
zip(('_id.' + field for field in Location._fields), repeat(1)),
)
# pylint: enable=no-member, protected_access
if default_class is not None:
module_path, _, class_name = default_class.rpartition('.')
class_ = getattr(import_module(module_path), class_name)
@@ -308,20 +336,24 @@ class MongoModuleStore(ModuleStoreWriteBase):
self.render_template = render_template
self.i18n_service = i18n_service
self.ignore_write_events_on_courses = []
self.ignore_write_events_on_courses = set()
def compute_metadata_inheritance_tree(self, location):
def _compute_metadata_inheritance_tree(self, course_id):
'''
TODO (cdodge) This method can be deleted when the 'split module store' work has been completed
'''
# get all collections in the course, this query should not return any leaf nodes
# note this is a bit ugly as when we add new categories of containers, we have to add it here
block_types_with_children = set(name for name, class_ in XBlock.load_classes() if getattr(class_, 'has_children', False))
query = {'_id.org': location.org,
'_id.course': location.course,
'_id.category': {'$in': list(block_types_with_children)}
}
block_types_with_children = set(
name for name, class_ in XBlock.load_classes() if getattr(class_, 'has_children', False)
)
query = SON([
('_id.tag', 'i4x'),
('_id.org', course_id.org),
('_id.course', course_id.course),
('_id.category', {'$in': list(block_types_with_children)})
])
# we just want the Location, children, and inheritable metadata
record_filter = {'_id': 1, 'definition.children': 1}
@@ -333,24 +365,26 @@ class MongoModuleStore(ModuleStoreWriteBase):
# call out to the DB
resultset = self.collection.find(query, record_filter)
# it's ok to keep these as urls b/c the overall cache is indexed by course_key and this
# is a dictionary relative to that course
results_by_url = {}
root = None
# now go through the results and order them by the location url
for result in resultset:
location = Location(result['_id'])
# We need to collate between draft and non-draft
# i.e. draft verticals will have draft children but will have non-draft parents currently
location = location.replace(revision=None)
location_url = location.url()
# manually pick it apart b/c the db has tag and we want revision = None regardless
location = Location._from_deprecated_son(result['_id'], course_id.run).replace(revision=None)
location_url = location.to_deprecated_string()
if location_url in results_by_url:
# found either draft or live to complement the other revision
existing_children = results_by_url[location_url].get('definition', {}).get('children', [])
additional_children = result.get('definition', {}).get('children', [])
total_children = existing_children + additional_children
results_by_url[location_url].setdefault('definition', {})['children'] = total_children
results_by_url[location.url()] = result
results_by_url[location_url] = result
if location.category == 'course':
root = location.url()
root = location_url
# now traverse the tree and compute down the inherited metadata
metadata_to_inherit = {}
@@ -379,31 +413,30 @@ class MongoModuleStore(ModuleStoreWriteBase):
return metadata_to_inherit
def get_cached_metadata_inheritance_tree(self, location, force_refresh=False):
def _get_cached_metadata_inheritance_tree(self, course_id, force_refresh=False):
'''
TODO (cdodge) This method can be deleted when the 'split module store' work has been completed
'''
key = metadata_cache_key(location)
tree = {}
if not force_refresh:
# see if we are first in the request cache (if present)
if self.request_cache is not None and key in self.request_cache.data.get('metadata_inheritance', {}):
return self.request_cache.data['metadata_inheritance'][key]
if self.request_cache is not None and course_id in self.request_cache.data.get('metadata_inheritance', {}):
return self.request_cache.data['metadata_inheritance'][course_id]
# then look in any caching subsystem (e.g. memcached)
if self.metadata_inheritance_cache_subsystem is not None:
tree = self.metadata_inheritance_cache_subsystem.get(key, {})
tree = self.metadata_inheritance_cache_subsystem.get(course_id, {})
else:
logging.warning('Running MongoModuleStore without a metadata_inheritance_cache_subsystem. This is OK in localdev and testing environment. Not OK in production.')
if not tree:
# if not in subsystem, or we are on force refresh, then we have to compute
tree = self.compute_metadata_inheritance_tree(location)
tree = self._compute_metadata_inheritance_tree(course_id)
# now write out computed tree to caching subsystem (e.g. memcached), if available
if self.metadata_inheritance_cache_subsystem is not None:
self.metadata_inheritance_cache_subsystem.set(key, tree)
self.metadata_inheritance_cache_subsystem.set(course_id, tree)
# now populate a request_cache, if available. NOTE, we are outside of the
# scope of the above if: statement so that after a memcache hit, it'll get
@@ -413,18 +446,22 @@ class MongoModuleStore(ModuleStoreWriteBase):
# defined
if 'metadata_inheritance' not in self.request_cache.data:
self.request_cache.data['metadata_inheritance'] = {}
self.request_cache.data['metadata_inheritance'][key] = tree
self.request_cache.data['metadata_inheritance'][course_id] = tree
return tree
def refresh_cached_metadata_inheritance_tree(self, location):
def refresh_cached_metadata_inheritance_tree(self, course_id, runtime=None):
"""
Refresh the cached metadata inheritance tree for the org/course combination
for location
If given a runtime, it replaces the cached_metadata in that runtime. NOTE: failure to provide
a runtime may mean that some objects report old values for inherited data.
"""
pseudo_course_id = '/'.join([location.org, location.course])
if pseudo_course_id not in self.ignore_write_events_on_courses:
self.get_cached_metadata_inheritance_tree(location, force_refresh=True)
if course_id not in self.ignore_write_events_on_courses:
cached_metadata = self._get_cached_metadata_inheritance_tree(course_id, force_refresh=True)
if runtime:
runtime.cached_metadata = cached_metadata
def _clean_item_data(self, item):
"""
@@ -433,17 +470,19 @@ class MongoModuleStore(ModuleStoreWriteBase):
item['location'] = item['_id']
del item['_id']
def _query_children_for_cache_children(self, items):
def _query_children_for_cache_children(self, course_key, items):
"""
Generate a pymongo in query for finding the items and return the payloads
"""
# first get non-draft in a round-trip
query = {
'_id': {'$in': [namedtuple_to_son(Location(item)) for item in items]}
'_id': {'$in': [
location_to_son(course_key.make_usage_key_from_deprecated_string(item)) for item in items
]}
}
return list(self.collection.find(query))
def _cache_children(self, items, depth=0):
def _cache_children(self, course_key, items, depth=0):
"""
Returns a dictionary mapping Location -> item data, populated with json data
for all descendents of items up to the specified depth.
@@ -459,7 +498,7 @@ class MongoModuleStore(ModuleStoreWriteBase):
for item in to_process:
self._clean_item_data(item)
children.extend(item.get('definition', {}).get('children', []))
data[Location(item['location'])] = item
data[Location._from_deprecated_son(item['location'], course_key.run)] = item
if depth == 0:
break
@@ -469,7 +508,7 @@ class MongoModuleStore(ModuleStoreWriteBase):
# for or-query syntax
to_process = []
if children:
to_process = self._query_children_for_cache_children(children)
to_process = self._query_children_for_cache_children(course_key, children)
# If depth is None, then we just recurse until we hit all the descendents
if depth is not None:
@@ -477,11 +516,11 @@ class MongoModuleStore(ModuleStoreWriteBase):
return data
def _load_item(self, item, data_cache, apply_cached_metadata=True):
def _load_item(self, course_key, item, data_cache, apply_cached_metadata=True):
"""
Load an XModuleDescriptor from item, using the children stored in data_cache
"""
location = Location(item['location'])
location = Location._from_deprecated_son(item['location'], course_key.run)
data_dir = getattr(item, 'data_dir', location.course)
root = self.fs_root / data_dir
@@ -491,16 +530,15 @@ class MongoModuleStore(ModuleStoreWriteBase):
cached_metadata = {}
if apply_cached_metadata:
cached_metadata = self.get_cached_metadata_inheritance_tree(location)
cached_metadata = self._get_cached_metadata_inheritance_tree(course_key)
services = {}
if self.i18n_service:
services["i18n"] = self.i18n_service
# TODO (cdodge): When the 'split module store' work has been completed, we should remove
# the 'metadata_inheritance_tree' parameter
system = CachingDescriptorSystem(
modulestore=self,
course_key=course_key,
module_data=data_cache,
default_class=self.default_class,
resources_fs=resource_fs,
@@ -513,70 +551,95 @@ class MongoModuleStore(ModuleStoreWriteBase):
)
return system.load_item(location)
def _load_items(self, items, depth=0):
def _load_items(self, course_key, items, depth=0):
"""
Load a list of xmodules from the data in items, with children cached up
to specified depth
"""
data_cache = self._cache_children(items, depth)
data_cache = self._cache_children(course_key, items, depth)
# if we are loading a course object, if we're not prefetching children (depth != 0) then don't
# bother with the metadata inheritance
return [self._load_item(item, data_cache,
apply_cached_metadata=(item['location']['category'] != 'course' or depth != 0)) for item in items]
return [
self._load_item(
course_key, item, data_cache,
apply_cached_metadata=(item['location']['category'] != 'course' or depth != 0)
)
for item in items
]
def get_courses(self):
'''
Returns a list of course descriptors.
'''
course_filter = Location(category="course")
return [
course
for course
in self.get_items(course_filter)
if not (
course.location.org == 'edx' and
course.location.course == 'templates'
)
]
return sum(
[
self._load_items(
SlashSeparatedCourseKey(course['_id']['org'], course['_id']['course'], course['_id']['name']),
[course]
)
for course
# I tried to add '$and': [{'_id.org': {'$ne': 'edx'}}, {'_id.course': {'$ne': 'templates'}}]
# but it didn't do the right thing (it filtered all edx and all templates out)
in self.collection.find({'_id.category': 'course'})
if not ( # TODO kill this
course['_id']['org'] == 'edx' and
course['_id']['course'] == 'templates'
)
],
[]
)
def _find_one(self, location):
'''Look for a given location in the collection. If revision is not
specified, returns the latest. If the item is not present, raise
ItemNotFoundError.
'''
assert isinstance(location, Location)
item = self.collection.find_one(
location_to_query(location, wildcard=False),
{'_id': location_to_son(location)},
sort=[('revision', pymongo.ASCENDING)],
)
if item is None:
raise ItemNotFoundError(location)
return item
def get_course(self, course_id):
def get_course(self, course_key, depth=None):
"""
Get the course with the given courseid (org/course/run)
"""
id_components = Location.parse_course_id(course_id)
id_components['tag'] = 'i4x'
id_components['category'] = 'course'
assert(isinstance(course_key, SlashSeparatedCourseKey))
location = course_key.make_usage_key('course', course_key.run)
try:
return self.get_item(Location(id_components))
return self.get_item(location, depth=depth)
except ItemNotFoundError:
return None
def has_item(self, course_id, location):
def has_course(self, course_key, ignore_case=False):
"""
Is the given course in this modulestore
If ignore_case is True, do a case insensitive search,
otherwise, do a case sensitive search
"""
assert(isinstance(course_key, SlashSeparatedCourseKey))
course_query = self._course_key_to_son(course_key)
if ignore_case:
for key in course_query.iterkeys():
course_query[key] = re.compile(r"(?i)^{}$".format(course_query[key]))
return self.collection.find_one(course_query, fields={'_id': True}) is not None
def has_item(self, usage_key):
"""
Returns True if location exists in this ModuleStore.
"""
location = Location.ensure_fully_specified(location)
try:
self._find_one(location)
self._find_one(usage_key)
return True
except ItemNotFoundError:
return False
def get_item(self, location, depth=0):
def get_item(self, usage_key, depth=0):
"""
Returns an XModuleDescriptor instance for the item at location.
@@ -585,52 +648,135 @@ class MongoModuleStore(ModuleStoreWriteBase):
If no object is found at that location, raises
xmodule.modulestore.exceptions.ItemNotFoundError
location: a Location object
usage_key: a :class:`.UsageKey` instance
depth (int): An argument that some module stores may use to prefetch
descendents of the queried modules for more efficient results later
in the request. The depth is counted in the number of
calls to get_children() to cache. None indicates to cache all descendents.
"""
location = Location.ensure_fully_specified(location)
item = self._find_one(location)
module = self._load_items([item], depth)[0]
item = self._find_one(usage_key)
module = self._load_items(usage_key.course_key, [item], depth)[0]
return module
def get_instance(self, course_id, location, depth=0):
@staticmethod
def _course_key_to_son(course_id, tag='i4x'):
"""
TODO (vshnayder): implement policy tracking in mongo.
For now, just delegate to get_item and ignore policy.
depth (int): An argument that some module stores may use to prefetch
descendents of the queried modules for more efficient results later
in the request. The depth is counted in the number of
calls to get_children() to cache. None indicates to cache all descendents.
Generate the partial key to look up items relative to a given course
"""
return self.get_item(location, depth=depth)
return SON([
('_id.tag', tag),
('_id.org', course_id.org),
('_id.course', course_id.course),
])
def get_items(self, location, course_id=None, depth=0, qualifiers=None):
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
the given course_id
NOTE: don't use this to look for courses
as the course_id is required. Use get_courses which is a lot faster anyway.
If you don't provide a value for revision, this limits the result to only ones in the
published course. Call this method on draft mongo store if you want to include drafts.
Args:
course_id (CourseKey): the course identifier
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.
revision (str): the revision of the items you're looking for.
kwargs (key=value): 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 this modulestore, ``name`` is a commonly provided key (Location based stores)
This modulestore does not allow searching dates by comparison or edited_by, previous_version,
update_version info.
"""
query = self._course_key_to_son(course_id)
query['_id.revision'] = revision
for field in ['category', 'name']:
if field in kwargs:
query['_id.' + field] = kwargs.pop(field)
for key, value in (settings or {}).iteritems():
query['metadata.' + key] = value
for key, value in (content or {}).iteritems():
query['definition.data.' + key] = value
if 'children' in kwargs:
query['definition.children'] = kwargs.pop('children')
query.update(kwargs)
items = self.collection.find(
location_to_query(location),
sort=[('revision', pymongo.ASCENDING)],
query,
sort=[('_id.revision', pymongo.ASCENDING)],
)
modules = self._load_items(list(items), depth)
modules = self._load_items(course_id, list(items))
return modules
def create_course(self, course_id, definition_data=None, metadata=None, runtime=None):
def create_course(self, org, offering, user_id=None, fields=None, **kwargs):
"""
Create a course with the given course_id.
Creates and returns the course.
Args:
org (str): the organization that owns the course
offering (str): the name of the course offering
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 CourseDescriptor
Raises:
InvalidLocationError: If a course with the same org and offering already exists
"""
if isinstance(course_id, Location):
location = course_id
if location.category != 'course':
raise ValueError(u"Course roots must be of category 'course': {}".format(unicode(location)))
else:
course_dict = Location.parse_course_id(course_id)
course_dict['category'] = 'course'
course_dict['tag'] = 'i4x'
location = Location(course_dict)
return self.create_and_save_xmodule(location, definition_data, metadata, runtime)
course, _, run = offering.partition('/')
course_id = SlashSeparatedCourseKey(org, course, run)
# Check if a course with this org/course has been defined before (case-insensitive)
course_search_location = SON([
('_id.tag', 'i4x'),
('_id.org', re.compile(u'^{}$'.format(course_id.org), re.IGNORECASE)),
('_id.course', re.compile(u'^{}$'.format(course_id.course), re.IGNORECASE)),
('_id.category', 'course'),
])
courses = self.collection.find(course_search_location, fields=('_id'))
if courses.count() > 0:
raise InvalidLocationError(
"There are already courses with the given org and course id: {}".format([
course['_id'] for course in courses
]))
location = course_id.make_usage_key('course', course_id.run)
course = self.create_and_save_xmodule(location, fields=fields, **kwargs)
# clone a default 'about' overview module as well
about_location = location.replace(
category='about',
name='overview'
)
overview_template = AboutDescriptor.get_template('overview.yaml')
self.create_and_save_xmodule(
about_location,
system=course.system,
definition_data=overview_template.get('data')
)
return course
def delete_course(self, course_key, user_id=None):
"""
The impl removes all of the db records for the course.
:param course_key:
:param user_id:
"""
course_query = self._course_key_to_son(course_key)
self.collection.remove(course_query, multi=True)
def create_xmodule(self, location, definition_data=None, metadata=None, system=None, fields={}):
"""
@@ -641,8 +787,6 @@ class MongoModuleStore(ModuleStoreWriteBase):
:param metadata: can be empty, the initial metadata for the kvs
:param system: if you already have an xblock from the course, the xblock.runtime value
"""
if not isinstance(location, Location):
location = Location(location)
# differs from split mongo in that I believe most of this logic should be above the persistence
# layer but added it here to enable quick conversion. I'll need to reconcile these.
if metadata is None:
@@ -659,6 +803,7 @@ class MongoModuleStore(ModuleStoreWriteBase):
system = CachingDescriptorSystem(
modulestore=self,
module_data={},
course_key=location.course_key,
default_class=self.default_class,
resources_fs=None,
error_tracker=self.error_tracker,
@@ -678,8 +823,9 @@ class MongoModuleStore(ModuleStoreWriteBase):
ScopeIds(None, location.category, location, location),
dbmodel,
)
for key, value in fields.iteritems():
setattr(xmodule, key, value)
if fields is not None:
for key, value in fields.iteritems():
setattr(xmodule, key, value)
# decache any pending field settings from init
xmodule.save()
return xmodule
@@ -700,7 +846,7 @@ class MongoModuleStore(ModuleStoreWriteBase):
# differs from split mongo in that I believe most of this logic should be above the persistence
# layer but added it here to enable quick conversion. I'll need to reconcile these.
new_object = self.create_xmodule(location, definition_data, metadata, system, fields)
location = new_object.location
location = new_object.scope_ids.usage_id
self.update_item(new_object, allow_not_found=True)
# VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so
@@ -712,47 +858,20 @@ class MongoModuleStore(ModuleStoreWriteBase):
course.tabs.append(
StaticTab(
name=new_object.display_name,
url_slug=new_object.location.name,
url_slug=new_object.scope_ids.usage_id.name,
)
)
self.update_item(course)
return new_object
def fire_updated_modulestore_signal(self, course_id, location):
"""
Send a signal using `self.modulestore_update_signal`, if that has been set
"""
if self.modulestore_update_signal is not None:
self.modulestore_update_signal.send(self, modulestore=self, course_id=course_id,
location=location)
def _get_course_for_item(self, location, depth=0):
'''
VS[compat]
cdodge: for a given Xmodule, return the course that it belongs to
NOTE: This makes a lot of assumptions about the format of the course location
for a given Xmodule, return the course that it belongs to
Also we have to assert that this module maps to only one course item - it'll throw an
assert if not
This is only used to support static_tabs as we need to be course module aware
'''
# @hack! We need to find the course location however, we don't
# know the 'name' parameter in this context, so we have
# to assume there's only one item in this query even though we are not specifying a name
course_search_location = Location('i4x', location.org, location.course, 'course', None)
courses = self.get_items(course_search_location, depth=depth)
# make sure we found exactly one match on this above course search
found_cnt = len(courses)
if found_cnt == 0:
raise Exception('Could not find course at {0}'.format(course_search_location))
if found_cnt > 1:
raise Exception('Found more than one course at {0}. There should only be one!!! '
'Dump = {1}'.format(course_search_location, courses))
return courses[0]
return self.get_course(location.course_key, depth)
def _update_single_item(self, location, update):
"""
@@ -763,7 +882,7 @@ class MongoModuleStore(ModuleStoreWriteBase):
# See http://www.mongodb.org/display/DOCS/Updating for
# atomic update syntax
result = self.collection.update(
{'_id': namedtuple_to_son(Location(location))},
{'_id': location_to_son(location)},
{'$set': update},
multi=False,
upsert=True,
@@ -774,50 +893,70 @@ class MongoModuleStore(ModuleStoreWriteBase):
if result['n'] == 0:
raise ItemNotFoundError(location)
def update_item(self, xblock, user=None, allow_not_found=False):
def update_item(self, xblock, user_id=None, allow_not_found=False, force=False):
"""
Update the persisted version of xblock to reflect its current values.
location: Something that can be passed to Location
data: A nested dictionary of problem data
xblock: which xblock to persist
user_id: who made the change (ignored for now by this modulestore)
allow_not_found: whether to create a new object if one didn't already exist or give an error
force: force is meaningless for this modulestore
"""
try:
definition_data = xblock.get_explicitly_set_fields_by_scope()
definition_data = self._convert_reference_fields_to_strings(xblock, xblock.get_explicitly_set_fields_by_scope())
payload = {
'definition.data': definition_data,
'metadata': own_metadata(xblock),
'metadata': self._convert_reference_fields_to_strings(xblock, own_metadata(xblock)),
}
if xblock.has_children:
# convert all to urls
xblock.children = [child.url() if isinstance(child, Location) else child
for child in xblock.children]
payload.update({'definition.children': xblock.children})
self._update_single_item(xblock.location, payload)
children = self._convert_reference_fields_to_strings(xblock, {'children': xblock.children})
payload.update({'definition.children': children['children']})
self._update_single_item(xblock.scope_ids.usage_id, payload)
# for static tabs, their containing course also records their display name
if xblock.category == 'static_tab':
course = self._get_course_for_item(xblock.location)
if xblock.scope_ids.block_type == 'static_tab':
course = self._get_course_for_item(xblock.scope_ids.usage_id)
# find the course's reference to this tab and update the name.
static_tab = CourseTabList.get_tab_by_slug(course.tabs, xblock.location.name)
static_tab = CourseTabList.get_tab_by_slug(course.tabs, xblock.scope_ids.usage_id.name)
# only update if changed
if static_tab and static_tab['name'] != xblock.display_name:
static_tab['name'] = xblock.display_name
self.update_item(course, user)
self.update_item(course, user_id)
# recompute (and update) the metadata inheritance tree which is cached
# was conditional on children or metadata having changed before dhm made one update to rule them all
self.refresh_cached_metadata_inheritance_tree(xblock.location)
self.refresh_cached_metadata_inheritance_tree(xblock.scope_ids.usage_id.course_key, xblock.runtime)
# fire signal that we've written to DB
self.fire_updated_modulestore_signal(get_course_id_no_run(xblock.location), xblock.location)
except ItemNotFoundError:
if not allow_not_found:
raise
def _convert_reference_fields_to_strings(self, xblock, jsonfields):
"""
Find all fields of type reference and convert the payload from UsageKeys to deprecated strings
:param xblock: the XBlock class
:param jsonfields: a dict of the jsonified version of the fields
"""
assert isinstance(jsonfields, dict)
for field_name, value in jsonfields.iteritems():
if value:
if isinstance(xblock.fields[field_name], Reference):
jsonfields[field_name] = value.to_deprecated_string()
elif isinstance(xblock.fields[field_name], ReferenceList):
jsonfields[field_name] = [
ele.to_deprecated_string() for ele in value
]
elif isinstance(xblock.fields[field_name], ReferenceValueDict):
for key, subvalue in value.iteritems():
assert isinstance(subvalue, Location)
value[key] = subvalue.to_deprecated_string()
return jsonfields
# pylint: disable=unused-argument
def delete_item(self, location, **kwargs):
"""
Delete an item from this modulestore
Delete an item from this modulestore.
location: Something that can be passed to Location
Args:
location (UsageKey)
"""
# pylint: enable=unused-argument
# VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so
@@ -825,26 +964,28 @@ class MongoModuleStore(ModuleStoreWriteBase):
# we should remove this once we can break this reference from the course to static tabs
if location.category == 'static_tab':
item = self.get_item(location)
course = self._get_course_for_item(item.location)
course = self._get_course_for_item(item.scope_ids.usage_id)
existing_tabs = course.tabs or []
course.tabs = [tab for tab in existing_tabs if tab.get('url_slug') != location.name]
self.update_item(course, '**replace_user**')
# Must include this to avoid the django debug toolbar (which defines the deprecated "safe=False")
# from overriding our default value set in the init method.
self.collection.remove({'_id': Location(location).dict()}, safe=self.collection.safe)
self.collection.remove({'_id': location_to_son(location)}, safe=self.collection.safe)
# recompute (and update) the metadata inheritance tree which is cached
self.refresh_cached_metadata_inheritance_tree(Location(location))
self.fire_updated_modulestore_signal(get_course_id_no_run(Location(location)), Location(location))
self.refresh_cached_metadata_inheritance_tree(location.course_key)
def get_parent_locations(self, location, course_id):
def get_parent_locations(self, location):
'''Find all locations that are the parents of this location in this
course. Needed for path_to_location().
'''
location = Location.ensure_fully_specified(location)
items = self.collection.find({'definition.children': location.url()},
{'_id': True})
return [Location(i['_id']) for i in items]
query = self._course_key_to_son(location.course_key)
query['definition.children'] = location.to_deprecated_string()
items = self.collection.find(query, {'_id': True})
return [
location.course_key.make_usage_key(i['_id']['category'], i['_id']['name'])
for i in items
]
def get_modulestore_type(self, course_id):
"""
@@ -856,21 +997,22 @@ class MongoModuleStore(ModuleStoreWriteBase):
"""
return MONGO_MODULESTORE_TYPE
def get_orphans(self, course_location, _branch):
def get_orphans(self, course_key):
"""
Return an array all of the locations for orphans in the course.
Return an array all of the locations (deprecated string format) for orphans in the course.
"""
detached_categories = [name for name, __ in XBlock.load_tagged_classes("detached")]
all_items = self.collection.find({
'_id.org': course_location.org,
'_id.course': course_location.course,
'_id.category': {'$nin': detached_categories}
})
query = self._course_key_to_son(course_key)
query['_id.category'] = {'$nin': detached_categories}
all_items = self.collection.find(query)
all_reachable = set()
item_locs = set()
for item in all_items:
if item['_id']['category'] != 'course':
item_locs.add(Location(item['_id']).replace(revision=None).url())
# It would be nice to change this method to return UsageKeys instead of the deprecated string.
item_locs.add(
Location._from_deprecated_son(item['_id'], course_key.run).replace(revision=None).to_deprecated_string()
)
all_reachable = all_reachable.union(item.get('definition', {}).get('children', []))
item_locs -= all_reachable
return list(item_locs)
@@ -882,7 +1024,8 @@ class MongoModuleStore(ModuleStoreWriteBase):
:return: list of course locations
"""
courses = self.collection.find({'definition.data.wiki_slug': wiki_slug})
return [Location(course['_id']) for course in courses]
# the course's run == its name. It's the only xblock for which that's necessarily true.
return [Location._from_deprecated_son(course['_id'], course['_id']['name']) for course in courses]
def _create_new_field_data(self, _category, _location, definition_data, metadata):
"""

View File

@@ -7,14 +7,14 @@ and otherwise returns i4x://org/course/cat/name).
"""
from datetime import datetime
from xmodule.exceptions import InvalidVersionError
from xmodule.modulestore import Location
from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateItemError
from xmodule.modulestore.mongo.base import location_to_query, namedtuple_to_son, get_course_id_no_run, MongoModuleStore
import pymongo
from pytz import UTC
from xmodule.exceptions import InvalidVersionError
from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateItemError
from xmodule.modulestore.mongo.base import MongoModuleStore
from xmodule.modulestore.locations import Location
DRAFT = 'draft'
# Things w/ these categories should never be marked as version='draft'
DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_tab', 'course_info']
@@ -24,14 +24,14 @@ def as_draft(location):
"""
Returns the Location that is the draft for `location`
"""
return Location(location).replace(revision=DRAFT)
return location.replace(revision=DRAFT)
def as_published(location):
"""
Returns the Location that is the published version for `location`
"""
return Location(location).replace(revision=None)
return location.replace(revision=None)
def wrap_draft(item):
@@ -56,19 +56,19 @@ class DraftModuleStore(MongoModuleStore):
their children) to published modules.
"""
def get_item(self, location, depth=0):
def get_item(self, usage_key, depth=0):
"""
Returns an XModuleDescriptor instance for the item at location.
If location.revision is None, returns the item with the most
Returns an XModuleDescriptor instance for the item at usage_key.
If usage_key.revision is None, returns the item with the most
recent revision
If any segment of the location is None except revision, raises
If any segment of the usage_key is None except revision, raises
xmodule.modulestore.exceptions.InsufficientSpecificationError
If no object is found at that location, raises
If no object is found at that usage_key, raises
xmodule.modulestore.exceptions.ItemNotFoundError
location: Something that can be passed to Location
usage_key: A :class:`.UsageKey` instance
depth (int): An argument that some module stores may use to prefetch
descendents of the queried modules for more efficient results later
@@ -77,20 +77,9 @@ class DraftModuleStore(MongoModuleStore):
"""
try:
return wrap_draft(super(DraftModuleStore, self).get_item(as_draft(location), depth=depth))
return wrap_draft(super(DraftModuleStore, self).get_item(as_draft(usage_key), depth=depth))
except ItemNotFoundError:
return wrap_draft(super(DraftModuleStore, self).get_item(location, depth=depth))
def get_instance(self, course_id, location, depth=0):
"""
Get an instance of this location, with policy for course_id applied.
TODO (vshnayder): this may want to live outside the modulestore eventually
"""
try:
return wrap_draft(super(DraftModuleStore, self).get_instance(course_id, as_draft(location), depth=depth))
except ItemNotFoundError:
return wrap_draft(super(DraftModuleStore, self).get_instance(course_id, location, depth=depth))
return wrap_draft(super(DraftModuleStore, self).get_item(usage_key, depth=depth))
def create_xmodule(self, location, definition_data=None, metadata=None, system=None, fields={}):
"""
@@ -101,37 +90,42 @@ class DraftModuleStore(MongoModuleStore):
:param metadata: can be empty, the initial metadata for the kvs
:param system: if you already have an xmodule from the course, the xmodule.system value
"""
draft_loc = as_draft(location)
if draft_loc.category in DIRECT_ONLY_CATEGORIES:
if location.category in DIRECT_ONLY_CATEGORIES:
raise InvalidVersionError(location)
draft_loc = as_draft(location)
return super(DraftModuleStore, self).create_xmodule(draft_loc, definition_data, metadata, system, fields)
def get_items(self, location, course_id=None, depth=0, qualifiers=None):
def get_items(self, course_key, settings=None, content=None, **kwargs):
"""
Returns a list of XModuleDescriptor instances for the items
that match location. Any element of location that is None is treated
as a wildcard that matches any value
Returns:
list of XModuleDescriptor instances for the matching items within the course with
the given course_key
location: Something that can be passed to Location
NOTE: don't use this to look for courses
as the course_key is required. Use get_courses.
depth: An argument that some module stores may use to prefetch
descendents of the queried modules for more efficient results later
in the request. The depth is counted in the number of calls to
get_children() to cache. None indicates to cache all descendents
Args:
course_key (CourseKey): the course identifier
settings: not used
content: not used
kwargs (key=value): 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.
``name`` is another commonly provided key (Location based stores)
"""
draft_loc = as_draft(location)
draft_items = super(DraftModuleStore, self).get_items(draft_loc, course_id=course_id, depth=depth)
items = super(DraftModuleStore, self).get_items(location, course_id=course_id, depth=depth)
draft_locs_found = set(item.location.replace(revision=None) for item in draft_items)
non_draft_items = [
item
for item in items
if (item.location.revision != DRAFT
and item.location.replace(revision=None) not in draft_locs_found)
draft_items = [
wrap_draft(item) for item in
super(DraftModuleStore, self).get_items(course_key, revision='draft', **kwargs)
]
return [wrap_draft(item) for item in draft_items + non_draft_items]
draft_items_locations = {item.location for item in draft_items}
non_draft_items = [
item for item in
super(DraftModuleStore, self).get_items(course_key, revision=None, **kwargs)
# filter out items that are not already in draft
if item.location not in draft_items_locations
]
return draft_items + non_draft_items
def convert_to_draft(self, source_location):
"""
@@ -139,40 +133,38 @@ class DraftModuleStore(MongoModuleStore):
:param source: the location of the source (its revision must be None)
"""
original = self.collection.find_one(location_to_query(source_location))
original = self.collection.find_one({'_id': source_location.to_deprecated_son()})
draft_location = as_draft(source_location)
if draft_location.category in DIRECT_ONLY_CATEGORIES:
raise InvalidVersionError(source_location)
if not original:
raise ItemNotFoundError(source_location)
original['_id'] = namedtuple_to_son(draft_location)
original['_id'] = draft_location.to_deprecated_son()
try:
self.collection.insert(original)
except pymongo.errors.DuplicateKeyError:
raise DuplicateItemError(original['_id'])
self.refresh_cached_metadata_inheritance_tree(draft_location)
self.fire_updated_modulestore_signal(get_course_id_no_run(draft_location), draft_location)
self.refresh_cached_metadata_inheritance_tree(draft_location.course_key)
return self._load_items([original])[0]
return self._load_items(source_location.course_key, [original])[0]
def update_item(self, xblock, user=None, allow_not_found=False):
def update_item(self, xblock, user_id=None, allow_not_found=False, force=False):
"""
Save the current values to persisted version of the xblock
location: Something that can be passed to Location
data: A nested dictionary of problem data
See superclass doc.
In addition to the superclass's behavior, this method converts the unit to draft if it's not
already draft.
"""
draft_loc = as_draft(xblock.location)
try:
if not self.has_item(None, draft_loc):
if not self.has_item(draft_loc):
self.convert_to_draft(xblock.location)
except ItemNotFoundError:
if not allow_not_found:
raise
xblock.location = draft_loc
super(DraftModuleStore, self).update_item(xblock, user, allow_not_found)
super(DraftModuleStore, self).update_item(xblock, user_id, allow_not_found)
# don't allow locations to truly represent themselves as draft outside of this file
xblock.location = as_published(xblock.location)
@@ -188,14 +180,6 @@ class DraftModuleStore(MongoModuleStore):
return
def get_parent_locations(self, location, course_id):
'''Find all locations that are the parents of this location. Needed
for path_to_location().
returns an iterable of things that can be passed to Location.
'''
return super(DraftModuleStore, self).get_parent_locations(location, course_id)
def publish(self, location, published_by_id):
"""
Save a current draft to the underlying modulestore
@@ -216,8 +200,8 @@ class DraftModuleStore(MongoModuleStore):
# 2) child moved
for child in original_published.children:
if child not in draft.children:
rents = [Location(mom) for mom in self.get_parent_locations(child, None)]
if (len(rents) == 1 and rents[0] == Location(location)): # the 1 is this original_published
rents = self.get_parent_locations(child)
if (len(rents) == 1 and rents[0] == location): # the 1 is this original_published
self.delete_item(child, True)
super(DraftModuleStore, self).update_item(draft, '**replace_user**')
self.delete_item(location)
@@ -229,17 +213,19 @@ class DraftModuleStore(MongoModuleStore):
self.convert_to_draft(location)
super(DraftModuleStore, self).delete_item(location)
def _query_children_for_cache_children(self, items):
def _query_children_for_cache_children(self, course_key, items):
# first get non-draft in a round-trip
to_process_non_drafts = super(DraftModuleStore, self)._query_children_for_cache_children(items)
to_process_non_drafts = super(DraftModuleStore, self)._query_children_for_cache_children(course_key, items)
to_process_dict = {}
for non_draft in to_process_non_drafts:
to_process_dict[Location(non_draft["_id"])] = non_draft
to_process_dict[Location._from_deprecated_son(non_draft["_id"], course_key.run)] = non_draft
# now query all draft content in another round-trip
query = {
'_id': {'$in': [namedtuple_to_son(as_draft(Location(item))) for item in items]}
'_id': {'$in': [
as_draft(course_key.make_usage_key_from_deprecated_string(item)).to_deprecated_son() for item in items
]}
}
to_process_drafts = list(self.collection.find(query))
@@ -247,7 +233,7 @@ class DraftModuleStore(MongoModuleStore):
# with the draft. This is because the semantics of the DraftStore is to
# always return the draft - if available
for draft in to_process_drafts:
draft_loc = Location(draft["_id"])
draft_loc = Location._from_deprecated_son(draft["_id"], course_key.run)
draft_as_non_draft_loc = draft_loc.replace(revision=None)
# does non-draft exist in the collection

View File

@@ -7,15 +7,15 @@ BLOCK_PREFIX = r"block/"
# Prefix for the version portion of a locator URL, when it is preceded by a course ID
VERSION_PREFIX = r"version/"
ALLOWED_ID_CHARS = r'[\w\-~.:]'
ALLOWED_ID_CHARS = r'[\w\-~.:+]'
ALLOWED_ID_RE = re.compile(r'^{}+$'.format(ALLOWED_ID_CHARS), re.UNICODE)
# NOTE: if we need to support period in place of +, make it aggressive (take the first period in the string)
URL_RE_SOURCE = r"""
(?P<tag>edx://)?
((?P<package_id>{ALLOWED_ID_CHARS}+)/?)?
((?P<org>{ALLOWED_ID_CHARS}+)\+(?P<offering>{ALLOWED_ID_CHARS}+)/?)?
({BRANCH_PREFIX}(?P<branch>{ALLOWED_ID_CHARS}+)/?)?
({VERSION_PREFIX}(?P<version_guid>[A-F0-9]+)/?)?
({BLOCK_PREFIX}(?P<block>{ALLOWED_ID_CHARS}+))?
({BLOCK_PREFIX}(?P<block_id>{ALLOWED_ID_CHARS}+))?
""".format(
ALLOWED_ID_CHARS=ALLOWED_ID_CHARS, BRANCH_PREFIX=BRANCH_PREFIX,
VERSION_PREFIX=VERSION_PREFIX, BLOCK_PREFIX=BLOCK_PREFIX
@@ -24,40 +24,33 @@ URL_RE_SOURCE = r"""
URL_RE = re.compile('^' + URL_RE_SOURCE + '$', re.IGNORECASE | re.VERBOSE | re.UNICODE)
def parse_url(string, tag_optional=False):
def parse_url(string):
"""
A url usually begins with 'edx://' (case-insensitive match),
followed by either a version_guid or a package_id. If tag_optional, then
followed by either a version_guid or a org + offering pair. If tag_optional, then
the url does not have to start with the tag and edx will be assumed.
Examples:
'edx://version/0123FFFF'
'edx://mit.eecs.6002x'
'edx://mit.eecs.6002x/branch/published'
'edx://mit.eecs.6002x/branch/published/block/HW3'
'edx://mit.eecs.6002x/branch/published/version/000eee12345/block/HW3'
'edx:version/0123FFFF'
'edx:mit.eecs.6002x'
'edx:mit.eecs.6002x/branch/published'
'edx:mit.eecs.6002x/branch/published/block/HW3'
'edx:mit.eecs.6002x/branch/published/version/000eee12345/block/HW3'
This returns None if string cannot be parsed.
If it can be parsed as a version_guid with no preceding package_id, returns a dict
If it can be parsed as a version_guid with no preceding org + offering, returns a dict
with key 'version_guid' and the value,
If it can be parsed as a package_id, returns a dict
If it can be parsed as a org + offering, returns a dict
with key 'id' and optional keys 'branch' and 'version_guid'.
"""
match = URL_RE.match(string)
if not match:
return None
matched_dict = match.groupdict()
if matched_dict['tag'] is None and not tag_optional:
return None
return matched_dict
BLOCK_RE = re.compile(r'^' + ALLOWED_ID_CHARS + r'+$', re.IGNORECASE | re.UNICODE)
def parse_block_ref(string):
r"""
A block_ref is a string of url safe characters (see ALLOWED_ID_CHARS)
@@ -65,46 +58,6 @@ def parse_block_ref(string):
If string is a block_ref, returns a dict with key 'block_ref' and the value,
otherwise returns None.
"""
if len(string) > 0 and BLOCK_RE.match(string):
return {'block': string}
if ALLOWED_ID_RE.match(string):
return {'block_id': string}
return None
def parse_package_id(string):
r"""
A package_id has a main id component.
There may also be an optional branch (/branch/published or /branch/draft).
There may also be an optional version (/version/519665f6223ebd6980884f2b).
There may also be an optional block (/block/HW3 or /block/Quiz2).
Examples of valid package_ids:
'mit.eecs.6002x'
'mit.eecs.6002x/branch/published'
'mit.eecs.6002x/block/HW3'
'mit.eecs.6002x/branch/published/block/HW3'
'mit.eecs.6002x/branch/published/version/519665f6223ebd6980884f2b/block/HW3'
Syntax:
package_id = main_id [/branch/ branch] [/version/ version ] [/block/ block]
main_id = name [. name]*
branch = name
block = name
name = ALLOWED_ID_CHARS
If string is a package_id, returns a dict with keys 'id', 'branch', and 'block'.
Revision is optional: if missing returned_dict['branch'] is None.
Block is optional: if missing returned_dict['block'] is None.
Else returns None.
"""
match = URL_RE.match(string)
if not match:
return None
return match.groupdict()

View File

@@ -1,34 +1,28 @@
from itertools import repeat
from xmodule.course_module import CourseDescriptor
from .exceptions import (ItemNotFoundError, NoPathToItem)
from . import Location
def path_to_location(modulestore, course_id, location):
def path_to_location(modulestore, usage_key):
'''
Try to find a course_id/chapter/section[/position] path to location in
modulestore. The courseware insists that the first level in the course is
chapter, but any kind of module can be a "section".
location: something that can be passed to Location
course_id: Search for paths in this course.
Args:
modulestore: which store holds the relevant objects
usage_key: :class:`UsageKey` the id of the location to which to generate the path
raise ItemNotFoundError if the location doesn't exist.
Raises
ItemNotFoundError if the location doesn't exist.
NoPathToItem if the location exists, but isn't accessible via
a chapter/section path in the course(s) being searched.
raise NoPathToItem if the location exists, but isn't accessible via
a chapter/section path in the course(s) being searched.
Returns:
a tuple (course_id, chapter, section, position) suitable for the
courseware index view.
Return a tuple (course_id, chapter, section, position) suitable for the
courseware index view.
A location may be accessible via many paths. This method may
return any valid path.
If the section is a sequential or vertical, position will be the position
of this location in that sequence. Otherwise, position will
be None. TODO (vshnayder): Not true yet.
If the section is a sequential or vertical, position will be the children index
of this location under that sequence.
'''
def flatten(xs):
@@ -55,41 +49,38 @@ def path_to_location(modulestore, course_id, location):
# tuples (location, path-so-far). To avoid lots of
# copying, the path-so-far is stored as a lisp-style
# list--nested hd::tl tuples, and flattened at the end.
queue = [(location, ())]
queue = [(usage_key, ())]
while len(queue) > 0:
(loc, path) = queue.pop() # Takes from the end
loc = Location(loc)
(next_usage, path) = queue.pop() # Takes from the end
# get_parent_locations should raise ItemNotFoundError if location
# isn't found so we don't have to do it explicitly. Call this
# first to make sure the location is there (even if it's a course, and
# we would otherwise immediately exit).
parents = modulestore.get_parent_locations(loc, course_id)
parents = modulestore.get_parent_locations(next_usage)
# print 'Processing loc={0}, path={1}'.format(loc, path)
if loc.category == "course":
# confirm that this is the right course
if course_id == CourseDescriptor.location_to_id(loc):
# Found it!
path = (loc, path)
return flatten(path)
# print 'Processing loc={0}, path={1}'.format(next_usage, path)
if next_usage.definition_key.block_type == "course":
# Found it!
path = (next_usage, path)
return flatten(path)
# otherwise, add parent locations at the end
newpath = (loc, path)
newpath = (next_usage, path)
queue.extend(zip(parents, repeat(newpath)))
# If we're here, there is no path
return None
if not modulestore.has_item(course_id, location):
raise ItemNotFoundError
if not modulestore.has_item(usage_key):
raise ItemNotFoundError(usage_key)
path = find_path_to_course()
if path is None:
raise NoPathToItem(location)
raise NoPathToItem(usage_key)
n = len(path)
course_id = CourseDescriptor.location_to_id(path[0])
course_id = path[0].course_key
# pull out the location names
chapter = path[1].name if n > 1 else None
section = path[2].name if n > 2 else None
@@ -105,9 +96,9 @@ def path_to_location(modulestore, course_id, location):
if n > 3:
position_list = []
for path_index in range(2, n - 1):
category = path[path_index].category
category = path[path_index].definition_key.block_type
if category == 'sequential' or category == 'videosequence':
section_desc = modulestore.get_instance(course_id, path[path_index])
section_desc = modulestore.get_item(path[path_index])
child_locs = [c.location for c in section_desc.get_children()]
# positions are 1-indexed, and should be strings to be consistent with
# url parsing.

View File

@@ -6,9 +6,7 @@ Exists at the top level of modulestore b/c it needs to know about and access eac
In general, it's strategy is to treat the other modulestores as read-only and to never directly
manipulate storage but use existing api's.
'''
from xmodule.modulestore import Location
from xmodule.modulestore.locator import CourseLocator
from xmodule.modulestore.mongo import draft
from xblock.fields import Reference, ReferenceList, ReferenceValueDict
class SplitMigrator(object):
@@ -23,65 +21,64 @@ class SplitMigrator(object):
self.draft_modulestore = draft_modulestore
self.loc_mapper = loc_mapper
def migrate_mongo_course(self, course_location, user, new_package_id=None):
def migrate_mongo_course(self, course_key, user, new_org=None, new_offering=None):
"""
Create a new course in split_mongo representing the published and draft versions of the course from the
original mongo store. And return the new_package_id (which the caller can also get by calling
self.loc_mapper.translate_location(old_course_location)
original mongo store. And return the new CourseLocator
If the new course already exists, this raises DuplicateItemError
:param course_location: a Location whose category is 'course' and points to the course
:param user: the user whose action is causing this migration
:param new_package_id: (optional) the Locator.package_id for the new course. Defaults to
whatever translate_location_to_locator returns
:param new_org: (optional) the Locator.org for the new course. Defaults to
whatever translate_location_to_locator returns
:param new_offering: (optional) the Locator.offering for the new course. Defaults to
whatever translate_location_to_locator returns
"""
new_package_id = self.loc_mapper.create_map_entry(course_location, package_id=new_package_id)
old_course_id = course_location.course_id
new_course_locator = self.loc_mapper.create_map_entry(course_key, new_org, new_offering)
# the only difference in data between the old and split_mongo xblocks are the locations;
# so, any field which holds a location must change to a Locator; otherwise, the persistence
# layer and kvs's know how to store it.
# locations are in location, children, conditionals, course.tab
# create the course: set fields to explicitly_set for each scope, id_root = new_package_id, master_branch = 'production'
original_course = self.direct_modulestore.get_item(course_location)
new_course_root_locator = self.loc_mapper.translate_location(old_course_id, course_location)
# 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)
new_course_root_locator = self.loc_mapper.translate_location(original_course.location)
new_course = self.split_modulestore.create_course(
new_package_id, course_location.org,
user.id,
fields=self._get_json_fields_translate_children(original_course, old_course_id, True),
new_course_root_locator.org, new_course_root_locator.offering, user.id,
fields=self._get_json_fields_translate_references(original_course, course_key, True),
root_block_id=new_course_root_locator.block_id,
master_branch=new_course_root_locator.branch
)
self._copy_published_modules_to_course(new_course, course_location, old_course_id, user)
self._add_draft_modules_to_course(new_package_id, old_course_id, course_location, user)
self._copy_published_modules_to_course(new_course, original_course.location, course_key, user)
self._add_draft_modules_to_course(new_course.id, course_key, user)
return new_package_id
return new_course_locator
def _copy_published_modules_to_course(self, new_course, old_course_loc, old_course_id, user):
def _copy_published_modules_to_course(self, new_course, old_course_loc, course_key, user):
"""
Copy all of the modules from the 'direct' version of the course to the new split course.
"""
course_version_locator = new_course.location.as_course_locator()
course_version_locator = new_course.id
# 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(
old_course_loc.replace(category=None, name=None, revision=None),
old_course_id
):
for module in self.direct_modulestore.get_items(course_key):
# don't copy the course again. No drafts should get here but check
if module.location != old_course_loc and not getattr(module, 'is_draft', False):
# create split_xblock using split.create_item
# where block_id is computed by translate_location_to_locator
new_locator = self.loc_mapper.translate_location(
old_course_id, module.location, True, add_entry_if_missing=True
module.location, True, add_entry_if_missing=True
)
# NOTE: the below auto populates the children when it migrates the parent; so,
# it doesn't need the parent as the first arg. That is, it translates and populates
# the 'children' field as it goes.
_new_module = self.split_modulestore.create_item(
course_version_locator, module.category, user.id,
block_id=new_locator.block_id,
fields=self._get_json_fields_translate_children(module, old_course_id, True),
fields=self._get_json_fields_translate_references(module, course_key, True),
continue_version=True
)
# after done w/ published items, add version for 'draft' pointing to the published structure
@@ -94,25 +91,22 @@ class SplitMigrator(object):
# children which meant some pointers were to non-existent locations in 'direct'
self.split_modulestore.internal_clean_children(course_version_locator)
def _add_draft_modules_to_course(self, new_package_id, old_course_id, old_course_loc, user):
def _add_draft_modules_to_course(self, published_course_key, course_key, user):
"""
update each draft. Create any which don't exist in published and attach to their parents.
"""
# each true update below will trigger a new version of the structure. We may want to just have one new version
# but that's for a later date.
new_draft_course_loc = CourseLocator(package_id=new_package_id, branch='draft')
new_draft_course_loc = published_course_key.for_branch('draft')
# to prevent race conditions of grandchilden being added before their parents and thus having no parent to
# add to
awaiting_adoption = {}
for module in self.draft_modulestore.get_items(
old_course_loc.replace(category=None, name=None, revision=draft.DRAFT),
old_course_id
):
for module in self.draft_modulestore.get_items(course_key):
if getattr(module, 'is_draft', False):
new_locator = self.loc_mapper.translate_location(
old_course_id, module.location, False, add_entry_if_missing=True
module.location, False, add_entry_if_missing=True
)
if self.split_modulestore.has_item(new_package_id, new_locator):
if self.split_modulestore.has_item(new_locator):
# was in 'direct' so draft is a new version
split_module = self.split_modulestore.get_item(new_locator)
# need to remove any no-longer-explicitly-set values and add/update any now set values.
@@ -131,25 +125,24 @@ class SplitMigrator(object):
_new_module = self.split_modulestore.create_item(
new_draft_course_loc, module.category, user.id,
block_id=new_locator.block_id,
fields=self._get_json_fields_translate_children(module, old_course_id, True)
fields=self._get_json_fields_translate_references(module, course_key, True)
)
awaiting_adoption[module.location] = new_locator.block_id
for draft_location, new_block_id in awaiting_adoption.iteritems():
for parent_loc in self.draft_modulestore.get_parent_locations(draft_location, old_course_id):
for parent_loc in self.draft_modulestore.get_parent_locations(draft_location):
old_parent = self.draft_modulestore.get_item(parent_loc)
new_parent = self.split_modulestore.get_item(
self.loc_mapper.translate_location(old_course_id, old_parent.location, False)
self.loc_mapper.translate_location(old_parent.location, False)
)
# this only occurs if the parent was also awaiting adoption
if new_block_id in new_parent.children:
break
# find index for module: new_parent may be missing quite a few of old_parent's children
new_parent_cursor = 0
draft_location = draft_location.url() # need as string
for old_child_loc in old_parent.children:
if old_child_loc == draft_location:
break
sibling_loc = self.loc_mapper.translate_location(old_course_id, Location(old_child_loc), False)
sibling_loc = self.loc_mapper.translate_location(old_child_loc, False)
# sibling may move cursor
for idx in range(new_parent_cursor, len(new_parent.children)):
if new_parent.children[idx] == sibling_loc.block_id:
@@ -158,24 +151,32 @@ class SplitMigrator(object):
new_parent.children.insert(new_parent_cursor, new_block_id)
new_parent = self.split_modulestore.update_item(new_parent, user.id)
def _get_json_fields_translate_children(self, xblock, old_course_id, published):
def _get_json_fields_translate_references(self, xblock, old_course_id, published):
"""
Return the json repr for explicitly set fields but convert all children to their block_id's
Return the json repr for explicitly set fields but convert all references to their block_id's
"""
fields = self.get_json_fields_explicitly_set(xblock)
# this will too generously copy the children even for ones that don't exist in the published b/c the old mongo
# had no way of not having parents point to draft only children :-(
if 'children' in fields:
fields['children'] = [
self.loc_mapper.translate_location(
old_course_id, Location(child), published, add_entry_if_missing=True
).block_id
for child in fields['children']]
return fields
# FIXME change split to take field values as pythonic values not json values
result = {}
for field_name, field in xblock.fields.iteritems():
if field.is_set_on(xblock):
if isinstance(field, Reference):
result[field_name] = unicode(self.loc_mapper.translate_location(
getattr(xblock, field_name), published, add_entry_if_missing=True
))
elif isinstance(field, ReferenceList):
result[field_name] = [
unicode(self.loc_mapper.translate_location(
ele, published, add_entry_if_missing=True
)) for ele in getattr(xblock, field_name)
]
elif isinstance(field, ReferenceValueDict):
result[field_name] = {
key: unicode(self.loc_mapper.translate_location(
subvalue, published, add_entry_if_missing=True
))
for key, subvalue in getattr(xblock, field_name).iteritems()
}
else:
result[field_name] = field.read_json(xblock)
def get_json_fields_explicitly_set(self, xblock):
"""
Get the json repr for fields set on this specific xblock
:param xblock:
"""
return {field.name: field.read_json(xblock) for field in xblock.fields.itervalues() if field.is_set_on(xblock)}
return result

View File

@@ -1,10 +1,10 @@
import sys
import logging
from xmodule.mako_module import MakoDescriptorSystem
from xmodule.modulestore.locator import BlockUsageLocator, LocalId
from xmodule.modulestore.locator import BlockUsageLocator, LocalId, CourseLocator
from xmodule.error_module import ErrorDescriptor
from xmodule.errortracker import exc_info_to_str
from xblock.runtime import KvsFieldData, IdReader
from xblock.runtime import KvsFieldData
from ..exceptions import ItemNotFoundError
from .split_mongo_kvs import SplitMongoKVS
from xblock.fields import ScopeIds
@@ -13,23 +13,6 @@ from xmodule.modulestore.loc_mapper_store import LocMapperStore
log = logging.getLogger(__name__)
class SplitMongoIdReader(IdReader):
"""
An :class:`~xblock.runtime.IdReader` associated with a particular
:class:`.CachingDescriptorSystem`.
"""
def __init__(self, system):
self.system = system
def get_definition_id(self, usage_id):
usage = self.system.load_item(usage_id)
return usage.definition_locator
def get_block_type(self, def_id):
definition = self.system.modulestore.db_connection.get_definition(def_id)
return definition['category']
class CachingDescriptorSystem(MakoDescriptorSystem):
"""
A system that has a cache of a course version's json that it will use to load modules
@@ -44,15 +27,14 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
modulestore: the module store that can be used to retrieve additional
modules
course_entry: the originally fetched enveloped course_structure w/ branch and package_id info.
course_entry: the originally fetched enveloped course_structure w/ branch and course id info.
Callers to _load_item provide an override but that function ignores the provided structure and
only looks at the branch and package_id
only looks at the branch and course id
module_data: a dict mapping Location -> json that was cached from the
underlying modulestore
"""
super(CachingDescriptorSystem, self).__init__(
id_reader=SplitMongoIdReader(self),
field_data=None,
load_item=self._load_item,
**kwargs
@@ -72,11 +54,14 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
self.local_modules = {}
def _load_item(self, block_id, course_entry_override=None):
if isinstance(block_id, BlockUsageLocator) and isinstance(block_id.block_id, LocalId):
try:
return self.local_modules[block_id]
except KeyError:
raise ItemNotFoundError
if isinstance(block_id, BlockUsageLocator):
if isinstance(block_id.block_id, LocalId):
try:
return self.local_modules[block_id]
except KeyError:
raise ItemNotFoundError
else:
block_id = block_id.block_id
json_data = self.module_data.get(block_id)
if json_data is None:
@@ -99,14 +84,15 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
# the thread is working with more than one named container pointing to the same specific structure is
# low; thus, the course_entry is most likely correct. If the thread is looking at > 1 named container
# pointing to the same structure, the access is likely to be chunky enough that the last known container
# is the intended one when not given a course_entry_override; thus, the caching of the last branch/package_id.
# is the intended one when not given a course_entry_override; thus, the caching of the last branch/course id.
def xblock_from_json(self, class_, block_id, json_data, course_entry_override=None):
if course_entry_override is None:
course_entry_override = self.course_entry
else:
# most recent retrieval is most likely the right one for next caller (see comment above fn)
self.course_entry['branch'] = course_entry_override['branch']
self.course_entry['package_id'] = course_entry_override['package_id']
self.course_entry['org'] = course_entry_override['org']
self.course_entry['offering'] = course_entry_override['offering']
# most likely a lazy loader or the id directly
definition = json_data.get('definition', {})
definition_id = self.modulestore.definition_locator(definition)
@@ -116,10 +102,13 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
block_id = LocalId()
block_locator = BlockUsageLocator(
version_guid=course_entry_override['structure']['_id'],
CourseLocator(
version_guid=course_entry_override['structure']['_id'],
org=course_entry_override.get('org'),
offering=course_entry_override.get('offering'),
branch=course_entry_override.get('branch'),
),
block_id=block_id,
package_id=course_entry_override.get('package_id'),
branch=course_entry_override.get('branch')
)
kvs = SplitMongoKVS(
@@ -141,7 +130,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
json_data,
self,
BlockUsageLocator(
version_guid=course_entry_override['structure']['_id'],
CourseLocator(version_guid=course_entry_override['structure']['_id']),
block_id=block_id
),
error_msg=exc_info_to_str(sys.exc_info())

View File

@@ -1,7 +1,9 @@
"""
Segregation of pymongo functions from the data modeling mechanisms for split modulestore.
"""
import re
import pymongo
from bson import son
class MongoConnection(object):
"""
@@ -18,6 +20,7 @@ class MongoConnection(object):
host=host,
port=port,
tz_aware=tz_aware,
document_class=son.SON,
**kwargs
),
db
@@ -63,11 +66,17 @@ class MongoConnection(object):
"""
self.structures.update({'_id': structure['_id']}, structure)
def get_course_index(self, key):
def get_course_index(self, key, ignore_case=False):
"""
Get the course_index from the persistence mechanism whose id is the given key
"""
return self.course_index.find_one({'_id': key})
case_regex = r"(?i)^{}$" if ignore_case else r"{}"
return self.course_index.find_one(
son.SON([
(key_attr, re.compile(case_regex.format(getattr(key, key_attr))))
for key_attr in ('org', 'offering')
])
)
def find_matching_course_indexes(self, query):
"""
@@ -86,13 +95,16 @@ class MongoConnection(object):
"""
Update the db record for course_index
"""
self.course_index.update({'_id': course_index['_id']}, course_index)
self.course_index.update(
son.SON([('org', course_index['org']), ('offering', course_index['offering'])]),
course_index
)
def delete_course_index(self, key):
def delete_course_index(self, course_index):
"""
Delete the course_index from the persistence mechanism whose id is the given key
Delete the course_index from the persistence mechanism whose id is the given course_index
"""
return self.course_index.remove({'_id': key})
return self.course_index.remove(son.SON([('org', course_index['org']), ('offering', course_index['offering'])]))
def get_definition(self, key):
"""

View File

@@ -3,8 +3,9 @@ Provides full versioning CRUD and representation for collections of xblocks (e.g
Representation:
* course_index: a dictionary:
** '_id': package_id (e.g., myu.mydept.mycourse.myrun),
** '_id': a unique id which cannot change,
** 'org': the org's id. Only used for searching not identity,
** 'offering': the course's catalog number and run id or whatever user decides,
** 'edited_by': user_id of user who created the original entry,
** 'edited_on': the datetime of the original creation,
** 'versions': versions_dict: {branch_id: structure_id, ...}
@@ -47,7 +48,6 @@ Representation:
import threading
import datetime
import logging
import re
from importlib import import_module
from path import path
import copy
@@ -122,7 +122,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
self.thread_cache = threading.local()
if default_class is not None:
module_path, _, class_name = default_class.rpartition('.')
module_path, __, class_name = default_class.rpartition('.')
class_ = getattr(import_module(module_path), class_name)
self.default_class = class_
else:
@@ -235,7 +235,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
return the CourseDescriptor! It returns the actual db json from
structures.
Semantics: if package_id and branch given, then it will get that branch. If
Semantics: if course id and branch given, then it will get that branch. If
also give a version_guid, it will see if the current head of that branch == that guid. If not
it raises VersionConflictError (the version now differs from what it was when you got your
reference)
@@ -247,9 +247,9 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
if not course_locator.is_fully_specified():
raise InsufficientSpecificationError('Not fully specified: %s' % course_locator)
if course_locator.package_id is not None and course_locator.branch is not None:
# use the package_id
index = self.db_connection.get_course_index(course_locator.package_id)
if course_locator.org and course_locator.offering and course_locator.branch:
# use the course id
index = self.db_connection.get_course_index(course_locator)
if index is None:
raise ItemNotFoundError(course_locator)
if course_locator.branch not in index['versions']:
@@ -266,11 +266,12 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
version_guid = course_locator.as_object_id(version_guid)
entry = self.db_connection.get_structure(version_guid)
# b/c more than one course can use same structure, the 'package_id' and 'branch' are not intrinsic to structure
# b/c more than one course can use same structure, the 'org', 'offering', and 'branch' are not intrinsic to structure
# and the one assoc'd w/ it by another fetch may not be the one relevant to this fetch; so,
# add it in the envelope for the structure.
envelope = {
'package_id': course_locator.package_id,
'org': course_locator.org,
'offering': course_locator.offering,
'branch': course_locator.branch,
'structure': entry,
}
@@ -300,15 +301,17 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
for structure in matching:
version_guid = structure['versions'][branch]
version_guids.append(version_guid)
id_version_map[version_guid] = structure['_id']
id_version_map[version_guid] = structure
course_entries = self.db_connection.find_matching_structures({'_id': {'$in': version_guids}})
# get the block for the course element (s/b the root)
result = []
for entry in course_entries:
course_info = id_version_map[entry['_id']]
envelope = {
'package_id': id_version_map[entry['_id']],
'org': course_info['org'],
'offering': course_info['offering'],
'branch': branch,
'structure': entry,
}
@@ -316,42 +319,44 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
result.extend(self._load_items(envelope, [root], 0, lazy=True))
return result
def get_course(self, course_locator):
def get_course(self, course_id, depth=None):
'''
Gets the course descriptor for the course identified by the locator
which may or may not be a blockLocator.
raises InsufficientSpecificationError
'''
course_entry = self._lookup_course(course_locator)
assert(isinstance(course_id, CourseLocator))
course_entry = self._lookup_course(course_id)
root = course_entry['structure']['root']
result = self._load_items(course_entry, [root], 0, lazy=True)
return result[0]
def get_course_for_item(self, location):
def has_course(self, course_id, ignore_case=False):
'''
Provided for backward compatibility. Is equivalent to calling get_course
:param location:
Does this course exist in this modulestore.
'''
return self.get_course(location)
assert(isinstance(course_id, CourseLocator))
course_entry = self.db_connection.get_course_index(course_id, ignore_case)
return course_entry is not None
def has_item(self, package_id, block_location):
def has_item(self, usage_key):
"""
Returns True if location exists in its course. Returns false if
the course or the block w/in the course do not exist for the given version.
raises InsufficientSpecificationError if the locator does not id a block
"""
if block_location.block_id is None:
raise InsufficientSpecificationError(block_location)
if usage_key.block_id is None:
raise InsufficientSpecificationError(usage_key)
try:
course_structure = self._lookup_course(block_location)['structure']
course_structure = self._lookup_course(usage_key)['structure']
except ItemNotFoundError:
# this error only occurs if the course does not exist
return False
return self._get_block_from_structure(course_structure, block_location.block_id) is not None
return self._get_block_from_structure(course_structure, usage_key.block_id) is not None
def get_item(self, location, depth=0):
def get_item(self, usage_key, depth=0):
"""
depth (int): An argument that some module stores may use to prefetch
descendants of the queried modules for more efficient results later
@@ -361,52 +366,77 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
raises InsufficientSpecificationError or ItemNotFoundError
"""
# intended for temporary support of some pointers being old-style
if isinstance(location, Location):
if isinstance(usage_key, Location):
if self.loc_mapper is None:
raise InsufficientSpecificationError('No location mapper configured')
else:
location = self.loc_mapper.translate_location(
None, location, location.revision is None,
usage_key = self.loc_mapper.translate_location(
usage_key,
usage_key.revision is None,
add_entry_if_missing=False
)
assert isinstance(location, BlockUsageLocator)
if not location.is_initialized():
raise InsufficientSpecificationError("Not yet initialized: %s" % location)
course = self._lookup_course(location)
items = self._load_items(course, [location.block_id], depth, lazy=True)
assert isinstance(usage_key, BlockUsageLocator)
course = self._lookup_course(usage_key)
items = self._load_items(course, [usage_key.block_id], depth, lazy=True)
if len(items) == 0:
raise ItemNotFoundError(location)
raise ItemNotFoundError(usage_key)
return items[0]
def get_items(self, locator, course_id=None, depth=0, qualifiers=None):
def get_items(self, course_locator, settings=None, content=None, **kwargs):
"""
Get all of the modules in the given course matching the qualifiers. The
qualifiers should only be fields in the structures collection (sorry).
There will be a separate search method for searching through
definitions.
Returns:
list of XModuleDescriptor instances for the matching items within the course with
the given course_id
Common qualifiers are category, definition (provide definition id),
display_name, anyfieldname, children (return
block if its children includes the one given value). If you want
substring matching use {$regex: /acme.*corp/i} type syntax.
Although these
look like mongo queries, it is all done in memory; so, you cannot
try arbitrary queries.
:param locator: CourseLocator or BlockUsageLocator restricting search scope
:param course_id: ignored. Only included for API compatibility.
:param depth: ignored. Only included for API compatibility.
:param qualifiers: a dict restricting which elements should match
NOTE: don't use this to look for courses
as the course_id is required. Use get_courses.
Args:
course_locator (CourseLocator): the course identifier
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.
kwargs (key=value): 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 split,
you can search by ``edited_by``, ``edited_on`` providing a function testing limits.
"""
# TODO extend to only search a subdag of the course?
if qualifiers is None:
qualifiers = {}
course = self._lookup_course(locator)
course = self._lookup_course(course_locator)
items = []
def _block_matches_all(block_json):
"""
Check that the block matches all the criteria
"""
# do the checks which don't require loading any additional data
if (
self._block_matches(block_json, kwargs) and
self._block_matches(block_json.get('fields', {}), settings)
):
if content:
definition_block = self.db_connection.get_definition(block_json['definition'])
return self._block_matches(definition_block.get('fields', {}), content)
else:
return True
if settings is None:
settings = {}
if 'name' in kwargs:
# odd case where we don't search just confirm
block_id = kwargs.pop('name')
block = course['structure']['blocks'].get(block_id)
if _block_matches_all(block):
return self._load_items(course, [block_id], lazy=True)
else:
return []
# don't expect caller to know that children are in fields
if 'children' in kwargs:
settings['children'] = kwargs.pop('children')
for block_id, value in course['structure']['blocks'].iteritems():
if self._block_matches(value, qualifiers):
if _block_matches_all(value):
items.append(block_id)
if len(items) > 0:
@@ -414,20 +444,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
else:
return []
def get_instance(self, course_id, location, depth=0):
"""
Get an instance of this location.
For now, just delegate to get_item and ignore course policy.
depth (int): An argument that some module stores may use to prefetch
descendants of the queried modules for more efficient results later
in the request. The depth is counted in the number of
calls to get_children() to cache. None indicates to cache all descendants.
"""
return self.get_item(location, depth=depth)
def get_parent_locations(self, locator, course_id=None):
def get_parent_locations(self, locator):
'''
Return the locations (Locators w/ block_ids) for the parents of this location in this
course. Could use get_items(location, {'children': block_id}) but this is slightly faster.
@@ -438,20 +455,20 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
'''
course = self._lookup_course(locator)
items = self._get_parents_from_structure(locator.block_id, course['structure'])
return [BlockUsageLocator(
url=locator.as_course_locator(),
block_id=LocMapperStore.decode_key_from_mongo(parent_id),
)
for parent_id in items]
return [
BlockUsageLocator.make_relative(
locator,
block_id=LocMapperStore.decode_key_from_mongo(parent_id),
)
for parent_id in items
]
def get_orphans(self, package_id, branch):
def get_orphans(self, course_key):
"""
Return a dict of all of the orphans in the course.
:param package_id:
"""
detached_categories = [name for name, __ in XBlock.load_tagged_classes("detached")]
course = self._lookup_course(CourseLocator(package_id=package_id, branch=branch))
course = self._lookup_course(course_key)
items = {LocMapperStore.decode_key_from_mongo(block_id) for block_id in course['structure']['blocks'].keys()}
items.remove(course['structure']['root'])
for block_id, block_data in course['structure']['blocks'].iteritems():
@@ -459,7 +476,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
if block_data['category'] in detached_categories:
items.discard(LocMapperStore.decode_key_from_mongo(block_id))
return [
BlockUsageLocator(package_id=package_id, branch=branch, block_id=block_id)
BlockUsageLocator(course_key=course_key, block_id=block_id)
for block_id in items
]
@@ -468,7 +485,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
The index records the initial creation of the indexed course and tracks the current version
heads. This function is primarily for test verification but may serve some
more general purpose.
:param course_locator: must have a package_id set
:param course_locator: must have a org and offering set
:return {'org': string,
versions: {'draft': the head draft version id,
'published': the head published version id if any,
@@ -477,9 +494,9 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
'edited_on': when the course was originally created
}
"""
if course_locator.package_id is None:
if not (course_locator.offering and course_locator.org):
return None
index = self.db_connection.get_course_index(course_locator.package_id)
index = self.db_connection.get_course_index(course_locator)
return index
# TODO figure out a way to make this info accessible from the course descriptor
@@ -529,6 +546,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
if course_locator.version_guid is None:
course = self._lookup_course(course_locator)
version_guid = course['structure']['_id']
course_locator = course_locator.for_version(version_guid)
else:
version_guid = course_locator.version_guid
@@ -547,7 +565,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
for course_structure in next_versions:
result.setdefault(course_structure['previous_version'], []).append(
CourseLocator(version_guid=struct['_id']))
return VersionTree(CourseLocator(course_locator, version_guid=version_guid), result)
return VersionTree(course_locator, result)
def get_block_generations(self, block_locator):
@@ -562,8 +580,12 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
course_struct = self._lookup_course(block_locator.course_agnostic())['structure']
block_id = block_locator.block_id
update_version_field = 'blocks.{}.edit_info.update_version'.format(block_id)
all_versions_with_block = self.db_connection.find_matching_structures({'original_version': course_struct['original_version'],
update_version_field: {'$exists': True}})
all_versions_with_block = self.db_connection.find_matching_structures(
{
'original_version': course_struct['original_version'],
update_version_field: {'$exists': True}
}
)
# find (all) root versions and build map {previous: {successors}..}
possible_roots = []
result = {}
@@ -590,9 +612,14 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
return None
# convert the results value sets to locators
for k, versions in result.iteritems():
result[k] = [BlockUsageLocator(version_guid=version, block_id=block_id)
for version in versions]
return VersionTree(BlockUsageLocator(version_guid=possible_roots[0], block_id=block_id), result)
result[k] = [
BlockUsageLocator(CourseLocator(version_guid=version), block_id=block_id)
for version in versions
]
return VersionTree(
BlockUsageLocator(CourseLocator(version_guid=possible_roots[0]), block_id=block_id),
result
)
def get_definition_successors(self, definition_locator, version_history_depth=1):
'''
@@ -646,7 +673,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
# actual change b/c the descriptor and cache probably point to the same objects
old_definition = self.db_connection.get_definition(definition_locator.definition_id)
if old_definition is None:
raise ItemNotFoundError(definition_locator.url())
raise ItemNotFoundError(definition_locator.to_deprecated_string())
if needs_saved():
# new id to create new version
@@ -693,11 +720,12 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
:param course_or_parent_locator: If BlockUsageLocator, then it's assumed to be the parent.
If it's a CourseLocator, then it's
merely the containing course.
merely the containing course. If it has a version_guid and a course org + offering + branch, this
method ensures that the version is the head of the given course branch before making the change.
raises InsufficientSpecificationError if there is no course locator.
raises VersionConflictError if package_id and version_guid given and the current version head != version_guid
and force is not True.
raises VersionConflictError if the version_guid of the course_or_parent_locator is not the head
of the its course unless force is true.
:param force: fork the structure and don't update the course draftVersion if the above
:param continue_revision: for multistep transactions, continue revising the given version rather than creating
a new version. Setting force to True conflicts with setting this to True and will cause a VersionConflictError
@@ -722,11 +750,11 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
Rules for course locator:
* If the course locator specifies a package_id and either it doesn't
* If the course locator specifies a org and offering and either it doesn't
specify version_guid or the one it specifies == the current head of the branch,
it progresses the course to point
to the new head and sets the active version to point to the new head
* If the locator has a package_id but its version_guid != current head, it raises VersionConflictError.
* If the locator has a org and offering but its version_guid != current head, it raises VersionConflictError.
NOTE: using a version_guid will end up creating a new version of the course. Your new item won't be in
the course id'd by version_guid but instead in one w/ a new version_guid. Ensure in this case that you get
@@ -800,29 +828,36 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
if not continue_version:
self._update_head(index_entry, course_or_parent_locator.branch, new_id)
item_loc = BlockUsageLocator(
package_id=course_or_parent_locator.package_id,
branch=course_or_parent_locator.branch,
course_or_parent_locator.version_agnostic(),
block_id=new_block_id,
)
else:
item_loc = BlockUsageLocator(
CourseLocator(version_guid=new_id),
block_id=new_block_id,
version_guid=new_id,
)
# reconstruct the new_item from the cache
return self.get_item(item_loc)
def create_course(
self, course_id, org, user_id, fields=None,
self, org, offering, user_id, fields=None,
master_branch='draft', versions_dict=None, root_category='course',
root_block_id='course'
root_block_id='course', **kwargs
):
"""
Create a new entry in the active courses index which points to an existing or new structure. Returns
the course root of the resulting entry (the location has the course id)
course_id: If it's already taken, this method will raise DuplicateCourseError
Arguments:
org (str): the organization that owns the course
offering (str): the name of the course offering
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
offering: If it's already taken, this method will raise DuplicateCourseError
fields: if scope.settings fields provided, will set the fields of the root course object in the
new course. If both
@@ -848,10 +883,11 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
provide any fields overrides, see above). if not provided, will create a mostly empty course
structure with just a category course root xblock.
"""
# check course_id's uniqueness
index = self.db_connection.get_course_index(course_id)
# check offering's uniqueness
locator = CourseLocator(org=org, offering=offering, branch=master_branch)
index = self.db_connection.get_course_index(locator)
if index is not None:
raise DuplicateCourseError(course_id, index)
raise DuplicateCourseError(locator, index)
partitioned_fields = self.partition_fields_by_scope(root_category, fields)
block_fields = partitioned_fields.setdefault(Scope.settings, {})
@@ -920,15 +956,16 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
versions_dict[master_branch] = new_id
index_entry = {
'_id': course_id,
'_id': ObjectId(),
'org': org,
'offering': offering,
'edited_by': user_id,
'edited_on': datetime.datetime.now(UTC),
'versions': versions_dict,
'schema_version': self.SCHEMA_VERSION,
}
self.db_connection.insert_course_index(index_entry)
return self.get_course(CourseLocator(package_id=course_id, branch=master_branch))
return self.get_course(locator)
def update_item(self, descriptor, user_id, allow_not_found=False, force=False):
"""
@@ -937,7 +974,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
raises ItemNotFoundError if the location does not exist.
Creates a new course version. If the descriptor's location has a package_id, it moves the course head
Creates a new course version. If the descriptor's location has a org and offering, it moves the course head
pointer. If the version_guid of the descriptor points to a non-head version and there's been an intervening
change to this item, it raises a VersionConflictError unless force is True. In the force case, it forks
the course but leaves the head pointer where it is (this change will not be in the course head).
@@ -983,10 +1020,16 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
# update the index entry if appropriate
if index_entry is not None:
self._update_head(index_entry, descriptor.location.branch, new_id)
course_key = CourseLocator(
org=index_entry['org'], offering=index_entry['offering'],
branch=descriptor.location.branch,
version_guid=new_id
)
else:
course_key = CourseLocator(version_guid=new_id)
# fetch and return the new item--fetching is unnecessary but a good qc step
new_locator = BlockUsageLocator(descriptor.location)
new_locator.version_guid = new_id
new_locator = BlockUsageLocator(course_key, descriptor.location.block_id)
return self.get_item(new_locator)
else:
# nothing changed, just return the one sent in
@@ -1060,10 +1103,8 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
# fetch and return the new item--fetching is unnecessary but a good qc step
return self.get_item(
BlockUsageLocator(
package_id=xblock.location.package_id,
xblock.location.course_key.for_version(new_id),
block_id=xblock.location.block_id,
branch=xblock.location.branch,
version_guid=new_id
)
)
else:
@@ -1088,7 +1129,8 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
if block_id is None:
block_id = self._generate_block_id(structure_blocks, xblock.category)
encoded_block_id = LocMapperStore.encode_key_for_mongo(block_id)
xblock.scope_ids.usage_id.block_id = block_id
new_usage_id = xblock.scope_ids.usage_id.replace(block_id=block_id)
xblock.scope_ids = xblock.scope_ids._replace(usage_id=new_usage_id) # pylint: disable=protected-access
else:
is_new = False
encoded_block_id = LocMapperStore.encode_key_for_mongo(xblock.location.block_id)
@@ -1179,7 +1221,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
"""
# get the destination's index, and source and destination structures.
source_structure = self._lookup_course(source_course)['structure']
index_entry = self.db_connection.get_course_index(destination_course.package_id)
index_entry = self.db_connection.get_course_index(destination_course)
if index_entry is None:
# brand new course
raise ItemNotFoundError(destination_course)
@@ -1244,13 +1286,13 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
raises ItemNotFoundError if the location does not exist.
raises ValueError if usage_locator points to the structure root
Creates a new course version. If the descriptor's location has a package_id, it moves the course head
Creates a new course version. If the descriptor's location has a org and offering, it moves the course head
pointer. If the version_guid of the descriptor points to a non-head version and there's been an intervening
change to this item, it raises a VersionConflictError unless force is True. In the force case, it forks
the course but leaves the head pointer where it is (this change will not be in the course head).
"""
assert isinstance(usage_locator, BlockUsageLocator) and usage_locator.is_initialized()
original_structure = self._lookup_course(usage_locator)['structure']
assert isinstance(usage_locator, BlockUsageLocator)
original_structure = self._lookup_course(usage_locator.course_key)['structure']
if original_structure['root'] == usage_locator.block_id:
raise ValueError("Cannot delete the root of a course")
index_entry = self._get_index_if_valid(usage_locator, force)
@@ -1283,32 +1325,29 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
# update index if appropriate and structures
self.db_connection.insert_structure(new_structure)
result = CourseLocator(version_guid=new_id)
# update the index entry if appropriate
if index_entry is not None:
# update the index entry if appropriate
self._update_head(index_entry, usage_locator.branch, new_id)
result.package_id = usage_locator.package_id
result.branch = usage_locator.branch
result = usage_locator.course_key.for_version(new_id)
else:
result = CourseLocator(version_guid=new_id)
return result
def delete_course(self, package_id):
def delete_course(self, course_key, user_id=None):
"""
Remove the given course from the course index.
Only removes the course from the index. The data remains. You can use create_course
with a versions hash to restore the course; however, the edited_on and
edited_by won't reflect the originals, of course.
:param package_id: uses package_id rather than locator to emphasize its global effect
"""
index = self.db_connection.get_course_index(package_id)
index = self.db_connection.get_course_index(course_key)
if index is None:
raise ItemNotFoundError(package_id)
raise ItemNotFoundError(course_key)
# this is the only real delete in the system. should it do something else?
log.info(u"deleting course from split-mongo: %s", package_id)
self.db_connection.delete_course_index(index['_id'])
log.info(u"deleting course from split-mongo: %s", course_key)
self.db_connection.delete_course_index(index)
def get_errored_courses(self):
"""
@@ -1413,38 +1452,6 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
# clear cache again b/c inheritance may be wrong over orphans
self._clear_cache(original_structure['_id'])
def _block_matches(self, value, qualifiers):
'''
Return True or False depending on whether the value (block contents)
matches the qualifiers as per get_items
:param value:
:param qualifiers:
'''
for key, criteria in qualifiers.iteritems():
if key in value:
target = value[key]
if not self._value_matches(target, criteria):
return False
elif criteria is not None:
return False
return True
def _value_matches(self, target, criteria):
''' helper for _block_matches '''
if isinstance(target, list):
return any(self._value_matches(ele, criteria)
for ele in target)
elif isinstance(criteria, dict):
if '$regex' in criteria:
return re.search(criteria['$regex'], target) is not None
elif not isinstance(target, dict):
return False
else:
return (isinstance(target, dict) and
self._block_matches(target, criteria))
else:
return criteria == target
def _get_index_if_valid(self, locator, force=False, continue_version=False):
"""
If the locator identifies a course and points to its draft (or plausibly its draft),
@@ -1458,7 +1465,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
:param continue_version: if True, assumes this operation requires a head version and will not create a new
version but instead continue an existing transaction on this version. This flag cannot be True if force is True.
"""
if locator.package_id is None or locator.branch is None:
if locator.org is None or locator.offering is None or locator.branch is None:
if continue_version:
raise InsufficientSpecificationError(
"To continue a version, the locator must point to one ({}).".format(locator)
@@ -1466,7 +1473,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
else:
return None
else:
index_entry = self.db_connection.get_course_index(locator.package_id)
index_entry = self.db_connection.get_course_index(locator)
is_head = (
locator.version_guid is None or
index_entry['versions'][locator.branch] == locator.version_guid

View File

@@ -2,7 +2,6 @@ import re
import logging
from xmodule.contentstore.content import StaticContent
from xmodule.modulestore import Location
def _prefix_only_url_replace_regex(prefix):
@@ -46,10 +45,6 @@ def rewrite_nonportable_content_links(source_course_id, dest_course_id, text):
"""
course_id_dict = Location.parse_course_id(source_course_id)
course_id_dict['tag'] = 'i4x'
course_id_dict['category'] = 'course'
def portable_asset_link_subtitution(match):
quote = match.group('quote')
rest = match.group('rest')
@@ -60,27 +55,21 @@ def rewrite_nonportable_content_links(source_course_id, dest_course_id, text):
rest = match.group('rest')
return quote + '/jump_to_id/' + rest + quote
def generic_courseware_link_substitution(match):
parts = Location.parse_course_id(dest_course_id)
parts['quote'] = match.group('quote')
parts['rest'] = match.group('rest')
return u'{quote}/courses/{org}/{course}/{name}/{rest}{quote}'.format(**parts)
course_location = Location(course_id_dict)
# NOTE: ultimately link updating is not a hard requirement, so if something blows up with
# the regex subsitution, log the error and continue
# the regex substitution, log the error and continue
c4x_link_base = StaticContent.get_base_url_path_for_course_assets(source_course_id)
try:
c4x_link_base = u'{0}/'.format(StaticContent.get_base_url_path_for_course_assets(course_location))
text = re.sub(_prefix_only_url_replace_regex(c4x_link_base), portable_asset_link_subtitution, text)
except Exception, e:
logging.warning("Error going regex subtituion %r on text = %r.\n\nError msg = %s", c4x_link_base, text, str(e))
except Exception as exc: # pylint: disable=broad-except
logging.warning("Error producing regex substitution %r for text = %r.\n\nError msg = %s", c4x_link_base, text, str(exc))
jump_to_link_base = u'/courses/{course_key_string}/jump_to/i4x://{course_key.org}/{course_key.course}/'.format(
course_key_string=source_course_id.to_deprecated_string(), course_key=source_course_id
)
try:
jump_to_link_base = u'/courses/{org}/{course}/{name}/jump_to/i4x://{org}/{course}/'.format(**course_id_dict)
text = re.sub(_prefix_and_category_url_replace_regex(jump_to_link_base), portable_jump_to_link_substitution, text)
except Exception, e:
logging.warning("Error going regex subtituion %r on text = %r.\n\nError msg = %s", jump_to_link_base, text, str(e))
except Exception as exc: # pylint: disable=broad-except
logging.warning("Error producing regex substitution %r for text = %r.\n\nError msg = %s", jump_to_link_base, text, str(exc))
# Also, there commonly is a set of link URL's used in the format:
# /courses/<org>/<course>/<name> which will be broken if migrated to a different course_id
@@ -90,65 +79,46 @@ def rewrite_nonportable_content_links(source_course_id, dest_course_id, text):
#
if source_course_id != dest_course_id:
try:
generic_courseware_link_base = u'/courses/{org}/{course}/{name}/'.format(**course_id_dict)
generic_courseware_link_base = u'/courses/{}/'.format(source_course_id.to_deprecated_string())
text = re.sub(_prefix_only_url_replace_regex(generic_courseware_link_base), portable_asset_link_subtitution, text)
except Exception, e:
logging.warning("Error going regex subtituion %r on text = %r.\n\nError msg = %s", generic_courseware_link_base, text, str(e))
except Exception as exc: # pylint: disable=broad-except
logging.warning("Error producing regex substitution %r for text = %r.\n\nError msg = %s", source_course_id, text, str(exc))
return text
def _clone_modules(modulestore, modules, source_location, dest_location):
def _clone_modules(modulestore, modules, source_course_id, dest_course_id):
for module in modules:
original_loc = Location(module.location)
if original_loc.category != 'course':
module.location = module.location._replace(
tag=dest_location.tag,
org=dest_location.org,
course=dest_location.course
)
else:
# on the course module we also have to update the module name
module.location = module.location._replace(
tag=dest_location.tag,
org=dest_location.org,
course=dest_location.course,
name=dest_location.name
)
original_loc = module.location
module.location = module.location.map_into_course(dest_course_id)
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_location.course_id, dest_location.course_id, module.data
source_course_id, dest_course_id, module.data
)
# repoint children
if module.has_children:
new_children = []
for child_loc_url in module.children:
child_loc = Location(child_loc_url)
child_loc = child_loc._replace(
tag=dest_location.tag,
org=dest_location.org,
course=dest_location.course
)
new_children.append(child_loc.url())
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, '**replace_user**')
def clone_course(modulestore, contentstore, source_location, dest_location, delete_original=False):
def clone_course(modulestore, contentstore, source_course_id, dest_course_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_item(dest_location.course_id, dest_location):
raise Exception("An empty course at {0} must have already been created. Aborting...".format(dest_location))
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_location.tag, dest_location.org, dest_location.course, None, None, None])
dest_modules = modulestore.get_items(dest_course_id)
basically_empty = True
for module in dest_modules:
@@ -163,107 +133,63 @@ def clone_course(modulestore, contentstore, source_location, dest_location, dele
raise Exception("Course at destination {0} is not an empty course. You can only clone into an empty course. Aborting...".format(dest_location))
# check to see if the source course is actually there
if not modulestore.has_item(source_location.course_id, source_location):
raise Exception("Cannot find a course at {0}. Aborting".format(source_location))
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_location.tag, source_location.org, source_location.course, None, None, None])
_clone_modules(modulestore, modules, source_location, dest_location)
modules = modulestore.get_items(source_course_id, revision=None)
_clone_modules(modulestore, modules, source_course_id, dest_course_id)
modules = modulestore.get_items([source_location.tag, source_location.org, source_location.course, None, None, 'draft'])
_clone_modules(modulestore, modules, source_location, dest_location)
modules = modulestore.get_items(source_course_id, revision='draft')
_clone_modules(modulestore, modules, source_course_id, dest_course_id)
# now iterate through all of the assets and clone them
# first the thumbnails
thumbs = contentstore.get_all_content_thumbnails_for_course(source_location)
for thumb in thumbs:
thumb_loc = Location(thumb["_id"])
content = contentstore.find(thumb_loc)
content.location = content.location._replace(org=dest_location.org,
course=dest_location.course)
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_loc, content.location)
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
assets, __ = contentstore.get_all_content_for_course(source_location)
for asset in assets:
asset_loc = Location(asset["_id"])
content = contentstore.find(asset_loc)
content.location = content.location._replace(org=dest_location.org,
course=dest_location.course)
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._replace(org=dest_location.org,
course=dest_location.course)
content.thumbnail_location = content.thumbnail_location.map_into_course(dest_course_id)
print "Cloning asset {0} to {1}".format(asset_loc, content.location)
print "Cloning asset {0} to {1}".format(asset_key, content.location)
contentstore.save(content)
return True
def _delete_modules_except_course(modulestore, modules, source_location, commit):
"""
This helper method will just enumerate through a list of modules and delete them, except for the
top-level course module
"""
for module in modules:
if module.category != 'course':
logging.warning("Deleting {0}...".format(module.location))
if commit:
# sanity check. Make sure we're not deleting a module in the incorrect course
if module.location.org != source_location.org or module.location.course != source_location.course:
raise Exception('Module {0} is not in same namespace as {1}. This should not happen! Aborting...'.format(module.location, source_location))
modulestore.delete_item(module.location)
def _delete_assets(contentstore, assets, commit):
"""
This helper method will enumerate through a list of assets and delete them
"""
for asset in assets:
asset_loc = Location(asset["_id"])
id = StaticContent.get_id_from_location(asset_loc)
logging.warning("Deleting {0}...".format(id))
if commit:
contentstore.delete(id)
def delete_course(modulestore, contentstore, source_location, commit=False):
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
courseware store. BE VERY CAREFUL, this is not reversable.
"""
# check to see if the source course is actually there
if not modulestore.has_item(source_location.course_id, source_location):
raise Exception("Cannot find a course at {0}. Aborting".format(source_location))
if not modulestore.has_course(course_key):
raise Exception("Cannot find a course at {0}. Aborting".format(course_key))
# first delete all of the thumbnails
thumbs = contentstore.get_all_content_thumbnails_for_course(source_location)
_delete_assets(contentstore, thumbs, commit)
# then delete all of the assets
assets, __ = contentstore.get_all_content_for_course(source_location)
_delete_assets(contentstore, assets, commit)
# then delete all course modules
modules = modulestore.get_items([source_location.tag, source_location.org, source_location.course, None, None, None])
_delete_modules_except_course(modulestore, modules, source_location, commit)
# then delete all draft course modules
modules = modulestore.get_items([source_location.tag, source_location.org, source_location.course, None, None, 'draft'])
_delete_modules_except_course(modulestore, modules, source_location, commit)
# finally delete the top-level course module itself
print "Deleting {0}...".format(source_location)
if commit:
modulestore.delete_item(source_location)
print "Deleting assets and thumbnails {}".format(course_key)
contentstore.delete_all_course_assets(course_key)
# finally delete the course
print "Deleting {0}...".format(course_key)
if commit:
modulestore.delete_course(course_key, '**replace-user**')
return True

View File

@@ -205,7 +205,7 @@ class ModuleStoreTestCase(TestCase):
"""
store = editable_modulestore()
store.update_item(course, '**replace_user**')
updated_course = store.get_instance(course.id, course.location)
updated_course = store.get_course(course.id)
return updated_course
@staticmethod

View File

@@ -2,7 +2,8 @@ from factory import Factory, lazy_attribute_sequence, lazy_attribute
from factory.containers import CyclicDefinitionError
from uuid import uuid4
from xmodule.modulestore import Location, prefer_xmodules
from xmodule.modulestore import prefer_xmodules
from xmodule.modulestore.locations import Location
from xblock.core import XBlock
@@ -36,6 +37,7 @@ class CourseFactory(XModuleFactory):
number = '999'
display_name = 'Robot Super Course'
# pylint: disable=unused-argument
@classmethod
def _create(cls, target_class, **kwargs):
@@ -46,8 +48,10 @@ class CourseFactory(XModuleFactory):
# because the factory provides a default 'number' arg, prefer the non-defaulted 'course' arg if any
number = kwargs.pop('course', kwargs.pop('number', None))
store = kwargs.pop('modulestore')
name = kwargs.get('name', kwargs.get('run', Location.clean(kwargs.get('display_name'))))
run = kwargs.get('run', name)
location = Location('i4x', org, number, 'course', Location.clean(kwargs.get('display_name')))
location = Location(org, number, run, 'course', name)
# Write the data to the mongo datastore
new_course = store.create_xmodule(location, metadata=kwargs.get('metadata', None))
@@ -82,11 +86,15 @@ class ItemFactory(XModuleFactory):
else:
dest_name = self.display_name.replace(" ", "_")
return self.parent_location.replace(category=self.category, name=dest_name)
new_location = self.parent_location.course_key.make_usage_key(
self.category,
dest_name
)
return new_location
@lazy_attribute
def parent_location(self):
default_location = Location('i4x://MITx/999/course/Robot_Super_Course')
default_location = Location('MITx', '999', 'Robot_Super_Course', 'course', 'Robot_Super_Course', None)
try:
parent = self.parent
# This error is raised if the caller hasn't provided either parent or parent_location
@@ -127,12 +135,14 @@ class ItemFactory(XModuleFactory):
# catch any old style users before they get into trouble
assert 'template' not in kwargs
parent_location = Location(kwargs.pop('parent_location', None))
parent_location = kwargs.pop('parent_location', None)
data = kwargs.pop('data', None)
category = kwargs.pop('category', None)
display_name = kwargs.pop('display_name', None)
metadata = kwargs.pop('metadata', {})
location = kwargs.pop('location')
assert isinstance(location, Location)
assert location != parent_location
store = kwargs.pop('modulestore')
@@ -164,7 +174,7 @@ class ItemFactory(XModuleFactory):
store.update_item(module)
if 'detached' not in module._class_tags:
parent.children.append(location.url())
parent.children.append(location)
store.update_item(parent, '**replace_user**')
return store.get_item(location)

View File

@@ -1,8 +1,11 @@
"""
Thorough tests of the Location class
"""
import ddt
from unittest import TestCase
from xmodule.modulestore import Location
from xmodule.modulestore.exceptions import InvalidLocationError
from opaque_keys import InvalidKeyError
from xmodule.modulestore.locations import Location, AssetLocation, SlashSeparatedCourseKey
# Pairs for testing the clean* functions.
# The first item in the tuple is the input string.
@@ -23,117 +26,110 @@ class TestLocations(TestCase):
Tests of :class:`.Location`
"""
@ddt.data(
"tag://org/course/category/name",
"tag://org/course/category/name@revision"
"org+course+run+category+name",
"org+course+run+category+name@revision"
)
def test_string_roundtrip(self, url):
self.assertEquals(url, Location(url).url())
self.assertEquals(url, str(Location(url)))
self.assertEquals(url, Location._from_string(url)._to_string()) # pylint: disable=protected-access
@ddt.data(
{
'tag': 'tag',
"i4x://org/course/category/name",
"i4x://org/course/category/name@revision"
)
def test_deprecated_roundtrip(self, url):
course_id = SlashSeparatedCourseKey('org', 'course', 'run')
self.assertEquals(
url,
course_id.make_usage_key_from_deprecated_string(url).to_deprecated_string()
)
def test_invalid_chars_ssck(self):
"""
Test that the ssck constructor fails if given invalid chars
"""
valid_base = SlashSeparatedCourseKey(u'org.dept-1%2', u'course.sub-2%3', u'run.faster-4%5')
for key in SlashSeparatedCourseKey.KEY_FIELDS:
with self.assertRaises(InvalidKeyError):
# this ends up calling the constructor where the legality check should occur
valid_base.replace(**{key: u'funny thing'})
def test_invalid_chars_location(self):
"""
Test that the location constructor fails if given invalid chars
"""
course_key = SlashSeparatedCourseKey(u'org.dept-1%2', u'course.sub-2%3', u'run.faster-4%5')
valid_base = course_key.make_usage_key('tomato-again%9', 'block-head:sub-4%9')
for key in SlashSeparatedCourseKey.KEY_FIELDS:
with self.assertRaises(InvalidKeyError):
# this ends up calling the constructor where the legality check should occur
valid_base.replace(**{key: u'funny thing'})
@ddt.data(
((), {
'org': 'org',
'course': 'course',
'run': 'run',
'category': 'category',
'name': 'name',
'org': 'org'
},
{
'tag': 'tag',
}, 'org', 'course', 'run', 'category', 'name', None),
((), {
'org': 'org',
'course': 'course',
'run': 'run',
'category': 'category',
'name': 'name:more_name',
'org': 'org'
},
['tag', 'org', 'course', 'category', 'name'],
"tag://org/course/category/name",
"tag://org/course/category/name@revision",
u"tag://org/course/category/name",
u"tag://org/course/category/name@revision",
}, 'org', 'course', 'run', 'category', 'name:more_name', None),
(['org', 'course', 'run', 'category', 'name'], {}, 'org', 'course', 'run', 'category', 'name', None),
)
def test_is_valid(self, loc):
self.assertTrue(Location.is_valid(loc))
@ddt.unpack
def test_valid_locations(self, args, kwargs, org, course, run, category, name, revision):
location = Location(*args, **kwargs)
self.assertEquals(org, location.org)
self.assertEquals(course, location.course)
self.assertEquals(run, location.run)
self.assertEquals(category, location.category)
self.assertEquals(name, location.name)
self.assertEquals(revision, location.revision)
@ddt.data(
{
(("foo",), {}),
(["foo", "bar"], {}),
(["foo", "bar", "baz", "blat/blat", "foo"], {}),
(["foo", "bar", "baz", "blat", "foo/bar"], {}),
(["foo", "bar", "baz", "blat:blat", "foo:bar"], {}), # ':' ok in name, not in category
(('org', 'course', 'run', 'category', 'name with spaces', 'revision'), {}),
(('org', 'course', 'run', 'category', 'name/with/slashes', 'revision'), {}),
(('org', 'course', 'run', 'category', 'name', u'\xae'), {}),
(('org', 'course', 'run', 'category', u'\xae', 'revision'), {}),
((), {
'tag': 'tag',
'course': 'course',
'category': 'category',
'name': 'name@more_name',
'org': 'org'
},
{
}),
((), {
'tag': 'tag',
'course': 'course',
'category': 'category',
'name': 'name ', # extra space
'org': 'org'
},
"foo",
["foo"],
["foo", "bar"],
["foo", "bar", "baz", "blat:blat", "foo:bar"], # ':' ok in name, not in category
"tag://org/course/category/name with spaces@revision",
"tag://org/course/category/name/with/slashes@revision",
u"tag://org/course/category/name\xae", # No non-ascii characters for now
u"tag://org/course/category\xae/name", # No non-ascii characters for now
}),
)
def test_is_invalid(self, loc):
self.assertFalse(Location.is_valid(loc))
def test_dict(self):
input_dict = {
'tag': 'tag',
'course': 'course',
'category': 'category',
'name': 'name',
'org': 'org'
}
self.assertEquals("tag://org/course/category/name", Location(input_dict).url())
self.assertEquals(dict(revision=None, **input_dict), Location(input_dict).dict())
input_dict['revision'] = 'revision'
self.assertEquals("tag://org/course/category/name@revision", Location(input_dict).url())
self.assertEquals(input_dict, Location(input_dict).dict())
def test_list(self):
input_list = ['tag', 'org', 'course', 'category', 'name']
self.assertEquals("tag://org/course/category/name", Location(input_list).url())
self.assertEquals(input_list + [None], Location(input_list).list())
input_list.append('revision')
self.assertEquals("tag://org/course/category/name@revision", Location(input_list).url())
self.assertEquals(input_list, Location(input_list).list())
def test_location(self):
input_list = ['tag', 'org', 'course', 'category', 'name']
self.assertEquals("tag://org/course/category/name", Location(Location(input_list)).url())
def test_none(self):
self.assertEquals([None] * 6, Location(None).list())
@ddt.data(
"foo",
["foo", "bar"],
["foo", "bar", "baz", "blat/blat", "foo"],
["foo", "bar", "baz", "blat", "foo/bar"],
"tag://org/course/category/name with spaces@revision",
"tag://org/course/category/name/revision",
)
def test_invalid_locations(self, loc):
with self.assertRaises(InvalidLocationError):
Location(loc)
@ddt.unpack
def test_invalid_locations(self, *args, **kwargs):
with self.assertRaises(TypeError):
Location(*args, **kwargs)
def test_equality(self):
self.assertEquals(
Location('tag', 'org', 'course', 'category', 'name'),
Location('tag', 'org', 'course', 'category', 'name')
Location('tag', 'org', 'course', 'run', 'category', 'name'),
Location('tag', 'org', 'course', 'run', 'category', 'name')
)
self.assertNotEquals(
Location('tag', 'org', 'course', 'category', 'name1'),
Location('tag', 'org', 'course', 'category', 'name')
Location('tag', 'org', 'course', 'run', 'category', 'name1'),
Location('tag', 'org', 'course', 'run', 'category', 'name')
)
@ddt.data(
@@ -164,42 +160,38 @@ class TestLocations(TestCase):
self.assertEquals(Location.clean_for_html(pair[0]), pair[1])
def test_html_id(self):
loc = Location("tag://org/course/cat/name:more_name@rev")
self.assertEquals(loc.html_id(), "tag-org-course-cat-name_more_name-rev")
def test_course_id(self):
loc = Location('i4x', 'mitX', '103', 'course', 'test2')
self.assertEquals('mitX/103/test2', loc.course_id)
loc = Location('i4x', 'mitX', '103', '_not_a_course', 'test2')
with self.assertRaises(InvalidLocationError):
loc.course_id # pylint: disable=pointless-statement
loc = Location('org', 'course', 'run', 'cat', 'name:more_name', 'rev')
self.assertEquals(loc.html_id(), "i4x-org-course-cat-name_more_name-rev")
def test_replacement(self):
# pylint: disable=protected-access
self.assertEquals(
Location('t://o/c/c/n@r')._replace(name='new_name'),
Location('t://o/c/c/new_name@r'),
Location('o', 'c', 'r', 'c', 'n', 'r').replace(name='new_name'),
Location('o', 'c', 'r', 'c', 'new_name', 'r'),
)
with self.assertRaises(InvalidLocationError):
Location('t://o/c/c/n@r')._replace(name=u'name\xae')
with self.assertRaises(InvalidKeyError):
Location('o', 'c', 'r', 'c', 'n', 'r').replace(name=u'name\xae')
@ddt.data('org', 'course', 'category', 'name', 'revision')
def test_immutable(self, attr):
loc = Location('t://o/c/c/n@r')
loc = Location('o', 'c', 'r', 'c', 'n', 'r')
with self.assertRaises(AttributeError):
setattr(loc, attr, attr)
def test_parse_course_id(self):
"""
Test the parse_course_id class method
"""
source_string = "myorg/mycourse/myrun"
parsed = Location.parse_course_id(source_string)
self.assertEqual(parsed['org'], 'myorg')
self.assertEqual(parsed['course'], 'mycourse')
self.assertEqual(parsed['name'], 'myrun')
with self.assertRaises(ValueError):
Location.parse_course_id('notlegit.id/foo')
def test_map_into_course_location(self):
loc = Location('org', 'course', 'run', 'cat', 'name:more_name', 'rev')
course_key = SlashSeparatedCourseKey("edX", "toy", "2012_Fall")
self.assertEquals(
Location("edX", "toy", "2012_Fall", 'cat', 'name:more_name', 'rev'),
loc.map_into_course(course_key)
)
def test_map_into_course_asset_location(self):
loc = AssetLocation('org', 'course', 'run', 'asset', 'foo.bar')
course_key = SlashSeparatedCourseKey("edX", "toy", "2012_Fall")
self.assertEquals(
AssetLocation("edX", "toy", "2012_Fall", 'asset', 'foo.bar'),
loc.map_into_course(course_key)
)

View File

@@ -1,15 +1,15 @@
'''
Created on Aug 5, 2013
@author: dmitchell
'''
"""
Test the loc mapper store
"""
import unittest
import uuid
from xmodule.modulestore import Location
from xmodule.modulestore.locator import BlockUsageLocator
from xmodule.modulestore.locator import BlockUsageLocator, CourseLocator
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
from xmodule.modulestore.loc_mapper_store import LocMapperStore
from mock import Mock
from xmodule.modulestore.locations import SlashSeparatedCourseKey
import bson.son
class LocMapperSetupSansDjango(unittest.TestCase):
@@ -41,41 +41,47 @@ class TestLocationMapper(LocMapperSetupSansDjango):
Test the location to locator mapper
"""
def test_create_map(self):
def _construct_course_son(org, course, run):
"""
Make a lookup son
"""
return bson.son.SON([
('org', org),
('course', course),
('name', run)
])
org = 'foo_org'
course = 'bar_course'
loc_mapper().create_map_entry(Location('i4x', org, course, 'course', 'baz_run'))
course1 = 'bar_course'
run = 'baz_run'
loc_mapper().create_map_entry(SlashSeparatedCourseKey(org, course1, run))
# pylint: disable=protected-access
entry = loc_mapper().location_map.find_one({
'_id': loc_mapper()._construct_location_son(org, course, 'baz_run')
'_id': _construct_course_son(org, course1, run)
})
self.assertIsNotNone(entry, "Didn't find entry")
self.assertEqual(entry['course_id'], '{}.{}.baz_run'.format(org, course))
self.assertEqual(entry['org'], org)
self.assertEqual(entry['offering'], '{}.{}'.format(course1, run))
self.assertEqual(entry['draft_branch'], 'draft')
self.assertEqual(entry['prod_branch'], 'published')
self.assertEqual(entry['block_map'], {})
# ensure create_entry does the right thing when not given a course (creates org/course
# rather than org/course/run course_id)
loc_mapper().create_map_entry(Location('i4x', org, course, 'vertical', 'baz_vert'))
# find the one which has no name
entry = loc_mapper().location_map.find_one({
'_id' : loc_mapper()._construct_location_son(org, course, None)
})
self.assertIsNotNone(entry, "Didn't find entry")
self.assertEqual(entry['course_id'], '{}.{}'.format(org, course))
course = 'quux_course'
course2 = 'quux_course'
# oldname: {category: newname}
block_map = {'abc123': {'problem': 'problem2'}}
loc_mapper().create_map_entry(
Location('i4x', org, course, 'problem', 'abc123', 'draft'),
'foo_org.geek_dept.quux_course.baz_run',
SlashSeparatedCourseKey(org, course2, run),
'foo_org.geek_dept',
'quux_course.baz_run',
'wip',
'live',
block_map)
entry = loc_mapper().location_map.find_one({'_id.org': org, '_id.course': course})
entry = loc_mapper().location_map.find_one({
'_id': _construct_course_son(org, course2, run)
})
self.assertIsNotNone(entry, "Didn't find entry")
self.assertEqual(entry['course_id'], 'foo_org.geek_dept.quux_course.baz_run')
self.assertEqual(entry['org'], 'foo_org.geek_dept')
self.assertEqual(entry['offering'], '{}.{}'.format(course2, run))
self.assertEqual(entry['draft_branch'], 'wip')
self.assertEqual(entry['prod_branch'], 'live')
self.assertEqual(entry['block_map'], block_map)
@@ -87,51 +93,50 @@ class TestLocationMapper(LocMapperSetupSansDjango):
org = u'foo_org'
course = u'bar_course'
run = u'baz_run'
course_location = Location('i4x', org, course, 'course', run)
course_locator = loc_mapper().translate_location(course_location.course_id, course_location)
course_location = SlashSeparatedCourseKey(org, course, run)
loc_mapper().create_map_entry(course_location)
# pylint: disable=protected-access
entry = loc_mapper().location_map.find_one({
'_id': loc_mapper()._construct_location_son(org, course, run)
'_id': loc_mapper()._construct_course_son(course_location)
})
self.assertIsNotNone(entry, 'Entry not found in loc_mapper')
self.assertEqual(entry['course_id'], u'{0}.{1}.{2}'.format(org, course, run))
self.assertEqual(entry['offering'], u'{1}.{2}'.format(org, course, run))
# now delete course location from loc_mapper and cache and test that course location no longer
# exists in loca_mapper and cache
loc_mapper().delete_course_mapping(course_location)
# pylint: disable=protected-access
entry = loc_mapper().location_map.find_one({
'_id': loc_mapper()._construct_location_son(org, course, run)
'_id': loc_mapper()._construct_course_son(course_location)
})
self.assertIsNone(entry, 'Entry found in loc_mapper')
# pylint: disable=protected-access
cached_value = loc_mapper()._get_location_from_cache(course_locator)
cached_value = loc_mapper()._get_location_from_cache(course_location.make_usage_key('course', run))
self.assertIsNone(cached_value, 'course_locator found in cache')
# pylint: disable=protected-access
cached_value = loc_mapper()._get_course_location_from_cache(course_locator.package_id)
cached_value = loc_mapper()._get_course_location_from_cache(course_location)
self.assertIsNone(cached_value, 'Entry found in cache')
def translate_n_check(self, location, old_style_course_id, new_style_package_id, block_id, branch, add_entry=False):
def translate_n_check(self, location, org, offering, block_id, branch, add_entry=False):
"""
Request translation, check package_id, block_id, and branch
Request translation, check org, offering, block_id, and branch
"""
prob_locator = loc_mapper().translate_location(
old_style_course_id,
location,
published= (branch=='published'),
published=(branch == 'published'),
add_entry_if_missing=add_entry
)
self.assertEqual(prob_locator.package_id, new_style_package_id)
self.assertEqual(prob_locator.org, org)
self.assertEqual(prob_locator.offering, offering)
self.assertEqual(prob_locator.block_id, block_id)
self.assertEqual(prob_locator.branch, branch)
course_locator = loc_mapper().translate_location_to_course_locator(
old_style_course_id,
location,
published=(branch == 'published'),
location.course_key,
published=(branch == 'published'),
)
self.assertEqual(course_locator.package_id, new_style_package_id)
self.assertEqual(course_locator.org, org)
self.assertEqual(course_locator.offering, offering)
self.assertEqual(course_locator.branch, branch)
def test_translate_location_read_only(self):
@@ -141,46 +146,45 @@ class TestLocationMapper(LocMapperSetupSansDjango):
# lookup before there are any maps
org = 'foo_org'
course = 'bar_course'
old_style_course_id = '{}/{}/{}'.format(org, course, 'baz_run')
run = 'baz_run'
slash_course_key = SlashSeparatedCourseKey(org, course, run)
with self.assertRaises(ItemNotFoundError):
_ = loc_mapper().translate_location(
old_style_course_id,
Location('i4x', org, course, 'problem', 'abc123'),
Location(org, course, run, 'problem', 'abc123'),
add_entry_if_missing=False
)
new_style_package_id = '{}.geek_dept.{}.baz_run'.format(org, course)
new_style_org = '{}.geek_dept'.format(org)
new_style_offering = '.{}.{}'.format(course, run)
block_map = {
'abc123': {'problem': 'problem2', 'vertical': 'vertical2'},
'def456': {'problem': 'problem4'},
'ghi789': {'problem': 'problem7'},
}
loc_mapper().create_map_entry(
Location('i4x', org, course, 'course', 'baz_run'),
new_style_package_id,
slash_course_key,
new_style_org, new_style_offering,
block_map=block_map
)
test_problem_locn = Location('i4x', org, course, 'problem', 'abc123')
# only one course matches
test_problem_locn = Location(org, course, run, 'problem', 'abc123')
# look for w/ only the Location (works b/c there's only one possible course match). Will force
# cache as default translation for this problemid
self.translate_n_check(test_problem_locn, None, new_style_package_id, 'problem2', 'published')
self.translate_n_check(test_problem_locn, new_style_org, new_style_offering, 'problem2', 'published')
# look for non-existent problem
with self.assertRaises(ItemNotFoundError):
loc_mapper().translate_location(
None,
Location('i4x', org, course, 'problem', '1def23'),
Location(org, course, run, 'problem', '1def23'),
add_entry_if_missing=False
)
test_no_cat_locn = test_problem_locn.replace(category=None)
with self.assertRaises(InvalidLocationError):
loc_mapper().translate_location(
old_style_course_id, test_no_cat_locn, False, False
slash_course_key.make_usage_key(None, 'abc123'), test_no_cat_locn, False, False
)
test_no_cat_locn = test_no_cat_locn.replace(name='def456')
# only one course matches
self.translate_n_check(test_no_cat_locn, old_style_course_id, new_style_package_id, 'problem4', 'published')
self.translate_n_check(
test_no_cat_locn, new_style_org, new_style_offering, 'problem4', 'published'
)
# add a distractor course (note that abc123 has a different translation in this one)
distractor_block_map = {
@@ -188,37 +192,23 @@ class TestLocationMapper(LocMapperSetupSansDjango):
'def456': {'problem': 'problem4'},
'ghi789': {'problem': 'problem7'},
}
test_delta_new_id = '{}.geek_dept.{}.{}'.format(org, course, 'delta_run')
test_delta_old_id = '{}/{}/{}'.format(org, course, 'delta_run')
run = 'delta_run'
test_delta_new_org = '{}.geek_dept'.format(org)
test_delta_new_offering = '{}.{}'.format(course, run)
loc_mapper().create_map_entry(
Location('i4x', org, course, 'course', 'delta_run'),
test_delta_new_id,
SlashSeparatedCourseKey(org, course, run),
test_delta_new_org, test_delta_new_offering,
block_map=distractor_block_map
)
# test that old translation still works
self.translate_n_check(test_problem_locn, old_style_course_id, new_style_package_id, 'problem2', 'published')
self.translate_n_check(
test_problem_locn, new_style_org, new_style_offering, 'problem2', 'published'
)
# and new returns new id
self.translate_n_check(test_problem_locn, test_delta_old_id, test_delta_new_id, 'problem3', 'published')
# look for default translation of uncached Location (not unique; so, just verify it returns something)
prob_locator = loc_mapper().translate_location(
None,
Location('i4x', org, course, 'problem', 'def456'),
add_entry_if_missing=False
self.translate_n_check(
test_problem_locn.replace(run=run), test_delta_new_org, test_delta_new_offering,
'problem3', 'published'
)
self.assertIsNotNone(prob_locator, "couldn't find ambiguous location")
# make delta_run default course: anything not cached using None as old_course_id will use this
loc_mapper().create_map_entry(
Location('i4x', org, course, 'problem', '789abc123efg456'),
test_delta_new_id,
block_map=block_map
)
# now an uncached ambiguous query should return delta
test_unused_locn = Location('i4x', org, course, 'problem', 'ghi789')
self.translate_n_check(test_unused_locn, None, test_delta_new_id, 'problem7', 'published')
# get the draft one (I'm sorry this is getting long)
self.translate_n_check(test_unused_locn, None, test_delta_new_id, 'problem7', 'draft')
def test_translate_location_dwim(self):
"""
@@ -227,27 +217,27 @@ class TestLocationMapper(LocMapperSetupSansDjango):
"""
org = 'foo_org'
course = 'bar_course'
old_style_course_id = '{}/{}/{}'.format(org, course, 'baz_run')
run = 'baz_run'
problem_name = 'abc123abc123abc123abc123abc123f9'
location = Location('i4x', org, course, 'problem', problem_name)
new_style_package_id = '{}.{}.{}'.format(org, course, 'baz_run')
self.translate_n_check(location, old_style_course_id, new_style_package_id, 'problemabc', 'published', True)
# look for w/ only the Location (works b/c there's only one possible course match): causes cache
self.translate_n_check(location, None, new_style_package_id, 'problemabc', 'published', True)
location = Location(org, course, run, 'problem', problem_name)
new_offering = '{}.{}'.format(course, run)
self.translate_n_check(location, org, new_offering, 'problemabc', 'published', True)
# create an entry w/o a guid name
other_location = Location('i4x', org, course, 'chapter', 'intro')
self.translate_n_check(other_location, old_style_course_id, new_style_package_id, 'intro', 'published', True)
other_location = Location(org, course, run, 'chapter', 'intro')
self.translate_n_check(other_location, org, new_offering, 'intro', 'published', True)
# add a distractor course
delta_new_package_id = '{}.geek_dept.{}.{}'.format(org, course, 'delta_run')
delta_course_locn = Location('i4x', org, course, 'course', 'delta_run')
delta_new_org = '{}.geek_dept'.format(org)
run = 'delta_run'
delta_new_offering = '{}.{}'.format(course, run)
delta_course_locn = SlashSeparatedCourseKey(org, course, run)
loc_mapper().create_map_entry(
delta_course_locn,
delta_new_package_id,
delta_new_org, delta_new_offering,
block_map={problem_name: {'problem': 'problem3'}}
)
self.translate_n_check(location, old_style_course_id, new_style_package_id, 'problemabc', 'published', True)
self.translate_n_check(location, org, new_offering, 'problemabc', 'published', True)
# add a new one to both courses (ensure name doesn't have same beginning)
new_prob_name = uuid.uuid4().hex
@@ -255,35 +245,11 @@ class TestLocationMapper(LocMapperSetupSansDjango):
new_prob_name = uuid.uuid4().hex
new_prob_locn = location.replace(name=new_prob_name)
new_usage_id = 'problem{}'.format(new_prob_name[:3])
self.translate_n_check(new_prob_locn, old_style_course_id, new_style_package_id, new_usage_id, 'published', True)
self.translate_n_check(new_prob_locn, org, new_offering, new_usage_id, 'published', True)
new_prob_locn = new_prob_locn.replace(run=run)
self.translate_n_check(
new_prob_locn, delta_course_locn.course_id, delta_new_package_id, new_usage_id, 'published', True
new_prob_locn, delta_new_org, delta_new_offering, new_usage_id, 'published', True
)
# look for w/ only the Location: causes caching and not unique; so, can't check which course
prob_locator = loc_mapper().translate_location(
None,
new_prob_locn,
add_entry_if_missing=True
)
self.assertIsNotNone(prob_locator, "couldn't find ambiguous location")
# add a default course pointing to the delta_run
loc_mapper().create_map_entry(
Location('i4x', org, course, 'problem', '789abc123efg456'),
delta_new_package_id,
block_map={problem_name: {'problem': 'problem3'}}
)
# now the ambiguous query should return delta
again_prob_name = uuid.uuid4().hex
while again_prob_name.startswith('abc') or again_prob_name.startswith(new_prob_name[:3]):
again_prob_name = uuid.uuid4().hex
again_prob_locn = location.replace(name=again_prob_name)
again_usage_id = 'problem{}'.format(again_prob_name[:3])
self.translate_n_check(again_prob_locn, old_style_course_id, new_style_package_id, again_usage_id, 'published', True)
self.translate_n_check(
again_prob_locn, delta_course_locn.course_id, delta_new_package_id, again_usage_id, 'published', True
)
self.translate_n_check(again_prob_locn, None, delta_new_package_id, again_usage_id, 'published', True)
def test_translate_locator(self):
"""
@@ -292,18 +258,23 @@ class TestLocationMapper(LocMapperSetupSansDjango):
# lookup for non-existent course
org = 'foo_org'
course = 'bar_course'
new_style_package_id = '{}.geek_dept.{}.baz_run'.format(org, course)
run = 'baz_run'
new_style_org = '{}.geek_dept'.format(org)
new_style_offering = '{}.{}'.format(course, run)
prob_course_key = CourseLocator(
org=new_style_org, offering=new_style_offering,
branch='published',
)
prob_locator = BlockUsageLocator(
package_id=new_style_package_id,
prob_course_key,
block_id='problem2',
branch='published'
)
prob_location = loc_mapper().translate_locator_to_location(prob_locator)
self.assertIsNone(prob_location, 'found entry in empty map table')
loc_mapper().create_map_entry(
Location('i4x', org, course, 'course', 'baz_run'),
new_style_package_id,
SlashSeparatedCourseKey(org, course, run),
new_style_org, new_style_offering,
block_map={
'abc123': {'problem': 'problem2'},
'48f23a10395384929234': {'chapter': 'chapter48f'},
@@ -313,74 +284,56 @@ class TestLocationMapper(LocMapperSetupSansDjango):
# only one course matches
prob_location = loc_mapper().translate_locator_to_location(prob_locator)
# default branch
self.assertEqual(prob_location, Location('i4x', org, course, 'problem', 'abc123', None))
self.assertEqual(prob_location, Location(org, course, run, 'problem', 'abc123', None))
# test get_course keyword
prob_location = loc_mapper().translate_locator_to_location(prob_locator, get_course=True)
self.assertEqual(prob_location, Location('i4x', org, course, 'course', 'baz_run', None))
self.assertEqual(prob_location, SlashSeparatedCourseKey(org, course, run))
# explicit branch
prob_locator = BlockUsageLocator(
package_id=prob_locator.package_id, branch='draft', block_id=prob_locator.block_id
prob_course_key.for_branch('draft'), block_id=prob_locator.block_id
)
prob_location = loc_mapper().translate_locator_to_location(prob_locator)
# Even though the problem was set as draft, we always return revision=None to work
# with old mongo/draft modulestores.
self.assertEqual(prob_location, Location('i4x', org, course, 'problem', 'abc123', None))
prob_locator = BlockUsageLocator(
package_id=new_style_package_id, block_id='problem2', branch='production'
)
self.assertEqual(prob_location, Location(org, course, run, 'problem', 'abc123', None))
prob_locator = BlockUsageLocator(prob_course_key.for_branch('production'), block_id='problem2')
prob_location = loc_mapper().translate_locator_to_location(prob_locator)
self.assertEqual(prob_location, Location('i4x', org, course, 'problem', 'abc123', None))
self.assertEqual(prob_location, Location(org, course, run, 'problem', 'abc123', None))
# same for chapter except chapter cannot be draft in old system
chap_locator = BlockUsageLocator(
package_id=new_style_package_id,
prob_course_key.for_branch('production'),
block_id='chapter48f',
branch='production'
)
chap_location = loc_mapper().translate_locator_to_location(chap_locator)
self.assertEqual(chap_location, Location('i4x', org, course, 'chapter', '48f23a10395384929234'))
self.assertEqual(chap_location, Location(org, course, run, 'chapter', '48f23a10395384929234'))
# explicit branch
chap_locator.branch = 'draft'
chap_locator = chap_locator.for_branch('draft')
chap_location = loc_mapper().translate_locator_to_location(chap_locator)
self.assertEqual(chap_location, Location('i4x', org, course, 'chapter', '48f23a10395384929234'))
self.assertEqual(chap_location, Location(org, course, run, 'chapter', '48f23a10395384929234'))
chap_locator = BlockUsageLocator(
package_id=new_style_package_id, block_id='chapter48f', branch='production'
prob_course_key.for_branch('production'), block_id='chapter48f'
)
chap_location = loc_mapper().translate_locator_to_location(chap_locator)
self.assertEqual(chap_location, Location('i4x', org, course, 'chapter', '48f23a10395384929234'))
self.assertEqual(chap_location, Location(org, course, run, 'chapter', '48f23a10395384929234'))
# look for non-existent problem
prob_locator2 = BlockUsageLocator(
package_id=new_style_package_id,
branch='draft',
prob_course_key.for_branch('draft'),
block_id='problem3'
)
prob_location = loc_mapper().translate_locator_to_location(prob_locator2)
self.assertIsNone(prob_location, 'Found non-existent problem')
# add a distractor course
new_style_package_id = '{}.geek_dept.{}.{}'.format(org, course, 'delta_run')
delta_run = 'delta_run'
new_style_offering = '{}.{}'.format(course, delta_run)
loc_mapper().create_map_entry(
Location('i4x', org, course, 'course', 'delta_run'),
new_style_package_id,
SlashSeparatedCourseKey(org, course, delta_run),
new_style_org, new_style_offering,
block_map={'abc123': {'problem': 'problem3'}}
)
prob_location = loc_mapper().translate_locator_to_location(prob_locator)
self.assertEqual(prob_location, Location('i4x', org, course, 'problem', 'abc123', None))
# add a default course pointing to the delta_run
loc_mapper().create_map_entry(
Location('i4x', org, course, 'problem', '789abc123efg456'),
new_style_package_id,
block_map={'abc123': {'problem': 'problem3'}}
)
# now query delta (2 entries point to it)
prob_locator = BlockUsageLocator(
package_id=new_style_package_id,
branch='production',
block_id='problem3'
)
prob_location = loc_mapper().translate_locator_to_location(prob_locator)
self.assertEqual(prob_location, Location('i4x', org, course, 'problem', 'abc123'))
self.assertEqual(prob_location, Location(org, course, run, 'problem', 'abc123', None))
def test_special_chars(self):
"""
@@ -390,10 +343,8 @@ class TestLocationMapper(LocMapperSetupSansDjango):
org = 'foo.org.edu'
course = 'bar.course-4'
name = 'baz.run_4-3'
old_style_course_id = '{}/{}/{}'.format(org, course, name)
location = Location('i4x', org, course, 'course', name)
location = Location(org, course, name, 'course', name)
prob_locator = loc_mapper().translate_location(
old_style_course_id,
location,
add_entry_if_missing=True
)
@@ -407,17 +358,17 @@ class TestLocationMapper(LocMapperSetupSansDjango):
org = "myorg"
course = "another_course"
name = "running_again"
course_location = Location('i4x', org, course, 'course', name)
course_xlate = loc_mapper().translate_location(None, course_location, add_entry_if_missing=True)
course_location = Location(org, course, name, 'course', name)
course_xlate = loc_mapper().translate_location(course_location, add_entry_if_missing=True)
self.assertEqual(course_location, loc_mapper().translate_locator_to_location(course_xlate))
eponymous_block = course_location.replace(category='chapter')
chapter_xlate = loc_mapper().translate_location(None, eponymous_block, add_entry_if_missing=True)
chapter_xlate = loc_mapper().translate_location(eponymous_block, add_entry_if_missing=True)
self.assertEqual(course_location, loc_mapper().translate_locator_to_location(course_xlate))
self.assertEqual(eponymous_block, loc_mapper().translate_locator_to_location(chapter_xlate))
# and a non-existent one w/o add
eponymous_block = course_location.replace(category='problem')
with self.assertRaises(ItemNotFoundError):
chapter_xlate = loc_mapper().translate_location(None, eponymous_block, add_entry_if_missing=False)
chapter_xlate = loc_mapper().translate_location(eponymous_block, add_entry_if_missing=False)
#==================================

View File

@@ -3,12 +3,12 @@ Tests for xmodule.modulestore.locator.
"""
from unittest import TestCase
import random
from bson.objectid import ObjectId
from opaque_keys import InvalidKeyError
from xmodule.modulestore.locator import Locator, CourseLocator, BlockUsageLocator, DefinitionLocator
from xmodule.modulestore.parsers import BRANCH_PREFIX, BLOCK_PREFIX, VERSION_PREFIX
from xmodule.modulestore.exceptions import InsufficientSpecificationError, OverSpecificationError
from xmodule.modulestore import Location
import random
from ddt import ddt
class LocatorTest(TestCase):
@@ -19,232 +19,190 @@ class LocatorTest(TestCase):
def test_cant_instantiate_abstract_class(self):
self.assertRaises(TypeError, Locator)
def test_course_constructor_overspecified(self):
self.assertRaises(
OverSpecificationError,
CourseLocator,
url='edx://mit.eecs.6002x',
package_id='harvard.history',
branch='published',
version_guid=ObjectId())
self.assertRaises(
OverSpecificationError,
CourseLocator,
url='edx://mit.eecs.6002x',
package_id='harvard.history',
version_guid=ObjectId())
self.assertRaises(
OverSpecificationError,
CourseLocator,
url='edx://mit.eecs.6002x/' + BRANCH_PREFIX + 'published',
branch='draft')
self.assertRaises(
OverSpecificationError,
CourseLocator,
package_id='mit.eecs.6002x/' + BRANCH_PREFIX + 'published',
branch='draft')
def test_course_constructor_underspecified(self):
self.assertRaises(InsufficientSpecificationError, CourseLocator)
self.assertRaises(InsufficientSpecificationError, CourseLocator, branch='published')
with self.assertRaises(InvalidKeyError):
CourseLocator()
with self.assertRaises(InvalidKeyError):
CourseLocator(branch='published')
def test_course_constructor_bad_version_guid(self):
self.assertRaises(ValueError, CourseLocator, version_guid="012345")
self.assertRaises(InsufficientSpecificationError, CourseLocator, version_guid=None)
with self.assertRaises(ValueError):
CourseLocator(version_guid="012345")
with self.assertRaises(InvalidKeyError):
CourseLocator(version_guid=None)
def test_course_constructor_version_guid(self):
# generate a random location
test_id_1 = ObjectId()
test_id_1_loc = str(test_id_1)
testobj_1 = CourseLocator(version_guid=test_id_1)
self.check_course_locn_fields(testobj_1, 'version_guid', version_guid=test_id_1)
self.check_course_locn_fields(testobj_1, version_guid=test_id_1)
self.assertEqual(str(testobj_1.version_guid), test_id_1_loc)
self.assertEqual(str(testobj_1), VERSION_PREFIX + test_id_1_loc)
self.assertEqual(testobj_1.url(), 'edx://' + VERSION_PREFIX + test_id_1_loc)
self.assertEqual(testobj_1._to_string(), VERSION_PREFIX + test_id_1_loc)
# Test using a given string
test_id_2_loc = '519665f6223ebd6980884f2b'
test_id_2 = ObjectId(test_id_2_loc)
testobj_2 = CourseLocator(version_guid=test_id_2)
self.check_course_locn_fields(testobj_2, 'version_guid', version_guid=test_id_2)
self.check_course_locn_fields(testobj_2, version_guid=test_id_2)
self.assertEqual(str(testobj_2.version_guid), test_id_2_loc)
self.assertEqual(str(testobj_2), VERSION_PREFIX + test_id_2_loc)
self.assertEqual(testobj_2.url(), 'edx://' + VERSION_PREFIX + test_id_2_loc)
self.assertEqual(testobj_2._to_string(), VERSION_PREFIX + test_id_2_loc)
def test_course_constructor_bad_package_id(self):
@ddt.data(
' mit.eecs',
'mit.eecs ',
VERSION_PREFIX + 'mit.eecs',
BLOCK_PREFIX + 'black/mit.eecs',
'mit.ee cs',
'mit.ee,cs',
'mit.ee/cs',
'mit.ee&cs',
'mit.ee()cs',
BRANCH_PREFIX + 'this',
'mit.eecs/' + BRANCH_PREFIX,
'mit.eecs/' + BRANCH_PREFIX + 'this/' + BRANCH_PREFIX + 'that',
'mit.eecs/' + BRANCH_PREFIX + 'this/' + BRANCH_PREFIX,
'mit.eecs/' + BRANCH_PREFIX + 'this ',
'mit.eecs/' + BRANCH_PREFIX + 'th%is ',
)
def test_course_constructor_bad_package_id(self, bad_id):
"""
Test all sorts of badly-formed package_ids (and urls with those package_ids)
"""
for bad_id in (' mit.eecs',
'mit.eecs ',
VERSION_PREFIX + 'mit.eecs',
BLOCK_PREFIX + 'black/mit.eecs',
'mit.ee cs',
'mit.ee,cs',
'mit.ee/cs',
'mit.ee&cs',
'mit.ee()cs',
BRANCH_PREFIX + 'this',
'mit.eecs/' + BRANCH_PREFIX,
'mit.eecs/' + BRANCH_PREFIX + 'this/' + BRANCH_PREFIX + 'that',
'mit.eecs/' + BRANCH_PREFIX + 'this/' + BRANCH_PREFIX,
'mit.eecs/' + BRANCH_PREFIX + 'this ',
'mit.eecs/' + BRANCH_PREFIX + 'th%is ',
):
self.assertRaises(ValueError, CourseLocator, package_id=bad_id)
self.assertRaises(ValueError, CourseLocator, url='edx://' + bad_id)
with self.assertRaises(InvalidKeyError):
CourseLocator(org=bad_id, offering='test')
def test_course_constructor_bad_url(self):
for bad_url in ('edx://',
'edx:/mit.eecs',
'http://mit.eecs',
'edx//mit.eecs'):
self.assertRaises(ValueError, CourseLocator, url=bad_url)
with self.assertRaises(InvalidKeyError):
CourseLocator(org='test', offering=bad_id)
def test_course_constructor_redundant_001(self):
testurn = 'mit.eecs.6002x'
testobj = CourseLocator(package_id=testurn, url='edx://' + testurn)
self.check_course_locn_fields(testobj, 'package_id', package_id=testurn)
with self.assertRaises(InvalidKeyError):
CourseLocator.from_string('course-locator:' + bad_id)
def test_course_constructor_redundant_002(self):
testurn = 'mit.eecs.6002x/' + BRANCH_PREFIX + 'published'
expected_urn = 'mit.eecs.6002x'
expected_rev = 'published'
testobj = CourseLocator(package_id=testurn, url='edx://' + testurn)
self.check_course_locn_fields(testobj, 'package_id',
package_id=expected_urn,
branch=expected_rev)
@ddt.data('course-locator:', 'course-locator:/mit.eecs', 'http:mit.eecs', 'course-locator//mit.eecs')
def test_course_constructor_bad_url(self, bad_url):
with self.assertRaises(InvalidKeyError):
CourseLocator.from_string(bad_url)
def test_course_constructor_url(self):
# Test parsing a url when it starts with a version ID and there is also a block ID.
# This hits the parsers parse_guid method.
test_id_loc = '519665f6223ebd6980884f2b'
testobj = CourseLocator(url="edx://{}{}/{}hw3".format(VERSION_PREFIX, test_id_loc, BLOCK_PREFIX))
testobj = CourseLocator.from_string("course-locator:{}{}/{}hw3".format(VERSION_PREFIX, test_id_loc, BLOCK_PREFIX))
self.check_course_locn_fields(
testobj,
'test_block constructor',
version_guid=ObjectId(test_id_loc)
)
def test_course_constructor_url_package_id_and_version_guid(self):
test_id_loc = '519665f6223ebd6980884f2b'
testobj = CourseLocator(url='edx://mit.eecs-honors.6002x/' + VERSION_PREFIX + test_id_loc)
self.check_course_locn_fields(testobj, 'error parsing url with both course ID and version GUID',
package_id='mit.eecs-honors.6002x',
version_guid=ObjectId(test_id_loc))
testobj = CourseLocator.from_string('course-locator:mit.eecs+honors.6002x/' + VERSION_PREFIX + test_id_loc)
self.check_course_locn_fields(
testobj,
org='mit.eecs',
offering='honors.6002x',
version_guid=ObjectId(test_id_loc)
)
def test_course_constructor_url_package_id_branch_and_version_guid(self):
test_id_loc = '519665f6223ebd6980884f2b'
testobj = CourseLocator(url='edx://mit.eecs.~6002x/' + BRANCH_PREFIX + 'draft-1/' + VERSION_PREFIX + test_id_loc)
self.check_course_locn_fields(testobj, 'error parsing url with both course ID branch, and version GUID',
package_id='mit.eecs.~6002x',
branch='draft-1',
version_guid=ObjectId(test_id_loc))
org = 'mit.eecs'
offering = '~6002x'
testobj = CourseLocator.from_string('course-locator:{}+{}/{}draft-1/{}{}'.format(
org, offering, BRANCH_PREFIX, VERSION_PREFIX, test_id_loc
))
self.check_course_locn_fields(
testobj,
org=org,
offering=offering,
branch='draft-1',
version_guid=ObjectId(test_id_loc)
)
def test_course_constructor_package_id_no_branch(self):
testurn = 'mit.eecs.6002x'
testobj = CourseLocator(package_id=testurn)
self.check_course_locn_fields(testobj, 'package_id', package_id=testurn)
org = 'mit.eecs'
offering = '6002x'
testurn = '{}+{}'.format(org, offering)
testobj = CourseLocator(org=org, offering=offering)
self.check_course_locn_fields(testobj, org=org, offering=offering)
self.assertEqual(testobj.package_id, testurn)
self.assertEqual(str(testobj), testurn)
self.assertEqual(testobj.url(), 'edx://' + testurn)
def test_course_constructor_package_id_with_branch(self):
testurn = 'mit.eecs.6002x/' + BRANCH_PREFIX + 'published'
expected_id = 'mit.eecs.6002x'
expected_branch = 'published'
testobj = CourseLocator(package_id=testurn)
self.check_course_locn_fields(testobj, 'package_id with branch',
package_id=expected_id,
branch=expected_branch,
)
self.assertEqual(testobj.package_id, expected_id)
self.assertEqual(testobj.branch, expected_branch)
self.assertEqual(str(testobj), testurn)
self.assertEqual(testobj.url(), 'edx://' + testurn)
self.assertEqual(testobj._to_string(), testurn)
def test_course_constructor_package_id_separate_branch(self):
test_id = 'mit.eecs.6002x'
org = 'mit.eecs'
offering = '6002x'
testurn = '{}+{}'.format(org, offering)
test_branch = 'published'
expected_urn = 'mit.eecs.6002x/' + BRANCH_PREFIX + 'published'
testobj = CourseLocator(package_id=test_id, branch=test_branch)
self.check_course_locn_fields(testobj, 'package_id with separate branch',
package_id=test_id,
branch=test_branch,
)
self.assertEqual(testobj.package_id, test_id)
expected_urn = '{}+{}/{}{}'.format(org, offering, BRANCH_PREFIX, test_branch)
testobj = CourseLocator(org=org, offering=offering, branch=test_branch)
self.check_course_locn_fields(
testobj,
org=org,
offering=offering,
branch=test_branch,
)
self.assertEqual(testobj.package_id, testurn)
self.assertEqual(testobj.branch, test_branch)
self.assertEqual(str(testobj), expected_urn)
self.assertEqual(testobj.url(), 'edx://' + expected_urn)
def test_course_constructor_package_id_repeated_branch(self):
"""
The same branch appears in the package_id and the branch field.
"""
test_id = 'mit.eecs.6002x/' + BRANCH_PREFIX + 'published'
test_branch = 'published'
expected_id = 'mit.eecs.6002x'
expected_urn = test_id
testobj = CourseLocator(package_id=test_id, branch=test_branch)
self.check_course_locn_fields(testobj, 'package_id with repeated branch',
package_id=expected_id,
branch=test_branch,
)
self.assertEqual(testobj.package_id, expected_id)
self.assertEqual(testobj.branch, test_branch)
self.assertEqual(str(testobj), expected_urn)
self.assertEqual(testobj.url(), 'edx://' + expected_urn)
self.assertEqual(testobj._to_string(), expected_urn)
def test_block_constructor(self):
testurn = 'mit.eecs.6002x/' + BRANCH_PREFIX + 'published/' + BLOCK_PREFIX + 'HW3'
expected_id = 'mit.eecs.6002x'
expected_org = 'mit.eecs'
expected_offering = '6002x'
expected_branch = 'published'
expected_block_ref = 'HW3'
testobj = BlockUsageLocator(url=testurn)
self.check_block_locn_fields(testobj, 'test_block constructor',
package_id=expected_id,
testurn = 'edx:{}+{}/{}{}/{}{}'.format(
expected_org, expected_offering, BRANCH_PREFIX, expected_branch, BLOCK_PREFIX, 'HW3'
)
testobj = BlockUsageLocator.from_string(testurn)
self.check_block_locn_fields(testobj,
org=expected_org,
offering=expected_offering,
branch=expected_branch,
block=expected_block_ref)
self.assertEqual(str(testobj), testurn)
self.assertEqual(testobj.url(), 'edx://' + testurn)
testobj = BlockUsageLocator(url=testurn, version_guid=ObjectId())
self.assertEqual(unicode(testobj), testurn)
testobj = BlockUsageLocator(testobj.course_key.for_version(ObjectId()), testobj.block_id)
agnostic = testobj.version_agnostic()
self.assertIsNone(agnostic.version_guid)
self.check_block_locn_fields(agnostic, 'test_block constructor',
package_id=expected_id,
self.check_block_locn_fields(agnostic,
org=expected_org,
offering=expected_offering,
branch=expected_branch,
block=expected_block_ref)
def test_block_constructor_url_version_prefix(self):
test_id_loc = '519665f6223ebd6980884f2b'
testobj = BlockUsageLocator(
url='edx://mit.eecs.6002x/{}{}/{}lab2'.format(VERSION_PREFIX, test_id_loc, BLOCK_PREFIX)
testobj = BlockUsageLocator.from_string(
'edx:mit.eecs+6002x/{}{}/{}lab2'.format(VERSION_PREFIX, test_id_loc, BLOCK_PREFIX)
)
self.check_block_locn_fields(
testobj, 'error parsing URL with version and block',
package_id='mit.eecs.6002x',
testobj,
org='mit.eecs',
offering='6002x',
block='lab2',
version_guid=ObjectId(test_id_loc)
)
agnostic = testobj.course_agnostic()
self.check_block_locn_fields(
agnostic, 'error parsing URL with version and block',
agnostic,
block='lab2',
package_id=None,
org=None,
offering=None,
version_guid=ObjectId(test_id_loc)
)
self.assertIsNone(agnostic.package_id)
self.assertIsNone(agnostic.offering)
self.assertIsNone(agnostic.org)
def test_block_constructor_url_kitchen_sink(self):
test_id_loc = '519665f6223ebd6980884f2b'
testobj = BlockUsageLocator(
url='edx://mit.eecs.6002x/{}draft/{}{}/{}lab2'.format(
testobj = BlockUsageLocator.from_string(
'edx:mit.eecs+6002x/{}draft/{}{}/{}lab2'.format(
BRANCH_PREFIX, VERSION_PREFIX, test_id_loc, BLOCK_PREFIX
)
)
self.check_block_locn_fields(
testobj, 'error parsing URL with branch, version, and block',
package_id='mit.eecs.6002x',
testobj,
org='mit.eecs',
offering='6002x',
branch='draft',
block='lab2',
version_guid=ObjectId(test_id_loc)
@@ -254,71 +212,50 @@ class LocatorTest(TestCase):
"""
It seems we used to use colons in names; so, ensure they're acceptable.
"""
package_id = 'mit.eecs-1'
org = 'mit.eecs'
offering = '1'
branch = 'foo'
block_id = 'problem:with-colon~2'
testobj = BlockUsageLocator(package_id=package_id, branch=branch, block_id=block_id)
self.check_block_locn_fields(testobj, 'Cannot handle colon', package_id=package_id, branch=branch, block=block_id)
testobj = BlockUsageLocator(
CourseLocator(org=org, offering=offering, branch=branch),
block_id=block_id
)
self.check_block_locn_fields(
testobj, org=org, offering=offering, branch=branch, block=block_id
)
def test_relative(self):
"""
Test making a relative usage locator.
"""
package_id = 'mit.eecs-1'
org = 'mit.eecs'
offering = '1'
branch = 'foo'
baseobj = CourseLocator(package_id=package_id, branch=branch)
baseobj = CourseLocator(org=org, offering=offering, branch=branch)
block_id = 'problem:with-colon~2'
testobj = BlockUsageLocator.make_relative(baseobj, block_id)
self.check_block_locn_fields(
testobj, 'Cannot make relative to course', package_id=package_id, branch=branch, block=block_id
testobj, org=org, offering=offering, branch=branch, block=block_id
)
block_id = 'completely_different'
testobj = BlockUsageLocator.make_relative(testobj, block_id)
self.check_block_locn_fields(
testobj, 'Cannot make relative to block usage', package_id=package_id, branch=branch, block=block_id
testobj, org=org, offering=offering, branch=branch, block=block_id
)
def test_repr(self):
testurn = 'mit.eecs.6002x/' + BRANCH_PREFIX + 'published/' + BLOCK_PREFIX + 'HW3'
testobj = BlockUsageLocator(package_id=testurn)
self.assertEqual('BlockUsageLocator("mit.eecs.6002x/branch/published/block/HW3")', repr(testobj))
def test_old_location_helpers(self):
"""
Test the functions intended to help with the conversion from old locations to locators
"""
location_tuple = ('i4x', 'mit', 'eecs.6002x', 'course', 't3_2013')
location = Location(location_tuple)
self.assertEqual(location, Locator.to_locator_or_location(location))
self.assertEqual(location, Locator.to_locator_or_location(location_tuple))
self.assertEqual(location, Locator.to_locator_or_location(list(location_tuple)))
self.assertEqual(location, Locator.to_locator_or_location(location.dict()))
locator = BlockUsageLocator(package_id='foo.bar', branch='alpha', block_id='deep')
self.assertEqual(locator, Locator.to_locator_or_location(locator))
self.assertEqual(locator.as_course_locator(), Locator.to_locator_or_location(locator.as_course_locator()))
self.assertEqual(location, Locator.to_locator_or_location(location.url()))
self.assertEqual(locator, Locator.to_locator_or_location(locator.url()))
self.assertEqual(locator, Locator.to_locator_or_location(locator.__dict__))
asset_location = Location(['c4x', 'mit', 'eecs.6002x', 'asset', 'selfie.jpeg'])
self.assertEqual(asset_location, Locator.to_locator_or_location(asset_location))
self.assertEqual(asset_location, Locator.to_locator_or_location(asset_location.url()))
def_location_url = "defx://version/" + '{:024x}'.format(random.randrange(16 ** 24))
self.assertEqual(DefinitionLocator(def_location_url), Locator.to_locator_or_location(def_location_url))
with self.assertRaises(ValueError):
Locator.to_locator_or_location(22)
with self.assertRaises(ValueError):
Locator.to_locator_or_location("hello.world.not.a.url")
self.assertIsNone(Locator.parse_url("unknown://foo.bar/baz"))
testurn = 'edx:mit.eecs+6002x/' + BRANCH_PREFIX + 'published/' + BLOCK_PREFIX + 'HW3'
testobj = BlockUsageLocator.from_string(testurn)
self.assertEqual("BlockUsageLocator(CourseLocator(u'mit.eecs', u'6002x', u'published', None), u'HW3')", repr(testobj))
def test_url_reverse(self):
"""
Test the url_reverse method
"""
locator = CourseLocator(package_id="a.fancy_course-id", branch="branch_1.2-3")
locator = BlockUsageLocator(
CourseLocator(org="a", offering="fancy_course-id", branch="branch_1.2-3"),
block_id='element'
)
self.assertEqual(
'/expression/{}/format'.format(unicode(locator)),
locator.url_reverse('expression', 'format')
@@ -339,8 +276,7 @@ class LocatorTest(TestCase):
def test_description_locator_url(self):
object_id = '{:024x}'.format(random.randrange(16 ** 24))
definition_locator = DefinitionLocator(object_id)
self.assertEqual('defx://' + VERSION_PREFIX + object_id, definition_locator.url())
self.assertEqual(definition_locator, DefinitionLocator(definition_locator.url()))
self.assertEqual('defx:' + VERSION_PREFIX + object_id, unicode(definition_locator))
def test_description_locator_version(self):
object_id = '{:024x}'.format(random.randrange(16 ** 24))
@@ -350,20 +286,21 @@ class LocatorTest(TestCase):
# ------------------------------------------------------------------
# Utilities
def check_course_locn_fields(self, testobj, msg, version_guid=None,
package_id=None, branch=None):
def check_course_locn_fields(self, testobj, version_guid=None,
org=None, offering=None, branch=None):
"""
Checks the version, package_id, and branch in testobj
Checks the version, org, offering, and branch in testobj
"""
self.assertEqual(testobj.version_guid, version_guid, msg)
self.assertEqual(testobj.package_id, package_id, msg)
self.assertEqual(testobj.branch, branch, msg)
self.assertEqual(testobj.version_guid, version_guid)
self.assertEqual(testobj.org, org)
self.assertEqual(testobj.offering, offering)
self.assertEqual(testobj.branch, branch)
def check_block_locn_fields(self, testobj, msg, version_guid=None,
package_id=None, branch=None, block=None):
def check_block_locn_fields(self, testobj, version_guid=None,
org=None, offering=None, branch=None, block=None):
"""
Does adds a block id check over and above the check_course_locn_fields tests
"""
self.check_course_locn_fields(testobj, msg, version_guid, package_id,
self.check_course_locn_fields(testobj, version_guid, org, offering,
branch)
self.assertEqual(testobj.block_id, block)

View File

@@ -14,6 +14,8 @@ from xmodule.modulestore.tests.test_location_mapper import LocMapperSetupSansDja
# Mixed modulestore depends on django, so we'll manually configure some django settings
# before importing the module
from django.conf import settings
from xmodule.modulestore.locations import SlashSeparatedCourseKey
import bson.son
if not settings.configured:
settings.configure()
from xmodule.modulestore.mixed import MixedModuleStore
@@ -83,7 +85,7 @@ class TestMixedModuleStore(LocMapperSetupSansDjango):
"""
AssertEqual replacement for CourseLocator
"""
if not (loc1.package_id == loc2.package_id and loc1.branch == loc2.branch and loc1.block_id == loc2.block_id):
if loc1.version_agnostic() != loc2.version_agnostic():
self.fail(self._formatMessage(msg, u"{} != {}".format(unicode(loc1), unicode(loc2))))
def setUp(self):
@@ -95,6 +97,7 @@ class TestMixedModuleStore(LocMapperSetupSansDjango):
host=self.HOST,
port=self.PORT,
tz_aware=True,
document_class=bson.son.SON,
)
self.connection.drop_database(self.DB)
self.addCleanup(self.connection.drop_database, self.DB)
@@ -109,29 +112,40 @@ class TestMixedModuleStore(LocMapperSetupSansDjango):
patcher.start()
self.addCleanup(patcher.stop)
self.addTypeEqualityFunc(BlockUsageLocator, '_compareIgnoreVersion')
self.addTypeEqualityFunc(CourseLocator, '_compareIgnoreVersion')
# define attrs which get set in initdb to quell pylint
self.import_chapter_location = self.store = self.fake_location = self.xml_chapter_location = None
self.course_locations = []
# pylint: disable=invalid-name
def _create_course(self, default, course_id):
def _create_course(self, default, course_key):
"""
Create a course w/ one item in the persistence store using the given course & item location.
"""
course = self.store.create_course(course_id, store_name=default)
if default == 'split':
offering = course_key.offering.replace('/', '.')
else:
offering = course_key.offering
course = self.store.create_course(course_key.org, offering, store_name=default)
category = self.import_chapter_location.category
block_id = self.import_chapter_location.name
chapter = self.store.create_item(
# don't use course_location as it may not be the repr
course.location, category, location=self.import_chapter_location, block_id=block_id
)
if isinstance(course.location, CourseLocator):
if isinstance(course.id, CourseLocator):
self.course_locations[self.MONGO_COURSEID] = course.location.version_agnostic()
self.import_chapter_location = chapter.location.version_agnostic()
else:
self.assertEqual(course.location.course_id, course_id)
self.assertEqual(course.id, course_key)
self.assertEqual(chapter.location, self.import_chapter_location)
def _course_key_from_string(self, string):
"""
Get the course key for the given course string
"""
return self.course_locations[string].course_key
def initdb(self, default):
"""
Initialize the database and create one test course in it
@@ -141,11 +155,17 @@ class TestMixedModuleStore(LocMapperSetupSansDjango):
self.store = MixedModuleStore(**self.options)
self.addCleanup(self.store.close_all_connections)
# convert to CourseKeys
self.course_locations = {
course_id: generate_location(course_id)
course_id: SlashSeparatedCourseKey.from_deprecated_string(course_id)
for course_id in [self.MONGO_COURSEID, self.XML_COURSEID1, self.XML_COURSEID2]
}
self.fake_location = Location('i4x', 'foo', 'bar', 'vertical', 'baz')
# and then to the root UsageKey
self.course_locations = {
course_id: course_key.make_usage_key('course', course_key.run)
for course_id, course_key in self.course_locations.iteritems() # pylint: disable=maybe-no-member
}
self.fake_location = Location('foo', 'bar', 'slowly', 'vertical', 'baz')
self.import_chapter_location = self.course_locations[self.MONGO_COURSEID].replace(
category='chapter', name='Overview'
)
@@ -154,9 +174,9 @@ class TestMixedModuleStore(LocMapperSetupSansDjango):
)
# get Locators and set up the loc mapper if app is Locator based
if default == 'split':
self.fake_location = loc_mapper().translate_location('foo/bar/2012_Fall', self.fake_location)
self.fake_location = loc_mapper().translate_location(self.fake_location)
self._create_course(default, self.MONGO_COURSEID)
self._create_course(default, self.course_locations[self.MONGO_COURSEID].course_key)
@ddt.data('direct', 'split')
def test_get_modulestore_type(self, default_ms):
@@ -164,57 +184,54 @@ class TestMixedModuleStore(LocMapperSetupSansDjango):
Make sure we get back the store type we expect for given mappings
"""
self.initdb(default_ms)
self.assertEqual(self.store.get_modulestore_type(self.XML_COURSEID1), XML_MODULESTORE_TYPE)
self.assertEqual(self.store.get_modulestore_type(self.XML_COURSEID2), XML_MODULESTORE_TYPE)
self.assertEqual(self.store.get_modulestore_type(
self._course_key_from_string(self.XML_COURSEID1)), XML_MODULESTORE_TYPE
)
self.assertEqual(self.store.get_modulestore_type(
self._course_key_from_string(self.XML_COURSEID2)), XML_MODULESTORE_TYPE
)
mongo_ms_type = MONGO_MODULESTORE_TYPE if default_ms == 'direct' else SPLIT_MONGO_MODULESTORE_TYPE
self.assertEqual(self.store.get_modulestore_type(self.MONGO_COURSEID), mongo_ms_type)
self.assertEqual(self.store.get_modulestore_type(
self._course_key_from_string(self.MONGO_COURSEID)), mongo_ms_type
)
# try an unknown mapping, it should be the 'default' store
self.assertEqual(self.store.get_modulestore_type('foo/bar/2012_Fall'), mongo_ms_type)
self.assertEqual(self.store.get_modulestore_type(
SlashSeparatedCourseKey('foo', 'bar', '2012_Fall')), mongo_ms_type
)
@ddt.data('direct', 'split')
def test_has_item(self, default_ms):
self.initdb(default_ms)
for course_id, course_locn in self.course_locations.iteritems():
self.assertTrue(self.store.has_item(course_id, course_locn))
for course_locn in self.course_locations.itervalues(): # pylint: disable=maybe-no-member
self.assertTrue(self.store.has_item(course_locn))
# try negative cases
self.assertFalse(self.store.has_item(
self.XML_COURSEID1,
self.course_locations[self.XML_COURSEID1].replace(name='not_findable', category='problem')
))
self.assertFalse(self.store.has_item(self.MONGO_COURSEID, self.fake_location))
self.assertFalse(self.store.has_item(self.fake_location))
@ddt.data('direct', 'split')
def test_get_item(self, default_ms):
self.initdb(default_ms)
with self.assertRaises(NotImplementedError):
self.store.get_item(self.fake_location)
@ddt.data('direct', 'split')
def test_get_instance(self, default_ms):
self.initdb(default_ms)
for course_id, course_locn in self.course_locations.iteritems():
self.assertIsNotNone(self.store.get_instance(course_id, course_locn))
for course_locn in self.course_locations.itervalues(): # pylint: disable=maybe-no-member
self.assertIsNotNone(self.store.get_item(course_locn))
# try negative cases
with self.assertRaises(ItemNotFoundError):
self.store.get_instance(
self.XML_COURSEID1,
self.store.get_item(
self.course_locations[self.XML_COURSEID1].replace(name='not_findable', category='problem')
)
with self.assertRaises(ItemNotFoundError):
self.store.get_instance(self.MONGO_COURSEID, self.fake_location)
self.store.get_item(self.fake_location)
@ddt.data('direct', 'split')
def test_get_items(self, default_ms):
self.initdb(default_ms)
for course_id, course_locn in self.course_locations.iteritems():
if hasattr(course_locn, 'as_course_locator'):
locn = course_locn.as_course_locator()
else:
locn = course_locn.replace(org=None, course=None, name=None)
for course_locn in self.course_locations.itervalues(): # pylint: disable=maybe-no-member
locn = course_locn.course_key
# NOTE: use get_course if you just want the course. get_items is expensive
modules = self.store.get_items(locn, course_id, qualifiers={'category': 'course'})
modules = self.store.get_items(locn, category='course')
self.assertEqual(len(modules), 1)
self.assertEqual(modules[0].location, course_locn)
@@ -224,25 +241,19 @@ class TestMixedModuleStore(LocMapperSetupSansDjango):
Update should fail for r/o dbs and succeed for r/w ones
"""
self.initdb(default_ms)
course_id = self.XML_COURSEID1
course = self.store.get_course(course_id)
course = self.store.get_course(self.course_locations[self.XML_COURSEID1].course_key)
# if following raised, then the test is really a noop, change it
self.assertFalse(course.show_calculator, "Default changed making test meaningless")
course.show_calculator = True
with self.assertRaises(NotImplementedError):
with self.assertRaises(AttributeError): # ensure it doesn't allow writing
self.store.update_item(course, None)
# now do it for a r/w db
# get_course api's are inconsistent: one takes Locators the other an old style course id
if hasattr(self.course_locations[self.MONGO_COURSEID], 'as_course_locator'):
locn = self.course_locations[self.MONGO_COURSEID]
else:
locn = self.MONGO_COURSEID
course = self.store.get_course(locn)
course = self.store.get_course(self.course_locations[self.MONGO_COURSEID].course_key)
# if following raised, then the test is really a noop, change it
self.assertFalse(course.show_calculator, "Default changed making test meaningless")
course.show_calculator = True
self.store.update_item(course, None)
course = self.store.get_course(locn)
course = self.store.get_course(self.course_locations[self.MONGO_COURSEID].course_key)
self.assertTrue(course.show_calculator)
@ddt.data('direct', 'split')
@@ -251,13 +262,13 @@ class TestMixedModuleStore(LocMapperSetupSansDjango):
Delete should reject on r/o db and work on r/w one
"""
self.initdb(default_ms)
# r/o try deleting the course
with self.assertRaises(NotImplementedError):
# r/o try deleting the course (is here to ensure it can't be deleted)
with self.assertRaises(AttributeError):
self.store.delete_item(self.xml_chapter_location)
self.store.delete_item(self.import_chapter_location, '**replace_user**')
# verify it's gone
with self.assertRaises(ItemNotFoundError):
self.store.get_instance(self.MONGO_COURSEID, self.import_chapter_location)
self.store.get_item(self.import_chapter_location)
@ddt.data('direct', 'split')
def test_get_courses(self, default_ms):
@@ -281,9 +292,9 @@ class TestMixedModuleStore(LocMapperSetupSansDjango):
self.initdb('direct')
courses = self.store.modulestores['xml'].get_courses()
self.assertEqual(len(courses), 2)
course_ids = [course.location.course_id for course in courses]
self.assertIn(self.XML_COURSEID1, course_ids)
self.assertIn(self.XML_COURSEID2, course_ids)
course_ids = [course.id for course in courses]
self.assertIn(self.course_locations[self.XML_COURSEID1].course_key, course_ids)
self.assertIn(self.course_locations[self.XML_COURSEID2].course_key, course_ids)
# this course is in the directory from which we loaded courses but not in the map
self.assertNotIn("edX/toy/TT_2012_Fall", course_ids)
@@ -293,35 +304,25 @@ class TestMixedModuleStore(LocMapperSetupSansDjango):
"""
self.initdb('direct')
with self.assertRaises(NotImplementedError):
self.store.create_course("org/course/run", store_name='xml')
self.store.create_course("org", "course/run", store_name='xml')
@ddt.data('direct', 'split')
def test_get_course(self, default_ms):
self.initdb(default_ms)
for course_locn in self.course_locations.itervalues():
if hasattr(course_locn, 'as_course_locator'):
locn = course_locn.as_course_locator()
else:
locn = course_locn.course_id
for course_location in self.course_locations.itervalues(): # pylint: disable=maybe-no-member
# NOTE: use get_course if you just want the course. get_items is expensive
course = self.store.get_course(locn)
course = self.store.get_course(course_location.course_key)
self.assertIsNotNone(course)
self.assertEqual(course.location, course_locn)
self.assertEqual(course.id, course_location.course_key)
@ddt.data('direct', 'split')
def test_get_parent_locations(self, default_ms):
self.initdb(default_ms)
parents = self.store.get_parent_locations(
self.import_chapter_location,
self.MONGO_COURSEID
)
parents = self.store.get_parent_locations(self.import_chapter_location)
self.assertEqual(len(parents), 1)
self.assertEqual(parents[0], self.course_locations[self.MONGO_COURSEID])
parents = self.store.get_parent_locations(
self.xml_chapter_location,
self.XML_COURSEID1
)
parents = self.store.get_parent_locations(self.xml_chapter_location)
self.assertEqual(len(parents), 1)
self.assertEqual(parents[0], self.course_locations[self.XML_COURSEID1])
@@ -329,33 +330,13 @@ class TestMixedModuleStore(LocMapperSetupSansDjango):
def test_get_orphans(self, default_ms):
self.initdb(default_ms)
# create an orphan
if default_ms == 'split':
course_id = self.course_locations[self.MONGO_COURSEID].as_course_locator()
branch = course_id.branch
else:
course_id = self.MONGO_COURSEID
branch = None
course_id = self.course_locations[self.MONGO_COURSEID].course_key
orphan = self.store.create_item(course_id, 'problem', block_id='orphan')
found_orphans = self.store.get_orphans(self.course_locations[self.MONGO_COURSEID], branch)
found_orphans = self.store.get_orphans(self.course_locations[self.MONGO_COURSEID].course_key)
if default_ms == 'split':
self.assertEqual(found_orphans, [orphan.location.version_agnostic()])
else:
self.assertEqual(found_orphans, [unicode(orphan.location)])
@ddt.data('split')
def test_create_item_from_course_id(self, default_ms):
"""
Test code paths missed by the above:
* passing an old-style course_id which has a loc map to split's create_item
"""
self.initdb(default_ms)
# create loc_map entry
loc_mapper().translate_location(self.MONGO_COURSEID, generate_location(self.MONGO_COURSEID))
orphan = self.store.create_item(self.MONGO_COURSEID, 'problem', block_id='orphan')
self.assertEqual(
orphan.location.version_agnostic().as_course_locator(),
self.course_locations[self.MONGO_COURSEID].as_course_locator()
)
self.assertEqual(found_orphans, [orphan.location.to_deprecated_string()])
@ddt.data('direct')
def test_create_item_from_parent_location(self, default_ms):
@@ -365,7 +346,7 @@ class TestMixedModuleStore(LocMapperSetupSansDjango):
"""
self.initdb(default_ms)
self.store.create_item(self.course_locations[self.MONGO_COURSEID], 'problem', block_id='orphan')
orphans = self.store.get_orphans(self.course_locations[self.MONGO_COURSEID], None)
orphans = self.store.get_orphans(self.course_locations[self.MONGO_COURSEID].course_key)
self.assertEqual(len(orphans), 0, "unexpected orphans: {}".format(orphans))
@ddt.data('direct')
@@ -376,11 +357,11 @@ class TestMixedModuleStore(LocMapperSetupSansDjango):
self.initdb(default_ms)
course_locations = self.store.get_courses_for_wiki('toy')
self.assertEqual(len(course_locations), 1)
self.assertIn(Location('i4x', 'edX', 'toy', 'course', '2012_Fall'), course_locations)
self.assertIn(self.course_locations[self.XML_COURSEID1], course_locations)
course_locations = self.store.get_courses_for_wiki('simple')
self.assertEqual(len(course_locations), 1)
self.assertIn(Location('i4x', 'edX', 'simple', 'course', '2012_Fall'), course_locations)
self.assertIn(self.course_locations[self.XML_COURSEID2], course_locations)
self.assertEqual(len(self.store.get_courses_for_wiki('edX.simple.2012_Fall')), 0)
self.assertEqual(len(self.store.get_courses_for_wiki('no_such_wiki')), 0)
@@ -413,13 +394,3 @@ def create_modulestore_instance(engine, doc_store_config, options, i18n_service=
doc_store_config=doc_store_config,
**options
)
def generate_location(course_id):
"""
Generate the locations for the given ids
"""
course_dict = Location.parse_course_id(course_id)
course_dict['tag'] = 'i4x'
course_dict['category'] = 'course'
return Location(course_dict)

View File

@@ -1,26 +1,70 @@
from nose.tools import assert_equals, assert_raises # pylint: disable=E0611
from nose.tools import assert_equals, assert_raises, assert_true, assert_false # pylint: disable=E0611
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.search import path_to_location
from xmodule.modulestore.locations import SlashSeparatedCourseKey
def check_path_to_location(modulestore):
"""
Make sure that path_to_location works: should be passed a modulestore
with the toy and simple courses loaded.
"""
course_id = SlashSeparatedCourseKey("edX", "toy", "2012_Fall")
should_work = (
("i4x://edX/toy/video/Welcome",
("edX/toy/2012_Fall", "Overview", "Welcome", None)),
("i4x://edX/toy/chapter/Overview",
("edX/toy/2012_Fall", "Overview", None, None)),
(course_id.make_usage_key('video', 'Welcome'),
(course_id, "Overview", "Welcome", None)),
(course_id.make_usage_key('chapter', 'Overview'),
(course_id, "Overview", None, None)),
)
course_id = "edX/toy/2012_Fall"
for location, expected in should_work:
assert_equals(path_to_location(modulestore, course_id, location), expected)
assert_equals(path_to_location(modulestore, location), expected)
not_found = (
"i4x://edX/toy/video/WelcomeX", "i4x://edX/toy/course/NotHome"
course_id.make_usage_key('video', 'WelcomeX'),
course_id.make_usage_key('course', 'NotHome'),
)
for location in not_found:
assert_raises(ItemNotFoundError, path_to_location, modulestore, course_id, location)
with assert_raises(ItemNotFoundError):
path_to_location(modulestore, location)
def check_has_course_method(modulestore, locator, locator_key_fields):
error_message = "Called has_course with query {0} and ignore_case is {1}."
for ignore_case in [True, False]:
# should find the course with exact locator
assert_true(modulestore.has_course(locator, ignore_case))
for key_field in locator_key_fields:
locator_changes_that_should_not_be_found = [ # pylint: disable=invalid-name
# replace value for one of the keys
{key_field: 'fake'},
# add a character at the end
{key_field: getattr(locator, key_field) + 'X'},
# add a character in the beginning
{key_field: 'X' + getattr(locator, key_field)},
]
for changes in locator_changes_that_should_not_be_found:
search_locator = locator.replace(**changes)
assert_false(
modulestore.has_course(search_locator),
error_message.format(search_locator, ignore_case)
)
# test case [in]sensitivity
locator_case_changes = [
{key_field: getattr(locator, key_field).upper()},
{key_field: getattr(locator, key_field).capitalize()},
{key_field: getattr(locator, key_field).capitalize().swapcase()},
]
for changes in locator_case_changes:
search_locator = locator.replace(**changes)
assert_equals(
modulestore.has_course(search_locator, ignore_case),
ignore_case,
error_message.format(search_locator, ignore_case)
)

View File

@@ -1,28 +1,33 @@
from pprint import pprint
# pylint: disable=E0611
from nose.tools import assert_equals, assert_raises, \
assert_not_equals, assert_false
from itertools import ifilter
assert_not_equals, assert_false, assert_true, assert_greater, assert_is_instance
# pylint: enable=E0611
import pymongo
import logging
from uuid import uuid4
import unittest
import bson.son
from xblock.core import XBlock
from xblock.fields import Scope
from xblock.fields import Scope, Reference, ReferenceList, ReferenceValueDict
from xblock.runtime import KeyValueStore
from xblock.exceptions import InvalidScopeError
from xblock.plugin import Plugin
from xmodule.tests import DATA_DIR
from xmodule.modulestore import Location, MONGO_MODULESTORE_TYPE
from xmodule.modulestore.mongo import MongoModuleStore, MongoKeyValueStore
from xmodule.modulestore.draft import DraftModuleStore
from xmodule.modulestore.locations import SlashSeparatedCourseKey, AssetLocation
from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint
from xmodule.contentstore.mongo import MongoContentStore
from xmodule.modulestore.tests.test_modulestore import check_path_to_location
from nose.tools import assert_in
from xmodule.exceptions import NotFoundError
from xmodule.modulestore.exceptions import InsufficientSpecificationError
from git.test.lib.asserts import assert_not_none
from xmodule.x_module import XModuleMixin
log = logging.getLogger(__name__)
@@ -35,7 +40,17 @@ DEFAULT_CLASS = 'xmodule.raw_module.RawDescriptor'
RENDER_TEMPLATE = lambda t_n, d, ctx = None, nsp = 'main': ''
class TestMongoModuleStore(object):
class ReferenceTestXBlock(XBlock):
"""
Test xblock type to test the reference field types
"""
has_children = True
reference_link = Reference(default=None, scope=Scope.content)
reference_list = ReferenceList(scope=Scope.content)
reference_dict = ReferenceValueDict(scope=Scope.settings)
class TestMongoModuleStore(unittest.TestCase):
'''Tests!'''
# Explicitly list the courses to load (don't want the big one)
courses = ['toy', 'simple', 'simple_with_draft', 'test_unicode']
@@ -46,6 +61,7 @@ class TestMongoModuleStore(object):
host=HOST,
port=PORT,
tz_aware=True,
document_class=bson.son.SON,
)
cls.connection.drop_database(DB)
@@ -57,6 +73,7 @@ class TestMongoModuleStore(object):
@classmethod
def teardownClass(cls):
# cls.patcher.stop()
if cls.connection:
cls.connection.drop_database(DB)
cls.connection.close()
@@ -69,7 +86,10 @@ class TestMongoModuleStore(object):
'db': DB,
'collection': COLLECTION,
}
store = MongoModuleStore(doc_store_config, FS_ROOT, RENDER_TEMPLATE, default_class=DEFAULT_CLASS)
store = MongoModuleStore(
doc_store_config, FS_ROOT, RENDER_TEMPLATE, default_class=DEFAULT_CLASS,
xblock_mixins=(XModuleMixin,)
)
# since MongoModuleStore and MongoContentStore are basically assumed to be together, create this class
# as well
content_store = MongoContentStore(HOST, DB)
@@ -103,25 +123,10 @@ class TestMongoModuleStore(object):
def tearDown(self):
pass
def get_course_by_id(self, name):
"""
Returns the first course with `id` of `name`, or `None` if there are none.
"""
courses = self.store.get_courses()
return next(ifilter(lambda x: x.id == name, courses), None)
def course_with_id_exists(self, name):
"""
Returns true iff there exists some course with `id` of `name`.
"""
return (self.get_course_by_id(name) is not None)
def test_init(self):
'''Make sure the db loads, and print all the locations in the db.
Call this directly from failing tests to see what is loaded'''
'''Make sure the db loads'''
ids = list(self.connection[DB][COLLECTION].find({}, {'_id': True}))
pprint([Location(i['_id']).url() for i in ids])
assert_greater(len(ids), 12)
def test_mongo_modulestore_type(self):
store = MongoModuleStore(
@@ -134,53 +139,64 @@ class TestMongoModuleStore(object):
'''Make sure the course objects loaded properly'''
courses = self.store.get_courses()
assert_equals(len(courses), 5)
assert self.course_with_id_exists('edX/simple/2012_Fall')
assert self.course_with_id_exists('edX/simple_with_draft/2012_Fall')
assert self.course_with_id_exists('edX/test_import_course/2012_Fall')
assert self.course_with_id_exists('edX/test_unicode/2012_Fall')
assert self.course_with_id_exists('edX/toy/2012_Fall')
course_ids = [course.id for course in courses]
for course_key in [
SlashSeparatedCourseKey(*fields)
for fields in [
['edX', 'simple', '2012_Fall'], ['edX', 'simple_with_draft', '2012_Fall'],
['edX', 'test_import_course', '2012_Fall'], ['edX', 'test_unicode', '2012_Fall'],
['edX', 'toy', '2012_Fall']
]
]:
assert_in(course_key, course_ids)
course = self.store.get_course(course_key)
assert_not_none(course)
def test_loads(self):
assert_not_equals(
self.store.get_item("i4x://edX/toy/course/2012_Fall"),
None)
assert_not_none(
self.store.get_item(Location('edX', 'toy', '2012_Fall', 'course', '2012_Fall'))
)
assert_not_equals(
self.store.get_item("i4x://edX/simple/course/2012_Fall"),
None)
assert_not_none(
self.store.get_item(Location('edX', 'simple', '2012_Fall', 'course', '2012_Fall')),
)
assert_not_equals(
self.store.get_item("i4x://edX/toy/video/Welcome"),
None)
assert_not_none(
self.store.get_item(Location('edX', 'toy', '2012_Fall', 'video', 'Welcome')),
)
def test_unicode_loads(self):
assert_not_equals(
self.store.get_item("i4x://edX/test_unicode/course/2012_Fall"),
None)
"""
Test that getting items from the test_unicode course works
"""
assert_not_none(
self.store.get_item(Location('edX', 'test_unicode', '2012_Fall', 'course', '2012_Fall')),
)
# All items with ascii-only filenames should load properly.
assert_not_equals(
self.store.get_item("i4x://edX/test_unicode/video/Welcome"),
None)
assert_not_equals(
self.store.get_item("i4x://edX/test_unicode/video/Welcome"),
None)
assert_not_equals(
self.store.get_item("i4x://edX/test_unicode/chapter/Overview"),
None)
assert_not_none(
self.store.get_item(Location('edX', 'test_unicode', '2012_Fall', 'video', 'Welcome')),
)
assert_not_none(
self.store.get_item(Location('edX', 'test_unicode', '2012_Fall', 'video', 'Welcome')),
)
assert_not_none(
self.store.get_item(Location('edX', 'test_unicode', '2012_Fall', 'chapter', 'Overview')),
)
def test_find_one(self):
assert_not_equals(
self.store._find_one(Location("i4x://edX/toy/course/2012_Fall")),
None)
assert_not_none(
self.store._find_one(Location('edX', 'toy', '2012_Fall', 'course', '2012_Fall')),
)
assert_not_equals(
self.store._find_one(Location("i4x://edX/simple/course/2012_Fall")),
None)
assert_not_none(
self.store._find_one(Location('edX', 'simple', '2012_Fall', 'course', '2012_Fall')),
)
assert_not_equals(
self.store._find_one(Location("i4x://edX/toy/video/Welcome")),
None)
assert_not_none(
self.store._find_one(Location('edX', 'toy', '2012_Fall', 'video', 'Welcome')),
)
def test_path_to_location(self):
'''Make sure that path_to_location works'''
@@ -209,7 +225,7 @@ class TestMongoModuleStore(object):
Assumes the information is desired for courses[4] ('toy' course).
"""
course = self.get_course_by_id('edX/toy/2012_Fall')
course = self.store.get_course(SlashSeparatedCourseKey('edX', 'toy', '2012_Fall'))
return course.tabs[index]['name']
# There was a bug where model.save was not getting called after the static tab name
@@ -224,29 +240,32 @@ class TestMongoModuleStore(object):
"""
Test getting, setting, and defaulting the locked attr and arbitrary attrs.
"""
location = Location('i4x', 'edX', 'toy', 'course', '2012_Fall')
course_content, __ = TestMongoModuleStore.content_store.get_all_content_for_course(location)
assert len(course_content) > 0
location = Location('edX', 'toy', '2012_Fall', 'course', '2012_Fall')
course_content, __ = TestMongoModuleStore.content_store.get_all_content_for_course(location.course_key)
assert_true(len(course_content) > 0)
# a bit overkill, could just do for content[0]
for content in course_content:
assert not content.get('locked', False)
assert not TestMongoModuleStore.content_store.get_attr(content['_id'], 'locked', False)
attrs = TestMongoModuleStore.content_store.get_attrs(content['_id'])
asset_key = AssetLocation._from_deprecated_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)
assert not attrs.get('locked', False)
TestMongoModuleStore.content_store.set_attr(content['_id'], 'locked', True)
assert TestMongoModuleStore.content_store.get_attr(content['_id'], 'locked', False)
attrs = TestMongoModuleStore.content_store.get_attrs(content['_id'])
TestMongoModuleStore.content_store.set_attr(asset_key, 'locked', True)
assert TestMongoModuleStore.content_store.get_attr(asset_key, 'locked', False)
attrs = TestMongoModuleStore.content_store.get_attrs(asset_key)
assert_in('locked', attrs)
assert attrs['locked'] is True
TestMongoModuleStore.content_store.set_attrs(content['_id'], {'miscel': 99})
assert_equals(TestMongoModuleStore.content_store.get_attr(content['_id'], 'miscel'), 99)
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)
assert_raises(
AttributeError, TestMongoModuleStore.content_store.set_attr, course_content[0]['_id'],
AttributeError, TestMongoModuleStore.content_store.set_attr, asset_key,
'md5', 'ff1532598830e3feac91c2449eaa60d6'
)
assert_raises(
AttributeError, TestMongoModuleStore.content_store.set_attrs, course_content[0]['_id'],
AttributeError, TestMongoModuleStore.content_store.set_attrs, asset_key,
{'foo': 9, 'md5': 'ff1532598830e3feac91c2449eaa60d6'}
)
assert_raises(
@@ -269,7 +288,7 @@ class TestMongoModuleStore(object):
{'displayname': 'hello'}
)
assert_raises(
InsufficientSpecificationError, TestMongoModuleStore.content_store.set_attrs,
NotFoundError, TestMongoModuleStore.content_store.set_attrs,
Location('bogus', 'bogus', 'bogus', 'asset', None),
{'displayname': 'hello'}
)
@@ -281,13 +300,13 @@ class TestMongoModuleStore(object):
for course_number in self.courses:
course_locations = self.store.get_courses_for_wiki(course_number)
assert_equals(len(course_locations), 1)
assert_equals(Location('i4x', 'edX', course_number, 'course', '2012_Fall'), course_locations[0])
assert_equals(Location('edX', course_number, '2012_Fall', 'course', '2012_Fall'), course_locations[0])
course_locations = self.store.get_courses_for_wiki('no_such_wiki')
assert_equals(len(course_locations), 0)
# set toy course to share the wiki with simple course
toy_course = self.store.get_course('edX/toy/2012_Fall')
toy_course = self.store.get_course(SlashSeparatedCourseKey('edX', 'toy', '2012_Fall'))
toy_course.wiki_slug = 'simple'
self.store.update_item(toy_course)
@@ -299,17 +318,78 @@ class TestMongoModuleStore(object):
course_locations = self.store.get_courses_for_wiki('simple')
assert_equals(len(course_locations), 2)
for course_number in ['toy', 'simple']:
assert_in(Location('i4x', 'edX', course_number, 'course', '2012_Fall'), course_locations)
assert_in(Location('edX', course_number, '2012_Fall', 'course', '2012_Fall'), course_locations)
# configure simple course to use unique wiki_slug.
simple_course = self.store.get_course('edX/simple/2012_Fall')
simple_course = self.store.get_course(SlashSeparatedCourseKey('edX', 'simple', '2012_Fall'))
simple_course.wiki_slug = 'edX.simple.2012_Fall'
self.store.update_item(simple_course)
# it should be retrievable with its new wiki_slug
course_locations = self.store.get_courses_for_wiki('edX.simple.2012_Fall')
assert_equals(len(course_locations), 1)
assert_in(Location('i4x', 'edX', 'simple', 'course', '2012_Fall'), course_locations)
assert_in(Location('edX', 'simple', '2012_Fall', 'course', '2012_Fall'), course_locations)
@Plugin.register_temp_plugin(ReferenceTestXBlock, 'ref_test')
def test_reference_converters(self):
"""
Test that references types get deserialized correctly
"""
course_key = SlashSeparatedCourseKey('edX', 'toy', '2012_Fall')
def setup_test():
course = self.store.get_course(course_key)
# can't use item factory as it depends on django settings
p1ele = self.store.create_and_save_xmodule(course.id.make_usage_key('problem', 'p1'))
p2ele = self.store.create_and_save_xmodule(course.id.make_usage_key('problem', 'p2'))
self.refloc = course.id.make_usage_key('ref_test', 'ref_test')
self.store.create_and_save_xmodule(
self.refloc, fields={
'reference_link': p1ele.location,
'reference_list': [p1ele.location, p2ele.location],
'reference_dict': {'p1': p1ele.location, 'p2': p2ele.location},
'children': [p1ele.location, p2ele.location],
}
)
def check_xblock_fields():
def check_children(xblock):
for child in xblock.children:
assert_is_instance(child, Location)
course = self.store.get_course(course_key)
check_children(course)
refele = self.store.get_item(self.refloc)
check_children(refele)
assert_is_instance(refele.reference_link, Location)
assert_greater(len(refele.reference_list), 0)
for ref in refele.reference_list:
assert_is_instance(ref, Location)
assert_greater(len(refele.reference_dict), 0)
for ref in refele.reference_dict.itervalues():
assert_is_instance(ref, Location)
def check_mongo_fields():
def get_item(location):
return self.store._find_one(location)
def check_children(payload):
for child in payload['definition']['children']:
assert_is_instance(child, basestring)
refele = get_item(self.refloc)
check_children(refele)
assert_is_instance(refele['definition']['data']['reference_link'], basestring)
assert_greater(len(refele['definition']['data']['reference_list']), 0)
for ref in refele['definition']['data']['reference_list']:
assert_is_instance(ref, basestring)
assert_greater(len(refele['metadata']['reference_dict']), 0)
for ref in refele['metadata']['reference_dict'].itervalues():
assert_is_instance(ref, basestring)
setup_test()
check_xblock_fields()
check_mongo_fields()
class TestMongoKeyValueStore(object):
"""
@@ -318,8 +398,8 @@ class TestMongoKeyValueStore(object):
def setUp(self):
self.data = {'foo': 'foo_value'}
self.location = Location('i4x://org/course/category/name@version')
self.children = ['i4x://org/course/child/a', 'i4x://org/course/child/b']
self.course_id = SlashSeparatedCourseKey('org', 'course', 'run')
self.children = [self.course_id.make_usage_key('child', 'a'), self.course_id.make_usage_key('child', 'b')]
self.metadata = {'meta': 'meta_val'}
self.kvs = MongoKeyValueStore(self.data, self.children, self.metadata)

View File

@@ -1,158 +1,52 @@
import uuid
import mock
import unittest
import random
import datetime
from xmodule.modulestore.inheritance import InheritanceMixin
from xmodule.modulestore.mongo import MongoModuleStore
from xmodule.modulestore.split_mongo import SplitMongoModuleStore
from xmodule.modulestore import Location
from xmodule.fields import Date
from xmodule.modulestore.locator import BlockUsageLocator, CourseLocator
from xmodule.modulestore.tests.test_split_w_old_mongo import SplitWMongoCourseBoostrapper
class TestOrphan(unittest.TestCase):
class TestOrphan(SplitWMongoCourseBoostrapper):
"""
Test the orphan finding code
"""
# Snippet of what would be in the django settings envs file
db_config = {
'host': 'localhost',
'db': 'test_xmodule',
}
modulestore_options = {
'default_class': 'xmodule.raw_module.RawDescriptor',
'fs_root': '',
'render_template': mock.Mock(return_value=""),
'xblock_mixins': (InheritanceMixin,)
}
split_package_id = 'test_org.test_course.runid'
def setUp(self):
self.db_config['collection'] = 'modulestore{0}'.format(uuid.uuid4().hex[:5])
self.userid = random.getrandbits(32)
super(TestOrphan, self).setUp()
self.split_mongo = SplitMongoModuleStore(
self.db_config,
**self.modulestore_options
)
self.addCleanup(self.tear_down_split)
self.old_mongo = MongoModuleStore(self.db_config, **self.modulestore_options)
self.addCleanup(self.tear_down_mongo)
self.course_location = None
self._create_course()
def tear_down_split(self):
"""
Remove the test collections, close the db connection
"""
split_db = self.split_mongo.db
split_db.drop_collection(split_db.course_index)
split_db.drop_collection(split_db.structures)
split_db.drop_collection(split_db.definitions)
split_db.connection.close()
def tear_down_mongo(self):
"""
Remove the test collections, close the db connection
"""
split_db = self.split_mongo.db
# old_mongo doesn't give a db attr, but all of the dbs are the same
split_db.drop_collection(self.old_mongo.collection)
def _create_item(self, category, name, data, metadata, parent_category, parent_name, runtime):
"""
Create the item of the given category and block id in split and old mongo, add it to the optional
parent. The parent category is only needed because old mongo requires it for the id.
"""
location = Location('i4x', 'test_org', 'test_course', category, name)
self.old_mongo.create_and_save_xmodule(location, data, metadata, runtime)
if isinstance(data, basestring):
fields = {'data': data}
else:
fields = data.copy()
fields.update(metadata)
if parent_name:
# add child to parent in mongo
parent_location = Location('i4x', 'test_org', 'test_course', parent_category, parent_name)
parent = self.old_mongo.get_item(parent_location)
parent.children.append(location.url())
self.old_mongo.update_item(parent, self.userid)
# create pointer for split
course_or_parent_locator = BlockUsageLocator(
package_id=self.split_package_id,
branch='draft',
block_id=parent_name
)
else:
course_or_parent_locator = CourseLocator(
package_id='test_org.test_course.runid',
branch='draft',
)
self.split_mongo.create_item(course_or_parent_locator, category, self.userid, block_id=name, fields=fields)
def _create_course(self):
"""
* some detached items
* some attached children
* some orphans
"""
date_proxy = Date()
metadata = {
'start': date_proxy.to_json(datetime.datetime(2000, 3, 13, 4)),
'display_name': 'Migration test course',
}
data = {
'wiki_slug': 'test_course_slug'
}
fields = metadata.copy()
fields.update(data)
# split requires the course to be created separately from creating items
self.split_mongo.create_course(
self.split_package_id, 'test_org', self.userid, fields=fields, root_block_id='runid'
)
self.course_location = Location('i4x', 'test_org', 'test_course', 'course', 'runid')
self.old_mongo.create_and_save_xmodule(self.course_location, data, metadata)
runtime = self.old_mongo.get_item(self.course_location).runtime
super(TestOrphan, self)._create_course()
self._create_item('chapter', 'Chapter1', {}, {'display_name': 'Chapter 1'}, 'course', 'runid', runtime)
self._create_item('chapter', 'Chapter2', {}, {'display_name': 'Chapter 2'}, 'course', 'runid', runtime)
self._create_item('chapter', 'OrphanChapter', {}, {'display_name': 'Orphan Chapter'}, None, None, runtime)
self._create_item('vertical', 'Vert1', {}, {'display_name': 'Vertical 1'}, 'chapter', 'Chapter1', runtime)
self._create_item('vertical', 'OrphanVert', {}, {'display_name': 'Orphan Vertical'}, None, None, runtime)
self._create_item('html', 'Html1', "<p>Goodbye</p>", {'display_name': 'Parented Html'}, 'vertical', 'Vert1', runtime)
self._create_item('html', 'OrphanHtml', "<p>Hello</p>", {'display_name': 'Orphan html'}, None, None, runtime)
self._create_item('static_tab', 'staticuno', "<p>tab</p>", {'display_name': 'Tab uno'}, None, None, runtime)
self._create_item('about', 'overview', "<p>overview</p>", {}, None, None, runtime)
self._create_item('course_info', 'updates', "<ol><li><h2>Sep 22</h2><p>test</p></li></ol>", {}, None, None, runtime)
self._create_item('chapter', 'Chapter1', {}, {'display_name': 'Chapter 1'}, 'course', 'runid')
self._create_item('chapter', 'Chapter2', {}, {'display_name': 'Chapter 2'}, 'course', 'runid')
self._create_item('chapter', 'OrphanChapter', {}, {'display_name': 'Orphan Chapter'}, None, None)
self._create_item('vertical', 'Vert1', {}, {'display_name': 'Vertical 1'}, 'chapter', 'Chapter1')
self._create_item('vertical', 'OrphanVert', {}, {'display_name': 'Orphan Vertical'}, None, None)
self._create_item('html', 'Html1', "<p>Goodbye</p>", {'display_name': 'Parented Html'}, 'vertical', 'Vert1')
self._create_item('html', 'OrphanHtml', "<p>Hello</p>", {'display_name': 'Orphan html'}, None, None)
self._create_item('static_tab', 'staticuno', "<p>tab</p>", {'display_name': 'Tab uno'}, None, None)
self._create_item('about', 'overview', "<p>overview</p>", {}, None, None)
self._create_item('course_info', 'updates', "<ol><li><h2>Sep 22</h2><p>test</p></li></ol>", {}, None, None)
def test_mongo_orphan(self):
"""
Test that old mongo finds the orphans
"""
orphans = self.old_mongo.get_orphans(self.course_location, None)
orphans = self.old_mongo.get_orphans(self.old_course_key)
self.assertEqual(len(orphans), 3, "Wrong # {}".format(orphans))
location = self.course_location.replace(category='chapter', name='OrphanChapter')
self.assertIn(location.url(), orphans)
location = self.course_location.replace(category='vertical', name='OrphanVert')
self.assertIn(location.url(), orphans)
location = self.course_location.replace(category='html', name='OrphanHtml')
self.assertIn(location.url(), orphans)
location = self.old_course_key.make_usage_key('chapter', name='OrphanChapter')
self.assertIn(location.to_deprecated_string(), orphans)
location = self.old_course_key.make_usage_key('vertical', name='OrphanVert')
self.assertIn(location.to_deprecated_string(), orphans)
location = self.old_course_key.make_usage_key('html', 'OrphanHtml')
self.assertIn(location.to_deprecated_string(), orphans)
def test_split_orphan(self):
"""
Test that old mongo finds the orphans
Test that split mongo finds the orphans
"""
orphans = self.split_mongo.get_orphans(self.split_package_id, 'draft')
orphans = self.split_mongo.get_orphans(self.split_course_key)
self.assertEqual(len(orphans), 3, "Wrong # {}".format(orphans))
location = BlockUsageLocator(package_id=self.split_package_id, branch='draft', block_id='OrphanChapter')
location = self.split_course_key.make_usage_key('chapter', 'OrphanChapter')
self.assertIn(location, orphans)
location = BlockUsageLocator(package_id=self.split_package_id, branch='draft', block_id='OrphanVert')
location = self.split_course_key.make_usage_key('vertical', 'OrphanVert')
self.assertIn(location, orphans)
location = BlockUsageLocator(package_id=self.split_package_id, branch='draft', block_id='OrphanHtml')
location = self.split_course_key.make_usage_key('html', 'OrphanHtml')
self.assertIn(location, orphans)

View File

@@ -1,103 +1,26 @@
"""
Test the publish code (primary causing orphans)
Test the publish code (mostly testing that publishing doesn't result in orphans)
"""
import uuid
import mock
import unittest
import datetime
import random
from xmodule.modulestore.inheritance import InheritanceMixin
from xmodule.modulestore.mongo import MongoModuleStore, DraftMongoModuleStore
from xmodule.modulestore import Location
from xmodule.fields import Date
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.mongo.draft import DIRECT_ONLY_CATEGORIES
from xmodule.modulestore.tests.test_split_w_old_mongo import SplitWMongoCourseBoostrapper
class TestPublish(unittest.TestCase):
class TestPublish(SplitWMongoCourseBoostrapper):
"""
Test the publish code (primary causing orphans)
"""
# Snippet of what would be in the django settings envs file
db_config = {
'host': 'localhost',
'db': 'test_xmodule',
}
modulestore_options = {
'default_class': 'xmodule.raw_module.RawDescriptor',
'fs_root': '',
'render_template': mock.Mock(return_value=""),
'xblock_mixins': (InheritanceMixin,)
}
def setUp(self):
self.db_config['collection'] = 'modulestore{0}'.format(uuid.uuid4().hex[:5])
self.old_mongo = MongoModuleStore(self.db_config, **self.modulestore_options)
self.draft_mongo = DraftMongoModuleStore(self.db_config, **self.modulestore_options)
self.addCleanup(self.tear_down_mongo)
self.course_location = None
def tear_down_mongo(self):
# old_mongo doesn't give a db attr, but all of the dbs are the same and draft and pub use same collection
dbref = self.old_mongo.collection.database
dbref.drop_collection(self.old_mongo.collection)
dbref.connection.close()
def _create_item(self, category, name, data, metadata, parent_category, parent_name, runtime):
"""
Create the item in either draft or direct based on category and attach to its parent.
"""
location = self.course_location.replace(category=category, name=name)
if category in DIRECT_ONLY_CATEGORIES:
mongo = self.old_mongo
else:
mongo = self.draft_mongo
mongo.create_and_save_xmodule(location, data, metadata, runtime)
if isinstance(data, basestring):
fields = {'data': data}
else:
fields = data.copy()
fields.update(metadata)
if parent_name:
# add child to parent in mongo
parent_location = self.course_location.replace(category=parent_category, name=parent_name)
parent = self.draft_mongo.get_item(parent_location)
parent.children.append(location.url())
if parent_category in DIRECT_ONLY_CATEGORIES:
mongo = self.old_mongo
else:
mongo = self.draft_mongo
mongo.update_item(parent, '**replace_user**')
def _create_course(self):
"""
Create the course, publish all verticals
* some detached items
"""
date_proxy = Date()
metadata = {
'start': date_proxy.to_json(datetime.datetime(2000, 3, 13, 4)),
'display_name': 'Migration test course',
}
data = {
'wiki_slug': 'test_course_slug'
}
fields = metadata.copy()
fields.update(data)
super(TestPublish, self)._create_course(split=False)
self.course_location = Location('i4x', 'test_org', 'test_course', 'course', 'runid')
self.old_mongo.create_and_save_xmodule(self.course_location, data, metadata)
runtime = self.draft_mongo.get_item(self.course_location).runtime
self._create_item('chapter', 'Chapter1', {}, {'display_name': 'Chapter 1'}, 'course', 'runid', runtime)
self._create_item('chapter', 'Chapter2', {}, {'display_name': 'Chapter 2'}, 'course', 'runid', runtime)
self._create_item('vertical', 'Vert1', {}, {'display_name': 'Vertical 1'}, 'chapter', 'Chapter1', runtime)
self._create_item('vertical', 'Vert2', {}, {'display_name': 'Vertical 2'}, 'chapter', 'Chapter1', runtime)
self._create_item('html', 'Html1', "<p>Goodbye</p>", {'display_name': 'Parented Html'}, 'vertical', 'Vert1', runtime)
self._create_item('chapter', 'Chapter1', {}, {'display_name': 'Chapter 1'}, 'course', 'runid', split=False)
self._create_item('chapter', 'Chapter2', {}, {'display_name': 'Chapter 2'}, 'course', 'runid', split=False)
self._create_item('vertical', 'Vert1', {}, {'display_name': 'Vertical 1'}, 'chapter', 'Chapter1', split=False)
self._create_item('vertical', 'Vert2', {}, {'display_name': 'Vertical 2'}, 'chapter', 'Chapter1', split=False)
self._create_item('html', 'Html1', "<p>Goodbye</p>", {'display_name': 'Parented Html'}, 'vertical', 'Vert1', split=False)
self._create_item(
'discussion', 'Discussion1',
"discussion discussion_category=\"Lecture 1\" discussion_id=\"a08bfd89b2aa40fa81f2c650a9332846\" discussion_target=\"Lecture 1\"/>\n",
@@ -107,9 +30,10 @@ class TestPublish(unittest.TestCase):
"display_name": "Lecture 1 Discussion",
"discussion_id": "a08bfd89b2aa40fa81f2c650a9332846"
},
'vertical', 'Vert1', runtime
'vertical', 'Vert1',
split=False
)
self._create_item('html', 'Html2', "<p>Hellow</p>", {'display_name': 'Hollow Html'}, 'vertical', 'Vert1', runtime)
self._create_item('html', 'Html2', "<p>Hellow</p>", {'display_name': 'Hollow Html'}, 'vertical', 'Vert1', split=False)
self._create_item(
'discussion', 'Discussion2',
"discussion discussion_category=\"Lecture 2\" discussion_id=\"b08bfd89b2aa40fa81f2c650a9332846\" discussion_target=\"Lecture 2\"/>\n",
@@ -119,11 +43,12 @@ class TestPublish(unittest.TestCase):
"display_name": "Lecture 2 Discussion",
"discussion_id": "b08bfd89b2aa40fa81f2c650a9332846"
},
'vertical', 'Vert2', runtime
'vertical', 'Vert2',
split=False
)
self._create_item('static_tab', 'staticuno', "<p>tab</p>", {'display_name': 'Tab uno'}, None, None, runtime)
self._create_item('about', 'overview', "<p>overview</p>", {}, None, None, runtime)
self._create_item('course_info', 'updates', "<ol><li><h2>Sep 22</h2><p>test</p></li></ol>", {}, None, None, runtime)
self._create_item('static_tab', 'staticuno', "<p>tab</p>", {'display_name': 'Tab uno'}, None, None, split=False)
self._create_item('about', 'overview', "<p>overview</p>", {}, None, None, split=False)
self._create_item('course_info', 'updates', "<ol><li><h2>Sep 22</h2><p>test</p></li></ol>", {}, None, None, split=False)
def _xmodule_recurse(self, item, action):
"""
@@ -142,13 +67,11 @@ class TestPublish(unittest.TestCase):
To reproduce a bug (STUD-811) publish a vertical, convert to draft, delete a child, move a child, publish.
See if deleted and moved children still is connected or exists in db (bug was disconnected but existed)
"""
self._create_course()
userid = random.getrandbits(32)
location = self.course_location.replace(category='vertical', name='Vert1')
location = self.old_course_key.make_usage_key('vertical', name='Vert1')
item = self.draft_mongo.get_item(location, 2)
self._xmodule_recurse(
item,
lambda i: self.draft_mongo.publish(i.location, userid)
lambda i: self.draft_mongo.publish(i.location, self.userid)
)
# verify status
item = self.draft_mongo.get_item(location, 0)
@@ -164,26 +87,26 @@ class TestPublish(unittest.TestCase):
self.assertFalse(getattr(item, 'is_draft', False), "Published item doesn't say so")
# delete the discussion (which oddly is not in draft mode)
location = self.course_location.replace(category='discussion', name='Discussion1')
location = self.old_course_key.make_usage_key('discussion', name='Discussion1')
self.draft_mongo.delete_item(location)
# remove pointer from draft vertical (verify presence first to ensure process is valid)
self.assertIn(location.url(), draft_vert.children)
draft_vert.children.remove(location.url())
self.assertIn(location, draft_vert.children)
draft_vert.children.remove(location)
# move the other child
other_child_loc = self.course_location.replace(category='html', name='Html2')
draft_vert.children.remove(other_child_loc.url())
other_vert = self.draft_mongo.get_item(self.course_location.replace(category='vertical', name='Vert2'), 0)
other_vert.children.append(other_child_loc.url())
self.draft_mongo.update_item(draft_vert, '**replace_user**')
self.draft_mongo.update_item(other_vert, '**replace_user**')
other_child_loc = self.old_course_key.make_usage_key('html', name='Html2')
draft_vert.children.remove(other_child_loc)
other_vert = self.draft_mongo.get_item(self.old_course_key.make_usage_key('vertical', name='Vert2'), 0)
other_vert.children.append(other_child_loc)
self.draft_mongo.update_item(draft_vert, self.userid)
self.draft_mongo.update_item(other_vert, self.userid)
# publish
self._xmodule_recurse(
draft_vert,
lambda i: self.draft_mongo.publish(i.location, userid)
lambda i: self.draft_mongo.publish(i.location, self.userid)
)
item = self.old_mongo.get_item(draft_vert.location, 0)
self.assertNotIn(location.url(), item.children)
self.assertNotIn(location, item.children)
with self.assertRaises(ItemNotFoundError):
self.draft_mongo.get_item(location)
self.assertNotIn(other_child_loc.url(), item.children)
self.assertTrue(self.draft_mongo.has_item(None, other_child_loc), "Oops, lost moved item")
self.assertNotIn(other_child_loc, item.children)
self.assertTrue(self.draft_mongo.has_item(other_child_loc), "Oops, lost moved item")

View File

@@ -1,81 +1,37 @@
"""
Created on Sep 10, 2013
@author: dmitchell
Tests for split_migrator
"""
import unittest
import uuid
import random
import mock
import datetime
from xmodule.fields import Date
from xmodule.modulestore import Location
from xmodule.modulestore.inheritance import InheritanceMixin
from xmodule.modulestore.loc_mapper_store import LocMapperStore
from xmodule.modulestore.mongo.draft import DraftModuleStore
from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore
from xmodule.modulestore.mongo.base import MongoModuleStore
from xmodule.modulestore.split_migrator import SplitMigrator
from xmodule.modulestore.mongo import draft
from xmodule.modulestore.tests import test_location_mapper
from xmodule.modulestore.tests.test_split_w_old_mongo import SplitWMongoCourseBoostrapper
from nose.tools import nottest
class TestMigration(unittest.TestCase):
@nottest
class TestMigration(SplitWMongoCourseBoostrapper):
"""
Test the split migrator
"""
# Snippet of what would be in the django settings envs file
db_config = {
'host': 'localhost',
'db': 'test_xmodule',
'collection': 'modulestore{0}'.format(uuid.uuid4().hex[:5]),
}
modulestore_options = {
'default_class': 'xmodule.raw_module.RawDescriptor',
'fs_root': '',
'render_template': mock.Mock(return_value=""),
'xblock_mixins': (InheritanceMixin,)
}
def setUp(self):
super(TestMigration, self).setUp()
# pylint: disable=W0142
self.loc_mapper = LocMapperStore(test_location_mapper.TrivialCache(), **self.db_config)
self.old_mongo = MongoModuleStore(self.db_config, **self.modulestore_options)
self.draft_mongo = DraftModuleStore(self.db_config, **self.modulestore_options)
self.split_mongo = SplitMongoModuleStore(
doc_store_config=self.db_config,
loc_mapper=self.loc_mapper,
**self.modulestore_options
)
self.split_mongo.loc_mapper = self.loc_mapper
self.migrator = SplitMigrator(self.split_mongo, self.old_mongo, self.draft_mongo, self.loc_mapper)
self.course_location = None
self.create_source_course()
def tearDown(self):
dbref = self.loc_mapper.db
dbref.drop_collection(self.loc_mapper.location_map)
split_db = self.split_mongo.db
split_db.drop_collection(self.split_mongo.db_connection.course_index)
split_db.drop_collection(self.split_mongo.db_connection.structures)
split_db.drop_collection(self.split_mongo.db_connection.definitions)
# old_mongo doesn't give a db attr, but all of the dbs are the same
dbref.drop_collection(self.old_mongo.collection)
dbref.connection.close()
super(TestMigration, self).tearDown()
def _create_and_get_item(self, store, location, data, metadata, runtime=None):
store.create_and_save_xmodule(location, data, metadata, runtime)
return store.get_item(location)
def create_source_course(self):
def _create_course(self):
"""
A course testing all of the conversion mechanisms:
* some inheritable settings
@@ -83,150 +39,138 @@ class TestMigration(unittest.TestCase):
only the live ones get to published. Some are only draft, some are both, some are only live.
* about, static_tab, and conditional documents
"""
location = Location('i4x', 'test_org', 'test_course', 'course', 'runid')
self.course_location = location
date_proxy = Date()
metadata = {
'start': date_proxy.to_json(datetime.datetime(2000, 3, 13, 4)),
'display_name': 'Migration test course',
}
data = {
'wiki_slug': 'test_course_slug'
}
course_root = self._create_and_get_item(self.old_mongo, location, data, metadata)
runtime = course_root.runtime
super(TestMigration, self)._create_course(split=False)
# chapters
location = location.replace(category='chapter', name=uuid.uuid4().hex)
chapter1 = self._create_and_get_item(self.old_mongo, location, {}, {'display_name': 'Chapter 1'}, runtime)
course_root.children.append(chapter1.location.url())
location = location.replace(category='chapter', name=uuid.uuid4().hex)
chapter2 = self._create_and_get_item(self.old_mongo, location, {}, {'display_name': 'Chapter 2'}, runtime)
course_root.children.append(chapter2.location.url())
self.old_mongo.update_item(course_root, '**replace_user**')
chapter1_name = uuid.uuid4().hex
self._create_item('chapter', chapter1_name, {}, {'display_name': 'Chapter 1'}, 'course', 'runid', split=False)
chap2_loc = self.old_course_key.make_usage_key('chapter', uuid.uuid4().hex)
self._create_item(
chap2_loc.category, chap2_loc.name, {}, {'display_name': 'Chapter 2'}, 'course', 'runid', split=False
)
# vertical in live only
location = location.replace(category='vertical', name=uuid.uuid4().hex)
live_vert = self._create_and_get_item(self.old_mongo, location, {}, {'display_name': 'Live vertical'}, runtime)
chapter1.children.append(live_vert.location.url())
self.create_random_units(self.old_mongo, live_vert)
live_vert_name = uuid.uuid4().hex
self._create_item(
'vertical', live_vert_name, {}, {'display_name': 'Live vertical'}, 'chapter', chapter1_name,
draft=False, split=False
)
self.create_random_units(False, self.old_course_key.make_usage_key('vertical', live_vert_name))
# vertical in both live and draft
location = location.replace(category='vertical', name=uuid.uuid4().hex)
both_vert = self._create_and_get_item(
self.old_mongo, location, {}, {'display_name': 'Both vertical'}, runtime
both_vert_loc = self.old_course_key.make_usage_key('vertical', uuid.uuid4().hex)
self._create_item(
both_vert_loc.category, both_vert_loc.name, {}, {'display_name': 'Both vertical'}, 'chapter', chapter1_name,
draft=False, split=False
)
draft_both = self._create_and_get_item(
self.draft_mongo, location, {}, {'display_name': 'Both vertical renamed'}, runtime
)
chapter1.children.append(both_vert.location.url())
self.create_random_units(self.old_mongo, both_vert, self.draft_mongo, draft_both)
self.create_random_units(False, both_vert_loc)
draft_both = self.draft_mongo.get_item(both_vert_loc)
draft_both.display_name = 'Both vertical renamed'
self.draft_mongo.update_item(draft_both)
self.create_random_units(True, both_vert_loc)
# vertical in draft only (x2)
location = location.replace(category='vertical', name=uuid.uuid4().hex)
draft_vert = self._create_and_get_item(
self.draft_mongo,
location, {}, {'display_name': 'Draft vertical'}, runtime)
chapter1.children.append(draft_vert.location.url())
self.create_random_units(self.draft_mongo, draft_vert)
location = location.replace(category='vertical', name=uuid.uuid4().hex)
draft_vert = self._create_and_get_item(
self.draft_mongo,
location, {}, {'display_name': 'Draft vertical2'}, runtime)
chapter1.children.append(draft_vert.location.url())
self.create_random_units(self.draft_mongo, draft_vert)
# and finally one in live only (so published has to skip 2)
location = location.replace(category='vertical', name=uuid.uuid4().hex)
live_vert = self._create_and_get_item(
self.old_mongo,
location, {}, {'display_name': 'Live vertical end'}, runtime)
chapter1.children.append(live_vert.location.url())
self.create_random_units(self.old_mongo, live_vert)
# update the chapter
self.old_mongo.update_item(chapter1, '**replace_user**')
# now the other one w/ the conditional
# first create some show children
indirect1 = self._create_and_get_item(
self.old_mongo,
location.replace(category='discussion', name=uuid.uuid4().hex),
"", {'display_name': 'conditional show 1'}, runtime
draft_vert_loc = self.old_course_key.make_usage_key('vertical', uuid.uuid4().hex)
self._create_item(
draft_vert_loc.category, draft_vert_loc.name, {}, {'display_name': 'Draft vertical'}, 'chapter', chapter1_name,
draft=True, split=False
)
indirect2 = self._create_and_get_item(
self.old_mongo,
location.replace(category='html', name=uuid.uuid4().hex),
"", {'display_name': 'conditional show 2'}, runtime
self.create_random_units(True, draft_vert_loc)
draft_vert_loc = self.old_course_key.make_usage_key('vertical', uuid.uuid4().hex)
self._create_item(
draft_vert_loc.category, draft_vert_loc.name, {}, {'display_name': 'Draft vertical2'}, 'chapter', chapter1_name,
draft=True, split=False
)
location = location.replace(category='conditional', name=uuid.uuid4().hex)
metadata = {
'xml_attributes': {
'sources': [live_vert.location.url(), ],
'completed': True,
self.create_random_units(True, draft_vert_loc)
# and finally one in live only (so published has to skip 2 preceding sibs)
live_vert_loc = self.old_course_key.make_usage_key('vertical', uuid.uuid4().hex)
self._create_item(
live_vert_loc.category, live_vert_loc.name, {}, {'display_name': 'Live vertical end'}, 'chapter', chapter1_name,
draft=False, split=False
)
self.create_random_units(True, draft_vert_loc)
# now the other chapter w/ the conditional
# create pointers to children (before the children exist)
indirect1_loc = self.old_course_key.make_usage_key('discussion', uuid.uuid4().hex)
indirect2_loc = self.old_course_key.make_usage_key('html', uuid.uuid4().hex)
conditional_loc = self.old_course_key.make_usage_key('conditional', uuid.uuid4().hex)
self._create_item(
conditional_loc.category, conditional_loc.name,
{
'show_tag_list': [indirect1_loc, indirect2_loc],
'sources_list': [live_vert_loc, ],
},
}
data = {
'show_tag_list': [indirect1.location.url(), indirect2.location.url()]
}
conditional = self._create_and_get_item(self.old_mongo, location, data, metadata, runtime)
conditional.children = [indirect1.location.url(), indirect2.location.url()]
{
'xml_attributes': {
'completed': True,
},
},
chap2_loc.category, chap2_loc.name,
draft=False, split=False
)
# create the children
self._create_item(
indirect1_loc.category, indirect1_loc.name, {'data': ""}, {'display_name': 'conditional show 1'},
conditional_loc.category, conditional_loc.name,
draft=False, split=False
)
self._create_item(
indirect2_loc.category, indirect2_loc.name, {'data': ""}, {'display_name': 'conditional show 2'},
conditional_loc.category, conditional_loc.name,
draft=False, split=False
)
# add direct children
self.create_random_units(self.old_mongo, conditional)
chapter2.children.append(conditional.location.url())
self.old_mongo.update_item(chapter2, '**replace_user**')
self.create_random_units(False, conditional_loc)
# and the ancillary docs (not children)
location = location.replace(category='static_tab', name=uuid.uuid4().hex)
# the below automatically adds the tab to the course
_tab = self._create_and_get_item(self.old_mongo, location, "", {'display_name': 'Tab uno'}, runtime)
location = location.replace(category='about', name='overview')
_overview = self._create_and_get_item(self.old_mongo, location, "<p>test</p>", {}, runtime)
location = location.replace(category='course_info', name='updates')
_overview = self._create_and_get_item(
self.old_mongo,
location, "<ol><li><h2>Sep 22</h2><p>test</p></li></ol>", {}, runtime
self._create_item(
'static_tab', uuid.uuid4().hex, {'data': ""}, {'display_name': 'Tab uno'},
None, None, draft=False, split=False
)
self._create_item(
'about', 'overview', {'data': "<p>test</p>"}, {},
None, None, draft=False, split=False
)
self._create_item(
'course_info', 'updates', {'data': "<ol><li><h2>Sep 22</h2><p>test</p></li></ol>"}, {},
None, None, draft=False, split=False
)
def create_random_units(self, store, parent, cc_store=None, cc_parent=None):
def create_random_units(self, draft, parent_loc):
"""
Create a random selection of units under the given parent w/ random names & attrs
:param store: which store (e.g., direct/draft) to create them in
:param parent: the parent to have point to them
:param cc_store: (optional) if given, make a small change and save also to this store but w/ same location
(only makes sense if store is 'direct' and this is 'draft' or vice versa)
"""
for _ in range(random.randrange(6)):
location = parent.location.replace(
location = parent_loc.replace(
category=random.choice(['html', 'video', 'problem', 'discussion']),
name=uuid.uuid4().hex
)
metadata = {'display_name': str(uuid.uuid4()), 'graded': True}
data = {}
element = self._create_and_get_item(store, location, data, metadata, parent.runtime)
parent.children.append(element.location.url())
if cc_store is not None:
# change display_name and remove graded to test the delta
element = self._create_and_get_item(
cc_store, location, data, {'display_name': str(uuid.uuid4())}, parent.runtime
)
cc_parent.children.append(element.location.url())
store.update_item(parent, '**replace_user**')
if cc_store is not None:
cc_store.update_item(cc_parent, '**replace_user**')
self._create_item(
location.category, location.name, data, metadata, parent_loc.category, parent_loc.name,
draft=draft, split=False
)
def compare_courses(self, presplit, published):
# descend via children to do comparison
old_root = presplit.get_item(self.course_location, depth=None)
new_root_locator = self.loc_mapper.translate_location(
self.course_location.course_id, self.course_location, published, add_entry_if_missing=False
old_root = presplit.get_course(self.old_course_key)
new_root_locator = self.loc_mapper.translate_location_to_course_locator(
old_root.id, published
)
new_root = self.split_mongo.get_course(new_root_locator)
self.compare_dags(presplit, old_root, new_root, published)
# grab the detached items to compare they should be in both published and draft
for category in ['conditional', 'about', 'course_info', 'static_tab']:
location = self.course_location.replace(name=None, category=category)
for conditional in presplit.get_items(location):
for conditional in presplit.get_items(self.old_course_key, category=category):
locator = self.loc_mapper.translate_location(
self.course_location.course_id,
conditional.location, published, add_entry_if_missing=False
conditional.location,
published,
add_entry_if_missing=False
)
self.compare_dags(presplit, conditional, self.split_mongo.get_item(locator), published)
@@ -262,9 +206,10 @@ class TestMigration(unittest.TestCase):
# compare children
if presplit_dag_root.has_children:
self.assertEqual(
len(presplit_dag_root.get_children()), len(split_dag_root.get_children()),
"{0.category} '{0.display_name}': children count {1} != {2}".format(
presplit_dag_root, len(presplit_dag_root.get_children()), split_dag_root.children
# need get_children to filter out drafts
len(presplit_dag_root.get_children()), len(split_dag_root.children),
"{0.category} '{0.display_name}': children {1} != {2}".format(
presplit_dag_root, presplit_dag_root.children, split_dag_root.children
)
)
for pre_child, split_child in zip(presplit_dag_root.get_children(), split_dag_root.get_children()):
@@ -272,7 +217,7 @@ class TestMigration(unittest.TestCase):
def test_migrator(self):
user = mock.Mock(id=1)
self.migrator.migrate_mongo_course(self.course_location, user)
self.migrator.migrate_mongo_course(self.old_course_key, user)
# now compare the migrated to the original course
self.compare_courses(self.old_mongo, True)
self.compare_courses(self.draft_mongo, False)

View File

@@ -17,8 +17,8 @@ from xmodule.modulestore.locator import CourseLocator, BlockUsageLocator, Versio
from xmodule.modulestore.inheritance import InheritanceMixin
from xmodule.x_module import XModuleMixin
from xmodule.fields import Date, Timedelta
from bson.objectid import ObjectId
from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore
from xmodule.modulestore.tests.test_modulestore import check_has_course_method
class SplitModuleTest(unittest.TestCase):
@@ -56,6 +56,7 @@ class SplitModuleTest(unittest.TestCase):
COURSE_CONTENT = {
"testx.GreekHero": {
"org": "testx",
"offering": "GreekHero",
"root_block_id": "head12345",
"user_id": "test@edx.org",
"fields": {
@@ -185,7 +186,7 @@ class SplitModuleTest(unittest.TestCase):
}}
},
{"user_id": "testassist@edx.org",
"update":
"update":
{"head12345": {
"end": _date_field.from_json("2013-06-13T04:30"),
"grading_policy": {
@@ -272,9 +273,10 @@ class SplitModuleTest(unittest.TestCase):
]
},
]
},
},
"testx.wonderful": {
"org": "testx",
"offering": "wonderful",
"root_block_id": "head23456",
"user_id": "test@edx.org",
"fields": {
@@ -377,9 +379,10 @@ class SplitModuleTest(unittest.TestCase):
}
}
]
},
},
"guestx.contender": {
"org": "guestx",
"offering": "contender",
"root_block_id": "head345679",
"user_id": "test@guestx.edu",
"fields": {
@@ -441,7 +444,7 @@ class SplitModuleTest(unittest.TestCase):
split_store = modulestore()
for course_id, course_spec in SplitModuleTest.COURSE_CONTENT.iteritems():
course = split_store.create_course(
course_id, course_spec['org'], course_spec['user_id'],
course_spec['org'], course_spec['offering'], course_spec['user_id'],
fields=course_spec['fields'],
root_block_id=course_spec['root_block_id']
)
@@ -452,7 +455,7 @@ class SplitModuleTest(unittest.TestCase):
block = course
else:
block_usage = BlockUsageLocator.make_relative(course.location, block_id)
block = split_store.get_instance(course.location.package_id, block_usage)
block = split_store.get_item(block_usage)
for key, value in fields.iteritems():
setattr(block, key, value)
# create new blocks into dag: parent must already exist; thus, order is important
@@ -464,7 +467,7 @@ class SplitModuleTest(unittest.TestCase):
parent = course
else:
block_usage = BlockUsageLocator.make_relative(course.location, spec['parent'])
parent = split_store.get_instance(course.location.package_id, block_usage)
parent = split_store.get_item(block_usage)
block_id = LocalId(spec['id'])
child = split_store.create_xblock(
course.runtime, spec['category'], spec['fields'], block_id, parent_xblock=parent
@@ -472,8 +475,11 @@ class SplitModuleTest(unittest.TestCase):
new_ele_dict[spec['id']] = child
course = split_store.persist_xblock_dag(course, revision['user_id'])
# publish "testx.wonderful"
to_publish = BlockUsageLocator(package_id="testx.wonderful", branch="draft", block_id="head23456")
destination = CourseLocator(package_id="testx.wonderful", branch="published")
to_publish = BlockUsageLocator(
CourseLocator(org="testx", offering="wonderful", branch="draft"),
block_id="head23456"
)
destination = CourseLocator(org="testx", offering="wonderful", branch="published")
split_store.xblock_publish("test@edx.org", to_publish, destination, [to_publish.block_id], None)
def tearDown(self):
@@ -509,7 +515,7 @@ class SplitModuleCourseTests(SplitModuleTest):
self.assertEqual(len(courses), 3, "Wrong number of courses")
# check metadata -- NOTE no promised order
course = self.findByIdInResult(courses, "head12345")
self.assertEqual(course.location.package_id, "testx.GreekHero")
self.assertEqual(course.location.org, "testx")
self.assertEqual(course.category, 'course', 'wrong category')
self.assertEqual(len(course.tabs), 6, "wrong number of tabs")
self.assertEqual(
@@ -532,7 +538,8 @@ class SplitModuleCourseTests(SplitModuleTest):
self.assertEqual(len(courses_published), 1, len(courses_published))
course = self.findByIdInResult(courses_published, "head23456")
self.assertIsNotNone(course, "published courses")
self.assertEqual(course.location.package_id, "testx.wonderful")
self.assertEqual(course.location.course_key.org, "testx")
self.assertEqual(course.location.course_key.offering, "wonderful")
self.assertEqual(course.category, 'course', 'wrong category')
self.assertEqual(len(course.tabs), 4, "wrong number of tabs")
self.assertEqual(course.display_name, "The most wonderful course",
@@ -550,16 +557,27 @@ class SplitModuleCourseTests(SplitModuleTest):
self.assertIsNotNone(self.findByIdInResult(courses, "head12345"))
self.assertIsNotNone(self.findByIdInResult(courses, "head23456"))
def test_has_course(self):
'''
Test the various calling forms for has_course
'''
check_has_course_method(
modulestore(),
CourseLocator(org='testx', offering='wonderful', branch="draft"),
locator_key_fields=['org', 'offering']
)
def test_get_course(self):
'''
Test the various calling forms for get_course
'''
locator = CourseLocator(package_id="testx.GreekHero", branch="draft")
locator = CourseLocator(org='testx', offering='GreekHero', branch="draft")
head_course = modulestore().get_course(locator)
self.assertNotEqual(head_course.location.version_guid, head_course.previous_version)
locator = CourseLocator(version_guid=head_course.previous_version)
course = modulestore().get_course(locator)
self.assertIsNone(course.location.package_id)
self.assertIsNone(course.location.course_key.org)
self.assertEqual(course.location.version_guid, head_course.previous_version)
self.assertEqual(course.category, 'course')
self.assertEqual(len(course.tabs), 6)
@@ -572,9 +590,10 @@ class SplitModuleCourseTests(SplitModuleTest):
self.assertEqual(course.edited_by, "testassist@edx.org")
self.assertDictEqual(course.grade_cutoffs, {"Pass": 0.55})
locator = CourseLocator(package_id='testx.GreekHero', branch='draft')
locator = CourseLocator(org='testx', offering='GreekHero', branch='draft')
course = modulestore().get_course(locator)
self.assertEqual(course.location.package_id, "testx.GreekHero")
self.assertEqual(course.location.course_key.org, "testx")
self.assertEqual(course.location.course_key.offering, "GreekHero")
self.assertEqual(course.category, 'course')
self.assertEqual(len(course.tabs), 6)
self.assertEqual(course.display_name, "The Ancient Greek Hero")
@@ -584,29 +603,28 @@ class SplitModuleCourseTests(SplitModuleTest):
self.assertEqual(course.edited_by, "testassist@edx.org")
self.assertDictEqual(course.grade_cutoffs, {"Pass": 0.45})
locator = CourseLocator(package_id='testx.wonderful', branch='published')
locator = CourseLocator(org='testx', offering='wonderful', branch='published')
course = modulestore().get_course(locator)
published_version = course.location.version_guid
locator = CourseLocator(package_id='testx.wonderful', branch='draft')
locator = CourseLocator(org='testx', offering='wonderful', branch='draft')
course = modulestore().get_course(locator)
self.assertNotEqual(course.location.version_guid, published_version)
def test_get_course_negative(self):
# Now negative testing
self.assertRaises(InsufficientSpecificationError,
modulestore().get_course, CourseLocator(package_id='edu.meh.blah'))
self.assertRaises(ItemNotFoundError,
modulestore().get_course, CourseLocator(package_id='nosuchthing', branch='draft'))
self.assertRaises(ItemNotFoundError,
modulestore().get_course,
CourseLocator(package_id='testx.GreekHero', branch='published'))
with self.assertRaises(InsufficientSpecificationError):
modulestore().get_course(CourseLocator(org='edu', offering='meh.blah'))
with self.assertRaises(ItemNotFoundError):
modulestore().get_course(CourseLocator(org='edu', offering='nosuchthing', branch='draft'))
with self.assertRaises(ItemNotFoundError):
modulestore().get_course(CourseLocator(org='testx', offering='GreekHero', branch='published'))
def test_cache(self):
"""
Test that the mechanics of caching work.
"""
locator = CourseLocator(package_id='testx.GreekHero', branch='draft')
locator = CourseLocator(org='testx', offering='GreekHero', branch='draft')
course = modulestore().get_course(locator)
block_map = modulestore().cache_items(course.system, course.children, depth=3)
self.assertIn('chapter1', block_map)
@@ -616,7 +634,7 @@ class SplitModuleCourseTests(SplitModuleTest):
"""
get_course_successors(course_locator, version_history_depth=1)
"""
locator = CourseLocator(package_id='testx.GreekHero', branch='draft')
locator = CourseLocator(org='testx', offering='GreekHero', branch='draft')
course = modulestore().get_course(locator)
versions = [course.location.version_guid, course.previous_version]
locator = CourseLocator(version_guid=course.previous_version)
@@ -626,7 +644,7 @@ class SplitModuleCourseTests(SplitModuleTest):
locator = CourseLocator(version_guid=course.previous_version)
result = modulestore().get_course_successors(locator)
self.assertIsInstance(result, VersionTree)
self.assertIsNone(result.locator.package_id)
self.assertIsNone(result.locator.org)
self.assertEqual(result.locator.version_guid, versions[-1])
self.assertEqual(len(result.children), 1)
self.assertEqual(result.children[0].locator.version_guid, versions[-2])
@@ -652,87 +670,85 @@ class SplitModuleItemTests(SplitModuleTest):
'''
has_item(BlockUsageLocator)
'''
package_id = 'testx.GreekHero'
locator = CourseLocator(package_id=package_id, branch='draft')
course = modulestore().get_course(locator)
org = 'testx'
offering = 'GreekHero'
course_locator = CourseLocator(org=org, offering=offering, branch='draft')
course = modulestore().get_course(course_locator)
previous_version = course.previous_version
# positive tests of various forms
locator = BlockUsageLocator(version_guid=previous_version, block_id='head12345')
locator = BlockUsageLocator(CourseLocator(version_guid=previous_version), block_id='head12345')
self.assertTrue(
modulestore().has_item(package_id, locator), "couldn't find in %s" % previous_version
modulestore().has_item(locator), "couldn't find in %s" % previous_version
)
locator = BlockUsageLocator(package_id='testx.GreekHero', block_id='head12345', branch='draft')
locator = BlockUsageLocator(course_locator, block_id='head12345')
self.assertTrue(
modulestore().has_item(locator.package_id, locator),
modulestore().has_item(locator),
)
self.assertFalse(
modulestore().has_item(locator.package_id, BlockUsageLocator(
package_id=locator.package_id,
branch='published',
block_id=locator.block_id)),
modulestore().has_item(
BlockUsageLocator(
locator.course_key.for_branch('published'),
block_id=locator.block_id
)
),
"found in published head"
)
# not a course obj
locator = BlockUsageLocator(package_id='testx.GreekHero', block_id='chapter1', branch='draft')
locator = BlockUsageLocator(course_locator, block_id='chapter1')
self.assertTrue(
modulestore().has_item(locator.package_id, locator),
modulestore().has_item(locator),
"couldn't find chapter1"
)
# in published course
locator = BlockUsageLocator(package_id="testx.wonderful", block_id="head23456", branch='draft')
locator = BlockUsageLocator(
CourseLocator(org="testx", offering="wonderful", branch='draft'),
block_id="head23456"
)
self.assertTrue(
modulestore().has_item(
locator.package_id,
BlockUsageLocator(package_id=locator.package_id, block_id=locator.block_id, branch='published')
BlockUsageLocator(locator.course_key.for_branch("published"), block_id=locator.block_id)
)
)
locator.branch = 'published'
self.assertTrue(modulestore().has_item(package_id, locator), "couldn't find in published")
locator = locator.for_branch('published')
self.assertTrue(modulestore().has_item(locator), "couldn't find in published")
def test_negative_has_item(self):
# negative tests--not found
# no such course or block
package_id = 'testx.GreekHero'
locator = BlockUsageLocator(package_id="doesnotexist", block_id="head23456", branch='draft')
self.assertFalse(modulestore().has_item(package_id, locator))
locator = BlockUsageLocator(package_id="testx.wonderful", block_id="doesnotexist", branch='draft')
self.assertFalse(modulestore().has_item(package_id, locator))
# negative tests--insufficient specification
self.assertRaises(InsufficientSpecificationError, BlockUsageLocator)
locator = CourseLocator(package_id=package_id, branch='draft')
course = modulestore().get_course(locator)
previous_version = course.previous_version
with self.assertRaises(InsufficientSpecificationError):
modulestore().has_item(None, BlockUsageLocator(version_guid=previous_version))
with self.assertRaises(InsufficientSpecificationError):
modulestore().has_item(None, BlockUsageLocator(package_id='testx.GreekHero'))
locator = BlockUsageLocator(
CourseLocator(org="foo", offering="doesnotexist", branch='draft'),
block_id="head23456"
)
self.assertFalse(modulestore().has_item(locator))
locator = BlockUsageLocator(
CourseLocator(org="testx", offering="wonderful", branch='draft'),
block_id="doesnotexist"
)
self.assertFalse(modulestore().has_item(locator))
def test_get_item(self):
'''
get_item(blocklocator)
'''
locator = CourseLocator(package_id="testx.GreekHero", branch='draft')
course = modulestore().get_course(locator)
hero_locator = CourseLocator(org="testx", offering="GreekHero", branch='draft')
course = modulestore().get_course(hero_locator)
previous_version = course.previous_version
# positive tests of various forms
locator = BlockUsageLocator(version_guid=previous_version, block_id='head12345')
locator = BlockUsageLocator(CourseLocator(version_guid=previous_version), block_id='head12345')
block = modulestore().get_item(locator)
self.assertIsInstance(block, CourseDescriptor)
# get_instance just redirects to get_item, ignores package_id
self.assertIsInstance(modulestore().get_instance("package_id", locator), CourseDescriptor)
self.assertIsInstance(modulestore().get_item(locator), CourseDescriptor)
def verify_greek_hero(block):
"""
Check contents of block
"""
self.assertEqual(block.location.package_id, "testx.GreekHero")
self.assertEqual(block.location.org, "testx")
self.assertEqual(block.location.offering, "GreekHero")
self.assertEqual(len(block.tabs), 6, "wrong number of tabs")
self.assertEqual(block.display_name, "The Ancient Greek Hero")
self.assertEqual(block.advertised_start, "Fall 2013")
@@ -743,18 +759,17 @@ class SplitModuleItemTests(SplitModuleTest):
block.grade_cutoffs, {"Pass": 0.45},
)
locator = BlockUsageLocator(package_id='testx.GreekHero', block_id='head12345', branch='draft')
locator = BlockUsageLocator(hero_locator, block_id='head12345')
verify_greek_hero(modulestore().get_item(locator))
# get_instance just redirects to get_item, ignores package_id
verify_greek_hero(modulestore().get_instance("package_id", locator))
# try to look up other branches
self.assertRaises(ItemNotFoundError,
modulestore().get_item,
BlockUsageLocator(package_id=locator.as_course_locator(),
block_id=locator.block_id,
branch='published'))
locator.branch = 'draft'
with self.assertRaises(ItemNotFoundError):
modulestore().get_item(
BlockUsageLocator(
hero_locator.for_branch("published"),
block_id=locator.block_id,
)
)
self.assertIsInstance(
modulestore().get_item(locator),
CourseDescriptor
@@ -762,15 +777,19 @@ class SplitModuleItemTests(SplitModuleTest):
def test_get_non_root(self):
# not a course obj
locator = BlockUsageLocator(package_id='testx.GreekHero', block_id='chapter1', branch='draft')
locator = BlockUsageLocator(
CourseLocator(org='testx', offering='GreekHero', branch='draft'), 'chapter1'
)
block = modulestore().get_item(locator)
self.assertEqual(block.location.package_id, "testx.GreekHero")
self.assertEqual(block.location.package_id, "testx+GreekHero")
self.assertEqual(block.category, 'chapter')
self.assertEqual(block.display_name, "Hercules")
self.assertEqual(block.edited_by, "testassist@edx.org")
# in published course
locator = BlockUsageLocator(package_id="testx.wonderful", block_id="head23456", branch='published')
locator = BlockUsageLocator(
CourseLocator(org='testx', offering='wonderful', branch='published'), 'head23456'
)
self.assertIsInstance(
modulestore().get_item(locator),
CourseDescriptor
@@ -778,19 +797,17 @@ class SplitModuleItemTests(SplitModuleTest):
# negative tests--not found
# no such course or block
locator = BlockUsageLocator(package_id="doesnotexist", block_id="head23456", branch='draft')
locator = BlockUsageLocator(
CourseLocator(org='doesnotexist', offering='doesnotexist', branch='draft'), 'head23456'
)
with self.assertRaises(ItemNotFoundError):
modulestore().get_item(locator)
locator = BlockUsageLocator(package_id="testx.wonderful", block_id="doesnotexist", branch='draft')
locator = BlockUsageLocator(
CourseLocator(org='testx', offering='wonderful', branch='draft'), 'doesnotexist'
)
with self.assertRaises(ItemNotFoundError):
modulestore().get_item(locator)
# negative tests--insufficient specification
with self.assertRaises(InsufficientSpecificationError):
modulestore().get_item(BlockUsageLocator(version_guid=ObjectId()))
with self.assertRaises(InsufficientSpecificationError):
modulestore().get_item(BlockUsageLocator(package_id='testx.GreekHero', branch='draft'))
# pylint: disable=W0212
def test_matching(self):
'''
@@ -800,66 +817,65 @@ class SplitModuleItemTests(SplitModuleTest):
self.assertFalse(modulestore()._value_matches('help', 'Help'))
self.assertTrue(modulestore()._value_matches(['distract', 'help', 'notme'], 'help'))
self.assertFalse(modulestore()._value_matches(['distract', 'Help', 'notme'], 'help'))
self.assertFalse(modulestore()._value_matches({'field': ['distract', 'Help', 'notme']}, {'field': 'help'}))
self.assertFalse(modulestore()._value_matches(['distract', 'Help', 'notme'], {'field': 'help'}))
self.assertTrue(modulestore()._value_matches(
self.assertFalse(modulestore()._block_matches({'field': ['distract', 'Help', 'notme']}, {'field': 'help'}))
self.assertTrue(modulestore()._block_matches(
{'field': ['distract', 'help', 'notme'],
'irrelevant': 2},
{'field': 'help'}))
self.assertTrue(modulestore()._value_matches('I need some help', {'$regex': 'help'}))
self.assertTrue(modulestore()._value_matches(['I need some help', 'today'], {'$regex': 'help'}))
self.assertFalse(modulestore()._value_matches('I need some help', {'$regex': 'Help'}))
self.assertFalse(modulestore()._value_matches(['I need some help', 'today'], {'$regex': 'Help'}))
self.assertTrue(modulestore()._value_matches('I need some help', re.compile(r'help')))
self.assertTrue(modulestore()._value_matches(['I need some help', 'today'], re.compile(r'help')))
self.assertFalse(modulestore()._value_matches('I need some help', re.compile(r'Help')))
self.assertTrue(modulestore()._value_matches(['I need some help', 'today'], re.compile(r'Help', re.IGNORECASE)))
self.assertTrue(modulestore()._block_matches({'a': 1, 'b': 2}, {'a': 1}))
self.assertTrue(modulestore()._block_matches({'a': 1, 'b': 2}, {'c': None}))
self.assertTrue(modulestore()._block_matches({'a': 1, 'b': 2}, {'a': 1, 'c': None}))
self.assertFalse(modulestore()._block_matches({'a': 1, 'b': 2}, {'a': 2}))
self.assertFalse(modulestore()._block_matches({'a': 1, 'b': 2}, {'c': 1}))
self.assertFalse(modulestore()._block_matches({'a': 1, 'b': 2}, {'a': 1, 'c': 1}))
self.assertTrue(modulestore()._block_matches({'a': 1, 'b': 2}, {'a': lambda i: 0 < i < 2}))
def test_get_items(self):
'''
get_items(locator, qualifiers, [branch])
'''
locator = CourseLocator(package_id="testx.GreekHero", branch='draft')
locator = CourseLocator(org='testx', offering='GreekHero', branch='draft')
# get all modules
matches = modulestore().get_items(locator)
self.assertEqual(len(matches), 6)
matches = modulestore().get_items(locator, qualifiers={})
matches = modulestore().get_items(locator)
self.assertEqual(len(matches), 6)
matches = modulestore().get_items(locator, qualifiers={'category': 'chapter'})
matches = modulestore().get_items(locator, category='chapter')
self.assertEqual(len(matches), 3)
matches = modulestore().get_items(locator, qualifiers={'category': 'garbage'})
matches = modulestore().get_items(locator, category='garbage')
self.assertEqual(len(matches), 0)
matches = modulestore().get_items(
locator,
qualifiers=
{
'category': 'chapter',
'fields': {'display_name': {'$regex': 'Hera'}}
}
category='chapter',
settings={'display_name': re.compile(r'Hera')},
)
self.assertEqual(len(matches), 2)
matches = modulestore().get_items(locator, qualifiers={'fields': {'children': 'chapter2'}})
matches = modulestore().get_items(locator, children='chapter2')
self.assertEqual(len(matches), 1)
self.assertEqual(matches[0].location.block_id, 'head12345')
def test_get_parents(self):
'''
get_parent_locations(locator, [block_id], [branch]): [BlockUsageLocator]
get_parent_locations(locator): [BlockUsageLocator]
'''
locator = BlockUsageLocator(package_id="testx.GreekHero", branch='draft', block_id='chapter1')
locator = BlockUsageLocator(
CourseLocator(org='testx', offering='GreekHero', branch='draft'),
block_id='chapter1'
)
parents = modulestore().get_parent_locations(locator)
self.assertEqual(len(parents), 1)
self.assertEqual(parents[0].block_id, 'head12345')
self.assertEqual(parents[0].package_id, "testx.GreekHero")
locator.block_id = 'chapter2'
self.assertEqual(parents[0].org, "testx")
self.assertEqual(parents[0].offering, "GreekHero")
locator = locator.course_key.make_usage_key('Chapter', 'chapter2')
parents = modulestore().get_parent_locations(locator)
self.assertEqual(len(parents), 1)
self.assertEqual(parents[0].block_id, 'head12345')
locator.block_id = 'nosuchblock'
locator = locator.course_key.make_usage_key('garbage', 'nosuchblock')
parents = modulestore().get_parent_locations(locator)
self.assertEqual(len(parents), 0)
@@ -867,7 +883,9 @@ class SplitModuleItemTests(SplitModuleTest):
"""
Test the existing get_children method on xdescriptors
"""
locator = BlockUsageLocator(package_id="testx.GreekHero", block_id="head12345", branch='draft')
locator = BlockUsageLocator(
CourseLocator(org='testx', offering='GreekHero', branch='draft'), 'head12345'
)
block = modulestore().get_item(locator)
children = block.get_children()
expected_ids = [
@@ -909,7 +927,7 @@ class TestItemCrud(SplitModuleTest):
create_item(course_or_parent_locator, category, user, definition_locator=None, fields): new_desciptor
"""
# grab link to course to ensure new versioning works
locator = CourseLocator(package_id="testx.GreekHero", branch='draft')
locator = CourseLocator(org='testx', offering='GreekHero', branch='draft')
premod_course = modulestore().get_course(locator)
premod_history = modulestore().get_course_history_info(premod_course.location)
# add minimal one w/o a parent
@@ -919,7 +937,7 @@ class TestItemCrud(SplitModuleTest):
fields={'display_name': 'new sequential'}
)
# check that course version changed and course's previous is the other one
self.assertEqual(new_module.location.package_id, "testx.GreekHero")
self.assertEqual(new_module.location.offering, "GreekHero")
self.assertNotEqual(new_module.location.version_guid, premod_course.location.version_guid)
self.assertIsNone(locator.version_guid, "Version inadvertently filled in")
current_course = modulestore().get_course(locator)
@@ -935,7 +953,7 @@ class TestItemCrud(SplitModuleTest):
self.assertEqual(new_module.display_name, 'new sequential')
# check that block does not exist in previous version
locator = BlockUsageLocator(
version_guid=premod_course.location.version_guid,
CourseLocator(version_guid=premod_course.location.version_guid),
block_id=new_module.location.block_id
)
self.assertRaises(ItemNotFoundError, modulestore().get_item, locator)
@@ -944,11 +962,16 @@ class TestItemCrud(SplitModuleTest):
"""
Test create_item w/ specifying the parent of the new item
"""
locator = BlockUsageLocator(package_id="testx.GreekHero", branch='draft', block_id='chapter2')
locator = BlockUsageLocator(
CourseLocator(org='testx', offering='GreekHero', branch='draft'),
block_id='chapter2'
)
original = modulestore().get_item(locator)
locator = BlockUsageLocator(package_id="testx.wonderful", block_id="head23456", branch='draft')
premod_course = modulestore().get_course(locator)
locator = BlockUsageLocator(
CourseLocator(org='testx', offering='wonderful', branch='draft'), 'head23456'
)
premod_course = modulestore().get_course(locator.course_key)
category = 'chapter'
new_module = modulestore().create_item(
locator, category, 'user123',
@@ -967,10 +990,15 @@ class TestItemCrud(SplitModuleTest):
a definition id and new def data that it branches the definition in the db.
Actually, this tries to test all create_item features not tested above.
"""
locator = BlockUsageLocator(package_id="testx.GreekHero", branch='draft', block_id='problem1')
locator = BlockUsageLocator(
CourseLocator(org='testx', offering='GreekHero', branch='draft'),
block_id='problem1'
)
original = modulestore().get_item(locator)
locator = BlockUsageLocator(package_id="guestx.contender", block_id="head345679", branch='draft')
locator = BlockUsageLocator(
CourseLocator(org='guestx', offering='contender', branch='draft'), 'head345679'
)
category = 'problem'
new_payload = "<problem>empty</problem>"
new_module = modulestore().create_item(
@@ -1002,8 +1030,9 @@ class TestItemCrud(SplitModuleTest):
"""
Check that using odd characters in block id don't break ability to add and retrieve block.
"""
parent_locator = BlockUsageLocator(package_id="guestx.contender", block_id="head345679", branch='draft')
chapter_locator = BlockUsageLocator(package_id="guestx.contender", block_id="foo.bar_-~:0", branch='draft')
course_key = CourseLocator(org='guestx', offering='contender', branch='draft')
parent_locator = BlockUsageLocator(course_key, block_id="head345679")
chapter_locator = BlockUsageLocator(course_key, block_id="foo.bar_-~:0")
modulestore().create_item(
parent_locator, 'chapter', 'anotheruser',
block_id=chapter_locator.block_id,
@@ -1014,7 +1043,7 @@ class TestItemCrud(SplitModuleTest):
self.assertEqual(new_module.location.block_id, "foo.bar_-~:0") # hardcode to ensure BUL init didn't change
# now try making that a parent of something
new_payload = "<problem>empty</problem>"
problem_locator = BlockUsageLocator(package_id="guestx.contender", block_id="prob.bar_-~:99a", branch='draft')
problem_locator = BlockUsageLocator(course_key, block_id="prob.bar_-~:99a")
modulestore().create_item(
chapter_locator, 'problem', 'anotheruser',
block_id=problem_locator.block_id,
@@ -1032,15 +1061,13 @@ class TestItemCrud(SplitModuleTest):
"""
# start transaction w/ simple creation
user = random.getrandbits(32)
new_course = modulestore().create_course('test_org.test_transaction', 'test_org', user)
new_course_locator = new_course.location.as_course_locator()
new_course = modulestore().create_course('test_org', 'test_transaction', user)
new_course_locator = new_course.id
index_history_info = modulestore().get_course_history_info(new_course.location)
course_block_prev_version = new_course.previous_version
course_block_update_version = new_course.update_version
self.assertIsNotNone(new_course_locator.version_guid, "Want to test a definite version")
versionless_course_locator = CourseLocator(
package_id=new_course_locator.package_id, branch=new_course_locator.branch
)
versionless_course_locator = new_course_locator.version_agnostic()
# positive simple case: no force, add chapter
new_ele = modulestore().create_item(
@@ -1093,9 +1120,8 @@ class TestItemCrud(SplitModuleTest):
# add new child to old parent in continued (leave off version_guid)
course_module_locator = BlockUsageLocator(
package_id=new_course.location.package_id,
new_course.location.course_key.version_agnostic(),
block_id=new_course.location.block_id,
branch=new_course.location.branch
)
new_ele = modulestore().create_item(
course_module_locator, 'chapter', user,
@@ -1115,7 +1141,10 @@ class TestItemCrud(SplitModuleTest):
"""
test updating an items metadata ensuring the definition doesn't version but the course does if it should
"""
locator = BlockUsageLocator(package_id="testx.GreekHero", block_id="problem3_2", branch='draft')
locator = BlockUsageLocator(
CourseLocator(org="testx", offering="GreekHero", branch='draft'),
block_id="problem3_2"
)
problem = modulestore().get_item(locator)
pre_def_id = problem.definition_locator.definition_id
pre_version_guid = problem.location.version_guid
@@ -1132,13 +1161,13 @@ class TestItemCrud(SplitModuleTest):
self.assertEqual(updated_problem.max_attempts, 4)
# refetch to ensure original didn't change
original_location = BlockUsageLocator(
version_guid=pre_version_guid,
CourseLocator(version_guid=pre_version_guid),
block_id=problem.location.block_id
)
problem = modulestore().get_item(original_location)
self.assertNotEqual(problem.max_attempts, 4, "original changed")
current_course = modulestore().get_course(locator)
current_course = modulestore().get_course(locator.course_key)
self.assertEqual(updated_problem.location.version_guid, current_course.location.version_guid)
history_info = modulestore().get_course_history_info(current_course.location)
@@ -1149,7 +1178,9 @@ class TestItemCrud(SplitModuleTest):
"""
test updating an item's children ensuring the definition doesn't version but the course does if it should
"""
locator = BlockUsageLocator(package_id="testx.GreekHero", block_id="chapter3", branch='draft')
locator = BlockUsageLocator(
CourseLocator(org='testx', offering='GreekHero', branch='draft'), 'chapter3'
)
block = modulestore().get_item(locator)
pre_def_id = block.definition_locator.definition_id
pre_version_guid = block.location.version_guid
@@ -1164,10 +1195,9 @@ class TestItemCrud(SplitModuleTest):
self.assertNotEqual(updated_problem.location.version_guid, pre_version_guid)
self.assertEqual(updated_problem.children, block.children)
self.assertNotIn(moved_child, updated_problem.children)
locator.block_id = "chapter1"
locator = locator.course_key.make_usage_key('Chapter', "chapter1")
other_block = modulestore().get_item(locator)
other_block.children.append(moved_child)
other_block.save() # decache model changes
other_updated = modulestore().update_item(other_block, '**replace_user**')
self.assertIn(moved_child, other_updated.children)
@@ -1175,7 +1205,9 @@ class TestItemCrud(SplitModuleTest):
"""
test updating an item's definition: ensure it gets versioned as well as the course getting versioned
"""
locator = BlockUsageLocator(package_id="testx.GreekHero", block_id="head12345", branch='draft')
locator = BlockUsageLocator(
CourseLocator(org='testx', offering='GreekHero', branch='draft'), 'head12345'
)
block = modulestore().get_item(locator)
pre_def_id = block.definition_locator.definition_id
pre_version_guid = block.location.version_guid
@@ -1192,10 +1224,16 @@ class TestItemCrud(SplitModuleTest):
"""
Test updating metadata, children, and definition in a single call ensuring all the versioning occurs
"""
locator = BlockUsageLocator(package_id="testx.GreekHero", branch='draft', block_id='problem1')
locator = BlockUsageLocator(
CourseLocator('testx', 'GreekHero', branch='draft'),
block_id='problem1'
)
original = modulestore().get_item(locator)
# first add 2 children to the course for the update to manipulate
locator = BlockUsageLocator(package_id="guestx.contender", block_id="head345679", branch='draft')
locator = BlockUsageLocator(
CourseLocator('guestx', 'contender', branch='draft'),
block_id="head345679"
)
category = 'problem'
new_payload = "<problem>empty</problem>"
modulestore().create_item(
@@ -1231,33 +1269,28 @@ class TestItemCrud(SplitModuleTest):
def test_delete_item(self):
course = self.create_course_for_deletion()
self.assertRaises(ValueError,
modulestore().delete_item,
course.location,
'deleting_user')
reusable_location = BlockUsageLocator(
package_id=course.location.package_id,
block_id=course.location.block_id,
branch='draft')
with self.assertRaises(ValueError):
modulestore().delete_item(course.location, 'deleting_user')
reusable_location = course.id.version_agnostic().for_branch('draft')
# delete a leaf
problems = modulestore().get_items(reusable_location, {'category': 'problem'})
problems = modulestore().get_items(reusable_location, category='problem')
locn_to_del = problems[0].location
new_course_loc = modulestore().delete_item(locn_to_del, 'deleting_user', delete_children=False)
deleted = BlockUsageLocator(package_id=reusable_location.package_id,
branch=reusable_location.branch,
block_id=locn_to_del.block_id)
self.assertFalse(modulestore().has_item(reusable_location.package_id, deleted))
self.assertRaises(VersionConflictError, modulestore().has_item, reusable_location.package_id, locn_to_del)
deleted = locn_to_del.version_agnostic()
self.assertFalse(modulestore().has_item(deleted))
with self.assertRaises(VersionConflictError):
modulestore().has_item(locn_to_del)
locator = BlockUsageLocator(
version_guid=locn_to_del.version_guid,
CourseLocator(version_guid=locn_to_del.version_guid),
block_id=locn_to_del.block_id
)
self.assertTrue(modulestore().has_item(reusable_location.package_id, locator))
self.assertTrue(modulestore().has_item(locator))
self.assertNotEqual(new_course_loc.version_guid, course.location.version_guid)
# delete a subtree
nodes = modulestore().get_items(reusable_location, {'category': 'chapter'})
nodes = modulestore().get_items(reusable_location, category='chapter')
new_course_loc = modulestore().delete_item(nodes[0].location, 'deleting_user', delete_children=True)
# check subtree
@@ -1267,15 +1300,23 @@ class TestItemCrud(SplitModuleTest):
"""
if node:
node_loc = node.location
self.assertFalse(modulestore().has_item(reusable_location.package_id,
BlockUsageLocator(
package_id=node_loc.package_id,
branch=node_loc.branch,
block_id=node.location.block_id)))
self.assertFalse(
modulestore().has_item(
BlockUsageLocator(
CourseLocator(
org=node_loc.org,
offering=node_loc.offering,
branch=node_loc.branch,
),
block_id=node_loc.block_id
)
)
)
locator = BlockUsageLocator(
version_guid=node.location.version_guid,
block_id=node.location.block_id)
self.assertTrue(modulestore().has_item(reusable_location.package_id, locator))
CourseLocator(version_guid=node.location.version_guid),
block_id=node.location.block_id
)
self.assertTrue(modulestore().has_item(locator))
if node.has_children:
for sub in node.get_children():
check_subtree(sub)
@@ -1285,11 +1326,11 @@ class TestItemCrud(SplitModuleTest):
"""
Create a course we can delete
"""
course = modulestore().create_course('nihilx.deletion', 'nihilx', 'deleting_user')
course = modulestore().create_course('nihilx', 'deletion', 'deleting_user')
root = BlockUsageLocator(
package_id=course.location.package_id,
course.id.version_agnostic().for_branch('draft'),
block_id=course.location.block_id,
branch='draft')
)
for _ in range(4):
self.create_subtree_for_deletion(root, ['chapter', 'vertical', 'problem'])
return modulestore().get_item(root)
@@ -1300,8 +1341,8 @@ class TestItemCrud(SplitModuleTest):
"""
if not category_queue:
return
node = modulestore().create_item(parent, category_queue[0], 'deleting_user')
node_loc = BlockUsageLocator(parent.as_course_locator(), block_id=node.location.block_id)
node = modulestore().create_item(parent.version_agnostic(), category_queue[0], 'deleting_user')
node_loc = BlockUsageLocator(parent.course_key, block_id=node.location.block_id)
for _ in range(4):
self.create_subtree_for_deletion(node_loc, category_queue[1:])
@@ -1315,7 +1356,7 @@ class TestCourseCreation(SplitModuleTest):
The simplest case but probing all expected results from it.
"""
# Oddly getting differences of 200nsec
new_course = modulestore().create_course('test_org.test_course', 'test_org', 'create_user')
new_course = modulestore().create_course('test_org', 'test_course', 'create_user')
new_locator = new_course.location
# check index entry
index_info = modulestore().get_course_index_info(new_locator)
@@ -1340,13 +1381,13 @@ class TestCourseCreation(SplitModuleTest):
"""
Test making a course which points to an existing draft and published but not making any changes to either.
"""
original_locator = CourseLocator(package_id="testx.wonderful", branch='draft')
original_locator = CourseLocator(org='testx', offering='wonderful', branch='draft')
original_index = modulestore().get_course_index_info(original_locator)
new_draft = modulestore().create_course(
'best', 'leech', 'leech_master',
versions_dict=original_index['versions'])
new_draft_locator = new_draft.location
self.assertRegexpMatches(new_draft_locator.package_id, 'best')
self.assertRegexpMatches(new_draft_locator.org, 'best')
# the edited_by and other meta fields on the new course will be the original author not this one
self.assertEqual(new_draft.edited_by, 'test@edx.org')
self.assertEqual(new_draft_locator.version_guid, original_index['versions']['draft'])
@@ -1354,7 +1395,7 @@ class TestCourseCreation(SplitModuleTest):
new_index = modulestore().get_course_index_info(new_draft_locator)
self.assertEqual(new_index['edited_by'], 'leech_master')
new_published_locator = CourseLocator(package_id=new_draft_locator.package_id, branch='published')
new_published_locator = new_draft_locator.course_key.for_branch("published")
new_published = modulestore().get_course(new_published_locator)
self.assertEqual(new_published.edited_by, 'test@edx.org')
self.assertEqual(new_published.location.version_guid, original_index['versions']['published'])
@@ -1365,7 +1406,7 @@ class TestCourseCreation(SplitModuleTest):
new_draft.location, 'chapter', 'leech_master',
fields={'display_name': 'new chapter'}
)
new_draft_locator.version_guid = None
new_draft_locator = new_draft_locator.course_key.version_agnostic()
new_index = modulestore().get_course_index_info(new_draft_locator)
self.assertNotEqual(new_index['versions']['draft'], original_index['versions']['draft'])
new_draft = modulestore().get_course(new_draft_locator)
@@ -1377,18 +1418,12 @@ class TestCourseCreation(SplitModuleTest):
original_course = modulestore().get_course(original_locator)
self.assertEqual(original_course.location.version_guid, original_index['versions']['draft'])
self.assertFalse(
modulestore().has_item(new_draft_locator.package_id, BlockUsageLocator(
original_locator,
block_id=new_item.location.block_id
))
)
def test_derived_course(self):
"""
Create a new course which overrides metadata and course_data
"""
original_locator = CourseLocator(package_id="guestx.contender", branch='draft')
original_locator = CourseLocator(org='guestx', offering='contender', branch='draft')
original = modulestore().get_course(original_locator)
original_index = modulestore().get_course_index_info(original_locator)
fields = {}
@@ -1410,7 +1445,7 @@ class TestCourseCreation(SplitModuleTest):
fields=fields
)
new_draft_locator = new_draft.location
self.assertRegexpMatches(new_draft_locator.package_id, 'counter')
self.assertRegexpMatches(new_draft_locator.org, 'counter')
# the edited_by and other meta fields on the new course will be the original author not this one
self.assertEqual(new_draft.edited_by, 'leech_master')
self.assertNotEqual(new_draft_locator.version_guid, original_index['versions']['draft'])
@@ -1425,19 +1460,12 @@ class TestCourseCreation(SplitModuleTest):
def test_update_course_index(self):
"""
Test changing the org, pretty id, etc of a course. Test that it doesn't allow changing the id, etc.
Test the versions pointers. NOTE: you can change the org, offering, or other things, but
it's not clear how you'd find them again or associate them w/ existing student history since
we use course_key so many places as immutable.
"""
locator = CourseLocator(package_id="testx.GreekHero", branch='draft')
locator = CourseLocator(org='testx', offering='GreekHero', branch='draft')
course_info = modulestore().get_course_index_info(locator)
course_info['org'] = 'funkyU'
modulestore().update_course_index(course_info)
course_info = modulestore().get_course_index_info(locator)
self.assertEqual(course_info['org'], 'funkyU')
course_info['org'] = 'moreFunky'
modulestore().update_course_index(course_info)
course_info = modulestore().get_course_index_info(locator)
self.assertEqual(course_info['org'], 'moreFunky')
# an allowed but not necessarily recommended way to revert the draft version
head_course = modulestore().get_course(locator)
@@ -1450,7 +1478,7 @@ class TestCourseCreation(SplitModuleTest):
# an allowed but not recommended way to publish a course
versions['published'] = versions['draft']
modulestore().update_course_index(course_info)
course = modulestore().get_course(CourseLocator(package_id=locator.package_id, branch="published"))
course = modulestore().get_course(locator.for_branch("published"))
self.assertEqual(course.location.version_guid, versions['draft'])
def test_create_with_root(self):
@@ -1459,7 +1487,7 @@ class TestCourseCreation(SplitModuleTest):
"""
user = random.getrandbits(32)
new_course = modulestore().create_course(
'test_org.test_transaction', 'test_org', user,
'test_org', 'test_transaction', user,
root_block_id='top', root_category='chapter'
)
self.assertEqual(new_course.location.block_id, 'top')
@@ -1480,7 +1508,8 @@ class TestCourseCreation(SplitModuleTest):
user = random.getrandbits(32)
courses = modulestore().get_courses()
with self.assertRaises(DuplicateCourseError):
modulestore().create_course(courses[0].location.package_id, 'org', 'pretty', user)
dupe_course_key = courses[0].location.course_key
modulestore().create_course(dupe_course_key.org, dupe_course_key.offering, user)
class TestInheritance(SplitModuleTest):
@@ -1493,11 +1522,15 @@ class TestInheritance(SplitModuleTest):
"""
# Note, not testing value where defined (course) b/c there's no
# defined accessor for it on CourseDescriptor.
locator = BlockUsageLocator(package_id="testx.GreekHero", block_id="problem3_2", branch='draft')
locator = BlockUsageLocator(
CourseLocator(org='testx', offering='GreekHero', branch='draft'), 'problem3_2'
)
node = modulestore().get_item(locator)
# inherited
self.assertEqual(node.graceperiod, datetime.timedelta(hours=2))
locator = BlockUsageLocator(package_id="testx.GreekHero", block_id="problem1", branch='draft')
locator = BlockUsageLocator(
CourseLocator(org='testx', offering='GreekHero', branch='draft'), 'problem1'
)
node = modulestore().get_item(locator)
# overridden
self.assertEqual(node.graceperiod, datetime.timedelta(hours=4))
@@ -1518,8 +1551,8 @@ class TestPublish(SplitModuleTest):
"""
Test the standard patterns: publish to new branch, revise and publish
"""
source_course = CourseLocator(package_id="testx.GreekHero", branch='draft')
dest_course = CourseLocator(package_id="testx.GreekHero", branch="published")
source_course = CourseLocator(org='testx', offering='GreekHero', branch='draft')
dest_course = CourseLocator(org='testx', offering='GreekHero', branch="published")
modulestore().xblock_publish(self.user, source_course, dest_course, ["head12345"], ["chapter2", "chapter3"])
expected = ["head12345", "chapter1"]
self._check_course(
@@ -1560,13 +1593,13 @@ class TestPublish(SplitModuleTest):
"""
Test the exceptions which preclude successful publication
"""
source_course = CourseLocator(package_id="testx.GreekHero", branch='draft')
source_course = CourseLocator(org='testx', offering='GreekHero', branch='draft')
# destination does not exist
destination_course = CourseLocator(package_id="Unknown", branch="published")
destination_course = CourseLocator(org='fake', offering='Unknown', branch="published")
with self.assertRaises(ItemNotFoundError):
modulestore().xblock_publish(self.user, source_course, destination_course, ["chapter3"], None)
# publishing into a new branch w/o publishing the root
destination_course = CourseLocator(package_id="testx.GreekHero", branch="published")
destination_course = CourseLocator(org='testx', offering='GreekHero', branch="published")
with self.assertRaises(ItemNotFoundError):
modulestore().xblock_publish(self.user, source_course, destination_course, ["chapter3"], None)
# publishing a subdag w/o the parent already in course
@@ -1578,8 +1611,8 @@ class TestPublish(SplitModuleTest):
"""
Test publishing moves and deletes.
"""
source_course = CourseLocator(package_id="testx.GreekHero", branch='draft')
dest_course = CourseLocator(package_id="testx.GreekHero", branch="published")
source_course = CourseLocator(org='testx', offering='GreekHero', branch='draft')
dest_course = CourseLocator(org='testx', offering='GreekHero', branch="published")
modulestore().xblock_publish(self.user, source_course, dest_course, ["head12345"], ["chapter2"])
expected = ["head12345", "chapter1", "chapter3", "problem1", "problem3_2"]
self._check_course(source_course, dest_course, expected, ["chapter2"])

View File

@@ -0,0 +1,137 @@
import unittest
import mock
import datetime
import uuid
import random
from xmodule.modulestore.inheritance import InheritanceMixin
from xmodule.modulestore.locator import CourseLocator, BlockUsageLocator
from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore
from xmodule.modulestore.mongo import MongoModuleStore, DraftMongoModuleStore
from xmodule.modulestore.mongo.draft import DIRECT_ONLY_CATEGORIES
class SplitWMongoCourseBoostrapper(unittest.TestCase):
"""
Helper for tests which need to construct split mongo & old mongo based courses to get interesting internal structure.
Override _create_course and after invoking the super() _create_course, have it call _create_item for
each xblock you want in the course.
This class ensures the db gets created, opened, and cleaned up in addition to creating the course
Defines the following attrs on self:
* userid: a random non-registered mock user id
* split_mongo: a pointer to the split mongo instance
* old_mongo: a pointer to the old_mongo instance
* draft_mongo: a pointer to the old draft instance
* split_course_key (CourseLocator): of the new course
* old_course_key: the SlashSpecifiedCourseKey for the course
"""
# Snippet of what would be in the django settings envs file
db_config = {
'host': 'localhost',
'db': 'test_xmodule',
}
modulestore_options = {
'default_class': 'xmodule.raw_module.RawDescriptor',
'fs_root': '',
'render_template': mock.Mock(return_value=""),
'xblock_mixins': (InheritanceMixin,)
}
split_course_key = CourseLocator('test_org', 'test_course.runid', branch='draft')
def setUp(self):
self.db_config['collection'] = 'modulestore{0}'.format(uuid.uuid4().hex[:5])
self.userid = random.getrandbits(32)
super(SplitWMongoCourseBoostrapper, self).setUp()
self.split_mongo = SplitMongoModuleStore(
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.draft_mongo = DraftMongoModuleStore(self.db_config, **self.modulestore_options)
self.addCleanup(self.tear_down_mongo)
self.old_course_key = None
self.runtime = None
self._create_course()
def tear_down_split(self):
"""
Remove the test collections, close the db connection
"""
split_db = self.split_mongo.db
split_db.drop_collection(split_db.course_index)
split_db.drop_collection(split_db.structures)
split_db.drop_collection(split_db.definitions)
def tear_down_mongo(self):
"""
Remove the test collections, close the db connection
"""
split_db = self.split_mongo.db
# old_mongo doesn't give a db attr, but all of the dbs are the same
split_db.drop_collection(self.old_mongo.collection)
def _create_item(self, category, name, data, metadata, parent_category, parent_name, draft=True, split=True):
"""
Create the item of the given category and block id in split and old mongo, add it to the optional
parent. The parent category is only needed because old mongo requires it for the id.
"""
location = self.old_course_key.make_usage_key(category, name)
if not draft or category in DIRECT_ONLY_CATEGORIES:
mongo = self.old_mongo
else:
mongo = self.draft_mongo
mongo.create_and_save_xmodule(location, data, metadata, self.runtime)
if isinstance(data, basestring):
fields = {'data': data}
else:
fields = data.copy()
fields.update(metadata)
if parent_name:
# add child to parent in mongo
parent_location = self.old_course_key.make_usage_key(parent_category, parent_name)
if not draft or parent_category in DIRECT_ONLY_CATEGORIES:
mongo = self.old_mongo
else:
mongo = self.draft_mongo
parent = mongo.get_item(parent_location)
parent.children.append(location)
mongo.update_item(parent, self.userid)
# create pointer for split
course_or_parent_locator = BlockUsageLocator(
course_key=self.split_course_key,
block_id=parent_name
)
else:
course_or_parent_locator = self.split_course_key
if split:
self.split_mongo.create_item(course_or_parent_locator, category, self.userid, block_id=name, fields=fields)
def _create_course(self, split=True):
"""
* some detached items
* some attached children
* some orphans
"""
metadata = {
'start': datetime.datetime(2000, 3, 13, 4),
'display_name': 'Migration test course',
}
data = {
'wiki_slug': 'test_course_slug'
}
fields = metadata.copy()
fields.update(data)
if split:
# split requires the course to be created separately from creating items
self.split_mongo.create_course(
self.split_course_key.org, self.split_course_key.offering, self.userid, fields=fields, root_block_id='runid'
)
old_course = self.old_mongo.create_course(self.split_course_key.org, 'test_course/runid', fields=fields)
self.old_course_key = old_course.id
self.runtime = old_course.runtime

View File

@@ -7,12 +7,13 @@ import unittest
from glob import glob
from mock import patch
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore.xml import XMLModuleStore
from xmodule.modulestore import Location, XML_MODULESTORE_TYPE
from .test_modulestore import check_path_to_location
from xmodule.tests import DATA_DIR
from xmodule.modulestore.locations import SlashSeparatedCourseKey
from xmodule.modulestore.tests.test_modulestore import check_has_course_method
def glob_tildes_at_end(path):
@@ -58,22 +59,16 @@ class TestXMLModuleStore(unittest.TestCase):
modulestore = XMLModuleStore(DATA_DIR, course_dirs=['toy'], load_error_modules=False)
# Look up the errors during load. There should be none.
location = CourseDescriptor.id_to_location("edX/toy/2012_Fall")
errors = modulestore.get_item_errors(location)
errors = modulestore.get_course_errors(SlashSeparatedCourseKey("edX", "toy", "2012_Fall"))
assert errors == []
@patch("xmodule.modulestore.xml.glob.glob", side_effect=glob_tildes_at_end)
def test_tilde_files_ignored(self, _fake_glob):
modulestore = XMLModuleStore(DATA_DIR, course_dirs=['tilde'], load_error_modules=False)
course_module = modulestore.modules['edX/tilde/2012_Fall']
about_location = Location({
'tag': 'i4x',
'org': 'edX',
'course': 'tilde',
'category': 'about',
'name': 'index',
})
about_module = course_module[about_location]
about_location = SlashSeparatedCourseKey('edX', 'tilde', '2012_Fall').make_usage_key(
'about', 'index',
)
about_module = modulestore.get_item(about_location)
self.assertIn("GREEN", about_module.data)
self.assertNotIn("RED", about_module.data)
@@ -85,13 +80,13 @@ class TestXMLModuleStore(unittest.TestCase):
for course in store.get_courses():
course_locations = store.get_courses_for_wiki(course.wiki_slug)
self.assertEqual(len(course_locations), 1)
self.assertIn(Location('i4x', 'edX', course.location.course, 'course', '2012_Fall'), course_locations)
self.assertIn(course.location, course_locations)
course_locations = store.get_courses_for_wiki('no_such_wiki')
self.assertEqual(len(course_locations), 0)
# now set toy course to share the wiki with simple course
toy_course = store.get_course('edX/toy/2012_Fall')
toy_course = store.get_course(SlashSeparatedCourseKey('edX', 'toy', '2012_Fall'))
toy_course.wiki_slug = 'simple'
course_locations = store.get_courses_for_wiki('toy')
@@ -100,4 +95,14 @@ class TestXMLModuleStore(unittest.TestCase):
course_locations = store.get_courses_for_wiki('simple')
self.assertEqual(len(course_locations), 2)
for course_number in ['toy', 'simple']:
self.assertIn(Location('i4x', 'edX', course_number, 'course', '2012_Fall'), course_locations)
self.assertIn(Location('edX', course_number, '2012_Fall', 'course', '2012_Fall'), course_locations)
def test_has_course(self):
"""
Test the has_course method
"""
check_has_course_method(
XMLModuleStore(DATA_DIR, course_dirs=['toy', 'simple']),
SlashSeparatedCourseKey('edX', 'toy', '2012_Fall'),
locator_key_fields=SlashSeparatedCourseKey.KEY_FIELDS
)

View File

@@ -1,7 +1,6 @@
"""
Tests for XML importer.
"""
from unittest import TestCase
import mock
from xblock.core import XBlock
from xblock.fields import String, Scope, ScopeIds
@@ -9,7 +8,93 @@ from xblock.runtime import Runtime, KvsFieldData, DictKeyValueStore
from xmodule.x_module import XModuleMixin
from xmodule.modulestore import Location
from xmodule.modulestore.inheritance import InheritanceMixin
from xmodule.modulestore.xml_importer import remap_namespace
from xmodule.modulestore.xml_importer import import_module
from xmodule.modulestore.locations import SlashSeparatedCourseKey
from xmodule.tests import DATA_DIR
from uuid import uuid4
import unittest
import importlib
class ModuleStoreNoSettings(unittest.TestCase):
"""
A mixin to create a mongo modulestore that avoids settings
"""
HOST = 'localhost'
PORT = 27017
DB = 'test_mongo_%s' % uuid4().hex[:5]
COLLECTION = 'modulestore'
FS_ROOT = DATA_DIR
DEFAULT_CLASS = 'xmodule.modulestore.tests.test_xml_importer.StubXBlock'
RENDER_TEMPLATE = lambda t_n, d, ctx = None, nsp = 'main': ''
modulestore_options = {
'default_class': DEFAULT_CLASS,
'fs_root': DATA_DIR,
'render_template': RENDER_TEMPLATE,
}
DOC_STORE_CONFIG = {
'host': HOST,
'db': DB,
'collection': COLLECTION,
}
MODULESTORE = {
'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
'DOC_STORE_CONFIG': DOC_STORE_CONFIG,
'OPTIONS': modulestore_options
}
modulestore = None
def cleanup_modulestore(self):
"""
cleanup
"""
if modulestore:
connection = self.modulestore.database.connection
connection.drop_database(self.modulestore.database)
connection.close()
def setUp(self):
"""
Add cleanups
"""
self.addCleanup(self.cleanup_modulestore)
super(ModuleStoreNoSettings, self).setUp()
#===========================================
def modulestore():
"""
Mock the django dependent global modulestore function to disentangle tests from django
"""
def load_function(engine_path):
"""
Load the given engine
"""
module_path, _, name = engine_path.rpartition('.')
return getattr(importlib.import_module(module_path), name)
if ModuleStoreNoSettings.modulestore is None:
class_ = load_function(ModuleStoreNoSettings.MODULESTORE['ENGINE'])
options = {}
options.update(ModuleStoreNoSettings.MODULESTORE['OPTIONS'])
options['render_template'] = render_to_template_mock
# pylint: disable=W0142
ModuleStoreNoSettings.modulestore = class_(
ModuleStoreNoSettings.MODULESTORE['DOC_STORE_CONFIG'],
**options
)
return ModuleStoreNoSettings.modulestore
# pylint: disable=W0613
def render_to_template_mock(*args):
pass
class StubXBlock(XBlock, XModuleMixin, InheritanceMixin):
@@ -29,7 +114,7 @@ class StubXBlock(XBlock, XModuleMixin, InheritanceMixin):
)
class RemapNamespaceTest(TestCase):
class RemapNamespaceTest(ModuleStoreNoSettings):
"""
Test that remapping the namespace from import to the actual course location.
"""
@@ -42,81 +127,99 @@ class RemapNamespaceTest(TestCase):
self.field_data = KvsFieldData(kvs=DictKeyValueStore())
self.scope_ids = ScopeIds('Bob', 'stubxblock', '123', 'import')
self.xblock = StubXBlock(self.runtime, self.field_data, self.scope_ids)
super(RemapNamespaceTest, self).setUp()
def test_remap_namespace_native_xblock(self):
# Set the XBlock's location
self.xblock.location = Location("i4x://import/org/run/stubxblock")
self.xblock.location = Location("org", "import", "run", "category", "stubxblock")
# Explicitly set the content and settings fields
self.xblock.test_content_field = "Explicitly set"
self.xblock.test_settings_field = "Explicitly set"
self.xblock.save()
# Remap the namespace
target_location_namespace = Location("i4x://course/org/run/stubxblock")
remap_namespace(self.xblock, target_location_namespace)
# Move to different runtime w/ different course id
target_location_namespace = SlashSeparatedCourseKey("org", "course", "run")
new_version = import_module(
self.xblock,
modulestore(),
self.xblock.location.course_key,
target_location_namespace,
do_import_static=False
)
# Check the XBlock's location
self.assertEqual(self.xblock.location, target_location_namespace)
self.assertEqual(new_version.location.course_key, target_location_namespace)
# Check the values of the fields.
# The content and settings fields should be preserved
self.assertEqual(self.xblock.test_content_field, 'Explicitly set')
self.assertEqual(self.xblock.test_settings_field, 'Explicitly set')
self.assertEqual(new_version.test_content_field, 'Explicitly set')
self.assertEqual(new_version.test_settings_field, 'Explicitly set')
# Expect that these fields are marked explicitly set
self.assertIn(
'test_content_field',
self.xblock.get_explicitly_set_fields_by_scope(scope=Scope.content)
new_version.get_explicitly_set_fields_by_scope(scope=Scope.content)
)
self.assertIn(
'test_settings_field',
self.xblock.get_explicitly_set_fields_by_scope(scope=Scope.settings)
new_version.get_explicitly_set_fields_by_scope(scope=Scope.settings)
)
def test_remap_namespace_native_xblock_default_values(self):
# Set the XBlock's location
self.xblock.location = Location("i4x://import/org/run/stubxblock")
self.xblock.location = Location("org", "import", "run", "category", "stubxblock")
# Do NOT set any values, so the fields should use the defaults
self.xblock.save()
# Remap the namespace
target_location_namespace = Location("i4x://course/org/run/stubxblock")
remap_namespace(self.xblock, target_location_namespace)
target_location_namespace = Location("org", "course", "run", "category", "stubxblock")
new_version = import_module(
self.xblock,
modulestore(),
self.xblock.location.course_key,
target_location_namespace.course_key,
do_import_static=False
)
# Check the values of the fields.
# The content and settings fields should be the default values
self.assertEqual(self.xblock.test_content_field, 'default value')
self.assertEqual(self.xblock.test_settings_field, 'default value')
self.assertEqual(new_version.test_content_field, 'default value')
self.assertEqual(new_version.test_settings_field, 'default value')
# The fields should NOT appear in the explicitly set fields
self.assertNotIn(
'test_content_field',
self.xblock.get_explicitly_set_fields_by_scope(scope=Scope.content)
new_version.get_explicitly_set_fields_by_scope(scope=Scope.content)
)
self.assertNotIn(
'test_settings_field',
self.xblock.get_explicitly_set_fields_by_scope(scope=Scope.settings)
new_version.get_explicitly_set_fields_by_scope(scope=Scope.settings)
)
def test_remap_namespace_native_xblock_inherited_values(self):
# Set the XBlock's location
self.xblock.location = Location("i4x://import/org/run/stubxblock")
self.xblock.location = Location("org", "import", "run", "category", "stubxblock")
self.xblock.save()
# Remap the namespace
target_location_namespace = Location("i4x://course/org/run/stubxblock")
remap_namespace(self.xblock, target_location_namespace)
target_location_namespace = Location("org", "course", "run", "category", "stubxblock")
new_version = import_module(
self.xblock,
modulestore(),
self.xblock.location.course_key,
target_location_namespace.course_key,
do_import_static=False
)
# Inherited fields should NOT be explicitly set
self.assertNotIn(
'start', self.xblock.get_explicitly_set_fields_by_scope(scope=Scope.settings)
'start', new_version.get_explicitly_set_fields_by_scope(scope=Scope.settings)
)
self.assertNotIn(
'graded', self.xblock.get_explicitly_set_fields_by_scope(scope=Scope.settings)
'graded', new_version.get_explicitly_set_fields_by_scope(scope=Scope.settings)
)

View File

@@ -16,21 +16,23 @@ from path import path
from xmodule.error_module import ErrorDescriptor
from xmodule.errortracker import make_error_tracker, exc_info_to_str
from xmodule.course_module import CourseDescriptor
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.tabs import CourseTabList
from xmodule.modulestore.keys import UsageKey
from xmodule.modulestore.locations import SlashSeparatedCourseKey
from xblock.fields import ScopeIds
from xblock.field_data import DictFieldData
from xblock.runtime import DictKeyValueStore, IdReader, IdGenerator
from xblock.runtime import DictKeyValueStore, IdGenerator
from . import ModuleStoreReadBase, Location, XML_MODULESTORE_TYPE
from .exceptions import ItemNotFoundError
from .inheritance import compute_inherited_metadata, inheriting_field_data
from xblock.fields import ScopeIds, Reference, ReferenceList, ReferenceValueDict
edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False,
remove_comments=True, remove_blank_text=True)
@@ -51,7 +53,7 @@ def clean_out_mako_templating(xml_string):
class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
def __init__(self, xmlstore, course_id, course_dir,
error_tracker, parent_tracker,
load_error_modules=True, id_reader=None, **kwargs):
load_error_modules=True, **kwargs):
"""
A class that handles loading from xml. Does some munging to ensure that
all elements have unique slugs.
@@ -60,13 +62,7 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
"""
self.unnamed = defaultdict(int) # category -> num of new url_names for that category
self.used_names = defaultdict(set) # category -> set of used url_names
course_id_dict = Location.parse_course_id(course_id)
self.org = course_id_dict['org']
self.course = course_id_dict['course']
self.url_name = course_id_dict['name']
if id_reader is None:
id_reader = LocationReader()
id_generator = CourseLocationGenerator(self.org, self.course)
id_generator = CourseLocationGenerator(course_id)
# cdodge: adding the course_id as passed in for later reference rather than having to recomine the org/course/url_name
self.course_id = course_id
@@ -178,7 +174,7 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
self,
id_generator,
)
except Exception as err:
except Exception as err: # pylint: disable=broad-except
if not self.load_error_modules:
raise
@@ -224,9 +220,9 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
# TODO (vshnayder): we are somewhat architecturally confused in the loading code:
# load_item should actually be get_instance, because it expects the course-specific
# policy to be loaded. For now, just add the course_id here...
def load_item(location):
def load_item(usage_key):
"""Return the XBlock for the specified location"""
return xmlstore.get_instance(course_id, Location(location))
return xmlstore.get_item(usage_key)
resources_fs = OSFS(xmlstore.data_dir / course_dir)
@@ -236,7 +232,6 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
render_template=render_template,
error_tracker=error_tracker,
process_xml=process_xml,
id_reader=id_reader,
**kwargs
)
@@ -247,37 +242,53 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
block.children.append(child_block.scope_ids.usage_id)
class LocationReader(IdReader):
"""
IdReader for definition and usage ids that are Locations
"""
def get_definition_id(self, usage_id):
return usage_id
def get_block_type(self, def_id):
location = def_id
return location.category
class CourseLocationGenerator(IdGenerator):
"""
IdGenerator for Location-based definition ids and usage ids
based within a course
"""
def __init__(self, org, course):
self.org = org
self.course = course
def __init__(self, course_id):
self.course_id = course_id
self.autogen_ids = itertools.count(0)
def create_usage(self, def_id):
return Location(def_id)
return def_id
def create_definition(self, block_type, slug=None):
assert block_type is not None
if slug is None:
slug = 'autogen_{}_{}'.format(block_type, self.autogen_ids.next())
location = Location('i4x', self.org, self.course, block_type, slug)
return location
return self.course_id.make_usage_key(block_type, slug)
def _make_usage_key(course_key, value):
"""
Makes value into a UsageKey inside the specified course.
If value is already a UsageKey, returns that.
"""
if isinstance(value, UsageKey):
return value
return course_key.make_usage_key_from_deprecated_string(value)
def _convert_reference_fields_to_keys(xblock): # pylint: disable=invalid-name
"""
Find all fields of type reference and convert the payload into UsageKeys
"""
course_key = xblock.scope_ids.usage_id.course_key
for field in xblock.fields.itervalues():
if field.is_set_on(xblock):
field_value = getattr(xblock, field.name)
if isinstance(field, Reference):
setattr(xblock, field.name, _make_usage_key(course_key, field_value))
elif isinstance(field, ReferenceList):
setattr(xblock, field.name, [_make_usage_key(course_key, ele) for ele in field_value])
elif isinstance(field, ReferenceValueDict):
for key, subvalue in field_value.iteritems():
assert isinstance(subvalue, basestring)
field_value[key] = _make_usage_key(course_key, subvalue)
setattr(xblock, field.name, field_value)
def create_block_from_xml(xml_data, system, id_generator):
@@ -309,6 +320,9 @@ def create_block_from_xml(xml_data, system, id_generator):
scope_ids = ScopeIds(None, block_type, def_id, usage_id)
xblock = xblock_class.parse_xml(node, system, scope_ids, id_generator)
_convert_reference_fields_to_keys(xblock)
return xblock
@@ -327,8 +341,8 @@ class ParentTracker(object):
child and parent must be :class:`.Location` instances.
"""
s = self._parents.setdefault(child, set())
s.add(parent)
setp = self._parents.setdefault(child, set())
setp.add(parent)
def is_known(self, child):
"""
@@ -359,13 +373,14 @@ class XMLModuleStore(ModuleStoreReadBase):
"""
Initialize an XMLModuleStore from data_dir
data_dir: path to data directory containing the course directories
Args:
data_dir (str): path to data directory containing the course directories
default_class: dot-separated string defining the default descriptor
class to use if none is specified in entry_points
default_class (str): dot-separated string defining the default descriptor
class to use if none is specified in entry_points
course_dirs or course_ids: If specified, the list of course_dirs or course_ids to load. Otherwise,
load all courses. Note, providing both
course_dirs or course_ids (list of str): If specified, the list of course_dirs or course_ids to load. Otherwise,
load all courses. Note, providing both
"""
super(XMLModuleStore, self).__init__(**kwargs)
@@ -374,6 +389,9 @@ class XMLModuleStore(ModuleStoreReadBase):
self.courses = {} # course_dir -> XBlock for the course
self.errored_courses = {} # course_dir -> errorlog, for dirs that failed to load
if course_ids is not None:
course_ids = [SlashSeparatedCourseKey.from_deprecated_string(course_id) for course_id in course_ids]
self.load_error_modules = load_error_modules
if default_class is None:
@@ -415,9 +433,9 @@ class XMLModuleStore(ModuleStoreReadBase):
course_descriptor = None
try:
course_descriptor = self.load_course(course_dir, course_ids, errorlog.tracker)
except Exception as e:
except Exception as exc: # pylint: disable=broad-except
msg = "ERROR: Failed to load course '{0}': {1}".format(
course_dir.encode("utf-8"), unicode(e)
course_dir.encode("utf-8"), unicode(exc)
)
log.exception(msg)
errorlog.tracker(msg)
@@ -430,7 +448,7 @@ class XMLModuleStore(ModuleStoreReadBase):
self.errored_courses[course_dir] = errorlog
else:
self.courses[course_dir] = course_descriptor
self._location_errors[course_descriptor.scope_ids.usage_id] = errorlog
self._course_errors[course_descriptor.id] = errorlog
self.parent_trackers[course_descriptor.id].make_known(course_descriptor.scope_ids.usage_id)
def __unicode__(self):
@@ -521,7 +539,7 @@ class XMLModuleStore(ModuleStoreReadBase):
raise ValueError("Can't load a course without a 'url_name' "
"(or 'name') set. Set url_name.")
course_id = CourseDescriptor.make_id(org, course, url_name)
course_id = SlashSeparatedCourseKey(org, course, url_name)
if course_ids is not None and course_id not in course_ids:
return None
@@ -670,42 +688,20 @@ class XMLModuleStore(ModuleStoreReadBase):
module.save()
self.modules[course_descriptor.id][module.scope_ids.usage_id] = module
except Exception, e:
except Exception as exc: # pylint: disable=broad-except
logging.exception("Failed to load %s. Skipping... \
Exception: %s", filepath, unicode(e))
system.error_tracker("ERROR: " + unicode(e))
Exception: %s", filepath, unicode(exc))
system.error_tracker("ERROR: " + unicode(exc))
def get_instance(self, course_id, location, depth=0):
"""
Returns an XBlock instance for the item at
location, with the policy for course_id. (In case two xml
dirs have different content at the same location, return the
one for this course_id.)
If any segment of the location is None except revision, raises
xmodule.modulestore.exceptions.InsufficientSpecificationError
If no object is found at that location, raises
xmodule.modulestore.exceptions.ItemNotFoundError
location: Something that can be passed to Location
"""
location = Location(location)
try:
return self.modules[course_id][location]
except KeyError:
raise ItemNotFoundError(location)
def has_item(self, course_id, location):
def has_item(self, usage_key):
"""
Returns True if location exists in this ModuleStore.
"""
location = Location(location)
return location in self.modules[course_id]
return usage_key in self.modules[usage_key.course_key]
def get_item(self, location, depth=0):
def get_item(self, usage_key, depth=0):
"""
Returns an XBlock instance for the item at location.
Returns an XBlock instance for the item for this UsageKey.
If any segment of the location is None except revision, raises
xmodule.modulestore.exceptions.InsufficientSpecificationError
@@ -713,26 +709,56 @@ class XMLModuleStore(ModuleStoreReadBase):
If no object is found at that location, raises
xmodule.modulestore.exceptions.ItemNotFoundError
location: Something that can be passed to Location
usage_key: a UsageKey that matches the module we are looking for.
"""
raise NotImplementedError("XMLModuleStores can't guarantee that definitions"
" are unique. Use get_instance.")
try:
return self.modules[usage_key.course_key][usage_key]
except KeyError:
raise ItemNotFoundError(usage_key)
def get_items(self, location, course_id=None, depth=0, qualifiers=None):
def get_items(self, course_id, settings=None, content=None, **kwargs):
"""
Returns:
list of XModuleDescriptor instances for the matching items within the course with
the given course_id
NOTE: don't use this to look for courses
as the course_id is required. Use get_courses.
Args:
course_id (CourseKey): the course identifier
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.
kwargs (key=value): 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 this modulestore, ``name`` is another commonly provided key (Location based stores)
(but not revision!)
For this modulestore,
you can search dates by providing either a datetime for == (probably
useless) or a tuple (">"|"<" datetime) for after or before, etc.
"""
items = []
def _add_get_items(self, location, modules):
for mod_loc, module in modules.iteritems():
# Locations match if each value in `location` is None or if the value from `location`
# matches the value from `mod_loc`
if all(goal is None or goal == value for goal, value in zip(location, mod_loc)):
items.append(module)
category = kwargs.pop('category', None)
name = kwargs.pop('name', None)
if course_id is None:
for _, modules in self.modules.iteritems():
_add_get_items(self, location, modules)
else:
_add_get_items(self, location, self.modules[course_id])
def _block_matches_all(mod_loc, module):
if category and mod_loc.category != category:
return False
if name and mod_loc.name != name:
return False
return all(
self._block_matches(module, fields or {})
for fields in [settings, content, kwargs]
)
for mod_loc, module in self.modules[course_id].iteritems():
if _block_matches_all(mod_loc, module):
items.append(module)
return items
@@ -750,7 +776,7 @@ class XMLModuleStore(ModuleStoreReadBase):
"""
return dict((k, self.errored_courses[k].errors) for k in self.errored_courses)
def get_orphans(self, course_location, _branch):
def get_orphans(self, course_key):
"""
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
@@ -759,28 +785,17 @@ class XMLModuleStore(ModuleStoreReadBase):
# here just to quell the abstractmethod. someone could write the impl if needed
raise NotImplementedError
def update_item(self, xblock, user, **kwargs):
"""
Set the data in the item specified by the location to
data
location: Something that can be passed to Location
data: A nested dictionary of problem data
"""
raise NotImplementedError("XMLModuleStores are read-only")
def get_parent_locations(self, location, course_id):
def get_parent_locations(self, location):
'''Find all locations that are the parents of this location in this
course. Needed for path_to_location().
returns an iterable of things that can be passed to Location. This may
be empty if there are no parents.
'''
location = Location.ensure_fully_specified(location)
if not self.parent_trackers[course_id].is_known(location):
raise ItemNotFoundError("{0} not in {1}".format(location, course_id))
if not self.parent_trackers[location.course_key].is_known(location):
raise ItemNotFoundError("{0} not in {1}".format(location, location.course_key))
return self.parent_trackers[course_id].parents(location)
return self.parent_trackers[location.course_key].parents(location)
def get_modulestore_type(self, course_id):
"""

View File

@@ -32,7 +32,7 @@ class EdxJSONEncoder(json.JSONEncoder):
"""
def default(self, obj):
if isinstance(obj, Location):
return obj.url()
return obj.to_deprecated_string()
elif isinstance(obj, datetime.datetime):
if obj.tzinfo is not None:
if obj.utcoffset() is None:
@@ -45,24 +45,23 @@ class EdxJSONEncoder(json.JSONEncoder):
return super(EdxJSONEncoder, self).default(obj)
def export_to_xml(modulestore, contentstore, course_location, root_dir, course_dir, draft_modulestore=None):
def export_to_xml(modulestore, contentstore, course_key, root_dir, course_dir, draft_modulestore=None):
"""
Export all modules from `modulestore` and content from `contentstore` as xml to `root_dir`.
`modulestore`: A `ModuleStore` object that is the source of the modules to export
`contentstore`: A `ContentStore` object that is the source of the content to export, can be None
`course_location`: The `Location` of the `CourseModuleDescriptor` to export
`course_key`: The `CourseKey` of the `CourseModuleDescriptor` to export
`root_dir`: The directory to write the exported xml to
`course_dir`: The name of the directory inside `root_dir` to write the course content to
`draft_modulestore`: An optional `DraftModuleStore` that contains draft content, which will be exported
alongside the public content in the course.
"""
course_id = course_location.course_id
course = modulestore.get_course(course_id)
course = modulestore.get_course(course_key)
fs = OSFS(root_dir)
export_fs = course.runtime.export_fs = fs.makeopendir(course_dir)
fsm = OSFS(root_dir)
export_fs = course.runtime.export_fs = fsm.makeopendir(course_dir)
root = lxml.etree.Element('unknown')
course.add_xml_to_node(root)
@@ -74,22 +73,22 @@ def export_to_xml(modulestore, contentstore, course_location, root_dir, course_d
policies_dir = export_fs.makeopendir('policies')
if contentstore:
contentstore.export_all_for_course(
course_location,
course_key,
root_dir + '/' + course_dir + '/static/',
root_dir + '/' + course_dir + '/policies/assets.json',
)
# export the static tabs
export_extra_content(export_fs, modulestore, course_id, course_location, 'static_tab', 'tabs', '.html')
export_extra_content(export_fs, modulestore, course_key, 'static_tab', 'tabs', '.html')
# export the custom tags
export_extra_content(export_fs, modulestore, course_id, course_location, 'custom_tag_template', 'custom_tags')
export_extra_content(export_fs, modulestore, course_key, 'custom_tag_template', 'custom_tags')
# export the course updates
export_extra_content(export_fs, modulestore, course_id, course_location, 'course_info', 'info', '.html')
export_extra_content(export_fs, modulestore, course_key, 'course_info', 'info', '.html')
# export the 'about' data (e.g. overview, etc.)
export_extra_content(export_fs, modulestore, course_id, course_location, 'about', 'about', '.html')
export_extra_content(export_fs, modulestore, course_key, 'about', 'about', '.html')
# export the grading policy
course_run_policy_dir = policies_dir.makeopendir(course.location.name)
@@ -106,18 +105,17 @@ def export_to_xml(modulestore, contentstore, course_location, root_dir, course_d
# should we change the application, then this assumption will no longer
# be valid
if draft_modulestore is not None:
draft_verticals = draft_modulestore.get_items([None, course_location.org, course_location.course,
'vertical', None, 'draft'])
draft_verticals = draft_modulestore.get_items(course_key, category='vertical')
if len(draft_verticals) > 0:
draft_course_dir = export_fs.makeopendir(DRAFT_DIR)
for draft_vertical in draft_verticals:
parent_locs = draft_modulestore.get_parent_locations(draft_vertical.location, course.location.course_id)
parent_locs = draft_modulestore.get_parent_locations(draft_vertical.location)
# Don't try to export orphaned items.
if len(parent_locs) > 0:
logging.debug('parent_locs = {0}'.format(parent_locs))
draft_vertical.xml_attributes['parent_sequential_url'] = Location(parent_locs[0]).url()
sequential = modulestore.get_item(Location(parent_locs[0]))
index = sequential.children.index(draft_vertical.location.url())
draft_vertical.xml_attributes['parent_sequential_url'] = parent_locs[0].to_deprecated_string()
sequential = modulestore.get_item(parent_locs[0])
index = sequential.children.index(draft_vertical.location)
draft_vertical.xml_attributes['index_in_children_list'] = str(index)
draft_vertical.runtime.export_fs = draft_course_dir
node = lxml.etree.Element('unknown')
@@ -138,9 +136,8 @@ def _export_field_content(xblock_item, item_dir):
field_content_file.write(dumps(module_data.get(field_name, {}), cls=EdxJSONEncoder))
def export_extra_content(export_fs, modulestore, course_id, course_location, category_type, dirname, file_suffix=''):
query_loc = Location('i4x', course_location.org, course_location.course, category_type, None)
items = modulestore.get_items(query_loc, course_id)
def export_extra_content(export_fs, modulestore, course_key, category_type, dirname, file_suffix=''):
items = modulestore.get_items(course_key, category=category_type)
if len(items) > 0:
item_dir = export_fs.makeopendir(dirname)

View File

@@ -5,22 +5,23 @@ from path import path
import json
from .xml import XMLModuleStore, ImportSystem, ParentTracker
from xmodule.modulestore import Location
from xblock.runtime import KvsFieldData, DictKeyValueStore
from xmodule.x_module import XModuleDescriptor
from xmodule.modulestore.keys import UsageKey
from xblock.fields import Scope, Reference, ReferenceList, ReferenceValueDict
from xmodule.contentstore.content import StaticContent
from .inheritance import own_metadata
from xmodule.errortracker import make_error_tracker
from .store_utilities import rewrite_nonportable_content_links
import xblock
from xmodule.tabs import CourseTabList
log = logging.getLogger(__name__)
def import_static_content(
modules, course_loc, course_data_path, static_content_store,
target_location_namespace, subpath='static', verbose=False):
course_data_path, static_content_store,
target_course_id, subpath='static', verbose=False):
remap_dict = {}
@@ -65,12 +66,9 @@ def import_static_content(
fullname_with_subpath = content_path.replace(static_dir, '')
if fullname_with_subpath.startswith('/'):
fullname_with_subpath = fullname_with_subpath[1:]
content_loc = StaticContent.compute_location(
target_location_namespace.org, target_location_namespace.course,
fullname_with_subpath
)
asset_key = StaticContent.compute_location(target_course_id, fullname_with_subpath)
policy_ele = policy.get(content_loc.name, {})
policy_ele = policy.get(asset_key.path, {})
displayname = policy_ele.get('displayname', filename)
locked = policy_ele.get('locked', False)
mime_type = policy_ele.get('contentType')
@@ -79,7 +77,7 @@ def import_static_content(
if not mime_type or mime_type not in mimetypes_list:
mime_type = mimetypes.guess_type(filename)[0] # Assign guessed mimetype
content = StaticContent(
content_loc, displayname, mime_type, data,
asset_key, displayname, mime_type, data,
import_path=fullname_with_subpath, locked=locked
)
@@ -99,7 +97,7 @@ def import_static_content(
# store the remapping information which will be needed
# to subsitute in the module data
remap_dict[fullname_with_subpath] = content_loc.name
remap_dict[fullname_with_subpath] = asset_key
return remap_dict
@@ -108,8 +106,8 @@ def import_from_xml(
store, data_dir, course_dirs=None,
default_class='xmodule.raw_module.RawDescriptor',
load_error_modules=True, static_content_store=None,
target_location_namespace=None, verbose=False, draft_store=None,
do_import_static=True):
target_course_id=None, verbose=False, draft_store=None,
do_import_static=True, create_new_course=False):
"""
Import the specified xml data_dir into the "store" modulestore,
using org and course as the location org and course.
@@ -117,8 +115,7 @@ def import_from_xml(
course_dirs: If specified, the list of course_dirs to load. Otherwise, load
all course dirs
target_location_namespace is the namespace [passed as Location]
(i.e. {tag},{org},{course}) that all modules in the should be remapped to
target_course_id is the CourseKey that all modules should be remapped to
after import off disk. We do this remapping as a post-processing step
because there's logic in the importing which expects a 'url_name' as an
identifier to where things are on disk
@@ -132,6 +129,9 @@ def import_from_xml(
time the course is loaded. Static content for some courses may also be
served directly by nginx, instead of going through django.
: create_new_course:
If True, then courses whose ids already exist in the store are not imported.
The check for existing courses is case-insensitive.
"""
xml_module_store = XMLModuleStore(
@@ -143,6 +143,12 @@ def import_from_xml(
xblock_select=store.xblock_select,
)
# If we're going to remap the course_id, then we can only do that with
# a single course
if target_course_id:
assert(len(xml_module_store.modules) == 1)
# NOTE: the XmlModuleStore does not implement get_items()
# which would be a preferable means to enumerate the entire collection
# of course modules. It will be left as a TBD to implement that
@@ -150,21 +156,28 @@ def import_from_xml(
course_items = []
for course_id in xml_module_store.modules.keys():
if target_location_namespace is not None:
pseudo_course_id = u'{0.org}/{0.course}'.format(target_location_namespace)
if target_course_id is not None:
dest_course_id = target_course_id
else:
course_id_components = Location.parse_course_id(course_id)
pseudo_course_id = u'{org}/{course}'.format(**course_id_components)
dest_course_id = course_id
if create_new_course:
if store.has_course(dest_course_id, ignore_case=True):
log.debug(
"Skipping import of course with id, {0},"
"since it collides with an existing one".format(dest_course_id)
)
continue
else:
store.create_course(dest_course_id.org, dest_course_id.offering)
try:
# turn off all write signalling while importing as this
# is a high volume operation on stores that need it
if (hasattr(store, 'ignore_write_events_on_courses') and
pseudo_course_id not in store.ignore_write_events_on_courses):
store.ignore_write_events_on_courses.append(pseudo_course_id)
if hasattr(store, 'ignore_write_events_on_courses'):
store.ignore_write_events_on_courses.add(dest_course_id)
course_data_path = None
course_location = None
if verbose:
log.debug("Scanning {0} for course module...".format(course_id))
@@ -175,40 +188,11 @@ def import_from_xml(
for module in xml_module_store.modules[course_id].itervalues():
if module.scope_ids.block_type == 'course':
course_data_path = path(data_dir) / module.data_dir
course_location = module.location
course_prefix = u'{0.org}/{0.course}'.format(course_location)
# Check to see if a course with the same
# pseudo_course_id, but different run exists in
# the passed store to avoid broken courses
courses = store.get_courses()
bad_run = False
for course in courses:
if course.location.course_id.startswith(course_prefix):
log.debug('Import is overwriting existing course')
# Importing over existing course, check
# that runs match or fail
if course.location.name != module.location.name:
log.error(
'A course with ID %s exists, and this '
'course has the same organization and '
'course number, but a different term that '
'is fully identified as %s.',
course.location.course_id,
module.location.course_id
)
bad_run = True
break
if bad_run:
# Skip this course, but keep trying to import courses
continue
log.debug('======> IMPORTING course to location {loc}'.format(
loc=course_location
log.debug(u'======> IMPORTING course {course_id}'.format(
course_id=course_id,
))
module = remap_namespace(module, target_location_namespace)
if not do_import_static:
# for old-style xblock where this was actually linked to kvs
module.static_asset_path = module.data_dir
@@ -219,6 +203,35 @@ def import_from_xml(
log.debug('course data_dir={0}'.format(module.data_dir))
course = import_module(
module, store,
course_id,
dest_course_id,
do_import_static=do_import_static
)
for entry in course.pdf_textbooks:
for chapter in entry.get('chapters', []):
if StaticContent.is_c4x_path(chapter.get('url', '')):
asset_key = StaticContent.get_location_from_path(chapter['url'])
chapter['url'] = StaticContent.get_static_path_from_location(asset_key)
# Original wiki_slugs had value location.course. To make them unique this was changed to 'org.course.name'.
# If we are importing into a course with a different course_id and wiki_slug is equal to either of these default
# values then remap it so that the wiki does not point to the old wiki.
if course_id != course.id:
original_unique_wiki_slug = u'{0}.{1}.{2}'.format(
course_id.org,
course_id.course,
course_id.run
)
if course.wiki_slug == original_unique_wiki_slug or course.wiki_slug == course_id.course:
course.wiki_slug = u'{0}.{1}.{2}'.format(
course.id.org,
course.id.course,
course.id.run,
)
# cdodge: more hacks (what else). Seems like we have a
# problem when importing a course (like 6.002) which
# does not have any tabs defined in the policy file.
@@ -227,36 +240,19 @@ def import_from_xml(
# the LMS barfs because it expects that -- if there are
# *any* tabs -- then there at least needs to be
# some predefined ones
if module.tabs is None or len(module.tabs) == 0:
module.tabs = [
{"type": "courseware"},
{"type": "course_info", "name": "Course Info"},
{"type": "discussion", "name": "Discussion"},
{"type": "wiki", "name": "Wiki"},
# note, add 'progress' when we can support it on Edge
]
if course.tabs is None or len(course.tabs) == 0:
CourseTabList.initialize_default(course)
import_module(
module, store, course_data_path, static_content_store,
course_location,
target_location_namespace or course_location,
do_import_static=do_import_static
)
store.update_item(course)
course_items.append(module)
course_items.append(course)
# then import all the static content
if static_content_store is not None and do_import_static:
if target_location_namespace is not None:
_namespace_rename = target_location_namespace
else:
_namespace_rename = course_location
# first pass to find everything in /static/
import_static_content(
xml_module_store.modules[course_id], course_location,
course_data_path, static_content_store,
_namespace_rename, subpath='static', verbose=verbose
dest_course_id, subpath='static', verbose=verbose
)
elif verbose and not do_import_static:
@@ -277,15 +273,9 @@ def import_from_xml(
simport = 'static_import'
if os.path.exists(course_data_path / simport):
if target_location_namespace is not None:
_namespace_rename = target_location_namespace
else:
_namespace_rename = course_location
import_static_content(
xml_module_store.modules[course_id], course_location,
course_data_path, static_content_store,
_namespace_rename, subpath=simport, verbose=verbose
dest_course_id, subpath=simport, verbose=verbose
)
# finally loop through all the modules
@@ -295,20 +285,17 @@ def import_from_xml(
# of the loop so just skip over it in the inner loop
continue
# remap module to the new namespace
if target_location_namespace is not None:
module = remap_namespace(module, target_location_namespace)
if verbose:
log.debug('importing module location {loc}'.format(
loc=module.location
))
import_module(
module, store, course_data_path, static_content_store,
course_location,
target_location_namespace if target_location_namespace else course_location,
do_import_static=do_import_static
module, store,
course_id,
dest_course_id,
do_import_static=do_import_static,
system=course.runtime
)
# now import any 'draft' items
@@ -319,51 +306,93 @@ def import_from_xml(
draft_store,
course_data_path,
static_content_store,
course_location,
target_location_namespace if target_location_namespace else course_location
course_id,
dest_course_id,
course.runtime
)
finally:
# turn back on all write signalling on stores that need it
if (hasattr(store, 'ignore_write_events_on_courses') and
pseudo_course_id in store.ignore_write_events_on_courses):
store.ignore_write_events_on_courses.remove(pseudo_course_id)
store.refresh_cached_metadata_inheritance_tree(
target_location_namespace if target_location_namespace is not None else course_location
)
dest_course_id in store.ignore_write_events_on_courses):
store.ignore_write_events_on_courses.remove(dest_course_id)
store.refresh_cached_metadata_inheritance_tree(dest_course_id)
return xml_module_store, course_items
def import_module(
module, store, course_data_path, static_content_store,
source_course_location, dest_course_location, allow_not_found=False,
do_import_static=True):
module, store,
source_course_id, dest_course_id,
do_import_static=True, system=None):
logging.debug(u'processing import of module {}...'.format(module.location.url()))
logging.debug(u'processing import of module {}...'.format(module.location.to_deprecated_string()))
if do_import_static and 'data' in module.fields and isinstance(module.fields['data'], xblock.fields.String):
# we want to convert all 'non-portable' links in the module_data
# (if it is a string) to portable strings (e.g. /static/)
module.data = rewrite_nonportable_content_links(
source_course_location.course_id,
dest_course_location.course_id, module.data
source_course_id,
dest_course_id,
module.data
)
# remove any export/import only xml_attributes
# which are used to wire together draft imports
if 'parent_sequential_url' in getattr(module, 'xml_attributes', []):
del module.xml_attributes['parent_sequential_url']
if 'index_in_children_list' in getattr(module, 'xml_attributes', []):
del module.xml_attributes['index_in_children_list']
# Move the module to a new course
new_usage_key = module.scope_ids.usage_id.map_into_course(dest_course_id)
if new_usage_key.category == 'course':
new_usage_key = new_usage_key.replace(name=dest_course_id.run)
new_module = store.create_xmodule(new_usage_key, system=system)
store.update_item(module, '**replace_user**', allow_not_found=allow_not_found)
def _convert_reference_fields_to_new_namespace(reference):
"""
Convert a reference to the new namespace, but only
if the original namespace matched the original course.
Otherwise, returns the input value.
"""
assert isinstance(reference, UsageKey)
if source_course_id == reference.course_key:
return reference.map_into_course(dest_course_id)
else:
return reference
for field_name, field in module.fields.iteritems():
if field.is_set_on(module):
if isinstance(field, Reference):
new_ref = _convert_reference_fields_to_new_namespace(getattr(module, field_name))
setattr(new_module, field_name, new_ref)
elif isinstance(field, ReferenceList):
references = getattr(module, field_name)
new_references = [_convert_reference_fields_to_new_namespace(reference) for reference in references]
setattr(new_module, field_name, new_references)
elif isinstance(field, ReferenceValueDict):
reference_dict = getattr(module, field_name)
new_reference_dict = {
key: _convert_reference_fields_to_new_namespace(reference)
for key, reference
in reference_dict.items()
}
setattr(new_module, field_name, new_reference_dict)
elif field_name == 'xml_attributes':
value = getattr(module, field_name)
# remove any export/import only xml_attributes
# which are used to wire together draft imports
if 'parent_sequential_url' in value:
del value['parent_sequential_url']
if 'index_in_children_list' in value:
del value['index_in_children_list']
setattr(new_module, field_name, value)
else:
setattr(new_module, field_name, getattr(module, field_name))
store.update_item(new_module, '**replace_user**', allow_not_found=True)
return new_module
def import_course_draft(
xml_module_store, store, draft_store, course_data_path,
static_content_store, source_location_namespace,
target_location_namespace):
static_content_store, source_course_id,
target_course_id, mongo_runtime):
'''
This will import all the content inside of the 'drafts' folder, if it exists
NOTE: This is not a full course import, basically in our current
@@ -388,7 +417,7 @@ def import_course_draft(
draft_course_dir = draft_dir.replace(data_dir, '', 1)
system = ImportSystem(
xmlstore=xml_module_store,
course_id=target_location_namespace.course_id,
course_id=target_course_id,
course_dir=draft_course_dir,
error_tracker=errorlog.tracker,
parent_tracker=ParentTracker(),
@@ -458,14 +487,13 @@ def import_course_draft(
else:
drafts[index] = [descriptor]
except Exception, e:
logging.exception('There was an error. {err}'.format(
err=unicode(e)
))
except Exception:
logging.exception('Error while parsing course xml.')
# For each index_in_children_list key, there is a list of vertical descriptors.
for key in sorted(drafts.iterkeys()):
for descriptor in drafts[key]:
course_key = descriptor.location.course_key
try:
def _import_module(module):
# Update the module's location to "draft" revision
@@ -482,141 +510,29 @@ def import_course_draft(
sequential_url = module.xml_attributes['parent_sequential_url']
index = int(module.xml_attributes['index_in_children_list'])
seq_location = Location(sequential_url)
seq_location = course_key.make_usage_key_from_deprecated_string(sequential_url)
# IMPORTANT: Be sure to update the sequential
# in the NEW namespace
seq_location = seq_location.replace(
org=target_location_namespace.org,
course=target_location_namespace.course
)
seq_location = seq_location.map_into_course(target_course_id)
sequential = store.get_item(seq_location, depth=0)
if non_draft_location.url() not in sequential.children:
sequential.children.insert(index, non_draft_location.url())
if non_draft_location not in sequential.children:
sequential.children.insert(index, non_draft_location)
store.update_item(sequential, '**replace_user**')
import_module(
module, draft_store, course_data_path,
static_content_store, source_location_namespace,
target_location_namespace, allow_not_found=True
module, draft_store,
source_course_id,
target_course_id, system=mongo_runtime
)
for child in module.get_children():
_import_module(child)
_import_module(descriptor)
except Exception, e:
logging.exception('There was an error. {err}'.format(
err=unicode(e)
))
def remap_namespace(module, target_location_namespace):
if target_location_namespace is None:
return module
original_location = module.location
# This looks a bit wonky as we need to also change the 'name' of the
# imported course to be what the caller passed in
if module.location.category != 'course':
_update_module_location(
module,
module.location.replace(
tag=target_location_namespace.tag,
org=target_location_namespace.org,
course=target_location_namespace.course
)
)
else:
#
# module is a course module
#
module.location = module.location.replace(
tag=target_location_namespace.tag,
org=target_location_namespace.org,
course=target_location_namespace.course,
name=target_location_namespace.name
)
# There is more re-namespacing work we have to do when
# importing course modules
# remap pdf_textbook urls to portable static URLs
for entry in module.pdf_textbooks:
for chapter in entry.get('chapters', []):
if StaticContent.is_c4x_path(chapter.get('url', '')):
chapter_loc = StaticContent.get_location_from_path(chapter['url'])
chapter['url'] = StaticContent.get_static_path_from_location(
chapter_loc
)
# Original wiki_slugs had value location.course. To make them unique this was changed to 'org.course.name'.
# If we are importing into a course with a different course_id and wiki_slug is equal to either of these default
# values then remap it so that the wiki does not point to the old wiki.
if original_location.course_id != target_location_namespace.course_id:
original_unique_wiki_slug = u'{0}.{1}.{2}'.format(
original_location.org,
original_location.course,
original_location.name
)
if module.wiki_slug == original_unique_wiki_slug or module.wiki_slug == original_location.course:
module.wiki_slug = u'{0}.{1}.{2}'.format(
target_location_namespace.org,
target_location_namespace.course,
target_location_namespace.name,
)
module.save()
all_fields = module.get_explicitly_set_fields_by_scope(Scope.content)
all_fields.update(module.get_explicitly_set_fields_by_scope(Scope.settings))
if hasattr(module, 'children'):
all_fields['children'] = module.children
def convert_ref(reference):
"""
Convert a reference to the new namespace, but only
if the original namespace matched the original course.
Otherwise, returns the input value.
"""
new_ref = reference
ref = Location(reference)
in_original_namespace = (original_location.tag == ref.tag and
original_location.org == ref.org and
original_location.course == ref.course)
if in_original_namespace:
new_ref = ref.replace(
tag=target_location_namespace.tag,
org=target_location_namespace.org,
course=target_location_namespace.course
).url()
return new_ref
for field_name in all_fields:
field_object = module.fields.get(field_name)
if isinstance(field_object, Reference):
new_ref = convert_ref(getattr(module, field_name))
setattr(module, field_name, new_ref)
module.save()
elif isinstance(field_object, ReferenceList):
references = getattr(module, field_name)
new_references = [convert_ref(reference) for reference in references]
setattr(module, field_name, new_references)
module.save()
elif isinstance(field_object, ReferenceValueDict):
reference_dict = getattr(module, field_name)
new_reference_dict = {
key: convert_ref(reference)
for key, reference
in reference_dict.items()
}
setattr(module, field_name, new_reference_dict)
module.save()
return module
except Exception:
logging.exception('There while importing draft descriptor %s', descriptor)
def allowed_metadata_by_category(category):
@@ -648,7 +564,7 @@ def check_module_metadata_editability(module):
print(
": found non-editable metadata on {url}. "
"These metadata keys are not supported = {keys}".format(
url=module.location.url(), keys=illegal_keys
url=module.location.to_deprecated_string(), keys=illegal_keys
)
)
@@ -676,7 +592,7 @@ def validate_category_hierarchy(
parents.append(module)
for parent in parents:
for child_loc in [Location(child) for child in parent.children]:
for child_loc in parent.children:
if child_loc.category != expected_child_category:
err_cnt += 1
print(
@@ -767,7 +683,7 @@ def perform_xlint(
warn_cnt += _warn_cnt
# first count all errors and warnings as part of the XMLModuleStore import
for err_log in module_store._location_errors.itervalues():
for err_log in module_store._course_errors.itervalues():
for err_log_entry in err_log.errors:
msg = err_log_entry[0]
if msg.startswith('ERROR:'):
@@ -815,12 +731,7 @@ def perform_xlint(
)
# check for a presence of a course marketing video
location_elements = Location.parse_course_id(course_id)
location_elements['tag'] = 'i4x'
location_elements['category'] = 'about'
location_elements['name'] = 'video'
loc = Location(location_elements)
if loc not in module_store.modules[course_id]:
if not module_store.has_item(course_id.make_usage_key('about', 'video')):
print(
"WARN: Missing course marketing video. It is recommended "
"that every course have a marketing video."

View File

@@ -412,7 +412,7 @@ class CombinedOpenEndedV1Module():
:param message: A message to put in the log.
:return: None
"""
info_message = "Combined open ended user state for user {0} in location {1} was invalid. It has been reset, and you now have a new attempt. {2}".format(self.system.anonymous_student_id, self.location.url(), message)
info_message = "Combined open ended user state for user {0} in location {1} was invalid. It has been reset, and you now have a new attempt. {2}".format(self.system.anonymous_student_id, self.location.to_deprecated_string(), message)
self.current_task_number = 0
self.student_attempts = 0
self.old_task_states.append(self.task_states)
@@ -800,7 +800,7 @@ class CombinedOpenEndedV1Module():
success = False
allowed_to_submit = True
try:
response = self.peer_gs.get_data_for_location(self.location.url(), student_id)
response = self.peer_gs.get_data_for_location(self.location.to_deprecated_string(), student_id)
count_graded = response['count_graded']
count_required = response['count_required']
student_sub_count = response['student_sub_count']

View File

@@ -96,7 +96,7 @@ class CombinedOpenEndedRubric(object):
if not success:
#This is a staff_facing_error
error_message = "Could not parse rubric : {0} for location {1}. Contact the learning sciences group for assistance.".format(
rubric_string, location.url())
rubric_string, location.to_deprecated_string())
log.error(error_message)
raise RubricParsingError(error_message)

View File

@@ -105,7 +105,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
# NOTE: self.system.location is valid because the capa_module
# __init__ adds it (easiest way to get problem location into
# response types)
except TypeError, ValueError:
except (TypeError, ValueError):
# This is a dev_facing_error
log.exception(
"Grader payload from external open ended grading server is not a json object! Object: {0}".format(
@@ -116,7 +116,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
parsed_grader_payload.update({
'location': self.location_string,
'course_id': system.course_id,
'course_id': system.course_id.to_deprecated_string(),
'prompt': prompt_string,
'rubric': rubric_string,
'initial_display': self.initial_display,

View File

@@ -157,7 +157,7 @@ class OpenEndedChild(object):
self.location_string = location
try:
self.location_string = self.location_string.url()
self.location_string = self.location_string.to_deprecated_string()
except:
pass

View File

@@ -87,6 +87,11 @@ class PeerGradingService(GradingService):
def get_problem_list(self, course_id, grader_id):
params = {'course_id': course_id, 'student_id': grader_id}
result = self.get(self.get_problem_list_url, params)
if 'problem_list' in result:
for problem in result['problem_list']:
problem['location'] = course_id.make_usage_key_from_deprecated_string(problem['location'])
self._record_result('get_problem_list', result)
dog_stats_api.histogram(
self._metric_name('get_problem_list.result.length'),

View File

@@ -8,7 +8,6 @@ from xblock.fields import Dict, String, Scope, Boolean, Float, Reference
from xmodule.capa_module import ComplexEncoder
from xmodule.fields import Date, Timedelta
from xmodule.modulestore import Location
from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem
from xmodule.raw_module import RawDescriptor
from xmodule.timeinfo import TimeInfo
@@ -261,7 +260,7 @@ class PeerGradingModule(PeerGradingFields, XModule):
if not success:
log.exception(
"No instance data found and could not get data from controller for loc {0} student {1}".format(
self.system.location.url(), self.system.anonymous_student_id
self.system.location.to_deprecated_string(), self.system.anonymous_student_id
))
return None
count_graded = response['count_graded']
@@ -563,7 +562,7 @@ class PeerGradingModule(PeerGradingFields, XModule):
good_problem_list = []
for problem in problem_list:
problem_location = Location(problem['location'])
problem_location = problem['location']
try:
descriptor = self._find_corresponding_module_for_location(problem_location)
except (NoPathToItem, ItemNotFoundError):
@@ -588,7 +587,6 @@ class PeerGradingModule(PeerGradingFields, XModule):
ajax_url = self.ajax_url
html = self.system.render_template('peer_grading/peer_grading.html', {
'course_id': self.course_id,
'ajax_url': ajax_url,
'success': success,
'problem_list': good_problem_list,
@@ -611,10 +609,10 @@ class PeerGradingModule(PeerGradingFields, XModule):
log.error(
"Peer grading problem in peer_grading_module called with no get parameters, but use_for_single_location is False.")
return {'html': "", 'success': False}
problem_location = Location(self.link_to_location)
problem_location = self.link_to_location
elif data.get('location') is not None:
problem_location = Location(data.get('location'))
problem_location = self.course_id.make_usage_key_from_deprecated_string(data.get('location'))
module = self._find_corresponding_module_for_location(problem_location)

View File

@@ -96,7 +96,7 @@ class SequenceModule(SequenceFields, XModule):
'progress_status': Progress.to_js_status_str(progress),
'progress_detail': Progress.to_js_detail_str(progress),
'type': child.get_icon_class(),
'id': child.id,
'id': child.scope_ids.usage_id.to_deprecated_string(),
}
if childinfo['title'] == '':
childinfo['title'] = child.display_name_with_default
@@ -104,7 +104,7 @@ class SequenceModule(SequenceFields, XModule):
params = {'items': contents,
'element_id': self.location.html_id(),
'item_id': self.id,
'item_id': self.location.to_deprecated_string(),
'position': self.position,
'tag': self.location.category,
'ajax_url': self.system.ajax_url,

View File

@@ -82,7 +82,7 @@ class SplitTestModule(SplitTestFields, XModule):
# we've picked a choice. Use self.descriptor.get_children() instead.
for child in self.descriptor.get_children():
if child.location.url() == location:
if child.location == location:
return child
return None
@@ -182,7 +182,7 @@ class SplitTestModule(SplitTestFields, XModule):
fragment.add_frag_resources(rendered_child)
contents.append({
'id': child.id,
'id': child.location.to_deprecated_string(),
'content': rendered_child.content
})
@@ -252,7 +252,11 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor):
def definition_to_xml(self, resource_fs):
xml_object = etree.Element('split_test')
xml_object.set('group_id_to_child', json.dumps(self.group_id_to_child))
renderable_groups = {}
# json.dumps doesn't know how to handle Location objects
for group in self.group_id_to_child:
renderable_groups[group] = self.group_id_to_child[group].to_deprecated_string()
xml_object.set('group_id_to_child', json.dumps(renderable_groups))
xml_object.set('user_partition_id', str(self.user_partition_id))
for child in self.get_children():
self.runtime.add_block_as_child_node(child, xml_object)

View File

@@ -456,7 +456,7 @@ class StaticTab(CourseTab):
super(StaticTab, self).__init__(
name=tab_dict['name'] if tab_dict else name,
tab_id='static_tab_{0}'.format(self.url_slug),
link_func=lambda course, reverse_func: reverse_func(self.type, args=[course.id, self.url_slug]),
link_func=lambda course, reverse_func: reverse_func(self.type, args=[course.id.to_deprecated_string(), self.url_slug]),
)
def __getitem__(self, key):
@@ -537,7 +537,7 @@ class TextbookTabs(TextbookTabsBase):
yield SingleTextbookTab(
name=textbook.title,
tab_id='textbook/{0}'.format(index),
link_func=lambda course, reverse_func: reverse_func('book', args=[course.id, index]),
link_func=lambda course, reverse_func: reverse_func('book', args=[course.id.to_deprecated_string(), index]),
)
@@ -557,7 +557,7 @@ class PDFTextbookTabs(TextbookTabsBase):
yield SingleTextbookTab(
name=textbook['tab_title'],
tab_id='pdftextbook/{0}'.format(index),
link_func=lambda course, reverse_func: reverse_func('pdf_book', args=[course.id, index]),
link_func=lambda course, reverse_func: reverse_func('pdf_book', args=[course.id.to_deprecated_string(), index]),
)
@@ -577,7 +577,7 @@ class HtmlTextbookTabs(TextbookTabsBase):
yield SingleTextbookTab(
name=textbook['tab_title'],
tab_id='htmltextbook/{0}'.format(index),
link_func=lambda course, reverse_func: reverse_func('html_book', args=[course.id, index]),
link_func=lambda course, reverse_func: reverse_func('html_book', args=[course.id.to_deprecated_string(), index]),
)
@@ -884,7 +884,7 @@ def link_reverse_func(reverse_name):
Returns a function that takes in a course and reverse_url_func,
and calls the reverse_url_func with the given reverse_name and course' ID.
"""
return lambda course, reverse_url_func: reverse_url_func(reverse_name, args=[course.id])
return lambda course, reverse_url_func: reverse_url_func(reverse_name, args=[course.id.to_deprecated_string()])
def link_value_func(value):

View File

@@ -16,12 +16,13 @@ from mock import Mock
from path import path
from xblock.field_data import DictFieldData
from xblock.fields import ScopeIds
from xmodule.x_module import ModuleSystem, XModuleDescriptor, XModuleMixin
from xmodule.modulestore.inheritance import InheritanceMixin
from xmodule.modulestore.locations import SlashSeparatedCourseKey
from xmodule.mako_module import MakoDescriptorSystem
from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore.xml import LocationReader
MODULE_DIR = path(__file__).dirname()
@@ -45,13 +46,21 @@ class TestModuleSystem(ModuleSystem): # pylint: disable=abstract-method
ModuleSystem for testing
"""
def handler_url(self, block, handler, suffix='', query='', thirdparty=False):
return str(block.scope_ids.usage_id) + '/' + handler + '/' + suffix + '?' + query
return '{usage_id}/{handler}{suffix}?{query}'.format(
usage_id=block.scope_ids.usage_id.to_deprecated_string(),
handler=handler,
suffix=suffix,
query=query,
)
def local_resource_url(self, block, uri):
return 'resource/' + str(block.scope_ids.block_type) + '/' + uri
return 'resource/{usage_id}/{uri}'.format(
usage_id=block.scope_ids.usage_id.to_deprecated_string(),
uri=uri,
)
def get_test_system(course_id=''):
def get_test_system(course_id=SlashSeparatedCourseKey('org', 'course', 'run')):
"""
Construct a test ModuleSystem instance.
@@ -96,7 +105,6 @@ def get_test_descriptor_system():
render_template=mock_render_template,
mixins=(InheritanceMixin, XModuleMixin),
field_data=DictFieldData({}),
id_reader=LocationReader(),
)
@@ -131,12 +139,15 @@ class LogicTest(unittest.TestCase):
url_name = ''
category = 'test'
self.system = get_test_system(course_id='test/course/id')
self.system = get_test_system()
self.descriptor = EmptyClass()
self.xmodule_class = self.descriptor_class.module_class
usage_key = self.system.course_id.make_usage_key(self.descriptor.category, 'test_loc')
# ScopeIds has 4 fields: user_id, block_type, def_id, usage_id
scope_ids = ScopeIds(1, self.descriptor.category, usage_key, usage_key)
self.xmodule = self.xmodule_class(
self.descriptor, self.system, DictFieldData(self.raw_field_data), Mock()
self.descriptor, self.system, DictFieldData(self.raw_field_data), scope_ids
)
def ajax_request(self, dispatch, data):

View File

@@ -35,7 +35,7 @@ class AnnotatableModuleTestCase(unittest.TestCase):
Mock(),
get_test_system(),
DictFieldData({'data': self.sample_xml}),
ScopeIds(None, None, None, None)
ScopeIds(None, None, None, Location('org', 'course', 'run', 'category', 'name', None))
)
def test_annotation_data_attr(self):

View File

@@ -101,8 +101,14 @@ class CapaFactory(object):
attempts: also added to instance state. Will be converted to an int.
"""
location = Location(["i4x", "edX", "capa_test", "problem",
"SampleProblem{0}".format(cls.next_num())])
location = Location(
"edX",
"capa_test",
"2012_Fall",
"problem",
"SampleProblem{0}".format(cls.next_num()),
None
)
if xml is None:
xml = cls.sample_problem_xml
field_data = {'data': xml}

View File

@@ -12,7 +12,7 @@ import unittest
from datetime import datetime
from lxml import etree
from mock import Mock, MagicMock, ANY, patch
from mock import Mock, MagicMock, patch
from pytz import UTC
from webob.multidict import MultiDict
@@ -20,7 +20,6 @@ from xmodule.open_ended_grading_classes.openendedchild import OpenEndedChild
from xmodule.open_ended_grading_classes.open_ended_module import OpenEndedModule
from xmodule.open_ended_grading_classes.self_assessment_module import SelfAssessmentModule
from xmodule.open_ended_grading_classes.combined_open_ended_modulev1 import CombinedOpenEndedV1Module
from xmodule.open_ended_grading_classes.grading_service_module import GradingServiceError
from xmodule.combined_open_ended_module import CombinedOpenEndedModule
from xmodule.modulestore import Location
from xmodule.tests import get_test_system, test_util_open_ended
@@ -48,8 +47,7 @@ class OpenEndedChildTest(unittest.TestCase):
"""
Test the open ended child class
"""
location = Location(["i4x", "edX", "sa_test", "selfassessment",
"SampleQuestion"])
location = Location("edX", "sa_test", "2012_Fall", "selfassessment", "SampleQuestion")
metadata = json.dumps({'attempts': '10'})
prompt = etree.XML("<prompt>This is a question prompt</prompt>")
@@ -173,8 +171,7 @@ class OpenEndedModuleTest(unittest.TestCase):
"""
Test the open ended module class
"""
location = Location(["i4x", "edX", "sa_test", "selfassessment",
"SampleQuestion"])
location = Location("edX", "sa_test", "2012_Fall", "selfassessment", "SampleQuestion")
metadata = json.dumps({'attempts': '10'})
prompt = etree.XML("<prompt>This is a question prompt</prompt>")
@@ -446,8 +443,7 @@ class CombinedOpenEndedModuleTest(unittest.TestCase):
"""
Unit tests for the combined open ended xmodule
"""
location = Location(["i4x", "edX", "open_ended", "combinedopenended",
"SampleQuestion"])
location = Location("edX", "open_ended", "2012_Fall", "combinedopenended", "SampleQuestion")
definition_template = """
<combinedopenended attempts="10000">
{rubric}
@@ -517,6 +513,9 @@ class CombinedOpenEndedModuleTest(unittest.TestCase):
descriptor = Mock(data=full_definition)
test_system = get_test_system()
test_system.open_ended_grading_interface = None
usage_key = test_system.course_id.make_usage_key('combinedopenended', 'test_loc')
# ScopeIds has 4 fields: user_id, block_type, def_id, usage_id
scope_ids = ScopeIds(1, 'combinedopenended', usage_key, usage_key)
combinedoe_container = CombinedOpenEndedModule(
descriptor=descriptor,
runtime=test_system,
@@ -524,7 +523,7 @@ class CombinedOpenEndedModuleTest(unittest.TestCase):
'data': full_definition,
'weight': '1',
}),
scope_ids=ScopeIds(None, None, None, None),
scope_ids=scope_ids,
)
def setUp(self):
@@ -799,8 +798,7 @@ class CombinedOpenEndedModuleConsistencyTest(unittest.TestCase):
# location, definition_template, prompt, rubric, max_score, metadata, oeparam, task_xml1, task_xml2
# All these variables are used to construct the xmodule descriptor.
location = Location(["i4x", "edX", "open_ended", "combinedopenended",
"SampleQuestion"])
location = Location("edX", "open_ended", "2012_Fall", "combinedopenended", "SampleQuestion")
definition_template = """
<combinedopenended attempts="10000">
{rubric}
@@ -871,6 +869,9 @@ class CombinedOpenEndedModuleConsistencyTest(unittest.TestCase):
descriptor = Mock(data=full_definition)
test_system = get_test_system()
test_system.open_ended_grading_interface = None
usage_key = test_system.course_id.make_usage_key('combinedopenended', 'test_loc')
# ScopeIds has 4 fields: user_id, block_type, def_id, usage_id
scope_ids = ScopeIds(1, 'combinedopenended', usage_key, usage_key)
combinedoe_container = CombinedOpenEndedModule(
descriptor=descriptor,
runtime=test_system,
@@ -878,7 +879,7 @@ class CombinedOpenEndedModuleConsistencyTest(unittest.TestCase):
'data': full_definition,
'weight': '1',
}),
scope_ids=ScopeIds(None, None, None, None),
scope_ids=scope_ids,
)
def setUp(self):
@@ -964,7 +965,7 @@ class OpenEndedModuleXmlTest(unittest.TestCase, DummyModulestore):
"""
Test the student flow in the combined open ended xmodule
"""
problem_location = Location(["i4x", "edX", "open_ended", "combinedopenended", "SampleQuestion"])
problem_location = Location("edX", "open_ended", "2012_Fall", "combinedopenended", "SampleQuestion")
answer = "blah blah"
assessment = [0, 1]
hint = "blah"
@@ -999,7 +1000,7 @@ class OpenEndedModuleXmlTest(unittest.TestCase, DummyModulestore):
return result
def _module(self):
return self.get_module_from_location(self.problem_location, COURSE)
return self.get_module_from_location(self.problem_location)
def test_open_ended_load_and_save(self):
"""
@@ -1212,7 +1213,7 @@ class OpenEndedModuleXmlAttemptTest(unittest.TestCase, DummyModulestore):
"""
Test if student is able to reset the problem
"""
problem_location = Location(["i4x", "edX", "open_ended", "combinedopenended", "SampleQuestion1Attempt"])
problem_location = Location("edX", "open_ended", "2012_Fall", "combinedopenended", "SampleQuestion1Attempt")
answer = "blah blah"
assessment = [0, 1]
hint = "blah"
@@ -1241,7 +1242,7 @@ class OpenEndedModuleXmlAttemptTest(unittest.TestCase, DummyModulestore):
return result
def _module(self):
return self.get_module_from_location(self.problem_location, COURSE)
return self.get_module_from_location(self.problem_location)
def test_reset_fail(self):
"""
@@ -1283,12 +1284,13 @@ class OpenEndedModuleXmlImageUploadTest(unittest.TestCase, DummyModulestore):
"""
Test if student is able to upload images properly.
"""
problem_location = Location(["i4x", "edX", "open_ended", "combinedopenended", "SampleQuestionImageUpload"])
problem_location = Location("edX", "open_ended", "2012_Fall", "combinedopenended", "SampleQuestionImageUpload")
answer_text = "Hello, this is my amazing answer."
file_text = "Hello, this is my amazing file."
file_name = "Student file 1"
answer_link = "http://www.edx.org"
autolink_tag = '<a target="_blank" href='
autolink_tag_swapped = '<a href='
def get_module_system(self, descriptor):
test_system = get_test_system()
@@ -1306,7 +1308,7 @@ class OpenEndedModuleXmlImageUploadTest(unittest.TestCase, DummyModulestore):
"""
Test to see if a student submission without a file attached fails.
"""
module = self.get_module_from_location(self.problem_location, COURSE)
module = self.get_module_from_location(self.problem_location)
# Simulate a student saving an answer
response = module.handle_ajax("save_answer", {"student_answer": self.answer_text})
@@ -1326,7 +1328,7 @@ class OpenEndedModuleXmlImageUploadTest(unittest.TestCase, DummyModulestore):
"""
Test to see if a student submission with a file is handled properly.
"""
module = self.get_module_from_location(self.problem_location, COURSE)
module = self.get_module_from_location(self.problem_location)
# Simulate a student saving an answer with a file
response = module.handle_ajax("save_answer", {
@@ -1338,13 +1340,14 @@ class OpenEndedModuleXmlImageUploadTest(unittest.TestCase, DummyModulestore):
response = json.loads(response)
self.assertTrue(response['success'])
self.assertIn(self.file_name, response['student_response'])
self.assertIn(self.autolink_tag, response['student_response'])
self.assertTrue(self.autolink_tag in response['student_response'] or
self.autolink_tag_swapped in response['student_response'])
def test_link_submission_success(self):
"""
Students can submit links instead of files. Check that the link is properly handled.
"""
module = self.get_module_from_location(self.problem_location, COURSE)
module = self.get_module_from_location(self.problem_location)
# Simulate a student saving an answer with a link.
response = module.handle_ajax("save_answer", {
@@ -1355,7 +1358,8 @@ class OpenEndedModuleXmlImageUploadTest(unittest.TestCase, DummyModulestore):
self.assertTrue(response['success'])
self.assertIn(self.answer_link, response['student_response'])
self.assertIn(self.autolink_tag, response['student_response'])
self.assertTrue(self.autolink_tag in response['student_response'] or
self.autolink_tag_swapped in response['student_response'])
class OpenEndedModuleUtilTest(unittest.TestCase):
@@ -1369,7 +1373,7 @@ class OpenEndedModuleUtilTest(unittest.TestCase):
embed_dirty = u'<embed height="200" id="cats" onhover="eval()" src="http://example.com/lolcats.swf" width="200"/>'
embed_clean = u'<embed width="200" height="200" id="cats" src="http://example.com/lolcats.swf">'
iframe_dirty = u'<iframe class="cats" height="200" onerror="eval()" src="http://example.com/lolcats" width="200"/>'
iframe_clean = u'<iframe height="200" class="cats" width="200" src="http://example.com/lolcats"></iframe>'
iframe_clean = ur'<iframe (height="200" ?|class="cats" ?|width="200" ?|src="http://example.com/lolcats" ?)+></iframe>'
text = u'I am a \u201c\xfcber student\u201d'
text_lessthan_noencd = u'This used to be broken < by the other parser. 3>5'
@@ -1402,7 +1406,7 @@ class OpenEndedModuleUtilTest(unittest.TestCase):
"""
Basic test for passing through iframe, but stripping bad attr
"""
self.assertEqual(OpenEndedChild.sanitize_html(self.iframe_dirty), self.iframe_clean)
self.assertRegexpMatches(OpenEndedChild.sanitize_html(self.iframe_dirty), self.iframe_clean)
def test_text(self):
"""

View File

@@ -1,4 +1,3 @@
from ast import literal_eval
import json
import unittest
@@ -8,7 +7,7 @@ from mock import Mock, patch
from xblock.field_data import DictFieldData
from xblock.fields import ScopeIds
from xmodule.error_module import NonStaffErrorDescriptor
from xmodule.modulestore import Location
from xmodule.modulestore.locations import SlashSeparatedCourseKey, Location
from xmodule.modulestore.xml import ImportSystem, XMLModuleStore, CourseLocationGenerator
from xmodule.conditional_module import ConditionalDescriptor
from xmodule.tests import DATA_DIR, get_test_system, get_test_descriptor_system
@@ -54,13 +53,13 @@ class ConditionalFactory(object):
descriptor_system = get_test_descriptor_system()
# construct source descriptor and module:
source_location = Location(["i4x", "edX", "conditional_test", "problem", "SampleProblem"])
source_location = Location("edX", "conditional_test", "test_run", "problem", "SampleProblem", None)
if source_is_error_module:
# Make an error descriptor and module
source_descriptor = NonStaffErrorDescriptor.from_xml(
'some random xml data',
system,
id_generator=CourseLocationGenerator(source_location.org, source_location.course),
id_generator=CourseLocationGenerator(SlashSeparatedCourseKey('edX', 'conditional_test', 'test_run')),
error_msg='random error message'
)
else:
@@ -78,15 +77,19 @@ class ConditionalFactory(object):
child_descriptor.runtime = descriptor_system
child_descriptor.xmodule_runtime = get_test_system()
child_descriptor.render = lambda view, context=None: descriptor_system.render(child_descriptor, view, context)
child_descriptor.location = source_location.replace(category='html', name='child')
descriptor_system.load_item = {'child': child_descriptor, 'source': source_descriptor}.get
descriptor_system.load_item = {
child_descriptor.location: child_descriptor,
source_location: source_descriptor
}.get
# construct conditional module:
cond_location = Location(["i4x", "edX", "conditional_test", "conditional", "SampleConditional"])
cond_location = Location("edX", "conditional_test", "test_run", "conditional", "SampleConditional", None)
field_data = DictFieldData({
'data': '<conditional/>',
'xml_attributes': {'attempted': 'true'},
'children': ['child'],
'children': [child_descriptor.location],
})
cond_descriptor = ConditionalDescriptor(
@@ -130,7 +133,6 @@ class ConditionalModuleBasicTest(unittest.TestCase):
expected = modules['cond_module'].xmodule_runtime.render_template('conditional_ajax.html', {
'ajax_url': modules['cond_module'].xmodule_runtime.ajax_url,
'element_id': u'i4x-edX-conditional_test-conditional-SampleConditional',
'id': u'i4x://edX/conditional_test/conditional/SampleConditional',
'depends': u'i4x-edX-conditional_test-problem-SampleProblem',
})
self.assertEquals(expected, html)
@@ -198,14 +200,14 @@ class ConditionalModuleXmlTest(unittest.TestCase):
def inner_get_module(descriptor):
if isinstance(descriptor, Location):
location = descriptor
descriptor = self.modulestore.get_instance(course.id, location, depth=None)
descriptor = self.modulestore.get_item(location, depth=None)
descriptor.xmodule_runtime = get_test_system()
descriptor.xmodule_runtime.get_module = inner_get_module
return descriptor
# edx - HarvardX
# cond_test - ER22x
location = Location(["i4x", "HarvardX", "ER22x", "conditional", "condone"])
location = Location("HarvardX", "ER22x", "2013_Spring", "conditional", "condone")
def replace_urls(text, staticfiles_prefix=None, replace_prefix='/static/', course_namespace=None):
return text
@@ -224,9 +226,8 @@ class ConditionalModuleXmlTest(unittest.TestCase):
'conditional_ajax.html',
{
# Test ajax url is just usage-id / handler_name
'ajax_url': 'i4x://HarvardX/ER22x/conditional/condone/xmodule_handler',
'ajax_url': '{}/xmodule_handler'.format(location.to_deprecated_string()),
'element_id': u'i4x-HarvardX-ER22x-conditional-condone',
'id': u'i4x://HarvardX/ER22x/conditional/condone',
'depends': u'i4x-HarvardX-ER22x-problem-choiceprob'
}
)
@@ -242,7 +243,7 @@ class ConditionalModuleXmlTest(unittest.TestCase):
self.assertFalse(any(['This is a secret' in item for item in html]))
# Now change state of the capa problem to make it completed
inner_module = inner_get_module(Location('i4x://HarvardX/ER22x/problem/choiceprob'))
inner_module = inner_get_module(location.replace(category="problem", name='choiceprob'))
inner_module.attempts = 1
# Save our modifications to the underlying KeyValueStore so they can be persisted
inner_module.save()

View File

@@ -1,7 +1,7 @@
import unittest
from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.content import ContentStore
from xmodule.modulestore import Location
from xmodule.modulestore.locations import SlashSeparatedCourseKey, AssetLocation
class Content:
@@ -21,18 +21,28 @@ class ContentTest(unittest.TestCase):
self.assertIsNone(content.thumbnail_location)
def test_static_url_generation_from_courseid(self):
url = StaticContent.convert_legacy_static_url_with_course_id('images_course_image.jpg', 'foo/bar/bz')
course_key = SlashSeparatedCourseKey('foo', 'bar', 'bz')
url = StaticContent.convert_legacy_static_url_with_course_id('images_course_image.jpg', course_key)
self.assertEqual(url, '/c4x/foo/bar/asset/images_course_image.jpg')
def test_generate_thumbnail_image(self):
contentStore = ContentStore()
content = Content(Location(u'c4x', u'mitX', u'800', u'asset', u'monsters__.jpg'), None)
content = Content(AssetLocation(u'mitX', u'800', u'ignore_run', u'asset', u'monsters__.jpg'), None)
(thumbnail_content, thumbnail_file_location) = contentStore.generate_thumbnail(content)
self.assertIsNone(thumbnail_content)
self.assertEqual(Location(u'c4x', u'mitX', u'800', u'thumbnail', u'monsters__.jpg'), thumbnail_file_location)
self.assertEqual(AssetLocation(u'mitX', u'800', u'ignore_run', u'thumbnail', u'monsters__.jpg'), thumbnail_file_location)
def test_compute_location(self):
# We had a bug that __ got converted into a single _. Make sure that substitution of INVALID_CHARS (like space)
# still happen.
asset_location = StaticContent.compute_location('mitX', '400', 'subs__1eo_jXvZnE .srt.sjson')
self.assertEqual(Location(u'c4x', u'mitX', u'400', u'asset', u'subs__1eo_jXvZnE_.srt.sjson', None), asset_location)
asset_location = StaticContent.compute_location(
SlashSeparatedCourseKey('mitX', '400', 'ignore'), 'subs__1eo_jXvZnE .srt.sjson'
)
self.assertEqual(AssetLocation(u'mitX', u'400', u'ignore', u'asset', u'subs__1eo_jXvZnE_.srt.sjson', None), asset_location)
def test_get_location_from_path(self):
asset_location = StaticContent.get_location_from_path(u'/c4x/foo/bar/asset/images_course_image.jpg')
self.assertEqual(
AssetLocation(u'foo', u'bar', None, u'asset', u'images_course_image.jpg', None),
asset_location
)

View File

@@ -8,7 +8,8 @@ from mock import Mock, patch
from xblock.runtime import KvsFieldData, DictKeyValueStore
import xmodule.course_module
from xmodule.modulestore.xml import ImportSystem, XMLModuleStore, LocationReader
from xmodule.modulestore.xml import ImportSystem, XMLModuleStore
from xmodule.modulestore.locations import SlashSeparatedCourseKey
from django.utils.timezone import UTC
@@ -32,7 +33,7 @@ class DummySystem(ImportSystem):
xmlstore = XMLModuleStore("data_dir", course_dirs=[],
load_error_modules=load_error_modules)
course_id = "/".join([ORG, COURSE, 'test_run'])
course_id = SlashSeparatedCourseKey(ORG, COURSE, 'test_run')
course_dir = "test_dir"
error_tracker = Mock()
parent_tracker = Mock()
@@ -45,7 +46,6 @@ class DummySystem(ImportSystem):
parent_tracker=parent_tracker,
load_error_modules=load_error_modules,
field_data=KvsFieldData(DictKeyValueStore()),
id_reader=LocationReader(),
)

View File

@@ -84,8 +84,7 @@ class CapaFactoryWithDelay(object):
"""
Optional parameters here are cut down to what we actually use vs. the regular CapaFactory.
"""
location = Location(["i4x", "edX", "capa_test", "problem",
"SampleProblem{0}".format(cls.next_num())])
location = Location("edX", "capa_test", "run", "problem", "SampleProblem{0}".format(cls.next_num()))
field_data = {'data': cls.sample_problem_xml}
if max_attempts is not None:

View File

@@ -5,6 +5,7 @@ import logging
from mock import Mock
from pkg_resources import resource_string
from xmodule.modulestore.locations import Location
from xmodule.editing_module import TabsEditingDescriptor
from xblock.field_data import DictFieldData
from xblock.fields import ScopeIds
@@ -46,7 +47,7 @@ class TabsEditingDescriptorTestCase(unittest.TestCase):
TabsEditingDescriptor.tabs = self.tabs
self.descriptor = system.construct_xblock_from_class(
TabsEditingDescriptor,
scope_ids=ScopeIds(None, None, None, None),
scope_ids=ScopeIds(None, None, None, Location('org', 'course', 'run', 'category', 'name', 'revision')),
field_data=DictFieldData({}),
)

View File

@@ -4,8 +4,8 @@ Tests for ErrorModule and NonStaffErrorModule
import unittest
from xmodule.tests import get_test_system
from xmodule.error_module import ErrorDescriptor, ErrorModule, NonStaffErrorDescriptor
from xmodule.modulestore import Location
from xmodule.modulestore.xml import CourseLocationGenerator
from xmodule.modulestore.locations import SlashSeparatedCourseKey, Location
from xmodule.x_module import XModuleDescriptor, XModule
from mock import MagicMock, Mock, patch
from xblock.runtime import Runtime, IdReader
@@ -17,9 +17,8 @@ from xblock.test.tools import unabc
class SetupTestErrorModules():
def setUp(self):
self.system = get_test_system()
self.org = "org"
self.course = "course"
self.location = Location(['i4x', self.org, self.course, None, None])
self.course_id = SlashSeparatedCourseKey('org', 'course', 'run')
self.location = self.course_id.make_usage_key('foo', 'bar')
self.valid_xml = u"<problem>ABC \N{SNOWMAN}</problem>"
self.error_msg = "Error"
@@ -35,7 +34,7 @@ class TestErrorModule(unittest.TestCase, SetupTestErrorModules):
descriptor = ErrorDescriptor.from_xml(
self.valid_xml,
self.system,
CourseLocationGenerator(self.org, self.course),
CourseLocationGenerator(self.course_id),
self.error_msg
)
self.assertIsInstance(descriptor, ErrorDescriptor)
@@ -70,7 +69,7 @@ class TestNonStaffErrorModule(unittest.TestCase, SetupTestErrorModules):
descriptor = NonStaffErrorDescriptor.from_xml(
self.valid_xml,
self.system,
CourseLocationGenerator(self.org, self.course)
CourseLocationGenerator(self.course_id)
)
self.assertIsInstance(descriptor, NonStaffErrorDescriptor)
@@ -78,7 +77,7 @@ class TestNonStaffErrorModule(unittest.TestCase, SetupTestErrorModules):
descriptor = NonStaffErrorDescriptor.from_xml(
self.valid_xml,
self.system,
CourseLocationGenerator(self.org, self.course)
CourseLocationGenerator(self.course_id)
)
descriptor.xmodule_runtime = self.system
context_repr = self.system.render(descriptor, 'student_view').content
@@ -130,7 +129,7 @@ class TestErrorModuleConstruction(unittest.TestCase):
self.descriptor = BrokenDescriptor(
TestRuntime(Mock(spec=IdReader), field_data),
field_data,
ScopeIds(None, None, None, 'i4x://org/course/broken/name')
ScopeIds(None, None, None, Location('org', 'course', 'run', 'broken', 'name', None))
)
self.descriptor.xmodule_runtime = TestRuntime(Mock(spec=IdReader), field_data)
self.descriptor.xmodule_runtime.error_descriptor_class = ErrorDescriptor

View File

@@ -36,7 +36,7 @@ def strip_filenames(descriptor):
"""
Recursively strips 'filename' from all children's definitions.
"""
print("strip filename from {desc}".format(desc=descriptor.location.url()))
print("strip filename from {desc}".format(desc=descriptor.location.to_deprecated_string()))
if descriptor._field_data.has(descriptor, 'filename'):
descriptor._field_data.delete(descriptor, 'filename')
@@ -173,11 +173,11 @@ class TestEdxJsonEncoder(unittest.TestCase):
self.null_utc_tz = NullTZ()
def test_encode_location(self):
loc = Location('i4x', 'org', 'course', 'category', 'name')
self.assertEqual(loc.url(), self.encoder.default(loc))
loc = Location('org', 'course', 'run', 'category', 'name', None)
self.assertEqual(loc.to_deprecated_string(), self.encoder.default(loc))
loc = Location('i4x', 'org', 'course', 'category', 'name', 'version')
self.assertEqual(loc.url(), self.encoder.default(loc))
loc = Location('org', 'course', 'run', 'category', 'name', 'version')
self.assertEqual(loc.to_deprecated_string(), self.encoder.default(loc))
def test_encode_naive_datetime(self):
self.assertEqual(

View File

@@ -12,12 +12,13 @@ from django.utils.timezone import UTC
from xmodule.xml_module import is_pointer_tag
from xmodule.modulestore import Location, only_xmodules
from xmodule.modulestore.xml import ImportSystem, XMLModuleStore, LocationReader
from xmodule.modulestore.xml import ImportSystem, XMLModuleStore
from xmodule.modulestore.inheritance import compute_inherited_metadata
from xmodule.x_module import XModuleMixin
from xmodule.fields import Date
from xmodule.tests import DATA_DIR
from xmodule.modulestore.inheritance import InheritanceMixin
from xmodule.modulestore.locations import SlashSeparatedCourseKey
from xblock.core import XBlock
from xblock.fields import Scope, String, Integer
@@ -34,7 +35,7 @@ class DummySystem(ImportSystem):
def __init__(self, load_error_modules):
xmlstore = XMLModuleStore("data_dir", course_dirs=[], load_error_modules=load_error_modules)
course_id = "/".join([ORG, COURSE, 'test_run'])
course_id = SlashSeparatedCourseKey(ORG, COURSE, 'test_run')
course_dir = "test_dir"
error_tracker = Mock()
parent_tracker = Mock()
@@ -48,7 +49,6 @@ class DummySystem(ImportSystem):
load_error_modules=load_error_modules,
mixins=(InheritanceMixin, XModuleMixin),
field_data=KvsFieldData(DictKeyValueStore()),
id_reader=LocationReader(),
)
def render_template(self, _template, _context):
@@ -343,7 +343,7 @@ class ImportTestCase(BaseCourseTestCase):
def check_for_key(key, node, value):
"recursive check for presence of key"
print("Checking {0}".format(node.location.url()))
print("Checking {0}".format(node.location.to_deprecated_string()))
self.assertEqual(getattr(node, key), value)
for c in node.get_children():
check_for_key(key, c, value)
@@ -383,12 +383,10 @@ class ImportTestCase(BaseCourseTestCase):
modulestore = XMLModuleStore(DATA_DIR, course_dirs=['toy', 'two_toys'])
toy_id = "edX/toy/2012_Fall"
two_toy_id = "edX/toy/TT_2012_Fall"
location = Location(["i4x", "edX", "toy", "video", "Welcome"])
toy_video = modulestore.get_instance(toy_id, location)
two_toy_video = modulestore.get_instance(two_toy_id, location)
location = Location("edX", "toy", "2012_Fall", "video", "Welcome", None)
toy_video = modulestore.get_item(location)
location_two = Location("edX", "toy", "TT_2012_Fall", "video", "Welcome", None)
two_toy_video = modulestore.get_item(location_two)
self.assertEqual(toy_video.youtube_id_1_0, "p2Q6BrNhdh8")
self.assertEqual(two_toy_video.youtube_id_1_0, "p2Q6BrNhdh9")
@@ -401,10 +399,9 @@ class ImportTestCase(BaseCourseTestCase):
courses = modulestore.get_courses()
self.assertEquals(len(courses), 1)
course = courses[0]
course_id = course.id
print("course errors:")
for (msg, err) in modulestore.get_item_errors(course.location):
for (msg, err) in modulestore.get_course_errors(course.id):
print(msg)
print(err)
@@ -416,13 +413,12 @@ class ImportTestCase(BaseCourseTestCase):
print("Ch2 location: ", ch2.location)
also_ch2 = modulestore.get_instance(course_id, ch2.location)
also_ch2 = modulestore.get_item(ch2.location)
self.assertEquals(ch2, also_ch2)
print("making sure html loaded")
cloc = course.location
loc = Location(cloc.tag, cloc.org, cloc.course, 'html', 'secret:toylab')
html = modulestore.get_instance(course_id, loc)
loc = course.id.make_usage_key('html', 'secret:toylab')
html = modulestore.get_item(loc)
self.assertEquals(html.display_name, "Toy lab")
def test_unicode(self):
@@ -442,12 +438,16 @@ class ImportTestCase(BaseCourseTestCase):
# Expect to find an error/exception about characters in "®esources"
expect = "Invalid characters"
errors = [(msg.encode("utf-8"), err.encode("utf-8"))
for msg, err in
modulestore.get_item_errors(course.location)]
errors = [
(msg.encode("utf-8"), err.encode("utf-8"))
for msg, err
in modulestore.get_course_errors(course.id)
]
self.assertTrue(any(expect in msg or expect in err
for msg, err in errors))
self.assertTrue(any(
expect in msg or expect in err
for msg, err in errors
))
chapters = course.get_children()
self.assertEqual(len(chapters), 4)
@@ -458,7 +458,7 @@ class ImportTestCase(BaseCourseTestCase):
modulestore = XMLModuleStore(DATA_DIR, course_dirs=['toy'])
toy_id = "edX/toy/2012_Fall"
toy_id = SlashSeparatedCourseKey('edX', 'toy', '2012_Fall')
course = modulestore.get_course(toy_id)
chapters = course.get_children()
@@ -484,20 +484,12 @@ class ImportTestCase(BaseCourseTestCase):
self.assertEqual(len(sections), 1)
location = course.location
conditional_location = Location(
location.tag, location.org, location.course,
'conditional', 'condone'
)
module = modulestore.get_instance(course.id, conditional_location)
conditional_location = course.id.make_usage_key('conditional', 'condone')
module = modulestore.get_item(conditional_location)
self.assertEqual(len(module.children), 1)
poll_location = Location(
location.tag, location.org, location.course,
'poll_question', 'first_poll'
)
module = modulestore.get_instance(course.id, poll_location)
poll_location = course.id.make_usage_key('poll_question', 'first_poll')
module = modulestore.get_item(poll_location)
self.assertEqual(len(module.get_children()), 0)
self.assertEqual(module.voted, False)
self.assertEqual(module.poll_answer, '')
@@ -527,9 +519,9 @@ class ImportTestCase(BaseCourseTestCase):
'''
modulestore = XMLModuleStore(DATA_DIR, course_dirs=['graphic_slider_tool'])
sa_id = "edX/gst_test/2012_Fall"
location = Location(["i4x", "edX", "gst_test", "graphical_slider_tool", "sample_gst"])
gst_sample = modulestore.get_instance(sa_id, location)
sa_id = SlashSeparatedCourseKey("edX", "gst_test", "2012_Fall")
location = sa_id.make_usage_key("graphical_slider_tool", "sample_gst")
gst_sample = modulestore.get_item(location)
render_string_from_sample_gst_xml = """
<slider var="a" style="width:400px;float:left;"/>\
<plot style="margin-top:15px;margin-bottom:15px;"/>""".strip()
@@ -545,12 +537,8 @@ class ImportTestCase(BaseCourseTestCase):
self.assertEqual(len(sections), 1)
location = course.location
location = Location(
location.tag, location.org, location.course,
'word_cloud', 'cloud1'
)
module = modulestore.get_instance(course.id, location)
location = course.id.make_usage_key('word_cloud', 'cloud1')
module = modulestore.get_item(location)
self.assertEqual(len(module.get_children()), 0)
self.assertEqual(module.num_inputs, 5)
self.assertEqual(module.num_top_words, 250)
@@ -561,7 +549,7 @@ class ImportTestCase(BaseCourseTestCase):
"""
modulestore = XMLModuleStore(DATA_DIR, course_dirs=['toy'])
toy_id = "edX/toy/2012_Fall"
toy_id = SlashSeparatedCourseKey('edX', 'toy', '2012_Fall')
course = modulestore.get_course(toy_id)

View File

@@ -3,8 +3,8 @@ Tests that check that we ignore the appropriate files when importing courses.
"""
import unittest
from mock import Mock
from xmodule.modulestore import Location
from xmodule.modulestore.xml_importer import import_static_content
from xmodule.modulestore.locations import SlashSeparatedCourseKey
from xmodule.tests import DATA_DIR
@@ -12,10 +12,10 @@ class IgnoredFilesTestCase(unittest.TestCase):
"Tests for ignored files"
def test_ignore_tilde_static_files(self):
course_dir = DATA_DIR / "tilde"
loc = Location("edX", "tilde", "Fall_2012")
course_id = SlashSeparatedCourseKey("edX", "tilde", "Fall_2012")
content_store = Mock()
content_store.generate_thumbnail.return_value = ("content", "location")
import_static_content(Mock(), Mock(), course_dir, content_store, loc)
import_static_content(course_dir, content_store, course_id)
saved_static_content = [call[0][0] for call in content_store.save.call_args_list]
name_val = {sc.name: sc.data for sc in saved_static_content}
self.assertIn("example.txt", name_val)

View File

@@ -262,26 +262,22 @@ class LTIModuleTest(LogicTest):
self.assertEqual(real_resource_link_id, expected_resource_link_id)
def test_lis_result_sourcedid(self):
with patch('xmodule.lti_module.LTIModule.location', new_callable=PropertyMock) as mock_location:
self.xmodule.location.html_id = lambda: 'i4x-2-3-lti-31de800015cf4afb973356dbe81496df'
expected_sourcedId = u':'.join(urllib.quote(i) for i in (
self.system.course_id,
urllib.quote(self.unquoted_resource_link_id),
self.user_id
))
real_lis_result_sourcedid = self.xmodule.get_lis_result_sourcedid()
self.assertEqual(real_lis_result_sourcedid, expected_sourcedId)
expected_sourcedId = u':'.join(urllib.quote(i) for i in (
self.system.course_id.to_deprecated_string(),
self.xmodule.get_resource_link_id(),
self.user_id
))
real_lis_result_sourcedid = self.xmodule.get_lis_result_sourcedid()
self.assertEqual(real_lis_result_sourcedid, expected_sourcedId)
@patch('xmodule.course_module.CourseDescriptor.id_to_location')
def test_client_key_secret(self, test):
def test_client_key_secret(self):
"""
LTI module gets client key and secret provided.
"""
#this adds lti passports to system
mocked_course = Mock(lti_passports = ['lti_id:test_client:test_secret'])
modulestore = Mock()
modulestore.get_item.return_value = mocked_course
modulestore.get_course.return_value = mocked_course
runtime = Mock(modulestore=modulestore)
self.xmodule.descriptor.runtime = runtime
self.xmodule.lti_id = "lti_id"
@@ -289,8 +285,7 @@ class LTIModuleTest(LogicTest):
expected = ('test_client', 'test_secret')
self.assertEqual(expected, (key, secret))
@patch('xmodule.course_module.CourseDescriptor.id_to_location')
def test_client_key_secret_not_provided(self, test):
def test_client_key_secret_not_provided(self):
"""
LTI module attempts to get client key and secret provided in cms.
@@ -300,7 +295,7 @@ class LTIModuleTest(LogicTest):
#this adds lti passports to system
mocked_course = Mock(lti_passports = ['test_id:test_client:test_secret'])
modulestore = Mock()
modulestore.get_item.return_value = mocked_course
modulestore.get_course.return_value = mocked_course
runtime = Mock(modulestore=modulestore)
self.xmodule.descriptor.runtime = runtime
#set another lti_id
@@ -309,8 +304,7 @@ class LTIModuleTest(LogicTest):
expected = ('','')
self.assertEqual(expected, key_secret)
@patch('xmodule.course_module.CourseDescriptor.id_to_location')
def test_bad_client_key_secret(self, test):
def test_bad_client_key_secret(self):
"""
LTI module attempts to get client key and secret provided in cms.
@@ -319,16 +313,16 @@ class LTIModuleTest(LogicTest):
#this adds lti passports to system
mocked_course = Mock(lti_passports = ['test_id_test_client_test_secret'])
modulestore = Mock()
modulestore.get_item.return_value = mocked_course
modulestore.get_course.return_value = mocked_course
runtime = Mock(modulestore=modulestore)
self.xmodule.descriptor.runtime = runtime
self.xmodule.lti_id = 'lti_id'
with self.assertRaises(LTIError):
self.xmodule.get_client_key_secret()
@patch('xmodule.lti_module.signature.verify_hmac_sha1', return_value=True)
@patch('xmodule.lti_module.LTIModule.get_client_key_secret', return_value=('test_client_key', u'test_client_secret'))
def test_successful_verify_oauth_body_sign(self, get_key_secret, mocked_verify):
@patch('xmodule.lti_module.signature.verify_hmac_sha1', Mock(return_value=True))
@patch('xmodule.lti_module.LTIModule.get_client_key_secret', Mock(return_value=('test_client_key', u'test_client_secret')))
def test_successful_verify_oauth_body_sign(self):
"""
Test if OAuth signing was successful.
"""
@@ -337,9 +331,9 @@ class LTIModuleTest(LogicTest):
except LTIError:
self.fail("verify_oauth_body_sign() raised LTIError unexpectedly!")
@patch('xmodule.lti_module.signature.verify_hmac_sha1', return_value=False)
@patch('xmodule.lti_module.LTIModule.get_client_key_secret', return_value=('test_client_key', u'test_client_secret'))
def test_failed_verify_oauth_body_sign(self, get_key_secret, mocked_verify):
@patch('xmodule.lti_module.signature.verify_hmac_sha1', Mock(return_value=False))
@patch('xmodule.lti_module.LTIModule.get_client_key_secret', Mock(return_value=('test_client_key', u'test_client_secret')))
def test_failed_verify_oauth_body_sign(self):
"""
Oauth signing verify fail.
"""
@@ -411,4 +405,4 @@ class LTIModuleTest(LogicTest):
"""
Tests that LTI parameter context_id is equal to course_id.
"""
self.assertEqual(self.system.course_id, self.xmodule.context_id)
self.assertEqual(self.system.course_id.to_deprecated_string(), self.xmodule.context_id)

View File

@@ -7,7 +7,7 @@ from webob.multidict import MultiDict
from xblock.field_data import DictFieldData
from xblock.fields import ScopeIds
from xmodule.modulestore import Location
from xmodule.modulestore.locations import Location, SlashSeparatedCourseKey
from xmodule.tests import get_test_system, get_test_descriptor_system
from xmodule.tests.test_util_open_ended import DummyModulestore
from xmodule.open_ended_grading_classes.peer_grading_service import MockPeerGradingService
@@ -16,20 +16,17 @@ from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem
log = logging.getLogger(__name__)
ORG = "edX"
COURSE = "open_ended"
class PeerGradingModuleTest(unittest.TestCase, DummyModulestore):
"""
Test peer grading xmodule at the unit level. More detailed tests are difficult, as the module relies on an
external grading service.
"""
problem_location = Location(["i4x", "edX", "open_ended", "peergrading",
"PeerGradingSample"])
coe_location = Location(["i4x", "edX", "open_ended", "combinedopenended", "SampleQuestion"])
course_id = SlashSeparatedCourseKey('edX', 'open_ended', '2012_Fall')
problem_location = course_id.make_usage_key("peergrading", "PeerGradingSample")
coe_location = course_id.make_usage_key("combinedopenended", "SampleQuestion")
calibrated_dict = {'location': "blah"}
coe_dict = {'location': coe_location.url()}
coe_dict = {'location': coe_location.to_deprecated_string()}
save_dict = MultiDict({
'location': "blah",
'submission_id': 1,
@@ -42,7 +39,7 @@ class PeerGradingModuleTest(unittest.TestCase, DummyModulestore):
save_dict.extend(('rubric_scores[]', val) for val in (0, 1))
def get_module_system(self, descriptor):
test_system = get_test_system()
test_system = get_test_system(self.course_id)
test_system.open_ended_grading_interface = None
return test_system
@@ -51,9 +48,9 @@ class PeerGradingModuleTest(unittest.TestCase, DummyModulestore):
Create a peer grading module from a test system
@return:
"""
self.setup_modulestore(COURSE)
self.peer_grading = self.get_module_from_location(self.problem_location, COURSE)
self.coe = self.get_module_from_location(self.coe_location, COURSE)
self.setup_modulestore(self.course_id.course)
self.peer_grading = self.get_module_from_location(self.problem_location)
self.coe = self.get_module_from_location(self.coe_location)
def test_module_closed(self):
"""
@@ -75,7 +72,7 @@ class PeerGradingModuleTest(unittest.TestCase, DummyModulestore):
Try getting data from the external grading service
@return:
"""
success, _data = self.peer_grading.query_data_for_location(self.problem_location.url())
success, _data = self.peer_grading.query_data_for_location(self.problem_location.to_deprecated_string())
self.assertTrue(success)
def test_get_score_none(self):
@@ -149,8 +146,11 @@ class PeerGradingModuleTest(unittest.TestCase, DummyModulestore):
Mainly for diff coverage
@return:
"""
# pylint: disable=protected-access
with self.assertRaises(ItemNotFoundError):
self.peer_grading._find_corresponding_module_for_location(Location('i4x', 'a', 'b', 'c', 'd'))
self.peer_grading._find_corresponding_module_for_location(
Location('org', 'course', 'run', 'category', 'name', 'revision')
)
def test_get_instance_state(self):
"""
@@ -235,7 +235,13 @@ class MockPeerGradingServiceProblemList(MockPeerGradingService):
def get_problem_list(self, course_id, grader_id):
return {'success': True,
'problem_list': [
{"num_graded": 3, "num_pending": 681, "num_required": 3, "location": "i4x://edX/open_ended/combinedopenended/SampleQuestion", "problem_name": "Peer-Graded Essay"},
{
"num_graded": 3,
"num_pending": 681,
"num_required": 3,
"location": course_id.make_usage_key('combinedopenended', 'SampleQuestion'),
"problem_name": "Peer-Graded Essay"
},
]}
@@ -244,12 +250,12 @@ class PeerGradingModuleScoredTest(unittest.TestCase, DummyModulestore):
Test peer grading xmodule at the unit level. More detailed tests are difficult, as the module relies on an
external grading service.
"""
problem_location = Location(
["i4x", "edX", "open_ended", "peergrading", "PeerGradingScored"]
)
course_id = SlashSeparatedCourseKey('edX', 'open_ended', '2012_Fall')
problem_location = course_id.make_usage_key("peergrading", "PeerGradingScored")
def get_module_system(self, descriptor):
test_system = get_test_system()
test_system = get_test_system(self.course_id)
test_system.open_ended_grading_interface = None
return test_system
@@ -258,10 +264,10 @@ class PeerGradingModuleScoredTest(unittest.TestCase, DummyModulestore):
Create a peer grading module from a test system
@return:
"""
self.setup_modulestore(COURSE)
self.setup_modulestore(self.course_id.course)
def test_metadata_load(self):
peer_grading = self.get_module_from_location(self.problem_location, COURSE)
peer_grading = self.get_module_from_location(self.problem_location)
self.assertFalse(peer_grading.closed())
def test_problem_list(self):
@@ -270,7 +276,7 @@ class PeerGradingModuleScoredTest(unittest.TestCase, DummyModulestore):
"""
# Initialize peer grading module.
peer_grading = self.get_module_from_location(self.problem_location, COURSE)
peer_grading = self.get_module_from_location(self.problem_location)
# Ensure that it cannot find any peer grading.
html = peer_grading.peer_grading()
@@ -286,13 +292,12 @@ class PeerGradingModuleLinkedTest(unittest.TestCase, DummyModulestore):
"""
Test peer grading that is linked to an open ended module.
"""
problem_location = Location(["i4x", "edX", "open_ended", "peergrading",
"PeerGradingLinked"])
coe_location = Location(["i4x", "edX", "open_ended", "combinedopenended",
"SampleQuestion"])
course_id = SlashSeparatedCourseKey('edX', 'open_ended', '2012_Fall')
problem_location = course_id.make_usage_key("peergrading", "PeerGradingLinked")
coe_location = course_id.make_usage_key("combinedopenended", "SampleQuestion")
def get_module_system(self, descriptor):
test_system = get_test_system()
test_system = get_test_system(self.course_id)
test_system.open_ended_grading_interface = None
return test_system
@@ -300,7 +305,7 @@ class PeerGradingModuleLinkedTest(unittest.TestCase, DummyModulestore):
"""
Create a peer grading module from a test system.
"""
self.setup_modulestore(COURSE)
self.setup_modulestore(self.course_id.course)
@property
def field_data(self):
@@ -312,7 +317,7 @@ class PeerGradingModuleLinkedTest(unittest.TestCase, DummyModulestore):
'data': '<peergrading/>',
'location': self.problem_location,
'use_for_single_location': True,
'link_to_location': self.coe_location.url(),
'link_to_location': self.coe_location.to_deprecated_string(),
'graded': True,
})
@@ -424,7 +429,7 @@ class PeerGradingModuleLinkedTest(unittest.TestCase, DummyModulestore):
peer_grading = self._create_peer_grading_with_linked_problem(self.coe_location)
# If we specify a location, it will render the problem for that location.
data = peer_grading.handle_ajax('problem', {'location': self.coe_location})
data = peer_grading.handle_ajax('problem', {'location': self.coe_location.to_deprecated_string()})
self.assertTrue(json.loads(data)['success'])
# If we don't specify a location, it should use the linked location.

View File

@@ -4,6 +4,7 @@ import unittest
from mock import Mock, MagicMock
from webob.multidict import MultiDict
from pytz import UTC
from xblock.fields import ScopeIds
from xmodule.open_ended_grading_classes.self_assessment_module import SelfAssessmentModule
from xmodule.modulestore import Location
from lxml import etree
@@ -29,8 +30,7 @@ class SelfAssessmentTest(unittest.TestCase):
'hintprompt': 'Consider this...',
}
location = Location(["i4x", "edX", "sa_test", "selfassessment",
"SampleQuestion"])
location = Location("edX", "sa_test", "run", "selfassessment", "SampleQuestion", None)
descriptor = Mock()
@@ -56,7 +56,10 @@ class SelfAssessmentTest(unittest.TestCase):
}
system = get_test_system()
system.xmodule_instance = Mock(scope_ids=Mock(usage_id='dummy-usage-id'))
usage_key = system.course_id.make_usage_key('combinedopenended', 'test_loc')
scope_ids = ScopeIds(1, 'combinedopenended', usage_key, usage_key)
system.xmodule_instance = Mock(scope_ids=scope_ids)
self.module = SelfAssessmentModule(
system,
self.location,

View File

@@ -2,6 +2,7 @@
from mock import MagicMock
import xmodule.tabs as tabs
import unittest
from xmodule.modulestore.locations import SlashSeparatedCourseKey
class TabTestCase(unittest.TestCase):
@@ -9,7 +10,7 @@ class TabTestCase(unittest.TestCase):
def setUp(self):
self.course = MagicMock()
self.course.id = 'edX/toy/2012_Fall'
self.course.id = SlashSeparatedCourseKey('edX', 'toy', '2012_Fall')
self.fake_dict_tab = {'fake_key': 'fake_value'}
self.settings = MagicMock()
self.settings.FEATURES = {}
@@ -137,7 +138,7 @@ class ProgressTestCase(TabTestCase):
return self.check_tab(
tab_class=tabs.ProgressTab,
dict_tab={'type': tabs.ProgressTab.type, 'name': 'same'},
expected_link=self.reverse('progress', args=[self.course.id]),
expected_link=self.reverse('progress', args=[self.course.id.to_deprecated_string()]),
expected_tab_id=tabs.ProgressTab.type,
invalid_dict_tab=None,
)
@@ -161,7 +162,7 @@ class WikiTestCase(TabTestCase):
return self.check_tab(
tab_class=tabs.WikiTab,
dict_tab={'type': tabs.WikiTab.type, 'name': 'same'},
expected_link=self.reverse('course_wiki', args=[self.course.id]),
expected_link=self.reverse('course_wiki', args=[self.course.id.to_deprecated_string()]),
expected_tab_id=tabs.WikiTab.type,
invalid_dict_tab=self.fake_dict_tab,
)
@@ -220,7 +221,7 @@ class StaticTabTestCase(TabTestCase):
tab = self.check_tab(
tab_class=tabs.StaticTab,
dict_tab={'type': tabs.StaticTab.type, 'name': 'same', 'url_slug': url_slug},
expected_link=self.reverse('static_tab', args=[self.course.id, url_slug]),
expected_link=self.reverse('static_tab', args=[self.course.id.to_deprecated_string(), url_slug]),
expected_tab_id='static_tab_schmug',
invalid_dict_tab=self.fake_dict_tab,
)
@@ -257,7 +258,10 @@ class TextbooksTestCase(TabTestCase):
# verify all textbook type tabs
if isinstance(tab, tabs.SingleTextbookTab):
book_type, book_index = tab.tab_id.split("/", 1)
expected_link = self.reverse(type_to_reverse_name[book_type], args=[self.course.id, book_index])
expected_link = self.reverse(
type_to_reverse_name[book_type],
args=[self.course.id.to_deprecated_string(), book_index]
)
self.assertEqual(tab.link_func(self.course, self.reverse), expected_link)
self.assertTrue(tab.name.startswith('Book{0}'.format(book_index)))
num_textbooks_found = num_textbooks_found + 1
@@ -279,7 +283,7 @@ class GradingTestCase(TabTestCase):
tab_class=tab_class,
dict_tab={'type': tab_class.type, 'name': name},
expected_name=name,
expected_link=self.reverse(link_value, args=[self.course.id]),
expected_link=self.reverse(link_value, args=[self.course.id.to_deprecated_string()]),
expected_tab_id=tab_class.type,
invalid_dict_tab=None,
)
@@ -314,7 +318,7 @@ class NotesTestCase(TabTestCase):
return self.check_tab(
tab_class=tabs.NotesTab,
dict_tab={'type': tabs.NotesTab.type, 'name': 'same'},
expected_link=self.reverse('notes', args=[self.course.id]),
expected_link=self.reverse('notes', args=[self.course.id.to_deprecated_string()]),
expected_tab_id=tabs.NotesTab.type,
invalid_dict_tab=self.fake_dict_tab,
)
@@ -341,7 +345,7 @@ class SyllabusTestCase(TabTestCase):
tab_class=tabs.SyllabusTab,
dict_tab={'type': tabs.SyllabusTab.type, 'name': name},
expected_name=name,
expected_link=self.reverse('syllabus', args=[self.course.id]),
expected_link=self.reverse('syllabus', args=[self.course.id.to_deprecated_string()]),
expected_tab_id=tabs.SyllabusTab.type,
invalid_dict_tab=None,
)
@@ -365,7 +369,7 @@ class InstructorTestCase(TabTestCase):
tab_class=tabs.InstructorTab,
dict_tab={'type': tabs.InstructorTab.type, 'name': name},
expected_name=name,
expected_link=self.reverse('instructor_dashboard', args=[self.course.id]),
expected_link=self.reverse('instructor_dashboard', args=[self.course.id.to_deprecated_string()]),
expected_tab_id=tabs.InstructorTab.type,
invalid_dict_tab=None,
)
@@ -603,7 +607,7 @@ class DiscussionLinkTestCase(TabTestCase):
"""Custom reverse function"""
def reverse_discussion_link(viewname, args):
"""reverse lookup for discussion link"""
if viewname == "django_comment_client.forum.views.forum_form_discussion" and args == [course.id]:
if viewname == "django_comment_client.forum.views.forum_form_discussion" and args == [course.id.to_deprecated_string()]:
return "default_discussion_link"
return reverse_discussion_link

View File

@@ -92,11 +92,8 @@ class DummyModulestore(object):
courses = self.modulestore.get_courses()
return courses[0]
def get_module_from_location(self, location, course):
course = self.get_course(course)
if not isinstance(location, Location):
location = Location(location)
descriptor = self.modulestore.get_instance(course.id, location, depth=None)
def get_module_from_location(self, usage_key):
descriptor = self.modulestore.get_item(usage_key, depth=None)
descriptor.xmodule_runtime = self.get_module_system(descriptor)
return descriptor

View File

@@ -125,7 +125,7 @@ class VideoDescriptorTest(unittest.TestCase):
def setUp(self):
system = get_test_descriptor_system()
location = Location('i4x://org/course/video/name')
location = Location('org', 'course', 'run', 'video', 'name', None)
self.descriptor = system.construct_xblock_from_class(
VideoDescriptor,
scope_ids=ScopeIds(None, None, location, location),
@@ -138,7 +138,7 @@ class VideoDescriptorTest(unittest.TestCase):
back out to XML.
"""
system = DummySystem(load_error_modules=True)
location = Location(["i4x", "edX", "video", "default", "SampleProblem1"])
location = Location("edX", 'course', 'run', "video", 'SampleProblem1', None)
field_data = DictFieldData({'location': location})
descriptor = VideoDescriptor(system, field_data, Mock())
descriptor.youtube_id_0_75 = 'izygArpw-Qo'
@@ -154,7 +154,7 @@ class VideoDescriptorTest(unittest.TestCase):
in the output string.
"""
system = DummySystem(load_error_modules=True)
location = Location(["i4x", "edX", "video", "default", "SampleProblem1"])
location = Location("edX", 'course', 'run', "video", "SampleProblem1", None)
field_data = DictFieldData({'location': location})
descriptor = VideoDescriptor(system, field_data, Mock())
descriptor.youtube_id_0_75 = 'izygArpw-Qo'
@@ -194,8 +194,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
<transcript language="ge" src="german_translation.srt" />
</video>
'''
location = Location(["i4x", "edX", "video", "default",
"SampleProblem1"])
location = Location("edX", 'course', 'run', "video", "SampleProblem1", None)
field_data = DictFieldData({
'data': sample_xml,
'location': location
@@ -498,6 +497,9 @@ class VideoExportTestCase(unittest.TestCase):
Make sure that VideoDescriptor can export itself to XML
correctly.
"""
def setUp(self):
self.location = Location("edX", 'course', 'run', "video", "SampleProblem1", None)
def assertXmlEqual(self, expected, xml):
for attr in ['tag', 'attrib', 'text', 'tail']:
self.assertEqual(getattr(expected, attr), getattr(xml, attr))
@@ -507,8 +509,7 @@ class VideoExportTestCase(unittest.TestCase):
def test_export_to_xml(self):
"""Test that we write the correct XML on export."""
module_system = DummySystem(load_error_modules=True)
location = Location(["i4x", "edX", "video", "default", "SampleProblem1"])
desc = VideoDescriptor(module_system, DictFieldData({}), ScopeIds(None, None, location, location))
desc = VideoDescriptor(module_system, DictFieldData({}), ScopeIds(None, None, self.location, self.location))
desc.youtube_id_0_75 = 'izygArpw-Qo'
desc.youtube_id_1_0 = 'p2Q6BrNhdh8'
@@ -540,8 +541,7 @@ class VideoExportTestCase(unittest.TestCase):
def test_export_to_xml_empty_end_time(self):
"""Test that we write the correct XML on export."""
module_system = DummySystem(load_error_modules=True)
location = Location(["i4x", "edX", "video", "default", "SampleProblem1"])
desc = VideoDescriptor(module_system, DictFieldData({}), ScopeIds(None, None, location, location))
desc = VideoDescriptor(module_system, DictFieldData({}), ScopeIds(None, None, self.location, self.location))
desc.youtube_id_0_75 = 'izygArpw-Qo'
desc.youtube_id_1_0 = 'p2Q6BrNhdh8'
@@ -569,8 +569,7 @@ class VideoExportTestCase(unittest.TestCase):
def test_export_to_xml_empty_parameters(self):
"""Test XML export with defaults."""
module_system = DummySystem(load_error_modules=True)
location = Location(["i4x", "edX", "video", "default", "SampleProblem1"])
desc = VideoDescriptor(module_system, DictFieldData({}), ScopeIds(None, None, location, location))
desc = VideoDescriptor(module_system, DictFieldData({}), ScopeIds(None, None, self.location, self.location))
xml = desc.definition_to_xml(None)
expected = '<video url_name="SampleProblem1"/>\n'

View File

@@ -190,7 +190,7 @@ class LeafDescriptorFactory(Factory):
@lazy_attribute
def location(self):
return Location('i4x://org/course/category/{}'.format(self.url_name))
return Location('org', 'course', 'run', 'category', self.url_name, None)
@lazy_attribute
def block_type(self):

View File

@@ -7,8 +7,8 @@ from unittest import TestCase
from xmodule.x_module import XMLParsingSystem, policy_key
from xmodule.mako_module import MakoDescriptorSystem
from xmodule.modulestore.xml import create_block_from_xml, LocationReader, CourseLocationGenerator
from xmodule.modulestore import Location
from xmodule.modulestore.xml import create_block_from_xml, CourseLocationGenerator
from xmodule.modulestore.locations import SlashSeparatedCourseKey, Location
from xblock.runtime import KvsFieldData, DictKeyValueStore
@@ -18,8 +18,7 @@ class InMemorySystem(XMLParsingSystem, MakoDescriptorSystem): # pylint: disable
The simplest possible XMLParsingSystem
"""
def __init__(self, xml_import_data):
self.org = xml_import_data.org
self.course = xml_import_data.course
self.course_id = SlashSeparatedCourseKey.from_deprecated_string(xml_import_data.course_id)
self.default_class = xml_import_data.default_class
self._descriptors = {}
@@ -37,7 +36,6 @@ class InMemorySystem(XMLParsingSystem, MakoDescriptorSystem): # pylint: disable
select=xml_import_data.xblock_select,
render_template=lambda template, context: pprint.pformat((template, context)),
field_data=KvsFieldData(DictKeyValueStore()),
id_reader=LocationReader(),
)
def process_xml(self, xml): # pylint: disable=method-hidden
@@ -45,14 +43,14 @@ class InMemorySystem(XMLParsingSystem, MakoDescriptorSystem): # pylint: disable
descriptor = create_block_from_xml(
xml,
self,
CourseLocationGenerator(self.org, self.course),
CourseLocationGenerator(self.course_id),
)
self._descriptors[descriptor.location.url()] = descriptor
self._descriptors[descriptor.location.to_deprecated_string()] = descriptor
return descriptor
def load_item(self, location): # pylint: disable=method-hidden
"""Return the descriptor loaded for `location`"""
return self._descriptors[Location(location).url()]
return self._descriptors[location.to_deprecated_string()]
class XModuleXmlImportTest(TestCase):

View File

@@ -17,15 +17,14 @@ class XmlImportData(object):
Class to capture all of the data needed to actually run an XML import,
so that the Factories have something to generate
"""
def __init__(self, xml_node, xml=None, org=None, course=None,
def __init__(self, xml_node, xml=None, course_id=None,
default_class=None, policy=None,
filesystem=None, parent=None,
xblock_mixins=(), xblock_select=None):
self._xml_node = xml_node
self._xml_string = xml
self.org = org
self.course = course
self.course_id = course_id
self.default_class = default_class
self.filesystem = filesystem
self.xblock_mixins = xblock_mixins
@@ -47,8 +46,8 @@ class XmlImportData(object):
def __repr__(self):
return u"XmlImportData{!r}".format((
self._xml_node, self._xml_string, self.org,
self.course, self.default_class, self.policy,
self._xml_node, self._xml_string, self.course_id,
self.default_class, self.policy,
self.filesystem, self.parent, self.xblock_mixins,
self.xblock_select,
))
@@ -74,6 +73,7 @@ class XmlImportFactory(Factory):
policy = {}
inline_xml = True
tag = 'unknown'
course_id = 'edX/xml_test_course/101'
@classmethod
def _adjust_kwargs(cls, **kwargs):
@@ -136,8 +136,6 @@ class XmlImportFactory(Factory):
class CourseFactory(XmlImportFactory):
"""Factory for <course> nodes"""
tag = 'course'
org = 'edX'
course = 'xml_test_course'
name = '101'
static_asset_path = 'xml_test_course'

View File

@@ -25,7 +25,7 @@ class VerticalModule(VerticalFields, XModule):
fragment.add_frag_resources(rendered_child)
contents.append({
'id': child.id,
'id': child.location.to_deprecated_string(),
'content': rendered_child.content
})

View File

@@ -289,9 +289,7 @@ def copy_or_rename_transcript(new_name, old_name, item, delete_old=False, user=N
If `delete_old` is True, removes `old_name` files from storage.
"""
filename = 'subs_{0}.srt.sjson'.format(old_name)
content_location = StaticContent.compute_location(
item.location.org, item.location.course, filename
)
content_location = StaticContent.compute_location(item.location.course_key, filename)
transcripts = contentstore().find(content_location).data
save_subs_to_store(json.loads(transcripts), new_name, item)
item.sub = new_name
@@ -532,7 +530,7 @@ class Transcript(object):
"""
Return asset location. `location` is module location.
"""
return StaticContent.compute_location(location.org, location.course, filename)
return StaticContent.compute_location(location.course_key, filename)
@staticmethod
def delete_asset(location, filename):
@@ -545,4 +543,5 @@ class Transcript(object):
log.info("Transcript asset %s was removed from store.", filename)
except NotFoundError:
pass
return StaticContent.compute_location(location.course_key, filename)

View File

@@ -18,14 +18,12 @@ from webob.multidict import MultiDict
from xblock.core import XBlock
from xblock.fields import Scope, Integer, Float, List, XBlockMixin, String, Dict
from xblock.fragment import Fragment
from xblock.plugin import default_select
from xblock.runtime import Runtime
from xmodule.fields import RelativeTime
from xmodule.errortracker import exc_info_to_str
from xmodule.modulestore import Location
from xmodule.modulestore.exceptions import ItemNotFoundError, InsufficientSpecificationError, InvalidLocationError
from xmodule.modulestore.locator import BlockUsageLocator
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.keys import OpaqueKeyReader, UsageKey
from xmodule.exceptions import UndefinedContext
from dogapi import dog_stats_api
@@ -156,11 +154,7 @@ class XModuleMixin(XBlockMixin):
@property
def course_id(self):
return self.runtime.course_id
@property
def id(self):
return self.location.url()
return self.location.course_key
@property
def category(self):
@@ -168,16 +162,11 @@ class XModuleMixin(XBlockMixin):
@property
def location(self):
try:
return Location(self.scope_ids.usage_id)
except InvalidLocationError:
if isinstance(self.scope_ids.usage_id, BlockUsageLocator):
return self.scope_ids.usage_id
else:
return BlockUsageLocator(self.scope_ids.usage_id)
return self.scope_ids.usage_id
@location.setter
def location(self, value):
assert isinstance(value, UsageKey)
self.scope_ids = self.scope_ids._replace(
def_id=value,
usage_id=value,
@@ -185,12 +174,7 @@ class XModuleMixin(XBlockMixin):
@property
def url_name(self):
if isinstance(self.location, Location):
return self.location.name
elif isinstance(self.location, BlockUsageLocator):
return self.location.block_id
else:
raise InsufficientSpecificationError()
return self.location.name
@property
def display_name_with_default(self):
@@ -203,6 +187,17 @@ class XModuleMixin(XBlockMixin):
name = self.url_name.replace('_', ' ')
return name
@property
def xblock_kvs(self):
"""
Retrieves the internal KeyValueStore for this XModule.
Should only be used by the persistence layer. Use with caution.
"""
# if caller wants kvs, caller's assuming it's up to date; so, decache it
self.save()
return self._field_data._kvs # pylint: disable=protected-access
def get_explicitly_set_fields_by_scope(self, scope=Scope.content):
"""
Get a dictionary of the fields for the given scope which are set explicitly on this xblock. (Including
@@ -214,15 +209,6 @@ class XModuleMixin(XBlockMixin):
result[field.name] = field.read_json(self)
return result
@property
def xblock_kvs(self):
"""
Use w/ caution. Really intended for use by the persistence layer.
"""
# if caller wants kvs, caller's assuming it's up to date; so, decache it
self.save()
return self._field_data._kvs # pylint: disable=protected-access
def get_content_titles(self):
"""
Returns list of content titles for all of self's children.
@@ -684,7 +670,6 @@ class XModuleDescriptor(XModuleMixin, HTMLSnippet, ResourceTemplates, XBlock):
Interpret the parsed XML in `node`, creating an XModuleDescriptor.
"""
xml = etree.tostring(node)
# TODO: change from_xml to not take org and course, it can use self.system.
block = cls.from_xml(xml, runtime, id_generator)
return block
@@ -1023,7 +1008,7 @@ class DescriptorSystem(MetricsMixin, ConfigurableFragmentWrapper, Runtime): # p
local_resource_url: an implementation of :meth:`xblock.runtime.Runtime.local_resource_url`
"""
super(DescriptorSystem, self).__init__(**kwargs)
super(DescriptorSystem, self).__init__(id_reader=OpaqueKeyReader(), **kwargs)
# This is used by XModules to write out separate files during xml export
self.export_fs = None
@@ -1217,7 +1202,7 @@ class ModuleSystem(MetricsMixin,ConfigurableFragmentWrapper, Runtime): # pylint
# Usage_store is unused, and field_data is often supplanted with an
# explicit field_data during construct_xblock.
super(ModuleSystem, self).__init__(id_reader=None, field_data=field_data, **kwargs)
super(ModuleSystem, self).__init__(id_reader=OpaqueKeyReader(), field_data=field_data, **kwargs)
self.STATIC_URL = static_url
self.xqueue = xqueue