Add jump_to functionality to lms on mongo
* More tests for mongo modulestore, including tweaks to test files * add location_to_id method to CourseDescriptors * Implement path_to_location method in mongo.py - TODO: does not handle position in sequences yet. * fix bug in jump_to view.
This commit is contained in:
@@ -28,17 +28,30 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
def has_started(self):
|
||||
return time.gmtime() > self.start
|
||||
|
||||
@classmethod
|
||||
def id_to_location(cls, course_id):
|
||||
@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.
|
||||
'''
|
||||
org, course, name = course_id.split('/')
|
||||
return Location('i4x', org, course, 'course', name)
|
||||
|
||||
@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 "/".join([self.location.org, self.location.course, self.location.name])
|
||||
return self.location_to_id(self.location)
|
||||
|
||||
@property
|
||||
def start_date_text(self):
|
||||
|
||||
@@ -9,16 +9,18 @@ from importlib import import_module
|
||||
from xmodule.errorhandlers import strict_error_handler
|
||||
from xmodule.x_module import XModuleDescriptor
|
||||
from xmodule.mako_module import MakoDescriptorSystem
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from mitxmako.shortcuts import render_to_string
|
||||
|
||||
from . import ModuleStore, Location
|
||||
from .exceptions import ItemNotFoundError, InsufficientSpecificationError
|
||||
from .exceptions import ItemNotFoundError, InsufficientSpecificationError, NoPathToItem
|
||||
|
||||
# TODO (cpennington): This code currently operates under the assumption that
|
||||
# there is only one revision for each item. Once we start versioning inside the CMS,
|
||||
# that assumption will have to change
|
||||
|
||||
|
||||
|
||||
class CachingDescriptorSystem(MakoDescriptorSystem):
|
||||
"""
|
||||
A system that has a cache of module json that it will use to load modules
|
||||
@@ -167,6 +169,14 @@ class MongoModuleStore(ModuleStore):
|
||||
course_filter = Location("i4x", category="course")
|
||||
return self.get_items(course_filter)
|
||||
|
||||
def _find_one(self, location):
|
||||
'''Look for a given location in the collection.
|
||||
If revision isn't specified, returns the latest.'''
|
||||
return self.collection.find_one(
|
||||
location_to_query(location),
|
||||
sort=[('revision', pymongo.ASCENDING)],
|
||||
)
|
||||
|
||||
def get_item(self, location, depth=0):
|
||||
"""
|
||||
Returns an XModuleDescriptor instance for the item at location.
|
||||
@@ -190,10 +200,7 @@ class MongoModuleStore(ModuleStore):
|
||||
if key != 'revision' and val is None:
|
||||
raise InsufficientSpecificationError(location)
|
||||
|
||||
item = self.collection.find_one(
|
||||
location_to_query(location),
|
||||
sort=[('revision', pymongo.ASCENDING)],
|
||||
)
|
||||
item = self._find_one(location)
|
||||
if item is None:
|
||||
raise ItemNotFoundError(location)
|
||||
return self._load_items([item], depth)[0]
|
||||
@@ -265,24 +272,101 @@ class MongoModuleStore(ModuleStore):
|
||||
{'$set': {'metadata': metadata}}
|
||||
)
|
||||
|
||||
def path_to_location(self, location, course=None):
|
||||
def get_parent_locations(self, location):
|
||||
'''Find all locations that are the parents of this location.
|
||||
Mostly intended for use in path_to_location, but exposed for testing
|
||||
and possible other usefulness.
|
||||
|
||||
returns an iterable of things that can be passed to Location.
|
||||
'''
|
||||
Try to find a course/chapter/section[/position] path to this location.
|
||||
location = Location(location)
|
||||
items = self.collection.find({'definition.children': str(location)},
|
||||
{'_id': True})
|
||||
return [i['_id'] for i in items]
|
||||
|
||||
|
||||
|
||||
def path_to_location(self, location, course_name=None):
|
||||
'''
|
||||
Try to find a course_id/chapter/section[/position] path to this location.
|
||||
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_name: [optional]. If not None, restrict search to paths
|
||||
in that course.
|
||||
|
||||
raise ItemNotFoundError if the location doesn't exist.
|
||||
|
||||
If course is not None, restrict search to paths in that course.
|
||||
|
||||
raise NoPathToItem if the location exists, but isn't accessible via
|
||||
a chapter/section path in the course(s) being searched.
|
||||
|
||||
In general, a location may be accessible via many paths. This method may
|
||||
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.
|
||||
|
||||
Return a tuple (course, chapter, section, position).
|
||||
|
||||
If the section a sequence, position should be the position of this location
|
||||
in that sequence. Otherwise, position should be None.
|
||||
If the section is a sequence, position will be the position
|
||||
of this location in that sequence. Otherwise, position will
|
||||
be None. TODO (vshnayder): Not true yet.
|
||||
'''
|
||||
raise NotImplementedError
|
||||
# Check that location is present at all
|
||||
if self._find_one(location) is None:
|
||||
raise ItemNotFoundError(location)
|
||||
|
||||
def flatten(xs):
|
||||
'''Convert lisp-style (a, (b, (c, ()))) lists into a python list.
|
||||
Not a general flatten function. '''
|
||||
p = []
|
||||
while xs != ():
|
||||
p.append(xs[0])
|
||||
xs = xs[1]
|
||||
return p
|
||||
|
||||
def find_path_to_course(location, course_name=None):
|
||||
'''Find a path up the location graph to a node with the
|
||||
specified category. If no path exists, return None. If a
|
||||
path exists, return it as a list with target location
|
||||
first, and the starting location last.
|
||||
'''
|
||||
# Standard DFS
|
||||
|
||||
# To keep track of where we came from, the work queue has
|
||||
# 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, ())]
|
||||
while len(queue) > 0:
|
||||
(loc, path) = queue.pop() # Takes from the end
|
||||
loc = Location(loc)
|
||||
print 'Processing loc={0}, path={1}'.format(loc, path)
|
||||
if loc.category == "course":
|
||||
if course_name is None or course_name == loc.name:
|
||||
# Found it!
|
||||
path = (loc, path)
|
||||
return flatten(path)
|
||||
|
||||
# otherwise, add parent locations at the end
|
||||
newpath = (loc, path)
|
||||
parents = self.get_parent_locations(loc)
|
||||
queue.extend(zip(parents, repeat(newpath)))
|
||||
|
||||
# If we're here, there is no path
|
||||
return None
|
||||
|
||||
path = find_path_to_course(location, course_name)
|
||||
if path is None:
|
||||
raise(NoPathToItem(location))
|
||||
|
||||
|
||||
n = len(path)
|
||||
course_id = CourseDescriptor.location_to_id(path[0])
|
||||
chapter = path[1].name if n > 1 else None
|
||||
section = path[2].name if n > 2 else None
|
||||
|
||||
# TODO (vshnayder): not handling position at all yet...
|
||||
position = None
|
||||
|
||||
return (course_id, chapter, section, position)
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@ import pymongo
|
||||
|
||||
from nose.tools import assert_equals, assert_raises, assert_not_equals, with_setup
|
||||
from path import path
|
||||
from pprint import pprint
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError
|
||||
from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError, NoPathToItem
|
||||
from xmodule.modulestore.mongo import MongoModuleStore
|
||||
from xmodule.modulestore.xml_importer import import_from_xml
|
||||
|
||||
@@ -33,28 +34,44 @@ class TestMongoModuleStore(object):
|
||||
cls.connection = pymongo.connection.Connection(HOST, PORT)
|
||||
cls.connection.drop_database(DB)
|
||||
|
||||
# NOTE: Creating a single db for all the tests to save time. This
|
||||
# is ok only as long as none of the tests modify the db.
|
||||
# If (when!) that changes, need to either reload the db, or load
|
||||
# once and copy over to a tmp db for each test.
|
||||
cls.store = cls.initdb()
|
||||
|
||||
@classmethod
|
||||
def teardownClass(cls):
|
||||
pass
|
||||
|
||||
def setUp(self):
|
||||
@staticmethod
|
||||
def initdb():
|
||||
# connect to the db
|
||||
self.store = MongoModuleStore(HOST, DB, COLLECTION, FS_ROOT, default_class=DEFAULT_CLASS)
|
||||
store = MongoModuleStore(HOST, DB, COLLECTION, FS_ROOT, default_class=DEFAULT_CLASS)
|
||||
# Explicitly list the courses to load (don't want the big one)
|
||||
courses = ['toy', 'simple']
|
||||
import_from_xml(self.store, DATA_DIR, courses)
|
||||
self.connection = TestMongoModuleStore.connection
|
||||
|
||||
def tearDown(self):
|
||||
import_from_xml(store, DATA_DIR, courses)
|
||||
return store
|
||||
|
||||
@staticmethod
|
||||
def destroy_db(connection):
|
||||
# Destroy the test db.
|
||||
self.connection.drop_database(DB)
|
||||
self.store = None
|
||||
connection.drop_database(DB)
|
||||
|
||||
def setUp(self):
|
||||
# make a copy for convenience
|
||||
self.connection = TestMongoModuleStore.connection
|
||||
|
||||
def tearDown(self):
|
||||
pass
|
||||
|
||||
def test_init(self):
|
||||
'''Just make sure the db loads'''
|
||||
'''Make sure the db loads, and print all the locations in the db.
|
||||
Call this directly from failing tests to see what's loaded'''
|
||||
ids = list(self.connection[DB][COLLECTION].find({}, {'_id': True}))
|
||||
print len(ids)
|
||||
|
||||
|
||||
pprint([Location(i['_id']).url() for i in ids])
|
||||
|
||||
def test_get_courses(self):
|
||||
'''Make sure the course objects loaded properly'''
|
||||
courses = self.store.get_courses()
|
||||
@@ -63,10 +80,41 @@ class TestMongoModuleStore(object):
|
||||
assert_equals(courses[0].id, 'edX/simple/2012_Fall')
|
||||
assert_equals(courses[1].id, 'edX/toy/2012_Fall')
|
||||
|
||||
def Xtest_path_to_location(self):
|
||||
def test_loads(self):
|
||||
assert_not_equals(
|
||||
self.store.get_item("i4x://edX/toy/course/2012_Fall"),
|
||||
None)
|
||||
|
||||
assert_not_equals(
|
||||
self.store.get_item("i4x://edX/simple/course/2012_Fall"),
|
||||
None)
|
||||
|
||||
assert_not_equals(
|
||||
self.store.get_item("i4x://edX/toy/video/Welcome"),
|
||||
None)
|
||||
|
||||
|
||||
|
||||
def test_find_one(self):
|
||||
assert_not_equals(
|
||||
self.store._find_one(Location("i4x://edX/toy/course/2012_Fall")),
|
||||
None)
|
||||
|
||||
assert_not_equals(
|
||||
self.store._find_one(Location("i4x://edX/simple/course/2012_Fall")),
|
||||
None)
|
||||
|
||||
assert_not_equals(
|
||||
self.store._find_one(Location("i4x://edX/toy/video/Welcome")),
|
||||
None)
|
||||
|
||||
def test_path_to_location(self):
|
||||
'''Make sure that path_to_location works'''
|
||||
should_work = (
|
||||
("i4x://edX/toy/video/Welcome", ("toy", "Overview", None, None)),
|
||||
("i4x://edX/toy/video/Welcome",
|
||||
("edX/toy/2012_Fall", "Overview", "Welcome", None)),
|
||||
("i4x://edX/toy/html/toylab",
|
||||
("edX/toy/2012_Fall", "Overview", "Toy_Videos", None)),
|
||||
)
|
||||
for location, expected in should_work:
|
||||
assert_equals(self.store.path_to_location(location), expected)
|
||||
@@ -76,10 +124,13 @@ class TestMongoModuleStore(object):
|
||||
)
|
||||
for location in not_found:
|
||||
assert_raises(ItemNotFoundError, self.store.path_to_location, location)
|
||||
|
||||
|
||||
# Since our test files are valid, there shouldn't be any
|
||||
# elements with no path to them. But we can look for them in
|
||||
# another course.
|
||||
no_path = (
|
||||
"i4x://edX/toy/video/Lost_Video",
|
||||
"i4x://edX/simple/video/Lost_Video",
|
||||
)
|
||||
for location in not_found:
|
||||
assert_raises(ItemNotFoundError, self.store.path_to_location, location)
|
||||
|
||||
for location in no_path:
|
||||
assert_raises(NoPathToItem, self.store.path_to_location, location, "toy")
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<problem type="lecture" showanswer="attempted" rerandomize="true" title="A simple coding problem" name="Simple coding problem" filename="ps01-simple"/>
|
||||
</sequential>
|
||||
</section>
|
||||
<video name="Lost Video" youtube="1.0:TBvX7HzxexQ"/>
|
||||
</chapter>
|
||||
<video name="Lost Video" youtube="1.0:TBvX7HzxexQ"/>
|
||||
|
||||
</course>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<course name="Toy Course" org="edX" course="toy" graceperiod="1 day 5 hours 59 minutes 59 seconds" slug="2012_Fall" start="2015-07-17T12:00">
|
||||
<chapter name="Overview">
|
||||
<video name="Welcome" youtube="1.0:p2Q6BrNhdh8"/>
|
||||
<videosequence format="Lecture Sequence" name="System Usage Sequence">
|
||||
<html id="toylab" filename="toylab"/>
|
||||
<video name="S0V1: Video Resources" youtube="1.0:1bK-WdDi6Qw"/>
|
||||
<videosequence format="Lecture Sequence" name="Toy Videos">
|
||||
<html name="toylab" filename="toylab"/>
|
||||
<video name="Video Resources" youtube="1.0:1bK-WdDi6Qw"/>
|
||||
</videosequence>
|
||||
<video name="Welcome" youtube="1.0:p2Q6BrNhdh8"/>
|
||||
</chapter>
|
||||
</course>
|
||||
|
||||
@@ -208,7 +208,7 @@ def index(request, course_id, chapter=None, section=None,
|
||||
result = render_to_response('courseware.html', context)
|
||||
return result
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def jump_to(request, location):
|
||||
'''
|
||||
Show the page that contains a specific location.
|
||||
@@ -228,17 +228,22 @@ def jump_to(request, location):
|
||||
|
||||
# Complain if there's not data for this location
|
||||
try:
|
||||
(course, chapter, section, position) = modulestore().path_to_location(location)
|
||||
(course_id, chapter, section, position) = modulestore().path_to_location(location)
|
||||
except ItemNotFoundError:
|
||||
raise Http404("No data at this location: {0}".format(location))
|
||||
except NoPathToItem:
|
||||
raise Http404("This location is not in any class: {0}".format(location))
|
||||
|
||||
|
||||
return index(course, chapter, section, position)
|
||||
|
||||
return index(request, course_id, chapter, section, position)
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def course_info(request, course_id):
|
||||
'''
|
||||
Display the course's info.html, or 404 if there is no such course.
|
||||
|
||||
Assumes the course_id is in a valid format.
|
||||
'''
|
||||
course = check_course(course_id)
|
||||
|
||||
return render_to_response('info.html', {'course': course})
|
||||
|
||||
Reference in New Issue
Block a user