Commit dac66a99 by Brittany Cheng

Merge branch 'master' of github.com:dementrock/mitx into discussion2

parents 01ac251c ce15faf6
......@@ -23,4 +23,7 @@ class Command(BaseCommand):
course_dirs = args[1:]
else:
course_dirs = None
print "Importing. Data_dir={data}, course_dirs={courses}".format(
data=data_dir,
courses=course_dirs)
import_from_xml(modulestore(), data_dir, course_dirs)
......@@ -214,7 +214,10 @@ def preview_module_system(request, preview_id, descriptor):
get_module=partial(get_preview_module, request, preview_id),
render_template=render_from_lms,
debug=True,
replace_urls=replace_urls
replace_urls=replace_urls,
# TODO (vshnayder): All CMS users get staff view by default
# is that what we want?
is_staff=True,
)
......
......@@ -76,3 +76,6 @@ CACHES = {
'KEY_FUNCTION': 'util.memcache.safe_key',
}
}
# Make the keyedcache startup warnings go away
CACHE_TIMEOUT = 0
......@@ -76,8 +76,9 @@ def add_histogram(get_html, module):
histogram = grade_histogram(module_id)
render_histogram = len(histogram) > 0
# TODO: fixme - no filename in module.xml in general (this code block for edx4edx)
# the following if block is for summer 2012 edX course development; it will change when the CMS comes online
# TODO: fixme - no filename in module.xml in general (this code block
# for edx4edx) the following if block is for summer 2012 edX course
# development; it will change when the CMS comes online
if settings.MITX_FEATURES.get('DISPLAY_EDIT_LINK') and settings.DEBUG and module_xml.get('filename') is not None:
coursename = multicourse_settings.get_coursename_from_request(request)
github_url = multicourse_settings.get_course_github_url(coursename)
......@@ -88,10 +89,8 @@ def add_histogram(get_html, module):
else:
edit_link = False
# Cast module.definition and module.metadata to dicts so that json can dump them
# even though they are lazily loaded
staff_context = {'definition': json.dumps(dict(module.definition), indent=4),
'metadata': json.dumps(dict(module.metadata), indent=4),
staff_context = {'definition': dict(module.definition),
'metadata': dict(module.metadata),
'element_id': module.location.html_id(),
'edit_link': edit_link,
'histogram': json.dumps(histogram),
......
......@@ -329,7 +329,7 @@ class LoncapaProblem(object):
# path is an absolute path or a path relative to the data dir
dir = os.path.join(self.system.filestore.root_path, dir)
abs_dir = os.path.normpath(dir)
log.debug("appending to path: %s" % abs_dir)
#log.debug("appending to path: %s" % abs_dir)
path.append(abs_dir)
return path
......
from lxml import etree
def check_html(html):
'''
Check whether the passed in html string can be parsed by lxml.
Return bool success.
'''
parser = etree.HTMLParser()
try:
etree.fromstring(html, parser)
return True
except Exception as err:
pass
return False
from collections import MutableMapping
class LazyLoadingDict(MutableMapping):
"""
A dictionary object that lazily loads its contents from a provided
function on reads (of members that haven't already been set).
"""
def __init__(self, loader):
'''
On the first read from this dictionary, it will call loader() to
populate its contents. loader() must return something dict-like. Any
elements set before the first read will be preserved.
'''
self._contents = {}
self._loaded = False
self._loader = loader
self._deleted = set()
def __getitem__(self, name):
if not (self._loaded or name in self._contents or name in self._deleted):
self.load()
return self._contents[name]
def __setitem__(self, name, value):
self._contents[name] = value
self._deleted.discard(name)
def __delitem__(self, name):
del self._contents[name]
self._deleted.add(name)
def __contains__(self, name):
self.load()
return name in self._contents
def __len__(self):
self.load()
return len(self._contents)
def __iter__(self):
self.load()
return iter(self._contents)
def __repr__(self):
self.load()
return repr(self._contents)
def load(self):
if self._loaded:
return
loaded_contents = self._loader()
loaded_contents.update(self._contents)
self._contents = loaded_contents
self._loaded = True
......@@ -25,6 +25,7 @@ setup(
"discuss = xmodule.backcompat_module:TranslateCustomTagDescriptor",
"html = xmodule.html_module:HtmlDescriptor",
"image = xmodule.backcompat_module:TranslateCustomTagDescriptor",
"error = xmodule.error_module:ErrorDescriptor",
"problem = xmodule.capa_module:CapaDescriptor",
"problemset = xmodule.vertical_module:VerticalDescriptor",
"section = xmodule.backcompat_module:SemanticSectionDescriptor",
......
from itertools import chain
from lxml import etree
def stringify_children(node):
'''
Return all contents of an xml tree, without the outside tags.
e.g. if node is parse of
"<html a="b" foo="bar">Hi <div>there <span>Bruce</span><b>!</b></div><html>"
should return
"Hi <div>there <span>Bruce</span><b>!</b></div>"
fixed from
http://stackoverflow.com/questions/4624062/get-all-text-inside-a-tag-in-lxml
'''
parts = ([node.text] +
list(chain(*([etree.tostring(c), c.tail]
for c in node.getchildren())
)))
# filter removes possible Nones in texts and tails
return ''.join(filter(None, parts))
......@@ -31,7 +31,8 @@ i4xs = ModuleSystem(
user=Mock(),
filestore=fs.osfs.OSFS(os.path.dirname(os.path.realpath(__file__))),
debug=True,
xqueue_callback_url='/'
xqueue_callback_url='/',
is_staff=False
)
......
from path import path
import unittest
from lxml import etree
from xmodule.x_module import XMLParsingSystem, XModuleDescriptor
from xmodule.errortracker import null_error_tracker
from xmodule.modulestore import Location
class ImportTestCase(unittest.TestCase):
'''Make sure module imports work properly, including for malformed inputs'''
@staticmethod
def get_system():
'''Get a dummy system'''
# Shouldn't need any system params, because the initial parse should fail
def load_item(loc):
raise Exception("Shouldn't be called")
resources_fs = None
def process_xml(xml):
raise Exception("Shouldn't be called")
def render_template(template, context):
raise Exception("Shouldn't be called")
system = XMLParsingSystem(load_item, resources_fs,
null_error_tracker, process_xml)
system.render_template = render_template
return system
def test_fallback(self):
'''Make sure that malformed xml loads as an ErrorDescriptor.'''
bad_xml = '''<sequential display_name="oops"><video url="hi"></sequential>'''
system = self.get_system()
descriptor = XModuleDescriptor.load_from_xml(bad_xml, system, 'org', 'course',
None)
self.assertEqual(descriptor.__class__.__name__,
'ErrorDescriptor')
def test_reimport(self):
'''Make sure an already-exported error xml tag loads properly'''
bad_xml = '''<sequential display_name="oops"><video url="hi"></sequential>'''
system = self.get_system()
descriptor = XModuleDescriptor.load_from_xml(bad_xml, system, 'org', 'course',
None)
resource_fs = None
tag_xml = descriptor.export_to_xml(resource_fs)
re_import_descriptor = XModuleDescriptor.load_from_xml(tag_xml, system,
'org', 'course',
None)
self.assertEqual(re_import_descriptor.__class__.__name__,
'ErrorDescriptor')
self.assertEqual(descriptor.definition['data'],
re_import_descriptor.definition['data'])
def test_fixed_xml_tag(self):
"""Make sure a tag that's been fixed exports as the original tag type"""
# create a error tag with valid xml contents
root = etree.Element('error')
good_xml = '''<sequential display_name="fixed"><video url="hi"/></sequential>'''
root.text = good_xml
xml_str_in = etree.tostring(root)
# load it
system = self.get_system()
descriptor = XModuleDescriptor.load_from_xml(xml_str_in, system, 'org', 'course',
None)
# export it
resource_fs = None
xml_str_out = descriptor.export_to_xml(resource_fs)
# Now make sure the exported xml is a sequential
xml_out = etree.fromstring(xml_str_out)
self.assertEqual(xml_out.tag, 'sequential')
from nose.tools import assert_equals
from lxml import etree
from stringify import stringify_children
def test_stringify():
html = '''<html a="b" foo="bar">Hi <div x="foo">there <span>Bruce</span><b>!</b></div></html>'''
xml = etree.fromstring(html)
out = stringify_children(xml)
assert_equals(out, '''Hi <div x="foo">there <span>Bruce</span><b>!</b></div>''')
......@@ -32,21 +32,25 @@ def process_includes(fn):
# read in and convert to XML
incxml = etree.XML(ifp.read())
# insert new XML into tree in place of inlcude
# insert new XML into tree in place of include
parent.insert(parent.index(next_include), incxml)
except Exception:
msg = "Error in problem xml include: %s" % (etree.tostring(next_include, pretty_print=True))
log.exception(msg)
parent = next_include.getparent()
# Log error
msg = "Error in problem xml include: %s" % (
etree.tostring(next_include, pretty_print=True))
# tell the tracker
system.error_tracker(msg)
# work around
parent = next_include.getparent()
errorxml = etree.Element('error')
messagexml = etree.SubElement(errorxml, 'message')
messagexml.text = msg
stackxml = etree.SubElement(errorxml, 'stacktrace')
stackxml.text = traceback.format_exc()
# insert error XML in place of include
parent.insert(parent.index(next_include), errorxml)
parent.remove(next_include)
next_include = xml_object.find('include')
......
......@@ -5,6 +5,7 @@ import json
import logging
import traceback
import re
import sys
from datetime import timedelta
from lxml import etree
......@@ -91,7 +92,8 @@ class CapaModule(XModule):
display_due_date_string = self.metadata.get('due', None)
if display_due_date_string is not None:
self.display_due_date = dateutil.parser.parse(display_due_date_string)
#log.debug("Parsed " + display_due_date_string + " to " + str(self.display_due_date))
#log.debug("Parsed " + display_due_date_string +
# " to " + str(self.display_due_date))
else:
self.display_due_date = None
......@@ -99,7 +101,8 @@ class CapaModule(XModule):
if grace_period_string is not None and self.display_due_date:
self.grace_period = parse_timedelta(grace_period_string)
self.close_date = self.display_due_date + self.grace_period
#log.debug("Then parsed " + grace_period_string + " to closing date" + str(self.close_date))
#log.debug("Then parsed " + grace_period_string +
# " to closing date" + str(self.close_date))
else:
self.grace_period = None
self.close_date = self.display_due_date
......@@ -138,10 +141,16 @@ class CapaModule(XModule):
try:
self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(),
instance_state, seed=seed, system=self.system)
except Exception:
msg = 'cannot create LoncapaProblem %s' % self.location.url()
log.exception(msg)
except Exception as err:
msg = 'cannot create LoncapaProblem {loc}: {err}'.format(
loc=self.location.url(), err=err)
# TODO (vshnayder): do modules need error handlers too?
# We shouldn't be switching on DEBUG.
if self.system.DEBUG:
log.error(msg)
# TODO (vshnayder): This logic should be general, not here--and may
# want to preserve the data instead of replacing it.
# e.g. in the CMS
msg = '<p>%s</p>' % msg.replace('<', '&lt;')
msg += '<p><pre>%s</pre></p>' % traceback.format_exc().replace('<', '&lt;')
# create a dummy problem with error message instead of failing
......@@ -152,7 +161,8 @@ class CapaModule(XModule):
problem_text, self.location.html_id(),
instance_state, seed=seed, system=self.system)
else:
raise
# add extra info and raise
raise Exception(msg), None, sys.exc_info()[2]
@property
def rerandomize(self):
......@@ -191,6 +201,7 @@ class CapaModule(XModule):
try:
return Progress(score, total)
except Exception as err:
# TODO (vshnayder): why is this still here? still needed?
if self.system.DEBUG:
return None
raise
......@@ -210,6 +221,7 @@ class CapaModule(XModule):
try:
html = self.lcp.get_html()
except Exception, err:
# TODO (vshnayder): another switch on DEBUG.
if self.system.DEBUG:
log.exception(err)
msg = (
......@@ -561,6 +573,7 @@ class CapaDescriptor(RawDescriptor):
'problems/' + path[8:],
path[8:],
]
@classmethod
def split_to_file(cls, xml_object):
'''Problems always written in their own files'''
......
......@@ -20,15 +20,22 @@ class CourseDescriptor(SequenceDescriptor):
self._grader = None
self._grade_cutoffs = None
msg = None
try:
self.start = time.strptime(self.metadata["start"], "%Y-%m-%dT%H:%M")
except KeyError:
self.start = time.gmtime(0) #The epoch
log.critical("Course loaded without a start date. %s", self.id)
msg = "Course loaded without a start date. id = %s" % self.id
log.critical(msg)
except ValueError as e:
self.start = time.gmtime(0) #The epoch
log.critical("Course loaded with a bad start date. %s '%s'",
self.id, e)
msg = "Course loaded with a bad start date. %s '%s'" % (self.id, e)
log.critical(msg)
# Don't call the tracker from the exception handler.
if msg is not None:
system.error_tracker(msg)
def has_started(self):
return time.gmtime() > self.start
......@@ -104,3 +111,4 @@ class CourseDescriptor(SequenceDescriptor):
@property
def org(self):
return self.location.org
from pkg_resources import resource_string
from lxml import etree
from xmodule.mako_module import MakoModuleDescriptor
import logging
log = logging.getLogger(__name__)
class EditingDescriptor(MakoModuleDescriptor):
"""
Module that provides a raw editing view of its data and children. It does not
perform any validation on its definition---just passes it along to the browser.
This class is intended to be used as a mixin.
"""
mako_template = "widgets/raw-edit.html"
js = {'coffee': [resource_string(__name__, 'js/src/raw/edit.coffee')]}
js_module_name = "RawDescriptor"
def get_context(self):
return {
'module': self,
'data': self.definition['data'],
}
from pkg_resources import resource_string
from lxml import etree
from xmodule.x_module import XModule
from xmodule.mako_module import MakoModuleDescriptor
from xmodule.xml_module import XmlDescriptor
from xmodule.editing_module import EditingDescriptor
import logging
log = logging.getLogger(__name__)
class ErrorModule(XModule):
def get_html(self):
'''Show an error.
TODO (vshnayder): proper style, divs, etc.
'''
if not self.system.is_staff:
return self.system.render_template('module-error.html', {})
# staff get to see all the details
return self.system.render_template('module-error-staff.html', {
'data' : self.definition['data'],
# TODO (vshnayder): need to get non-syntax errors in here somehow
'error' : self.definition.get('error', 'Error not available')
})
class ErrorDescriptor(EditingDescriptor):
"""
Module that provides a raw editing view of broken xml.
"""
module_class = ErrorModule
@classmethod
def from_xml(cls, xml_data, system, org=None, course=None, err=None):
'''Create an instance of this descriptor from the supplied data.
Does not try to parse the data--just stores it.
Takes an extra, optional, parameter--the error that caused an
issue.
'''
definition = {}
if err is not None:
definition['error'] = err
try:
# If this is already an error tag, don't want to re-wrap it.
xml_obj = etree.fromstring(xml_data)
if xml_obj.tag == 'error':
xml_data = xml_obj.text
except etree.XMLSyntaxError as err:
# Save the error to display later--overrides other problems
definition['error'] = err
definition['data'] = xml_data
# TODO (vshnayder): Do we need a unique slug here? Just pick a random
# 64-bit num?
location = ['i4x', org, course, 'error', 'slug']
metadata = {} # stays in the xml_data
return cls(system, definition, location=location, metadata=metadata)
def export_to_xml(self, resource_fs):
'''
If the definition data is invalid xml, export it wrapped in an "error"
tag. If it is valid, export without the wrapper.
NOTE: There may still be problems with the valid xml--it could be
missing required attributes, could have the wrong tags, refer to missing
files, etc. That would just get re-wrapped on import.
'''
try:
xml = etree.fromstring(self.definition['data'])
return etree.tostring(xml)
except etree.XMLSyntaxError:
# still not valid.
root = etree.Element('error')
root.text = self.definition['data']
return etree.tostring(root)
import logging
import sys
log = logging.getLogger(__name__)
def in_exception_handler():
'''Is there an active exception?'''
return sys.exc_info() != (None, None, None)
def strict_error_handler(msg, exc_info=None):
'''
Do not let errors pass. If exc_info is not None, ignore msg, and just
re-raise. Otherwise, check if we are in an exception-handling context.
If so, re-raise. Otherwise, raise Exception(msg).
Meant for use in validation, where any errors should trap.
'''
if exc_info is not None:
raise exc_info[0], exc_info[1], exc_info[2]
if in_exception_handler():
raise
raise Exception(msg)
def logging_error_handler(msg, exc_info=None):
'''Log all errors, but otherwise let them pass, relying on the caller to
workaround.'''
if exc_info is not None:
log.exception(msg, exc_info=exc_info)
return
if in_exception_handler():
log.exception(msg)
return
log.error(msg)
def ignore_errors_handler(msg, exc_info=None):
'''Ignore all errors, relying on the caller to workaround.
Meant for use in the LMS, where an error in one part of the course
shouldn't bring down the whole system'''
pass
import logging
import sys
import traceback
from collections import namedtuple
log = logging.getLogger(__name__)
ErrorLog = namedtuple('ErrorLog', 'tracker errors')
def in_exception_handler():
'''Is there an active exception?'''
return sys.exc_info() != (None, None, None)
def make_error_tracker():
'''Return an ErrorLog (named tuple), with fields (tracker, errors), where
the logger appends a tuple (message, exception_str) to the errors on every
call. exception_str is in the format returned by traceback.format_exception.
error_list is a simple list. If the caller modifies it, info
will be lost.
'''
errors = []
def error_tracker(msg):
'''Log errors'''
exc_str = ''
if in_exception_handler():
exc_str = ''.join(traceback.format_exception(*sys.exc_info()))
errors.append((msg, exc_str))
return ErrorLog(error_tracker, errors)
def null_error_tracker(msg):
'''A dummy error tracker that just ignores the messages'''
pass
import copy
from fs.errors import ResourceNotFoundError
import logging
import os
import sys
from lxml import etree
from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
from xmodule.xml_module import XmlDescriptor
from xmodule.editing_module import EditingDescriptor
from stringify import stringify_children
from html_checker import check_html
log = logging.getLogger("mitx.courseware")
class HtmlModule(XModule):
def get_html(self):
return self.html
......@@ -19,33 +24,110 @@ class HtmlModule(XModule):
self.html = self.definition['data']
class HtmlDescriptor(RawDescriptor):
class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
"""
Module for putting raw html in a course
"""
mako_template = "widgets/html-edit.html"
module_class = HtmlModule
filename_extension = "html"
filename_extension = "xml"
# TODO (cpennington): Delete this method once all fall 2012 course are being
# edited in the cms
# VS[compat] TODO (cpennington): Delete this method once all fall 2012 course
# are being edited in the cms
@classmethod
def backcompat_paths(cls, path):
if path.endswith('.html.html'):
path = path[:-5]
origpath = path
if path.endswith('.html.xml'):
path = path[:-9] + '.html' #backcompat--look for html instead of xml
candidates = []
while os.sep in path:
candidates.append(path)
_, _, path = path.partition(os.sep)
# also look for .html versions instead of .xml
if origpath.endswith('.xml'):
candidates.append(origpath[:-4] + '.html')
return candidates
# NOTE: html descriptors are special. We do not want to parse and
# export them ourselves, because that can break things (e.g. lxml
# adds body tags when it exports, but they should just be html
# snippets that will be included in the middle of pages.
@classmethod
def file_to_xml(cls, file_object):
parser = etree.HTMLParser()
return etree.parse(file_object, parser).getroot()
def load_definition(cls, xml_object, system, location):
'''Load a descriptor from the specified xml_object:
If there is a filename attribute, load it as a string, and
log a warning if it is not parseable by etree.HTMLParser.
If there is not a filename attribute, the definition is the body
of the xml_object, without the root tag (do not want <html> in the
middle of a page)
'''
filename = xml_object.get('filename')
if filename is None:
definition_xml = copy.deepcopy(xml_object)
cls.clean_metadata_from_xml(definition_xml)
return {'data' : stringify_children(definition_xml)}
else:
filepath = cls._format_filepath(xml_object.tag, filename)
# VS[compat]
# TODO (cpennington): If the file doesn't exist at the right path,
# give the class a chance to fix it up. The file will be written out
# again in the correct format. This should go away once the CMS is
# online and has imported all current (fall 2012) courses from xml
if not system.resources_fs.exists(filepath):
candidates = cls.backcompat_paths(filepath)
#log.debug("candidates = {0}".format(candidates))
for candidate in candidates:
if system.resources_fs.exists(candidate):
filepath = candidate
break
try:
with system.resources_fs.open(filepath) as file:
html = file.read()
# Log a warning if we can't parse the file, but don't error
if not check_html(html):
msg = "Couldn't parse html in {0}.".format(filepath)
log.warning(msg)
system.error_tracker("Warning: " + msg)
return {'data' : html}
except (ResourceNotFoundError) as err:
msg = 'Unable to load file contents at path {0}: {1} '.format(
filepath, err)
# add more info and re-raise
raise Exception(msg), None, sys.exc_info()[2]
@classmethod
def split_to_file(cls, xml_object):
# never include inline html
'''Never include inline html'''
return True
# TODO (vshnayder): make export put things in the right places.
def definition_to_xml(self, resource_fs):
'''If the contents are valid xml, write them to filename.xml. Otherwise,
write just the <html filename=""> tag to filename.xml, and the html
string to filename.html.
'''
try:
return etree.fromstring(self.definition['data'])
except etree.XMLSyntaxError:
pass
# Not proper format. Write html to file, return an empty tag
filepath = u'{category}/{name}.html'.format(category=self.category,
name=self.name)
resource_fs.makedir(os.path.dirname(filepath), allow_recreate=True)
with resource_fs.open(filepath, 'w') as file:
file.write(self.definition['data'])
elt = etree.Element('html')
elt.set("filename", self.name)
return elt
......@@ -2,10 +2,10 @@ from x_module import XModuleDescriptor, DescriptorSystem
class MakoDescriptorSystem(DescriptorSystem):
def __init__(self, load_item, resources_fs, error_handler,
render_template):
def __init__(self, load_item, resources_fs, error_tracker,
render_template, **kwargs):
super(MakoDescriptorSystem, self).__init__(
load_item, resources_fs, error_handler)
load_item, resources_fs, error_tracker, **kwargs)
self.render_template = render_template
......
......@@ -5,7 +5,7 @@ that are stored in a database an accessible using their Location as an identifie
import re
from collections import namedtuple
from .exceptions import InvalidLocationError
from .exceptions import InvalidLocationError, InsufficientSpecificationError
import logging
log = logging.getLogger('mitx.' + 'modulestore')
......@@ -38,15 +38,15 @@ class Location(_LocationBase):
'''
__slots__ = ()
@classmethod
def clean(cls, value):
@staticmethod
def clean(value):
"""
Return value, made into a form legal for locations
"""
return re.sub('_+', '_', INVALID_CHARS.sub('_', value))
@classmethod
def is_valid(cls, value):
@staticmethod
def is_valid(value):
'''
Check if the value is a valid location, in any acceptable format.
'''
......@@ -56,6 +56,21 @@ class Location(_LocationBase):
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):
"""
......@@ -198,6 +213,18 @@ class ModuleStore(object):
"""
raise NotImplementedError
def get_item_errors(self, location):
"""
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
Raises the same exceptions as get_item if the location isn't found or
isn't fully specified.
"""
raise NotImplementedError
def get_items(self, location, depth=0):
"""
Returns a list of XModuleDescriptor instances for the items
......@@ -254,25 +281,12 @@ class ModuleStore(object):
'''
raise NotImplementedError
def path_to_location(self, location, course=None, chapter=None, section=None):
'''
Try to find a course/chapter/section[/position] path to this location.
raise ItemNotFoundError if the location doesn't exist.
If course, chapter, section are not None, restrict search to paths with those
components as specified.
raise NoPathToItem if the location exists, but isn't accessible via
a path that matches the course/chapter/section restrictions.
In general, a location may be accessible via many paths. This method may
return any valid path.
Return a tuple (course, chapter, section, position).
def get_parent_locations(self, location):
'''Find all locations that are the parents of this location. Needed
for path_to_location().
If the section a sequence, position should be the position of this location
in that sequence. Otherwise, position should be None.
returns an iterable of things that can be passed to Location.
'''
raise NotImplementedError
......@@ -6,14 +6,13 @@ from itertools import repeat
from path import path
from importlib import import_module
from xmodule.errorhandlers import strict_error_handler
from xmodule.errortracker import null_error_tracker
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,
NoPathToItem, DuplicateItemError)
# TODO (cpennington): This code currently operates under the assumption that
......@@ -27,7 +26,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
from, with a backup of calling to the underlying modulestore for more data
"""
def __init__(self, modulestore, module_data, default_class, resources_fs,
error_handler, render_template):
error_tracker, render_template):
"""
modulestore: the module store that can be used to retrieve additional modules
......@@ -39,13 +38,13 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
resources_fs: a filesystem, as per MakoDescriptorSystem
error_handler:
error_tracker:
render_template: a function for rendering templates, as per
MakoDescriptorSystem
"""
super(CachingDescriptorSystem, self).__init__(
self.load_item, resources_fs, error_handler, render_template)
self.load_item, resources_fs, error_tracker, render_template)
self.modulestore = modulestore
self.module_data = module_data
self.default_class = default_class
......@@ -80,7 +79,8 @@ class MongoModuleStore(ModuleStore):
"""
# TODO (cpennington): Enable non-filesystem filestores
def __init__(self, host, db, collection, fs_root, port=27017, default_class=None):
def __init__(self, host, db, collection, fs_root, port=27017, default_class=None,
error_tracker=null_error_tracker):
self.collection = pymongo.connection.Connection(
host=host,
port=port
......@@ -91,13 +91,17 @@ class MongoModuleStore(ModuleStore):
# Force mongo to maintain an index over _id.* that is in the same order
# that is used when querying by a location
self.collection.ensure_index(zip(('_id.' + field for field in Location._fields), repeat(1)))
self.collection.ensure_index(
zip(('_id.' + field for field in Location._fields), repeat(1)))
# TODO (vshnayder): default arg default_class=None will make this error
module_path, _, class_name = default_class.rpartition('.')
class_ = getattr(import_module(module_path), class_name)
self.default_class = class_
if default_class is not None:
module_path, _, class_name = default_class.rpartition('.')
class_ = getattr(import_module(module_path), class_name)
self.default_class = class_
else:
self.default_class = None
self.fs_root = path(fs_root)
self.error_tracker = error_tracker
def _clean_item_data(self, item):
"""
......@@ -149,7 +153,7 @@ class MongoModuleStore(ModuleStore):
data_cache,
self.default_class,
resource_fs,
strict_error_handler,
self.error_tracker,
render_to_string,
)
return system.load_item(item['location'])
......@@ -172,12 +176,17 @@ class MongoModuleStore(ModuleStore):
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(
'''Look for a given location in the collection. If revision is not
specified, returns the latest. If the item is not present, raise
ItemNotFoundError.
'''
item = self.collection.find_one(
location_to_query(location),
sort=[('revision', pymongo.ASCENDING)],
)
if item is None:
raise ItemNotFoundError(location)
return item
def get_item(self, location, depth=0):
"""
......@@ -197,14 +206,8 @@ class MongoModuleStore(ModuleStore):
calls to get_children() to cache. None indicates to cache all descendents.
"""
for key, val in Location(location).dict().iteritems():
if key != 'revision' and val is None:
raise InsufficientSpecificationError(location)
location = Location.ensure_fully_specified(location)
item = self._find_one(location)
if item is None:
raise ItemNotFoundError(location)
return self._load_items([item], depth)[0]
def get_items(self, location, depth=0):
......@@ -282,96 +285,20 @@ class MongoModuleStore(ModuleStore):
)
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.
'''Find all locations that are the parents of this location. Needed
for path_to_location().
If there is no data at location in this modulestore, raise
ItemNotFoundError.
returns an iterable of things that can be passed to Location.
returns an iterable of things that can be passed to Location. This may
be empty if there are no parents.
'''
location = Location(location)
items = self.collection.find({'definition.children': str(location)},
location = Location.ensure_fully_specified(location)
# Check that it's actually in this modulestore.
item = self._find_one(location)
# now get the parents
items = self.collection.find({'definition.children': location.url()},
{'_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.
raise NoPathToItem if the location exists, but isn't accessible via
a chapter/section path in the course(s) being searched.
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 sequence, position will be the position
of this location in that sequence. Otherwise, position will
be None. TODO (vshnayder): Not true yet.
'''
# 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)
from itertools import repeat
from xmodule.course_module import CourseDescriptor
from .exceptions import (ItemNotFoundError, NoPathToItem)
from . import ModuleStore, Location
def path_to_location(modulestore, location, course_name=None):
'''
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_name: [optional]. If not None, restrict search to paths
in that course.
raise ItemNotFoundError if the location doesn't exist.
raise NoPathToItem if the location exists, but isn't accessible via
a chapter/section path in the course(s) being searched.
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 sequence, position will be the position
of this location in that sequence. Otherwise, position will
be None. TODO (vshnayder): Not true yet.
'''
def flatten(xs):
'''Convert lisp-style (a, (b, (c, ()))) list 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)
# 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)
# 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)
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)
......@@ -8,6 +8,7 @@ from xmodule.modulestore import Location
from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError, NoPathToItem
from xmodule.modulestore.mongo import MongoModuleStore
from xmodule.modulestore.xml_importer import import_from_xml
from xmodule.modulestore.search import path_to_location
# from ~/mitx_all/mitx/common/lib/xmodule/xmodule/modulestore/tests/
# to ~/mitx_all/mitx/common/test
......@@ -28,7 +29,7 @@ DEFAULT_CLASS = 'xmodule.raw_module.RawDescriptor'
class TestMongoModuleStore(object):
'''Tests!'''
@classmethod
def setupClass(cls):
cls.connection = pymongo.connection.Connection(HOST, PORT)
......@@ -67,7 +68,7 @@ class TestMongoModuleStore(object):
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's loaded'''
Call this directly from failing tests to see what is loaded'''
ids = list(self.connection[DB][COLLECTION].find({}, {'_id': True}))
pprint([Location(i['_id']).url() for i in ids])
......@@ -93,8 +94,6 @@ class TestMongoModuleStore(object):
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")),
......@@ -117,13 +116,13 @@ class TestMongoModuleStore(object):
("edX/toy/2012_Fall", "Overview", "Toy_Videos", None)),
)
for location, expected in should_work:
assert_equals(self.store.path_to_location(location), expected)
assert_equals(path_to_location(self.store, location), expected)
not_found = (
"i4x://edX/toy/video/WelcomeX",
"i4x://edX/toy/video/WelcomeX", "i4x://edX/toy/course/NotHome"
)
for location in not_found:
assert_raises(ItemNotFoundError, self.store.path_to_location, location)
assert_raises(ItemNotFoundError, path_to_location, self.store, 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
......@@ -132,5 +131,5 @@ class TestMongoModuleStore(object):
"i4x://edX/simple/video/Lost_Video",
)
for location in no_path:
assert_raises(NoPathToItem, self.store.path_to_location, location, "toy")
assert_raises(NoPathToItem, path_to_location, self.store, location, "toy")
import logging
import os
import re
from fs.osfs import OSFS
from importlib import import_module
from lxml import etree
from path import path
from xmodule.errorhandlers import logging_error_handler
from xmodule.errortracker import ErrorLog, make_error_tracker
from xmodule.x_module import XModuleDescriptor, XMLParsingSystem
from xmodule.course_module import CourseDescriptor
from xmodule.mako_module import MakoDescriptorSystem
from cStringIO import StringIO
import os
import re
from . import ModuleStore, Location
from .exceptions import ItemNotFoundError
......@@ -19,7 +21,6 @@ etree.set_default_parser(
log = logging.getLogger('mitx.' + __name__)
# VS[compat]
# TODO (cpennington): Remove this once all fall 2012 courses have been imported
# into the cms from xml
......@@ -29,7 +30,7 @@ def clean_out_mako_templating(xml_string):
return xml_string
class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
def __init__(self, xmlstore, org, course, course_dir, error_handler):
def __init__(self, xmlstore, org, course, course_dir, error_tracker, **kwargs):
"""
A class that handles loading from xml. Does some munging to ensure that
all elements have unique slugs.
......@@ -70,28 +71,28 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
# log.debug('-> slug=%s' % slug)
xml_data.set('url_name', slug)
module = XModuleDescriptor.load_from_xml(
descriptor = XModuleDescriptor.load_from_xml(
etree.tostring(xml_data), self, org,
course, xmlstore.default_class)
#log.debug('==> importing module location %s' % repr(module.location))
module.metadata['data_dir'] = course_dir
#log.debug('==> importing descriptor location %s' %
# repr(descriptor.location))
descriptor.metadata['data_dir'] = course_dir
xmlstore.modules[module.location] = module
xmlstore.modules[descriptor.location] = descriptor
if xmlstore.eager:
module.get_children()
return module
descriptor.get_children()
return descriptor
render_template = lambda: ''
load_item = xmlstore.get_item
resources_fs = OSFS(xmlstore.data_dir / course_dir)
MakoDescriptorSystem.__init__(self, load_item, resources_fs,
error_handler, render_template)
error_tracker, render_template, **kwargs)
XMLParsingSystem.__init__(self, load_item, resources_fs,
error_handler, process_xml)
error_tracker, process_xml, **kwargs)
class XMLModuleStore(ModuleStore):
......@@ -99,8 +100,7 @@ class XMLModuleStore(ModuleStore):
An XML backed ModuleStore
"""
def __init__(self, data_dir, default_class=None, eager=False,
course_dirs=None,
error_handler=logging_error_handler):
course_dirs=None):
"""
Initialize an XMLModuleStore from data_dir
......@@ -114,17 +114,14 @@ class XMLModuleStore(ModuleStore):
course_dirs: If specified, the list of course_dirs to load. Otherwise,
load all course dirs
error_handler: The error handler used here and in the underlying
DescriptorSystem. By default, raise exceptions for all errors.
See the comments in x_module.py:DescriptorSystem
"""
self.eager = eager
self.data_dir = path(data_dir)
self.modules = {} # location -> XModuleDescriptor
self.courses = {} # course_dir -> XModuleDescriptor for the course
self.error_handler = error_handler
self.location_errors = {} # location -> ErrorLog
if default_class is None:
self.default_class = None
......@@ -148,15 +145,18 @@ class XMLModuleStore(ModuleStore):
for course_dir in course_dirs:
try:
course_descriptor = self.load_course(course_dir)
# make a tracker, then stick in the right place once the course loads
# and we know its location
errorlog = make_error_tracker()
course_descriptor = self.load_course(course_dir, errorlog.tracker)
self.courses[course_dir] = course_descriptor
self.location_errors[course_descriptor.location] = errorlog
except:
msg = "Failed to load course '%s'" % course_dir
log.exception(msg)
error_handler(msg)
def load_course(self, course_dir):
def load_course(self, course_dir, tracker):
"""
Load a course into this module store
course_path: Course directory name
......@@ -190,13 +190,13 @@ class XMLModuleStore(ModuleStore):
))
course = course_dir
system = ImportSystem(self, org, course, course_dir,
self.error_handler)
system = ImportSystem(self, org, course, course_dir, tracker)
course_descriptor = system.process_xml(etree.tostring(course_data))
log.debug('========> Done with course import from {0}'.format(course_dir))
return course_descriptor
def get_item(self, location, depth=0):
"""
Returns an XModuleDescriptor instance for the item at location.
......@@ -217,9 +217,28 @@ class XMLModuleStore(ModuleStore):
except KeyError:
raise ItemNotFoundError(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: This only actually works for courses in the xml datastore--
will return an empty list for all other modules.
"""
location = Location(location)
# check that item is present
self.get_item(location)
# now look up errors
if location in self.location_errors:
return self.location_errors[location].errors
return []
def get_courses(self, depth=0):
"""
Returns a list of course descriptors
Returns a list of course descriptors. If there were errors on loading,
some of these may be ErrorDescriptors instead.
"""
return self.courses.values()
......
from pkg_resources import resource_string
from lxml import etree
from xmodule.mako_module import MakoModuleDescriptor
from xmodule.editing_module import EditingDescriptor
from xmodule.xml_module import XmlDescriptor
import logging
import sys
log = logging.getLogger(__name__)
class RawDescriptor(MakoModuleDescriptor, XmlDescriptor):
class RawDescriptor(XmlDescriptor, EditingDescriptor):
"""
Module that provides a raw editing view of its data and children
Module that provides a raw editing view of its data and children. It
requires that the definition xml is valid.
"""
mako_template = "widgets/raw-edit.html"
js = {'coffee': [resource_string(__name__, 'js/src/raw/edit.coffee')]}
js_module_name = "RawDescriptor"
def get_context(self):
return {
'module': self,
'data': self.definition['data'],
}
@classmethod
def definition_from_xml(cls, xml_object, system):
return {'data': etree.tostring(xml_object)}
......@@ -30,13 +19,12 @@ class RawDescriptor(MakoModuleDescriptor, XmlDescriptor):
try:
return etree.fromstring(self.definition['data'])
except etree.XMLSyntaxError as err:
# Can't recover here, so just add some info and
# re-raise
lines = self.definition['data'].split('\n')
line, offset = err.position
msg = ("Unable to create xml for problem {loc}. "
"Context: '{context}'".format(
context=lines[line - 1][offset - 40:offset + 40],
loc=self.location))
log.exception(msg)
self.system.error_handler(msg)
# no workaround possible, so just re-raise
raise
raise Exception, msg, sys.exc_info()[2]
......@@ -7,16 +7,14 @@ from mako.template import Template
class CustomTagModule(XModule):
"""
This module supports tags of the form
<customtag option="val" option2="val2">
<impl>$tagname</impl>
</customtag>
<customtag option="val" option2="val2" impl="tagname"/>
In this case, $tagname should refer to a file in data/custom_tags, which contains
a mako template that uses ${option} and ${option2} for the content.
For instance:
data/custom_tags/book::
data/mycourse/custom_tags/book::
More information given in <a href="/book/${page}">the text</a>
course.xml::
......@@ -34,7 +32,18 @@ class CustomTagModule(XModule):
instance_state, shared_state, **kwargs)
xmltree = etree.fromstring(self.definition['data'])
template_name = xmltree.attrib['impl']
if 'impl' in xmltree.attrib:
template_name = xmltree.attrib['impl']
else:
# VS[compat] backwards compatibility with old nested customtag structure
child_impl = xmltree.find('impl')
if child_impl is not None:
template_name = child_impl.text
else:
# TODO (vshnayder): better exception type
raise Exception("Could not find impl attribute in customtag {0}"
.format(location))
params = dict(xmltree.items())
with self.system.filestore.open(
'custom_tags/{name}'.format(name=template_name)) as template:
......
from lxml import etree
from lxml.etree import XMLSyntaxError
import pkg_resources
import logging
from fs.errors import ResourceNotFoundError
from functools import partial
from xmodule.modulestore import Location
from functools import partial
log = logging.getLogger('mitx.' + __name__)
......@@ -192,9 +194,6 @@ class XModule(HTMLSnippet):
self.metadata = kwargs.get('metadata', {})
self._loaded_children = None
def get_name(self):
return self.name
def get_children(self):
'''
Return module instances for all the children of this module.
......@@ -443,16 +442,30 @@ class XModuleDescriptor(Plugin, HTMLSnippet):
system is an XMLParsingSystem
org and course are optional strings that will be used in the generated
modules url identifiers
module's url identifiers
"""
class_ = XModuleDescriptor.load_class(
etree.fromstring(xml_data).tag,
default_class
)
# leave next line, commented out - useful for low-level debugging
# log.debug('[XModuleDescriptor.load_from_xml] tag=%s, class_=%s' % (
# etree.fromstring(xml_data).tag,class_))
return class_.from_xml(xml_data, system, org, course)
try:
class_ = XModuleDescriptor.load_class(
etree.fromstring(xml_data).tag,
default_class
)
# leave next line, commented out - useful for low-level debugging
# log.debug('[XModuleDescriptor.load_from_xml] tag=%s, class_=%s' % (
# etree.fromstring(xml_data).tag,class_))
descriptor = class_.from_xml(xml_data, system, org, course)
except Exception as err:
# Didn't load properly. Fall back on loading as an error
# descriptor. This should never error due to formatting.
# Put import here to avoid circular import errors
from xmodule.error_module import ErrorDescriptor
msg = "Error loading from xml."
log.exception(msg)
system.error_tracker(msg)
descriptor = ErrorDescriptor.from_xml(xml_data, system, org, course, err)
return descriptor
@classmethod
def from_xml(cls, xml_data, system, org=None, course=None):
......@@ -521,16 +534,19 @@ class XModuleDescriptor(Plugin, HTMLSnippet):
class DescriptorSystem(object):
def __init__(self, load_item, resources_fs, error_handler):
def __init__(self, load_item, resources_fs, error_tracker, **kwargs):
"""
load_item: Takes a Location and returns an XModuleDescriptor
resources_fs: A Filesystem object that contains all of the
resources needed for the course
error_handler: A hook for handling errors in loading the descriptor.
Must be a function of (error_msg, exc_info=None).
See errorhandlers.py for some simple ones.
error_tracker: A hook for tracking errors in loading the descriptor.
Used for example to get a list of all non-fatal problems on course
load, and display them to the user.
A function of (error_msg). errortracker.py provides a
handy make_error_tracker() function.
Patterns for using the error handler:
try:
......@@ -539,10 +555,8 @@ class DescriptorSystem(object):
except SomeProblem:
msg = 'Grommet {0} is broken'.format(x)
log.exception(msg) # don't rely on handler to log
self.system.error_handler(msg)
# if we get here, work around if possible
raise # if no way to work around
OR
self.system.error_tracker(msg)
# work around
return 'Oops, couldn't load grommet'
OR, if not in an exception context:
......@@ -550,25 +564,27 @@ class DescriptorSystem(object):
if not check_something(thingy):
msg = "thingy {0} is broken".format(thingy)
log.critical(msg)
error_handler(msg)
# if we get here, work around
pass # e.g. if no workaround needed
self.system.error_tracker(msg)
NOTE: To avoid duplication, do not call the tracker on errors
that you're about to re-raise---let the caller track them.
"""
self.load_item = load_item
self.resources_fs = resources_fs
self.error_handler = error_handler
self.error_tracker = error_tracker
class XMLParsingSystem(DescriptorSystem):
def __init__(self, load_item, resources_fs, error_handler, process_xml):
def __init__(self, load_item, resources_fs, error_tracker, process_xml, **kwargs):
"""
load_item, resources_fs, error_handler: see DescriptorSystem
load_item, resources_fs, error_tracker: see DescriptorSystem
process_xml: Takes an xml string, and returns a XModuleDescriptor
created from that xml
"""
DescriptorSystem.__init__(self, load_item, resources_fs, error_handler)
DescriptorSystem.__init__(self, load_item, resources_fs, error_tracker,
**kwargs)
self.process_xml = process_xml
......@@ -584,10 +600,18 @@ class ModuleSystem(object):
Note that these functions can be closures over e.g. a django request
and user, or other environment-specific info.
'''
def __init__(self, ajax_url, track_function,
get_module, render_template, replace_urls,
user=None, filestore=None, debug=False,
xqueue_callback_url=None, xqueue_default_queuename="null"):
def __init__(self,
ajax_url,
track_function,
get_module,
render_template,
replace_urls,
user=None,
filestore=None,
debug=False,
xqueue_callback_url=None,
xqueue_default_queuename="null",
is_staff=False):
'''
Create a closure around the system environment.
......@@ -613,6 +637,9 @@ class ModuleSystem(object):
replace_urls - TEMPORARY - A function like static_replace.replace_urls
that capa_module can use to fix up the static urls in
ajax results.
is_staff - Is the user making the request a staff user?
TODO (vshnayder): this will need to change once we have real user roles.
'''
self.ajax_url = ajax_url
self.xqueue_callback_url = xqueue_callback_url
......@@ -624,6 +651,7 @@ class ModuleSystem(object):
self.DEBUG = self.debug = debug
self.seed = user.id if user is not None else 0
self.replace_urls = replace_urls
self.is_staff = is_staff
def get(self, attr):
''' provide uniform access to attributes (like etree).'''
......
from collections import MutableMapping
from xmodule.x_module import XModuleDescriptor
from xmodule.modulestore import Location
from lxml import etree
......@@ -8,74 +7,12 @@ import traceback
from collections import namedtuple
from fs.errors import ResourceNotFoundError
import os
import sys
log = logging.getLogger(__name__)
# TODO (cpennington): This was implemented in an attempt to improve performance,
# but the actual improvement wasn't measured (and it was implemented late at night).
# We should check if it hurts, and whether there's a better way of doing lazy loading
class LazyLoadingDict(MutableMapping):
"""
A dictionary object that lazily loads its contents from a provided
function on reads (of members that haven't already been set).
"""
def __init__(self, loader):
'''
On the first read from this dictionary, it will call loader() to
populate its contents. loader() must return something dict-like. Any
elements set before the first read will be preserved.
'''
self._contents = {}
self._loaded = False
self._loader = loader
self._deleted = set()
def __getitem__(self, name):
if not (self._loaded or name in self._contents or name in self._deleted):
self.load()
return self._contents[name]
def __setitem__(self, name, value):
self._contents[name] = value
self._deleted.discard(name)
def __delitem__(self, name):
del self._contents[name]
self._deleted.add(name)
def __contains__(self, name):
self.load()
return name in self._contents
def __len__(self):
self.load()
return len(self._contents)
def __iter__(self):
self.load()
return iter(self._contents)
def __repr__(self):
self.load()
return repr(self._contents)
def load(self):
if self._loaded:
return
loaded_contents = self._loader()
loaded_contents.update(self._contents)
self._contents = loaded_contents
self._loaded = True
_AttrMapBase = namedtuple('_AttrMap', 'metadata_key to_metadata from_metadata')
class AttrMap(_AttrMapBase):
"""
A class that specifies a metadata_key, and two functions:
......@@ -164,6 +101,45 @@ class XmlDescriptor(XModuleDescriptor):
return etree.parse(file_object).getroot()
@classmethod
def load_definition(cls, xml_object, system, location):
'''Load a descriptor definition from the specified xml_object.
Subclasses should not need to override this except in special
cases (e.g. html module)'''
filename = xml_object.get('filename')
if filename is None:
definition_xml = copy.deepcopy(xml_object)
else:
filepath = cls._format_filepath(xml_object.tag, filename)
# VS[compat]
# TODO (cpennington): If the file doesn't exist at the right path,
# give the class a chance to fix it up. The file will be written out
# again in the correct format. This should go away once the CMS is
# online and has imported all current (fall 2012) courses from xml
if not system.resources_fs.exists(filepath) and hasattr(
cls,
'backcompat_paths'):
candidates = cls.backcompat_paths(filepath)
for candidate in candidates:
if system.resources_fs.exists(candidate):
filepath = candidate
break
try:
with system.resources_fs.open(filepath) as file:
definition_xml = cls.file_to_xml(file)
except Exception:
msg = 'Unable to load file contents at path %s for item %s' % (
filepath, location.url())
# Add info about where we are, but keep the traceback
raise Exception, msg, sys.exc_info()[2]
cls.clean_metadata_from_xml(definition_xml)
return cls.definition_from_xml(definition_xml, system)
@classmethod
def from_xml(cls, xml_data, system, org=None, course=None):
"""
Creates an instance of this descriptor from the supplied xml_data.
......@@ -180,7 +156,7 @@ class XmlDescriptor(XModuleDescriptor):
slug = xml_object.get('url_name', xml_object.get('slug'))
location = Location('i4x', org, course, xml_object.tag, slug)
def metadata_loader():
def load_metadata():
metadata = {}
for attr in cls.metadata_attributes:
val = xml_object.get(attr)
......@@ -192,49 +168,15 @@ class XmlDescriptor(XModuleDescriptor):
metadata[attr_map.metadata_key] = attr_map.to_metadata(val)
return metadata
def definition_loader():
filename = xml_object.get('filename')
if filename is None:
definition_xml = copy.deepcopy(xml_object)
else:
filepath = cls._format_filepath(xml_object.tag, filename)
# VS[compat]
# TODO (cpennington): If the file doesn't exist at the right path,
# give the class a chance to fix it up. The file will be written out again
# in the correct format.
# This should go away once the CMS is online and has imported all current (fall 2012)
# courses from xml
if not system.resources_fs.exists(filepath) and hasattr(cls, 'backcompat_paths'):
candidates = cls.backcompat_paths(filepath)
for candidate in candidates:
if system.resources_fs.exists(candidate):
filepath = candidate
break
try:
with system.resources_fs.open(filepath) as file:
definition_xml = cls.file_to_xml(file)
except (ResourceNotFoundError, etree.XMLSyntaxError):
msg = 'Unable to load file contents at path %s for item %s' % (filepath, location.url())
log.exception(msg)
system.error_handler(msg)
# if error_handler didn't reraise, work around problem.
error_elem = etree.Element('error')
message_elem = etree.SubElement(error_elem, 'error_message')
message_elem.text = msg
stack_elem = etree.SubElement(error_elem, 'stack_trace')
stack_elem.text = traceback.format_exc()
return {'data': etree.tostring(error_elem)}
cls.clean_metadata_from_xml(definition_xml)
return cls.definition_from_xml(definition_xml, system)
definition = cls.load_definition(xml_object, system, location)
metadata = load_metadata()
# VS[compat] -- just have the url_name lookup once translation is done
slug = xml_object.get('url_name', xml_object.get('slug'))
return cls(
system,
LazyLoadingDict(definition_loader),
definition,
location=location,
metadata=LazyLoadingDict(metadata_loader),
metadata=metadata,
)
@classmethod
......@@ -282,7 +224,7 @@ class XmlDescriptor(XModuleDescriptor):
# Write it to a file if necessary
if self.split_to_file(xml_object):
# Put this object in it's own file
# Put this object in its own file
filepath = self.__class__._format_filepath(self.category, self.name)
resource_fs.makedir(os.path.dirname(filepath), allow_recreate=True)
with resource_fs.open(filepath, 'w') as file:
......
......@@ -33,6 +33,7 @@ def check_course(course_id, course_must_be_open=True, course_required=True):
try:
course_loc = CourseDescriptor.id_to_location(course_id)
course = modulestore().get_item(course_loc)
except (KeyError, ItemNotFoundError):
raise Http404("Course not found.")
......
......@@ -10,37 +10,17 @@ from lxml import etree
from django.core.management.base import BaseCommand
from xmodule.modulestore.xml import XMLModuleStore
from xmodule.errortracker import make_error_tracker
def traverse_tree(course):
'''Load every descriptor in course. Return bool success value.'''
queue = [course]
while len(queue) > 0:
node = queue.pop()
# print '{0}:'.format(node.location)
# if 'data' in node.definition:
# print '{0}'.format(node.definition['data'])
queue.extend(node.get_children())
return True
def make_logging_error_handler():
'''Return a tuple (handler, error_list), where
the handler appends the message and any exc_info
to the error_list on every call.
'''
errors = []
def error_handler(msg, exc_info=None):
'''Log errors'''
if exc_info is None:
if sys.exc_info() != (None, None, None):
exc_info = sys.exc_info()
errors.append((msg, exc_info))
return (error_handler, errors)
def export(course, export_dir):
"""Export the specified course to course_dir. Creates dir if it doesn't exist.
......@@ -73,14 +53,14 @@ def import_with_checks(course_dir, verbose=True):
data_dir = course_dir.dirname()
course_dirs = [course_dir.basename()]
(error_handler, errors) = make_logging_error_handler()
(error_tracker, errors) = make_error_tracker()
# No default class--want to complain if it doesn't find plugins for any
# module.
modulestore = XMLModuleStore(data_dir,
default_class=None,
eager=True,
course_dirs=course_dirs,
error_handler=error_handler)
error_tracker=error_tracker)
def str_of_err(tpl):
(msg, exc_info) = tpl
......@@ -143,6 +123,7 @@ def check_roundtrip(course_dir):
# dircmp doesn't do recursive diffs.
# diff = dircmp(course_dir, export_dir, ignore=[], hide=[])
print "======== Roundtrip diff: ========="
sys.stdout.flush() # needed to make diff appear in the right place
os.system("diff -r {0} {1}".format(course_dir, export_dir))
print "======== ideally there is no diff above this ======="
......
......@@ -172,6 +172,7 @@ def get_module(user, request, location, student_module_cache, position=None):
# a module is coming through get_html and is therefore covered
# by the replace_static_urls code below
replace_urls=replace_urls,
is_staff=user.is_staff,
)
# pass position specified in URL to module through ModuleSystem
system.set('position', position)
......
import copy
import json
from path import path
import os
from pprint import pprint
from nose import SkipTest
from django.test import TestCase
from django.test.client import Client
from mock import patch, Mock
from override_settings import override_settings
from django.conf import settings
from django.core.urlresolvers import reverse
from path import path
from mock import patch, Mock
from override_settings import override_settings
from student.models import Registration
from django.contrib.auth.models import User
from student.models import Registration
from xmodule.modulestore.django import modulestore
import xmodule.modulestore.django
......@@ -189,11 +190,12 @@ class RealCoursesLoadTestCase(PageLoader):
xmodule.modulestore.django._MODULESTORES = {}
xmodule.modulestore.django.modulestore().collection.drop()
# TODO: Disabled test for now.. Fix once things are cleaned up.
def Xtest_real_courses_loads(self):
def test_real_courses_loads(self):
'''See if any real courses are available at the REAL_DATA_DIR.
If they are, check them.'''
# TODO: Disabled test for now.. Fix once things are cleaned up.
raise SkipTest
# TODO: adjust staticfiles_dirs
if not os.path.isdir(REAL_DATA_DIR):
# No data present. Just pass.
......
......@@ -29,6 +29,7 @@ from multicourse import multicourse_settings
from django_comment_client.utils import get_discussion_title
from xmodule.modulestore import Location
from xmodule.modulestore.search import path_to_location
from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError, NoPathToItem
from xmodule.modulestore.django import modulestore
from xmodule.course_module import CourseDescriptor
......@@ -68,14 +69,20 @@ def user_groups(user):
def format_url_params(params):
return [urllib.quote(string.replace(' ', '_')) for string in params]
return [urllib.quote(string.replace(' ', '_'))
if string is not None else None
for string in params]
@ensure_csrf_cookie
@cache_if_anonymous
def courses(request):
# TODO: Clean up how 'error' is done.
courses = sorted(modulestore().get_courses(), key=lambda course: course.number)
# filter out any courses that errored.
courses = [c for c in modulestore().get_courses()
if isinstance(c, CourseDescriptor)]
courses = sorted(courses, key=lambda course: course.number)
universities = defaultdict(list)
for course in courses:
universities[course.org].append(course)
......@@ -197,34 +204,57 @@ def index(request, course_id, chapter=None, section=None,
chapter = clean(chapter)
section = clean(section)
context = {
'csrf': csrf(request)['csrf_token'],
'accordion': render_accordion(request, course, chapter, section),
'COURSE_TITLE': course.title,
'course': course,
'init': '',
'content': ''
}
look_for_module = chapter is not None and section is not None
if look_for_module:
# TODO (cpennington): Pass the right course in here
section_descriptor = get_section(course, chapter, section)
if section_descriptor is not None:
student_module_cache = StudentModuleCache(request.user,
section_descriptor)
module, _, _, _ = get_module(request.user, request,
section_descriptor.location,
student_module_cache)
context['content'] = module.get_html()
try:
context = {
'csrf': csrf(request)['csrf_token'],
'accordion': render_accordion(request, course, chapter, section),
'COURSE_TITLE': course.title,
'course': course,
'init': '',
'content': ''
}
look_for_module = chapter is not None and section is not None
if look_for_module:
# TODO (cpennington): Pass the right course in here
section_descriptor = get_section(course, chapter, section)
if section_descriptor is not None:
student_module_cache = StudentModuleCache(request.user,
section_descriptor)
module, _, _, _ = get_module(request.user, request,
section_descriptor.location,
student_module_cache)
context['content'] = module.get_html()
else:
log.warning("Couldn't find a section descriptor for course_id '{0}',"
"chapter '{1}', section '{2}'".format(
course_id, chapter, section))
else:
log.warning("Couldn't find a section descriptor for course_id '{0}',"
"chapter '{1}', section '{2}'".format(
course_id, chapter, section))
if request.user.is_staff:
# Add a list of all the errors...
context['course_errors'] = modulestore().get_item_errors(course.location)
result = render_to_response('courseware.html', context)
except:
# In production, don't want to let a 500 out for any reason
if settings.DEBUG:
raise
else:
log.exception("Error in index view: user={user}, course={course},"
" chapter={chapter} section={section}"
"position={position}".format(
user=request.user,
course=course,
chapter=chapter,
section=section,
position=position
))
try:
result = render_to_response('courseware-error.html', {})
except:
result = HttpResponse("There was an unrecoverable error")
result = render_to_response('courseware.html', context)
return result
@ensure_csrf_cookie
......@@ -247,7 +277,7 @@ def jump_to(request, location):
# Complain if there's not data for this location
try:
(course_id, chapter, section, position) = modulestore().path_to_location(location)
(course_id, chapter, section, position) = path_to_location(modulestore(), location)
except ItemNotFoundError:
raise Http404("No data at this location: {0}".format(location))
except NoPathToItem:
......
......@@ -3,18 +3,28 @@ from django.views.decorators.http import require_POST
from django.http import HttpResponse
from django.utils import simplejson
from django.core.context_processors import csrf
from django.core.urlresolvers import reverse
from mitxmako.shortcuts import render_to_response, render_to_string
from courseware.courses import check_course
import comment_client
import dateutil
from dateutil.tz import tzlocal
from datehelper import time_ago_in_words
from django_comment_client.utils import get_categorized_discussion_info
from urllib import urlencode
import json
import comment_client
import dateutil
THREADS_PER_PAGE = 20
PAGES_NEARBY_DELTA = 2
class HtmlResponse(HttpResponse):
def __init__(self, html=''):
super(HtmlResponse, self).__init__(html, content_type='text/plain')
def render_accordion(request, course, discussion_id):
......@@ -29,29 +39,58 @@ def render_accordion(request, course, discussion_id):
return render_to_string('discussion/_accordion.html', context)
def render_discussion(request, course_id, threads, discussion_id=None, with_search_bar=True, search_text='', template='discussion/_inline.html'):
def render_discussion(request, course_id, threads, discussion_id=None, with_search_bar=True,
search_text='', discussion_type='inline', page=1, num_pages=None,
per_page=THREADS_PER_PAGE):
template = {
'inline': 'discussion/_inline.html',
'forum': 'discussion/_forum.html',
}[discussion_type]
def _url_for_inline_page(page, per_page):
raw_url = reverse('django_comment_client.forum.views.inline_discussion', args=[course_id, discussion_id])
return raw_url + '?' + urlencode({'page': page, 'per_page': per_page})
def _url_for_forum_page(page, per_page):
raw_url = reverse('django_comment_client.forum.views.forum_form_discussion', args=[course_id, discussion_id])
return raw_url + '?' + urlencode({'page': page, 'per_page': per_page})
url_for_page = {
'inline': _url_for_inline_page,
'forum': _url_for_forum_page,
}[discussion_type]
context = {
'threads': threads,
'discussion_id': discussion_id,
'search_bar': '' if not with_search_bar \
else render_search_bar(request, course_id, discussion_id, text=search_text),
'user_info': comment_client.get_user_info(request.user.id, raw=True),
'tags': comment_client.get_threads_tags(raw=True),
'course_id': course_id,
'request': request,
'page': page,
'per_page': per_page,
'num_pages': num_pages,
'pages_nearby_delta': PAGES_NEARBY_DELTA,
'discussion_type': discussion_type,
'url_for_page': url_for_page,
}
return render_to_string(template, context)
def render_inline_discussion(*args, **kwargs):
return render_discussion(template='discussion/_inline.html', *args, **kwargs)
return render_discussion(discussion_type='inline', *args, **kwargs)
def render_forum_discussion(*args, **kwargs):
return render_discussion(template='discussion/_forum.html', *args, **kwargs)
return render_discussion(discussion_type='forum', *args, **kwargs)
# discussion per page is fixed for now
def inline_discussion(request, course_id, discussion_id):
threads = comment_client.get_threads(discussion_id, recursive=False)
html = render_inline_discussion(request, course_id, threads, discussion_id=discussion_id)
return HttpResponse(html, content_type="text/plain")
page = request.GET.get('page', 1)
threads, page, per_page, num_pages = comment_client.get_threads(discussion_id, recursive=False, page=page, per_page=THREADS_PER_PAGE)
html = render_inline_discussion(request, course_id, threads, discussion_id=discussion_id, num_pages=num_pages, page=page, per_page=per_page)
return HtmlResponse(html)
def render_search_bar(request, course_id, discussion_id=None, text=''):
if not discussion_id:
......@@ -66,18 +105,29 @@ def render_search_bar(request, course_id, discussion_id=None, text=''):
def forum_form_discussion(request, course_id, discussion_id):
course = check_course(course_id)
search_text = request.GET.get('text', '')
page = request.GET.get('page', 1)
if len(search_text) > 0:
threads = comment_client.search_threads({'text': search_text, 'commentable_id': discussion_id})
threads, page, per_page, num_pages = comment_client.search_threads({
'text': search_text,
'commentable_id': discussion_id,
'course_id': course_id,
'page': page,
'per_page': THREADS_PER_PAGE,
})
else:
threads = comment_client.get_threads(discussion_id, recursive=False)
threads, page, per_page, num_pages = comment_client.get_threads(discussion_id, recursive=False, page=page, per_page=THREADS_PER_PAGE)
context = {
'csrf': csrf(request)['csrf_token'],
'course': course,
'content': render_forum_discussion(request, course_id, threads, discussion_id=discussion_id, search_text=search_text),
'content': render_forum_discussion(request, course_id, threads,
discussion_id=discussion_id,
search_text=search_text,
num_pages=num_pages,
per_page=per_page,
page=page),
'accordion': render_accordion(request, course, discussion_id),
}
......@@ -102,7 +152,6 @@ def render_single_thread(request, course_id, thread_id):
'thread': thread,
'user_info': comment_client.get_user_info(request.user.id, raw=True),
'annotated_content_info': json.dumps(get_annotated_content_info(thread=thread, user_id=request.user.id)),
'tags': comment_client.get_threads_tags(raw=True),
'course_id': course_id,
'request': request,
}
......
......@@ -17,7 +17,7 @@ MITX_FEATURES['DISABLE_START_DATES'] = True
WIKI_ENABLED = True
LOGGING = get_logger_config(ENV_ROOT / "log",
LOGGING = get_logger_config(ENV_ROOT / "log",
logging_env="dev",
tracking_filename="tracking.log",
debug=True)
......@@ -30,7 +30,7 @@ DATABASES = {
}
CACHES = {
# This is the cache used for most things. Askbot will not work without a
# This is the cache used for most things. Askbot will not work without a
# functioning cache -- it relies on caching to load its settings in places.
# In staging/prod envs, the sessions also live here.
'default': {
......@@ -52,11 +52,14 @@ CACHES = {
}
}
# Make the keyedcache startup warnings go away
CACHE_TIMEOUT = 0
# Dummy secret key for dev
SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
################################ DEBUG TOOLBAR #################################
INSTALLED_APPS += ('debug_toolbar',)
INSTALLED_APPS += ('debug_toolbar',)
MIDDLEWARE_CLASSES += ('debug_toolbar.middleware.DebugToolbarMiddleware',)
INTERNAL_IPS = ('127.0.0.1',)
......@@ -71,8 +74,8 @@ DEBUG_TOOLBAR_PANELS = (
'debug_toolbar.panels.logger.LoggingPanel',
# Enabling the profiler has a weird bug as of django-debug-toolbar==0.9.4 and
# Django=1.3.1/1.4 where requests to views get duplicated (your method gets
# hit twice). So you can uncomment when you need to diagnose performance
# Django=1.3.1/1.4 where requests to views get duplicated (your method gets
# hit twice). So you can uncomment when you need to diagnose performance
# problems, but you shouldn't leave it on.
# 'debug_toolbar.panels.profiling.ProfilingDebugPanel',
)
......
......@@ -15,8 +15,9 @@ class CommentClientUnknownError(CommentClientError):
def delete_threads(commentable_id, *args, **kwargs):
return _perform_request('delete', _url_for_commentable_threads(commentable_id), *args, **kwargs)
def get_threads(commentable_id, recursive=False, *args, **kwargs):
return _perform_request('get', _url_for_threads(commentable_id), {'recursive': recursive}, *args, **kwargs)
def get_threads(commentable_id, recursive=False, page=1, per_page=20, *args, **kwargs):
response = _perform_request('get', _url_for_threads(commentable_id), {'recursive': recursive, 'page': page, 'per_page': per_page}, *args, **kwargs)
return response['collection'], response['page'], response['per_page'], response['num_pages']
def get_threads_tags(*args, **kwargs):
return _perform_request('get', _url_for_threads_tags(), {}, *args, **kwargs)
......@@ -98,6 +99,8 @@ def unsubscribe_commentable(user_id, commentable_id, *args, **kwargs):
return unsubscribe(user_id, {'source_type': 'other', 'source_id': commentable_id})
def search_threads(attributes, *args, **kwargs):
default_attributes = {'page': 1, 'per_page': 20}
attributes = dict(default_attributes.items() + attributes.items())
return _perform_request('get', _url_for_search_threads(), attributes, *args, **kwargs)
def _perform_request(method, url, data_or_params=None, *args, **kwargs):
......
......@@ -303,7 +303,12 @@ $discussion_input_width: 90%;
}
}
}
.discussion-paginator {
margin-top: 40px;
div {
display: inline-block;
}
}
}
......
......@@ -52,6 +52,19 @@
<section class="course-content">
${content}
% if course_errors is not UNDEFINED:
<h2>Course errors</h2>
<div id="course-errors">
<ul>
% for (msg, err) in course_errors:
<li>${msg}
<ul><li><pre>${err}</pre></li></ul>
</li>
% endfor
</ul>
</div>
% endif
</section>
</div>
</section>
......@@ -13,9 +13,11 @@
<div class="discussion-new-post control-button" href="javascript:void(0)">New Post</div>
</div>
<%include file="_paginator.html" />
% for thread in threads:
${renderer.render_thread(course_id, thread, edit_thread=False, show_comments=False)}
% endfor
<%include file="_paginator.html" />
</section>
<%!
......@@ -26,5 +28,4 @@
<script type="text/javascript">
var $$user_info = JSON.parse('${user_info | escape_quotes}');
var $$course_id = "${course_id}";
var $$tags = JSON.parse("${tags | escape_quotes}");
</script>
......@@ -7,9 +7,11 @@
</div>
<div class="discussion-new-post control-button" href="javascript:void(0)">New Post</div>
</div>
<%include file="_paginator.html" />
% for thread in threads:
${renderer.render_thread(course_id, thread, edit_thread=False, show_comments=False)}
% endfor
<%include file="_paginator.html" />
</section>
<%!
......@@ -20,5 +22,4 @@
<script type="text/javascript">
var $$user_info = JSON.parse('${user_info | escape_quotes}');
var $$course_id = "${course_id}";
var $$tags = JSON.parse("${tags | escape_quotes}");
</script>
<%def name="link_to_page(_page)">
% if _page != page:
<div class="page-link">
<a href="${url_for_page(_page, per_page)}">${_page}</a>
</div>
% else:
<div class="page-link">${_page}</div>
% endif
</%def>
<%def name="list_pages(*args)">
% for arg in args:
% if arg == 'dots':
<div class="page-dots">...</div>
% elif isinstance(arg, list):
% for _page in arg:
${link_to_page(_page)}
% endfor
% else:
${link_to_page(arg)}
% endif
% endfor
</%def>
<div class="discussion-${discussion_type}-paginator discussion-paginator">
<div class="prev-page">
% if page > 1:
<a href="${url_for_page(page - 1, per_page)}">&lt; Previous page</a>
% else:
&lt; Previous page
% endif
</div>
% if num_pages <= 2 * pages_nearby_delta + 2:
${list_pages(range(1, num_pages + 1))}
% else:
% if page <= 2 * pages_nearby_delta:
${list_pages(range(1, 2 * pages_nearby_delta + 2), 'dots', num_pages)}
% elif num_pages - page + 1 <= 2 * pages_nearby_delta:
${list_pages(1, 'dots', range(num_pages - 2 * pages_nearby_delta, num_pages + 1))}
% else:
${list_pages(1, 'dots', range(page - pages_nearby_delta, page + pages_nearby_delta + 1), 'dots', num_pages)}
% endif
% endif
<div class="next-page">
% if page < num_pages:
<a href="${url_for_page(page + 1, per_page)}">Next page &gt;</a>
% else:
Next page &gt;
% endif
</div>
</div>
<section class="outside-app">
<h1>There has been an error on the <em>MITx</em> servers</h1>
<p>We're sorry, this module is temporarily unavailable. Our staff is working to fix it as soon as possible. Please email us at <a href="mailto:technical@mitx.mit.edu">technical@mitx.mit.edu</a> to report any problems or downtime.</p>
<h1>Staff-only details below:</h1>
<p>Error: ${error}</p>
<p>Raw data: ${data}</p>
</section>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment