From 833e7777db3c4f1ede42576a63c912bbd48aed04 Mon Sep 17 00:00:00 2001 From: Arjun Singh Date: Mon, 6 Aug 2012 05:27:36 -0400 Subject: [PATCH 001/141] 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/141] 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/141] 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/141] 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 = $(" + + + From 453d623b43e42a46c1271c81ff03aa70c377a5d5 Mon Sep 17 00:00:00 2001 From: ichuang Date: Sat, 11 Aug 2012 23:42:39 -0400 Subject: [PATCH 105/141] use mitx content-qa central server for staff_problem_info --- lms/templates/staff_problem_info.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lms/templates/staff_problem_info.html b/lms/templates/staff_problem_info.html index 436af6842f..f3dabb03b1 100644 --- a/lms/templates/staff_problem_info.html +++ b/lms/templates/staff_problem_info.html @@ -75,12 +75,13 @@ function sendlog_${element_id}(){ entry: $('#${element_id}_xqa_entry').val()}; $.ajax({ - url: 'http://zion.ike.net:9001/log', + url: 'http://xqa:server@content-qa.mitx.mit.edu/log', type: 'GET', contentType: 'application/json', data: JSON.stringify(xqaLog), crossDomain: true, dataType: 'jsonp', + beforeSend: function (xhr) { xhr.setRequestHeader ("Authorization", "Basic eHFhOmFnYXJ3YWw="); }, timeout : 1000, success: function(result) { $('#${element_id}_xqa_log_data').html(result); @@ -100,7 +101,7 @@ var xqaQuery = {authkey: '${xqa_key}', format: 'html'}; $.ajax({ - url: 'http://zion.ike.net:9001/query', + url: 'http://xqa:server@content-qa.mitx.mit.edu/query', type: 'GET', contentType: 'application/json', data: JSON.stringify(xqaQuery), From 656a162aba96296625ad0491f958b394594062de Mon Sep 17 00:00:00 2001 From: ichuang Date: Sun, 12 Aug 2012 12:43:08 -0400 Subject: [PATCH 106/141] allow xqa server url to be set by MITX_FEATURES['USE_XQA_SERVER'] --- common/djangoapps/xmodule_modifiers.py | 1 + lms/envs/dev.py | 1 + lms/envs/dev_ike.py | 1 + lms/templates/staff_problem_info.html | 4 ++-- 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/common/djangoapps/xmodule_modifiers.py b/common/djangoapps/xmodule_modifiers.py index 982d47efaa..58dbbd7c6e 100644 --- a/common/djangoapps/xmodule_modifiers.py +++ b/common/djangoapps/xmodule_modifiers.py @@ -110,6 +110,7 @@ def add_histogram(get_html, module, user): 'element_id': module.location.html_id().replace('-','_'), 'edit_link': edit_link, 'user': user, + 'xqa_server' : settings.MITX_FEATURES.get('USE_XQA_SERVER','http://xqa:server@content-qa.mitx.mit.edu'), 'histogram': json.dumps(histogram), 'render_histogram': render_histogram, 'module_content': get_html()} diff --git a/lms/envs/dev.py b/lms/envs/dev.py index bc5b621b32..1c40a936af 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -62,6 +62,7 @@ SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' ################################ LMS Migration ################################# MITX_FEATURES['ENABLE_LMS_MIGRATION'] = True MITX_FEATURES['ACCESS_REQUIRE_STAFF_FOR_COURSE'] = False # require that user be in the staff_* group to be able to enroll +MITX_FEATURES['USE_XQA_SERVER'] = 'http://xqa:server@content-qa.mitx.mit.edu' LMS_MIGRATION_ALLOWED_IPS = ['127.0.0.1'] diff --git a/lms/envs/dev_ike.py b/lms/envs/dev_ike.py index c58a35f082..ba695e193e 100644 --- a/lms/envs/dev_ike.py +++ b/lms/envs/dev_ike.py @@ -20,6 +20,7 @@ MITX_FEATURES['ACCESS_REQUIRE_STAFF_FOR_COURSE'] = True # require that user be myhost = socket.gethostname() if ('edxvm' in myhost) or ('ocw' in myhost): MITX_FEATURES['DISABLE_LOGIN_BUTTON'] = True # auto-login with MIT certificate + MITX_FEATURES['USE_XQA_SERVER'] = 'https://qisx.mit.edu/xqa' # needs to be ssl or browser blocks it SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTOCOL', 'https') # django 1.4 for nginx ssl proxy diff --git a/lms/templates/staff_problem_info.html b/lms/templates/staff_problem_info.html index f3dabb03b1..f91decf876 100644 --- a/lms/templates/staff_problem_info.html +++ b/lms/templates/staff_problem_info.html @@ -75,7 +75,7 @@ function sendlog_${element_id}(){ entry: $('#${element_id}_xqa_entry').val()}; $.ajax({ - url: 'http://xqa:server@content-qa.mitx.mit.edu/log', + url: '${xqa_server}/log', type: 'GET', contentType: 'application/json', data: JSON.stringify(xqaLog), @@ -101,7 +101,7 @@ var xqaQuery = {authkey: '${xqa_key}', format: 'html'}; $.ajax({ - url: 'http://xqa:server@content-qa.mitx.mit.edu/query', + url: '${xqa_server}/query', type: 'GET', contentType: 'application/json', data: JSON.stringify(xqaQuery), From 54b0a465faca18184dbef40c27a9363141ffbdbe Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Sun, 12 Aug 2012 16:10:08 -0400 Subject: [PATCH 107/141] Ugly hack so that an LMS-specific test of the shared Student djangoapp doesn't break CMS test runs --- common/djangoapps/student/tests.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/common/djangoapps/student/tests.py b/common/djangoapps/student/tests.py index 64b1845b7c..ad7ddb70d1 100644 --- a/common/djangoapps/student/tests.py +++ b/common/djangoapps/student/tests.py @@ -28,7 +28,9 @@ class ReplicationTest(TestCase): portal_user.last_login=datetime(2012, 1, 1) portal_user.date_joined=datetime(2011, 1, 1) # This is an Askbot field and will break if askbot is not included - portal_user.seen_response_count = 10 + + if hasattr(portal_user, 'seen_response_count'): + portal_user.seen_response_count = 10 portal_user.save(using='default') @@ -45,18 +47,23 @@ class ReplicationTest(TestCase): field, portal_user, course_user )) - # Since it's the first copy over of User data, we should have all of it - self.assertEqual(portal_user.seen_response_count, - course_user.seen_response_count) + if hasattr(portal_user, 'seen_response_count'): + # Since it's the first copy over of User data, we should have all of it + self.assertEqual(portal_user.seen_response_count, + course_user.seen_response_count) # But if we replicate again, the user already exists in the Course DB, # so it shouldn't update the seen_response_count (which is Askbot - # controlled) - portal_user.seen_response_count = 20 - replicate_user(portal_user, COURSE_1) - course_user = User.objects.using(COURSE_1).get(id=portal_user.id) - self.assertEqual(portal_user.seen_response_count, 20) - self.assertEqual(course_user.seen_response_count, 10) + # controlled). + # This hasattr lameness is here because we don't want this test to be + # triggered when we're being run by CMS tests (Askbot doesn't exist + # there, so the test will fail). + if hasattr(portal_user, 'seen_response_count'): + portal_user.seen_response_count = 20 + replicate_user(portal_user, COURSE_1) + course_user = User.objects.using(COURSE_1).get(id=portal_user.id) + self.assertEqual(portal_user.seen_response_count, 20) + self.assertEqual(course_user.seen_response_count, 10) # Another replication should work for an email change however, since # it's a field we care about. From 622eebc4736835a282726b98a94057f0b927aa4c Mon Sep 17 00:00:00 2001 From: Victor Shnayder Date: Sun, 12 Aug 2012 16:21:38 -0400 Subject: [PATCH 108/141] Don't error on missing static files * Just log a warning and return a dummy url * May want smarter checking later (e.g. would be nice to tell staff what files are missing.) --- common/djangoapps/pipeline_mako/__init__.py | 9 ++++---- common/djangoapps/static_replace.py | 23 +++++++++++++++++++-- lms/djangoapps/courseware/courses.py | 10 ++++----- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/common/djangoapps/pipeline_mako/__init__.py b/common/djangoapps/pipeline_mako/__init__.py index 4703b53e52..1cdc287e2e 100644 --- a/common/djangoapps/pipeline_mako/__init__.py +++ b/common/djangoapps/pipeline_mako/__init__.py @@ -1,10 +1,9 @@ -from staticfiles.storage import staticfiles_storage - from mitxmako.shortcuts import render_to_string from pipeline.conf import settings from pipeline.packager import Packager from pipeline.utils import guess_type +from static_replace import try_staticfiles_lookup def compressed_css(package_name): @@ -25,9 +24,11 @@ def compressed_css(package_name): def render_css(package, path): template_name = package.template_name or "mako/css.html" context = package.extra_context + + url = try_staticfiles_lookup(path) context.update({ 'type': guess_type(path, 'text/css'), - 'url': staticfiles_storage.url(path) + 'url': url, }) return render_to_string(template_name, context) @@ -58,7 +59,7 @@ def render_js(package, path): context = package.extra_context context.update({ 'type': guess_type(path, 'text/javascript'), - 'url': staticfiles_storage.url(path) + 'url': try_staticfiles_lookup(path) }) return render_to_string(template_name, context) diff --git a/common/djangoapps/static_replace.py b/common/djangoapps/static_replace.py index 73e473c412..ba3cfb302a 100644 --- a/common/djangoapps/static_replace.py +++ b/common/djangoapps/static_replace.py @@ -1,7 +1,25 @@ +import logging +import re + from staticfiles.storage import staticfiles_storage from staticfiles import finders from django.conf import settings -import re + +log = logging.getLogger(__name__) + +def try_staticfiles_lookup(path): + """ + Try to lookup a path in staticfiles_storage. If it fails, return + a dead link instead of raising an exception. + """ + try: + url = staticfiles_storage.url(path) + except Exception as err: + log.warning("staticfiles_storage couldn't find path {}: {}".format( + path, str(err))) + # Just return a dead link--don't kill everything. + url = "file_not_found" + return url def replace(static_url, prefix=None): @@ -22,7 +40,8 @@ def replace(static_url, prefix=None): if servable: return static_url.group(0) else: - url = staticfiles_storage.url(prefix + static_url.group('rest')) + # don't error if file can't be found + url = try_staticfiles_lookup(prefix + static_url.group('rest')) return "".join([quote, url, quote]) diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 3e603a108d..c48b9b378a 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -10,8 +10,7 @@ from django.http import Http404 from xmodule.course_module import CourseDescriptor from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError -from static_replace import replace_urls -from staticfiles.storage import staticfiles_storage +from static_replace import replace_urls, try_staticfiles_lookup log = logging.getLogger(__name__) @@ -46,9 +45,10 @@ def check_course(course_id, course_must_be_open=True, course_required=True): def course_image_url(course): - return staticfiles_storage.url(course.metadata['data_dir'] + - "/images/course_image.jpg") - + """Try to look up the image url for the course. If it's not found, + log an error and return the dead link""" + path = course.metadata['data_dir'] + "/images/course_image.jpg" + return try_staticfiles_lookup(path) def get_course_about_section(course, section_key): """ From d7f94a05b72f23b92c0174303e930fea9bf9f108 Mon Sep 17 00:00:00 2001 From: Victor Shnayder Date: Sun, 12 Aug 2012 16:23:31 -0400 Subject: [PATCH 109/141] Add DARK_LAUNCH functionality * pass user to check_course * if dark launch feature enabled, users with staff access to course can see courseware before start date. Students still can't. * tests. * Remaining: enrollment view has custom access control. Need to check it. --- lms/djangoapps/courseware/courses.py | 20 +++- lms/djangoapps/courseware/tests/tests.py | 143 +++++++++++++++++++++-- lms/djangoapps/courseware/views.py | 15 +-- lms/djangoapps/simplewiki/views.py | 24 ++-- lms/djangoapps/staticbook/views.py | 2 +- lms/envs/common.py | 1 + lms/urls.py | 10 +- 7 files changed, 178 insertions(+), 37 deletions(-) diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index c48b9b378a..fa9b6c8220 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -15,11 +15,11 @@ from static_replace import replace_urls, try_staticfiles_lookup log = logging.getLogger(__name__) -def check_course(course_id, course_must_be_open=True, course_required=True): +def check_course(user, course_id, course_must_be_open=True, course_required=True): """ - Given a course_id, this returns the course object. By default, - if the course is not found or the course is not open yet, this - method will raise a 404. + Given a django user and a course_id, this returns the course + object. By default, if the course is not found or the course is + not open yet, this method will raise a 404. If course_must_be_open is False, the course will be returned without a 404 even if it is not open. @@ -27,6 +27,10 @@ def check_course(course_id, course_must_be_open=True, course_required=True): If course_required is False, a course_id of None is acceptable. The course returned will be None. Even if the course is not required, if a course_id is given that does not exist a 404 will be raised. + + This behavior is modified by MITX_FEATURES['DARK_LAUNCH']: + if dark launch is enabled, course_must_be_open is ignored for + users that have staff access. """ course = None if course_required or course_id: @@ -38,7 +42,13 @@ def check_course(course_id, course_must_be_open=True, course_required=True): raise Http404("Course not found.") started = course.has_started() or settings.MITX_FEATURES['DISABLE_START_DATES'] - if course_must_be_open and not started: + + must_be_open = course_must_be_open + if (settings.MITX_FEATURES['DARK_LAUNCH'] and + has_staff_access_to_course(user, course)): + must_be_open = False + + if must_be_open and not started: raise Http404("This course has not yet started.") return course diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index da0688be3d..0fb5c9983e 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -1,11 +1,14 @@ import copy import json -from path import path import os +import sys +import time -from pprint import pprint from nose import SkipTest +from path import path +from pprint import pprint +from django.contrib.auth.models import User, Group from django.test import TestCase from django.test.client import Client from django.conf import settings @@ -13,12 +16,11 @@ from django.core.urlresolvers import reverse from mock import patch, Mock from override_settings import override_settings -from django.contrib.auth.models import User, Group +import xmodule.modulestore.django + from student.models import Registration from courseware.courses import course_staff_group_name - from xmodule.modulestore.django import modulestore -import xmodule.modulestore.django from xmodule.modulestore import Location from xmodule.modulestore.xml_importer import import_from_xml @@ -206,13 +208,14 @@ class TestCoursesLoadTestCase(PageLoader): @override_settings(MODULESTORE=TEST_DATA_MODULESTORE) -class TestInstructorAuth(PageLoader): - """Check that authentication works properly""" +class TestViewAuth(PageLoader): + """Check that view authentication works properly""" # NOTE: setUpClass() runs before override_settings takes effect, so # can't do imports there without manually hacking settings. def setUp(self): + print "sys.path: {}".format(sys.path) xmodule.modulestore.django._MODULESTORES = {} modulestore().collection.drop() import_from_xml(modulestore(), TEST_DATA_DIR, ['toy']) @@ -237,12 +240,16 @@ class TestInstructorAuth(PageLoader): # TODO (vshnayder): once we're returning 404s, get rid of this if. if code != 404: self.assertEqual(resp.status_code, code) + # And 'page not found' shouldn't be in the returned page + self.assertTrue(resp.content.lower().find('page not found') == -1) else: # look for "page not found" instead of the status code + #print resp.content self.assertTrue(resp.content.lower().find('page not found') != -1) - def test_instructor_page(self): - "Make sure only instructors can load it" + def test_instructor_pages(self): + """Make sure only instructors for the course or staff can load the instructor + dashboard, the grade views, and student profile pages""" # First, try with an enrolled student self.login(self.student, self.password) @@ -297,7 +304,125 @@ class TestInstructorAuth(PageLoader): self.check_for_get_code(200, url) + def test_dark_launch(self): + """Make sure that when dark launch is on, students can't access course + pages, but instructors can""" + # test.py turns off start dates, enable them and set them correctly. + # Because settings is global, be careful not to mess it up for other tests + # (Can't use override_settings because we're only changing part of the + # MITX_FEATURES dict) + oldDSD = settings.MITX_FEATURES['DISABLE_START_DATES'] + oldDL = settings.MITX_FEATURES['DARK_LAUNCH'] + + try: + settings.MITX_FEATURES['DISABLE_START_DATES'] = False + settings.MITX_FEATURES['DARK_LAUNCH'] = True + self._do_test_dark_launch() + finally: + settings.MITX_FEATURES['DISABLE_START_DATES'] = oldDSD + settings.MITX_FEATURES['DARK_LAUNCH'] = oldDL + + + def _do_test_dark_launch(self): + """Actually do the test, relying on settings to be right.""" + + # Make courses start in the future + tomorrow = time.time() + 24*3600 + self.toy.start = self.toy.metadata['start'] = time.gmtime(tomorrow) + self.full.start = self.full.metadata['start'] = time.gmtime(tomorrow) + + self.assertFalse(self.toy.has_started()) + self.assertFalse(self.full.has_started()) + self.assertFalse(settings.MITX_FEATURES['DISABLE_START_DATES']) + self.assertTrue(settings.MITX_FEATURES['DARK_LAUNCH']) + + def reverse_urls(names, course): + return [reverse(name, kwargs={'course_id': course.id}) for name in names] + + def dark_student_urls(course): + """ + list of urls that students should be able to see only + after launch, but staff should see before + """ + urls = reverse_urls(['info', 'book', 'courseware', 'profile'], course) + return urls + + def light_student_urls(course): + """ + list of urls that students should be able to see before + launch. + """ + urls = reverse_urls(['about_course'], course) + urls.append(reverse('courses')) + # Need separate test for change_enrollment, since it's a POST view + #urls.append(reverse('change_enrollment')) + + return urls + + def instructor_urls(course): + """list of urls that only instructors/staff should be able to see""" + urls = reverse_urls(['instructor_dashboard','gradebook','grade_summary'], + course) + urls.append(reverse('student_profile', kwargs={'course_id': course.id, + 'student_id': user(self.student).id})) + return urls + + def check_non_staff(course): + """Check that access is right for non-staff in course""" + print '=== Checking non-staff access for {}'.format(course.id) + for url in instructor_urls(course) + dark_student_urls(course): + print 'checking for 404 on {}'.format(url) + self.check_for_get_code(404, url) + + for url in light_student_urls(course): + print 'checking for 200 on {}'.format(url) + self.check_for_get_code(200, url) + + def check_staff(course): + """Check that access is right for staff in course""" + print '=== Checking staff access for {}'.format(course.id) + for url in (instructor_urls(course) + + dark_student_urls(course) + + light_student_urls(course)): + print 'checking for 200 on {}'.format(url) + self.check_for_get_code(200, url) + + # First, try with an enrolled student + print '=== Testing student access....' + self.login(self.student, self.password) + self.enroll(self.toy) + self.enroll(self.full) + + # shouldn't be able to get to anything except the light pages + check_non_staff(self.toy) + check_non_staff(self.full) + + print '=== Testing course instructor access....' + # Make the instructor staff in the toy course + group_name = course_staff_group_name(self.toy) + g = Group.objects.create(name=group_name) + g.user_set.add(user(self.instructor)) + + self.logout() + self.login(self.instructor, self.password) + # Enroll in the classes---can't see courseware otherwise. + self.enroll(self.toy) + self.enroll(self.full) + + # should now be able to get to everything for toy course + check_non_staff(self.full) + check_staff(self.toy) + + print '=== Testing staff access....' + # now also make the instructor staff + u = user(self.instructor) + u.is_staff = True + u.save() + + # and now should be able to load both + check_staff(self.toy) + check_staff(self.full) @override_settings(MODULESTORE=REAL_DATA_MODULESTORE) diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 02e6d00a58..00ab45e605 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -110,9 +110,10 @@ def index(request, course_id, chapter=None, section=None, - HTTPresponse ''' - course = check_course(course_id) + course = check_course(request.user, course_id) registered = registered_for_course(course, request.user) if not registered: + # TODO (vshnayder): do course instructors need to be registered to see course? log.debug('User %s tried to view course %s but is not enrolled' % (request.user,course.location.url())) return redirect(reverse('about_course', args=[course.id])) @@ -203,7 +204,7 @@ def course_info(request, course_id): Assumes the course_id is in a valid format. """ - course = check_course(course_id) + course = check_course(request.user, course_id) return render_to_response('info.html', {'course': course}) @@ -220,7 +221,7 @@ def registered_for_course(course, user): @ensure_csrf_cookie @cache_if_anonymous def course_about(request, course_id): - course = check_course(course_id, course_must_be_open=False) + course = check_course(request.user, course_id, course_must_be_open=False) registered = registered_for_course(course, request.user) return render_to_response('portal/course_about.html', {'course': course, 'registered': registered}) @@ -252,7 +253,7 @@ def profile(request, course_id, student_id=None): Course staff are allowed to see the profiles of students in their class. """ - course = check_course(course_id) + course = check_course(request.user, course_id) if student_id is None or student_id == request.user.id: # always allowed to see your own profile @@ -299,7 +300,7 @@ def gradebook(request, course_id): if not has_staff_access_to_course_id(request.user, course_id): raise Http404 - course = check_course(course_id) + course = check_course(request.user, course_id) enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).order_by('username') @@ -324,7 +325,7 @@ def grade_summary(request, course_id): if not has_staff_access_to_course_id(request.user, course_id): raise Http404 - course = check_course(course_id) + course = check_course(request.user, course_id) # For now, just a static page context = {'course': course } @@ -337,7 +338,7 @@ def instructor_dashboard(request, course_id): if not has_staff_access_to_course_id(request.user, course_id): raise Http404 - course = check_course(course_id) + course = check_course(request.user, course_id) # For now, just a static page context = {'course': course } diff --git a/lms/djangoapps/simplewiki/views.py b/lms/djangoapps/simplewiki/views.py index 77fadc49ba..a6bb192fd7 100644 --- a/lms/djangoapps/simplewiki/views.py +++ b/lms/djangoapps/simplewiki/views.py @@ -51,7 +51,7 @@ def update_template_dictionary(dictionary, request=None, course=None, article=No def view(request, article_path, course_id=None): - course = check_course(course_id, course_required=False) + course = check_course(request.user, course_id, course_required=False) (article, err) = get_article(request, article_path, course) if err: @@ -67,7 +67,7 @@ def view(request, article_path, course_id=None): def view_revision(request, revision_number, article_path, course_id=None): - course = check_course(course_id, course_required=False) + course = check_course(request.user, course_id, course_required=False) (article, err) = get_article(request, article_path, course) if err: @@ -91,7 +91,7 @@ def view_revision(request, revision_number, article_path, course_id=None): def root_redirect(request, course_id=None): - course = check_course(course_id, course_required=False) + course = check_course(request.user, course_id, course_required=False) #TODO: Add a default namespace to settings. namespace = course.wiki_namespace if course else "edX" @@ -109,7 +109,7 @@ def root_redirect(request, course_id=None): def create(request, article_path, course_id=None): - course = check_course(course_id, course_required=False) + course = check_course(request.user, course_id, course_required=False) article_path_components = article_path.split('/') @@ -170,7 +170,7 @@ def create(request, article_path, course_id=None): def edit(request, article_path, course_id=None): - course = check_course(course_id, course_required=False) + course = check_course(request.user, course_id, course_required=False) (article, err) = get_article(request, article_path, course) if err: @@ -218,7 +218,7 @@ def edit(request, article_path, course_id=None): def history(request, article_path, page=1, course_id=None): - course = check_course(course_id, course_required=False) + course = check_course(request.user, course_id, course_required=False) (article, err) = get_article(request, article_path, course) if err: @@ -300,7 +300,7 @@ def history(request, article_path, page=1, course_id=None): def revision_feed(request, page=1, namespace=None, course_id=None): - course = check_course(course_id, course_required=False) + course = check_course(request.user, course_id, course_required=False) page_size = 10 @@ -333,7 +333,7 @@ def revision_feed(request, page=1, namespace=None, course_id=None): def search_articles(request, namespace=None, course_id=None): - course = check_course(course_id, course_required=False) + course = check_course(request.user, course_id, course_required=False) # blampe: We should check for the presence of other popular django search # apps and use those if possible. Only fall back on this as a last resort. @@ -382,7 +382,7 @@ def search_articles(request, namespace=None, course_id=None): def search_add_related(request, course_id, slug, namespace): - course = check_course(course_id, course_required=False) + course = check_course(request.user, course_id, course_required=False) (article, err) = get_article(request, slug, namespace if namespace else course_id) if err: @@ -415,7 +415,7 @@ def search_add_related(request, course_id, slug, namespace): def add_related(request, course_id, slug, namespace): - course = check_course(course_id, course_required=False) + course = check_course(request.user, course_id, course_required=False) (article, err) = get_article(request, slug, namespace if namespace else course_id) if err: @@ -439,7 +439,7 @@ def add_related(request, course_id, slug, namespace): def remove_related(request, course_id, namespace, slug, related_id): - course = check_course(course_id, course_required=False) + course = check_course(request.user, course_id, course_required=False) (article, err) = get_article(request, slug, namespace if namespace else course_id) @@ -462,7 +462,7 @@ def remove_related(request, course_id, namespace, slug, related_id): def random_article(request, course_id=None): - course = check_course(course_id, course_required=False) + course = check_course(request.user, course_id, course_required=False) from random import randint num_arts = Article.objects.count() diff --git a/lms/djangoapps/staticbook/views.py b/lms/djangoapps/staticbook/views.py index cb15f5855e..e63619756c 100644 --- a/lms/djangoapps/staticbook/views.py +++ b/lms/djangoapps/staticbook/views.py @@ -6,7 +6,7 @@ from lxml import etree @login_required def index(request, course_id, page=0): - course = check_course(course_id) + course = check_course(request.user, course_id) raw_table_of_contents = open('lms/templates/book_toc.xml', 'r') # TODO: This will need to come from S3 table_of_contents = etree.parse(raw_table_of_contents).getroot() return render_to_response('staticbook.html', {'page': int(page), 'course': course, 'table_of_contents': table_of_contents}) diff --git a/lms/envs/common.py b/lms/envs/common.py index 487e2efe48..9772b98fc3 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -48,6 +48,7 @@ MITX_FEATURES = { ## DO NOT SET TO True IN THIS FILE ## Doing so will cause all courses to be released on production 'DISABLE_START_DATES': False, # When True, all courses will be active, regardless of start date + 'DARK_LAUNCH': False, # When True, courses will be active for staff only 'ENABLE_TEXTBOOK' : True, 'ENABLE_DISCUSSION' : True, diff --git a/lms/urls.py b/lms/urls.py index 4f1895e2c4..468ee8e62c 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -97,12 +97,16 @@ if settings.PERFSTATS: if settings.COURSEWARE_ENABLED: urlpatterns += ( + # Hook django-masquerade, allowing staff to view site as other users url(r'^masquerade/', include('masquerade.urls')), url(r'^jump_to/(?P.*)$', 'courseware.views.jump_to', name="jump_to"), - url(r'^modx/(?P.*?)/(?P[^/]*)$', 'courseware.module_render.modx_dispatch'), #reset_problem'), - url(r'^xqueue/(?P[^/]*)/(?P.*?)/(?P[^/]*)$', 'courseware.module_render.xqueue_callback'), - url(r'^change_setting$', 'student.views.change_setting'), + url(r'^modx/(?P.*?)/(?P[^/]*)$', + 'courseware.module_render.modx_dispatch', name='modx_dispatch'), + url(r'^xqueue/(?P[^/]*)/(?P.*?)/(?P[^/]*)$', + 'courseware.module_render.xqueue_callback', name='xqueue_callback'), + url(r'^change_setting$', 'student.views.change_setting', + name='change_setting'), # TODO: These views need to be updated before they work # url(r'^calculate$', 'util.views.calculate'), From e980f8b296565e0eb46ac9d883a135f1b45817c8 Mon Sep 17 00:00:00 2001 From: Victor Shnayder Date: Sun, 12 Aug 2012 16:59:07 -0400 Subject: [PATCH 110/141] add notes on how to run individual tests --- doc/development.md | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/doc/development.md b/doc/development.md index 9d6628732a..44965cb0de 100644 --- a/doc/development.md +++ b/doc/development.md @@ -34,12 +34,34 @@ This will import all courses in your data directory into mongodb This runs all the tests (long, uses collectstatic): rake test - + +If if you aren't changing static files, can run `rake test` once, then run + + rake fasttest_{lms,cms} + xmodule can be tested independently, with this: rake test_common/lib/xmodule - + To see all available rake commands, do this: rake -T - \ No newline at end of file + +To run a single django test class: + + django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/courseware/tests/tests.py:TestViewAuth + +To run a single django test: + + django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/courseware/tests/tests.py:TestViewAuth.test_dark_launch + + +To run a single nose test file: + + nosetests common/lib/xmodule/xmodule/tests/test_stringify.py + +To run a single nose test: + + nosetests common/lib/xmodule/xmodule/tests/test_stringify.py:test_stringify + + From af7e70a9791c11c131ffde31a64c056e0a60f057 Mon Sep 17 00:00:00 2001 From: Victor Shnayder Date: Sun, 12 Aug 2012 17:21:40 -0400 Subject: [PATCH 111/141] Log content problems as warnings, not errors * will avoid newrelic complaining * NOTE: Is this what we want post-ship? - need some way of notifying instructors of problems --- common/djangoapps/student/views.py | 4 ++-- common/lib/capa/capa/capa_problem.py | 16 ++++++++-------- common/lib/capa/capa/inputtypes.py | 14 +++++++------- common/lib/xmodule/xmodule/capa_module.py | 2 +- common/lib/xmodule/xmodule/modulestore/xml.py | 9 +++++---- common/lib/xmodule/xmodule/x_module.py | 9 +++++---- 6 files changed, 28 insertions(+), 26 deletions(-) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 8093a5a51a..ab22cc0d82 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -128,7 +128,7 @@ def dashboard(request): try: courses.append(course_from_id(enrollment.course_id)) except ItemNotFoundError: - log.error("User {0} enrolled in non-existant course {1}" + log.error("User {0} enrolled in non-existent course {1}" .format(user.username, enrollment.course_id)) message = "" @@ -182,7 +182,7 @@ def change_enrollment(request): try: course = course_from_id(course_id) except ItemNotFoundError: - log.error("User {0} tried to enroll in non-existant course {1}" + log.warning("User {0} tried to enroll in non-existant course {1}" .format(user.username, enrollment.course_id)) return {'success': False, 'error': 'The course requested does not exist.'} diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 92823667e7..d1798f2c67 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -229,14 +229,14 @@ class LoncapaProblem(object): Calls the Response for each question in this problem, to do the actual grading. ''' - + self.student_answers = convert_files_to_filenames(answers) - + oldcmap = self.correct_map # old CorrectMap newcmap = CorrectMap() # start new with empty CorrectMap # log.debug('Responders: %s' % self.responders) for responder in self.responders.values(): # Call each responsetype instance to do actual grading - if 'filesubmission' in responder.allowed_inputfields: # File objects are passed only if responsetype + if 'filesubmission' in responder.allowed_inputfields: # File objects are passed only if responsetype # explicitly allows for file submissions results = responder.evaluate_answers(answers, oldcmap) else: @@ -295,9 +295,9 @@ class LoncapaProblem(object): try: ifp = self.system.filestore.open(file) # open using ModuleSystem OSFS filestore except Exception as err: - log.error('Error %s in problem xml include: %s' % ( + log.warning('Error %s in problem xml include: %s' % ( err, etree.tostring(inc, pretty_print=True))) - log.error('Cannot find file %s in %s' % ( + log.warning('Cannot find file %s in %s' % ( file, self.system.filestore)) # if debugging, don't fail - just log error # TODO (vshnayder): need real error handling, display to users @@ -306,11 +306,11 @@ class LoncapaProblem(object): else: continue try: - incxml = etree.XML(ifp.read()) # read in and convert to XML + incxml = etree.XML(ifp.read()) # read in and convert to XML except Exception as err: - log.error('Error %s in problem xml include: %s' % ( + log.warning('Error %s in problem xml include: %s' % ( err, etree.tostring(inc, pretty_print=True))) - log.error('Cannot parse XML in %s' % (file)) + log.warning('Cannot parse XML in %s' % (file)) # if debugging, don't fail - just log error # TODO (vshnayder): same as above if not self.system.get('DEBUG'): diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 0c47892598..c3acbdda91 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -313,13 +313,13 @@ 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. (Queue length: %s)' % queue_len - context = { 'id': eid, 'state': status, 'msg': msg, 'value': value, + context = { 'id': eid, 'state': status, 'msg': msg, 'value': value, 'queue_len': queue_len } html = render_template("filesubmission.html", context) - return etree.XML(html) + return etree.XML(html) #----------------------------------------------------------------------------- @@ -339,16 +339,16 @@ def textbox(element, value, status, render_template, msg=''): hidden = element.get('hidden', '') # if specified, then textline is hidden and id is stored in div of name given by hidden if not value: value = element.text # if no student input yet, then use the default input given by the problem - + # Check if problem has been queued queue_len = 0 if status == 'incomplete': # Flag indicating that the problem has been queued, 'msg' is length of queue - status = 'queued' + status = 'queued' queue_len = msg - msg = 'Submitted to grader. (Queue length: %s)' % queue_len + msg = 'Submitted to grader. (Queue length: %s)' % queue_len # For CodeMirror - mode = element.get('mode','python') + mode = element.get('mode','python') linenumbers = element.get('linenumbers','true') tabsize = element.get('tabsize','4') tabsize = int(tabsize) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index eb083e97db..f12b1c2be4 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -150,7 +150,7 @@ class CapaModule(XModule): # TODO (vshnayder): do modules need error handlers too? # We shouldn't be switching on DEBUG. if self.system.DEBUG: - log.error(msg) + log.warning(msg) # TODO (vshnayder): This logic should be general, not here--and may # want to preserve the data instead of replacing it. # e.g. in the CMS diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index 2dc3b33323..8c4c373d4f 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -50,8 +50,9 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): # have been imported into the cms from xml xml = clean_out_mako_templating(xml) xml_data = etree.fromstring(xml) - except: - log.exception("Unable to parse xml: {xml}".format(xml=xml)) + except Exception as err: + log.warning("Unable to parse xml: {err}, xml: {xml}".format( + err=str(err), xml=xml)) raise # VS[compat]. Take this out once course conversion is done @@ -194,7 +195,7 @@ class XMLModuleStore(ModuleStoreBase): if org is None: msg = ("No 'org' attribute set for course in {dir}. " "Using default 'edx'".format(dir=course_dir)) - log.error(msg) + log.warning(msg) tracker(msg) org = 'edx' @@ -206,7 +207,7 @@ class XMLModuleStore(ModuleStoreBase): dir=course_dir, default=course_dir )) - log.error(msg) + log.warning(msg) tracker(msg) course = course_dir diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 2cd51b9e6e..3b89f29989 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -522,7 +522,7 @@ class XModuleDescriptor(Plugin, HTMLSnippet): # Put import here to avoid circular import errors from xmodule.error_module import ErrorDescriptor msg = "Error loading from xml." - log.exception(msg) + log.warning(msg + " " + str(err)) system.error_tracker(msg) err_msg = msg + "\n" + exc_info_to_str(sys.exc_info()) descriptor = ErrorDescriptor.from_xml(xml_data, system, org, course, @@ -615,9 +615,10 @@ class DescriptorSystem(object): try: x = access_some_resource() check_some_format(x) - except SomeProblem: - msg = 'Grommet {0} is broken'.format(x) - log.exception(msg) # don't rely on handler to log + except SomeProblem as err: + msg = 'Grommet {0} is broken: {1}'.format(x, str(err)) + log.warning(msg) # don't rely on tracker to log + # NOTE: we generally don't want content errors logged as errors self.system.error_tracker(msg) # work around return 'Oops, couldn't load grommet' From 3455f8f64d5e2a4723beb3ffefc08430def313ed Mon Sep 17 00:00:00 2001 From: Victor Shnayder Date: Sun, 12 Aug 2012 18:03:12 -0400 Subject: [PATCH 112/141] Hide errors from non-staff users --- common/lib/xmodule/xmodule/error_module.py | 11 ++++++++++- lms/djangoapps/courseware/courses.py | 9 +++++++++ lms/djangoapps/courseware/grades.py | 6 ++++++ lms/djangoapps/courseware/module_render.py | 5 +++-- 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/error_module.py b/common/lib/xmodule/xmodule/error_module.py index 882d181b8b..20301ee460 100644 --- a/common/lib/xmodule/xmodule/error_module.py +++ b/common/lib/xmodule/xmodule/error_module.py @@ -27,6 +27,14 @@ class ErrorModule(XModule): 'is_staff' : self.system.is_staff, }) + def displayable_items(self): + """Hide errors in the profile and table of contents for non-staff + users. + """ + if self.system.is_staff: + return [self] + return [] + class ErrorDescriptor(EditingDescriptor): """ Module that provides a raw editing view of broken xml. @@ -75,7 +83,8 @@ class ErrorDescriptor(EditingDescriptor): # TODO (vshnayder): Do we need a unique slug here? Just pick a random # 64-bit num? location = ['i4x', org, course, 'error', url_name] - metadata = {} # stays in the xml_data + # real metadata stays in the xml_data, but add a display name + metadata = {'display_name': 'Error ' + url_name} return cls(system, definition, location=location, metadata=metadata) diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 3e603a108d..18313910a1 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -8,6 +8,7 @@ from django.conf import settings from django.http import Http404 from xmodule.course_module import CourseDescriptor +from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError from static_replace import replace_urls @@ -168,6 +169,14 @@ def has_staff_access_to_course_id(user, course_id): return has_staff_access_to_course(user, loc.course) +def has_staff_access_to_location(user, location): + """Helper method that checks whether the user has staff access to + the course of the location. + + location: something that can be passed to Location + """ + return has_staff_access_to_course(user, Location(location).course) + def has_access_to_course(user, course): '''course is the .course element of a location''' if course.metadata.get('ispublic'): diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index 192794b6b3..85f883d2f0 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -146,8 +146,14 @@ def progress_summary(student, course, grader, student_module_cache): """ chapters = [] for c in course.get_children(): + # Don't include chapters that aren't displayable (e.g. due to error) + if c not in c.displayable_items(): + continue sections = [] for s in c.get_children(): + # Same for sections + if s not in s.displayable_items(): + continue graded = s.metadata.get('graded', False) scores = [] for module in yield_module_descendents(s): diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index d32f3d81ec..0341febab2 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -16,7 +16,8 @@ from xmodule.exceptions import NotFoundError from xmodule.x_module import ModuleSystem from xmodule_modifiers import replace_static_urls, add_histogram, wrap_xmodule -from courseware.courses import has_staff_access_to_course +from courseware.courses import (has_staff_access_to_course, + has_staff_access_to_location) log = logging.getLogger("mitx.courseware") @@ -182,7 +183,7 @@ def get_module(user, request, location, student_module_cache, position=None): # a module is coming through get_html and is therefore covered # by the replace_static_urls code below replace_urls=replace_urls, - is_staff=user.is_staff, + is_staff=has_staff_access_to_location(user, location), ) # pass position specified in URL to module through ModuleSystem system.set('position', position) From 2c9652ab740ca2d2b7d9b309f03d026fc4b04c4c Mon Sep 17 00:00:00 2001 From: ichuang Date: Sun, 12 Aug 2012 18:29:48 -0400 Subject: [PATCH 113/141] change xqa server location to suburl --- common/djangoapps/xmodule_modifiers.py | 2 +- lms/envs/dev.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common/djangoapps/xmodule_modifiers.py b/common/djangoapps/xmodule_modifiers.py index 58dbbd7c6e..0aeaa59d69 100644 --- a/common/djangoapps/xmodule_modifiers.py +++ b/common/djangoapps/xmodule_modifiers.py @@ -110,7 +110,7 @@ def add_histogram(get_html, module, user): 'element_id': module.location.html_id().replace('-','_'), 'edit_link': edit_link, 'user': user, - 'xqa_server' : settings.MITX_FEATURES.get('USE_XQA_SERVER','http://xqa:server@content-qa.mitx.mit.edu'), + 'xqa_server' : settings.MITX_FEATURES.get('USE_XQA_SERVER','http://xqa:server@content-qa.mitx.mit.edu/xqa'), 'histogram': json.dumps(histogram), 'render_histogram': render_histogram, 'module_content': get_html()} diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 1c40a936af..85850e81e3 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -62,7 +62,7 @@ SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' ################################ LMS Migration ################################# MITX_FEATURES['ENABLE_LMS_MIGRATION'] = True MITX_FEATURES['ACCESS_REQUIRE_STAFF_FOR_COURSE'] = False # require that user be in the staff_* group to be able to enroll -MITX_FEATURES['USE_XQA_SERVER'] = 'http://xqa:server@content-qa.mitx.mit.edu' +MITX_FEATURES['USE_XQA_SERVER'] = 'http://xqa:server@content-qa.mitx.mit.edu/xqa' LMS_MIGRATION_ALLOWED_IPS = ['127.0.0.1'] From da9742a88a4268ccc1c2f0eb9588aa1177037ccf Mon Sep 17 00:00:00 2001 From: ichuang Date: Sun, 12 Aug 2012 18:39:39 -0400 Subject: [PATCH 114/141] reroute email on AWS test machine (dev_ike) --- lms/envs/dev_ike.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lms/envs/dev_ike.py b/lms/envs/dev_ike.py index ba695e193e..309ea1ac42 100644 --- a/lms/envs/dev_ike.py +++ b/lms/envs/dev_ike.py @@ -22,6 +22,10 @@ if ('edxvm' in myhost) or ('ocw' in myhost): MITX_FEATURES['DISABLE_LOGIN_BUTTON'] = True # auto-login with MIT certificate MITX_FEATURES['USE_XQA_SERVER'] = 'https://qisx.mit.edu/xqa' # needs to be ssl or browser blocks it +if ('domU' in myhost): + EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' + MITX_FEATURES['REROUTE_ACTIVATION_EMAIL'] = 'ichuang@mitx.mit.edu' # nonempty string = address for all activation emails + SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTOCOL', 'https') # django 1.4 for nginx ssl proxy #----------------------------------------------------------------------------- From f072979a6cf084c1fa150934b9e616bb580fe289 Mon Sep 17 00:00:00 2001 From: ichuang Date: Sun, 12 Aug 2012 18:45:24 -0400 Subject: [PATCH 115/141] fix error in student views - js was undefined in _do_create_account --- common/djangoapps/student/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 8093a5a51a..c88aeaf337 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -297,6 +297,7 @@ def _do_create_account(post_vars): try: user.save() except IntegrityError: + js = {'success': False} # Figure out the cause of the integrity error if len(User.objects.filter(username=post_vars['username'])) > 0: js['value'] = "An account with this username already exists." From 47c48e9c662c4b8d7b2e140a5b2dd005c6a68af8 Mon Sep 17 00:00:00 2001 From: ichuang Date: Sun, 12 Aug 2012 19:07:31 -0400 Subject: [PATCH 116/141] fix student.views.create_account to handle errors from _do_create_account --- common/djangoapps/student/views.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index c88aeaf337..7924f5b6ce 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -403,7 +403,10 @@ def create_account(request, post_override=None): return HttpResponse(json.dumps(js)) # Ok, looks like everything is legit. Create the account. - (user, profile, registration) = _do_create_account(post_vars) + ret = _do_create_account(post_vars) + if isinstance(ret,HttpResponse): # if there was an error then return that + return ret + (user, profile, registration) = ret d = {'name': post_vars['name'], 'key': registration.activation_key, From 7711f81303f200752acd924ee0775bd18e698e88 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Sun, 12 Aug 2012 23:35:50 -0400 Subject: [PATCH 117/141] Add nginx.conf file for running a multi-server dev env --- proxy/nginx.conf | 67 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 proxy/nginx.conf diff --git a/proxy/nginx.conf b/proxy/nginx.conf new file mode 100644 index 0000000000..470c3933ac --- /dev/null +++ b/proxy/nginx.conf @@ -0,0 +1,67 @@ +# Mapping of +# +# From the /mitx directory: +# /usr/local/Cellar/nginx/1.2.2/sbin/nginx -p `pwd`/ -c nginx.conf + +worker_processes 1; + +events { + worker_connections 1024; +} + +http { + ## + # Basic Settings + ## + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + # server_tokens off; + # server_names_hash_bucket_size 64; + # server_name_in_redirect off; + + include /usr/local/etc/nginx/mime.types; + default_type application/octet-stream; + + ## + # Gzip Settings + ## + gzip on; + gzip_disable "msie6"; + + upstream portal { + server localhost:8000; + } + + upstream course_harvardx_cs50_2012 { + server localhost:8001; + } + + upstream course_mitx_6002_2012_fall { + server localhost:8002; + } + + # Mostly copied from our existing server... + server { + listen 8100 default_server; + + rewrite ^(.*)/favicon.ico$ /static/images/favicon.ico last; + + # Our catchall + location / { + proxy_pass http://portal; + } + + location /courses/HarvardX/CS50x/2012/ { + proxy_pass http://course_harvardx_cs50_2012; + } + + location /courses/MITx/6.002x/2012_Fall/ { + proxy_pass http://course_mitx_6002_2012_fall; + } + } +} + + From 510435ada5325a47542c5fc23ed9c6ca425de7a3 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Mon, 13 Aug 2012 00:35:51 -0400 Subject: [PATCH 118/141] Sketchy adding of test dbs to the cms test.py config file -- these are required for the Student (shared django app) tests to run --- cms/envs/test.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cms/envs/test.py b/cms/envs/test.py index bce3c796cf..3823cd9dd9 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -55,6 +55,17 @@ DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': ENV_ROOT / "db" / "cms.db", + }, + + # The following are for testing purposes... + 'edX/toy/2012_Fall': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ENV_ROOT / "db" / "course1.db", + }, + + 'edx/full/6.002_Spring_2012': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ENV_ROOT / "db" / "course2.db", } } From 0aad62d6a82c7157176968f5d011ec90e427216d Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Mon, 13 Aug 2012 00:48:35 -0400 Subject: [PATCH 119/141] Remove extra braces that were causing mako syntax errors --- lms/templates/gradebook.html | 4 ++-- lms/templates/instructor_dashboard.html | 4 ++-- lms/templates/profile.html | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lms/templates/gradebook.html b/lms/templates/gradebook.html index 5b88a7f2b6..a4a81a6868 100644 --- a/lms/templates/gradebook.html +++ b/lms/templates/gradebook.html @@ -61,8 +61,8 @@ %for student in students: + kwargs=dict(course_id=course_id, + student_id=student['id']))}"> ${student['username']} %for section in student['grade_summary']['section_breakdown']: ${percent_data( section['percent'] )} diff --git a/lms/templates/instructor_dashboard.html b/lms/templates/instructor_dashboard.html index a5b333a809..6b87f63031 100644 --- a/lms/templates/instructor_dashboard.html +++ b/lms/templates/instructor_dashboard.html @@ -14,10 +14,10 @@

Instructor Dashboard

- Gradebook + Gradebook

- Grade summary + Grade summary 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 120/141] 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 121/141] 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 1ff8e271f5413a6738fcf1bea80c99ab54b0dae5 Mon Sep 17 00:00:00 2001 From: kimth Date: Mon, 13 Aug 2012 10:14:29 -0400 Subject: [PATCH 122/141] Partial scoring --- common/lib/capa/capa/capa_problem.py | 13 +-------- common/lib/capa/capa/responsetypes.py | 30 +++++++++++++++----- common/lib/xmodule/xmodule/capa_module.py | 2 +- common/lib/xmodule/xmodule/tests/__init__.py | 3 +- 4 files changed, 27 insertions(+), 21 deletions(-) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index d1798f2c67..93c1b77caa 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -154,21 +154,10 @@ class LoncapaProblem(object): def get_max_score(self): ''' Return maximum score for this problem. - We do this by counting the number of answers available for each question - in the problem. If the Response for a question has a get_max_score() method - then we call that and add its return value to the count. That can be - used to give complex problems (eg programming questions) multiple points. ''' maxscore = 0 for response, responder in self.responders.iteritems(): - if hasattr(responder, 'get_max_score'): - try: - maxscore += responder.get_max_score() - except Exception: - log.debug('responder %s failed to properly return from get_max_score()' % responder) # FIXME - raise - else: - maxscore += len(self.responder_answers[response]) + maxscore += responder.get_max_score() return maxscore def get_score(self): diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 25b99fc00a..e474d5a5f8 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -71,7 +71,6 @@ class LoncapaResponse(object): In addition, these methods are optional: - - get_max_score : if defined, this is called to obtain the maximum score possible for this question - setup_response : find and note the answer input field IDs for the response; called by __init__ - check_hint_condition : check to see if the student's answers satisfy a particular condition for a hint to be displayed - render_html : render this Response as HTML (must return XHTML compliant string) @@ -130,6 +129,11 @@ class LoncapaResponse(object): if self.max_inputfields == 1: self.answer_id = self.answer_ids[0] # for convenience + self.maxpoints = dict() + for inputfield in self.inputfields: + maxpoints = inputfield.get('points','1') # By default, each answerfield is worth 1 point + self.maxpoints.update({inputfield.get('id'): int(maxpoints)}) + self.default_answer_map = {} # dict for default answer map (provided in input elements) for entry in self.inputfields: answer = entry.get('correct_answer') @@ -139,6 +143,12 @@ class LoncapaResponse(object): if hasattr(self, 'setup_response'): self.setup_response() + def get_max_score(self): + ''' + Return the total maximum points of all answer fields under this Response + ''' + return sum(self.maxpoints.values()) + def render_html(self, renderer): ''' Return XHTML Element tree representation of this Response. @@ -877,7 +887,10 @@ class CodeResponse(LoncapaResponse): (err, self.answer_id, convert_files_to_filenames(student_answers))) raise Exception(err) - self.context.update({'submission': unicode(submission)}) + if is_file(submission): + self.context.update({'submission': submission.name}) + else: + self.context.update({'submission': submission}) # Prepare xqueue request #------------------------------------------------------------ @@ -924,21 +937,24 @@ class CodeResponse(LoncapaResponse): def update_score(self, score_msg, oldcmap, queuekey): - (valid_score_msg, correct, score, msg) = self._parse_score_msg(score_msg) + (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.') return oldcmap - correctness = 'incorrect' - if correct: - correctness = 'correct' + correctness = 'correct' if correct else 'incorrect' self.context['correct'] = correctness # TODO: Find out how this is used elsewhere, if any # Replace 'oldcmap' with new grading results if queuekey matches. # If queuekey does not match, we keep waiting for the score_msg whose key actually matches if oldcmap.is_right_queuekey(self.answer_id, queuekey): - oldcmap.set(self.answer_id, correctness=correctness, msg=msg.replace(' ', ' '), queuekey=None) # Queuekey is consumed + # Sanity check on returned points + if points < 0: + 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 else: log.debug('CodeResponse: queuekey %s does not match for answer_id=%s.' % (queuekey, self.answer_id)) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index f12b1c2be4..46e02542c8 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -464,7 +464,7 @@ class CapaModule(XModule): return {'success': msg} log.exception("Error in capa_module problem checking") raise Exception("error in capa_module") - + self.attempts = self.attempts + 1 self.lcp.done = True diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py index 2d8c32d893..f47600030c 100644 --- a/common/lib/xmodule/xmodule/tests/__init__.py +++ b/common/lib/xmodule/xmodule/tests/__init__.py @@ -323,7 +323,8 @@ class CodeResponseTest(unittest.TestCase): new_cmap = CorrectMap() new_cmap.update(old_cmap) - new_cmap.set(answer_id=answer_ids[i], correctness=correctness, msg='MESSAGE', queuekey=None) + 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=1000 + i) self.assertEquals(test_lcp.correct_map.get_dict(), new_cmap.get_dict()) From 8b79d811d4454abbdf7605be63558101685ad2f6 Mon Sep 17 00:00:00 2001 From: Tom Giannattasio Date: Fri, 10 Aug 2012 14:08:58 -0400 Subject: [PATCH 123/141] added spinner gif --- common/lib/xmodule/xmodule/css/capa/display.scss | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss index 6b1c32ae65..d209f2d157 100644 --- a/common/lib/xmodule/xmodule/css/capa/display.scss +++ b/common/lib/xmodule/xmodule/css/capa/display.scss @@ -89,6 +89,19 @@ div { } } + &.checking { + p.status { + @include inline-block(); + background: url('../images/spinner.gif') center center no-repeat; + height: 20px; + width: 20px; + } + + input { + border-color: green; + } + } + &.incorrect, &.ui-icon-close { p.status { @include inline-block(); From 4eefa73336f54961a5373c671b104bee09e6632c Mon Sep 17 00:00:00 2001 From: Tom Giannattasio Date: Fri, 10 Aug 2012 14:17:59 -0400 Subject: [PATCH 124/141] changed field border color --- common/lib/xmodule/xmodule/css/capa/display.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss index d209f2d157..9a5aa82f6f 100644 --- a/common/lib/xmodule/xmodule/css/capa/display.scss +++ b/common/lib/xmodule/xmodule/css/capa/display.scss @@ -89,7 +89,7 @@ div { } } - &.checking { + &.processing { p.status { @include inline-block(); background: url('../images/spinner.gif') center center no-repeat; @@ -98,7 +98,7 @@ div { } input { - border-color: green; + border-color: #aaa; } } From ee0cbf66f9d458baefc96263ad6120209569f56b Mon Sep 17 00:00:00 2001 From: Tom Giannattasio Date: Fri, 10 Aug 2012 15:48:58 -0400 Subject: [PATCH 125/141] added a cleaner arrow implementation --- lms/static/sass/multicourse/_dashboard.scss | 23 ++++++++------------- lms/templates/dashboard.html | 2 +- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index 9c2b71f5c0..9581f5e016 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -203,28 +203,23 @@ display: block; left: 0px; position: absolute; + z-index: 50; top: 0px; @include transition(all, 0.15s, linear); right: 0px; } .arrow { - border-top: 8px solid; - border-left: 8px solid; - border-color: rgba(0,0,0, 0.7); - @include box-shadow(inset 0 1px 0 0 rgba(255,255,255, 0.8), -1px 0 1px 0 rgba(255,255,255, 0.8)); - content: ""; - display: block; - height: 55px; - left: 50%; - margin-left: -10px; - margin-top: -30px; - opacity: 0; position: absolute; - top: 50%; - @include transform(rotate(-45deg)); + z-index: 100; + width: 100%; + font-size: 70px; + line-height: 110px; + text-align: center; + text-decoration: none; + color: rgba(0, 0, 0, .7); + opacity: 0; @include transition(all, 0.15s, linear); - width: 55px; } &:hover { diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 480568a5b9..fc8e9abf30 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -73,7 +73,7 @@ %>

-
+
From 7b4cd3e4b345586fa569d7844e3ee9d14536be4c Mon Sep 17 00:00:00 2001 From: Tom Giannattasio Date: Fri, 10 Aug 2012 15:54:00 -0400 Subject: [PATCH 126/141] added additional processing class --- common/lib/xmodule/xmodule/css/capa/display.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss index 9a5aa82f6f..6a9b57cfef 100644 --- a/common/lib/xmodule/xmodule/css/capa/display.scss +++ b/common/lib/xmodule/xmodule/css/capa/display.scss @@ -159,7 +159,7 @@ div { width: 14px; } - &.processing, &.ui-icon-check { + &.processing, &.ui-icon-processing { @include inline-block(); background: url('../images/spinner.gif') center center no-repeat; height: 20px; From fe954ca1c7cbbb541e644c8e987e5b2d9a4512e9 Mon Sep 17 00:00:00 2001 From: kimth Date: Mon, 13 Aug 2012 10:46:01 -0400 Subject: [PATCH 127/141] Unbreak frontend polling --- common/lib/xmodule/xmodule/js/src/capa/display.coffee | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 6bdd8e1c36..d61813ce58 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -40,8 +40,9 @@ class @Problem poll: => $.postWithPrefix "#{@url}/problem_get", (response) => @el.html(response.html) - @executeProblemScripts() - @bind() + @executeProblemScripts () => + @setupInputTypes() + @bind() @queued_items = @$(".xqueue") if @queued_items.length == 0 From b5beb13964210089d5411bf129541c90acace9e5 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Mon, 13 Aug 2012 13:06:35 -0400 Subject: [PATCH 128/141] Fix side-effect related problems with User replication. 1. Multiple save()s on the same model are now handled properly. We had to unmark model objects after the appropriate signals had fired. 2. There was a side-effect where we were saving the portal User object to the course_db with the using kw param, but models remember where they were last saved to, so a later save on that model object would go to the wrong database. --- common/djangoapps/student/models.py | 48 ++++++++++++++--------------- common/djangoapps/student/tests.py | 29 +++++++++++------ 2 files changed, 44 insertions(+), 33 deletions(-) diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 0fbe70c0b3..a3f6926a51 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -255,10 +255,13 @@ def add_user_to_default_group(user, group): utg.save() ########################## REPLICATION SIGNALS ################################# -@receiver(post_save, sender=User) +#@receiver(post_save, sender=User) def replicate_user_save(sender, **kwargs): - user_obj = kwargs['instance'] - return replicate_model(User.save, user_obj, user_obj.id) + user_obj = kwargs['instance'] + if not should_replicate(user_obj): + return + for course_db_name in db_names_to_replicate_to(user_obj.id): + replicate_user(user_obj, course_db_name) @receiver(post_save, sender=CourseEnrollment) def replicate_enrollment_save(sender, **kwargs): @@ -287,8 +290,8 @@ def replicate_enrollment_save(sender, **kwargs): @receiver(post_delete, sender=CourseEnrollment) def replicate_enrollment_delete(sender, **kwargs): - enrollment_obj = kwargs['instance'] - return replicate_model(CourseEnrollment.delete, enrollment_obj, enrollment_obj.user_id) + enrollment_obj = kwargs['instance'] + return replicate_model(CourseEnrollment.delete, enrollment_obj, enrollment_obj.user_id) @receiver(post_save, sender=UserProfile) def replicate_userprofile_save(sender, **kwargs): @@ -311,23 +314,20 @@ def replicate_user(portal_user, course_db_name): overridden. """ try: - # If the user exists in the Course DB, update the appropriate fields and - # save it back out to the Course DB. course_user = User.objects.using(course_db_name).get(id=portal_user.id) - for field in USER_FIELDS_TO_COPY: - setattr(course_user, field, getattr(portal_user, field)) - - mark_handled(course_user) log.debug("User {0} found in Course DB, replicating fields to {1}" .format(course_user, course_db_name)) - course_user.save(using=course_db_name) # Just being explicit. - except User.DoesNotExist: - # Otherwise, just make a straight copy to the Course DB. - mark_handled(portal_user) log.debug("User {0} not found in Course DB, creating copy in {1}" .format(portal_user, course_db_name)) - portal_user.save(using=course_db_name) + course_user = User() + + for field in USER_FIELDS_TO_COPY: + setattr(course_user, field, getattr(portal_user, field)) + + mark_handled(course_user) + course_user.save(using=course_db_name) # Just being explicit. + unmark(course_user) def replicate_model(model_method, instance, user_id): """ @@ -337,13 +337,14 @@ def replicate_model(model_method, instance, user_id): if not should_replicate(instance): return - mark_handled(instance) course_db_names = db_names_to_replicate_to(user_id) log.debug("Replicating {0} for user {1} to DBs: {2}" .format(model_method, user_id, course_db_names)) + mark_handled(instance) for db_name in course_db_names: model_method(instance, using=db_name) + unmark(instance) ######### Replication Helpers ######### @@ -371,7 +372,7 @@ def db_names_to_replicate_to(user_id): def marked_handled(instance): """Have we marked this instance as being handled to avoid infinite loops caused by saving models in post_save hooks for the same models?""" - return hasattr(instance, '_do_not_copy_to_course_db') + return hasattr(instance, '_do_not_copy_to_course_db') and instance._do_not_copy_to_course_db def mark_handled(instance): """You have to mark your instance with this function or else we'll go into @@ -384,6 +385,11 @@ def mark_handled(instance): """ instance._do_not_copy_to_course_db = True +def unmark(instance): + """If we don't unmark a model after we do replication, then consecutive + save() calls won't be properly replicated.""" + instance._do_not_copy_to_course_db = False + def should_replicate(instance): """Should this instance be replicated? We need to be a Portal server and the instance has to not have been marked_handled.""" @@ -398,9 +404,3 @@ def should_replicate(instance): return False return True - - - - - - diff --git a/common/djangoapps/student/tests.py b/common/djangoapps/student/tests.py index ad7ddb70d1..b71fce5158 100644 --- a/common/djangoapps/student/tests.py +++ b/common/djangoapps/student/tests.py @@ -4,6 +4,7 @@ when you run "manage.py test". Replace this with more appropriate tests for your application. """ +import logging from datetime import datetime from django.test import TestCase @@ -13,6 +14,8 @@ from .models import User, UserProfile, CourseEnrollment, replicate_user, USER_FI COURSE_1 = 'edX/toy/2012_Fall' COURSE_2 = 'edx/full/6.002_Spring_2012' +log = logging.getLogger(__name__) + class ReplicationTest(TestCase): multi_db = True @@ -47,23 +50,18 @@ class ReplicationTest(TestCase): field, portal_user, course_user )) - if hasattr(portal_user, 'seen_response_count'): - # Since it's the first copy over of User data, we should have all of it - self.assertEqual(portal_user.seen_response_count, - course_user.seen_response_count) - - # But if we replicate again, the user already exists in the Course DB, - # so it shouldn't update the seen_response_count (which is Askbot - # controlled). # This hasattr lameness is here because we don't want this test to be # triggered when we're being run by CMS tests (Askbot doesn't exist # there, so the test will fail). + # + # seen_response_count isn't a field we care about, so it shouldn't have + # been copied over. if hasattr(portal_user, 'seen_response_count'): portal_user.seen_response_count = 20 replicate_user(portal_user, COURSE_1) course_user = User.objects.using(COURSE_1).get(id=portal_user.id) self.assertEqual(portal_user.seen_response_count, 20) - self.assertEqual(course_user.seen_response_count, 10) + self.assertEqual(course_user.seen_response_count, 0) # Another replication should work for an email change however, since # it's a field we care about. @@ -123,6 +121,19 @@ class ReplicationTest(TestCase): UserProfile.objects.using(COURSE_2).get, id=portal_user_profile.id) + log.debug("Make sure our seen_response_count is not replicated.") + if hasattr(portal_user, 'seen_response_count'): + portal_user.seen_response_count = 200 + course_user = User.objects.using(COURSE_1).get(id=portal_user.id) + self.assertEqual(portal_user.seen_response_count, 200) + self.assertEqual(course_user.seen_response_count, 0) + portal_user.save() + + course_user = User.objects.using(COURSE_1).get(id=portal_user.id) + self.assertEqual(portal_user.seen_response_count, 200) + self.assertEqual(course_user.seen_response_count, 0) + + def test_enrollment_for_user_info_after_enrollment(self): """Test the effect of modifying User data after you've enrolled.""" From 301695a5c4e6913ff2e15529be12941d8e3d34a1 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Mon, 13 Aug 2012 13:11:27 -0400 Subject: [PATCH 129/141] Remove outdated comment --- common/djangoapps/student/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index a3f6926a51..d04a56362e 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -326,7 +326,7 @@ def replicate_user(portal_user, course_db_name): setattr(course_user, field, getattr(portal_user, field)) mark_handled(course_user) - course_user.save(using=course_db_name) # Just being explicit. + course_user.save(using=course_db_name) unmark(course_user) def replicate_model(model_method, instance, user_id): From a25f289ca7d14bbdb3bf3c6a62fd02b694c40d8c Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Mon, 13 Aug 2012 13:16:33 -0400 Subject: [PATCH 130/141] re-enable User save signal handler --- common/djangoapps/student/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index d04a56362e..6d1cbb5afb 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -255,7 +255,7 @@ def add_user_to_default_group(user, group): utg.save() ########################## REPLICATION SIGNALS ################################# -#@receiver(post_save, sender=User) +@receiver(post_save, sender=User) def replicate_user_save(sender, **kwargs): user_obj = kwargs['instance'] if not should_replicate(user_obj): From c4d89cd535cc9afa04a719e23d83bc35527033bc Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Mon, 13 Aug 2012 13:34:24 -0400 Subject: [PATCH 131/141] One more test to make sure users are really being copied --- common/djangoapps/student/tests.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/common/djangoapps/student/tests.py b/common/djangoapps/student/tests.py index b71fce5158..b33678fbac 100644 --- a/common/djangoapps/student/tests.py +++ b/common/djangoapps/student/tests.py @@ -133,6 +133,12 @@ class ReplicationTest(TestCase): self.assertEqual(portal_user.seen_response_count, 200) self.assertEqual(course_user.seen_response_count, 0) + portal_user.email = 'jim@edx.org' + portal_user.save() + course_user = User.objects.using(COURSE_1).get(id=portal_user.id) + self.assertEqual(portal_user.email, 'jim@edx.org') + self.assertEqual(course_user.email, 'jim@edx.org') + def test_enrollment_for_user_info_after_enrollment(self): From 706fd99cabcd6d36e5f525b0522459b1571c844e Mon Sep 17 00:00:00 2001 From: Victor Shnayder Date: Mon, 13 Aug 2012 14:21:19 -0400 Subject: [PATCH 132/141] Address comments on #394 --- common/lib/xmodule/xmodule/x_module.py | 4 ++-- lms/djangoapps/courseware/grades.py | 10 +++------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 9f99f5a526..071e453901 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -227,7 +227,7 @@ class XModule(HTMLSnippet): def get_display_items(self): ''' Returns a list of descendent module instances that will display - immediately inside this module + immediately inside this module. ''' items = [] for child in self.get_children(): @@ -238,7 +238,7 @@ class XModule(HTMLSnippet): def displayable_items(self): ''' Returns list of displayable modules contained by this module. If this - module is visible, should return [self] + module is visible, should return [self]. ''' return [self] diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index 85f883d2f0..aa160ee22a 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -145,15 +145,11 @@ def progress_summary(student, course, grader, student_module_cache): instance_modules for the student """ chapters = [] - for c in course.get_children(): - # Don't include chapters that aren't displayable (e.g. due to error) - if c not in c.displayable_items(): - continue + # Don't include chapters that aren't displayable (e.g. due to error) + for c in course.get_display_items(): sections = [] - for s in c.get_children(): + for s in c.get_display_items(): # Same for sections - if s not in s.displayable_items(): - continue graded = s.metadata.get('graded', False) scores = [] for module in yield_module_descendents(s): From add050593af2dc0b14010c5876bfc3004c826786 Mon Sep 17 00:00:00 2001 From: Victor Shnayder Date: Mon, 13 Aug 2012 14:23:41 -0400 Subject: [PATCH 133/141] address comment on #392 --- common/djangoapps/static_replace.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/djangoapps/static_replace.py b/common/djangoapps/static_replace.py index ba3cfb302a..ce3dc55031 100644 --- a/common/djangoapps/static_replace.py +++ b/common/djangoapps/static_replace.py @@ -17,8 +17,8 @@ def try_staticfiles_lookup(path): except Exception as err: log.warning("staticfiles_storage couldn't find path {}: {}".format( path, str(err))) - # Just return a dead link--don't kill everything. - url = "file_not_found" + # Just return the original path; don't kill everything. + url = path return url From 8716f88155ad253ebff573415f5cca1e6126a3ad Mon Sep 17 00:00:00 2001 From: Victor Shnayder Date: Mon, 13 Aug 2012 15:03:46 -0400 Subject: [PATCH 134/141] add course enrollment windows * if the course metadata have enrollment_start and/or enrollment_end, only allow normal users to enroll post start and pre end. * If DARK_LAUNCH is on, staff can enroll outside the window --- common/djangoapps/student/views.py | 57 +++++++-- common/lib/xmodule/xmodule/course_module.py | 27 +++- lms/djangoapps/courseware/tests/tests.py | 129 +++++++++++++++++--- 3 files changed, 177 insertions(+), 36 deletions(-) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 1951426ea7..d9720903d3 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -1,13 +1,14 @@ import datetime +import feedparser +import itertools import json import logging import random import string import sys -import uuid -import feedparser +import time import urllib -import itertools +import uuid from django.conf import settings from django.contrib.auth import logout, authenticate, login @@ -26,17 +27,19 @@ from bs4 import BeautifulSoup from django.core.cache import cache from django_future.csrf import ensure_csrf_cookie -from student.models import Registration, UserProfile, PendingNameChange, PendingEmailChange, CourseEnrollment +from student.models import (Registration, UserProfile, + PendingNameChange, PendingEmailChange, + CourseEnrollment) from util.cache import cache_if_anonymous from xmodule.course_module import CourseDescriptor from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError -from models import Registration, UserProfile, PendingNameChange, PendingEmailChange, CourseEnrollment from datetime import date from collections import namedtuple -from courseware.courses import course_staff_group_name, has_staff_access_to_course, get_courses_by_university +from courseware.courses import (course_staff_group_name, has_staff_access_to_course, + get_courses_by_university) log = logging.getLogger("mitx.student") Article = namedtuple('Article', 'title url author image deck publication publish_date') @@ -47,7 +50,8 @@ def csrf_token(context): csrf_token = context.get('csrf_token', '') if csrf_token == 'NOTPROVIDED': return '' - return u'
' % (csrf_token) + return (u'
' % (csrf_token)) @ensure_csrf_cookie @@ -162,6 +166,26 @@ def change_enrollment_view(request): """Delegate to change_enrollment to actually do the work.""" return HttpResponse(json.dumps(change_enrollment(request))) +def enrollment_allowed(user, course): + """If the course has an enrollment period, check whether we are in it. + Also respects the DARK_LAUNCH setting""" + now = time.gmtime() + start = course.enrollment_start + end = course.enrollment_end + + if (start is None or now > start) and (end is None or now < end): + # in enrollment period. + print "allowing enrollment in {}: start {}, end {}, now {}".format( + course.location.url(), start, end, now) + return True + + if settings.MITX_FEATURES['DARK_LAUNCH']: + if has_staff_access_to_course(user, course): + # if dark launch, staff can enroll outside enrollment window + return True + return False + + def change_enrollment(request): if request.method != "POST": raise Http404 @@ -174,7 +198,8 @@ def change_enrollment(request): course_id = request.POST.get("course_id", None) if course_id == None: - return HttpResponse(json.dumps({'success': False, 'error': 'There was an error receiving the course id.'})) + return HttpResponse(json.dumps({'success': False, + 'error': 'There was an error receiving the course id.'})) if action == "enroll": # Make sure the course exists @@ -187,12 +212,20 @@ def change_enrollment(request): return {'success': False, 'error': 'The course requested does not exist.'} if settings.MITX_FEATURES.get('ACCESS_REQUIRE_STAFF_FOR_COURSE'): - # require that user be in the staff_* group (or be an overall admin) to be able to enroll - # eg staff_6.002x or staff_6.00x + # require that user be in the staff_* group (or be an + # overall admin) to be able to enroll eg staff_6.002x or + # staff_6.00x if not has_staff_access_to_course(user, course): staff_group = course_staff_group_name(course) - log.debug('user %s denied enrollment to %s ; not in %s' % (user,course.location.url(),staff_group)) - return {'success': False, 'error' : '%s membership required to access course.' % staff_group} + log.debug('user %s denied enrollment to %s ; not in %s' % ( + user, course.location.url(), staff_group)) + return {'success': False, + 'error' : '%s membership required to access course.' % staff_group} + + if not enrollment_allowed(user, course): + return {'success': False, + 'error': 'enrollment in {} not allowed at this time' + .format(course.display_name)} enrollment, created = CourseEnrollment.objects.get_or_create(user=user, course_id=course.id) return {'success': True} diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 7ec9f54cd2..e7d480f4e9 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -21,18 +21,35 @@ class CourseDescriptor(SequenceDescriptor): try: self.start = time.strptime(self.metadata["start"], "%Y-%m-%dT%H:%M") except KeyError: - self.start = time.gmtime(0) #The epoch msg = "Course loaded without a start date. id = %s" % self.id - log.critical(msg) except ValueError as e: - self.start = time.gmtime(0) #The epoch msg = "Course loaded with a bad start date. %s '%s'" % (self.id, e) - log.critical(msg) # Don't call the tracker from the exception handler. if msg is not None: + self.start = time.gmtime(0) # The epoch + log.critical(msg) system.error_tracker(msg) + def try_parse_time(key): + """ + Parse an optional metadata key: if present, must be valid. + Return None if not present. + """ + if key in self.metadata: + try: + return time.strptime(self.metadata[key], "%Y-%m-%dT%H:%M") + except ValueError as e: + msg = "Course %s loaded with a bad metadata key %s '%s'" % ( + self.id, self.metadata[key], e) + log.warning(msg) + return None + + self.enrollment_start = try_parse_time("enrollment_start") + self.enrollment_end = try_parse_time("enrollment_end") + + + def has_started(self): return time.gmtime() > self.start @@ -100,7 +117,7 @@ class CourseDescriptor(SequenceDescriptor): for s in c.get_children(): if s.metadata.get('graded', False): xmoduledescriptors = list(yield_descriptor_descendents(s)) - + # The xmoduledescriptors included here are only the ones that have scores. section_description = { 'section_descriptor' : s, 'xmoduledescriptors' : filter(lambda child: child.has_score, xmoduledescriptors) } diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 0fb5c9983e..daffa44d2a 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -58,8 +58,22 @@ def mongo_store_config(data_dir): } } +def xml_store_config(data_dir): + return { + 'default': { + 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', + 'OPTIONS': { + 'data_dir': data_dir, + 'default_class': 'xmodule.hidden_module.HiddenDescriptor', + 'eager': True, + } + } +} + + TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT -TEST_DATA_MODULESTORE = mongo_store_config(TEST_DATA_DIR) +TEST_DATA_MONGO_MODULESTORE = mongo_store_config(TEST_DATA_DIR) +TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) REAL_DATA_DIR = settings.GITHUB_REPO_ROOT REAL_DATA_MODULESTORE = mongo_store_config(REAL_DATA_DIR) @@ -149,8 +163,27 @@ class ActivateLoginTestCase(TestCase): class PageLoader(ActivateLoginTestCase): ''' Base class that adds a function to load all pages in a modulestore ''' + def _enroll(self, course): + """Post to the enrollment view, and return the parsed json response""" + resp = self.client.post('/change_enrollment', { + 'enrollment_action': 'enroll', + 'course_id': course.id, + }) + return parse_json(resp) + + def try_enroll(self, course): + """Try to enroll. Return bool success instead of asserting it.""" + data = self._enroll(course) + print 'Enrollment in {} result: {}'.format(course.location.url(), data) + return data['success'] + def enroll(self, course): """Enroll the currently logged-in user, and check that it worked.""" + data = self._enroll(course) + self.assertTrue(data['success']) + + def unenroll(self, course): + """Unenroll the currently logged-in user, and check that it worked.""" resp = self.client.post('/change_enrollment', { 'enrollment_action': 'enroll', 'course_id': course.id, @@ -159,6 +192,7 @@ class PageLoader(ActivateLoginTestCase): self.assertTrue(data['success']) def check_pages_load(self, course_name, data_dir, modstore): + """Make all locations in course load""" print "Checking course {0} in {1}".format(course_name, data_dir) import_from_xml(modstore, data_dir, [course_name]) @@ -191,7 +225,7 @@ class PageLoader(ActivateLoginTestCase): self.assertTrue(all_ok) -@override_settings(MODULESTORE=TEST_DATA_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) class TestCoursesLoadTestCase(PageLoader): '''Check that all pages in test courses load properly''' @@ -207,7 +241,7 @@ class TestCoursesLoadTestCase(PageLoader): self.check_pages_load('full', TEST_DATA_DIR, modulestore()) -@override_settings(MODULESTORE=TEST_DATA_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class TestViewAuth(PageLoader): """Check that view authentication works properly""" @@ -215,15 +249,15 @@ class TestViewAuth(PageLoader): # can't do imports there without manually hacking settings. def setUp(self): - print "sys.path: {}".format(sys.path) xmodule.modulestore.django._MODULESTORES = {} - modulestore().collection.drop() - import_from_xml(modulestore(), TEST_DATA_DIR, ['toy']) - import_from_xml(modulestore(), TEST_DATA_DIR, ['full']) courses = modulestore().get_courses() - # get the two courses sorted out - courses.sort(key=lambda c: c.location.course) - [self.full, self.toy] = courses + + def find_course(name): + """Assumes the course is present""" + return [c for c in courses if c.location.course==name][0] + + self.full = find_course("full") + self.toy = find_course("toy") # Create two accounts self.student = 'view@test.com' @@ -304,26 +338,35 @@ class TestViewAuth(PageLoader): self.check_for_get_code(200, url) - def test_dark_launch(self): - """Make sure that when dark launch is on, students can't access course - pages, but instructors can""" - - # test.py turns off start dates, enable them and set them correctly. - # Because settings is global, be careful not to mess it up for other tests - # (Can't use override_settings because we're only changing part of the - # MITX_FEATURES dict) + def run_wrapped(self, test): + """ + test.py turns off start dates. Enable them and DARK_LAUNCH. + Because settings is global, be careful not to mess it up for other tests + (Can't use override_settings because we're only changing part of the + MITX_FEATURES dict) + """ oldDSD = settings.MITX_FEATURES['DISABLE_START_DATES'] oldDL = settings.MITX_FEATURES['DARK_LAUNCH'] try: settings.MITX_FEATURES['DISABLE_START_DATES'] = False settings.MITX_FEATURES['DARK_LAUNCH'] = True - self._do_test_dark_launch() + test() finally: settings.MITX_FEATURES['DISABLE_START_DATES'] = oldDSD settings.MITX_FEATURES['DARK_LAUNCH'] = oldDL + def test_dark_launch(self): + """Make sure that when dark launch is on, students can't access course + pages, but instructors can""" + self.run_wrapped(self._do_test_dark_launch) + + def test_enrollment_period(self): + """Check that enrollment periods work""" + self.run_wrapped(self._do_test_enrollment_period) + + def _do_test_dark_launch(self): """Actually do the test, relying on settings to be right.""" @@ -338,6 +381,7 @@ class TestViewAuth(PageLoader): self.assertTrue(settings.MITX_FEATURES['DARK_LAUNCH']) def reverse_urls(names, course): + """Reverse a list of course urls""" return [reverse(name, kwargs={'course_id': course.id}) for name in names] def dark_student_urls(course): @@ -424,6 +468,53 @@ class TestViewAuth(PageLoader): check_staff(self.toy) check_staff(self.full) + def _do_test_enrollment_period(self): + """Actually do the test, relying on settings to be right.""" + + # Make courses start in the future + tomorrow = time.time() + 24 * 3600 + nextday = tomorrow + 24 * 3600 + yesterday = time.time() - 24 * 3600 + + print "changing" + # toy course's enrollment period hasn't started + self.toy.enrollment_start = time.gmtime(tomorrow) + self.toy.enrollment_end = time.gmtime(nextday) + + # full course's has + self.full.enrollment_start = time.gmtime(yesterday) + self.full.enrollment_end = time.gmtime(tomorrow) + + print "login" + # First, try with an enrolled student + print '=== Testing student access....' + self.login(self.student, self.password) + self.assertFalse(self.try_enroll(self.toy)) + self.assertTrue(self.try_enroll(self.full)) + + print '=== Testing course instructor access....' + # Make the instructor staff in the toy course + group_name = course_staff_group_name(self.toy) + g = Group.objects.create(name=group_name) + g.user_set.add(user(self.instructor)) + + print "logout/login" + self.logout() + self.login(self.instructor, self.password) + print "Instructor should be able to enroll in toy course" + self.assertTrue(self.try_enroll(self.toy)) + + print '=== Testing staff access....' + # now make the instructor global staff, but not in the instructor group + g.user_set.remove(user(self.instructor)) + u = user(self.instructor) + u.is_staff = True + u.save() + + # unenroll and try again + self.unenroll(self.toy) + self.assertTrue(self.try_enroll(self.toy)) + @override_settings(MODULESTORE=REAL_DATA_MODULESTORE) class RealCoursesLoadTestCase(PageLoader): From 731f1cd7a8ca194ed806e37a25cd0e9d255302bc Mon Sep 17 00:00:00 2001 From: Kyle Fiedler Date: Mon, 13 Aug 2012 15:05:43 -0400 Subject: [PATCH 135/141] Added tooltip styles, made all buttons the same, and other small changes throughout the courseware --- .../xmodule/xmodule/css/video/display.scss | 46 ++-------- lms/static/sass/course.scss | 1 + lms/static/sass/course/_info.scss | 20 ++-- lms/static/sass/course/base/_base.scss | 1 + lms/static/sass/course/base/_extends.scss | 27 ++---- .../sass/course/courseware/_courseware.scss | 6 +- .../sass/course/courseware/_sidebar.scss | 3 +- .../sass/course/discussion/_answers.scss | 3 +- lms/static/sass/course/discussion/_forms.scss | 2 +- lms/static/sass/course/wiki/_sidebar.scss | 16 ++-- lms/static/sass/shared/_forms.scss | 92 ++++++++++--------- lms/static/sass/shared/_tooltips.scss | 12 +++ lms/templates/simplewiki/simplewiki_base.html | 6 +- 13 files changed, 105 insertions(+), 130 deletions(-) create mode 100644 lms/static/sass/shared/_tooltips.scss diff --git a/common/lib/xmodule/xmodule/css/video/display.scss b/common/lib/xmodule/xmodule/css/video/display.scss index 235e2e3277..8d0c4ac522 100644 --- a/common/lib/xmodule/xmodule/css/video/display.scss +++ b/common/lib/xmodule/xmodule/css/video/display.scss @@ -14,7 +14,7 @@ div.video { section.video-player { height: 0; - overflow: hidden; + // overflow: hidden; padding-bottom: 56.25%; position: relative; @@ -45,12 +45,13 @@ div.video { div.slider { @extend .clearfix; background: #c2c2c2; - border: none; - border-bottom: 1px solid #000; + border: 1px solid #000; @include border-radius(0); border-top: 1px solid #000; @include box-shadow(inset 0 1px 0 #eee, 0 1px 0 #555); height: 7px; + margin-left: -1px; + margin-right: -1px; @include transition(height 2.0s ease-in-out); div.ui-widget-header { @@ -58,43 +59,12 @@ div.video { @include box-shadow(inset 0 1px 0 #999); } - .ui-tooltip.qtip .ui-tooltip-content { - background: $mit-red; - border: 1px solid darken($mit-red, 20%); - @include border-radius(2px); - @include box-shadow(inset 0 1px 0 lighten($mit-red, 10%)); - color: #fff; - font: bold 12px $body-font-family; - margin-bottom: 6px; - margin-right: 0; - overflow: visible; - padding: 4px; - text-align: center; - text-shadow: 0 -1px 0 darken($mit-red, 10%); - -webkit-font-smoothing: antialiased; - - &::after { - background: $mit-red; - border-bottom: 1px solid darken($mit-red, 20%); - border-right: 1px solid darken($mit-red, 20%); - bottom: -5px; - content: " "; - display: block; - height: 7px; - left: 50%; - margin-left: -3px; - position: absolute; - @include transform(rotate(45deg)); - width: 7px; - } - } - a.ui-slider-handle { - background: $mit-red url(../images/slider-handle.png) center center no-repeat; + background: $pink url(../images/slider-handle.png) center center no-repeat; @include background-size(50%); - border: 1px solid darken($mit-red, 20%); + border: 1px solid darken($pink, 20%); @include border-radius(15px); - @include box-shadow(inset 0 1px 0 lighten($mit-red, 10%)); + @include box-shadow(inset 0 1px 0 lighten($pink, 10%)); cursor: pointer; height: 15px; margin-left: -7px; @@ -103,7 +73,7 @@ div.video { width: 15px; &:focus, &:hover { - background-color: lighten($mit-red, 10%); + background-color: lighten($pink, 10%); outline: none; } } diff --git a/lms/static/sass/course.scss b/lms/static/sass/course.scss index cc1b49a0a2..c874076a31 100644 --- a/lms/static/sass/course.scss +++ b/lms/static/sass/course.scss @@ -7,6 +7,7 @@ @import 'base/base'; @import 'base/extends'; @import 'base/animations'; +@import 'shared/tooltips'; // Course base / layout styles @import 'course/layout/courseware_subnav'; diff --git a/lms/static/sass/course/_info.scss b/lms/static/sass/course/_info.scss index 1dac5354b6..1651ad4da8 100644 --- a/lms/static/sass/course/_info.scss +++ b/lms/static/sass/course/_info.scss @@ -15,15 +15,15 @@ div.info-wrapper { > ol { list-style: none; - padding-left: 0; margin-bottom: lh(); + padding-left: 0; > li { @extend .clearfix; border-bottom: 1px solid lighten($border-color, 10%); + list-style-type: disk; margin-bottom: lh(); padding-bottom: lh(.5); - list-style-type: disk; &:first-child { margin: 0 (-(lh(.5))) lh(); @@ -41,10 +41,10 @@ div.info-wrapper { h2 { float: left; - margin: 0 flex-gutter() 0 0; - width: flex-grid(2, 9); font-size: $body-font-size; font-weight: bold; + margin: 0 flex-gutter() 0 0; + width: flex-grid(2, 9); } section.update-description { @@ -68,15 +68,15 @@ div.info-wrapper { section.handouts { @extend .sidebar; - border-left: 1px solid #d3d3d3; + border-left: 1px solid $border-color; @include border-radius(0 4px 4px 0); - @include box-shadow(none); border-right: 0; + @include box-shadow(none); h1 { @extend .bottom-border; - padding: lh(.5) lh(.5); margin-bottom: 0; + padding: lh(.5) lh(.5); } ol { @@ -90,8 +90,9 @@ div.info-wrapper { &.expandable, &.collapsable { h4 { - font-weight: normal; + color: $blue; font-size: 1em; + font-weight: normal; padding: lh(.25) 0 lh(.25) lh(1.5); } } @@ -145,7 +146,8 @@ div.info-wrapper { filter: alpha(opacity=60); + h4 { - background-color: #e3e3e3; + @extend a:hover; + text-decoration: underline; } } diff --git a/lms/static/sass/course/base/_base.scss b/lms/static/sass/course/base/_base.scss index cd68b4bbaf..034e047754 100644 --- a/lms/static/sass/course/base/_base.scss +++ b/lms/static/sass/course/base/_base.scss @@ -3,6 +3,7 @@ body { } body, h1, h2, h3, h4, h5, h6, p, p a:link, p a:visited, a { + text-align: left; font-family: $sans-serif; } diff --git a/lms/static/sass/course/base/_extends.scss b/lms/static/sass/course/base/_extends.scss index 7b3e1cba84..c5e61f593e 100644 --- a/lms/static/sass/course/base/_extends.scss +++ b/lms/static/sass/course/base/_extends.scss @@ -25,24 +25,12 @@ h1.top-header { } } -.action-link { - a { - color: $mit-red; - - &:hover { - color: darken($mit-red, 20%); - text-decoration: none; - } - } -} - .content { @include box-sizing(border-box); display: table-cell; padding: lh(); vertical-align: top; width: flex-grid(9) + flex-gutter(); - overflow: hidden; @media print { @include box-shadow(none); @@ -164,7 +152,6 @@ h1.top-header { .topbar { @extend .clearfix; border-bottom: 1px solid $border-color; - font-size: 14px; @media print { display: none; @@ -193,17 +180,17 @@ h1.top-header { h2 { display: block; - width: 700px; float: left; font-size: 0.9em; font-weight: 600; - line-height: 40px; letter-spacing: 0; - text-transform: none; - text-shadow: 0 1px 0 #fff; - white-space: nowrap; - text-overflow: ellipsis; + line-height: 40px; overflow: hidden; + text-overflow: ellipsis; + text-shadow: 0 1px 0 #fff; + text-transform: none; + white-space: nowrap; + width: 700px; .provider { font: inherit; @@ -211,4 +198,4 @@ h1.top-header { color: #6d6d6d; } } -} \ No newline at end of file +} diff --git a/lms/static/sass/course/courseware/_courseware.scss b/lms/static/sass/course/courseware/_courseware.scss index fa3e844e88..198902c146 100644 --- a/lms/static/sass/course/courseware/_courseware.scss +++ b/lms/static/sass/course/courseware/_courseware.scss @@ -146,13 +146,13 @@ div.course-wrapper { @include border-radius(0); a.ui-slider-handle { - @include box-shadow(inset 0 1px 0 lighten($mit-red, 10%)); + @include box-shadow(inset 0 1px 0 lighten($pink, 10%)); background: $mit-red url(../images/slider-bars.png) center center no-repeat; - border: 1px solid darken($mit-red, 20%); + border: 1px solid darken($pink, 20%); cursor: pointer; &:hover, &:focus { - background-color: lighten($mit-red, 10%); + background-color: lighten($pink, 10%); outline: none; } } diff --git a/lms/static/sass/course/courseware/_sidebar.scss b/lms/static/sass/course/courseware/_sidebar.scss index 7f24659533..51e9cbd90d 100644 --- a/lms/static/sass/course/courseware/_sidebar.scss +++ b/lms/static/sass/course/courseware/_sidebar.scss @@ -13,7 +13,7 @@ section.course-index { div#accordion { h3 { @include border-radius(0); - border-top: 1px solid #e3e3e3; + border-top: 1px solid lighten($border-color, 10%); font-size: em(16, 18); margin: 0; overflow: hidden; @@ -34,6 +34,7 @@ section.course-index { } &.ui-accordion-header { + border-bottom: none; color: #000; a { diff --git a/lms/static/sass/course/discussion/_answers.scss b/lms/static/sass/course/discussion/_answers.scss index f0de650206..8ab22aa833 100644 --- a/lms/static/sass/course/discussion/_answers.scss +++ b/lms/static/sass/course/discussion/_answers.scss @@ -17,7 +17,6 @@ div.answer-controls { margin-left: flex-gutter(); nav { - @extend .action-link; float: right; margin-top: 34px; @@ -144,7 +143,7 @@ div.answer-actions { text-decoration: none; &.question-delete { - // color: $mit-red; + color: $mit-red; } } } diff --git a/lms/static/sass/course/discussion/_forms.scss b/lms/static/sass/course/discussion/_forms.scss index 3d484729b1..ae02ab3b20 100644 --- a/lms/static/sass/course/discussion/_forms.scss +++ b/lms/static/sass/course/discussion/_forms.scss @@ -92,7 +92,7 @@ form.answer-form { margin-left: 2.5%; padding-left: 1.5%; border-left: 1px dashed #ddd; - color: $mit-red;; + color: $mit-red; } ul, ol, pre { diff --git a/lms/static/sass/course/wiki/_sidebar.scss b/lms/static/sass/course/wiki/_sidebar.scss index 8c9f97d27d..22574c7a5a 100644 --- a/lms/static/sass/course/wiki/_sidebar.scss +++ b/lms/static/sass/course/wiki/_sidebar.scss @@ -14,14 +14,6 @@ div#wiki_panel { } } - form { - input[type="submit"]{ - @extend .light-button; - text-transform: none; - text-shadow: none; - } - } - div#wiki_create_form { @extend .clearfix; padding: lh(.5) lh() lh(.5) 0; @@ -53,4 +45,12 @@ div#wiki_panel { } } } + + input#wiki_search_input_submit { + padding: 4px 8px; + } + + input#wiki_search_input { + margin-right: 10px; + } } diff --git a/lms/static/sass/shared/_forms.scss b/lms/static/sass/shared/_forms.scss index 760dc0bf63..842ffb0086 100644 --- a/lms/static/sass/shared/_forms.scss +++ b/lms/static/sass/shared/_forms.scss @@ -1,52 +1,54 @@ form { font-size: 1em; +} - label { - color: $base-font-color; - font: italic 300 1rem/1.6rem $serif; - margin-bottom: 5px; - text-shadow: 0 1px rgba(255,255,255, 0.4); - -webkit-font-smoothing: antialiased; +label { + color: $base-font-color; + font: italic 300 1rem/1.6rem $serif; + margin-bottom: 5px; + text-shadow: 0 1px rgba(255,255,255, 0.4); + -webkit-font-smoothing: antialiased; +} + +textarea, +input[type="text"], +input[type="email"], +input[type="password"] { + background: rgb(250,250,250); + border: 1px solid rgb(200,200,200); + @include border-radius(3px); + @include box-shadow(0 1px 0 0 rgba(255,255,255, 0.6), inset 0 0 3px 0 rgba(0,0,0, 0.1)); + @include box-sizing(border-box); + font: italic 300 1rem/1.6rem $serif; + height: 35px; + padding: 5px 12px; + vertical-align: top; + -webkit-font-smoothing: antialiased; + + &:last-child { + margin-right: 0px; } - textarea, - input[type="text"], - input[type="email"], - input[type="password"] { - background: rgb(250,250,250); - border: 1px solid rgb(200,200,200); - @include border-radius(3px); - @include box-shadow(0 1px 0 0 rgba(255,255,255, 0.6), inset 0 0 3px 0 rgba(0,0,0, 0.1)); - @include box-sizing(border-box); - font: italic 300 1rem/1.6rem $serif; - height: 35px; - padding: 5px 12px; - vertical-align: top; - -webkit-font-smoothing: antialiased; - - &:last-child { - margin-right: 0px; - } - - &:focus { - border-color: lighten($blue, 20%); - @include box-shadow(0 0 6px 0 rgba($blue, 0.4), inset 0 0 4px 0 rgba(0,0,0, 0.15)); - outline: none; - } - } - - textarea { - height: 60px; - } - - input[type="submit"] { - @include button(shiny, $blue); - @include border-radius(3px); - font: normal 1.2rem/1.6rem $sans-serif; - height: 35px; - letter-spacing: 1px; - text-transform: uppercase; - vertical-align: top; - -webkit-font-smoothing: antialiased; + &:focus { + border-color: lighten($blue, 20%); + @include box-shadow(0 0 6px 0 rgba($blue, 0.4), inset 0 0 4px 0 rgba(0,0,0, 0.15)); + outline: none; } } + +textarea { + height: 60px; +} + +input[type="submit"], +input[type="button"], +.button { + @include border-radius(3px); + @include button(shiny, $blue); + font: normal 1.2rem/1.6rem $sans-serif; + letter-spacing: 1px; + padding: 4px 20px; + text-transform: uppercase; + vertical-align: top; + -webkit-font-smoothing: antialiased; +} diff --git a/lms/static/sass/shared/_tooltips.scss b/lms/static/sass/shared/_tooltips.scss new file mode 100644 index 0000000000..eefbc09bef --- /dev/null +++ b/lms/static/sass/shared/_tooltips.scss @@ -0,0 +1,12 @@ +.ui-tooltip.qtip .ui-tooltip-content { + background: rgba($pink, .8); + border: 0; + color: #fff; + font: bold 12px $body-font-family; + margin-bottom: 6px; + margin-right: 0; + overflow: visible; + padding: 4px; + text-align: center; + -webkit-font-smoothing: antialiased; +} diff --git a/lms/templates/simplewiki/simplewiki_base.html b/lms/templates/simplewiki/simplewiki_base.html index 04a239b6c3..8736f3e421 100644 --- a/lms/templates/simplewiki/simplewiki_base.html +++ b/lms/templates/simplewiki/simplewiki_base.html @@ -110,7 +110,7 @@
  • - +
@@ -120,8 +120,8 @@ From 276f22b9657a1d766af47fb2871434130aaff634 Mon Sep 17 00:00:00 2001 From: Victor Shnayder Date: Mon, 13 Aug 2012 15:32:04 -0400 Subject: [PATCH 136/141] remove debugging print stmt --- common/djangoapps/student/views.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index d9720903d3..ea1770109b 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -175,8 +175,6 @@ def enrollment_allowed(user, course): if (start is None or now > start) and (end is None or now < end): # in enrollment period. - print "allowing enrollment in {}: start {}, end {}, now {}".format( - course.location.url(), start, end, now) return True if settings.MITX_FEATURES['DARK_LAUNCH']: From 99e7711acad272d5087d47d4e68d8b959d0a7b46 Mon Sep 17 00:00:00 2001 From: kimth Date: Mon, 13 Aug 2012 15:48:33 -0400 Subject: [PATCH 137/141] Generic CodeResponse XML (+ support for old ExternalResponse XML) --- common/lib/capa/capa/capa_problem.py | 4 +- common/lib/capa/capa/responsetypes.py | 73 +++++++++++++++++++-------- 2 files changed, 54 insertions(+), 23 deletions(-) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 3d68d2d604..82eb330174 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -41,7 +41,7 @@ response_tag_dict = dict([(x.response_tag, x) for x in responsetypes.__all__]) 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 +response_properties = ["codeparam", "responseparam", "answer"] # these get captured as student responses # special problem tags which should be turned into innocuous HTML html_transforms = {'problem': {'tag': 'div'}, @@ -57,7 +57,7 @@ global_context = {'random': random, 'eia': eia} # These should be removed from HTML output, including all subelements -html_problem_semantics = ["responseparam", "answer", "script", "hintgroup"] +html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup"] log = logging.getLogger('mitx.' + __name__) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 4c6627b352..5f23093099 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1024,41 +1024,70 @@ class CodeResponse(LoncapaResponse): self.url = xml.get('url', None) # XML can override external resource (grader/queue) URL self.queue_name = xml.get('queuename', self.system.xqueue['default_queuename']) - self._parse_externalresponse_xml() + # VS[compat]: + # Check if XML uses the ExternalResponse format or the generic CodeResponse format + codeparam = self.xml.find('codeparam') + if codeparam is None: + self._parse_externalresponse_xml() + else: + self._parse_coderesponse_xml(codeparam) + + def _parse_coderesponse_xml(self,codeparam): + ''' + Parse the new CodeResponse XML format. When successful, sets: + self.initial_display + self.answer (an answer to display to the student in the LMS) + self.payload + ''' + # Note that CodeResponse is agnostic to the specific contents of grader_payload + grader_payload = codeparam.find('grader_payload') + grader_payload = grader_payload.text if grader_payload is not None else '' + self.payload = {'grader_payload': grader_payload} + + answer_display = codeparam.find('answer_display') + if answer_display is not None: + self.answer = answer_display.text + else: + self.answer = 'No answer provided.' + + initial_display = codeparam.find('initial_display') + if initial_display is not None: + self.initial_display = initial_display.text + else: + self.initial_display = '' def _parse_externalresponse_xml(self): ''' VS[compat]: Suppport for old ExternalResponse XML format. When successful, sets: - self.code - self.tests - self.answer self.initial_display + self.answer (an answer to display to the student in the LMS) + self.payload ''' answer = self.xml.find('answer') if answer is not None: answer_src = answer.get('src') if answer_src is not None: - self.code = self.system.filesystem.open('src/' + answer_src).read() + code = self.system.filesystem.open('src/' + answer_src).read() else: - self.code = answer.text + code = answer.text else: # no stanza; get code from