diff --git a/cms/djangoapps/github_sync/tests/__init__.py b/cms/djangoapps/github_sync/tests/__init__.py index 581ac3cb25..e2b9a909a7 100644 --- a/cms/djangoapps/github_sync/tests/__init__.py +++ b/cms/djangoapps/github_sync/tests/__init__.py @@ -61,7 +61,7 @@ class GithubSyncTestCase(TestCase): self.assertIn( Location('i4x://edX/toy/chapter/Overview'), [child.location for child in self.import_course.get_children()]) - self.assertEquals(1, len(self.import_course.get_children())) + self.assertEquals(2, len(self.import_course.get_children())) @patch('github_sync.sync_with_github') def test_sync_all_with_github(self, sync_with_github): diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 82eb330174..f386c9fe24 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -14,6 +14,8 @@ This is used by capa_module. from __future__ import division +from datetime import datetime +import json import logging import math import numpy @@ -32,6 +34,7 @@ from correctmap import CorrectMap import eia import inputtypes from util import contextualize_text, convert_files_to_filenames +import xqueue_interface # to be replaced with auto-registering import responsetypes @@ -202,11 +205,24 @@ class LoncapaProblem(object): ''' Returns True if any part of the problem has been submitted to an external queue ''' - queued = False - for answer_id in self.correct_map: - if self.correct_map.is_queued(answer_id): - queued = True - return queued + return any(self.correct_map.is_queued(answer_id) for answer_id in self.correct_map) + + + def get_recentmost_queuetime(self): + ''' + Returns a DateTime object that represents the timestamp of the most recent queueing request, or None if not queued + ''' + if not self.is_queued(): + return None + + # Get a list of timestamps of all queueing requests, then convert it to a DateTime object + queuetime_strs = [self.correct_map.get_queuetime_str(answer_id) + for answer_id in self.correct_map + if self.correct_map.is_queued(answer_id)] + queuetimes = [datetime.strptime(qt_str, xqueue_interface.dateformat) for qt_str in queuetime_strs] + + return max(queuetimes) + def grade_answers(self, answers): ''' diff --git a/common/lib/capa/capa/correctmap.py b/common/lib/capa/capa/correctmap.py index eb6ef2d00c..52411a8e8c 100644 --- a/common/lib/capa/capa/correctmap.py +++ b/common/lib/capa/capa/correctmap.py @@ -15,7 +15,8 @@ class CorrectMap(object): - msg : string (may have HTML) giving extra message response (displayed below textline or textbox) - hint : string (may have HTML) giving optional hint (displayed below textline or textbox, above msg) - hintmode : one of (None,'on_request','always') criteria for displaying hint - - queuekey : a random integer for xqueue_callback verification + - queuestate : Dict {key:'', time:''} where key is a secret string, and time is a string dump + of a DateTime object in the format '%Y%m%d%H%M%S'. Is None when not queued Behaves as a dict. ''' @@ -31,14 +32,15 @@ class CorrectMap(object): def __iter__(self): return self.cmap.__iter__() - def set(self, answer_id=None, correctness=None, npoints=None, msg='', hint='', hintmode=None, queuekey=None): + # See the documentation for 'set_dict' for the use of kwargs + def set(self, answer_id=None, correctness=None, npoints=None, msg='', hint='', hintmode=None, queuestate=None, **kwargs): if answer_id is not None: self.cmap[answer_id] = {'correctness': correctness, 'npoints': npoints, 'msg': msg, 'hint': hint, 'hintmode': hintmode, - 'queuekey': queuekey, + 'queuestate': queuestate, } def __repr__(self): @@ -52,25 +54,39 @@ class CorrectMap(object): def set_dict(self, correct_map): ''' - set internal dict to provided correct_map dict - for graceful migration, if correct_map is a one-level dict, then convert it to the new - dict of dicts format. + Set internal dict of CorrectMap to provided correct_map dict + + correct_map is saved by LMS as a plaintext JSON dump of the correctmap dict. This means that + when the definition of CorrectMap (e.g. its properties) are altered, existing correct_map dict + not coincide with the newest CorrectMap format as defined by self.set. + + For graceful migration, feed the contents of each correct map to self.set, rather than + making a direct copy of the given correct_map dict. This way, the common keys between + the incoming correct_map dict and the new CorrectMap instance will be written, while + mismatched keys will be gracefully ignored. + + Special migration case: + If correct_map is a one-level dict, then convert it to the new dict of dicts format. ''' if correct_map and not (type(correct_map[correct_map.keys()[0]]) == dict): - self.__init__() # empty current dict - for k in correct_map: self.set(k, correct_map[k]) # create new dict entries + self.__init__() # empty current dict + for k in correct_map: self.set(k, correct_map[k]) # create new dict entries else: - self.cmap = correct_map + self.__init__() + for k in correct_map: self.set(k, **correct_map[k]) def is_correct(self, answer_id): if answer_id in self.cmap: return self.cmap[answer_id]['correctness'] == 'correct' return None def is_queued(self, answer_id): - return answer_id in self.cmap and self.cmap[answer_id]['queuekey'] is not None + return answer_id in self.cmap and self.cmap[answer_id]['queuestate'] is not None def is_right_queuekey(self, answer_id, test_key): - return answer_id in self.cmap and self.cmap[answer_id]['queuekey'] == test_key + return self.is_queued(answer_id) and self.cmap[answer_id]['queuestate']['key'] == test_key + + def get_queuetime_str(self, answer_id): + return self.cmap[answer_id]['queuestate']['time'] def get_npoints(self, answer_id): npoints = self.get_property(answer_id, 'npoints') diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index ad54736359..a323a5dbfe 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -351,7 +351,7 @@ def filesubmission(element, value, status, render_template, msg=''): if status == 'incomplete': # Flag indicating that the problem has been queued, 'msg' is length of queue status = 'queued' queue_len = msg - msg = 'Submitted to grader. (Queue length: %s)' % queue_len + msg = 'Submitted to grader.' context = { 'id': eid, 'state': status, 'msg': msg, 'value': value, 'queue_len': queue_len, 'allowed_files': allowed_files, @@ -384,7 +384,7 @@ def textbox(element, value, status, render_template, msg=''): if status == 'incomplete': # Flag indicating that the problem has been queued, 'msg' is length of queue status = 'queued' queue_len = msg - msg = 'Submitted to grader. (Queue length: %s)' % queue_len + msg = 'Submitted to grader.' # For CodeMirror mode = element.get('mode','python') diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index b2d56b48ca..d1b58e53a9 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -8,6 +8,7 @@ Used by capa_problem.py ''' # standard library imports +import cgi import inspect import json import logging @@ -26,6 +27,7 @@ import xml.sax.saxutils as saxutils # specific library imports from calc import evaluator, UndefinedVariable from correctmap import CorrectMap +from datetime import datetime from util import * from lxml import etree from lxml.html.soupparser import fromstring as fromstring_bs # uses Beautiful Soup!!! FIXME? @@ -317,30 +319,37 @@ class JavascriptResponse(LoncapaResponse): def compile_display_javascript(self): - latestTimestamp = 0 - basepath = self.system.filestore.root_path + '/js/' - for filename in (self.display_dependencies + [self.display]): - filepath = basepath + filename - timestamp = os.stat(filepath).st_mtime - if timestamp > latestTimestamp: - latestTimestamp = timestamp - - h = hashlib.md5() - h.update(self.answer_id + str(self.display_dependencies)) - compiled_filename = 'compiled/' + h.hexdigest() + '.js' - compiled_filepath = basepath + compiled_filename + # TODO FIXME + # arjun: removing this behavior for now (and likely forever). Keeping + # until we decide on exactly how to solve this issue. For now, files are + # manually being compiled to DATA_DIR/js/compiled. - if not os.path.exists(compiled_filepath) or os.stat(compiled_filepath).st_mtime < latestTimestamp: - outfile = open(compiled_filepath, 'w') - for filename in (self.display_dependencies + [self.display]): - filepath = basepath + filename - infile = open(filepath, 'r') - outfile.write(infile.read()) - outfile.write(';\n') - infile.close() - outfile.close() + #latestTimestamp = 0 + #basepath = self.system.filestore.root_path + '/js/' + #for filename in (self.display_dependencies + [self.display]): + # filepath = basepath + filename + # timestamp = os.stat(filepath).st_mtime + # if timestamp > latestTimestamp: + # latestTimestamp = timestamp + # + #h = hashlib.md5() + #h.update(self.answer_id + str(self.display_dependencies)) + #compiled_filename = 'compiled/' + h.hexdigest() + '.js' + #compiled_filepath = basepath + compiled_filename - self.display_filename = compiled_filename + #if not os.path.exists(compiled_filepath) or os.stat(compiled_filepath).st_mtime < latestTimestamp: + # outfile = open(compiled_filepath, 'w') + # for filename in (self.display_dependencies + [self.display]): + # filepath = basepath + filename + # infile = open(filepath, 'r') + # outfile.write(infile.read()) + # outfile.write(';\n') + # infile.close() + # outfile.close() + + # TODO this should also be fixed when the above is fixed. + filename = self.system.ajax_url.split('/')[-1] + '.js' + self.display_filename = 'compiled/' + filename def parse_xml(self): self.generator_xml = self.xml.xpath('//*[@id=$id]//generator', @@ -384,19 +393,23 @@ class JavascriptResponse(LoncapaResponse): node_path = self.system.node_path + ":" + os.path.normpath(js_dir) tmp_env["NODE_PATH"] = node_path return tmp_env + + def call_node(self, args): + + subprocess_args = ["node"] + subprocess_args.extend(args) + + return subprocess.check_output(subprocess_args, env=self.get_node_env()) def generate_problem_state(self): generator_file = os.path.dirname(os.path.normpath(__file__)) + '/javascript_problem_generator.js' - output = subprocess.check_output(["node", - generator_file, - self.generator, - json.dumps(self.generator_dependencies), - json.dumps(str(self.system.seed)), - json.dumps(self.params) - ], - env=self.get_node_env()).strip() + output = self.call_node([generator_file, + self.generator, + json.dumps(self.generator_dependencies), + json.dumps(str(self.system.seed)), + json.dumps(self.params)]).strip() return json.loads(output) @@ -407,7 +420,8 @@ class JavascriptResponse(LoncapaResponse): for param in self.xml.xpath('//*[@id=$id]//responseparam', id=self.xml.get('id')): - params[param.get("name")] = json.loads(param.get("value")) + raw_param = param.get("value") + params[param.get("name")] = json.loads(contextualize_text(raw_param, self.context)) return params @@ -435,22 +449,23 @@ class JavascriptResponse(LoncapaResponse): (all_correct, evaluation, solution) = self.run_grader(json_submission) self.solution = solution correctness = 'correct' if all_correct else 'incorrect' - return CorrectMap(self.answer_id, correctness, msg=evaluation) + if all_correct: + points = self.get_max_score() + else: + points = 0 + return CorrectMap(self.answer_id, correctness, npoints=points, msg=evaluation) def run_grader(self, submission): if submission is None or submission == '': submission = json.dumps(None) grader_file = os.path.dirname(os.path.normpath(__file__)) + '/javascript_problem_grader.js' - outputs = subprocess.check_output(["node", - grader_file, - self.grader, - json.dumps(self.grader_dependencies), - submission, - json.dumps(self.problem_state), - json.dumps(self.params) - ], - env=self.get_node_env()).split('\n') + outputs = self.call_node([grader_file, + self.grader, + json.dumps(self.grader_dependencies), + submission, + json.dumps(self.problem_state), + json.dumps(self.params)]).split('\n') all_correct = json.loads(outputs[0].strip()) evaluation = outputs[1].strip() @@ -711,7 +726,8 @@ class NumericalResponse(LoncapaResponse): # I think this is just pyparsing.ParseException, calc.UndefinedVariable: # But we'd need to confirm except: - raise StudentInputError('Invalid input -- please use a number only') + raise StudentInputError("Invalid input: could not parse '%s' as a number" %\ + cgi.escape(student_answer)) if correct: return CorrectMap(self.answer_id, 'correct') @@ -1005,7 +1021,7 @@ class CodeResponse(LoncapaResponse): ''' Grade student code using an external queueing server, called 'xqueue' - Expects 'xqueue' dict in ModuleSystem with the following keys: + Expects 'xqueue' dict in ModuleSystem with the following keys that are needed by CodeResponse: system.xqueue = { 'interface': XqueueInterface object, 'callback_url': Per-StudentModule callback URL where results are posted (string), 'default_queuename': Default queuename to submit request (string) @@ -1026,7 +1042,7 @@ class CodeResponse(LoncapaResponse): TODO: Determines whether in synchronous or asynchronous (queued) mode ''' xml = self.xml - self.url = xml.get('url', None) # XML can override external resource (grader/queue) URL + self.url = xml.get('url', None) # TODO: XML can override external resource (grader/queue) URL self.queue_name = xml.get('queuename', self.system.xqueue['default_queuename']) # VS[compat]: @@ -1121,22 +1137,34 @@ class CodeResponse(LoncapaResponse): # Prepare xqueue request #------------------------------------------------------------ + qinterface = self.system.xqueue['interface'] + qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat) + + anonymous_student_id = self.system.anonymous_student_id # Generate header - queuekey = xqueue_interface.make_hashkey(str(self.system.seed)+self.answer_id) + queuekey = xqueue_interface.make_hashkey(str(self.system.seed) + qtime + + anonymous_student_id + + self.answer_id) xheader = xqueue_interface.make_xheader(lms_callback_url=self.system.xqueue['callback_url'], lms_key=queuekey, queue_name=self.queue_name) - + # Generate body if is_list_of_files(submission): - self.context.update({'submission': queuekey}) # For tracking. TODO: May want to record something else here + self.context.update({'submission': ''}) # TODO: Get S3 pointer from the Queue else: self.context.update({'submission': submission}) contents = self.payload.copy() + # Metadata related to the student submission revealed to the external grader + student_info = {'anonymous_student_id': anonymous_student_id, + 'submission_time': qtime, + } + contents.update({'student_info': json.dumps(student_info)}) + # Submit request. When successful, 'msg' is the prior length of the queue if is_list_of_files(submission): contents.update({'student_response': ''}) # TODO: Is there any information we want to send here? @@ -1148,16 +1176,21 @@ class CodeResponse(LoncapaResponse): (error, msg) = qinterface.send_to_queue(header=xheader, body=json.dumps(contents)) + # State associated with the queueing request + queuestate = {'key': queuekey, + 'time': qtime, + } + cmap = CorrectMap() if error: - cmap.set(self.answer_id, queuekey=None, + cmap.set(self.answer_id, queuestate=None, msg='Unable to deliver your submission to grader. (Reason: %s.) Please try again later.' % msg) else: # Queueing mechanism flags: - # 1) Backend: Non-null CorrectMap['queuekey'] indicates that the problem has been queued + # 1) Backend: Non-null CorrectMap['queuestate'] indicates that the problem has been queued # 2) Frontend: correctness='incomplete' eventually trickles down through inputtypes.textbox # and .filesubmission to inform the browser to poll the LMS - cmap.set(self.answer_id, queuekey=queuekey, correctness='incomplete', msg=msg) + cmap.set(self.answer_id, queuestate=queuestate, correctness='incomplete', msg=msg) return cmap @@ -1165,7 +1198,7 @@ class CodeResponse(LoncapaResponse): (valid_score_msg, correct, points, msg) = self._parse_score_msg(score_msg) if not valid_score_msg: - oldcmap.set(self.answer_id, msg='Error: Invalid grader reply.') + oldcmap.set(self.answer_id, msg='Invalid grader reply. Please contact the course staff.') return oldcmap correctness = 'correct' if correct else 'incorrect' @@ -1180,7 +1213,7 @@ class CodeResponse(LoncapaResponse): points = 0 elif points > self.maxpoints[self.answer_id]: points = self.maxpoints[self.answer_id] - oldcmap.set(self.answer_id, npoints=points, correctness=correctness, msg=msg.replace(' ', ' '), queuekey=None) # Queuekey is consumed + oldcmap.set(self.answer_id, npoints=points, correctness=correctness, msg=msg.replace(' ', ' '), queuestate=None) # Queuestate is consumed else: log.debug('CodeResponse: queuekey %s does not match for answer_id=%s.' % (queuekey, self.answer_id)) @@ -1197,26 +1230,41 @@ class CodeResponse(LoncapaResponse): ''' Grader reply is a JSON-dump of the following dict { 'correct': True/False, - 'score': # TODO -- Partial grading + 'score': Numeric value (floating point is okay) to assign to answer 'msg': grader_msg } Returns (valid_score_msg, correct, score, msg): valid_score_msg: Flag indicating valid score_msg format (Boolean) correct: Correctness of submission (Boolean) - score: # TODO: Implement partial grading + score: Points to be assigned (numeric, can be float) msg: Message from grader to display to student (string) ''' - fail = (False, False, -1, '') + fail = (False, False, 0, '') try: score_result = json.loads(score_msg) except (TypeError, ValueError): + log.error("External grader message should be a JSON-serialized dict. Received score_msg = %s" % score_msg) return fail if not isinstance(score_result, dict): + log.error("External grader message should be a JSON-serialized dict. Received score_result = %s" % score_result) return fail for tag in ['correct', 'score', 'msg']: - if not score_result.has_key(tag): + if tag not in score_result: + log.error("External grader message is missing one or more required tags: 'correct', 'score', 'msg'") return fail - return (True, score_result['correct'], score_result['score'], score_result['msg']) + + # Next, we need to check that the contents of the external grader message + # is safe for the LMS. + # 1) Make sure that the message is valid XML (proper opening/closing tags) + # 2) TODO: Is the message actually HTML? + msg = score_result['msg'] + try: + etree.fromstring(msg) + except etree.XMLSyntaxError as err: + log.error("Unable to parse external grader message as valid XML: score_msg['msg']=%s" % msg) + return fail + + return (True, score_result['correct'], score_result['score'], msg) #----------------------------------------------------------------------------- @@ -1471,11 +1519,12 @@ class FormulaResponse(LoncapaResponse): cs=self.case_sensitive) except UndefinedVariable as uv: log.debug('formularesponse: undefined variable in given=%s' % given) - raise StudentInputError(uv.message + " not permitted in answer") + raise StudentInputError("Invalid input: " + uv.message + " not permitted in answer") except Exception as err: #traceback.print_exc() log.debug('formularesponse: error %s in formula' % err) - raise StudentInputError("Error in formula") + raise StudentInputError("Invalid input: Could not parse '%s' as a formula" %\ + cgi.escape(given)) if numpy.isnan(student_result) or numpy.isinf(student_result): return "incorrect" if not compare_with_tolerance(student_result, instructor_result, self.tolerance): diff --git a/common/lib/capa/capa/templates/filesubmission.html b/common/lib/capa/capa/templates/filesubmission.html index a859dc8458..e9fd7c5674 100644 --- a/common/lib/capa/capa/templates/filesubmission.html +++ b/common/lib/capa/capa/templates/filesubmission.html @@ -10,7 +10,7 @@ % endif - (${state}) +
${msg|n}
diff --git a/common/lib/capa/capa/templates/textbox.html b/common/lib/capa/capa/templates/textbox.html index 647f4fe4e8..19c43482a8 100644 --- a/common/lib/capa/capa/templates/textbox.html +++ b/common/lib/capa/capa/templates/textbox.html @@ -21,7 +21,7 @@
% endif
- (${state}) +
${msg|n}
diff --git a/common/lib/capa/capa/xqueue_interface.py b/common/lib/capa/capa/xqueue_interface.py index 2930eb682d..f2fa94a1a4 100644 --- a/common/lib/capa/capa/xqueue_interface.py +++ b/common/lib/capa/capa/xqueue_interface.py @@ -5,20 +5,17 @@ import hashlib import json import logging import requests -import time log = logging.getLogger('mitx.' + __name__) +dateformat = '%Y%m%d%H%M%S' - -def make_hashkey(seed=None): +def make_hashkey(seed): ''' Generate a string key by hashing ''' h = hashlib.md5() - if seed is not None: - h.update(str(seed)) - h.update(str(time.time())) + h.update(str(seed)) return h.hexdigest() diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index d2ed3912a4..cfe3d2d48b 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -462,6 +462,15 @@ class CapaModule(XModule): self.system.track_function('save_problem_check_fail', event_info) raise NotFoundError('Problem must be reset before it can be checked again') + # Problem queued. Students must wait a specified waittime before they are allowed to submit + if self.lcp.is_queued(): + current_time = datetime.datetime.now() + prev_submit_time = self.lcp.get_recentmost_queuetime() + waittime_between_requests = self.system.xqueue['waittime'] + if (current_time-prev_submit_time).total_seconds() < waittime_between_requests: + msg = 'You must wait at least %d seconds between submissions' % waittime_between_requests + return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback + try: old_state = self.lcp.get_state() lcp_id = self.lcp.problem_id diff --git a/common/lib/xmodule/xmodule/discussion_module.py b/common/lib/xmodule/xmodule/discussion_module.py index c029d95098..4bfaeee183 100644 --- a/common/lib/xmodule/xmodule/discussion_module.py +++ b/common/lib/xmodule/xmodule/discussion_module.py @@ -1,4 +1,5 @@ from lxml import etree +from pkg_resources import resource_string, resource_listdir from xmodule.x_module import XModule from xmodule.raw_module import RawDescriptor @@ -6,6 +7,11 @@ from xmodule.raw_module import RawDescriptor import json class DiscussionModule(XModule): + js = {'coffee': + [resource_string(__name__, 'js/src/time.coffee'), + resource_string(__name__, 'js/src/discussion/display.coffee')] + } + js_module_name = "InlineDiscussion" def get_html(self): context = { 'discussion_id': self.discussion_id, diff --git a/common/lib/xmodule/xmodule/html_module.py b/common/lib/xmodule/xmodule/html_module.py index 6c424c26f2..3f834c335c 100644 --- a/common/lib/xmodule/xmodule/html_module.py +++ b/common/lib/xmodule/xmodule/html_module.py @@ -4,9 +4,10 @@ import logging import os import sys from lxml import etree +from path import path from .x_module import XModule -from .xml_module import XmlDescriptor +from .xml_module import XmlDescriptor, name_to_pathname from .editing_module import EditingDescriptor from .stringify import stringify_children from .html_checker import check_html @@ -75,9 +76,19 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor): cls.clean_metadata_from_xml(definition_xml) return {'data': stringify_children(definition_xml)} else: - # html is special. cls.filename_extension is 'xml', but if 'filename' is in the definition, - # that means to load from .html - filepath = "{category}/{name}.html".format(category='html', name=filename) + # html is special. cls.filename_extension is 'xml', but + # if 'filename' is in the definition, that means to load + # from .html + # 'filename' in html pointers is a relative path + # (not same as 'html/blah.html' when the pointer is in a directory itself) + pointer_path = "{category}/{url_path}".format(category='html', + url_path=name_to_pathname(location.name)) + base = path(pointer_path).dirname() + #log.debug("base = {0}, base.dirname={1}, filename={2}".format(base, base.dirname(), filename)) + filepath = "{base}/{name}.html".format(base=base, name=filename) + #log.debug("looking for html file for {0} at {1}".format(location, filepath)) + + # VS[compat] # TODO (cpennington): If the file doesn't exist at the right path, @@ -128,13 +139,18 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor): pass # Not proper format. Write html to file, return an empty tag - filepath = u'{category}/{name}.html'.format(category=self.category, - name=self.url_name) + pathname = name_to_pathname(self.url_name) + pathdir = path(pathname).dirname() + filepath = u'{category}/{pathname}.html'.format(category=self.category, + pathname=pathname) resource_fs.makedir(os.path.dirname(filepath), allow_recreate=True) with resource_fs.open(filepath, 'w') as file: file.write(self.definition['data']) + # write out the relative name + relname = path(pathname).basename() + elt = etree.Element('html') - elt.set("filename", self.url_name) + elt.set("filename", relname) return elt diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 23fa4d70fe..0ea6cffb58 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -192,8 +192,11 @@ class @Problem if file_not_selected errors.push 'You did not select any files to submit' - if errors.length > 0 - alert errors.join("\n") + error_html = '' + @gentle_alert error_html abort_submission = file_too_large or file_not_selected or unallowed_file_submitted or required_files_not_submitted @@ -208,7 +211,7 @@ class @Problem @render(response.contents) @updateProgress response else - alert(response.success) + @gentle_alert response.success if not abort_submission $.ajaxWithPrefix("#{@url}/problem_check", settings) @@ -220,8 +223,10 @@ class @Problem when 'incorrect', 'correct' @render(response.contents) @updateProgress response + if @el.hasClass 'showed' + @el.removeClass 'showed' else - alert(response.success) + @gentle_alert response.success reset: => Logger.log 'problem_reset', @answers @@ -253,11 +258,19 @@ class @Problem @el.removeClass 'showed' @$('.show').val 'Show Answer' + gentle_alert: (msg) => + if @el.find('.capa_alert').length + @el.find('.capa_alert').remove() + alert_elem = "
" + msg + "
" + @el.find('.action').after(alert_elem) + @el.find('.capa_alert').animate(opacity: 0, 500).animate(opacity: 1, 500) + save: => Logger.log 'problem_save', @answers $.postWithPrefix "#{@url}/problem_save", @answers, (response) => if response.success - alert 'Saved' + saveMessage = "Your answers have been saved but not graded. Hit 'Check' to grade them." + @gentle_alert saveMessage @updateProgress response refreshMath: (event, element) => @@ -293,6 +306,9 @@ class @Problem problemState = data.data("problem_state") displayClass = window[data.data('display_class')] + if evaluation == '' + evaluation = null + container = $(element).find(".javascriptinput_container") submissionField = $(element).find(".javascriptinput_input") diff --git a/common/lib/xmodule/xmodule/js/src/discussion/display.coffee b/common/lib/xmodule/xmodule/js/src/discussion/display.coffee new file mode 100644 index 0000000000..27a67f5aab --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/discussion/display.coffee @@ -0,0 +1,4 @@ +class @InlineDiscussion + constructor: (element) -> + @el = $(element).find('.discussion-module') + @view = new DiscussionModuleView(el: @el) diff --git a/common/lib/xmodule/xmodule/js/src/sequence/display.coffee b/common/lib/xmodule/xmodule/js/src/sequence/display.coffee index 35a33a9ea1..ab337d9b7e 100644 --- a/common/lib/xmodule/xmodule/js/src/sequence/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/sequence/display.coffee @@ -2,6 +2,7 @@ class @Sequence constructor: (element) -> @el = $(element).find('.sequence') @contents = @$('.seq_contents') + @num_contents = @contents.length @id = @el.data('id') @modx_url = @el.data('course_modx_root') @initProgress() @@ -90,18 +91,28 @@ class @Sequence @toggleArrows() @hookUpProgressEvent() + sequence_links = @$('#seq_content a.seqnav') + sequence_links.click @goto + goto: (event) => event.preventDefault() - new_position = $(event.target).data('element') - Logger.log "seq_goto", old: @position, new: new_position, id: @id - - # On Sequence chage, destroy any existing polling thread - # for queued submissions, see ../capa/display.coffee - if window.queuePollerID - window.clearTimeout(window.queuePollerID) - delete window.queuePollerID + if $(event.target).hasClass 'seqnav' # Links from courseware ... + new_position = $(event.target).attr('href') + else # Tab links generated by backend template + new_position = $(event.target).data('element') - @render new_position + if (1 <= new_position) and (new_position <= @num_contents) + Logger.log "seq_goto", old: @position, new: new_position, id: @id + + # On Sequence chage, destroy any existing polling thread + # for queued submissions, see ../capa/display.coffee + if window.queuePollerID + window.clearTimeout(window.queuePollerID) + delete window.queuePollerID + + @render new_position + else + alert 'Sequence error! Cannot navigate to tab ' + new_position + 'in the current SequenceModule. Please contact the course staff.' next: (event) => event.preventDefault() diff --git a/common/lib/xmodule/xmodule/js/src/video/display.coffee b/common/lib/xmodule/xmodule/js/src/video/display.coffee index ecf9ee1a72..3880091661 100644 --- a/common/lib/xmodule/xmodule/js/src/video/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/video/display.coffee @@ -3,6 +3,7 @@ class @Video @el = $(element).find('.video') @id = @el.attr('id').replace(/video_/, '') @caption_data_dir = @el.data('caption-data-dir') + @show_captions = @el.data('show-captions') == "true" window.player = null @el = $("#video_#{@id}") @parseVideos @el.data('streams') diff --git a/common/lib/xmodule/xmodule/js/src/video/display/video_caption.coffee b/common/lib/xmodule/xmodule/js/src/video/display/video_caption.coffee index 1690fb6092..f65debb1a2 100644 --- a/common/lib/xmodule/xmodule/js/src/video/display/video_caption.coffee +++ b/common/lib/xmodule/xmodule/js/src/video/display/video_caption.coffee @@ -1,4 +1,7 @@ class @VideoCaption extends Subview + initialize: -> + @loaded = false + bind: -> $(window).bind('resize', @resize) @$('.hide-subtitles').click @toggle @@ -10,8 +13,12 @@ class @VideoCaption extends Subview "/static/#{@captionDataDir}/subs/#{@youtubeId}.srt.sjson" render: -> + # TODO: make it so you can have a video with no captions. + #@$('.video-wrapper').after """ + #
  1. Attempting to load captions...
+ # """ @$('.video-wrapper').after """ -
  1. Attempting to load captions...
+
    """ @$('.video-controls .secondary-controls').append """ Captions @@ -24,6 +31,8 @@ class @VideoCaption extends Subview @captions = captions.text @start = captions.start + @loaded = true + if onTouchBasedDevice() $('.subtitles li').html "Caption will be displayed when you start playing the video." else @@ -47,37 +56,40 @@ class @VideoCaption extends Subview @rendered = true search: (time) -> - min = 0 - max = @start.length - 1 + if @loaded + min = 0 + max = @start.length - 1 - while min < max - index = Math.ceil((max + min) / 2) - if time < @start[index] - max = index - 1 - if time >= @start[index] - min = index - - return min + while min < max + index = Math.ceil((max + min) / 2) + if time < @start[index] + max = index - 1 + if time >= @start[index] + min = index + return min play: -> - @renderCaption() unless @rendered - @playing = true + if @loaded + @renderCaption() unless @rendered + @playing = true pause: -> - @playing = false + if @loaded + @playing = false updatePlayTime: (time) -> - # This 250ms offset is required to match the video speed - time = Math.round(Time.convert(time, @currentSpeed, '1.0') * 1000 + 250) - newIndex = @search time + if @loaded + # This 250ms offset is required to match the video speed + time = Math.round(Time.convert(time, @currentSpeed, '1.0') * 1000 + 250) + newIndex = @search time - if newIndex != undefined && @currentIndex != newIndex - if @currentIndex - @$(".subtitles li.current").removeClass('current') - @$(".subtitles li[data-index='#{newIndex}']").addClass('current') + if newIndex != undefined && @currentIndex != newIndex + if @currentIndex + @$(".subtitles li.current").removeClass('current') + @$(".subtitles li[data-index='#{newIndex}']").addClass('current') - @currentIndex = newIndex - @scrollCaption() + @currentIndex = newIndex + @scrollCaption() resize: => @$('.subtitles').css maxHeight: @captionHeight() diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py index d6dd85deea..c3bbc1e508 100644 --- a/common/lib/xmodule/xmodule/modulestore/__init__.py +++ b/common/lib/xmodule/xmodule/modulestore/__init__.py @@ -13,18 +13,21 @@ from xmodule.errortracker import ErrorLog, make_error_tracker log = logging.getLogger('mitx.' + 'modulestore') + URL_RE = re.compile(""" (?P[^:]+):// (?P[^/]+)/ (?P[^/]+)/ (?P[^/]+)/ - (?P[^/]+) - (/(?P[^/]+))? + (?P[^@]+) + (@(?P[^/]+))? """, re.VERBOSE) # TODO (cpennington): We should decide whether we want to expand the # list of valid characters in a location INVALID_CHARS = re.compile(r"[^\w.-]") +# Names are allowed to have colons. +INVALID_CHARS_NAME = re.compile(r"[^\w.:-]") _LocationBase = namedtuple('LocationBase', 'tag org course category name revision') @@ -34,7 +37,7 @@ class Location(_LocationBase): Encodes a location. Locations representations of URLs of the - form {tag}://{org}/{course}/{category}/{name}[/{revision}] + form {tag}://{org}/{course}/{category}/{name}[@{revision}] However, they can also be represented a dictionaries (specifying each component), tuples or list (specified in order), or as strings of the url @@ -81,7 +84,7 @@ class Location(_LocationBase): location - Can be any of the following types: string: should be of the form - {tag}://{org}/{course}/{category}/{name}[/{revision}] + {tag}://{org}/{course}/{category}/{name}[@{revision}] list: should be of the form [tag, org, course, category, name, revision] @@ -99,10 +102,11 @@ class Location(_LocationBase): ommitted. Components must be composed of alphanumeric characters, or the - characters '_', '-', and '.' + characters '_', '-', and '.'. The name component is additionally allowed to have ':', + which is interpreted specially for xml storage. - Components may be set to None, which may be interpreted by some contexts - to mean wildcard selection + Components may be set to None, which may be interpreted in some contexts + to mean wildcard selection. """ @@ -116,14 +120,23 @@ class Location(_LocationBase): return _LocationBase.__new__(_cls, *([None] * 6)) def check_dict(dict_): - check_list(dict_.itervalues()) + # Order matters, so flatten out into a list + keys = ['tag', 'org', 'course', 'category', 'name', 'revision'] + list_ = [dict_[k] for k in keys] + check_list(list_) def check_list(list_): - for val in list_: - if val is not None and INVALID_CHARS.search(val) is not None: + def check(val, regexp): + if val is not None and regexp.search(val) is not None: log.debug('invalid characters val="%s", list_="%s"' % (val, list_)) raise InvalidLocationError(location) + list_ = list(list_) + for val in list_[:4] + [list_[5]]: + check(val, INVALID_CHARS) + # names allow colons + check(list_[4], INVALID_CHARS_NAME) + if isinstance(location, basestring): match = URL_RE.match(location) if match is None: @@ -162,7 +175,7 @@ class Location(_LocationBase): """ url = "{tag}://{org}/{course}/{category}/{name}".format(**self.dict()) if self.revision: - url += "/" + self.revision + url += "@" + self.revision return url def html_id(self): @@ -170,6 +183,7 @@ class Location(_LocationBase): Return a string with a version of the location that is safe for use in html id attributes """ + # TODO: is ':' ok in html ids? return "-".join(str(v) for v in self.list() if v is not None).replace('.', '_') diff --git a/common/lib/xmodule/xmodule/modulestore/django.py b/common/lib/xmodule/xmodule/modulestore/django.py index 6a7315a074..0b86c2fea4 100644 --- a/common/lib/xmodule/xmodule/modulestore/django.py +++ b/common/lib/xmodule/xmodule/modulestore/django.py @@ -5,8 +5,8 @@ Passes settings.MODULESTORE as kwargs to MongoModuleStore """ from __future__ import absolute_import - from importlib import import_module +from os import environ from django.conf import settings @@ -43,3 +43,8 @@ def modulestore(name='default'): ) return _MODULESTORES[name] + +# if 'DJANGO_SETTINGS_MODULE' in environ: +# # Initialize the modulestores immediately +# for store_name in settings.MODULESTORE: +# modulestore(store_name) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_location.py b/common/lib/xmodule/xmodule/modulestore/tests/test_location.py index 70c6351685..529b1f88eb 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_location.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_location.py @@ -10,7 +10,7 @@ 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@revision") input_dict = { @@ -21,18 +21,28 @@ input_dict = { 'org': 'org' } + +also_valid_dict = { + 'tag': 'tag', + 'course': 'course', + 'category': 'category', + 'name': 'name:more_name', + 'org': 'org' +} + + input_list = ['tag', 'org', 'course', 'category', 'name'] input_str = "tag://org/course/category/name" -input_str_rev = "tag://org/course/category/name/revision" +input_str_rev = "tag://org/course/category/name@revision" -valid = (input_list, input_dict, input_str, input_str_rev) +valid = (input_list, input_dict, input_str, input_str_rev, also_valid_dict) invalid_dict = { 'tag': 'tag', 'course': 'course', 'category': 'category', - 'name': 'name/more_name', + 'name': 'name@more_name', 'org': 'org' } @@ -45,8 +55,9 @@ invalid_dict2 = { } invalid = ("foo", ["foo"], ["foo", "bar"], - ["foo", "bar", "baz", "blat", "foo/bar"], - "tag://org/course/category/name with spaces/revision", + ["foo", "bar", "baz", "blat:blat", "foo:bar"], # ':' ok in name, not in category + "tag://org/course/category/name with spaces@revision", + "tag://org/course/category/name/with/slashes@revision", invalid_dict, invalid_dict2) @@ -62,16 +73,15 @@ def test_dict(): assert_equals(dict(revision=None, **input_dict), Location(input_dict).dict()) input_dict['revision'] = 'revision' - assert_equals("tag://org/course/category/name/revision", Location(input_dict).url()) + assert_equals("tag://org/course/category/name@revision", Location(input_dict).url()) assert_equals(input_dict, Location(input_dict).dict()) - def test_list(): assert_equals("tag://org/course/category/name", Location(input_list).url()) assert_equals(input_list + [None], Location(input_list).list()) input_list.append('revision') - assert_equals("tag://org/course/category/name/revision", Location(input_list).url()) + assert_equals("tag://org/course/category/name@revision", Location(input_list).url()) assert_equals(input_list, Location(input_list).list()) @@ -87,8 +97,10 @@ def test_none(): def test_invalid_locations(): assert_raises(InvalidLocationError, Location, "foo") assert_raises(InvalidLocationError, Location, ["foo", "bar"]) + assert_raises(InvalidLocationError, Location, ["foo", "bar", "baz", "blat/blat", "foo"]) assert_raises(InvalidLocationError, Location, ["foo", "bar", "baz", "blat", "foo/bar"]) - assert_raises(InvalidLocationError, Location, "tag://org/course/category/name with spaces/revision") + assert_raises(InvalidLocationError, Location, "tag://org/course/category/name with spaces@revision") + assert_raises(InvalidLocationError, Location, "tag://org/course/category/name/revision") def test_equality(): diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index 3eca72987e..92eca8f5e6 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -1,3 +1,4 @@ +import hashlib import json import logging import os @@ -43,14 +44,76 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): xmlstore: the XMLModuleStore to store the loaded modules in """ - self.unnamed_modules = 0 - self.used_slugs = set() + self.unnamed = defaultdict(int) # category -> num of new url_names for that category + self.used_names = defaultdict(set) # category -> set of used url_names self.org, self.course, self.url_name = course_id.split('/') def process_xml(xml): """Takes an xml string, and returns a XModuleDescriptor created from that xml. """ + + def make_name_unique(xml_data): + """ + Make sure that the url_name of xml_data is unique. If a previously loaded + unnamed descriptor stole this element's url_name, create a new one. + + Removes 'slug' attribute if present, and adds or overwrites the 'url_name' attribute. + """ + # VS[compat]. Take this out once course conversion is done (perhaps leave the uniqueness check) + + attr = xml_data.attrib + tag = xml_data.tag + id = lambda x: x + # Things to try to get a name, in order (key, cleaning function, remove key after reading?) + lookups = [('url_name', id, False), + ('slug', id, True), + ('name', Location.clean, False), + ('display_name', Location.clean, False)] + + url_name = None + for key, clean, remove in lookups: + if key in attr: + url_name = clean(attr[key]) + if remove: + del attr[key] + break + + def fallback_name(): + """Return the fallback name for this module. This is a function instead of a variable + because we want it to be lazy.""" + # use the hash of the content--the first 12 bytes should be plenty. + return tag + "_" + hashlib.sha1(xml).hexdigest()[:12] + + # Fallback if there was nothing we could use: + if url_name is None or url_name == "": + url_name = fallback_name() + # Don't log a warning--we don't need this in the log. Do + # put it in the error tracker--content folks need to see it. + need_uniq_names = ('problem', 'sequence', 'video', 'course', 'chapter') + + if tag in need_uniq_names: + error_tracker("ERROR: no name of any kind specified for {tag}. Student " + "state won't work right. Problem xml: '{xml}...'".format(tag=tag, xml=xml[:100])) + else: + # TODO (vshnayder): We may want to enable this once course repos are cleaned up. + # (or we may want to give up on the requirement for non-state-relevant issues...) + #error_tracker("WARNING: no name specified for module. xml='{0}...'".format(xml[:100])) + pass + + # Make sure everything is unique + if url_name in self.used_names[tag]: + msg = ("Non-unique url_name in xml. This may break content. url_name={0}. Content={1}" + .format(url_name, xml[:100])) + error_tracker("ERROR: " + msg) + log.warning(msg) + # Just set name to fallback_name--if there are multiple things with the same fallback name, + # they are actually identical, so it's fragile, but not immediately broken. + url_name = fallback_name() + + self.used_names[tag].add(url_name) + xml_data.set('url_name', url_name) + try: # VS[compat] # TODO (cpennington): Remove this once all fall 2012 courses @@ -62,32 +125,11 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): err=str(err), xml=xml)) raise - # VS[compat]. Take this out once course conversion is done - if xml_data.get('slug') is None and xml_data.get('url_name') is None: - if xml_data.get('name'): - slug = Location.clean(xml_data.get('name')) - elif xml_data.get('display_name'): - slug = Location.clean(xml_data.get('display_name')) - else: - self.unnamed_modules += 1 - slug = '{tag}_{count}'.format(tag=xml_data.tag, - count=self.unnamed_modules) - - while slug in self.used_slugs: - self.unnamed_modules += 1 - slug = '{slug}_{count}'.format(slug=slug, - count=self.unnamed_modules) - - self.used_slugs.add(slug) - # log.debug('-> slug=%s' % slug) - xml_data.set('url_name', slug) + make_name_unique(xml_data) descriptor = XModuleDescriptor.load_from_xml( etree.tostring(xml_data), self, self.org, self.course, xmlstore.default_class) - - #log.debug('==> importing descriptor location %s' % - # repr(descriptor.location)) descriptor.metadata['data_dir'] = course_dir xmlstore.modules[course_id][descriptor.location] = descriptor diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py index 2a380bb8be..f95a92397a 100644 --- a/common/lib/xmodule/xmodule/tests/__init__.py +++ b/common/lib/xmodule/xmodule/tests/__init__.py @@ -19,6 +19,8 @@ import capa.calc as calc import capa.capa_problem as lcp from capa.correctmap import CorrectMap from capa.util import convert_files_to_filenames +from capa.xqueue_interface import dateformat +from datetime import datetime from xmodule import graders, x_module from xmodule.x_module import ModuleSystem from xmodule.graders import Score, aggregate_scores @@ -35,8 +37,9 @@ i4xs = ModuleSystem( user=Mock(), filestore=fs.osfs.OSFS(os.path.dirname(os.path.realpath(__file__))+"/test_files"), debug=True, - xqueue={'interface':None, 'callback_url':'/', 'default_queuename': 'testqueue'}, - node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules") + xqueue={'interface':None, 'callback_url':'/', 'default_queuename': 'testqueue', 'waittime': 10}, + node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"), + anonymous_student_id = 'student' ) @@ -282,71 +285,143 @@ class StringResponseWithHintTest(unittest.TestCase): class CodeResponseTest(unittest.TestCase): ''' Test CodeResponse + TODO: Add tests for external grader messages ''' + @staticmethod + def make_queuestate(key, time): + timestr = datetime.strftime(time, dateformat) + return {'key': key, 'time': timestr} + + def test_is_queued(self): + ''' + Simple test of whether LoncapaProblem knows when it's been queued + ''' + problem_file = os.path.join(os.path.dirname(__file__), "test_files/coderesponse.xml") + with open(problem_file) as input_file: + test_lcp = lcp.LoncapaProblem(input_file.read(), '1', system=i4xs) + + answer_ids = sorted(test_lcp.get_question_answers()) + + # CodeResponse requires internal CorrectMap state. Build it now in the unqueued state + cmap = CorrectMap() + for answer_id in answer_ids: + cmap.update(CorrectMap(answer_id=answer_id, queuestate=None)) + test_lcp.correct_map.update(cmap) + + self.assertEquals(test_lcp.is_queued(), False) + + # Now we queue the LCP + cmap = CorrectMap() + for i, answer_id in enumerate(answer_ids): + queuestate = CodeResponseTest.make_queuestate(i, datetime.now()) + cmap.update(CorrectMap(answer_id=answer_ids[i], queuestate=queuestate)) + test_lcp.correct_map.update(cmap) + + self.assertEquals(test_lcp.is_queued(), True) + + def test_update_score(self): - problem_file = os.path.dirname(__file__) + "/test_files/coderesponse.xml" - test_lcp = lcp.LoncapaProblem(open(problem_file).read(), '1', system=i4xs) + ''' + Test whether LoncapaProblem.update_score can deliver queued result to the right subproblem + ''' + problem_file = os.path.join(os.path.dirname(__file__), "test_files/coderesponse.xml") + with open(problem_file) as input_file: + test_lcp = lcp.LoncapaProblem(input_file.read(), '1', system=i4xs) - # CodeResponse requires internal CorrectMap state. Build it now in the 'queued' state - old_cmap = CorrectMap() - answer_ids = sorted(test_lcp.get_question_answers().keys()) - numAnswers = len(answer_ids) - for i in range(numAnswers): - old_cmap.update(CorrectMap(answer_id=answer_ids[i], queuekey=1000 + i)) + answer_ids = sorted(test_lcp.get_question_answers()) - # TODO: Message format inherited from ExternalResponse - #correct_score_msg = "EXACT_ANSMESSAGE" - #incorrect_score_msg = "WRONG_FORMATMESSAGE" + # CodeResponse requires internal CorrectMap state. Build it now in the queued state + old_cmap = CorrectMap() + for i, answer_id in enumerate(answer_ids): + queuekey = 1000 + i + queuestate = CodeResponseTest.make_queuestate(1000+i, datetime.now()) + old_cmap.update(CorrectMap(answer_id=answer_ids[i], queuestate=queuestate)) - # New message format common to external graders - correct_score_msg = json.dumps({'correct':True, 'score':1, 'msg':'MESSAGE'}) - incorrect_score_msg = json.dumps({'correct':False, 'score':0, 'msg':'MESSAGE'}) + # Message format common to external graders + grader_msg = 'MESSAGE' # Must be valid XML + correct_score_msg = json.dumps({'correct':True, 'score':1, 'msg': grader_msg}) + incorrect_score_msg = json.dumps({'correct':False, 'score':0, 'msg': grader_msg}) - xserver_msgs = {'correct': correct_score_msg, - 'incorrect': incorrect_score_msg, - } + xserver_msgs = {'correct': correct_score_msg, + 'incorrect': incorrect_score_msg,} - # Incorrect queuekey, state should not be updated - for correctness in ['correct', 'incorrect']: - test_lcp.correct_map = CorrectMap() - test_lcp.correct_map.update(old_cmap) # Deep copy - - test_lcp.update_score(xserver_msgs[correctness], queuekey=0) - self.assertEquals(test_lcp.correct_map.get_dict(), old_cmap.get_dict()) # Deep comparison - - for i in range(numAnswers): - self.assertTrue(test_lcp.correct_map.is_queued(answer_ids[i])) # Should be still queued, since message undelivered - - # Correct queuekey, state should be updated - for correctness in ['correct', 'incorrect']: - for i in range(numAnswers): # Target specific answer_id's + # Incorrect queuekey, state should not be updated + for correctness in ['correct', 'incorrect']: test_lcp.correct_map = CorrectMap() - test_lcp.correct_map.update(old_cmap) + test_lcp.correct_map.update(old_cmap) # Deep copy - new_cmap = CorrectMap() - new_cmap.update(old_cmap) - npoints = 1 if correctness=='correct' else 0 - new_cmap.set(answer_id=answer_ids[i], npoints=npoints, correctness=correctness, msg='MESSAGE', queuekey=None) + test_lcp.update_score(xserver_msgs[correctness], queuekey=0) + self.assertEquals(test_lcp.correct_map.get_dict(), old_cmap.get_dict()) # Deep comparison - test_lcp.update_score(xserver_msgs[correctness], queuekey=1000 + i) - self.assertEquals(test_lcp.correct_map.get_dict(), new_cmap.get_dict()) + for answer_id in answer_ids: + self.assertTrue(test_lcp.correct_map.is_queued(answer_id)) # Should be still queued, since message undelivered - for j in range(numAnswers): - if j == i: - self.assertFalse(test_lcp.correct_map.is_queued(answer_ids[j])) # Should be dequeued, message delivered - else: - self.assertTrue(test_lcp.correct_map.is_queued(answer_ids[j])) # Should be queued, message undelivered + # Correct queuekey, state should be updated + for correctness in ['correct', 'incorrect']: + for i, answer_id in enumerate(answer_ids): + test_lcp.correct_map = CorrectMap() + test_lcp.correct_map.update(old_cmap) - def test_convert_files_to_filenames(self): - problem_file = os.path.dirname(__file__) + "/test_files/coderesponse.xml" - fp = open(problem_file) - answers_with_file = {'1_2_1': 'String-based answer', - '1_3_1': ['answer1', 'answer2', 'answer3'], - '1_4_1': [fp, fp]} - answers_converted = convert_files_to_filenames(answers_with_file) - self.assertEquals(answers_converted['1_2_1'], 'String-based answer') - self.assertEquals(answers_converted['1_3_1'], ['answer1', 'answer2', 'answer3']) - self.assertEquals(answers_converted['1_4_1'], [fp.name, fp.name]) + new_cmap = CorrectMap() + new_cmap.update(old_cmap) + npoints = 1 if correctness=='correct' else 0 + new_cmap.set(answer_id=answer_id, npoints=npoints, correctness=correctness, msg=grader_msg, queuestate=None) + + test_lcp.update_score(xserver_msgs[correctness], queuekey=1000 + i) + self.assertEquals(test_lcp.correct_map.get_dict(), new_cmap.get_dict()) + + for j, test_id in enumerate(answer_ids): + if j == i: + self.assertFalse(test_lcp.correct_map.is_queued(test_id)) # Should be dequeued, message delivered + else: + self.assertTrue(test_lcp.correct_map.is_queued(test_id)) # Should be queued, message undelivered + + + def test_recentmost_queuetime(self): + ''' + Test whether the LoncapaProblem knows about the time of queue requests + ''' + problem_file = os.path.join(os.path.dirname(__file__), "test_files/coderesponse.xml") + with open(problem_file) as input_file: + test_lcp = lcp.LoncapaProblem(input_file.read(), '1', system=i4xs) + + answer_ids = sorted(test_lcp.get_question_answers()) + + # CodeResponse requires internal CorrectMap state. Build it now in the unqueued state + cmap = CorrectMap() + for answer_id in answer_ids: + cmap.update(CorrectMap(answer_id=answer_id, queuestate=None)) + test_lcp.correct_map.update(cmap) + + self.assertEquals(test_lcp.get_recentmost_queuetime(), None) + + # CodeResponse requires internal CorrectMap state. Build it now in the queued state + cmap = CorrectMap() + for i, answer_id in enumerate(answer_ids): + queuekey = 1000 + i + latest_timestamp = datetime.now() + queuestate = CodeResponseTest.make_queuestate(1000+i, latest_timestamp) + cmap.update(CorrectMap(answer_id=answer_id, queuestate=queuestate)) + test_lcp.correct_map.update(cmap) + + # Queue state only tracks up to second + latest_timestamp = datetime.strptime(datetime.strftime(latest_timestamp, dateformat), dateformat) + + self.assertEquals(test_lcp.get_recentmost_queuetime(), latest_timestamp) + + def test_convert_files_to_filenames(self): + ''' + Test whether file objects are converted to filenames without altering other structures + ''' + problem_file = os.path.join(os.path.dirname(__file__), "test_files/coderesponse.xml") + with open(problem_file) as fp: + answers_with_file = {'1_2_1': 'String-based answer', + '1_3_1': ['answer1', 'answer2', 'answer3'], + '1_4_1': [fp, fp]} + answers_converted = convert_files_to_filenames(answers_with_file) + self.assertEquals(answers_converted['1_2_1'], 'String-based answer') + self.assertEquals(answers_converted['1_3_1'], ['answer1', 'answer2', 'answer3']) + self.assertEquals(answers_converted['1_4_1'], [fp.name, fp.name]) class ChoiceResponseTest(unittest.TestCase): diff --git a/common/lib/xmodule/xmodule/tests/test_files/coderesponse.xml b/common/lib/xmodule/xmodule/tests/test_files/coderesponse.xml index 42b6e0a54a..1c0bf8d4e6 100644 --- a/common/lib/xmodule/xmodule/tests/test_files/coderesponse.xml +++ b/common/lib/xmodule/xmodule/tests/test_files/coderesponse.xml @@ -9,91 +9,23 @@ Write a program to compute the square of a number - - + + def square(x): + answer + grader stuff + -Write a program to compute the cube of a number +Write a program to compute the square of a number - - + + def square(x): + answer + grader stuff + diff --git a/common/lib/xmodule/xmodule/tests/test_files/coderesponse_externalresponseformat.xml b/common/lib/xmodule/xmodule/tests/test_files/coderesponse_externalresponseformat.xml new file mode 100644 index 0000000000..42b6e0a54a --- /dev/null +++ b/common/lib/xmodule/xmodule/tests/test_files/coderesponse_externalresponseformat.xml @@ -0,0 +1,101 @@ + + +

    Code response

    + +

    +

    + + +Write a program to compute the square of a number + + + + + + + + +Write a program to compute the cube of a number + + + + + + + +
    +
    diff --git a/common/lib/xmodule/xmodule/tests/test_files/js/compiled/javascriptresponse.js b/common/lib/xmodule/xmodule/tests/test_files/js/compiled/javascriptresponse.js new file mode 100644 index 0000000000..6670c6a09a --- /dev/null +++ b/common/lib/xmodule/xmodule/tests/test_files/js/compiled/javascriptresponse.js @@ -0,0 +1,50 @@ +// Generated by CoffeeScript 1.3.3 +(function() { + var MinimaxProblemDisplay, root, + __hasProp = {}.hasOwnProperty, + __extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }; + + MinimaxProblemDisplay = (function(_super) { + + __extends(MinimaxProblemDisplay, _super); + + function MinimaxProblemDisplay(state, submission, evaluation, container, submissionField, parameters) { + this.state = state; + this.submission = submission; + this.evaluation = evaluation; + this.container = container; + this.submissionField = submissionField; + this.parameters = parameters != null ? parameters : {}; + MinimaxProblemDisplay.__super__.constructor.call(this, this.state, this.submission, this.evaluation, this.container, this.submissionField, this.parameters); + } + + MinimaxProblemDisplay.prototype.render = function() {}; + + MinimaxProblemDisplay.prototype.createSubmission = function() { + var id, value, _ref, _results; + this.newSubmission = {}; + if (this.submission != null) { + _ref = this.submission; + _results = []; + for (id in _ref) { + value = _ref[id]; + _results.push(this.newSubmission[id] = value); + } + return _results; + } + }; + + MinimaxProblemDisplay.prototype.getCurrentSubmission = function() { + return this.newSubmission; + }; + + return MinimaxProblemDisplay; + + })(XProblemDisplay); + + root = typeof exports !== "undefined" && exports !== null ? exports : this; + + root.TestProblemDisplay = TestProblemDisplay; + +}).call(this); +; diff --git a/common/lib/xmodule/xmodule/tests/test_import.py b/common/lib/xmodule/xmodule/tests/test_import.py index 3454366c1a..a369850209 100644 --- a/common/lib/xmodule/xmodule/tests/test_import.py +++ b/common/lib/xmodule/xmodule/tests/test_import.py @@ -255,3 +255,37 @@ class ImportTestCase(unittest.TestCase): two_toy_video = modulestore.get_instance(two_toy_id, location) self.assertEqual(toy_video.metadata['youtube'], "1.0:p2Q6BrNhdh8") self.assertEqual(two_toy_video.metadata['youtube'], "1.0:p2Q6BrNhdh9") + + + def test_colon_in_url_name(self): + """Ensure that colons in url_names convert to file paths properly""" + + print "Starting import" + modulestore = XMLModuleStore(DATA_DIR, eager=True, course_dirs=['toy']) + + courses = modulestore.get_courses() + self.assertEquals(len(courses), 1) + course = courses[0] + course_id = course.id + + print "course errors:" + for (msg, err) in modulestore.get_item_errors(course.location): + print msg + print err + + chapters = course.get_children() + self.assertEquals(len(chapters), 2) + + ch2 = chapters[1] + self.assertEquals(ch2.url_name, "secret:magic") + + print "Ch2 location: ", ch2.location + + also_ch2 = modulestore.get_instance(course_id, ch2.location) + self.assertEquals(ch2, also_ch2) + + print "making sure html loaded" + cloc = course.location + loc = Location(cloc.tag, cloc.org, cloc.course, 'html', 'secret:toylab') + html = modulestore.get_instance(course_id, loc) + self.assertEquals(html.display_name, "Toy lab") diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py index 7d11dea283..a18a470272 100644 --- a/common/lib/xmodule/xmodule/video_module.py +++ b/common/lib/xmodule/xmodule/video_module.py @@ -30,6 +30,7 @@ class VideoModule(XModule): xmltree = etree.fromstring(self.definition['data']) self.youtube = xmltree.get('youtube') self.position = 0 + self.show_captions = xmltree.get('show_captions', 'true') if instance_state is not None: state = json.loads(instance_state) @@ -75,6 +76,7 @@ class VideoModule(XModule): 'display_name': self.display_name, # TODO (cpennington): This won't work when we move to data that isn't on the filesystem 'data_dir': self.metadata['data_dir'], + 'show_captions': self.show_captions }) diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index c581911c03..0dc16bd976 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -717,7 +717,8 @@ class ModuleSystem(object): filestore=None, debug=False, xqueue=None, - node_path=""): + node_path="", + anonymous_student_id=''): ''' Create a closure around the system environment. @@ -742,11 +743,16 @@ class ModuleSystem(object): at settings.DATA_DIR. xqueue - Dict containing XqueueInterface object, as well as parameters - for the specific StudentModule + for the specific StudentModule: + xqueue = {'interface': XQueueInterface object, + 'callback_url': Callback into the LMS, + 'queue_name': Target queuename in Xqueue} replace_urls - TEMPORARY - A function like static_replace.replace_urls that capa_module can use to fix up the static urls in ajax results. + + anonymous_student_id - Used for tracking modules with student id ''' self.ajax_url = ajax_url self.xqueue = xqueue @@ -758,6 +764,7 @@ class ModuleSystem(object): self.seed = user.id if user is not None else 0 self.replace_urls = replace_urls self.node_path = node_path + self.anonymous_student_id = anonymous_student_id def get(self, attr): ''' provide uniform access to attributes (like etree).''' diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py index b6f791ffc6..25dc4e0c6e 100644 --- a/common/lib/xmodule/xmodule/xml_module.py +++ b/common/lib/xmodule/xmodule/xml_module.py @@ -12,6 +12,12 @@ import sys log = logging.getLogger(__name__) +def name_to_pathname(name): + """ + Convert a location name for use in a path: replace ':' with '/'. + This allows users of the xml format to organize content into directories + """ + return name.replace(':', '/') def is_pointer_tag(xml_obj): """ @@ -245,8 +251,8 @@ class XmlDescriptor(XModuleDescriptor): # VS[compat] -- detect new-style each-in-a-file mode if is_pointer_tag(xml_object): # new style: - # read the actual definition file--named using url_name - filepath = cls._format_filepath(xml_object.tag, url_name) + # read the actual definition file--named using url_name.replace(':','/') + filepath = cls._format_filepath(xml_object.tag, name_to_pathname(url_name)) definition_xml = cls.load_file(filepath, system.resources_fs, location) else: definition_xml = xml_object # this is just a pointer, not the real definition content @@ -292,7 +298,8 @@ class XmlDescriptor(XModuleDescriptor): """If this returns True, write the definition of this descriptor to a separate file. - NOTE: Do not override this without a good reason. It is here specifically for customtag... + NOTE: Do not override this without a good reason. It is here + specifically for customtag... """ return True @@ -335,7 +342,8 @@ class XmlDescriptor(XModuleDescriptor): if self.export_to_file(): # Write the definition to a file - filepath = self.__class__._format_filepath(self.category, self.url_name) + url_path = name_to_pathname(self.url_name) + filepath = self.__class__._format_filepath(self.category, url_path) resource_fs.makedir(os.path.dirname(filepath), allow_recreate=True) with resource_fs.open(filepath, 'w') as file: file.write(etree.tostring(xml_object, pretty_print=True)) diff --git a/common/test/data/toy/chapter/secret/magic.xml b/common/test/data/toy/chapter/secret/magic.xml new file mode 100644 index 0000000000..dd16380a70 --- /dev/null +++ b/common/test/data/toy/chapter/secret/magic.xml @@ -0,0 +1,3 @@ + + diff --git a/common/test/data/toy/course/2012_Fall.xml b/common/test/data/toy/course/2012_Fall.xml index d34eb9d56a..c87fccd9ab 100644 --- a/common/test/data/toy/course/2012_Fall.xml +++ b/common/test/data/toy/course/2012_Fall.xml @@ -1,9 +1,10 @@ - + + diff --git a/common/test/data/toy/html/toylab.html b/common/test/data/toy/html/secret/toylab.html similarity index 100% rename from common/test/data/toy/html/toylab.html rename to common/test/data/toy/html/secret/toylab.html diff --git a/common/test/data/toy/html/toylab.xml b/common/test/data/toy/html/secret/toylab.xml similarity index 100% rename from common/test/data/toy/html/toylab.xml rename to common/test/data/toy/html/secret/toylab.xml diff --git a/common/test/data/toy/policies/2012_Fall.json b/common/test/data/toy/policies/2012_Fall.json index 6c501d66f8..5ea437a8e4 100644 --- a/common/test/data/toy/policies/2012_Fall.json +++ b/common/test/data/toy/policies/2012_Fall.json @@ -11,7 +11,7 @@ "display_name": "Toy Videos", "format": "Lecture Sequence" }, - "html/toylab": { + "html/secret:toylab": { "display_name": "Toy lab" }, "video/Video_Resources": { diff --git a/create-dev-env.sh b/create-dev-env.sh index 96f212c9b5..3664129775 100755 --- a/create-dev-env.sh +++ b/create-dev-env.sh @@ -105,7 +105,7 @@ NUMPY_VER="1.6.2" SCIPY_VER="0.10.1" BREW_FILE="$BASE/mitx/brew-formulas.txt" LOG="/var/tmp/install-$(date +%Y%m%d-%H%M%S).log" -APT_PKGS="curl git python-virtualenv build-essential python-dev gfortran liblapack-dev libfreetype6-dev libpng12-dev libxml2-dev libxslt-dev yui-compressor coffeescript graphviz" +APT_PKGS="curl git python-virtualenv build-essential python-dev gfortran liblapack-dev libfreetype6-dev libpng12-dev libxml2-dev libxslt-dev yui-compressor coffeescript graphviz libgraphviz-dev" if [[ $EUID -eq 0 ]]; then error "This script should not be run using sudo or as the root user" diff --git a/doc/xml-format.md b/doc/xml-format.md index 2a9e379ccc..3994a23c5d 100644 --- a/doc/xml-format.md +++ b/doc/xml-format.md @@ -1,147 +1,321 @@ -This doc is a rough spec of our xml format +# edX xml format tutorial -Every content element (within a course) should have a unique id. This id is formed as {category}/{url_name}. Categories are the different tag types ('chapter', 'problem', 'html', 'sequential', etc). Url_name is a string containing a-z, A-Z, dot (.) and _. This is what appears in urls that point to this object. +## Goals of this document -File layout: +* This was written assuming the reader has no prior programming/CS knowledge and has jumped cold turkey into the edX platform. +* To educate the reader on how to build and maintain the back end structure of the course content. This is important for debugging and standardization. +* After reading this, you should be able to add content to a course and make sure it shows up in the courseware and does not break the code. +* __Prerequisites:__ it would be helpful to know a little bit about xml. Here is a [simple example](http://www.ultraslavonic.info/intro-to-xml/) if you've never seen it before. -- Xml files have content -- "policy", which is also called metadata in various places, should live in a policy file. +## Outline -- each module (except customtag and course, which are special, see below) should live in a file, located at {category}/{url_name].xml -To include this module in another one (e.g. to put a problem in a vertical), put in a "pointer tag": <{category} url_name="{url_name}"/>. When we read that, we'll load the actual contents. +* First, we will show a sample course structure as a case study/model of how xml and files in a course are organized to introductory understanding. -Customtag is already a pointer, you can just use it in place: +* More technical details are below, including discussion of some special cases. -Course tags: - - the top level course pointer tag lives in course.xml - - have 2 extra required attributes: "org" and "course" -- organization name, and course name. Note that the course name is referring to the platonic ideal of this course, not to any particular run of this course. The url_name should be particular run of this course. E.g. -If course.xml contains: - +## Introduction -we would load the actual course definition from course/2012.xml +* The course is organized hierarchically. We start by describing course-wide parameters, then break the course into chapters, and then go deeper and deeper until we reach a specific pset, video, etc. -To support multiple different runs of the course, you could have a different course.xml, containing +* You could make an analogy to finding a green shirt in your house - front door -> bedroom -> closet -> drawer -> shirts -> green shirt - -which would load the Harvard-internal version from course/2012H.xml +## Case Study -If there is only one run of the course for now, just have a single course.xml with the right url_name. +Let's jump right in by looking at the directory structure of a very simple toy course: -If there is more than one run of the course, the different course root pointer files should live in -roots/url_name.xml, and course.xml should be a symbolic link to the one you want to run in your dev instance. + toy/ + course + course.xml + problem + policies + roots -If you want to run both versions, you need to checkout the repo twice, and have course.xml point to different root/{url_name}.xml files. +The only top level file is `course.xml`, which should contain one line, looking something like this: -Policies: - - the policy for a course url_name lives in policies/{url_name}.json + -The format is called "json", and is best shown by example (though also feel free to google :) +This gives all the information to uniquely identify a particular run of any course--which organization is producing the course, what the course name is, and what "run" this is, specified via the `url_name` attribute. -the file is a dictionary (mapping from keys to values, syntax "{ key : value, key2 : value2, etc}" +Obviously, this doesn't actually specify any of the course content, so we need to find that next. To know where to look, you need to know the standard organizational structure of our system: _course elements are uniquely identified by the combination `(category, url_name)`_. In this case, we are looking for a `course` element with the `url_name` "2012_Fall". The definition of this element will be in `course/2012_Fall.xml`. Let's look there next: -Keys are in the form "{category}/{url_name}", which should uniquely id a content element. -Values are dictionaries of the form {"metadata-key" : "metadata-value"}. +`course/2012_Fall.xml` -metadata can also live in the xml files, but anything defined in the policy file overrides anything in the xml. This is primarily for backwards compatibility, and you should probably not use both. If you do leave some metadata tags in the xml, please be consistent (e.g. if display_names stay in xml, they should all stay in xml). - - note, some xml attributes are not metadata. e.g. in
    {{#thread}} diff --git a/lms/templates/video.html b/lms/templates/video.html index 93273ddb87..bd3ec77fbe 100644 --- a/lms/templates/video.html +++ b/lms/templates/video.html @@ -1,8 +1,8 @@ -% if name is not UNDEFINED and name is not None: -

    ${display_name}

    +% if display_name is not UNDEFINED and display_name is not None: +

    ${display_name}

    % endif -
    +
    diff --git a/lms/templates/wiki/base.html b/lms/templates/wiki/base.html index fc4a2d18d4..8a9686f546 100644 --- a/lms/templates/wiki/base.html +++ b/lms/templates/wiki/base.html @@ -27,21 +27,20 @@ }); } - - + + {% addtoblock 'js' %} {% comment %} These scripts load at the bottom of the body {% endcomment %} - + - + {% with mathjax_mode='wiki' %} {% include "mathjax_include.html" %} {% endwith %} - {% endaddtoblock %} - + {% endblock %} @@ -64,11 +63,12 @@
    {% endfor %} {% endif %} - + {% block wiki_contents %}{% endblock %} - + {% endblock %}
    + {% endblock %} diff --git a/lms/templates/wiki/create.html b/lms/templates/wiki/create.html index 886764ba84..745be08cf8 100644 --- a/lms/templates/wiki/create.html +++ b/lms/templates/wiki/create.html @@ -42,6 +42,7 @@ {% trans "Go back" %}
    + {% include "wiki/includes/cheatsheet.html" %} diff --git a/lms/templates/wiki/edit.html b/lms/templates/wiki/edit.html index f4bd7d138f..65378da4e4 100644 --- a/lms/templates/wiki/edit.html +++ b/lms/templates/wiki/edit.html @@ -40,7 +40,10 @@
    + {% include "wiki/includes/cheatsheet.html" %} {% endblock %} + + diff --git a/lms/templates/wiki/includes/cheatsheet.html b/lms/templates/wiki/includes/cheatsheet.html new file mode 100644 index 0000000000..6f5170e601 --- /dev/null +++ b/lms/templates/wiki/includes/cheatsheet.html @@ -0,0 +1,56 @@ + + diff --git a/lms/templates/wiki/includes/editor_widget.html b/lms/templates/wiki/includes/editor_widget.html new file mode 100644 index 0000000000..2f5dd7ce68 --- /dev/null +++ b/lms/templates/wiki/includes/editor_widget.html @@ -0,0 +1,4 @@ + +

    + Markdown syntax is allowed. See the cheatsheet for help. +

    diff --git a/lms/wsgi.py b/lms/wsgi.py new file mode 100644 index 0000000000..270b019add --- /dev/null +++ b/lms/wsgi.py @@ -0,0 +1,14 @@ +import os + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "lms.envs.aws") + +# This application object is used by the development server +# as well as any WSGI server configured to use this file. +from django.core.wsgi import get_wsgi_application +application = get_wsgi_application() + +from django.conf import settings +from xmodule.modulestore.django import modulestore + +for store_name in settings.MODULESTORE: + modulestore(store_name) diff --git a/repo-requirements.txt b/repo-requirements.txt index 7119106d8b..f98d05ffc9 100644 --- a/repo-requirements.txt +++ b/repo-requirements.txt @@ -1,6 +1,6 @@ -e git://github.com/MITx/django-staticfiles.git@6d2504e5c8#egg=django-staticfiles -e git://github.com/MITx/django-pipeline.git#egg=django-pipeline --e git://github.com/benjaoming/django-wiki.git@cd1c23e1#egg=django-wiki +-e git://github.com/MITx/django-wiki.git@e2e84558#egg=django-wiki -e git://github.com/dementrock/pystache_custom.git@776973740bdaad83a3b029f96e415a7d1e8bec2f#egg=pystache_custom-dev -e common/lib/capa -e common/lib/xmodule diff --git a/requirements.txt b/requirements.txt index 72b13e63ba..3376fc1a1d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -46,4 +46,5 @@ django-sekizai<0.7 django-mptt>=0.5.3 sorl-thumbnail networkx +pygraphviz -r repo-requirements.txt