From 833e7777db3c4f1ede42576a63c912bbd48aed04 Mon Sep 17 00:00:00 2001 From: Arjun Singh Date: Mon, 6 Aug 2012 05:27:36 -0400 Subject: [PATCH 001/187] Watching xmodule js --- run_watch_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/run_watch_data.py b/run_watch_data.py index f5605a5c6a..c6cdd4f0df 100755 --- a/run_watch_data.py +++ b/run_watch_data.py @@ -17,7 +17,7 @@ from watchdog.events import LoggingEventHandler, FileSystemEventHandler # watch fewer or more extensions, you can change EXTENSIONS. To watch all # extensions, add "*" to EXTENSIONS. -WATCH_DIRS = ["../data"] +WATCH_DIRS = ["../data", "common/lib/xmodule/xmodule/js"] EXTENSIONS = ["*", "xml", "js", "css", "coffee", "scss", "html"] WATCH_DIRS = [os.path.abspath(os.path.normpath(dir)) for dir in WATCH_DIRS] From 869e638e07b37e57cd9dd65e455352de7a352297 Mon Sep 17 00:00:00 2001 From: Arjun Singh Date: Mon, 6 Aug 2012 05:28:19 -0400 Subject: [PATCH 002/187] Removing compiled js files in data directories before recompiling them; this causes compilation errors to fail loudly. --- lms/envs/common.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lms/envs/common.py b/lms/envs/common.py index 83a4bd4181..8b41470f92 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -457,6 +457,7 @@ if os.path.isdir(DATA_DIR): js_timestamp = os.stat(js_dir / new_filename).st_mtime if coffee_timestamp <= js_timestamp: continue + os.system("rm %s" % (js_dir / new_filename)) os.system("coffee -c %s" % (js_dir / filename)) PIPELINE_COMPILERS = [ From 2337eeba1e0c74c45af2a184cd1ddd569caf6865 Mon Sep 17 00:00:00 2001 From: Arjun Singh Date: Mon, 6 Aug 2012 18:02:18 -0400 Subject: [PATCH 003/187] Adding javascriptresponse. This responsetype is a framework for problems that are entirely controlled by javascript and graded by Node.js on the server. --- common/lib/capa/capa/capa_problem.py | 2 +- common/lib/capa/capa/inputtypes.py | 29 +++ .../capa/capa/javascript_problem_generator.js | 24 +++ .../capa/capa/javascript_problem_grader.js | 21 +++ common/lib/capa/capa/responsetypes.py | 174 +++++++++++++++++- .../capa/capa/templates/javascriptinput.html | 10 + 6 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 common/lib/capa/capa/javascript_problem_generator.js create mode 100644 common/lib/capa/capa/javascript_problem_grader.js create mode 100644 common/lib/capa/capa/templates/javascriptinput.html diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index ba99ee681e..2dc059e7d7 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -39,7 +39,7 @@ import responsetypes # dict of tagname, Response Class -- this should come from auto-registering response_tag_dict = dict([(x.response_tag, x) for x in responsetypes.__all__]) -entry_types = ['textline', 'schematic', 'textbox', 'imageinput', 'optioninput', 'choicegroup', 'radiogroup', 'checkboxgroup', 'filesubmission'] +entry_types = ['textline', 'schematic', 'textbox', 'imageinput', 'optioninput', 'choicegroup', 'radiogroup', 'checkboxgroup', 'filesubmission', 'javascriptinput'] solution_types = ['solution'] # extra things displayed after "show answers" is pressed response_properties = ["responseparam", "answer"] # these get captured as student responses diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 583d29b82e..2d1da53625 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -11,6 +11,7 @@ Module containing the problem elements which render into input objects - choicegroup - radiogroup - checkboxgroup +- javascriptinput - imageinput (for clickable image) - optioninput (for option list) - filesubmission (upload a file) @@ -246,6 +247,34 @@ def checkboxgroup(element, value, status, render_template, msg=''): html = render_template("choicegroup.html", context) return etree.XML(html) +@register_render_function +def javascriptinput(element, value, status, render_template, msg='null'): + ''' + Hidden field for javascript to communicate via; also loads the required + scripts for rendering the problem and passes data to the problem. + ''' + eid = element.get('id') + params = element.get('params') + problem_state = element.get('problem_state') + display_class = element.get('display_class') + display_file = element.get('display_file') + + # Need to provide a value that JSON can parse if there is no + # student-supplied value yet. + if value == "": + value = 'null' + + escapedict = {'"': '"'} + value = saxutils.escape(value, escapedict) + msg = saxutils.escape(msg, escapedict) + context = {'id': eid, 'params': params, 'display_file': display_file, + 'display_class': display_class, 'problem_state': problem_state, + 'value': value, 'evaluation': msg, + } + html = render_template("javascriptinput.html", context) + return etree.XML(html) + + @register_render_function def textline(element, value, status, render_template, msg=""): diff --git a/common/lib/capa/capa/javascript_problem_generator.js b/common/lib/capa/capa/javascript_problem_generator.js new file mode 100644 index 0000000000..670f6b838e --- /dev/null +++ b/common/lib/capa/capa/javascript_problem_generator.js @@ -0,0 +1,24 @@ +var importAll = function (modulePath) { + module = require(modulePath); + for(key in module){ + global[key] = module[key]; + } +} + +importAll("mersenne-twister-min"); +importAll("xproblem"); + +generatorModulePath = process.argv[2]; +seed = process.argv[3]; +params = JSON.parse(process.argv[4]); + +if(seed==null){ + seed = 4; +}else{ + seed = parseInt(seed); +} + +generatorModule = require(generatorModulePath); +generatorClass = generatorModule.generatorClass; +generator = new generatorClass(seed, params); +console.log(JSON.stringify(generator.generate())); diff --git a/common/lib/capa/capa/javascript_problem_grader.js b/common/lib/capa/capa/javascript_problem_grader.js new file mode 100644 index 0000000000..9e796f0e32 --- /dev/null +++ b/common/lib/capa/capa/javascript_problem_grader.js @@ -0,0 +1,21 @@ +var importAll = function (modulePath) { + module = require(modulePath); + for(key in module){ + global[key] = module[key]; + } +} + +importAll("xproblem"); +importAll("minimax.js"); + +graderModulePath = process.argv[2]; +submission = JSON.parse(process.argv[3]); +problemState = JSON.parse(process.argv[4]); +params = JSON.parse(process.argv[5]); + +graderModule = require(graderModulePath); +graderClass = graderModule.graderClass; +grader = new graderClass(submission, problemState, params); +console.log(JSON.stringify(grader.grade())); +console.log(JSON.stringify(grader.evaluation)); +console.log(JSON.stringify(grader.solution)); diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 12f619a881..903c697459 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -18,6 +18,8 @@ import re import requests import traceback import abc +import os +import xml.sax.saxutils as saxutils # specific library imports from calc import evaluator, UndefinedVariable @@ -273,9 +275,172 @@ class LoncapaResponse(object): #----------------------------------------------------------------------------- + +class JavascriptResponse(LoncapaResponse): + ''' + This response type is used when the student's answer is graded via + Javascript using Node.js. + ''' + + response_tag = 'javascriptresponse' + max_inputfields = 1 + allowed_inputfields = ['javascriptinput'] + + def setup_response(self): + + # Sets up generator, grader, display, and their dependencies. + self.parse_xml() + + self.compile_display_javascript() + + self.params = self.extract_params() + + if self.generator: + self.problem_state = self.generate_problem_state() + print self.problem_state + else: + self.problem_state = None + + self.solution = None + + self.prepare_inputfield() + + 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 + + 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() + + self.display_filename = compiled_filename + + def parse_xml(self): + self.generator_xml = self.xml.xpath('//*[@id=$id]//generator', + id=self.xml.get('id'))[0] + + self.grader_xml = self.xml.xpath('//*[@id=$id]//grader', + id=self.xml.get('id'))[0] + + self.display_xml = self.xml.xpath('//*[@id=$id]//display', + id=self.xml.get('id'))[0] + + self.xml.remove(self.generator_xml) + self.xml.remove(self.grader_xml) + self.xml.remove(self.display_xml) + + self.generator = self.generator_xml.get("src") + self.grader = self.grader_xml.get("src") + self.display = self.display_xml.get("src") + + if self.generator_xml.get("dependencies"): + self.generator_dependencies = self.generator_xml.get("dependencies").split() + + if self.grader_xml.get("dependencies"): + self.grader_dependencies = self.grader_xml.get("dependencies").split() + + if self.display_xml.get("dependencies"): + self.display_dependencies = self.display_xml.get("dependencies").split() + + self.display_class = self.display_xml.get("class") + + def generate_problem_state(self): + + js_dir = os.path.join(self.system.filestore.root_path, 'js') + node_path = os.path.normpath(js_dir) + generator_file = os.path.dirname(os.path.normpath(__file__)) + '/javascript_problem_generator.js' + command = "NODE_PATH=%s node %s %s '%s' '%s'" % (node_path, generator_file, self.generator, json.dumps(self.system.seed), json.dumps(self.params)) + node_process = os.popen(command) + output = node_process.readline().strip() + node_process.close() + return json.loads(output) + + def extract_params(self): + + params = {} + + for param in self.xml.xpath('//*[@id=$id]//responseparam', + id=self.xml.get('id')): + + params[param.get("name")] = json.loads(param.get("value")) + + return params + + def prepare_inputfield(self): + + for inputfield in self.xml.xpath('//*[@id=$id]//javascriptinput', + id=self.xml.get('id')): + + escapedict = {'"': '"'} + + encoded_params = json.dumps(self.params) + encoded_params = saxutils.escape(encoded_params, escapedict) + inputfield.set("params", encoded_params) + + encoded_problem_state = json.dumps(self.problem_state) + encoded_problem_state = saxutils.escape(encoded_problem_state, + escapedict) + inputfield.set("problem_state", encoded_problem_state) + + inputfield.set("display_file", self.display_filename) + inputfield.set("display_class", self.display_class) + + def get_score(self, student_answers): + json_submission = student_answers[self.answer_id] + (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) + + def run_grader(self, submission): + if submission is None or submission == '': + submission = json.dumps(None) + js_dir = os.path.join(self.system.filestore.root_path, 'js') + node_path = os.path.normpath(js_dir) + grader_file = os.path.dirname(os.path.normpath(__file__)) + '/javascript_problem_grader.js' + command = "NODE_PATH=%s node %s %s '%s' '%s' '%s'" % (node_path, + grader_file, + self.grader, + submission, + json.dumps(self.problem_state), + json.dumps(self.params)) + node_process = os.popen(command) + all_correct = json.loads(node_process.readline().strip()) + evaluation = node_process.readline().strip() + solution = node_process.readline().strip() + node_process.close() + return (all_correct, evaluation, solution) + + def get_answers(self): + if self.solution is None: + (_, _, self.solution) = self.run_grader(None) + + return {self.answer_id: self.solution} + + + +#----------------------------------------------------------------------------- + class ChoiceResponse(LoncapaResponse): ''' - This Response type is used when the student chooses from a discrete set of + This response type is used when the student chooses from a discrete set of choices. Currently, to be marked correct, all "correct" choices must be supplied by the student, and no extraneous choices may be included. @@ -314,6 +479,11 @@ class ChoiceResponse(LoncapaResponse): In the above example, radiogroup can be replaced with checkboxgroup to allow the student to select more than one choice. + TODO: In order for the inputtypes to render properly, this response type + must run setup_response prior to the input type rendering. Specifically, the + choices must be given names. This behavior seems like a leaky abstraction, + and it'd be nice to change this at some point. + ''' response_tag = 'choiceresponse' @@ -1314,4 +1484,4 @@ class ImageResponse(LoncapaResponse): # TEMPORARY: List of all response subclasses # FIXME: To be replaced by auto-registration -__all__ = [CodeResponse, NumericalResponse, FormulaResponse, CustomResponse, SchematicResponse, ExternalResponse, ImageResponse, OptionResponse, SymbolicResponse, StringResponse, ChoiceResponse, MultipleChoiceResponse, TrueFalseResponse] +__all__ = [CodeResponse, NumericalResponse, FormulaResponse, CustomResponse, SchematicResponse, ExternalResponse, ImageResponse, OptionResponse, SymbolicResponse, StringResponse, ChoiceResponse, MultipleChoiceResponse, TrueFalseResponse, JavascriptResponse] diff --git a/common/lib/capa/capa/templates/javascriptinput.html b/common/lib/capa/capa/templates/javascriptinput.html new file mode 100644 index 0000000000..bafcb23cc7 --- /dev/null +++ b/common/lib/capa/capa/templates/javascriptinput.html @@ -0,0 +1,10 @@ +
+ +
+
+
+
+
+ From 99e7d0076e4ee1c67e6e3e124db10c9e6107b833 Mon Sep 17 00:00:00 2001 From: Arjun Singh Date: Mon, 6 Aug 2012 18:03:32 -0400 Subject: [PATCH 004/187] Adding a method of executing scripts fetched via AJAX in IE8+. Also adds inputtype-specific handlers so that javascript can be executed when an inputtype is encountered. --- .../xmodule/js/src/capa/display.coffee | 86 +++++++++++++++++-- 1 file changed, 77 insertions(+), 9 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 0e760e98ff..92900aa851 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -1,4 +1,5 @@ class @Problem + constructor: (element) -> @el = $(element).find('.problems-wrapper') @id = @el.data('problem-id') @@ -28,22 +29,69 @@ class @Problem render: (content) -> if content @el.html(content) - @bind() + @executeProblemScripts () => + @setupInputTypes() + @bind() else $.postWithPrefix "#{@url}/problem_get", (response) => @el.html(response.html) - @executeProblemScripts() - @bind() + @executeProblemScripts () => + @setupInputTypes() + @bind() - executeProblemScripts: -> - @el.find(".script_placeholder").each (index, placeholder) -> - s = $(" - -
-
+
diff --git a/lms/templates/profile.html b/lms/templates/profile.html index a6e28c6e57..ca27920a1b 100644 --- a/lms/templates/profile.html +++ b/lms/templates/profile.html @@ -137,7 +137,7 @@ $(function() { percentageString = "{0:.0%}".format( float(earned)/total) if earned > 0 and total > 0 else "" %> -

+

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

${section['format']} From d24ee256150b368dd19328a168aa46223e9d771f Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Mon, 13 Aug 2012 00:57:33 -0400 Subject: [PATCH 102/187] Remove debug checking for UserProfile --- common/djangoapps/student/models.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 4c47e6c67e..257d3cb0f5 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -334,9 +334,6 @@ def replicate_model(model_method, instance, user_id): model_method is the model action that we want replicated. For instance, UserProfile.save """ - if isinstance(instance, UserProfile): - log.debug("replicate_model called on UserProfile {0}".format(instance)) - if not should_replicate(instance): return From 3cabb2dea63a378878a28f35ea838538654a50a8 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Mon, 13 Aug 2012 01:00:55 -0400 Subject: [PATCH 103/187] Remove the no-longer-used is_valid_course_id code --- common/djangoapps/student/models.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 257d3cb0f5..0fbe70c0b3 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -348,17 +348,14 @@ def replicate_model(model_method, instance, user_id): ######### Replication Helpers ######### def is_valid_course_id(course_id): - """We check to both make sure that it's a valid course_id (and not - 'default', or some other non-course DB name) and that we have a mapping - for what database it belongs to.""" + """Right now, the only database that's not a course database is 'default'. + I had nicer checking in here originally -- it would scan the courses that + were in the system and only let you choose that. But it was annoying to run + tests with, since we don't have course data for some for our course test + databases. Hence the lazy version. + """ return course_id != 'default' - course_ids = set(course.id for course in modulestore().get_courses()) - is_valid = (course_id in course_ids) and (course_id in settings.DATABASES) - if not is_valid: - log.error("{0} is not a valid DB to replicate to.".format(course_id)) - return is_valid - def is_portal(): """Are we in the portal pool? Only Portal servers are allowed to replicate their changes. For now, only Portal servers see multiple DBs, so we use From 12897ffff031846394c32f80fbd82a950bc2a554 Mon Sep 17 00:00:00 2001 From: Rocky Duan Date: Sun, 12 Aug 2012 22:54:02 -0700 Subject: [PATCH 104/187] added title & tag for new post & fixed error handling; reply button in generated thread doesn't work --- lms/djangoapps/django_comment_client/utils.py | 2 +- .../coffee/src/discussion/content.coffee | 12 +++++----- .../coffee/src/discussion/discussion.coffee | 10 +++++---- .../coffee/src/discussion/templates.coffee | 22 +++++++++---------- lms/static/coffee/src/discussion/utils.coffee | 13 +++++------ lms/static/sass/_discussion.scss | 18 ++++++++++++++- lms/templates/discussion/_inline.html | 13 ----------- 7 files changed, 47 insertions(+), 43 deletions(-) diff --git a/lms/djangoapps/django_comment_client/utils.py b/lms/djangoapps/django_comment_client/utils.py index 6c0b9c2c2c..570d3f0b31 100644 --- a/lms/djangoapps/django_comment_client/utils.py +++ b/lms/djangoapps/django_comment_client/utils.py @@ -113,7 +113,7 @@ class JsonError(HttpResponse): indent=2, ensure_ascii=False) super(JsonError, self).__init__(content, - mimetype='application/json; charset=utf8', status=500) + mimetype='application/json; charset=utf8', status=400) class HtmlResponse(HttpResponse): def __init__(self, html=''): diff --git a/lms/static/coffee/src/discussion/content.coffee b/lms/static/coffee/src/discussion/content.coffee index cd217b5e52..7c5e9e6fe4 100644 --- a/lms/static/coffee/src/discussion/content.coffee +++ b/lms/static/coffee/src/discussion/content.coffee @@ -74,7 +74,8 @@ initializeFollowThread = (thread) -> body: body anonymous: anonymous autowatch: autowatch - success: Discussion.formErrorHandler($local(".discussion-errors"), (response, textStatus) -> + error: Discussion.formErrorHandler($local(".discussion-errors")) + success: (response, textStatus) -> $comment = $(response.html) $content.children(".comments").prepend($comment) Discussion.setWmdContent $content, $local, "reply-body", "" @@ -86,7 +87,6 @@ initializeFollowThread = (thread) -> $local(".discussion-reply").show() $local(".discussion-edit").show() $discussionContent.attr("status", "normal") - ) handleVote = (elem, value) -> contentType = if $content.hasClass("thread") then "thread" else "comment" @@ -148,11 +148,11 @@ initializeFollowThread = (thread) -> type: "POST" dataType: 'json' data: {title: title, body: body, tags: tags}, - success: Discussion.formErrorHandler($local(".discussion-update-errors"), (response, textStatus) -> + error: Discussion.formErrorHandler($local(".discussion-update-errors")) + success: (response, textStatus) -> $discussionContent.replaceWith(response.html) Discussion.initializeContent($content) Discussion.bindContentEvents($content) - ) handleEditComment = (elem) -> $local(".discussion-content-wrapper").hide() @@ -175,11 +175,11 @@ initializeFollowThread = (thread) -> type: "POST" dataType: "json" data: {body: body} - success: Discussion.formErrorHandler($local(".discussion-update-errors"), (response, textStatus) -> + error: Discussion.formErrorHandler($local(".discussion-update-errors")) + success: (response, textStatus) -> $discussionContent.replaceWith(response.html) Discussion.initializeContent($content) Discussion.bindContentEvents($content) - ) handleEndorse = (elem, endorsed) -> url = Discussion.urlFor('endorse_comment', id) diff --git a/lms/static/coffee/src/discussion/discussion.coffee b/lms/static/coffee/src/discussion/discussion.coffee index 8cf2596e0e..9565a7193b 100644 --- a/lms/static/coffee/src/discussion/discussion.coffee +++ b/lms/static/coffee/src/discussion/discussion.coffee @@ -46,7 +46,8 @@ initializeFollowDiscussion = (discussion) -> title: title body: body tags: tags - success: Discussion.formErrorHandler($local(".new-post-form-error"), (response, textStatus) -> + error: Discussion.formErrorHandler($local(".new-post-form-errors")) + success: (response, textStatus) -> $thread = $(response.html) $discussion.children(".threads").prepend($thread) Discussion.setWmdContent $discussion, $local, "new-post-body", "" @@ -55,7 +56,6 @@ initializeFollowDiscussion = (discussion) -> Discussion.bindContentEvents($thread) $(".new-post-form").hide() $local(".discussion-new-post").show() - ) handleCancelNewPost = (elem) -> $local(".new-post-form").hide() @@ -96,9 +96,11 @@ initializeFollowDiscussion = (discussion) -> initializeNewPost = (elem) -> #newPostForm = $local(".new-post-form") - #view = { discussion_id: id } #$newPostButton = $local(".discussion-new-post") - #$newPostButton.after Mustache.render Discussion.newPostTemplate, view + view = { discussion_id: id } + $discussionNonContent = $discussion.children(".discussion-non-content") + + $discussionNonContent.append Mustache.render Discussion.newPostTemplate, view newPostBody = $discussion.find(".new-post-body") if newPostBody.length Discussion.makeWmdEditor $discussion, $local, "new-post-body" diff --git a/lms/static/coffee/src/discussion/templates.coffee b/lms/static/coffee/src/discussion/templates.coffee index 8d98e1f5f1..e2869c8931 100644 --- a/lms/static/coffee/src/discussion/templates.coffee +++ b/lms/static/coffee/src/discussion/templates.coffee @@ -7,18 +7,18 @@ Discussion = @Discussion @Discussion = $.extend @Discussion, newPostTemplate: """ -

-
    - -