diff --git a/cms/djangoapps/contentstore/management/commands/import.py b/cms/djangoapps/contentstore/management/commands/import.py index 9f0cd7f21c..e24111dbb7 100644 --- a/cms/djangoapps/contentstore/management/commands/import.py +++ b/cms/djangoapps/contentstore/management/commands/import.py @@ -4,13 +4,8 @@ from django.core.management.base import BaseCommand, CommandError from keystore.django import keystore -from raw_module import RawDescriptor from lxml import etree -from fs.osfs import OSFS -from mako.lookup import TemplateLookup - -from path import path -from x_module import XModuleDescriptor, XMLParsingSystem +from keystore.xml import XMLModuleStore unnamed_modules = 0 @@ -27,33 +22,11 @@ class Command(BaseCommand): raise CommandError("import requires 3 arguments: ") org, course, data_dir = args - data_dir = path(data_dir) - class ImportSystem(XMLParsingSystem): - def __init__(self): - self.load_item = keystore().get_item - self.fs = OSFS(data_dir) - - def process_xml(self, xml): - try: - xml_data = etree.fromstring(xml) - except: - raise CommandError("Unable to parse xml: " + xml) - - if not xml_data.get('name'): - global unnamed_modules - unnamed_modules += 1 - xml_data.set('name', '{tag}_{count}'.format(tag=xml_data.tag, count=unnamed_modules)) - - module = XModuleDescriptor.load_from_xml(etree.tostring(xml_data), self, org, course, RawDescriptor) - keystore().create_item(module.url) - if 'data' in module.definition: - keystore().update_item(module.url, module.definition['data']) - if 'children' in module.definition: - keystore().update_children(module.url, module.definition['children']) - return module - - lookup = TemplateLookup(directories=[data_dir]) - template = lookup.get_template("course.xml") - course_string = template.render(groups=[]) - ImportSystem().process_xml(course_string) + module_store = XMLModuleStore(org, course, data_dir, 'xmodule.raw_module.RawDescriptor') + for module in module_store.modules.itervalues(): + keystore().create_item(module.location) + if 'data' in module.definition: + keystore().update_item(module.location, module.definition['data']) + if 'children' in module.definition: + keystore().update_children(module.location, module.definition['children']) diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 9cc7eec9b2..f7d5efe22a 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -10,7 +10,7 @@ def index(request): # TODO (cpennington): These need to be read in from the active user org = 'mit.edu' course = '6002xs12' - name = '6.002 Spring 2012' + name = '6.002_Spring_2012' course = keystore().get_item(['i4x', org, course, 'course', name]) weeks = course.get_children() return render_to_response('index.html', {'weeks': weeks}) @@ -22,7 +22,7 @@ def edit_item(request): return render_to_response('unit.html', { 'contents': item.get_html(), 'js_module': item.js_module_name(), - 'type': item.type, + 'category': item.category, 'name': item.name, }) diff --git a/cms/envs/dev.py b/cms/envs/dev.py index 16bed60729..ce775d962a 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -8,9 +8,13 @@ TEMPLATE_DEBUG = DEBUG KEYSTORE = { 'default': { - 'host': 'localhost', - 'db': 'mongo_base', - 'collection': 'key_store', + 'ENGINE': 'keystore.mongo.MongoModuleStore', + 'OPTIONS': { + 'default_class': 'xmodule.raw_module.RawDescriptor', + 'host': 'localhost', + 'db': 'mongo_base', + 'collection': 'key_store', + } } } diff --git a/cms/templates/unit.html b/cms/templates/unit.html index 59044ab28d..34e21ca049 100644 --- a/cms/templates/unit.html +++ b/cms/templates/unit.html @@ -2,7 +2,7 @@

${name}

-

${type}

+

${category}

diff --git a/cms/templates/widgets/html-edit.html b/cms/templates/widgets/html-edit.html index 7eec86215a..666aa1de81 100644 --- a/cms/templates/widgets/html-edit.html +++ b/cms/templates/widgets/html-edit.html @@ -33,8 +33,8 @@ - -
${module.definition['data']['text']}
+ +
${module.definition['data']}
Save & Update diff --git a/cms/templates/widgets/navigation.html b/cms/templates/widgets/navigation.html index 38b1cd9d94..ea158d305a 100644 --- a/cms/templates/widgets/navigation.html +++ b/cms/templates/widgets/navigation.html @@ -38,10 +38,10 @@ % for week in weeks:
  • -

    ${week.name}

    +

    ${week.name}

      - % if week.goals: - % for goal in week.goals: + % if 'goals' in week.metadata: + % for goal in week.metadata['goals']:
    • ${goal}
    • % endfor % else: @@ -52,8 +52,8 @@
        % for module in week.get_children(): -
      • - ${module.name} +
      • + ${module.name} handle
      • % endfor diff --git a/cms/templates/widgets/sequence-edit.html b/cms/templates/widgets/sequence-edit.html index abeec9209d..319e137638 100644 --- a/cms/templates/widgets/sequence-edit.html +++ b/cms/templates/widgets/sequence-edit.html @@ -37,7 +37,7 @@
          % for child in module.get_children():
        1. - ${child.name} + ${child.name} handle
        2. %endfor diff --git a/common/lib/keystore/__init__.py b/common/lib/keystore/__init__.py index c0fb40d33e..0671e7e568 100644 --- a/common/lib/keystore/__init__.py +++ b/common/lib/keystore/__init__.py @@ -4,6 +4,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 URL_RE = re.compile(""" @@ -15,8 +16,10 @@ URL_RE = re.compile(""" (/(?P[^/]+))? """, re.VERBOSE) +INVALID_CHARS = re.compile(r"[^\w.-]") -class Location(object): +_LocationBase = namedtuple('LocationBase', 'tag org course category name revision') +class Location(_LocationBase): ''' Encodes a location. @@ -26,7 +29,16 @@ class Location(object): However, they can also be represented a dictionaries (specifying each component), tuples or list (specified in order), or as strings of the url ''' - def __init__(self, location): + __slots__ = () + + @classmethod + def clean(cls, value): + """ + Return value, made into a form legal for locations + """ + return re.sub('_+', '_', INVALID_CHARS.sub('_', value)) + + def __new__(_cls, loc_or_tag, org=None, course=None, category=None, name=None, revision=None): """ Create a new location that is a clone of the specifed one. @@ -45,55 +57,55 @@ class Location(object): In both the dict and list forms, the revision is optional, and can be ommitted. - None of the components of a location may contain the '/' character + Components must be composed of alphanumeric characters, or the characters '_', '-', and '.' Components may be set to None, which may be interpreted by some contexts to mean wildcard selection """ - self.update(location) - def update(self, location): - """ - Update this instance with data from another Location object. + if org is None and course is None and category is None and name is None and revision is None: + location = loc_or_tag + else: + location = (loc_or_tag, org, course, category, name, revision) - location: can take the same forms as specified by `__init__` - """ - self.tag = self.org = self.course = self.category = self.name = self.revision = None + def check_dict(dict_): + check_list(dict_.values()) + + def check_list(list_): + for val in list_: + if val is not None and INVALID_CHARS.search(val) is not None: + raise InvalidLocationError(location) if isinstance(location, basestring): match = URL_RE.match(location) if match is None: raise InvalidLocationError(location) else: - self.update(match.groupdict()) - elif isinstance(location, list): + groups = match.groupdict() + check_dict(groups) + return _LocationBase.__new__(_cls, **groups) + elif isinstance(location, (list, tuple)): if len(location) not in (5, 6): raise InvalidLocationError(location) - (self.tag, self.org, self.course, self.category, self.name) = location[0:5] - self.revision = location[5] if len(location) == 6 else None + if len(location) == 5: + args = tuple(location) + (None, ) + else: + args = tuple(location) + + check_list(args) + return _LocationBase.__new__(_cls, *args) elif isinstance(location, dict): - try: - self.tag = location['tag'] - self.org = location['org'] - self.course = location['course'] - self.category = location['category'] - self.name = location['name'] - except KeyError: - raise InvalidLocationError(location) - self.revision = location.get('revision') + kwargs = dict(location) + kwargs.setdefault('revision', None) + + check_dict(kwargs) + return _LocationBase.__new__(_cls, **kwargs) elif isinstance(location, Location): - self.update(location.list()) + return _LocationBase.__new__(_cls, location) else: raise InvalidLocationError(location) - for val in self.list(): - if val is not None and '/' in val: - raise InvalidLocationError(location) - - def __str__(self): - return self.url() - def url(self): """ Return a string containing the URL for this location @@ -103,22 +115,23 @@ class Location(object): url += "/" + self.revision return url - def list(self): + def html_id(self): """ - Return a list representing this location + Return a string with a version of the location that is safe for use in html id attributes """ - return [self.tag, self.org, self.course, self.category, self.name, self.revision] + return "-".join(str(v) for v in self if v is not None) def dict(self): - """ - Return a dictionary representing this location - """ - return {'tag': self.tag, - 'org': self.org, - 'course': self.course, - 'category': self.category, - 'name': self.name, - 'revision': self.revision} + return self.__dict__ + + def list(self): + return list(self) + + def __str__(self): + return self.url() + + def __repr__(self): + return "Location%s" % repr(tuple(self)) class ModuleStore(object): diff --git a/common/lib/keystore/django.py b/common/lib/keystore/django.py index 98479a7f7c..89aa9d07b0 100644 --- a/common/lib/keystore/django.py +++ b/common/lib/keystore/django.py @@ -6,9 +6,9 @@ Passes settings.KEYSTORE as kwargs to MongoModuleStore from __future__ import absolute_import +from importlib import import_module + from django.conf import settings -from .mongo import MongoModuleStore -from raw_module import RawDescriptor _KEYSTORES = {} @@ -17,9 +17,10 @@ def keystore(name='default'): global _KEYSTORES if name not in _KEYSTORES: - # TODO (cpennington): Load the default class from a string - _KEYSTORES[name] = MongoModuleStore( - default_class=RawDescriptor, - **settings.KEYSTORE[name]) + class_path = settings.KEYSTORE[name]['ENGINE'] + module_path, _, class_name = class_path.rpartition('.') + class_ = getattr(import_module(module_path), class_name) + _KEYSTORES[name] = class_( + **settings.KEYSTORE[name]['OPTIONS']) return _KEYSTORES[name] diff --git a/common/lib/keystore/mongo.py b/common/lib/keystore/mongo.py index ece8b35b71..20c4ffde1a 100644 --- a/common/lib/keystore/mongo.py +++ b/common/lib/keystore/mongo.py @@ -1,7 +1,9 @@ import pymongo +from importlib import import_module +from xmodule.x_module import XModuleDescriptor, DescriptorSystem + from . import ModuleStore, Location from .exceptions import ItemNotFoundError, InsufficientSpecificationError -from xmodule.x_module import XModuleDescriptor, DescriptorSystem class MongoModuleStore(ModuleStore): @@ -16,7 +18,10 @@ class MongoModuleStore(ModuleStore): # Force mongo to report errors, at the expense of performance self.collection.safe = True - self.default_class = default_class + + module_path, _, class_name = default_class.rpartition('.') + class_ = getattr(import_module(module_path), class_name) + self.default_class = class_ def get_item(self, location): """ @@ -29,8 +34,6 @@ class MongoModuleStore(ModuleStore): If no object is found at that location, raises keystore.exceptions.ItemNotFoundError location: Something that can be passed to Location - default_class: An XModuleDescriptor subclass to use if no plugin matching the - location is found """ query = {} @@ -48,8 +51,9 @@ class MongoModuleStore(ModuleStore): if item is None: raise ItemNotFoundError(location) + # TODO (cpennington): Pass a proper resources_fs to the system return XModuleDescriptor.load_from_json( - item, DescriptorSystem(self.get_item), self.default_class) + item, DescriptorSystem(self.get_item, None), self.default_class) def create_item(self, location): """ diff --git a/common/lib/keystore/tests/test_location.py b/common/lib/keystore/tests/test_location.py index f10f03c0b0..01d36d946b 100644 --- a/common/lib/keystore/tests/test_location.py +++ b/common/lib/keystore/tests/test_location.py @@ -1,4 +1,4 @@ -from nose.tools import assert_equals, assert_raises +from nose.tools import assert_equals, assert_raises, assert_not_equals from keystore import Location from keystore.exceptions import InvalidLocationError @@ -11,7 +11,6 @@ def check_string_roundtrip(url): def test_string_roundtrip(): check_string_roundtrip("tag://org/course/category/name") check_string_roundtrip("tag://org/course/category/name/revision") - check_string_roundtrip("tag://org/course/category/name with spaces/revision") def test_dict(): @@ -50,3 +49,15 @@ def test_invalid_locations(): assert_raises(InvalidLocationError, Location, ["foo", "bar"]) assert_raises(InvalidLocationError, Location, ["foo", "bar", "baz", "blat", "foo/bar"]) assert_raises(InvalidLocationError, Location, None) + assert_raises(InvalidLocationError, Location, "tag://org/course/category/name with spaces/revision") + +def test_equality(): + assert_equals( + Location('tag', 'org', 'course', 'category', 'name'), + Location('tag', 'org', 'course', 'category', 'name') + ) + + assert_not_equals( + Location('tag', 'org', 'course', 'category', 'name1'), + Location('tag', 'org', 'course', 'category', 'name') + ) diff --git a/common/lib/keystore/xml.py b/common/lib/keystore/xml.py new file mode 100644 index 0000000000..e7adb56ad6 --- /dev/null +++ b/common/lib/keystore/xml.py @@ -0,0 +1,96 @@ +import logging +from fs.osfs import OSFS +from importlib import import_module +from lxml import etree +from path import path +from xmodule.x_module import XModuleDescriptor, XMLParsingSystem + +from . import ModuleStore, Location +from .exceptions import ItemNotFoundError + +etree.set_default_parser(etree.XMLParser(dtd_validation=False, load_dtd=False, + remove_comments=True)) + +log = logging.getLogger(__name__) + + +class XMLModuleStore(ModuleStore): + """ + An XML backed ModuleStore + """ + def __init__(self, org, course, data_dir, default_class=None): + self.data_dir = path(data_dir) + self.modules = {} + + module_path, _, class_name = default_class.rpartition('.') + class_ = getattr(import_module(module_path), class_name) + self.default_class = class_ + + with open(self.data_dir / "course.xml") as course_file: + class ImportSystem(XMLParsingSystem): + def __init__(self, modulestore): + """ + modulestore: the XMLModuleStore to store the loaded modules in + """ + self.unnamed_modules = 0 + + def process_xml(xml): + try: + xml_data = etree.fromstring(xml) + except: + log.exception("Unable to parse xml: {xml}".format(xml=xml)) + raise + if xml_data.get('name'): + xml_data.set('slug', Location.clean(xml_data.get('name'))) + else: + self.unnamed_modules += 1 + xml_data.set('slug', '{tag}_{count}'.format(tag=xml_data.tag, count=self.unnamed_modules)) + + module = XModuleDescriptor.load_from_xml(etree.tostring(xml_data), self, org, course, modulestore.default_class) + modulestore.modules[module.location] = module + return module + + XMLParsingSystem.__init__(self, modulestore.get_item, OSFS(data_dir), process_xml) + + ImportSystem(self).process_xml(course_file.read()) + + def get_item(self, location): + """ + Returns an XModuleDescriptor instance for the item at location. + If location.revision is None, returns the most item with the most + recent revision + + If any segment of the location is None except revision, raises + keystore.exceptions.InsufficientSpecificationError + If no object is found at that location, raises keystore.exceptions.ItemNotFoundError + + location: Something that can be passed to Location + """ + location = Location(location) + try: + return self.modules[location] + except KeyError: + raise ItemNotFoundError(location) + + def create_item(self, location): + raise NotImplementedError("XMLModuleStores are read-only") + + def update_item(self, location, data): + """ + Set the data in the item specified by the location to + data + + location: Something that can be passed to Location + data: A nested dictionary of problem data + """ + raise NotImplementedError("XMLModuleStores are read-only") + + def update_children(self, location, children): + """ + Set the children for the item specified by the location to + data + + location: Something that can be passed to Location + children: A list of child item identifiers + """ + raise NotImplementedError("XMLModuleStores are read-only") diff --git a/common/lib/xmodule/__init__.py b/common/lib/xmodule/__init__.py index 307b544b79..e69de29bb2 100644 --- a/common/lib/xmodule/__init__.py +++ b/common/lib/xmodule/__init__.py @@ -1,62 +0,0 @@ -import capa_module -import html_module -import schematic_module -import seq_module -import template_module -import vertical_module -import video_module - -# Import all files in modules directory, excluding backups (# and . in name) -# and __init__ -# -# Stick them in a list -# modx_module_list = [] - -# for f in os.listdir(os.path.dirname(__file__)): -# if f!='__init__.py' and \ -# f[-3:] == ".py" and \ -# "." not in f[:-3] \ -# and '#' not in f: -# mod_path = 'courseware.modules.'+f[:-3] -# mod = __import__(mod_path, fromlist = "courseware.modules") -# if 'Module' in mod.__dict__: -# modx_module_list.append(mod) - -#print modx_module_list -modx_module_list = [capa_module, html_module, schematic_module, seq_module, template_module, vertical_module, video_module] -#print modx_module_list - -modx_modules = {} - -# Convert list to a dictionary for lookup by tag -def update_modules(): - global modx_modules - modx_modules = dict() - for module in modx_module_list: - for tag in module.Module.get_xml_tags(): - modx_modules[tag] = module.Module - -update_modules() - -def get_module_class(tag): - ''' Given an XML tag (e.g. 'video'), return - the associated module (e.g. video_module.Module). - ''' - if tag not in modx_modules: - update_modules() - return modx_modules[tag] - -def get_module_id(tag): - ''' Given an XML tag (e.g. 'video'), return - the default ID for that module (e.g. 'youtube_id') - ''' - return modx_modules[tag].id_attribute - -def get_valid_tags(): - return modx_modules.keys() - -def get_default_ids(): - tags = get_valid_tags() - ids = map(get_module_id, tags) - return dict(zip(tags, ids)) - diff --git a/common/lib/xmodule/abtest_module.py b/common/lib/xmodule/abtest_module.py new file mode 100644 index 0000000000..beaeb4ad1c --- /dev/null +++ b/common/lib/xmodule/abtest_module.py @@ -0,0 +1,118 @@ +import json +import random +from lxml import etree + +from xmodule.x_module import XModule +from xmodule.raw_module import RawDescriptor +from xmodule.xml_module import XmlDescriptor +from xmodule.exceptions import InvalidDefinitionError + + +def group_from_value(groups, v): + ''' Given group: (('a',0.3),('b',0.4),('c',0.3)) And random value + in [0,1], return the associated group (in the above case, return + 'a' if v<0.3, 'b' if 0.3<=v<0.7, and 'c' if v>0.7 +''' + sum = 0 + for (g, p) in groups: + sum = sum + p + if sum > v: + return g + + # Round off errors might cause us to run to the end of the list + # If the do, return the last element + return g + + +class ABTestModule(XModule): + """ + Implements an A/B test with an aribtrary number of competing groups + + Format: + + + + + + """ + + def __init__(self, system, location, definition, instance_state=None, shared_state=None, **kwargs): + XModule.__init__(self, system, location, definition, instance_state, shared_state, **kwargs) + + target_groups = self.definition['data'].keys() + if shared_state is None: + + self.group = group_from_value( + self.definition['data']['group_portions'], + random.uniform(0, 1) + ) + else: + shared_state = json.loads(shared_state) + + # TODO (cpennington): Remove this once we aren't passing in + # groups from django groups + if 'groups' in shared_state: + self.group = None + target_names = [elem.get('name') for elem in target_groups] + for group in shared_state['groups']: + if group in target_names: + self.group = group + break + else: + self.group = shared_state['group'] + + def get_shared_state(self): + print self.group + return json.dumps({'group': self.group}) + + def displayable_items(self): + return [self.system.get_module(child) + for child + in self.definition['data']['group_content'][self.group]] + + +class ABTestDescriptor(RawDescriptor, XmlDescriptor): + module_class = ABTestModule + + def __init__(self, system, definition=None, **kwargs): + kwargs['shared_state_key'] = definition['data']['experiment'] + RawDescriptor.__init__(self, system, definition, **kwargs) + + @classmethod + def definition_from_xml(cls, xml_object, system): + experiment = xml_object.get('experiment') + + if experiment is None: + raise InvalidDefinitionError("ABTests must specify an experiment. Not found in:\n{xml}".format(xml=etree.tostring(xml_object, pretty_print=True))) + + definition = { + 'data': { + 'experiment': experiment, + 'group_portions': [], + 'group_content': {None: []}, + }, + 'children': []} + for group in xml_object: + if group.tag == 'default': + name = None + else: + name = group.get('name') + definition['data']['group_portions'].append( + (name, float(group.get('portion', 0))) + ) + + child_content_urls = [ + system.process_xml(etree.tostring(child)).location.url() + for child in group + ] + + definition['data']['group_content'][name] = child_content_urls + definition['children'].extend(child_content_urls) + + default_portion = 1 - sum(portion for (name, portion) in definition['data']['group_portions']) + if default_portion < 0: + raise InvalidDefinitionError("ABTest portions must add up to less than or equal to 1") + + definition['data']['group_portions'].append((None, default_portion)) + + return definition diff --git a/common/lib/xmodule/capa_module.py b/common/lib/xmodule/capa_module.py index b59bc9de56..6a95789417 100644 --- a/common/lib/xmodule/capa_module.py +++ b/common/lib/xmodule/capa_module.py @@ -10,8 +10,8 @@ import StringIO from datetime import timedelta from lxml import etree -from x_module import XModule -from mako_module import MakoModuleDescriptor +from xmodule.x_module import XModule +from xmodule.raw_module import RawDescriptor from progress import Progress from capa.capa_problem import LoncapaProblem from capa.responsetypes import StudentInputError @@ -64,44 +64,126 @@ class ComplexEncoder(json.JSONEncoder): return json.JSONEncoder.default(self, obj) -class CapaModuleDescriptor(MakoModuleDescriptor): - """ - Module implementing problems in the LON-CAPA format, - as implemented by capa.capa_problem - """ - - mako_template = 'widgets/problem-edit.html' - - - -class Module(XModule): +class CapaModule(XModule): ''' Interface between capa_problem and x_module. Originally a hack meant to be refactored out, but it seems to be serving a useful prupose now. We can e.g .destroy and create the capa_problem on a reset. ''' + icon_class = 'problem' - id_attribute = "filename" + def __init__(self, system, location, definition, instance_state=None, shared_state=None, **kwargs): + XModule.__init__(self, system, location, definition, instance_state, shared_state, **kwargs) - @classmethod - def get_xml_tags(c): - return ["problem"] + self.attempts = 0 + self.max_attempts = None + dom2 = etree.fromstring(definition['data']) - def get_state(self): + self.explanation = "problems/" + only_one(dom2.xpath('/problem/@explain'), + default="closed") + # TODO: Should be converted to: self.explanation=only_one(dom2.xpath('/problem/@explain'), default="closed") + self.explain_available = only_one(dom2.xpath('/problem/@explain_available')) + + 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)) + else: + self.display_due_date = None + + grace_period_string = self.metadata.get('graceperiod', None) + 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)) + else: + self.grace_period = None + self.close_date = self.display_due_date + + self.max_attempts = only_one(dom2.xpath('/problem/@attempts')) + if len(self.max_attempts) > 0: + self.max_attempts = int(self.max_attempts) + else: + self.max_attempts = None + + self.show_answer = self.metadata.get('showanwser', 'closed') + + if self.show_answer == "": + self.show_answer = "closed" + + if instance_state != None: + instance_state = json.loads(instance_state) + if instance_state != None and 'attempts' in instance_state: + self.attempts = instance_state['attempts'] + + # TODO: Should be: self.filename=only_one(dom2.xpath('/problem/@filename')) + self.filename = "problems/" + only_one(dom2.xpath('/problem/@filename')) + ".xml" + self.name = only_one(dom2.xpath('/problem/@name')) + + weight_string = only_one(dom2.xpath('/problem/@weight')) + if weight_string: + self.weight = float(weight_string) + else: + self.weight = 1 + + if self.rerandomize == 'never': + seed = 1 + elif self.rerandomize == "per_student" and hasattr(system, 'id'): + seed = system.id + else: + seed = None + try: + fp = self.system.filestore.open(self.filename) + except Exception: + log.exception('cannot open file %s' % self.filename) + if self.system.DEBUG: + # create a dummy problem instead of failing + fp = StringIO.StringIO('Problem file %s is missing' % self.filename) + fp.name = "StringIO" + else: + raise + try: + self.lcp = LoncapaProblem(fp, self.location.html_id(), instance_state, seed=seed, system=self.system) + except Exception: + msg = 'cannot create LoncapaProblem %s' % self.filename + log.exception(msg) + if self.system.DEBUG: + msg = '

          %s

          ' % msg.replace('<', '<') + msg += '

          %s

          ' % traceback.format_exc().replace('<', '<') + # create a dummy problem with error message instead of failing + fp = StringIO.StringIO('Problem file %s has an error:%s' % (self.filename, msg)) + fp.name = "StringIO" + self.lcp = LoncapaProblem(fp, self.location.html_id(), instance_state, seed=seed, system=self.system) + else: + raise + + @property + def rerandomize(self): + """ + Property accessor that returns self.metadata['rerandomize'] in a canonical form + """ + rerandomize = self.metadata.get('rerandomize', 'always') + if rerandomize in ("", "always", "true"): + return "always" + elif rerandomize in ("false", "per_student"): + return "per_student" + elif rerandomize == "never": + return "never" + else: + raise Exception("Invalid rerandomize attribute " + rerandomize) + + def get_instance_state(self): state = self.lcp.get_state() state['attempts'] = self.attempts return json.dumps(state) - def get_score(self): return self.lcp.get_score() - def max_score(self): return self.lcp.get_max_score() - def get_progress(self): ''' For now, just return score / max_score ''' @@ -112,14 +194,13 @@ class Module(XModule): return Progress(score, total) return None - def get_html(self): return self.system.render_template('problem_ajax.html', { - 'id': self.item_id, - 'ajax_url': self.ajax_url, + 'element_id': self.location.html_id(), + 'id': self.id, + 'ajax_url': self.system.ajax_url, }) - def get_problem_html(self, encapsulate=True): '''Return html for the problem. Adds check, reset, save buttons as necessary based on the problem config and state.''' @@ -172,12 +253,12 @@ class Module(XModule): explain = False context = {'problem': content, - 'id': self.item_id, + 'id': self.id, 'check_button': check_button, 'reset_button': reset_button, 'save_button': save_button, 'answer_available': self.answer_available(), - 'ajax_url': self.ajax_url, + 'ajax_url': self.system.ajax_url, 'attempts_used': self.attempts, 'attempts_allowed': self.max_attempts, 'explain': explain, @@ -187,100 +268,10 @@ class Module(XModule): html = self.system.render_template('problem.html', context) if encapsulate: html = '
          '.format( - id=self.item_id, ajax_url=self.ajax_url) + html + "
          " + id=self.location.html_id(), ajax_url=self.system.ajax_url) + html + "
  • " return html - def __init__(self, system, xml, item_id, state=None): - XModule.__init__(self, system, xml, item_id, state) - - self.attempts = 0 - self.max_attempts = None - - dom2 = etree.fromstring(xml) - - self.explanation = "problems/" + only_one(dom2.xpath('/problem/@explain'), - default="closed") - # TODO: Should be converted to: self.explanation=only_one(dom2.xpath('/problem/@explain'), default="closed") - self.explain_available = only_one(dom2.xpath('/problem/@explain_available')) - - display_due_date_string = only_one(dom2.xpath('/problem/@due')) - if len(display_due_date_string) > 0: - self.display_due_date = dateutil.parser.parse(display_due_date_string) - #log.debug("Parsed " + display_due_date_string + " to " + str(self.display_due_date)) - else: - self.display_due_date = None - - grace_period_string = only_one(dom2.xpath('/problem/@graceperiod')) - if len(grace_period_string) >0 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)) - else: - self.grace_period = None - self.close_date = self.display_due_date - - self.max_attempts = only_one(dom2.xpath('/problem/@attempts')) - if len(self.max_attempts) > 0: - self.max_attempts = int(self.max_attempts) - else: - self.max_attempts = None - - self.show_answer = only_one(dom2.xpath('/problem/@showanswer')) - - if self.show_answer == "": - self.show_answer = "closed" - - self.rerandomize = only_one(dom2.xpath('/problem/@rerandomize')) - if self.rerandomize == "" or self.rerandomize=="always" or self.rerandomize=="true": - self.rerandomize="always" - elif self.rerandomize=="false" or self.rerandomize=="per_student": - self.rerandomize="per_student" - elif self.rerandomize=="never": - self.rerandomize="never" - else: - raise Exception("Invalid rerandomize attribute "+self.rerandomize) - - if state!=None: - state=json.loads(state) - if state!=None and 'attempts' in state: - self.attempts=state['attempts'] - - # TODO: Should be: self.filename=only_one(dom2.xpath('/problem/@filename')) - self.filename= "problems/"+only_one(dom2.xpath('/problem/@filename'))+".xml" - self.name=only_one(dom2.xpath('/problem/@name')) - self.weight=only_one(dom2.xpath('/problem/@weight')) - if self.rerandomize == 'never': - seed = 1 - elif self.rerandomize == "per_student" and hasattr(system, 'id'): - seed = system.id - else: - seed = None - try: - fp = self.filestore.open(self.filename) - except Exception,err: - log.exception('[courseware.capa.capa_module.Module.init] error %s: cannot open file %s' % (err,self.filename)) - if self.DEBUG: - # create a dummy problem instead of failing - fp = StringIO.StringIO('Problem file %s is missing' % self.filename) - fp.name = "StringIO" - else: - raise - try: - self.lcp=LoncapaProblem(fp, self.item_id, state, seed = seed, system=self.system) - except Exception,err: - msg = '[courseware.capa.capa_module.Module.init] error %s: cannot create LoncapaProblem %s' % (err,self.filename) - log.exception(msg) - if self.DEBUG: - msg = '

    %s

    ' % msg.replace('<','<') - msg += '

    %s

    ' % traceback.format_exc().replace('<','<') - # create a dummy problem with error message instead of failing - fp = StringIO.StringIO('Problem file %s has an error:%s' % (self.filename,msg)) - fp.name = "StringIO" - self.lcp=LoncapaProblem(fp, self.item_id, state, seed = seed, system=self.system) - else: - raise - def handle_ajax(self, dispatch, get): ''' This is called by courseware.module_render, to handle an AJAX call. @@ -306,8 +297,8 @@ class Module(XModule): d = handlers[dispatch](get) after = self.get_progress() d.update({ - 'progress_changed' : after != before, - 'progress_status' : Progress.to_js_status_str(after), + 'progress_changed': after != before, + 'progress_status': Progress.to_js_status_str(after), }) return json.dumps(d, cls=ComplexEncoder) @@ -320,7 +311,6 @@ class Module(XModule): return False - def answer_available(self): ''' Is the user allowed to see an answer? ''' @@ -341,7 +331,8 @@ class Module(XModule): if self.show_answer == 'always': return True - raise self.system.exception404 #TODO: Not 404 + #TODO: Not 404 + raise self.system.exception404 def get_answer(self, get): ''' @@ -355,8 +346,7 @@ class Module(XModule): raise self.system.exception404 else: answers = self.lcp.get_question_answers() - return {'answers' : answers} - + return {'answers': answers} # Figure out if we should move these to capa_problem? def get_problem(self, get): @@ -365,8 +355,8 @@ class Module(XModule): Used if we want to reconfirm we have the right thing e.g. after several AJAX calls. - ''' - return {'html' : self.get_problem_html(encapsulate=False)} + ''' + return {'html': self.get_problem_html(encapsulate=False)} @staticmethod def make_dict_of_responses(get): @@ -399,7 +389,7 @@ class Module(XModule): # Too late. Cannot submit if self.closed(): event_info['failure'] = 'closed' - self.tracker('save_problem_check_fail', event_info) + self.system.track_function('save_problem_check_fail', event_info) # TODO (vshnayder): probably not 404? raise self.system.exception404 @@ -407,7 +397,7 @@ class Module(XModule): # again. if self.lcp.done and self.rerandomize == "always": event_info['failure'] = 'unreset' - self.tracker('save_problem_check_fail', event_info) + self.system.track_function('save_problem_check_fail', event_info) raise self.system.exception404 try: @@ -416,18 +406,16 @@ class Module(XModule): correct_map = self.lcp.grade_answers(answers) except StudentInputError as inst: # TODO (vshnayder): why is this line here? - self.lcp = LoncapaProblem(self.filestore.open(self.filename), + self.lcp = LoncapaProblem(self.system.filestore.open(self.filename), id=lcp_id, state=old_state, system=self.system) traceback.print_exc() return {'success': inst.message} except: # TODO: why is this line here? - self.lcp = LoncapaProblem(self.filestore.open(self.filename), + self.lcp = LoncapaProblem(self.system.filestore.open(self.filename), id=lcp_id, state=old_state, system=self.system) traceback.print_exc() - raise Exception,"error in capa_module" - # TODO: Dead code... is this a bug, or just old? - return {'success':'Unknown Error'} + raise Exception("error in capa_module") self.attempts = self.attempts + 1 self.lcp.done = True @@ -438,21 +426,18 @@ class Module(XModule): if not correct_map.is_correct(answer_id): success = 'incorrect' - event_info['correct_map'] = correct_map.get_dict() # log this in the tracker + # log this in the track_function + event_info['correct_map'] = correct_map.get_dict() event_info['success'] = success - self.tracker('save_problem_check', event_info) + self.system.track_function('save_problem_check', event_info) - try: - html = self.get_problem_html(encapsulate=False) # render problem into HTML - except Exception,err: - log.error('failed to generate html') - raise + # render problem into HTML + html = self.get_problem_html(encapsulate=False) return {'success': success, 'contents': html, } - def save_problem(self, get): ''' Save the passed in answers. @@ -469,7 +454,7 @@ class Module(XModule): # Too late. Cannot submit if self.closed(): event_info['failure'] = 'closed' - self.tracker('save_problem_fail', event_info) + self.system.track_function('save_problem_fail', event_info) return {'success': False, 'error': "Problem is closed"} @@ -477,14 +462,14 @@ class Module(XModule): # again. if self.lcp.done and self.rerandomize == "always": event_info['failure'] = 'done' - self.tracker('save_problem_fail', event_info) - return {'success' : False, - 'error' : "Problem needs to be reset prior to save."} + self.system.track_function('save_problem_fail', event_info) + return {'success': False, + 'error': "Problem needs to be reset prior to save."} self.lcp.student_answers = answers # TODO: should this be save_problem_fail? Looks like success to me... - self.tracker('save_problem_fail', event_info) + self.system.track_function('save_problem_fail', event_info) return {'success': True} def reset_problem(self, get): @@ -492,30 +477,39 @@ class Module(XModule): and causes problem to rerender itself. Returns problem html as { 'html' : html-string }. - ''' + ''' event_info = dict() event_info['old_state'] = self.lcp.get_state() event_info['filename'] = self.filename if self.closed(): event_info['failure'] = 'closed' - self.tracker('reset_problem_fail', event_info) + self.system.track_function('reset_problem_fail', event_info) return "Problem is closed" if not self.lcp.done: event_info['failure'] = 'not_done' - self.tracker('reset_problem_fail', event_info) + self.system.track_function('reset_problem_fail', event_info) return "Refresh the page and make an attempt before resetting." self.lcp.do_reset() if self.rerandomize == "always": # reset random number generator seed (note the self.lcp.get_state() in next line) - self.lcp.seed=None - - self.lcp = LoncapaProblem(self.filestore.open(self.filename), - self.item_id, self.lcp.get_state(), system=self.system) + self.lcp.seed = None + + self.lcp = LoncapaProblem(self.system.filestore.open(self.filename), + self.location.html_id(), self.lcp.get_state(), system=self.system) event_info['new_state'] = self.lcp.get_state() - self.tracker('reset_problem', event_info) + self.system.track_function('reset_problem', event_info) - return {'html' : self.get_problem_html(encapsulate=False)} + return {'html': self.get_problem_html(encapsulate=False)} + + +class CapaDescriptor(RawDescriptor): + """ + Module implementing problems in the LON-CAPA format, + as implemented by capa.capa_problem + """ + + module_class = CapaModule diff --git a/common/lib/xmodule/exceptions.py b/common/lib/xmodule/exceptions.py new file mode 100644 index 0000000000..9a9258d600 --- /dev/null +++ b/common/lib/xmodule/exceptions.py @@ -0,0 +1,2 @@ +class InvalidDefinitionError(Exception): + pass diff --git a/common/lib/xmodule/hidden_module.py b/common/lib/xmodule/hidden_module.py new file mode 100644 index 0000000000..d4f2a0fa33 --- /dev/null +++ b/common/lib/xmodule/hidden_module.py @@ -0,0 +1,10 @@ +from xmodule.x_module import XModule +from xmodule.raw_module import RawDescriptor + + +class HiddenModule(XModule): + pass + + +class HiddenDescriptor(RawDescriptor): + module_class = HiddenModule diff --git a/common/lib/xmodule/html_module.py b/common/lib/xmodule/html_module.py index b35549d971..32963600cd 100644 --- a/common/lib/xmodule/html_module.py +++ b/common/lib/xmodule/html_module.py @@ -1,75 +1,34 @@ import json import logging -from x_module import XModule -from mako_module import MakoModuleDescriptor +from xmodule.x_module import XModule +from xmodule.mako_module import MakoModuleDescriptor +from xmodule.xml_module import XmlDescriptor from lxml import etree from pkg_resources import resource_string log = logging.getLogger("mitx.courseware") -#----------------------------------------------------------------------------- -class HtmlModuleDescriptor(MakoModuleDescriptor): +class HtmlModule(XModule): + def get_html(self): + return self.html + + def __init__(self, system, location, definition, instance_state=None, shared_state=None, **kwargs): + XModule.__init__(self, system, location, definition, instance_state, shared_state, **kwargs) + self.html = self.definition['data']['text'] + + +class HtmlDescriptor(MakoModuleDescriptor, XmlDescriptor): """ Module for putting raw html in a course """ mako_template = "widgets/html-edit.html" + module_class = HtmlModule js = {'coffee': [resource_string(__name__, 'js/module/html.coffee')]} js_module = 'HTML' @classmethod - def from_xml(cls, xml_data, system, org=None, course=None): - """ - Creates an instance of this descriptor from the supplied xml_data. - This may be overridden by subclasses - - xml_data: A string of xml that will be translated into data and children for - this module - system: An XModuleSystem for interacting with external resources - org and course are optional strings that will be used in the generated modules - url identifiers - """ - xml_object = etree.fromstring(xml_data) - return cls( - system, - definition={'data': {'text': xml_data}}, - location=['i4x', - org, - course, - xml_object.tag, - xml_object.get('name')] - ) - -class Module(XModule): - id_attribute = 'filename' - - def get_state(self): - return json.dumps({ }) - - @classmethod - def get_xml_tags(c): - return ["html"] - - def get_html(self): - if self.filename==None: - xmltree=etree.fromstring(self.xml) - textlist=[xmltree.text]+[etree.tostring(i) for i in xmltree]+[xmltree.tail] - textlist=[i for i in textlist if type(i)==str] - return "".join(textlist) - try: - filename="html/"+self.filename - return self.filestore.open(filename).read() - except: # For backwards compatibility. TODO: Remove - if self.DEBUG: - log.info('[courseware.modules.html_module] filename=%s' % self.filename) - return self.system.render_template(self.filename, {'id': self.item_id}, namespace='course') - - def __init__(self, system, xml, item_id, state=None): - XModule.__init__(self, system, xml, item_id, state) - xmltree=etree.fromstring(xml) - self.filename = None - filename_l=xmltree.xpath("/html/@filename") - if len(filename_l)>0: - self.filename=str(filename_l[0]) + def definition_from_xml(cls, xml_object, system): + return {'data': {'text': etree.tostring(xml_object)}} diff --git a/common/lib/xmodule/raw_module.py b/common/lib/xmodule/raw_module.py index 7bb94c9b63..43a92303ad 100644 --- a/common/lib/xmodule/raw_module.py +++ b/common/lib/xmodule/raw_module.py @@ -1,8 +1,9 @@ from pkg_resources import resource_string -from mako_module import MakoModuleDescriptor from lxml import etree +from xmodule.mako_module import MakoModuleDescriptor +from xmodule.xml_module import XmlDescriptor -class RawDescriptor(MakoModuleDescriptor): +class RawDescriptor(MakoModuleDescriptor, XmlDescriptor): """ Module that provides a raw editing view of it's data and children """ @@ -18,24 +19,5 @@ class RawDescriptor(MakoModuleDescriptor): } @classmethod - def from_xml(cls, xml_data, system, org=None, course=None): - """ - Creates an instance of this descriptor from the supplied xml_data. - This may be overridden by subclasses - - xml_data: A string of xml that will be translated into data and children for - this module - system: An XModuleSystem for interacting with external resources - org and course are optional strings that will be used in the generated modules - url identifiers - """ - xml_object = etree.fromstring(xml_data) - return cls( - system, - definition={'data': xml_data}, - location=['i4x', - org, - course, - xml_object.tag, - xml_object.get('name')] - ) + def definition_from_xml(cls, xml_object, system): + return {'data': etree.tostring(xml_object)} diff --git a/common/lib/xmodule/schematic_module.py b/common/lib/xmodule/schematic_module.py index 30175c16a8..f95729d4ab 100644 --- a/common/lib/xmodule/schematic_module.py +++ b/common/lib/xmodule/schematic_module.py @@ -6,18 +6,5 @@ class ModuleDescriptor(XModuleDescriptor): pass class Module(XModule): - id_attribute = 'id' - - def get_state(self): - return json.dumps({ }) - - @classmethod - def get_xml_tags(c): - return ["schematic"] - def get_html(self): return ''.format(item_id=self.item_id) - - def __init__(self, system, xml, item_id, state=None): - XModule.__init__(self, system, xml, item_id, state) - diff --git a/common/lib/xmodule/seq_module.py b/common/lib/xmodule/seq_module.py index e3a19c3d60..6d493b96ad 100644 --- a/common/lib/xmodule/seq_module.py +++ b/common/lib/xmodule/seq_module.py @@ -3,8 +3,9 @@ import logging from lxml import etree -from x_module import XModule -from mako_module import MakoModuleDescriptor +from xmodule.mako_module import MakoModuleDescriptor +from xmodule.xml_module import XmlDescriptor +from xmodule.x_module import XModule from xmodule.progress import Progress log = logging.getLogger("mitx.common.lib.seq_module") @@ -13,32 +14,33 @@ log = logging.getLogger("mitx.common.lib.seq_module") # OBSOLETE: This obsoletes 'type' class_priority = ['video', 'problem'] -class Module(XModule): + +class SequenceModule(XModule): ''' Layout module which lays out content in a temporal sequence ''' - id_attribute = 'id' - def get_state(self): - return json.dumps({ 'position':self.position }) + def __init__(self, system, location, definition, instance_state=None, shared_state=None, **kwargs): + XModule.__init__(self, system, location, definition, instance_state, shared_state, **kwargs) + self.position = 1 + + if instance_state is not None: + state = json.loads(instance_state) + if 'position' in state: + self.position = int(state['position']) + + # if position is specified in system, then use that instead + if system.get('position'): + self.position = int(system.get('position')) + + self.rendered = False + + def get_instance_state(self): + return json.dumps({'position': self.position}) - @classmethod - def get_xml_tags(c): - obsolete_tags = ["sequential", 'tab'] - modern_tags = ["videosequence"] - return obsolete_tags + modern_tags - def get_html(self): self.render() return self.content - def get_init_js(self): - self.render() - return self.init_js - - def get_destroy_js(self): - self.render() - return self.destroy_js - def get_progress(self): ''' Return the total progress, adding total done and total available. (assumes that each submodule uses the same "units" for progress.) @@ -60,78 +62,49 @@ class Module(XModule): if self.rendered: return ## Returns a set of all types of all sub-children - child_classes = [set([i.tag for i in e.iter()]) for e in self.xmltree] - - titles = ["\n".join([i.get("name").strip() for i in e.iter() if i.get("name") is not None]) \ - for e in self.xmltree] - - children = self.get_children() - progresses = [child.get_progress() for child in children] - - self.contents = self.rendered_children() - - for contents, title, progress in zip(self.contents, titles, progresses): - contents['title'] = title - contents['progress_status'] = Progress.to_js_status_str(progress) - contents['progress_detail'] = Progress.to_js_detail_str(progress) - - for (content, element_class) in zip(self.contents, child_classes): - new_class = 'other' - for c in class_priority: - if c in element_class: - new_class = c - content['type'] = new_class + contents = [] + for child in self.get_display_items(): + progress = child.get_progress() + contents.append({ + 'content': child.get_html(), + 'title': "\n".join( + grand_child.metadata['display_name'].strip() + for grand_child in child.get_children() + if 'display_name' in grand_child.metadata + ), + 'progress_status': Progress.to_js_status_str(progress), + 'progress_detail': Progress.to_js_detail_str(progress), + 'type': child.get_icon_class(), + }) # Split tags -- browsers handle this as end # of script, even if it occurs mid-string. Do this after json.dumps()ing # so that we can be sure of the quotations being used - params={'items': json.dumps(self.contents).replace('', '<"+"/script>'), - 'id': self.item_id, - 'position': self.position, - 'titles': titles, - 'tag': self.xmltree.tag} + params = {'items': json.dumps(contents).replace('', '<"+"/script>'), + 'element_id': self.location.html_id(), + 'item_id': self.id, + 'position': self.position, + 'tag': self.location.category} - if self.xmltree.tag in ['sequential', 'videosequence']: - self.content = self.system.render_template('seq_module.html', params) - if self.xmltree.tag == 'tab': - self.content = self.system.render_template('tab_module.html', params) - log.debug("rendered content: %s", content) + self.content = self.system.render_template('seq_module.html', params) self.rendered = True - def __init__(self, system, xml, item_id, state=None): - XModule.__init__(self, system, xml, item_id, state) - self.xmltree = etree.fromstring(xml) - - self.position = 1 - - if state is not None: - state = json.loads(state) - if 'position' in state: self.position = int(state['position']) - - # if position is specified in system, then use that instead - if system.get('position'): - self.position = int(system.get('position')) - - self.rendered = False + def get_icon_class(self): + child_classes = set(child.get_icon_class() for child in self.get_children()) + new_class = 'other' + for c in class_priority: + if c in child_classes: + new_class = c + return new_class -class SequenceDescriptor(MakoModuleDescriptor): +class SequenceDescriptor(MakoModuleDescriptor, XmlDescriptor): mako_template = 'widgets/sequence-edit.html' + module_class = SequenceModule @classmethod - def from_xml(cls, xml_data, system, org=None, course=None): - xml_object = etree.fromstring(xml_data) - - children = [ - system.process_xml(etree.tostring(child_module)).url + def definition_from_xml(cls, xml_object, system): + return {'children': [ + system.process_xml(etree.tostring(child_module)).location.url() for child_module in xml_object - ] - - return cls( - system, {'children': children}, - location=['i4x', - org, - course, - xml_object.tag, - xml_object.get('name')] - ) + ]} diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index 17d7af50db..e45e6654c2 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -13,14 +13,23 @@ setup( # for a description of entry_points entry_points={ 'xmodule.v1': [ - "chapter = seq_module:SequenceDescriptor", - "course = seq_module:SequenceDescriptor", - "html = html_module:HtmlModuleDescriptor", - "section = translation_module:SemanticSectionDescriptor", - "sequential = seq_module:SequenceDescriptor", - "vertical = seq_module:SequenceDescriptor", - "problemset = seq_module:SequenceDescriptor", - "videosequence = seq_module:SequenceDescriptor", + "abtest = xmodule.abtest_module:ABTestDescriptor", + "book = xmodule.translation_module:TranslateCustomTagDescriptor", + "chapter = xmodule.seq_module:SequenceDescriptor", + "course = xmodule.seq_module:SequenceDescriptor", + "customtag = xmodule.template_module:CustomTagDescriptor", + "discuss = xmodule.translation_module:TranslateCustomTagDescriptor", + "html = xmodule.html_module:HtmlDescriptor", + "image = xmodule.translation_module:TranslateCustomTagDescriptor", + "problem = xmodule.capa_module:CapaDescriptor", + "problemset = xmodule.vertical_module:VerticalDescriptor", + "section = xmodule.translation_module:SemanticSectionDescriptor", + "sequential = xmodule.seq_module:SequenceDescriptor", + "slides = xmodule.translation_module:TranslateCustomTagDescriptor", + "vertical = xmodule.vertical_module:VerticalDescriptor", + "video = xmodule.video_module:VideoDescriptor", + "videodev = xmodule.translation_module:TranslateCustomTagDescriptor", + "videosequence = xmodule.seq_module:SequenceDescriptor", ] } ) diff --git a/common/lib/xmodule/template_module.py b/common/lib/xmodule/template_module.py index 51f9447c06..1057fc2a25 100644 --- a/common/lib/xmodule/template_module.py +++ b/common/lib/xmodule/template_module.py @@ -1,14 +1,9 @@ -import json - -from x_module import XModule, XModuleDescriptor +from xmodule.x_module import XModule +from xmodule.raw_module import RawDescriptor from lxml import etree -class ModuleDescriptor(XModuleDescriptor): - pass - - -class Module(XModule): +class CustomTagModule(XModule): """ This module supports tags of the form @@ -31,19 +26,16 @@ class Module(XModule): Renders to:: More information given in the text """ - def get_state(self): - return json.dumps({}) - @classmethod - def get_xml_tags(c): - return ['customtag'] + def __init__(self, system, location, definition, instance_state=None, shared_state=None, **kwargs): + XModule.__init__(self, system, location, definition, instance_state, shared_state, **kwargs) + xmltree = etree.fromstring(self.definition['data']) + filename = xmltree.find('impl').text + params = dict(xmltree.items()) + self.html = self.system.render_template(filename, params, namespace='custom_tags') def get_html(self): return self.html - def __init__(self, system, xml, item_id, state=None): - XModule.__init__(self, system, xml, item_id, state) - xmltree = etree.fromstring(xml) - filename = xmltree.find('impl').text - params = dict(xmltree.items()) - self.html = self.system.render_template(filename, params, namespace='custom_tags') +class CustomTagDescriptor(RawDescriptor): + module_class = CustomTagModule diff --git a/common/lib/xmodule/translation_module.py b/common/lib/xmodule/translation_module.py index b56fed02cd..d379ced507 100644 --- a/common/lib/xmodule/translation_module.py +++ b/common/lib/xmodule/translation_module.py @@ -23,7 +23,7 @@ def process_includes(fn): file = next_include.get('file') if file is not None: try: - ifp = system.fs.open(file) + ifp = system.resources_fs.open(file) except Exception: log.exception('Error in problem xml include: %s' % (etree.tostring(next_include, pretty_print=True))) log.exception('Cannot find file %s in %s' % (file, dir)) @@ -57,11 +57,26 @@ class SemanticSectionDescriptor(XModuleDescriptor): if len(xml_object) == 1: for (key, val) in xml_object.items(): - if key == 'format': - continue xml_object[0].set(key, val) return system.process_xml(etree.tostring(xml_object[0])) else: xml_object.tag = 'sequence' return system.process_xml(etree.tostring(xml_object)) + + +class TranslateCustomTagDescriptor(XModuleDescriptor): + @classmethod + def from_xml(cls, xml_data, system, org=None, course=None): + """ + Transforms the xml_data from <$custom_tag attr="" attr=""/> to + $custom_tag + """ + + xml_object = etree.fromstring(xml_data) + tag = xml_object.tag + xml_object.tag = 'customtag' + impl = etree.SubElement(xml_object, 'impl') + impl.text = tag + + return system.process_xml(etree.tostring(xml_object)) diff --git a/common/lib/xmodule/vertical_module.py b/common/lib/xmodule/vertical_module.py index b3feec8bae..c9ecc5ea18 100644 --- a/common/lib/xmodule/vertical_module.py +++ b/common/lib/xmodule/vertical_module.py @@ -1,24 +1,23 @@ -import json - -from x_module import XModule, XModuleDescriptor +from xmodule.x_module import XModule +from xmodule.seq_module import SequenceDescriptor from xmodule.progress import Progress -from lxml import etree -class ModuleDescriptor(XModuleDescriptor): - pass +# HACK: This shouldn't be hard-coded to two types +# OBSOLETE: This obsoletes 'type' +class_priority = ['video', 'problem'] -class Module(XModule): + +class VerticalModule(XModule): ''' Layout module for laying out submodules vertically.''' - id_attribute = 'id' - def get_state(self): - return json.dumps({ }) + def __init__(self, system, location, definition, instance_state=None, shared_state=None, **kwargs): + XModule.__init__(self, system, location, definition, instance_state, shared_state, **kwargs) + self.contents = None - @classmethod - def get_xml_tags(c): - return ["vertical", "problemset"] - def get_html(self): + if self.contents is None: + self.contents = [child.get_html() for child in self.get_display_items()] + return self.system.render_template('vert_module.html', { 'items': self.contents }) @@ -30,8 +29,14 @@ class Module(XModule): progress = reduce(Progress.add_counts, progresses) return progress - def __init__(self, system, xml, item_id, state=None): - XModule.__init__(self, system, xml, item_id, state) - xmltree=etree.fromstring(xml) - self.contents=[(e.get("name"),self.render_function(e)) \ - for e in xmltree] + def get_icon_class(self): + child_classes = set(child.get_icon_class() for child in self.get_children()) + new_class = 'other' + for c in class_priority: + if c in child_classes: + new_class = c + return new_class + + +class VerticalDescriptor(SequenceDescriptor): + module_class = VerticalModule diff --git a/common/lib/xmodule/video_module.py b/common/lib/xmodule/video_module.py index f3d615fd3d..ed44a2d422 100644 --- a/common/lib/xmodule/video_module.py +++ b/common/lib/xmodule/video_module.py @@ -3,17 +3,27 @@ import logging from lxml import etree -from x_module import XModule, XModuleDescriptor -from progress import Progress +from xmodule.x_module import XModule +from xmodule.raw_module import RawDescriptor -log = logging.getLogger("mitx.courseware.modules") +log = logging.getLogger(__name__) -class ModuleDescriptor(XModuleDescriptor): - pass -class Module(XModule): - id_attribute = 'youtube' +class VideoModule(XModule): video_time = 0 + icon_class = 'video' + + def __init__(self, system, location, definition, instance_state=None, shared_state=None, **kwargs): + XModule.__init__(self, system, location, definition, instance_state, shared_state, **kwargs) + xmltree = etree.fromstring(self.definition['data']) + self.youtube = xmltree.get('youtube') + self.name = xmltree.get('name') + self.position = 0 + + if instance_state is not None: + state = json.loads(instance_state) + if 'position' in state: + self.position = int(float(state['position'])) def handle_ajax(self, dispatch, get): ''' @@ -39,14 +49,9 @@ class Module(XModule): ''' return None - def get_state(self): + def get_instance_state(self): log.debug(u"STATE POSITION {0}".format(self.position)) - return json.dumps({ 'position': self.position }) - - @classmethod - def get_xml_tags(c): - '''Tags in the courseware file guaranteed to correspond to the module''' - return ["video"] + return json.dumps({'position': self.position}) def video_list(self): return self.youtube @@ -54,27 +59,11 @@ class Module(XModule): def get_html(self): return self.system.render_template('video.html', { 'streams': self.video_list(), - 'id': self.item_id, + 'id': self.location.html_id(), 'position': self.position, 'name': self.name, - 'annotations': self.annotations, }) - def __init__(self, system, xml, item_id, state=None): - XModule.__init__(self, system, xml, item_id, state) - xmltree = etree.fromstring(xml) - self.youtube = xmltree.get('youtube') - self.name = xmltree.get('name') - self.position = 0 - if state is not None: - state = json.loads(state) - if 'position' in state: - self.position = int(float(state['position'])) - - self.annotations=[(e.get("name"),self.render_function(e)) \ - for e in xmltree] - - -class VideoSegmentDescriptor(XModuleDescriptor): - pass +class VideoDescriptor(RawDescriptor): + module_class = VideoModule diff --git a/common/lib/xmodule/x_module.py b/common/lib/xmodule/x_module.py index 336ccc6d0c..191cda6b06 100644 --- a/common/lib/xmodule/x_module.py +++ b/common/lib/xmodule/x_module.py @@ -3,6 +3,7 @@ import pkg_resources import logging from keystore import Location +from functools import partial log = logging.getLogger('mitx.' + __name__) @@ -55,86 +56,107 @@ class Plugin(object): class XModule(object): - ''' Implements a generic learning module. - Initialized on access with __init__, first time with state=None, and - then with state + ''' Implements a generic learning module. - See the HTML module for a simple example + Subclasses must at a minimum provide a definition for get_html in order to be displayed to users. + + See the HTML module for a simple example. ''' - id_attribute='id' # An attribute guaranteed to be unique - @classmethod - def get_xml_tags(c): - ''' Tags in the courseware file guaranteed to correspond to the module ''' - return [] - - @classmethod - def get_usage_tags(c): - ''' We should convert to a real module system - For now, this tells us whether we use this as an xmodule, a CAPA response type - or a CAPA input type ''' - return ['xmodule'] + # The default implementation of get_icon_class returns the icon_class attribute of the class + # This attribute can be overridden by subclasses, and the function can also be overridden + # if the icon class depends on the data in the module + icon_class = 'other' + + def __init__(self, system, location, definition, instance_state=None, shared_state=None, **kwargs): + ''' + Construct a new xmodule + + system: An I4xSystem allowing access to external resources + location: Something Location-like that identifies this xmodule + definition: A dictionary containing 'data' and 'children'. Both are optional + 'data': is JSON-like (string, dictionary, list, bool, or None, optionally nested). + This defines all of the data necessary for a problem to display that is intrinsic to the problem. + It should not include any data that would vary between two courses using the same problem + (due dates, grading policy, randomization, etc.) + 'children': is a list of Location-like values for child modules that this module depends on + instance_state: A string of serialized json that contains the state of this module for + current student accessing the system, or None if no state has been saved + shared_state: A string of serialized json that contains the state that is shared between + this module and any modules of the same type with the same shared_state_key. This + state is only shared per-student, not across different students + kwargs: Optional arguments. Subclasses should always accept kwargs and pass them + to the parent class constructor. + Current known uses of kwargs: + metadata: A dictionary containing data that specifies information that is particular + to a problem in the context of a course + ''' + self.system = system + self.location = Location(location) + self.definition = definition + self.instance_state = instance_state + self.shared_state = shared_state + self.id = self.location.url() + self.name = self.location.name + self.category = self.location.category + self.metadata = kwargs.get('metadata', {}) + self._loaded_children = None def get_name(self): name = self.__xmltree.get('name') - if name: + if name: return name - else: + else: raise "We should iterate through children and find a default name" def get_children(self): ''' Return module instances for all the children of this module. ''' - children = [self.module_from_xml(e) for e in self.__xmltree] - return children + if self._loaded_children is None: + self._loaded_children = [self.system.get_module(child) for child in self.definition.get('children', [])] + return self._loaded_children - def rendered_children(self): + def get_display_items(self): ''' - Render all children. - This really ought to return a list of xmodules, instead of dictionaries + Returns a list of descendent module instances that will display immediately + inside this module ''' - children = [self.render_function(e) for e in self.__xmltree] - return children + items = [] + for child in self.get_children(): + items.extend(child.displayable_items()) - def __init__(self, system = None, xml = None, item_id = None, - json = None, track_url=None, state=None): - ''' In most cases, you must pass state or xml''' - if not item_id: - raise ValueError("Missing Index") - if not xml and not json: - raise ValueError("xml or json required") - if not system: - raise ValueError("System context required") + return items - self.xml = xml - self.json = json - self.item_id = item_id - self.state = state - self.DEBUG = False - - self.__xmltree = etree.fromstring(xml) # PRIVATE + def displayable_items(self): + ''' + Returns list of displayable modules contained by this module. If this module + is visible, should return [self] + ''' + return [self] - if system: - ## These are temporary; we really should go - ## through self.system. - self.ajax_url = system.ajax_url - self.tracker = system.track_function - self.filestore = system.filestore - self.render_function = system.render_function - self.module_from_xml = system.module_from_xml - self.DEBUG = system.DEBUG - self.system = system + def get_icon_class(self): + ''' + Return a css class identifying this module in the context of an icon + ''' + return self.icon_class ### Functions used in the LMS - def get_state(self): - ''' State of the object, as stored in the database + def get_instance_state(self): + ''' State of the object, as stored in the database ''' - return "" + return '{}' + + def get_shared_state(self): + ''' + Get state that should be shared with other instances + using the same 'shared_state_key' attribute. + ''' + return '{}' def get_score(self): - ''' Score the student received on the problem. + ''' Score the student received on the problem. ''' return None @@ -149,7 +171,7 @@ class XModule(object): def get_html(self): ''' HTML, as shown in the browser. This is the only method that must be implemented ''' - return "Unimplemented" + raise NotImplementedError("get_html must be defined for all XModules that appear on the screen. Not defined in %s" % self.__class__.__name__) def get_progress(self): ''' Return a progress.Progress object that represents how far the student has gone @@ -180,6 +202,49 @@ class XModuleDescriptor(Plugin): js = {} js_module = None + # A list of metadata that this module can inherit from its parent module + inheritable_metadata = ('graded', 'due', 'graceperiod', 'showanswer', 'rerandomize') + + def __init__(self, + system, + definition=None, + **kwargs): + """ + Construct a new XModuleDescriptor. The only required arguments are the + system, used for interaction with external resources, and the definition, + which specifies all the data needed to edit and display the problem (but none + of the associated metadata that handles recordkeeping around the problem). + + This allows for maximal flexibility to add to the interface while preserving + backwards compatibility. + + system: An XModuleSystem for interacting with external resources + definition: A dict containing `data` and `children` representing the problem definition + + Current arguments passed in kwargs: + location: A keystore.Location object indicating the name and ownership of this problem + shared_state_key: The key to use for sharing StudentModules with other + modules of this type + metadata: A dictionary containing the following optional keys: + goals: A list of strings of learning goals associated with this module + display_name: The name to use for displaying this module to the user + format: The format of this module ('Homework', 'Lab', etc) + graded (bool): Whether this module is should be graded or not + due (string): The due date for this module + graceperiod (string): The amount of grace period to allow when enforcing the due date + showanswer (string): When to show answers for this module + rerandomize (string): When to generate a newly randomized instance of the module data + """ + self.system = system + self.definition = definition if definition is not None else {} + self.name = Location(kwargs.get('location')).name + self.category = Location(kwargs.get('location')).category + self.location = Location(kwargs.get('location')) + self.metadata = kwargs.get('metadata', {}) + self.shared_state_key = kwargs.get('shared_state_key') + + self._child_instances = None + @staticmethod def load_from_json(json_data, system, default_class=None): """ @@ -201,13 +266,18 @@ class XModuleDescriptor(Plugin): Creates an instance of this descriptor from the supplied json_data. This may be overridden by subclasses - json_data: Json data specifying the data, children, and metadata for the descriptor + json_data: A json object specifying the definition and any optional keyword arguments for + the XModuleDescriptor system: An XModuleSystem for interacting with external resources """ return cls(system=system, **json_data) @staticmethod - def load_from_xml(xml_data, system, org=None, course=None, default_class=None): + def load_from_xml(xml_data, + system, + org=None, + course=None, + default_class=None): """ This method instantiates the correct subclass of XModuleDescriptor based on the contents of xml_data. @@ -256,43 +326,27 @@ class XModuleDescriptor(Plugin): """ return self.js_module - def __init__(self, - system, - definition=None, - **kwargs): + + def inherit_metadata(self, metadata): """ - Construct a new XModuleDescriptor. The only required arguments are the - system, used for interaction with external resources, and the definition, - which specifies all the data needed to edit and display the problem (but none - of the associated metadata that handles recordkeeping around the problem). - - This allows for maximal flexibility to add to the interface while preserving - backwards compatibility. - - system: An XModuleSystem for interacting with external resources - definition: A dict containing `data` and `children` representing the problem definition - - Current arguments passed in kwargs: - location: A keystore.Location object indicating the name and ownership of this problem - goals: A list of strings of learning goals associated with this module + Updates this module with metadata inherited from a containing module. + Only metadata specified in self.inheritable_metadata will + be inherited """ - self.system = system - self.definition = definition if definition is not None else {} - self.name = Location(kwargs.get('location')).name - self.type = Location(kwargs.get('location')).category - self.url = Location(kwargs.get('location')).url() - - # For now, we represent goals as a list of strings, but this - # is one of the things that we are going to be iterating on heavily - # to find the best teaching method - self.goals = kwargs.get('goals', []) - - self._child_instances = None + # Set all inheritable metadata from kwargs that are + # in self.inheritable_metadata and aren't already set in metadata + for attr in self.inheritable_metadata: + if attr not in self.metadata and attr in metadata: + self.metadata[attr] = metadata[attr] def get_children(self): """Returns a list of XModuleDescriptor instances for the children of this module""" if self._child_instances is None: - self._child_instances = [self.system.load_item(child) for child in self.definition.get('children', [])] + self._child_instances = [] + for child_loc in self.definition.get('children', []): + child = self.system.load_item(child_loc) + child.inherit_metadata(self.metadata) + self._child_instances.append(child) return self._child_instances @@ -302,49 +356,36 @@ class XModuleDescriptor(Plugin): """ raise NotImplementedError("get_html() must be provided by specific modules") - def get_xml(self): - ''' For conversions between JSON and legacy XML representations. - ''' - if self.xml: - return self.xml - else: - raise NotImplementedError("JSON->XML Translation not implemented") - - def get_json(self): - ''' For conversions between JSON and legacy XML representations. - ''' - if self.json: - raise NotImplementedError - return self.json # TODO: Return context as well -- files, etc. - else: - raise NotImplementedError("XML->JSON Translation not implemented") - - #def handle_cms_json(self): - # raise NotImplementedError - - #def render(self, size): - # ''' Size: [thumbnail, small, full] - # Small ==> what we drag around - # Full ==> what we edit - # ''' - # raise NotImplementedError + def xmodule_constructor(self, system): + """ + Returns a constructor for an XModule. This constructor takes two arguments: + instance_state and shared_state, and returns a fully nstantiated XModule + """ + return partial( + self.module_class, + system, + self.location, + self.definition, + metadata=self.metadata + ) class DescriptorSystem(object): - def __init__(self, load_item): + def __init__(self, load_item, resources_fs): """ load_item: Takes a Location and returns an XModuleDescriptor + resources_fs: A Filesystem object that contains all of the + resources needed for the course """ self.load_item = load_item + self.resources_fs = resources_fs class XMLParsingSystem(DescriptorSystem): - def __init__(self, load_item, process_xml, fs): + def __init__(self, load_item, resources_fs, process_xml): """ process_xml: Takes an xml string, and returns the the XModuleDescriptor created from that xml - fs: A Filesystem object that contains all of the xml resources needed to parse - the course """ + DescriptorSystem.__init__(self, load_item, resources_fs) self.process_xml = process_xml - self.fs = fs diff --git a/common/lib/xmodule/xml_module.py b/common/lib/xmodule/xml_module.py new file mode 100644 index 0000000000..b167e52e88 --- /dev/null +++ b/common/lib/xmodule/xml_module.py @@ -0,0 +1,53 @@ +from xmodule.x_module import XModuleDescriptor +from lxml import etree + + +class XmlDescriptor(XModuleDescriptor): + """ + Mixin class for standardized parsing of from xml + """ + + @classmethod + def definition_from_xml(cls, xml_object, system): + """ + Return the definition to be passed to the newly created descriptor + during from_xml + """ + raise NotImplementedError("%s does not implement definition_from_xml" % cls.__class__.__name__) + + @classmethod + def from_xml(cls, xml_data, system, org=None, course=None): + """ + Creates an instance of this descriptor from the supplied xml_data. + This may be overridden by subclasses + + xml_data: A string of xml that will be translated into data and children for + this module + system: An XModuleSystem for interacting with external resources + org and course are optional strings that will be used in the generated modules + url identifiers + """ + xml_object = etree.fromstring(xml_data) + + metadata = {} + for attr in ('format', 'graceperiod', 'showanswer', 'rerandomize', 'due'): + from_xml = xml_object.get(attr) + if from_xml is not None: + metadata[attr] = from_xml + + if xml_object.get('graded') is not None: + metadata['graded'] = xml_object.get('graded') == 'true' + + if xml_object.get('name') is not None: + metadata['display_name'] = xml_object.get('name') + + return cls( + system, + cls.definition_from_xml(xml_object, system), + location=['i4x', + org, + course, + xml_object.tag, + xml_object.get('slug')], + metadata=metadata, + ) diff --git a/lms/djangoapps/courseware/content_parser.py b/lms/djangoapps/courseware/content_parser.py deleted file mode 100644 index 95c3afed8c..0000000000 --- a/lms/djangoapps/courseware/content_parser.py +++ /dev/null @@ -1,365 +0,0 @@ -''' -courseware/content_parser.py - -This file interfaces between all courseware modules and the top-level course.xml file for a course. - -Does some caching (to be explained). - -''' - -import logging -import os -import sys -import urllib - -from lxml import etree -from util.memcache import fasthash - -from django.conf import settings - -from student.models import UserProfile -from student.models import UserTestGroup -from mitxmako.shortcuts import render_to_string -from util.cache import cache -from multicourse import multicourse_settings -import xmodule - -''' This file will eventually form an abstraction layer between the -course XML file and the rest of the system. -''' - -# ==== This section has no direct dependencies on django ==================================== -# NOTE: it does still have some indirect dependencies: -# util.memcache.fasthash (which does not depend on memcache at all) -# - -class ContentException(Exception): - pass - -log = logging.getLogger("mitx.courseware") - -def format_url_params(params): - return [ urllib.quote(string.replace(' ','_')) for string in params ] - -def xpath_remove(tree, path): - ''' Remove all items matching path from lxml tree. Works in - place.''' - items = tree.xpath(path) - for item in items: - item.getparent().remove(item) - return tree - -def id_tag(course): - ''' Tag all course elements with unique IDs ''' - default_ids = xmodule.get_default_ids() - - # Tag elements with unique IDs - elements = course.xpath("|".join('//' + c for c in default_ids)) - for elem in elements: - if elem.get('id'): - pass - elif elem.get(default_ids[elem.tag]): - new_id = elem.get(default_ids[elem.tag]) - # Convert to alphanumeric - new_id = "".join(a for a in new_id if a.isalnum()) - - # Without this, a conflict may occur between an html or youtube id - new_id = default_ids[elem.tag] + new_id - elem.set('id', new_id) - else: - elem.set('id', "id" + fasthash(etree.tostring(elem))) - -def propogate_downward_tag(element, attribute_name, parent_attribute = None): - ''' This call is to pass down an attribute to all children. If an element - has this attribute, it will be "inherited" by all of its children. If a - child (A) already has that attribute, A will keep the same attribute and - all of A's children will inherit A's attribute. This is a recursive call.''' - - if (parent_attribute is None): - #This is the entry call. Select all elements with this attribute - all_attributed_elements = element.xpath("//*[@" + attribute_name +"]") - for attributed_element in all_attributed_elements: - attribute_value = attributed_element.get(attribute_name) - for child_element in attributed_element: - propogate_downward_tag(child_element, attribute_name, attribute_value) - else: - '''The hack below is because we would get _ContentOnlyELements from the - iterator that can't have attributes set. We can't find API for it. If we - ever have an element which subclasses BaseElement, we will not tag it''' - if not element.get(attribute_name) and type(element) == etree._Element: - element.set(attribute_name, parent_attribute) - - for child_element in element: - propogate_downward_tag(child_element, attribute_name, parent_attribute) - else: - #This element would have already been found by Xpath, so we return - #for now and trust that this element will get its turn to propogate - #to its children later. - return - - -def course_xml_process(tree): - ''' Do basic pre-processing of an XML tree. Assign IDs to all - items without. Propagate due dates, grace periods, etc. to child - items. - ''' - replace_custom_tags(tree) - id_tag(tree) - propogate_downward_tag(tree, "due") - propogate_downward_tag(tree, "graded") - propogate_downward_tag(tree, "graceperiod") - propogate_downward_tag(tree, "showanswer") - propogate_downward_tag(tree, "rerandomize") - return tree - - -def toc_from_xml(dom, active_chapter, active_section): - ''' - Create a table of contents from the course xml. - - Return format: - [ {'name': name, 'sections': SECTIONS, 'active': bool}, ... ] - - where SECTIONS is a list - [ {'name': name, 'format': format, 'due': due, 'active' : bool}, ...] - - active is set for the section and chapter corresponding to the passed - parameters. Everything else comes from the xml, or defaults to "". - - chapters with name 'hidden' are skipped. - ''' - name = dom.xpath('//course/@name')[0] - - chapters = dom.xpath('//course[@name=$name]/chapter', name=name) - ch = list() - for c in chapters: - if c.get('name') == 'hidden': - continue - sections = list() - for s in dom.xpath('//course[@name=$name]/chapter[@name=$chname]/section', - name=name, chname=c.get('name')): - - format = s.get("subtitle") if s.get("subtitle") else s.get("format") or "" - active = (c.get("name") == active_chapter and - s.get("name") == active_section) - - sections.append({'name': s.get("name") or "", - 'format': format, - 'due': s.get("due") or "", - 'active': active}) - - ch.append({'name': c.get("name"), - 'sections': sections, - 'active': c.get("name") == active_chapter}) - return ch - - -def replace_custom_tags_dir(tree, dir): - ''' - Process tree to replace all custom tags defined in dir. - ''' - tags = os.listdir(dir) - for tag in tags: - for element in tree.iter(tag): - element.tag = 'customtag' - impl = etree.SubElement(element, 'impl') - impl.text = tag - -def parse_course_file(filename, options, namespace): - ''' - Parse a course file with the given options, and return the resulting - xml tree object. - - Options should be a dictionary including keys - 'dev_content': bool, - 'groups' : [list, of, user, groups] - - namespace is used to in searching for the file. Could be e.g. 'course', - 'sections'. - ''' - xml = etree.XML(render_to_string(filename, options, namespace=namespace)) - return course_xml_process(xml) - - -def get_section(section, options, dirname): - ''' - Given the name of a section, an options dict containing keys - 'dev_content' and 'groups', and a directory to look in, - returns the xml tree for the section, or None if there's no - such section. - ''' - filename = section + ".xml" - - if filename not in os.listdir(dirname): - log.error(filename + " not in " + str(os.listdir(dirname))) - return None - - tree = parse_course_file(filename, options, namespace='sections') - return tree - - -def get_module(tree, module, id_tag, module_id, sections_dirname, options): - ''' - Given the xml tree of the course, get the xml string for a module - with the specified module type, id_tag, module_id. Looks in - sections_dirname for sections. - - id_tag -- use id_tag if the place the module stores its id is not 'id' - ''' - # Sanitize input - if not module.isalnum(): - raise Exception("Module is not alphanumeric") - - if not module_id.isalnum(): - raise Exception("Module ID is not alphanumeric") - - # Generate search - xpath_search='//{module}[(@{id_tag} = "{id}") or (@id = "{id}")]'.format( - module=module, - id_tag=id_tag, - id=module_id) - - - result_set = tree.xpath(xpath_search) - if len(result_set) < 1: - # Not found in main tree. Let's look in the section files. - section_list = (s[:-4] for s in os.listdir(sections_dirname) if s.endswith('.xml')) - for section in section_list: - try: - s = get_section(section, options, sections_dirname) - except etree.XMLSyntaxError: - ex = sys.exc_info() - raise ContentException("Malformed XML in " + section + - "(" + str(ex[1].msg) + ")") - result_set = s.xpath(xpath_search) - if len(result_set) != 0: - break - - if len(result_set) > 1: - log.error("WARNING: Potentially malformed course file", module, module_id) - - if len(result_set)==0: - log.error('[content_parser.get_module] cannot find %s in course.xml tree', - xpath_search) - log.error('tree = %s' % etree.tostring(tree, pretty_print=True)) - return None - - # log.debug('[courseware.content_parser.module_xml] found %s' % result_set) - - return etree.tostring(result_set[0]) - - - - - - -# ==== All Django-specific code below ============================================= - -def user_groups(user): - if not user.is_authenticated(): - return [] - - # TODO: Rewrite in Django - key = 'user_group_names_{user.id}'.format(user=user) - cache_expiration = 60 * 60 # one hour - - # Kill caching on dev machines -- we switch groups a lot - group_names = cache.get(key) - - if group_names is None: - group_names = [u.name for u in UserTestGroup.objects.filter(users=user)] - cache.set(key, group_names, cache_expiration) - - return group_names - - -def get_options(user): - return {'dev_content': settings.DEV_CONTENT, - 'groups': user_groups(user)} - - -def replace_custom_tags(tree): - '''Replace custom tags defined in our custom_tags dir''' - replace_custom_tags_dir(tree, settings.DATA_DIR+'/custom_tags') - - -def course_file(user, coursename=None): - ''' Given a user, return an xml tree object for the course file. - - Handles getting the right file, and processing it depending on the - groups the user is in. Does caching of the xml strings. - ''' - - if user.is_authenticated(): - # use user.profile_cache.courseware? - filename = UserProfile.objects.get(user=user).courseware - else: - filename = 'guest_course.xml' - - # if a specific course is specified, then use multicourse to get - # the right path to the course XML directory - if coursename and settings.ENABLE_MULTICOURSE: - xp = multicourse_settings.get_course_xmlpath(coursename) - filename = xp + filename # prefix the filename with the path - - groups = user_groups(user) - options = get_options(user) - - # Try the cache... - cache_key = "{0}_processed?dev_content:{1}&groups:{2}".format( - filename, - options['dev_content'], - sorted(groups)) - - if "dev" in settings.DEFAULT_GROUPS: - tree_string = None - else: - tree_string = cache.get(cache_key) - - if tree_string: - tree = etree.XML(tree_string) - else: - tree = parse_course_file(filename, options, namespace='course') - # Cache it - tree_string = etree.tostring(tree) - cache.set(cache_key, tree_string, 60) - - return tree - - -def sections_dir(coursename=None): - ''' Get directory where sections information is stored. - ''' - # if a specific course is specified, then use multicourse to get the - # right path to the course XML directory - xp = '' - if coursename and settings.ENABLE_MULTICOURSE: - xp = multicourse_settings.get_course_xmlpath(coursename) - - return settings.DATA_DIR + xp + '/sections/' - - - -def section_file(user, section, coursename=None): - ''' - Given a user and the name of a section, return that section. - This is done specific to each course. - - Returns the xml tree for the section, or None if there's no such section. - ''' - dirname = sections_dir(coursename) - - - return get_section(section, options, dirname) - - -def module_xml(user, module, id_tag, module_id, coursename=None): - ''' Get XML for a module based on module and module_id. Assumes - module occurs once in courseware XML file or hidden section. - ''' - tree = course_file(user, coursename) - sdirname = sections_dir(coursename) - options = get_options(user) - - return get_module(tree, module, id_tag, module_id, sdirname, options) - diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index b31f4421cf..66aff08dca 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -1,9 +1,5 @@ -from lxml import etree import random -import imp import logging -import sys -import types from django.conf import settings @@ -11,134 +7,119 @@ from courseware.course_settings import course_settings from xmodule import graders from xmodule.graders import Score from models import StudentModule -import courseware.content_parser as content_parser -import xmodule _log = logging.getLogger("mitx.courseware") -def grade_sheet(student,coursename=None): +def grade_sheet(student, course, student_module_cache): """ This pulls a summary of all problems in the course. It returns a dictionary with two datastructures: - + - courseware_summary is a summary of all sections with problems in the course. It is organized as an array of chapters, each containing an array of sections, each containing an array of scores. This contains information for graded and ungraded problems, and is good for displaying a course summary with due dates, etc. - + - grade_summary is the output from the course grader. More information on the format is in the docstring for CourseGrader. + + Arguments: + student: A User object for the student to grade + course: An XModule containing the course to grade + student_module_cache: A StudentModuleCache initialized with all instance_modules for the student """ - dom=content_parser.course_file(student,coursename) - course = dom.xpath('//course/@name')[0] - xmlChapters = dom.xpath('//course[@name=$course]/chapter', course=course) - - responses=StudentModule.objects.filter(student=student) - response_by_id = {} - for response in responses: - response_by_id[response.module_id] = response - - totaled_scores = {} - chapters=[] - for c in xmlChapters: + chapters = [] + for c in course.get_children(): sections = [] - chname=c.get('name') - - - for s in dom.xpath('//course[@name=$course]/chapter[@name=$chname]/section', - course=course, chname=chname): - problems=dom.xpath('//course[@name=$course]/chapter[@name=$chname]/section[@name=$section]//problem', - course=course, chname=chname, section=s.get('name')) + for s in c.get_children(): + def yield_descendents(module): + yield module + for child in module.get_display_items(): + for module in yield_descendents(child): + yield module - graded = True if s.get('graded') == "true" else False - scores=[] - if len(problems)>0: - for p in problems: - (correct,total) = get_score(student, p, response_by_id, coursename=coursename) - - if settings.GENERATE_PROFILE_SCORES: - if total > 1: - correct = random.randrange( max(total-2, 1) , total + 1 ) - else: - correct = total - - if not total > 0: - #We simply cannot grade a problem that is 12/0, because we might need it as a percentage - graded = False - scores.append( Score(correct,total, graded, p.get("name")) ) + graded = s.metadata.get('graded', False) + scores = [] + for module in yield_descendents(s): + (correct, total) = get_score(student, module, student_module_cache) - section_total, graded_total = graders.aggregate_scores(scores, s.get("name")) - #Add the graded total to totaled_scores - format = s.get('format', "") - subtitle = s.get('subtitle', format) - if format and graded_total[1] > 0: - format_scores = totaled_scores.get(format, []) - format_scores.append( graded_total ) - totaled_scores[ format ] = format_scores + if correct is None and total is None: + continue - section_score={'section':s.get("name"), - 'scores':scores, - 'section_total' : section_total, - 'format' : format, - 'subtitle' : subtitle, - 'due' : s.get("due") or "", - 'graded' : graded, - } - sections.append(section_score) + if settings.GENERATE_PROFILE_SCORES: + if total > 1: + correct = random.randrange(max(total - 2, 1), total + 1) + else: + correct = total + + if not total > 0: + #We simply cannot grade a problem that is 12/0, because we might need it as a percentage + graded = False + + scores.append(Score(correct, total, graded, module.metadata.get('display_name'))) + + section_total, graded_total = graders.aggregate_scores(scores, s.metadata.get('display_name')) + #Add the graded total to totaled_scores + format = s.metadata.get('format', "") + if format and graded_total.possible > 0: + format_scores = totaled_scores.get(format, []) + format_scores.append(graded_total) + totaled_scores[format] = format_scores + + sections.append({ + 'section': s.metadata.get('display_name'), + 'scores': scores, + 'section_total': section_total, + 'format': format, + 'due': s.metadata.get("due", ""), + 'graded': graded, + }) + + chapters.append({'course': course.metadata.get('display_name'), + 'chapter': c.metadata.get('display_name'), + 'sections': sections}) - chapters.append({'course':course, - 'chapter' : c.get("name"), - 'sections' : sections,}) - - grader = course_settings.GRADER grade_summary = grader.grade(totaled_scores) - - return {'courseware_summary' : chapters, - 'grade_summary' : grade_summary} -def get_score(user, problem, cache, coursename=None): - ## HACK: assumes max score is fixed per problem - id = problem.get('id') + return {'courseware_summary': chapters, + 'grade_summary': grade_summary} + + +def get_score(user, problem, cache): + """ + Return the score for a user on a problem + + user: a Student object + problem: an XModule + cache: A StudentModuleCache + """ correct = 0.0 - - # If the ID is not in the cache, add the item - if id not in cache: - module = StudentModule(module_type = 'problem', # TODO: Move into StudentModule.__init__? - module_id = id, - student = user, - state = None, - grade = 0, - max_grade = None, - done = 'i') - cache[id] = module - # Grab the # correct from cache - if id in cache: - response = cache[id] - if response.grade!=None: - correct=float(response.grade) - - # Grab max grade from cache, or if it doesn't exist, compute and save to DB - if id in cache and response.max_grade is not None: - total = response.max_grade - else: - ## HACK 1: We shouldn't specifically reference capa_module - ## HACK 2: Backwards-compatibility: This should be written when a grade is saved, and removed from the system - # TODO: These are no longer correct params for I4xSystem -- figure out what this code - # does, clean it up. - # from module_render import I4xSystem - # system = I4xSystem(None, None, None, coursename=coursename) - # total=float(xmodule.capa_module.Module(system, etree.tostring(problem), "id").max_score()) - # response.max_grade = total - # response.save() - total = 1 - # For a temporary fix, we just assume a problem is worth 1 point if we haven't seen it before. This is totally incorrect - - #Now we re-weight the problem, if specified - weight = problem.get("weight", None) - if weight: - weight = float(weight) - correct = correct * weight / total - total = weight + # If the ID is not in the cache, add the item + instance_module = cache.lookup(problem.category, problem.id) + if instance_module is None: + instance_module = StudentModule(module_type=problem.category, + module_state_key=problem.id, + student=user, + state=None, + grade=0, + max_grade=problem.max_score(), + done='i') + cache.append(instance_module) + instance_module.save() + + # If this problem is ungraded/ungradable, bail + if instance_module.max_grade is None: + return (None, None) + + correct = instance_module.grade if instance_module.grade is not None else 0 + total = instance_module.max_grade + + if correct is not None and total is not None: + #Now we re-weight the problem, if specified + weight = getattr(problem, 'weight', 1) + if weight != 1: + correct = correct * weight / total + total = weight return (correct, total) diff --git a/lms/djangoapps/courseware/management/commands/check_course.py b/lms/djangoapps/courseware/management/commands/check_course.py index 8af0c5d4be..afc7e47857 100644 --- a/lms/djangoapps/courseware/management/commands/check_course.py +++ b/lms/djangoapps/courseware/management/commands/check_course.py @@ -6,50 +6,36 @@ from django.core.management.base import BaseCommand from django.conf import settings from django.contrib.auth.models import User -from courseware.content_parser import course_file -import courseware.module_render import xmodule import mitxmako.middleware as middleware middleware.MakoMiddleware() +from keystore.django import keystore +from courseware.models import StudentModuleCache +from courseware.module_render import get_module -def check_names(user, course): - ''' - Complain if any problems have non alphanumeric names. - TODO (vshnayder): there are some in 6.002x that don't. Is that actually a problem? - ''' - all_ok = True - print "Confirming all problems have alphanumeric names" - for problem in course.xpath('//problem'): - filename = problem.get('filename') - if not filename.isalnum(): - print "==============> Invalid (non-alphanumeric) filename", filename - all_ok = False - return all_ok -def check_rendering(user, course): +def check_rendering(module): '''Check that all modules render''' all_ok = True print "Confirming all modules render. Nothing should print during this step. " - for module in course.xpath('//problem|//html|//video|//vertical|//sequential|/tab'): - module_class = xmodule.modx_modules[module.tag] - # TODO: Abstract this out in render_module.py - try: - module_class(etree.tostring(module), - module.get('id'), - ajax_url='', - state=None, - track_function = lambda x,y,z:None, - render_function = lambda x: {'content':'','type':'video'}) + + def _check_module(module): + try: + module.get_html() except Exception as ex: - print "==============> Error in ", etree.tostring(module) + print "==============> Error in ", module.id print "" print ex all_ok = False + for child in module.get_children(): + _check_module(child) + _check_module(module) print "Module render check finished" return all_ok -def check_sections(user, course): + +def check_sections(course): all_ok = True sections_dir = settings.DATA_DIR + "/sections" print "Checking that all sections exist and parse properly" @@ -69,11 +55,13 @@ def check_sections(user, course): all_ok = False print "checked all sections" else: - print "Skipping check of include files -- no section includes dir ("+sections_dir+")" + print "Skipping check of include files -- no section includes dir (" + sections_dir + ")" return all_ok + class Command(BaseCommand): help = "Does basic validity tests on course.xml." + def handle(self, *args, **options): all_ok = True @@ -86,22 +74,25 @@ class Command(BaseCommand): sample_user = User.objects.all()[0] - print "Attempting to load courseware" - course = course_file(sample_user) - to_run = [check_names, - # TODO (vshnayder) : make check_rendering work (use module_render.py), - # turn it on - # check_rendering, - check_sections, - ] + # TODO (cpennington): Get coursename in a legitimate way + course_location = 'i4x://edx/6002xs12/course/6.002_Spring_2012' + student_module_cache = StudentModuleCache(sample_user, keystore().get_item(course_location)) + (course, _, _, _) = get_module(sample_user, None, course_location, student_module_cache) + + to_run = [ + #TODO (vshnayder) : make check_rendering work (use module_render.py), + # turn it on + check_rendering, + check_sections, + ] for check in to_run: - all_ok = check(sample_user, course) and all_ok + all_ok = check(course) and all_ok # TODO: print "Checking course properly annotated with preprocess.py" - + if all_ok: print 'Courseware passes all checks!' - else: + else: print "Courseware fails some checks" diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index a97b09ae2b..f0bd8dc17e 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -13,7 +13,6 @@ ASSUMPTIONS: modules have unique IDs, even across different module_types """ from django.db import models -from django.db.models.signals import post_save, post_delete #from django.core.cache import cache from django.contrib.auth.models import User @@ -21,72 +20,106 @@ from django.contrib.auth.models import User #CACHE_TIMEOUT = 60 * 60 * 4 # Set the cache timeout to be four hours + class StudentModule(models.Model): # For a homework problem, contains a JSON # object consisting of state - MODULE_TYPES = (('problem','problem'), - ('video','video'), - ('html','html'), + MODULE_TYPES = (('problem', 'problem'), + ('video', 'video'), + ('html', 'html'), ) ## These three are the key for the object module_type = models.CharField(max_length=32, choices=MODULE_TYPES, default='problem', db_index=True) - module_id = models.CharField(max_length=255, db_index=True) # Filename for homeworks, etc. + + # Key used to share state. By default, this is the module_id, + # but for abtests and the like, this can be set to a shared value + # for many instances of the module. + # Filename for homeworks, etc. + module_state_key = models.CharField(max_length=255, db_index=True, db_column='module_id') student = models.ForeignKey(User, db_index=True) + class Meta: - unique_together = (('student', 'module_id'),) + unique_together = (('student', 'module_state_key'),) ## Internal state of the object state = models.TextField(null=True, blank=True) - ## Grade, and are we done? + ## Grade, and are we done? grade = models.FloatField(null=True, blank=True, db_index=True) max_grade = models.FloatField(null=True, blank=True) - DONE_TYPES = (('na','NOT_APPLICABLE'), - ('f','FINISHED'), - ('i','INCOMPLETE'), + DONE_TYPES = (('na', 'NOT_APPLICABLE'), + ('f', 'FINISHED'), + ('i', 'INCOMPLETE'), ) done = models.CharField(max_length=8, choices=DONE_TYPES, default='na', db_index=True) - # DONE_TYPES = (('done','DONE'), # Finished - # ('incomplete','NOTDONE'), # Not finished - # ('na','NA')) # Not applicable (e.g. vertical) - # done = models.CharField(max_length=16, choices=DONE_TYPES) - created = models.DateTimeField(auto_now_add=True, db_index=True) modified = models.DateTimeField(auto_now=True, db_index=True) def __unicode__(self): - return self.module_type+'/'+self.student.username+"/"+self.module_id+'/'+str(self.state)[:20] - - # @classmethod - # def get_with_caching(cls, student, module_id): - # k = cls.key_for(student, module_id) - # student_module = cache.get(k) - # if student_module is None: - # student_module = StudentModule.objects.filter(student=student, - # module_id=module_id)[0] - # # It's possible it really doesn't exist... - # if student_module is not None: - # cache.set(k, student_module, CACHE_TIMEOUT) - - # return student_module - - @classmethod - def key_for(cls, student, module_id): - return "StudentModule-student_id:{0};module_id:{1}".format(student.id, module_id) + return '/'.join([self.module_type, self.student.username, self.module_state_key, str(self.state)[:20]]) -# def clear_cache_by_student_and_module_id(sender, instance, *args, **kwargs): -# k = sender.key_for(instance.student, instance.module_id) -# cache.delete(k) - -# def update_cache_by_student_and_module_id(sender, instance, *args, **kwargs): -# k = sender.key_for(instance.student, instance.module_id) -# cache.set(k, instance, CACHE_TIMEOUT) +# TODO (cpennington): Remove these once the LMS switches to using XModuleDescriptors -#post_save.connect(update_cache_by_student_and_module_id, sender=StudentModule, weak=False) -#post_delete.connect(clear_cache_by_student_and_module_id, sender=StudentModule, weak=False) -#cache_model(StudentModule) +class StudentModuleCache(object): + """ + A cache of StudentModules for a specific student + """ + def __init__(self, user, descriptor, depth=None): + ''' + Find any StudentModule objects that are needed by any child modules of the + supplied descriptor. Avoids making multiple queries to the database + ''' + if user.is_authenticated(): + module_ids = self._get_module_state_keys(descriptor, depth) + # This works around a limitation in sqlite3 on the number of parameters + # that can be put into a single query + self.cache = [] + chunk_size = 500 + for id_chunk in [module_ids[i:i+chunk_size] for i in xrange(0, len(module_ids), chunk_size)]: + self.cache.extend(StudentModule.objects.filter( + student=user, + module_state_key__in=id_chunk) + ) + + else: + self.cache = [] + + def _get_module_state_keys(self, descriptor, depth): + ''' + Get a list of the state_keys needed for StudentModules + required for this chunk of module xml + ''' + keys = [descriptor.location.url()] + + shared_state_key = getattr(descriptor, 'shared_state_key', None) + if shared_state_key is not None: + keys.append(shared_state_key) + + if depth is None or depth > 0: + new_depth = depth - 1 if depth is not None else depth + + for child in descriptor.get_children(): + keys.extend(self._get_module_state_keys(child, new_depth)) + + return keys + + def lookup(self, module_type, module_state_key): + ''' + Look for a student module with the given type and id in the cache. + + cache -- list of student modules + + returns first found object, or None + ''' + for o in self.cache: + if o.module_type == module_type and o.module_state_key == module_state_key: + return o + return None + + def append(self, student_module): + self.cache.append(student_module) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 3a6fcbfb45..5119cc2910 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -1,30 +1,22 @@ import json import logging -from lxml import etree - +from django.conf import settings from django.http import Http404 from django.http import HttpResponse -from django.shortcuts import redirect +from lxml import etree -from fs.osfs import OSFS - -from django.conf import settings -from mitxmako.shortcuts import render_to_string, render_to_response - -from models import StudentModule -from multicourse import multicourse_settings -from util.views import accepts - -import courseware.content_parser as content_parser -import xmodule +from keystore.django import keystore +from mitxmako.shortcuts import render_to_string +from models import StudentModule, StudentModuleCache log = logging.getLogger("mitx.courseware") + class I4xSystem(object): ''' - This is an abstraction such that x_modules can function independent - of the courseware (e.g. import into other types of courseware, LMS, + This is an abstraction such that x_modules can function independent + of the courseware (e.g. import into other types of courseware, LMS, or if we want to have a sandbox server for user-contributed content) I4xSystem objects are passed to x_modules to provide access to system @@ -33,8 +25,8 @@ class I4xSystem(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, render_function, - module_from_xml, render_template, request=None, + def __init__(self, ajax_url, track_function, + get_module, render_template, user=None, filestore=None): ''' Create a closure around the system environment. @@ -44,39 +36,28 @@ class I4xSystem(object): or otherwise tracking the event. TODO: Not used, and has inconsistent args in different files. Update or remove. - module_from_xml - function that takes (module_xml) and returns a corresponding + get_module - function that takes (location) and returns a corresponding module instance object. - render_function - function that takes (module_xml) and renders it, - returning a dictionary with a context for rendering the - module to html. Dictionary will contain keys 'content' - and 'type'. render_template - a function that takes (template_file, context), and returns rendered html. - request - the request in progress + user - The user to base the seed off of for this request filestore - A filestore ojbect. Defaults to an instance of OSFS based at settings.DATA_DIR. ''' self.ajax_url = ajax_url self.track_function = track_function - if not filestore: - self.filestore = OSFS(settings.DATA_DIR) - else: - self.filestore = filestore - if settings.DEBUG: - log.info("[courseware.module_render.I4xSystem] filestore path = %s", - filestore) - self.module_from_xml = module_from_xml - self.render_function = render_function + self.filestore = filestore + self.get_module = get_module self.render_template = render_template self.exception404 = Http404 self.DEBUG = settings.DEBUG - self.id = request.user.id if request is not None else 0 + self.seed = user.id if user is not None else 0 def get(self, attr): ''' provide uniform access to attributes (like etree).''' return self.__dict__.get(attr) - - def set(self,attr,val): + + def set(self, attr, val): '''provide uniform access to attributes (like etree)''' self.__dict__[attr] = val @@ -86,21 +67,9 @@ class I4xSystem(object): def __str__(self): return str(self.__dict__) -def smod_cache_lookup(cache, module_type, module_id): - ''' - Look for a student module with the given type and id in the cache. - - cache -- list of student modules - - returns first found object, or None - ''' - for o in cache: - if o.module_type == module_type and o.module_id == module_id: - return o - return None def make_track_function(request): - ''' + ''' Make a tracking function that logs what happened. For use in I4xSystem. ''' @@ -110,8 +79,9 @@ def make_track_function(request): return track.views.server_track(request, event_type, event, page='x_module') return f + def grade_histogram(module_id): - ''' Print out a histogram of grades on a given problem. + ''' Print out a histogram of grades on a given problem. Part of staff member debug info. ''' from django.db import connection @@ -137,13 +107,87 @@ def make_module_from_xml_fn(user, request, student_module_cache, position): def module_from_xml(xml): '''Modules need a way to convert xml to instance objects. Pass the rest of the context through.''' - (instance, sm, module_type) = get_module( + (instance, _, _, _) = get_module( user, request, xml, student_module_cache, position) return instance return module_from_xml -def get_module(user, request, module_xml, student_module_cache, position=None): +def toc_for_course(user, request, course_location, active_chapter, active_section): + ''' + Create a table of contents from the module store + + Return format: + [ {'name': name, 'sections': SECTIONS, 'active': bool}, ... ] + + where SECTIONS is a list + [ {'name': name, 'format': format, 'due': due, 'active' : bool}, ...] + + active is set for the section and chapter corresponding to the passed + parameters. Everything else comes from the xml, or defaults to "". + + chapters with name 'hidden' are skipped. + ''' + + student_module_cache = StudentModuleCache(user, keystore().get_item(course_location), depth=2) + (course, _, _, _) = get_module(user, request, course_location, student_module_cache) + + chapters = list() + for chapter in course.get_display_items(): + sections = list() + for section in chapter.get_display_items(): + + active = (chapter.metadata.get('display_name') == active_chapter and + section.metadata.get('display_name') == active_section) + + sections.append({'name': section.metadata.get('display_name'), + 'format': section.metadata.get('format', ''), + 'due': section.metadata.get('due', ''), + 'active': active}) + + chapters.append({'name': chapter.metadata.get('display_name'), + 'sections': sections, + 'active': chapter.metadata.get('display_name') == active_chapter}) + return chapters + + +def get_section(course, chapter, section): + """ + Returns the xmodule descriptor for the name course > chapter > section, + or None if this doesn't specify a valid section + + course: Course url + chapter: Chapter name + section: Section name + """ + try: + course_module = keystore().get_item(course) + except: + log.exception("Unable to load course_module") + return None + + if course_module is None: + return + + chapter_module = None + for _chapter in course_module.get_children(): + if _chapter.metadata.get('display_name') == chapter: + chapter_module = _chapter + break + + if chapter_module is None: + return + + section_module = None + for _section in chapter_module.get_children(): + if _section.metadata.get('display_name') == section: + section_module = _section + break + + return section_module + + +def get_module(user, request, location, student_module_cache, position=None): ''' Get an instance of the xmodule class corresponding to module_xml, setting the state based on an existing StudentModule, or creating one if none exists. @@ -152,196 +196,122 @@ def get_module(user, request, module_xml, student_module_cache, position=None): - user : current django User - request : current django HTTPrequest - module_xml : lxml etree of xml subtree for the requested module - - student_module_cache : list of StudentModule objects, one of which may - match this module type and id - - position : extra information from URL for user-specified + - student_module_cache : a StudentModuleCache + - position : extra information from URL for user-specified position within module Returns: - - a tuple (xmodule instance, student module, module type). + - a tuple (xmodule instance, instance_module, shared_module, module type). + instance_module is a StudentModule specific to this module for this student + shared_module is a StudentModule specific to all modules with the same 'shared_state_key' attribute, or None if the module doesn't elect to share state ''' - module_type = module_xml.tag - module_class = xmodule.get_module_class(module_type) - module_id = module_xml.get('id') + descriptor = keystore().get_item(location) - # Grab xmodule state from StudentModule cache - smod = smod_cache_lookup(student_module_cache, module_type, module_id) - state = smod.state if smod else None - - # get coursename if present in request - coursename = multicourse_settings.get_coursename_from_request(request) - - if coursename and settings.ENABLE_MULTICOURSE: - # path to XML for the course - xp = multicourse_settings.get_course_xmlpath(coursename) - data_root = settings.DATA_DIR + xp + instance_module = student_module_cache.lookup(descriptor.category, descriptor.location.url()) + shared_state_key = getattr(descriptor, 'shared_state_key', None) + if shared_state_key is not None: + shared_module = student_module_cache.lookup(descriptor.category, shared_state_key) else: - data_root = settings.DATA_DIR + shared_module = None + + instance_state = instance_module.state if instance_module is not None else None + shared_state = shared_module.state if shared_module is not None else None # Setup system context for module instance - ajax_url = settings.MITX_ROOT_URL + '/modx/' + module_type + '/' + module_id + '/' + ajax_url = settings.MITX_ROOT_URL + '/modx/' + descriptor.location.url() + '/' - module_from_xml = make_module_from_xml_fn( - user, request, student_module_cache, position) - - system = I4xSystem(track_function = make_track_function(request), - render_function = lambda xml: render_x_module( - user, request, xml, student_module_cache, position), - render_template = render_to_string, - ajax_url = ajax_url, - request = request, - filestore = OSFS(data_root), - module_from_xml = module_from_xml, + def _get_module(location): + (module, _, _, _) = get_module(user, request, location, student_module_cache, position) + return module + + system = I4xSystem(track_function=make_track_function(request), + render_template=render_to_string, + ajax_url=ajax_url, + # TODO (cpennington): Figure out how to share info between systems + filestore=descriptor.system.resources_fs, + get_module=_get_module, + user=user, ) # pass position specified in URL to module through I4xSystem - system.set('position', position) - instance = module_class(system, - etree.tostring(module_xml), - module_id, - state=state) + system.set('position', position) + + module = descriptor.xmodule_constructor(system)(instance_state, shared_state) + + if settings.MITX_FEATURES.get('DISPLAY_HISTOGRAMS_TO_STAFF') and user.is_staff: + module = add_histogram(module) # If StudentModule for this instance wasn't already in the database, # and this isn't a guest user, create it. - if not smod and user.is_authenticated(): - smod = StudentModule(student=user, module_type = module_type, - module_id=module_id, state=instance.get_state()) - smod.save() - # Add to cache. The caller and the system context have references - # to it, so the change persists past the return - student_module_cache.append(smod) + if user.is_authenticated(): + if not instance_module: + instance_module = StudentModule( + student=user, + module_type=descriptor.category, + module_state_key=module.id, + state=module.get_instance_state(), + max_grade=module.max_score()) + instance_module.save() + # Add to cache. The caller and the system context have references + # to it, so the change persists past the return + student_module_cache.append(instance_module) + if not shared_module and shared_state_key is not None: + shared_module = StudentModule( + student=user, + module_type=descriptor.category, + module_state_key=shared_state_key, + state=module.get_shared_state()) + shared_module.save() + student_module_cache.append(shared_module) - return (instance, smod, module_type) + return (module, instance_module, shared_module, descriptor.category) -def render_x_module(user, request, module_xml, student_module_cache, position=None): - ''' Generic module for extensions. This renders to HTML. - modules include sequential, vertical, problem, video, html +def add_histogram(module): + original_get_html = module.get_html - Note that modules can recurse. problems, video, html, can be inside sequential or vertical. - - Arguments: - - - user : current django User - - request : current django HTTPrequest - - module_xml : lxml etree of xml subtree for the current module - - student_module_cache : list of StudentModule objects, one of which may match this module type and id - - position : extra information from URL for user-specified position within module - - Returns: - - - dict which is context for HTML rendering of the specified module. Will have - key 'content', and will have 'type' key if passed a valid module. - ''' - if module_xml is None : - return {"content": ""} - - (instance, smod, module_type) = get_module( - user, request, module_xml, student_module_cache, position) - - content = instance.get_html() - - # special extra information about each problem, only for users who are staff - if settings.MITX_FEATURES.get('DISPLAY_HISTOGRAMS_TO_STAFF') and user.is_staff: - module_id = module_xml.get('id') + def get_html(): + module_id = module.id histogram = grade_histogram(module_id) render_histogram = len(histogram) > 0 - staff_context = {'xml': etree.tostring(module_xml), - 'module_id': module_id, + staff_context = {'definition': json.dumps(module.definition, indent=4), + 'metadata': json.dumps(module.metadata, indent=4), + 'element_id': module.location.html_id(), 'histogram': json.dumps(histogram), - 'render_histogram': render_histogram} - content += render_to_string("staff_problem_info.html", staff_context) + 'render_histogram': render_histogram, + 'module_content': original_get_html()} + return render_to_string("staff_problem_info.html", staff_context) - context = {'content': content, 'type': module_type} - return context + module.get_html = get_html + return module -def modx_dispatch(request, module=None, dispatch=None, id=None): + +def modx_dispatch(request, dispatch=None, id=None): ''' Generic view for extensions. This is where AJAX calls go. Arguments: - request -- the django request. - - module -- the type of the module, as used in the course configuration xml. - e.g. 'problem', 'video', etc - dispatch -- the command string to pass through to the module's handle_ajax call (e.g. 'problem_reset'). If this string contains '?', only pass through the part before the first '?'. - - id -- the module id. Used to look up the student module. - e.g. filenamexformularesponse + - id -- the module id. Used to look up the XModule instance ''' # ''' (fix emacs broken parsing) - if not request.user.is_authenticated(): - return redirect('/') - - # python concats adjacent strings - error_msg = ("We're sorry, this module is temporarily unavailable. " - "Our staff is working to fix it as soon as possible") - - - # Grab the student information for the module from the database - s = StudentModule.objects.filter(student=request.user, - module_id=id) - - if s is None or len(s) == 0: - log.debug("Couldn't find module '%s' for user '%s' and id '%s'", - module, request.user, id) - raise Http404 - s = s[0] - - oldgrade = s.grade - oldstate = s.state # If there are arguments, get rid of them dispatch, _, _ = dispatch.partition('?') - ajax_url = '{root}/modx/{module}/{id}'.format(root = settings.MITX_ROOT_URL, - module=module, id=id) - coursename = multicourse_settings.get_coursename_from_request(request) - if coursename and settings.ENABLE_MULTICOURSE: - xp = multicourse_settings.get_course_xmlpath(coursename) - data_root = settings.DATA_DIR + xp - else: - data_root = settings.DATA_DIR + student_module_cache = StudentModuleCache(request.user, keystore().get_item(id)) + instance, instance_module, shared_module, module_type = get_module(request.user, request, id, student_module_cache) - # Grab the XML corresponding to the request from course.xml - try: - xml = content_parser.module_xml(request.user, module, 'id', id, coursename) - except: - log.exception( - "Unable to load module during ajax call. module=%s, dispatch=%s, id=%s", - module, dispatch, id) - if accepts(request, 'text/html'): - return render_to_response("module-error.html", {}) - else: - response = HttpResponse(json.dumps({'success': error_msg})) - return response + if instance_module is None: + log.debug("Couldn't find module '%s' for user '%s'", + id, request.user) + raise Http404 - # TODO: This doesn't have a cache of child student modules. Just - # passing the current one. If ajax calls end up needing children, - # this won't work (but fixing it may cause performance issues...) - # Figure out :) - module_from_xml = make_module_from_xml_fn( - request.user, request, [s], None) - - # Create the module - system = I4xSystem(track_function = make_track_function(request), - render_function = None, - module_from_xml = module_from_xml, - render_template = render_to_string, - ajax_url = ajax_url, - request = request, - filestore = OSFS(data_root), - ) - - try: - module_class = xmodule.get_module_class(module) - instance = module_class(system, xml, id, state=oldstate) - except: - log.exception("Unable to load module instance during ajax call") - if accepts(request, 'text/html'): - return render_to_response("module-error.html", {}) - else: - response = HttpResponse(json.dumps({'success': error_msg})) - return response + oldgrade = instance_module.grade + old_instance_state = instance_module.state + old_shared_state = shared_module.state if shared_module is not None else None # Let the module handle the AJAX try: @@ -351,10 +321,16 @@ def modx_dispatch(request, module=None, dispatch=None, id=None): raise # Save the state back to the database - s.state = instance.get_state() - if instance.get_score(): - s.grade = instance.get_score()['score'] - if s.grade != oldgrade or s.state != oldstate: - s.save() + instance_module.state = instance.get_instance_state() + if instance.get_score(): + instance_module.grade = instance.get_score()['score'] + if instance_module.grade != oldgrade or instance_module.state != old_instance_state: + instance_module.save() + + if shared_module is not None: + shared_module.state = instance.get_shared_state() + if shared_module.state != old_shared_state: + shared_module.save() + # Return whatever the module wanted to return to the client/caller return HttpResponse(ajax_return) diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 5cbbe18d7d..48e9bcc795 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -1,8 +1,6 @@ import logging import urllib -from fs.osfs import OSFS - from django.conf import settings from django.core.context_processors import csrf from django.contrib.auth.models import User @@ -16,40 +14,73 @@ from django.views.decorators.cache import cache_control from lxml import etree -from module_render import render_x_module, make_track_function, I4xSystem -from models import StudentModule +from module_render import toc_for_course, get_module, get_section +from models import StudentModuleCache from student.models import UserProfile from multicourse import multicourse_settings -import xmodule +from keystore.django import keystore -import courseware.content_parser as content_parser - -import courseware.grades as grades +from util.cache import cache +from student.models import UserTestGroup +from courseware import grades log = logging.getLogger("mitx.courseware") etree.set_default_parser(etree.XMLParser(dtd_validation=False, load_dtd=False, - remove_comments = True)) + remove_comments=True)) + +template_imports = {'urllib': urllib} + + +def user_groups(user): + if not user.is_authenticated(): + return [] + + # TODO: Rewrite in Django + key = 'user_group_names_{user.id}'.format(user=user) + cache_expiration = 60 * 60 # one hour + + # Kill caching on dev machines -- we switch groups a lot + group_names = cache.get(key) + + if group_names is None: + group_names = [u.name for u in UserTestGroup.objects.filter(users=user)] + cache.set(key, group_names, cache_expiration) + + return group_names + + +def format_url_params(params): + return [urllib.quote(string.replace(' ', '_')) for string in params] -template_imports={'urllib':urllib} @cache_control(no_cache=True, no_store=True, must_revalidate=True) def gradebook(request): - if 'course_admin' not in content_parser.user_groups(request.user): + if 'course_admin' not in user_groups(request.user): raise Http404 coursename = multicourse_settings.get_coursename_from_request(request) student_objects = User.objects.all()[:100] - student_info = [{'username': s.username, - 'id': s.id, - 'email': s.email, - 'grade_info': grades.grade_sheet(s, coursename), - 'realname': UserProfile.objects.get(user = s).name - } for s in student_objects] + student_info = [] + + coursename = multicourse_settings.get_coursename_from_request(request) + course_location = multicourse_settings.get_course_location(coursename) + + for student in student_objects: + student_module_cache = StudentModuleCache(student, keystore().get_item(course_location)) + course, _, _, _ = get_module(request.user, request, course_location, student_module_cache) + student_info.append({ + 'username': student.username, + 'id': student.id, + 'email': student.email, + 'grade_info': grades.grade_sheet(student, course, student_module_cache), + 'realname': UserProfile.objects.get(user=student).name + }) return render_to_response('gradebook.html', {'students': student_info}) + @login_required @cache_control(no_cache=True, no_store=True, must_revalidate=True) def profile(request, student_id=None): @@ -59,23 +90,26 @@ def profile(request, student_id=None): if student_id is None: student = request.user else: - if 'course_admin' not in content_parser.user_groups(request.user): + if 'course_admin' not in user_groups(request.user): raise Http404 - student = User.objects.get( id = int(student_id)) + student = User.objects.get(id=int(student_id)) - user_info = UserProfile.objects.get(user=student) # request.user.profile_cache # + user_info = UserProfile.objects.get(user=student) coursename = multicourse_settings.get_coursename_from_request(request) + course_location = multicourse_settings.get_course_location(coursename) + student_module_cache = StudentModuleCache(request.user, keystore().get_item(course_location)) + course, _, _, _ = get_module(request.user, request, course_location, student_module_cache) context = {'name': user_info.name, 'username': student.username, 'location': user_info.location, 'language': user_info.language, 'email': student.email, - 'format_url_params': content_parser.format_url_params, + 'format_url_params': format_url_params, 'csrf': csrf(request)['csrf_token'] } - context.update(grades.grade_sheet(student, coursename)) + context.update(grades.grade_sheet(student, course, student_module_cache)) return render_to_response('profile.html', context) @@ -87,73 +121,23 @@ def render_accordion(request, course, chapter, section): If chapter and section are '' or None, renders a default accordion. Returns (initialization_javascript, content)''' - if not course: - course = "6.002 Spring 2012" - toc = content_parser.toc_from_xml( - content_parser.course_file(request.user, course), chapter, section) + course_location = multicourse_settings.get_course_location(course) + toc = toc_for_course(request.user, request, course_location, chapter, section) active_chapter = 1 for i in range(len(toc)): if toc[i]['active']: active_chapter = i - context=dict([('active_chapter', active_chapter), - ('toc', toc), - ('course_name', course), - ('format_url_params', content_parser.format_url_params), - ('csrf', csrf(request)['csrf_token'])] + - template_imports.items()) + context = dict([('active_chapter', active_chapter), + ('toc', toc), + ('course_name', course), + ('format_url_params', format_url_params), + ('csrf', csrf(request)['csrf_token'])] + template_imports.items()) return render_to_string('accordion.html', context) -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -def render_section(request, section): - ''' TODO: Consolidate with index - ''' - user = request.user - if not settings.COURSEWARE_ENABLED: - return redirect('/') - - coursename = multicourse_settings.get_coursename_from_request(request) - - try: - dom = content_parser.section_file(user, section, coursename) - except: - log.exception("Unable to parse courseware xml") - return render_to_response('courseware-error.html', {}) - - context = { - 'csrf': csrf(request)['csrf_token'], - 'accordion': render_accordion(request, '', '', '') - } - - module_ids = dom.xpath("//@id") - - if user.is_authenticated(): - student_module_cache = list(StudentModule.objects.filter(student=user, - module_id__in=module_ids)) - else: - student_module_cache = [] - - try: - module = render_x_module(user, request, dom, student_module_cache) - except: - log.exception("Unable to load module") - context.update({ - 'init': '', - 'content': render_to_string("module-error.html", {}), - }) - return render_to_response('courseware.html', context) - - context.update({ - 'init': module.get('init_js', ''), - 'content': module['content'], - }) - - result = render_to_response('courseware.html', context) - return result - def get_course(request, course): ''' Figure out what the correct course is. @@ -161,7 +145,7 @@ def get_course(request, course): TODO: Can this go away once multicourse becomes standard? ''' - if course==None: + if course == None: if not settings.ENABLE_MULTICOURSE: course = "6.002 Spring 2012" elif 'coursename' in request.session: @@ -170,35 +154,6 @@ def get_course(request, course): course = settings.COURSE_DEFAULT return course -def get_module_xml(user, course, chapter, section): - ''' Look up the module xml for the given course/chapter/section path. - - Takes the user to look up the course file. - - Returns None if there was a problem, or the lxml etree for the module. - ''' - try: - # this is the course.xml etree - dom = content_parser.course_file(user, course) - except: - log.exception("Unable to parse courseware xml") - return None - - # this is the module's parent's etree - path = "//course[@name=$course]/chapter[@name=$chapter]//section[@name=$section]" - dom_module = dom.xpath(path, course=course, chapter=chapter, section=section) - - module_wrapper = dom_module[0] if len(dom_module) > 0 else None - if module_wrapper is None: - module = None - elif module_wrapper.get("src"): - module = content_parser.section_file( - user=user, section=module_wrapper.get("src"), coursename=course) - else: - # Copy the element out of the module's etree - module = etree.XML(etree.tostring(module_wrapper[0])) - return module - @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) @@ -228,55 +183,6 @@ def index(request, course=None, chapter=None, section=None, ''' return s.replace('_', ' ') if s is not None else None - def get_submodule_ids(module_xml): - ''' - Get a list with ids of the modules within this module. - ''' - return module_xml.xpath("//@id") - - def preload_student_modules(module_xml): - ''' - Find any StudentModule objects for this user that match - one of the given module_ids. Used as a cache to avoid having - each rendered module hit the db separately. - - Returns the list, or None on error. - ''' - if request.user.is_authenticated(): - module_ids = get_submodule_ids(module_xml) - return list(StudentModule.objects.filter(student=request.user, - module_id__in=module_ids)) - else: - return [] - - def get_module_context(): - ''' - Look up the module object and render it. If all goes well, returns - {'init': module-init-js, 'content': module-rendered-content} - - If there's an error, returns - {'content': module-error message} - ''' - user = request.user - - module_xml = get_module_xml(user, course, chapter, section) - if module_xml is None: - log.exception("couldn't get module_xml: course/chapter/section: '%s/%s/%s'", - course, chapter, section) - return {'content' : render_to_string("module-error.html", {})} - - student_module_cache = preload_student_modules(module_xml) - - try: - module_context = render_x_module(user, request, module_xml, - student_module_cache, position) - except: - log.exception("Unable to load module") - return {'content' : render_to_string("module-error.html", {})} - - return {'init': module_context.get('init_js', ''), - 'content': module_context['content']} - if not settings.COURSEWARE_ENABLED: return redirect('/') @@ -300,11 +206,16 @@ def index(request, course=None, chapter=None, section=None, look_for_module = chapter is not None and section is not None if look_for_module: - context.update(get_module_context()) + course_location = multicourse_settings.get_course_location(course) + section = get_section(course_location, chapter, section) + student_module_cache = StudentModuleCache(request.user, section) + module, _, _, _ = get_module(request.user, request, section.location, student_module_cache) + context['content'] = module.get_html() result = render_to_response('courseware.html', context) return result + def jump_to(request, probname=None): ''' Jump to viewing a specific problem. The problem is specified by a @@ -327,7 +238,8 @@ def jump_to(request, probname=None): # look for problem of given name pxml = xml.xpath('//problem[@filename="%s"]' % probname) - if pxml: pxml = pxml[0] + if pxml: + pxml = pxml[0] # get the parent element parent = pxml.getparent() @@ -336,7 +248,7 @@ def jump_to(request, probname=None): chapter = None section = None branch = parent - for k in range(4): # max depth of recursion + for k in range(4): # max depth of recursion if branch.tag == 'section': section = branch.get('name') if branch.tag == 'chapter': @@ -345,7 +257,7 @@ def jump_to(request, probname=None): position = None if parent.tag == 'sequential': - position = parent.index(pxml) + 1 # position in sequence + position = parent.index(pxml) + 1 # position in sequence return index(request, course=coursename, chapter=chapter, diff --git a/lms/djangoapps/multicourse/multicourse_settings.py b/lms/djangoapps/multicourse/multicourse_settings.py index 05b05c8ec9..4d568d55a1 100644 --- a/lms/djangoapps/multicourse/multicourse_settings.py +++ b/lms/djangoapps/multicourse/multicourse_settings.py @@ -31,11 +31,13 @@ if hasattr(settings,'COURSE_SETTINGS'): # in the future, this could be repla elif hasattr(settings,'COURSE_NAME'): # backward compatibility COURSE_SETTINGS = {settings.COURSE_NAME: {'number': settings.COURSE_NUMBER, 'title': settings.COURSE_TITLE, + 'location': settings.COURSE_LOCATION, }, } else: # default to 6.002_Spring_2012 COURSE_SETTINGS = {'6.002_Spring_2012': {'number': '6.002x', 'title': 'Circuits and Electronics', + 'location': 'i4x://edx/6002xs12/course/6.002 Spring 2012', }, } @@ -51,31 +53,47 @@ def get_coursename_from_request(request): def get_course_settings(coursename): if not coursename: - if hasattr(settings,'COURSE_DEFAULT'): + if hasattr(settings, 'COURSE_DEFAULT'): coursename = settings.COURSE_DEFAULT else: coursename = '6.002_Spring_2012' - if coursename in COURSE_SETTINGS: return COURSE_SETTINGS[coursename] - coursename = coursename.replace(' ','_') - if coursename in COURSE_SETTINGS: return COURSE_SETTINGS[coursename] + if coursename in COURSE_SETTINGS: + return COURSE_SETTINGS[coursename] + coursename = coursename.replace(' ', '_') + if coursename in COURSE_SETTINGS: + return COURSE_SETTINGS[coursename] return None + def is_valid_course(coursename): return get_course_settings(coursename) != None -def get_course_property(coursename,property): + +def get_course_property(coursename, property): cs = get_course_settings(coursename) - if not cs: return '' # raise exception instead? - if property in cs: return cs[property] - return '' # default + + # raise exception instead? + if not cs: + return '' + + if property in cs: + return cs[property] + + # default + return '' + def get_course_xmlpath(coursename): - return get_course_property(coursename,'xmlpath') + return get_course_property(coursename, 'xmlpath') + def get_course_title(coursename): - return get_course_property(coursename,'title') + return get_course_property(coursename, 'title') + def get_course_number(coursename): - return get_course_property(coursename,'number') - + return get_course_property(coursename, 'number') + +def get_course_location(coursename): + return get_course_property(coursename, 'location') diff --git a/lms/envs/common.py b/lms/envs/common.py index 60834f9d91..d1faf00f62 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -132,10 +132,25 @@ COURSE_DEFAULT = '6.002_Spring_2012' COURSE_SETTINGS = {'6.002_Spring_2012': {'number' : '6.002x', 'title' : 'Circuits and Electronics', 'xmlpath': '6002x/', + 'location': 'i4x://edx/6002xs12/course/6.002_Spring_2012', } } +############################### XModule Store ################################## +KEYSTORE = { + 'default': { + 'ENGINE': 'keystore.xml.XMLModuleStore', + 'OPTIONS': { + 'org': 'edx', + 'course': '6002xs12', + 'data_dir': DATA_DIR, + 'default_class': 'xmodule.hidden_module.HiddenDescriptor', + } + } +} + + ############################### DJANGO BUILT-INS ############################### # Change DEBUG/TEMPLATE_DEBUG in your environment settings files, not here DEBUG = False diff --git a/lms/envs/dev.py b/lms/envs/dev.py index decd92d136..f175ca1f53 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -11,7 +11,7 @@ from .common import * from .logsettings import get_logger_config DEBUG = True -TEMPLATE_DEBUG = True +TEMPLATE_DEBUG = False LOGGING = get_logger_config(ENV_ROOT / "log", logging_env="dev", diff --git a/lms/lib/dogfood/views.py b/lms/lib/dogfood/views.py index 17096afc70..ba8601cc20 100644 --- a/lms/lib/dogfood/views.py +++ b/lms/lib/dogfood/views.py @@ -174,7 +174,7 @@ def quickedit(request, id=None, qetemplate='quickedit.html',coursename=None): module = 'problem' xml = content_parser.module_xml(request.user, module, 'id', id, coursename) - ajax_url = settings.MITX_ROOT_URL + '/modx/'+module+'/'+id+'/' + ajax_url = settings.MITX_ROOT_URL + '/modx/'+id+'/' # Create the module (instance of capa_module.Module) system = I4xSystem(track_function = make_track_function(request), @@ -184,29 +184,29 @@ def quickedit(request, id=None, qetemplate='quickedit.html',coursename=None): filestore = OSFS(settings.DATA_DIR + xp), #role = 'staff' if request.user.is_staff else 'student', # TODO: generalize this ) - instance=xmodule.get_module_class(module)(system, - xml, + instance = xmodule.get_module_class(module)(system, + xml, id, state=None) log.info('ajax_url = ' + instance.ajax_url) # create empty student state for this problem, if not previously existing - s = StudentModule.objects.filter(student=request.user, - module_id=id) + s = StudentModule.objects.filter(student=request.user, + module_state_key=id) if len(s) == 0 or s is None: - smod=StudentModule(student=request.user, - module_type = 'problem', - module_id=id, - state=instance.get_state()) + smod = StudentModule(student=request.user, + module_type='problem', + module_state_key=id, + state=instance.get_instance_state()) smod.save() lcp = instance.lcp pxml = lcp.tree - pxmls = etree.tostring(pxml,pretty_print=True) + pxmls = etree.tostring(pxml, pretty_print=True) return instance, pxmls - instance, pxmls = get_lcp(coursename,id) + instance, pxmls = get_lcp(coursename, id) # if there was a POST, then process it msg = '' @@ -246,8 +246,6 @@ def quickedit(request, id=None, qetemplate='quickedit.html',coursename=None): # get the rendered problem HTML phtml = instance.get_html() # phtml = instance.get_problem_html() - # init_js = instance.get_init_js() - # destory_js = instance.get_destroy_js() context = {'id':id, 'msg' : msg, diff --git a/lms/static/coffee/src/courseware.coffee b/lms/static/coffee/src/courseware.coffee index de232e05e4..4e57d13194 100644 --- a/lms/static/coffee/src/courseware.coffee +++ b/lms/static/coffee/src/courseware.coffee @@ -20,8 +20,8 @@ class @Courseware id = $(this).attr('id').replace(/video_/, '') new Video id, $(this).data('streams') $('.course-content .problems-wrapper').each -> - id = $(this).attr('id').replace(/problem_/, '') - new Problem id, $(this).data('url') + id = $(this).attr('problem-id') + new Problem id, $(this).attr('id'), $(this).data('url') $('.course-content .histogram').each -> id = $(this).attr('id').replace(/histogram_/, '') new Histogram id, $(this).data('histogram') diff --git a/lms/static/coffee/src/modules/problem.coffee b/lms/static/coffee/src/modules/problem.coffee index f29c9eb72b..85186a2903 100644 --- a/lms/static/coffee/src/modules/problem.coffee +++ b/lms/static/coffee/src/modules/problem.coffee @@ -1,6 +1,6 @@ class @Problem - constructor: (@id, url) -> - @element = $("#problem_#{id}") + constructor: (@id, @element_id, url) -> + @element = $("##{element_id}") @render() $: (selector) -> @@ -26,13 +26,13 @@ class @Problem @element.html(content) @bind() else - $.postWithPrefix "/modx/problem/#{@id}/problem_get", (response) => + $.postWithPrefix "/modx/#{@id}/problem_get", (response) => @element.html(response.html) @bind() check: => Logger.log 'problem_check', @answers - $.postWithPrefix "/modx/problem/#{@id}/problem_check", @answers, (response) => + $.postWithPrefix "/modx/#{@id}/problem_check", @answers, (response) => switch response.success when 'incorrect', 'correct' @render(response.contents) @@ -42,14 +42,14 @@ class @Problem reset: => Logger.log 'problem_reset', @answers - $.postWithPrefix "/modx/problem/#{@id}/problem_reset", id: @id, (response) => + $.postWithPrefix "/modx/#{@id}/problem_reset", id: @id, (response) => @render(response.html) @updateProgress response show: => if !@element.hasClass 'showed' Logger.log 'problem_show', problem: @id - $.postWithPrefix "/modx/problem/#{@id}/problem_show", (response) => + $.postWithPrefix "/modx/#{@id}/problem_show", (response) => answers = response.answers $.each answers, (key, value) => if $.isArray(value) @@ -69,7 +69,7 @@ class @Problem save: => Logger.log 'problem_save', @answers - $.postWithPrefix "/modx/problem/#{@id}/problem_save", @answers, (response) => + $.postWithPrefix "/modx/#{@id}/problem_save", @answers, (response) => if response.success alert 'Saved' @updateProgress response @@ -94,4 +94,4 @@ class @Problem element.schematic.update_value() @$(".CodeMirror").each (index, element) -> element.CodeMirror.save() if element.CodeMirror.save - @answers = @$("[id^=input_#{@id}_]").serialize() + @answers = @$("[id^=input_#{@element_id.replace(/problem_/, '')}_]").serialize() diff --git a/lms/static/coffee/src/modules/sequence.coffee b/lms/static/coffee/src/modules/sequence.coffee index 32a90f51a5..2c979f0853 100644 --- a/lms/static/coffee/src/modules/sequence.coffee +++ b/lms/static/coffee/src/modules/sequence.coffee @@ -1,6 +1,6 @@ class @Sequence - constructor: (@id, @elements, @tag, position) -> - @element = $("#sequence_#{@id}") + constructor: (@id, @element_id, @elements, @tag, position) -> + @element = $("#sequence_#{@element_id}") @buildNavigation() @initProgress() @bind() @@ -88,7 +88,7 @@ class @Sequence if @position != new_position if @position != undefined @mark_visited @position - $.postWithPrefix "/modx/#{@tag}/#{@id}/goto_position", position: new_position + $.postWithPrefix "/modx/#{@id}/goto_position", position: new_position @mark_active new_position @$('#seq_content').html @elements[new_position - 1].content diff --git a/lms/templates/problem_ajax.html b/lms/templates/problem_ajax.html index 78b85df3c1..6330edfac0 100644 --- a/lms/templates/problem_ajax.html +++ b/lms/templates/problem_ajax.html @@ -1 +1 @@ -
    +
    diff --git a/lms/templates/profile.html b/lms/templates/profile.html index e732616d5a..1ba0940eff 100644 --- a/lms/templates/profile.html +++ b/lms/templates/profile.html @@ -156,7 +156,7 @@ $(function() {

    ${ section['section'] } ${"({0:.3n}/{1:.3n}) {2}".format( float(earned), float(total), percentageString )}

    - ${section['subtitle']} + ${section['format']} %if 'due' in section and section['due']!="": due ${section['due']} %endif diff --git a/lms/templates/seq_module.html b/lms/templates/seq_module.html index ab903457dc..00221a4951 100644 --- a/lms/templates/seq_module.html +++ b/lms/templates/seq_module.html @@ -1,4 +1,4 @@ -
    +
    - -
      -% for t in annotations: -
    1. - ${t[1]['content']} -
    2. -% endfor -
    diff --git a/lms/urls.py b/lms/urls.py index 313be62c51..d8d4356e5c 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -56,8 +56,7 @@ if settings.COURSEWARE_ENABLED: url(r'^courseware/(?P[^/]*)/(?P[^/]*)/$', 'courseware.views.index', name="courseware_chapter"), url(r'^courseware/(?P[^/]*)/$', 'courseware.views.index', name="courseware_course"), url(r'^jumpto/(?P[^/]+)/$', 'courseware.views.jump_to'), - url(r'^section/(?P
    [^/]*)/$', 'courseware.views.render_section'), - url(r'^modx/(?P[^/]*)/(?P[^/]*)/(?P[^/]*)$', 'courseware.module_render.modx_dispatch'), #reset_problem'), + url(r'^modx/(?P.*?)/(?P[^/]*)$', 'courseware.module_render.modx_dispatch'), #reset_problem'), url(r'^profile$', 'courseware.views.profile'), url(r'^profile/(?P[^/]*)/$', 'courseware.views.profile'), url(r'^change_setting$', 'student.views.change_setting'),