From ad44882835675272b9f97b981476eb2ff8d1b5e4 Mon Sep 17 00:00:00 2001 From: Isaac Chuang <ichuang@mit.edu> Date: Wed, 16 May 2012 21:18:47 -0400 Subject: [PATCH] Ike's changes to enable multicourse, new response types, etc. --- .gitignore | 3 + djangoapps/courseware/capa/capa_problem.py | 32 +- djangoapps/courseware/capa/inputtypes.py | 89 ++++-- djangoapps/courseware/capa/responsetypes.py | 202 +++++++++++-- djangoapps/courseware/content_parser.py | 37 ++- djangoapps/courseware/grades.py | 10 +- .../management/commands/check_course.py | 20 +- djangoapps/courseware/module_render.py | 15 + djangoapps/courseware/modules/capa_module.py | 35 ++- .../courseware/test_files/optionresponse.xml | 63 ++++ djangoapps/courseware/tests.py | 37 ++- djangoapps/courseware/views.py | 142 ++++++++- djangoapps/multicourse/__init__.py | 0 .../multicourse/multicourse_settings.py | 73 +++++ djangoapps/multicourse/views.py | 1 + djangoapps/simplewiki/views.py | 9 +- djangoapps/ssl_auth/__init__.py | 0 djangoapps/ssl_auth/ssl_auth.py | 281 ++++++++++++++++++ lib/loncapa/__init__.py | 3 + lib/loncapa/loncapa_check.py | 17 ++ lib/mitxmako/shortcuts.py | 3 + lib/util/views.py | 7 +- settings.py | 22 +- templates/quickedit.html | 95 ++++++ urls.py | 6 + 25 files changed, 1088 insertions(+), 114 deletions(-) create mode 100644 djangoapps/courseware/test_files/optionresponse.xml create mode 100644 djangoapps/multicourse/__init__.py create mode 100644 djangoapps/multicourse/multicourse_settings.py create mode 100644 djangoapps/multicourse/views.py create mode 100644 djangoapps/ssl_auth/__init__.py create mode 100755 djangoapps/ssl_auth/ssl_auth.py create mode 100644 lib/loncapa/__init__.py create mode 100644 lib/loncapa/loncapa_check.py create mode 100644 templates/quickedit.html diff --git a/.gitignore b/.gitignore index f98fdf7bf9e..e2340d2aa77 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ *.swp *.orig *.DS_Store +:2e_* +:2e# +.AppleDouble database.sqlite courseware/static/js/mathjax/* db.newaskbot diff --git a/djangoapps/courseware/capa/capa_problem.py b/djangoapps/courseware/capa/capa_problem.py index e164429f11a..f5739fd8b06 100644 --- a/djangoapps/courseware/capa/capa_problem.py +++ b/djangoapps/courseware/capa/capa_problem.py @@ -25,7 +25,7 @@ from mako.template import Template from util import contextualize_text import inputtypes -from responsetypes import NumericalResponse, FormulaResponse, CustomResponse, SchematicResponse, MultipleChoiceResponse, StudentInputError, TrueFalseResponse, ExternalResponse,ImageResponse +from responsetypes import NumericalResponse, FormulaResponse, CustomResponse, SchematicResponse, MultipleChoiceResponse, StudentInputError, TrueFalseResponse, ExternalResponse,ImageResponse,OptionResponse import calc import eia @@ -40,8 +40,9 @@ response_types = {'numericalresponse':NumericalResponse, 'multiplechoiceresponse':MultipleChoiceResponse, 'truefalseresponse':TrueFalseResponse, 'imageresponse':ImageResponse, + 'optionresponse':OptionResponse, } -entry_types = ['textline', 'schematic', 'choicegroup','textbox','imageinput'] +entry_types = ['textline', 'schematic', 'choicegroup','textbox','imageinput','optioninput'] solution_types = ['solution'] # extra things displayed after "show answers" is pressed response_properties = ["responseparam", "answer"] # these get captured as student responses @@ -186,6 +187,13 @@ class LoncapaProblem(object): if answer: answer_map[entry.get('id')] = contextualize_text(answer, self.context) + # include solutions from <solution>...</solution> stanzas + # Tentative merge; we should figure out how we want to handle hints and solutions + for entry in self.tree.xpath("//"+"|//".join(solution_types)): + answer = etree.tostring(entry) + if answer: + answer_map[entry.get('id')] = answer + return answer_map # ======= Private ======== @@ -241,7 +249,24 @@ class LoncapaProblem(object): if self.student_answers and problemid in self.student_answers: value = self.student_answers[problemid] - return getattr(inputtypes, problemtree.tag)(problemtree, value, status) #TODO + #### This code is a hack. It was merged to help bring two branches + #### in sync, but should be replaced. msg should be passed in a + #### response_type + # prepare the response message, if it exists in correct_map + if 'msg' in self.correct_map: + msg = self.correct_map['msg'] + elif ('msg_%s' % problemid) in self.correct_map: + msg = self.correct_map['msg_%s' % problemid] + else: + msg = '' + + #if settings.DEBUG: + # print "[courseware.capa.capa_problem.extract_html] msg = ",msg + + # do the rendering + #render_function = html_special_response[problemtree.tag] + render_function = getattr(inputtypes, problemtree.tag) + return render_function(problemtree, value, status, msg) # render the special response (textline, schematic,...) tree=Element(problemtree.tag) for item in problemtree: @@ -287,6 +312,7 @@ class LoncapaProblem(object): answer_id = 1 for entry in tree.xpath("|".join(['//'+response.tag+'[@id=$id]//'+x for x in (entry_types + solution_types)]), id=response_id_str): + # assign one answer_id for each entry_type or solution_type entry.attrib['response_id'] = str(response_id) entry.attrib['answer_id'] = str(answer_id) entry.attrib['id'] = "%s_%i_%i"%(self.problem_id, response_id, answer_id) diff --git a/djangoapps/courseware/capa/inputtypes.py b/djangoapps/courseware/capa/inputtypes.py index e093a7929cf..0388b35d0b4 100644 --- a/djangoapps/courseware/capa/inputtypes.py +++ b/djangoapps/courseware/capa/inputtypes.py @@ -6,11 +6,16 @@ Module containing the problem elements which render into input objects - textline -- textbox (change this to textarea?) +- textbox (change this to textarea?) - schemmatic +- choicegroup (for multiplechoice: checkbox, radio, or select option) +- imageinput (for clickable image) +- optioninput (for option list) These are matched by *.html files templates/*.html which are mako templates with the actual html. +Each input type takes the xml tree as 'element', the previous answer as 'value', and the graded status as 'status' + ''' # TODO: rename "state" to "status" for all below @@ -18,6 +23,7 @@ These are matched by *.html files templates/*.html which are mako templates with # but it will turn into a dict containing both the answer and any associated message for the problem ID for the input element. import re +import shlex # for splitting quoted strings from django.conf import settings @@ -27,9 +33,42 @@ from lxml import etree from mitxmako.shortcuts import render_to_string #----------------------------------------------------------------------------- -#takes the xml tree as 'element', the student's previous answer as 'value', and the graded status as 'state' -def choicegroup(element, value, state, msg=""): +def optioninput(element, value, status, msg=''): + ''' + Select option input type. + + Example: + + <optioninput options="('Up','Down')" correct="Up"/><text>The location of the sky</text> + ''' + eid=element.get('id') + options = element.get('options') + if not options: + raise Exception,"[courseware.capa.inputtypes.optioninput] Missing options specification in " + etree.tostring(element) + oset = shlex.shlex(options[1:-1]) + oset.quotes = "'" + oset.whitespace = "," + oset = [x[1:-1] for x in list(oset)] + + # osetdict = dict([('option_%s_%s' % (eid,x),oset[x]) for x in range(len(oset)) ]) # make dict with IDs + osetdict = dict([(oset[x],oset[x]) for x in range(len(oset)) ]) # make dict with key,value same + if settings.DEBUG: + print '[courseware.capa.inputtypes.optioninput] osetdict=',osetdict + + context={'id':eid, + 'value':value, + 'state':status, + 'msg':msg, + 'options':osetdict, + } + + html=render_to_string("optioninput.html", context) + return etree.XML(html) + +#----------------------------------------------------------------------------- + +def choicegroup(element, value, status, msg=''): ''' Radio button inputs: multiple choice or true/false @@ -47,7 +86,7 @@ def choicegroup(element, value, state, msg=""): for choice in element: assert choice.tag =="choice", "only <choice> tags should be immediate children of a <choicegroup>" choices[choice.get("name")] = etree.tostring(choice[0]) # TODO: what if choice[0] has math tags in it? - context={'id':eid, 'value':value, 'state':state, 'type':type, 'choices':choices} + context={'id':eid, 'value':value, 'state':status, 'type':type, 'choices':choices} html=render_to_string("choicegroup.html", context) return etree.XML(html) @@ -60,9 +99,9 @@ def textline(element, value, state, msg=""): return etree.XML(html) #----------------------------------------------------------------------------- -# TODO: Make a wrapper for <formulainput> -# TODO: Make an AJAX loop to confirm equation is okay in real-time as user types -def jstextline(element, value, state, msg=""): + +def js_textline(element, value, status, msg=''): + ## TODO: Code should follow PEP8 (4 spaces per indentation level) ''' textline is used for simple one-line inputs, like formularesponse and symbolicresponse. ''' @@ -72,7 +111,7 @@ def jstextline(element, value, state, msg=""): dojs = element.get('dojs') # dojs is used for client-side javascript display & return # when dojs=='math', a <span id=display_eid>`{::}`</span> # and a hidden textarea with id=input_eid_fromjs will be output - context = {'id':eid, 'value':value, 'state':state, 'count':count, 'size': size, + context = {'id':eid, 'value':value, 'state':status, 'count':count, 'size': size, 'dojs':dojs, 'msg':msg, } @@ -81,7 +120,7 @@ def jstextline(element, value, state, msg=""): #----------------------------------------------------------------------------- ## TODO: Make a wrapper for <codeinput> -def textbox(element, value, state, msg=''): +def textbox(element, value, status, msg=''): ''' The textbox is used for code input. The message is the return HTML string from evaluating the code, eg error messages, and output from the code tests. @@ -91,12 +130,12 @@ def textbox(element, value, state, msg=''): eid=element.get('id') count = int(eid.split('_')[-2])-1 # HACK size = element.get('size') - context = {'id':eid, 'value':value, 'state':state, 'count':count, 'size': size, 'msg':msg} + context = {'id':eid, 'value':value, 'state':status, 'count':count, 'size': size, 'msg':msg} html=render_to_string("textbox.html", context) return etree.XML(html) #----------------------------------------------------------------------------- -def schematic(element, value, state): +def schematic(element, value, status, msg=''): eid = element.get('id') height = element.get('height') width = element.get('width') @@ -120,7 +159,7 @@ def schematic(element, value, state): #----------------------------------------------------------------------------- ### TODO: Move out of inputtypes -def math(element, value, state, msg=''): +def math(element, value, status, msg=''): ''' This is not really an input type. It is a convention from Lon-CAPA, used for displaying a math equation. @@ -134,21 +173,27 @@ def math(element, value, state, msg=''): TODO: use shorter tags (but this will require converting problem XML files!) ''' - mathstr = element.text[1:-1] - if '\\displaystyle' in mathstr: - isinline = False - mathstr = mathstr.replace('\\displaystyle','') - else: - isinline = True - - html=render_to_string("mathstring.html",{'mathstr':mathstr,'isinline':isinline,'tail':element.tail}) + mathstr = re.sub('\$(.*)\$','[mathjaxinline]\\1[/mathjaxinline]',element.text) + mtag = 'mathjax' + if not '\\displaystyle' in mathstr: mtag += 'inline' + else: mathstr = mathstr.replace('\\displaystyle','') + mathstr = mathstr.replace('mathjaxinline]','%s]'%mtag) + + #if '\\displaystyle' in mathstr: + # isinline = False + # mathstr = mathstr.replace('\\displaystyle','') + #else: + # isinline = True + # html=render_to_string("mathstring.html",{'mathstr':mathstr,'isinline':isinline,'tail':element.tail}) + + html = '<html><html>%s</html><html>%s</html></html>' % (mathstr,element.tail) xhtml = etree.XML(html) # xhtml.tail = element.tail # don't forget to include the tail! return xhtml #----------------------------------------------------------------------------- -def solution(element, value, state, msg=''): +def solution(element, value, status, msg=''): ''' This is not really an input type. It is just a <span>...</span> which is given an ID, that is used for displaying an extended answer (a problem "solution") after "show answers" @@ -159,7 +204,7 @@ def solution(element, value, state, msg=''): size = element.get('size') context = {'id':eid, 'value':value, - 'state':state, + 'state':status, 'size': size, 'msg':msg, } diff --git a/djangoapps/courseware/capa/responsetypes.py b/djangoapps/courseware/capa/responsetypes.py index 7fb682faf6a..d9b18428feb 100644 --- a/djangoapps/courseware/capa/responsetypes.py +++ b/djangoapps/courseware/capa/responsetypes.py @@ -32,6 +32,8 @@ from lxml.html.soupparser import fromstring as fromstring_bs # uses Beautiful So import calc import eia +from util import contextualize_text + def compare_with_tolerance(v1, v2, tol): ''' Compare v1 to v2 with maximum tolerance tol tol is relative if it ends in %; otherwise, it is absolute @@ -61,6 +63,8 @@ class GenericResponse(object): #Every response type needs methods "grade" and "get_answers" +#----------------------------------------------------------------------------- + class MultipleChoiceResponse(GenericResponse): ''' Example: @@ -84,6 +88,7 @@ class MultipleChoiceResponse(GenericResponse): self.correct_choices = [choice.get('name') for choice in self.correct_choices] self.context = context + self.answer_field = xml.find('choicegroup') # assumes only ONE choicegroup within this response self.answer_id = xml.xpath('//*[@id=$id]//choicegroup/@id', id=xml.get('id')) if not len(self.answer_id) == 1: @@ -100,9 +105,14 @@ class MultipleChoiceResponse(GenericResponse): return {self.answer_id:self.correct_choices} def preprocess_response(self): + ''' + Initialize name attributes in <choice> stanzas in the <choicegroup> in this response. + ''' i=0 for response in self.xml.xpath("choicegroup"): - response.set("type", "MultipleChoice") + rtype = response.get('type') + if rtype not in ["MultipleChoice"]: + response.set("type", "MultipleChoice") # force choicegroup to be MultipleChoice if not valid for choice in list(response): if choice.get("name") == None: choice.set("name", "choice_"+str(i)) @@ -131,6 +141,42 @@ class TrueFalseResponse(MultipleChoiceResponse): return {self.answer_id : 'incorrect'} +#----------------------------------------------------------------------------- + +class OptionResponse(GenericResponse): + ''' + Example: + + <optionresponse direction="vertical" randomize="yes"> + <optioninput options="('Up','Down')" correct="Up"><text>The location of the sky</text></optioninput> + <optioninput options="('Up','Down')" correct="Down"><text>The location of the earth</text></optioninput> + </optionresponse> + + TODO: handle direction and randomize + + ''' + def __init__(self, xml, context): + self.xml = xml + self.answer_fields = xml.findall('optioninput') + if settings.DEBUG: + print '[courseware.capa.responsetypes.OR.init] answer_fields=%s' % (self.answer_fields) + self.context = context + + def grade(self, student_answers): + cmap = {} + amap = self.get_answers() + for aid in amap: + if aid in student_answers and student_answers[aid]==amap[aid]: + cmap[aid] = 'correct' + else: + cmap[aid] = 'incorrect' + return cmap + + def get_answers(self): + amap = dict([(af.get('id'),af.get('correct')) for af in self.answer_fields]) + return amap + +#----------------------------------------------------------------------------- class NumericalResponse(GenericResponse): def __init__(self, xml, context): @@ -219,43 +265,153 @@ def sympy_check2(): self.answer_ids = xml.xpath('//*[@id=$id]//textline/@id', id=xml.get('id')) self.context = context - answer_list = xml.xpath('//*[@id=$id]//answer', - id=xml.get('id')) - if len(answer_list): - answer=answer_list[0] - else: - raise Exception("Invalid custom response -- no checker code") - answer_src = answer.get('src') - if answer_src != None: - self.code = open(settings.DATA_DIR+'src/'+answer_src).read() - else: - self.code = answer.text + # if <customresponse> has an "expect" attribute then save that + self.expect = xml.get('expect') + self.myid = xml.get('id') + + # the <answer>...</answer> stanza should be local to the current <customresponse>. So try looking there first. + self.code = None + answer = None + try: + answer = xml.xpath('//*[@id=$id]//answer',id=xml.get('id'))[0] + except IndexError,err: + # print "xml = ",etree.tostring(xml,pretty_print=True) + + # if we have a "cfn" attribute then look for the function specified by cfn, in the problem context + # ie the comparison function is defined in the <script>...</script> stanza instead + cfn = xml.get('cfn') + if cfn: + if settings.DEBUG: print "[courseware.capa.responsetypes] cfn = ",cfn + if cfn in context: + self.code = context[cfn] + else: + print "can't find cfn in context = ",context + + if not self.code: + if not answer: + # raise Exception,"[courseware.capa.responsetypes.customresponse] missing code checking script! id=%s" % self.myid + print "[courseware.capa.responsetypes.customresponse] missing code checking script! id=%s" % self.myid + self.code = '' + else: + answer_src = answer.get('src') + if answer_src != None: + self.code = open(settings.DATA_DIR+'src/'+answer_src).read() + else: + self.code = answer.text def grade(self, student_answers): ''' student_answers is a dict with everything from request.POST, but with the first part of each key removed (the string before the first "_"). ''' - from capa_problem import global_context - submission = [student_answers[k] for k in sorted(self.answer_ids)] - self.context.update({'submission':submission}) - exec self.code in global_context, self.context - return zip(sorted(self.answer_ids), self.context['correct']) + + def getkey2(dict,key,default): + """utilify function: get dict[key] if key exists, or return default""" + if dict.has_key(key): + return dict[key] + return default + + idset = sorted(self.answer_ids) # ordered list of answer id's + submission = [student_answers[k] for k in idset] # ordered list of answers + fromjs = [ getkey2(student_answers,k+'_fromjs',None) for k in idset ] # ordered list of fromjs_XXX responses (if exists) + + # if there is only one box, and it's empty, then don't evaluate + if len(idset)==1 and not submission[0]: + return {idset[0]:'no_answer_entered'} + + gctxt = self.context['global_context'] + + correct = ['unknown'] * len(idset) + messages = [''] * len(idset) + + # put these in the context of the check function evaluator + # note that this doesn't help the "cfn" version - only the exec version + self.context.update({'xml' : self.xml, # our subtree + 'response_id' : self.myid, # my ID + 'expect': self.expect, # expected answer (if given as attribute) + 'submission':submission, # ordered list of student answers from entry boxes in our subtree + 'idset':idset, # ordered list of ID's of all entry boxes in our subtree + 'fromjs':fromjs, # ordered list of all javascript inputs in our subtree + 'answers':student_answers, # dict of student's responses, with keys being entry box IDs + 'correct':correct, # the list to be filled in by the check function + 'messages':messages, # the list of messages to be filled in by the check function + 'testdat':'hello world', + }) + + # exec the check function + if type(self.code)==str: + try: + exec self.code in self.context['global_context'], self.context + except Exception,err: + print "oops in customresponse (code) error %s" % err + print "context = ",self.context + print traceback.format_exc() + else: # self.code is not a string; assume its a function + + # this is an interface to the Tutor2 check functions + fn = self.code + try: + answer_given = submission[0] if (len(idset)==1) else submission + if fn.func_code.co_argcount>=4: # does it want four arguments (the answers dict, myname)? + ret = fn(self.expect,answer_given,student_answers,self.answer_ids[0]) + elif fn.func_code.co_argcount>=3: # does it want a third argument (the answers dict)? + ret = fn(self.expect,answer_given,student_answers) + else: + ret = fn(self.expect,answer_given) + except Exception,err: + print "oops in customresponse (cfn) error %s" % err + # print "context = ",self.context + print traceback.format_exc() + if settings.DEBUG: print "[courseware.capa.responsetypes.customresponse.grade] ret = ",ret + if type(ret)==dict: + correct[0] = 'correct' if ret['ok'] else 'incorrect' + msg = ret['msg'] + + if 1: + # try to clean up message html + msg = '<html>'+msg+'</html>' + msg = etree.tostring(fromstring_bs(msg),pretty_print=True) + msg = msg.replace(' ','') + #msg = re.sub('<html>(.*)</html>','\\1',msg,flags=re.M|re.DOTALL) # python 2.7 + msg = re.sub('(?ms)<html>(.*)</html>','\\1',msg) + + messages[0] = msg + else: + correct[0] = 'correct' if ret else 'incorrect' + + # build map giving "correct"ness of the answer(s) + #correct_map = dict(zip(idset, self.context['correct'])) + correct_map = {} + for k in range(len(idset)): + correct_map[idset[k]] = correct[k] + correct_map['msg_%s' % idset[k]] = messages[k] + return correct_map def get_answers(self): - # Since this is explicitly specified in the problem, this will - # be handled by capa_problem + ''' + Give correct answer expected for this response. + + capa_problem handles correct_answers from entry objects like textline, and that + is what should be used when this response has multiple entry objects. + + but for simplicity, if an "expect" attribute was given by the content author + ie <customresponse expect="foo" ...> then return it now. + ''' + if len(self.answer_ids)>1: + return {} + if self.expect: + return {self.answer_ids[0] : self.expect} return {} #----------------------------------------------------------------------------- class ExternalResponse(GenericResponse): - ''' + """ Grade the student's input using an external server. Typically used by coding problems. - ''' + """ def __init__(self, xml, context): self.xml = xml self.answer_ids = xml.xpath('//*[@id=$id]//textbox/@id|//*[@id=$id]//textline/@id', @@ -471,10 +627,6 @@ class ImageResponse(GenericResponse): raise Exception,'[capamodule.capa.responsetypes.imageinput] error grading %s (input=%s)' % (err,aid,given) (gx,gy) = [int(x) for x in m.groups()] - if settings.DEBUG: - print "[capamodule.capa.responsetypes.imageinput] llx,lly,urx,ury=",(llx,lly,urx,ury) - print "[capamodule.capa.responsetypes.imageinput] gx,gy=",(gx,gy) - # answer is correct if (x,y) is within the specified rectangle if (llx <= gx <= urx) and (lly <= gy <= ury): correct_map[aid] = 'correct' diff --git a/djangoapps/courseware/content_parser.py b/djangoapps/courseware/content_parser.py index 9ae937e52e1..adb10f7dfc0 100644 --- a/djangoapps/courseware/content_parser.py +++ b/djangoapps/courseware/content_parser.py @@ -24,7 +24,9 @@ try: # This lets us do __name__ == ='__main__' from student.models import UserTestGroup from mitxmako.shortcuts import render_to_string from util.cache import cache + from multicourse import multicourse_settings except: + print "Could not import/content_parser" settings = None ''' This file will eventually form an abstraction layer between the @@ -181,7 +183,7 @@ def course_xml_process(tree): propogate_downward_tag(tree, "rerandomize") return tree -def course_file(user): +def course_file(user,coursename=None): ''' Given a user, return course.xml''' if user.is_authenticated(): @@ -189,6 +191,11 @@ def course_file(user): else: filename = 'guest_course.xml' + # if a specific course is specified, then use multicourse to get the right path to the course XML directory + if coursename and settings.ENABLE_MULTICOURSE: + xp = multicourse_settings.get_course_xmlpath(coursename) + filename = xp + filename # prefix the filename with the path + groups = user_groups(user) options = {'dev_content':settings.DEV_CONTENT, 'groups' : groups} @@ -210,13 +217,24 @@ def course_file(user): return tree -def section_file(user, section): - ''' Given a user and the name of a section, return that section +def section_file(user, section, coursename=None, dironly=False): + ''' + Given a user and the name of a section, return that section. + This is done specific to each course. + If dironly=True then return the sections directory. ''' filename = section+".xml" - if filename not in os.listdir(settings.DATA_DIR + '/sections/'): - print filename+" not in "+str(os.listdir(settings.DATA_DIR + '/sections/')) + # if a specific course is specified, then use multicourse to get the right path to the course XML directory + xp = '' + if coursename and settings.ENABLE_MULTICOURSE: xp = multicourse_settings.get_course_xmlpath(coursename) + + dirname = settings.DATA_DIR + xp + '/sections/' + + if dironly: return dirname + + if filename not in os.listdir(dirname): + print filename+" not in "+str(os.listdir(dirname)) return None options = {'dev_content':settings.DEV_CONTENT, @@ -226,7 +244,7 @@ def section_file(user, section): return tree -def module_xml(user, module, id_tag, module_id): +def module_xml(user, module, id_tag, module_id, coursename=None): ''' Get XML for a module based on module and module_id. Assumes module occurs once in courseware XML file or hidden section. ''' # Sanitize input @@ -239,14 +257,15 @@ def module_xml(user, module, id_tag, module_id): id_tag=id_tag, id=module_id) #result_set=doc.xpathEval(xpath_search) - doc = course_file(user) - section_list = (s[:-4] for s in os.listdir(settings.DATA_DIR+'/sections') if s[-4:]=='.xml') + doc = course_file(user,coursename) + sdirname = section_file(user,'',coursename,True) # get directory where sections information is stored + section_list = (s[:-4] for s in os.listdir(sdirname) if s[-4:]=='.xml') result_set=doc.xpath(xpath_search) if len(result_set)<1: for section in section_list: try: - s = section_file(user, section) + s = section_file(user, section, coursename) except etree.XMLSyntaxError: ex= sys.exc_info() raise ContentException("Malformed XML in " + section+ "("+str(ex[1].msg)+")") diff --git a/djangoapps/courseware/grades.py b/djangoapps/courseware/grades.py index 67816dc04e1..2013dc28b59 100644 --- a/djangoapps/courseware/grades.py +++ b/djangoapps/courseware/grades.py @@ -67,7 +67,7 @@ course_settings = Settings() -def grade_sheet(student): +def grade_sheet(student,coursename=None): """ This pulls a summary of all problems in the course. It returns a dictionary with two datastructures: @@ -77,7 +77,7 @@ def grade_sheet(student): - grade_summary is the output from the course grader. More information on the format is in the docstring for CourseGrader. """ - dom=content_parser.course_file(student) + dom=content_parser.course_file(student,coursename) course = dom.xpath('//course/@name')[0] xmlChapters = dom.xpath('//course[@name=$course]/chapter', course=course) @@ -103,7 +103,7 @@ def grade_sheet(student): scores=[] if len(problems)>0: for p in problems: - (correct,total) = get_score(student, p, response_by_id) + (correct,total) = get_score(student, p, response_by_id, coursename=coursename) if settings.GENERATE_PROFILE_SCORES: if total > 1: @@ -167,7 +167,7 @@ def aggregate_scores(scores, section_name = "summary"): return all_total, graded_total -def get_score(user, problem, cache): +def get_score(user, problem, cache, coursename=None): ## HACK: assumes max score is fixed per problem id = problem.get('id') correct = 0.0 @@ -196,7 +196,7 @@ def get_score(user, problem, cache): ## HACK 1: We shouldn't specifically reference capa_module ## HACK 2: Backwards-compatibility: This should be written when a grade is saved, and removed from the system from module_render import I4xSystem - system = I4xSystem(None, None, None) + system = I4xSystem(None, None, None, coursename=coursename) total=float(courseware.modules.capa_module.Module(system, etree.tostring(problem), "id").max_score()) response.max_grade = total response.save() diff --git a/djangoapps/courseware/management/commands/check_course.py b/djangoapps/courseware/management/commands/check_course.py index 2f069ee5f38..4d0b9840ab9 100644 --- a/djangoapps/courseware/management/commands/check_course.py +++ b/djangoapps/courseware/management/commands/check_course.py @@ -6,9 +6,9 @@ from django.core.management.base import BaseCommand from django.conf import settings from django.contrib.auth.models import User -from courseware.content_parser import course_file -import courseware.module_render -import courseware.modules +from mitx.courseware.content_parser import course_file +import mitx.courseware.module_render +import mitx.courseware.modules class Command(BaseCommand): help = "Does basic validity tests on course.xml." @@ -25,15 +25,15 @@ class Command(BaseCommand): check = False print "Confirming all modules render. Nothing should print during this step. " for module in course.xpath('//problem|//html|//video|//vertical|//sequential|/tab'): - module_class = courseware.modules.modx_modules[module.tag] + module_class=mitx.courseware.modules.modx_modules[module.tag] # TODO: Abstract this out in render_module.py try: - module_class(etree.tostring(module), - module.get('id'), - ajax_url='', - state=None, - track_function = lambda x,y,z:None, - render_function = lambda x: {'content':'','destroy_js':'','init_js':'','type':'video'}) + instance=module_class(etree.tostring(module), + module.get('id'), + ajax_url='', + state=None, + track_function = lambda x,y,z:None, + render_function = lambda x: {'content':'','destroy_js':'','init_js':'','type':'video'}) except: print "==============> Error in ", etree.tostring(module) check = False diff --git a/djangoapps/courseware/module_render.py b/djangoapps/courseware/module_render.py index 9bb7872a66d..0e317f70046 100644 --- a/djangoapps/courseware/module_render.py +++ b/djangoapps/courseware/module_render.py @@ -22,6 +22,11 @@ import courseware.modules log = logging.getLogger("mitx.courseware") class I4xSystem(object): + ''' + This is an abstraction such that x_modules can function independent + of the courseware (e.g. import into other types of courseware, LMS, + or if we want to have a sandbox server for user-contributed content) + ''' def __init__(self, ajax_url, track_function, render_function, filestore=None): self.ajax_url = ajax_url self.track_function = track_function @@ -29,6 +34,10 @@ class I4xSystem(object): self.filestore = OSFS(settings.DATA_DIR) self.render_function = render_function self.exception404 = Http404 + def __repr__(self): + return repr(self.__dict__) + def __str__(self): + return str(self.__dict__) def object_cache(cache, user, module_type, module_id): # We don't look up on user -- all queries include user @@ -50,6 +59,7 @@ def make_track_function(request): def f(event_type, event): return track.views.server_track(request, event_type, event, page='x_module') return f + def grade_histogram(module_id): ''' Print out a histogram of grades on a given problem. Part of staff member debug info. @@ -83,6 +93,10 @@ def render_x_module(user, request, xml_module, module_object_preload): else: state = smod.state + # get coursename if stored + if 'coursename' in request.session: coursename = request.session['coursename'] + else: coursename = None + # Create a new instance ajax_url = settings.MITX_ROOT_URL + '/modx/'+module_type+'/'+module_id+'/' system = I4xSystem(track_function = make_track_function(request), @@ -104,6 +118,7 @@ def render_x_module(user, request, xml_module, module_object_preload): state=instance.get_state()) smod.save() module_object_preload.append(smod) + # Grab content content = instance.get_html() init_js = instance.get_init_js() diff --git a/djangoapps/courseware/modules/capa_module.py b/djangoapps/courseware/modules/capa_module.py index 7a43051026d..0b75faa4917 100644 --- a/djangoapps/courseware/modules/capa_module.py +++ b/djangoapps/courseware/modules/capa_module.py @@ -21,6 +21,7 @@ from mitxmako.shortcuts import render_to_string from x_module import XModule from courseware.capa.capa_problem import LoncapaProblem, StudentInputError import courseware.content_parser as content_parser +from multicourse import multicourse_settings log = logging.getLogger("mitx.courseware") @@ -115,18 +116,19 @@ class Module(XModule): if len(explain) == 0: explain = False - html=render_to_string('problem.html', - {'problem' : content, - 'id' : self.item_id, - 'check_button' : check_button, - 'reset_button' : reset_button, - 'save_button' : save_button, - 'answer_available' : self.answer_available(), - 'ajax_url' : self.ajax_url, - 'attempts_used': self.attempts, - 'attempts_allowed': self.max_attempts, - 'explain': explain - }) + context = {'problem' : content, + 'id' : self.item_id, + 'check_button' : check_button, + 'reset_button' : reset_button, + 'save_button' : save_button, + 'answer_available' : self.answer_available(), + 'ajax_url' : self.ajax_url, + 'attempts_used': self.attempts, + 'attempts_allowed': self.max_attempts, + 'explain': explain, + } + + html=render_to_string('problem.html', context) if encapsulate: html = '<div id="main_{id}">'.format(id=self.item_id)+html+"</div>" @@ -193,7 +195,12 @@ class Module(XModule): seed = 1 else: seed = None - self.lcp=LoncapaProblem(self.filestore.open(self.filename), self.item_id, state, seed = seed) + try: + fp = self.filestore.open(self.filename) + except Exception,err: + print '[courseware.capa.capa_module.Module.init] error %s: cannot open file %s' % (err,self.filename) + raise Exception,err + self.lcp=LoncapaProblem(fp, self.item_id, state, seed = seed) def handle_ajax(self, dispatch, get): ''' @@ -306,7 +313,7 @@ class Module(XModule): except: self.lcp = LoncapaProblem(self.filestore.open(self.filename), id=lcp_id, state=old_state) traceback.print_exc() - raise + raise Exception,"error in capa_module" return json.dumps({'success':'Unknown Error'}) self.attempts = self.attempts + 1 diff --git a/djangoapps/courseware/test_files/optionresponse.xml b/djangoapps/courseware/test_files/optionresponse.xml new file mode 100644 index 00000000000..99a17e8fac2 --- /dev/null +++ b/djangoapps/courseware/test_files/optionresponse.xml @@ -0,0 +1,63 @@ + <problem> + <text> + <p> +Why do bicycles benefit from having larger wheels when going up a bump as shown in the picture? <br/> +Assume that for both bicycles:<br/> +1.) The tires have equal air pressure.<br/> +2.) The bicycles never leave the contact with the bump.<br/> +3.) The bicycles have the same mass. The bicycle tires (regardless of size) have the same mass.<br/> +</p> + </text> + <optionresponse texlayout="horizontal" max="10" randomize="yes"> + <ul> + <li> + <text> + <p>The bicycles with larger wheels have more time to go over the bump. This decreases the magnitude of the force needed to lift the bicycle.</p> + </text> + <optioninput name="Foil1" location="random" options="('True','False')" correct="True"> +</optioninput> + </li> + <li> + <text> + <p>The bicycles with larger wheels always have a smaller vertical displacement regardless of speed.</p> + </text> + <optioninput name="Foil2" location="random" options="('True','False')" correct="False"> +</optioninput> + </li> + <li> + <text> + <p>The bicycles with larger wheels experience a force backward with less magnitude for the same amount of time.</p> + </text> + <optioninput name="Foil3" location="random" options="('True','False')" correct="False"> +</optioninput> + </li> + <li> + <text> + <p>The bicycles with larger wheels experience a force backward with less magnitude for a greater amount of time.</p> + </text> + <optioninput name="Foil4" location="random" options="('True','False')" correct="True"> +</optioninput> + </li> + <li> + <text> + <p>The bicycles with larger wheels have more kinetic energy turned into gravitational potential energy.</p> + </text> + <optioninput name="Foil5" location="random" options="('True','False')" correct="False"> +</optioninput> + </li> + <li> + <text> + <p>The bicycles with larger wheels have more rotational kinetic energy, so the horizontal velocity of the biker changes less.</p> + </text> + <optioninput name="Foil6" location="random" options="('True','False')" correct="False"> +</optioninput> + </li> + </ul> + <hintgroup showoncorrect="no"> + <text> + <br/> + <br/> + </text> + </hintgroup> + </optionresponse> + </problem> diff --git a/djangoapps/courseware/tests.py b/djangoapps/courseware/tests.py index 5688d698b2b..682927efb71 100644 --- a/djangoapps/courseware/tests.py +++ b/djangoapps/courseware/tests.py @@ -63,6 +63,9 @@ class ModelsTest(unittest.TestCase): exception_happened = True self.assertTrue(exception_happened) +#----------------------------------------------------------------------------- +# tests of capa_problem inputtypes + class MultiChoiceTest(unittest.TestCase): def test_MC_grade(self): multichoice_file = os.path.dirname(__file__)+"/test_files/multichoice.xml" @@ -93,6 +96,38 @@ class MultiChoiceTest(unittest.TestCase): self.assertEquals(test_lcp.grade_answers(false_answers)['1_2_1'], 'incorrect') false_answers = {'1_2_1':['choice_foil1', 'choice_foil2', 'choice_foil3']} self.assertEquals(test_lcp.grade_answers(false_answers)['1_2_1'], 'incorrect') + +class ImageResponseTest(unittest.TestCase): + def test_ir_grade(self): + imageresponse_file = os.path.dirname(__file__)+"/test_files/imageresponse.xml" + test_lcp = lcp.LoncapaProblem(open(imageresponse_file), '1') + correct_answers = {'1_2_1':'(490,11)-(556,98)', + '1_2_2':'(242,202)-(296,276)'} + test_answers = {'1_2_1':'[500,20]', + '1_2_2':'[250,300]', + } + self.assertEquals(test_lcp.grade_answers(test_answers)['1_2_1'], 'correct') + self.assertEquals(test_lcp.grade_answers(test_answers)['1_2_2'], 'incorrect') + +class OptionResponseTest(unittest.TestCase): + ''' + Run this with + + python manage.py test courseware.OptionResponseTest + ''' + def test_or_grade(self): + optionresponse_file = os.path.dirname(__file__)+"/test_files/optionresponse.xml" + test_lcp = lcp.LoncapaProblem(open(optionresponse_file), '1') + correct_answers = {'1_2_1':'True', + '1_2_2':'False'} + test_answers = {'1_2_1':'True', + '1_2_2':'True', + } + self.assertEquals(test_lcp.grade_answers(test_answers)['1_2_1'], 'correct') + self.assertEquals(test_lcp.grade_answers(test_answers)['1_2_2'], 'incorrect') + +#----------------------------------------------------------------------------- +# Grading tests class GradesheetTest(unittest.TestCase): @@ -118,7 +153,7 @@ class GradesheetTest(unittest.TestCase): all, graded = aggregate_scores(scores) self.assertAlmostEqual(all, Score(earned=5, possible=15, graded=False, section="summary")) self.assertAlmostEqual(graded, Score(earned=5, possible=10, graded=True, section="summary")) - + class GraderTest(unittest.TestCase): empty_gradesheet = { diff --git a/djangoapps/courseware/views.py b/djangoapps/courseware/views.py index 1b4dd32ad27..5f67e599c09 100644 --- a/djangoapps/courseware/views.py +++ b/djangoapps/courseware/views.py @@ -16,6 +16,7 @@ from lxml import etree from module_render import render_module, make_track_function, I4xSystem from models import StudentModule from student.models import UserProfile +from multicourse import multicourse_settings import courseware.content_parser as content_parser import courseware.modules @@ -33,11 +34,16 @@ template_imports={'urllib':urllib} def gradebook(request): if 'course_admin' not in content_parser.user_groups(request.user): raise Http404 + + # TODO: This should be abstracted out. We repeat this logic many times. + if 'coursename' in request.session: coursename = request.session['coursename'] + else: coursename = None + student_objects = User.objects.all()[:100] student_info = [{'username' :s.username, 'id' : s.id, 'email': s.email, - 'grade_info' : grades.grade_sheet(s), + 'grade_info' : grades.grade_sheet(s,coursename), 'realname' : UserProfile.objects.get(user = s).name } for s in student_objects] @@ -59,6 +65,9 @@ def profile(request, student_id = None): user_info = UserProfile.objects.get(user=student) # request.user.profile_cache # + if 'coursename' in request.session: coursename = request.session['coursename'] + else: coursename = None + context={'name':user_info.name, 'username':student.username, 'location':user_info.location, @@ -67,7 +76,7 @@ def profile(request, student_id = None): 'format_url_params' : content_parser.format_url_params, 'csrf':csrf(request)['csrf_token'] } - context.update(grades.grade_sheet(student)) + context.update(grades.grade_sheet(student,coursename)) return render_to_response('profile.html', context) @@ -77,7 +86,7 @@ def render_accordion(request,course,chapter,section): if not course: course = "6.002 Spring 2012" - toc=content_parser.toc_from_xml(content_parser.course_file(request.user), chapter, section) + toc=content_parser.toc_from_xml(content_parser.course_file(request.user,course), chapter, section) active_chapter=1 for i in range(len(toc)): if toc[i]['active']: @@ -98,8 +107,11 @@ def render_section(request, section): if not settings.COURSEWARE_ENABLED: return redirect('/') + if 'coursename' in request.session: coursename = request.session['coursename'] + else: coursename = None + # try: - dom = content_parser.section_file(user, section) + dom = content_parser.section_file(user, section, coursename) #except: # raise Http404 @@ -128,13 +140,21 @@ def render_section(request, section): @cache_control(no_cache=True, no_store=True, must_revalidate=True) -def index(request, course="6.002 Spring 2012", chapter="Using the System", section="Hints"): +def index(request, course=None, chapter="Using the System", section="Hints"): ''' Displays courseware accordion, and any associated content. ''' user = request.user if not settings.COURSEWARE_ENABLED: return redirect('/') + if course==None: + if not settings.ENABLE_MULTICOURSE: + course = "6.002 Spring 2012" + elif 'coursename' in request.session: + course = request.session['coursename'] + else: + course = settings.COURSE_DEFAULT + # Fixes URLs -- we don't get funny encoding characters from spaces # so they remain readable ## TODO: Properly replace underscores @@ -142,16 +162,18 @@ def index(request, course="6.002 Spring 2012", chapter="Using the System", secti chapter=chapter.replace("_"," ") section=section.replace("_"," ") - # HACK: Force course to 6.002 for now - # Without this, URLs break - if course!="6.002 Spring 2012": + # use multicourse module to determine if "course" is valid + #if course!=settings.COURSE_NAME.replace('_',' '): + if not multicourse_settings.is_valid_course(course): return redirect('/') #import logging #log = logging.getLogger("mitx") #log.info( "DEBUG: "+str(user) ) - dom = content_parser.course_file(user) + request.session['coursename'] = course # keep track of current course being viewed in django's request.session + + dom = content_parser.course_file(user,course) # also pass course to it, for course-specific XML path dom_module = dom.xpath("//course[@name=$course]/chapter[@name=$chapter]//section[@name=$section]/*[1]", course=course, chapter=chapter, section=section) if len(dom_module) == 0: @@ -179,6 +201,7 @@ def index(request, course="6.002 Spring 2012", chapter="Using the System", secti context={'init':module['init_js'], 'accordion':accordion, 'content':module['content'], + 'COURSE_TITLE':multicourse_settings.get_course_title(course), 'csrf':csrf(request)['csrf_token']} result = render_to_response('courseware.html', context) @@ -206,8 +229,12 @@ def modx_dispatch(request, module=None, dispatch=None, id=None): ajax_url = settings.MITX_ROOT_URL + '/modx/'+module+'/'+id+'/' + # get coursename if stored + if 'coursename' in request.session: coursename = request.session['coursename'] + else: coursename = None + # Grab the XML corresponding to the request from course.xml - xml = content_parser.module_xml(request.user, module, 'id', id) + xml = content_parser.module_xml(request.user, module, 'id', id, coursename) # Create the module system = I4xSystem(track_function = make_track_function(request), @@ -229,3 +256,98 @@ def modx_dispatch(request, module=None, dispatch=None, id=None): s.save() # Return whatever the module wanted to return to the client/caller return HttpResponse(ajax_return) + +def quickedit(request, id=None): + ''' + quick-edit capa problem. + + Maybe this should be moved into capa/views.py + Or this should take a "module" argument, and the quickedit moved into capa_module. + ''' + print "WARNING: UNDEPLOYABLE CODE. FOR DEV USE ONLY." + print "In deployed use, this will only edit on one server" + print "We need a setting to disable for production where there is" + print "a load balanacer" + if not request.user.is_staff(): + return redirect('/') + + # get coursename if stored + if 'coursename' in request.session: coursename = request.session['coursename'] + else: coursename = None + + def get_lcp(coursename,id): + # Grab the XML corresponding to the request from course.xml + module = 'problem' + xml = content_parser.module_xml(request.user, module, 'id', id, coursename) + + ajax_url = settings.MITX_ROOT_URL + '/modx/'+module+'/'+id+'/' + + # Create the module (instance of capa_module.Module) + system = I4xSystem(track_function = make_track_function(request), + render_function = None, + ajax_url = ajax_url, + filestore = None, + coursename = coursename, + role = 'staff' if request.user.is_staff else 'student', # TODO: generalize this + ) + instance=courseware.modules.get_module_class(module)(system, + xml, + id, + state=None) + lcp = instance.lcp + pxml = lcp.tree + pxmls = etree.tostring(pxml,pretty_print=True) + + return instance, pxmls + + instance, pxmls = get_lcp(coursename,id) + + # if there was a POST, then process it + msg = '' + if 'qesubmit' in request.POST: + action = request.POST['qesubmit'] + if "Revert" in action: + msg = "Reverted to original" + elif action=='Change Problem': + key = 'quickedit_%s' % id + if not key in request.POST: + msg = "oops, missing code key=%s" % key + else: + newcode = request.POST[key] + + # see if code changed + if str(newcode)==str(pxmls) or '<?xml version="1.0"?>\n'+str(newcode)==str(pxmls): + msg = "No changes" + else: + # check new code + isok = False + try: + newxml = etree.fromstring(newcode) + isok = True + except Exception,err: + msg = "Failed to change problem: XML error \"<font color=red>%s</font>\"" % err + + if isok: + filename = instance.lcp.fileobject.name + fp = open(filename,'w') # TODO - replace with filestore call? + fp.write(newcode) + fp.close() + msg = "<font color=green>Problem changed!</font> (<tt>%s</tt>)" % filename + instance, pxmls = get_lcp(coursename,id) + + lcp = instance.lcp + + # get the rendered problem HTML + phtml = instance.get_problem_html() + + context = {'id':id, + 'msg' : msg, + 'lcp' : lcp, + 'filename' : lcp.fileobject.name, + 'pxmls' : pxmls, + 'phtml' : phtml, + 'init_js':instance.get_init_js(), + } + + result = render_to_response('quickedit.html', context) + return result diff --git a/djangoapps/multicourse/__init__.py b/djangoapps/multicourse/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/djangoapps/multicourse/multicourse_settings.py b/djangoapps/multicourse/multicourse_settings.py new file mode 100644 index 00000000000..99c9ef86208 --- /dev/null +++ b/djangoapps/multicourse/multicourse_settings.py @@ -0,0 +1,73 @@ +# multicourse/multicourse_settings.py +# +# central module for providing fixed settings (course name, number, title) +# for multiple courses. Loads this information from django.conf.settings +# +# Allows backward compatibility with settings configurations without +# multiple courses specified. +# +# The central piece of configuration data is the dict COURSE_SETTINGS, with +# keys being the COURSE_NAME (spaces ok), and the value being a dict of +# parameter,value pairs. The required parameters are: +# +# - number : course number (used in the simplewiki pages) +# - title : humanized descriptive course title +# +# Optional parameters: +# +# - xmlpath : path (relative to data directory) for this course (defaults to "") +# +# If COURSE_SETTINGS does not exist, then fallback to 6.002_Spring_2012 default, +# for now. + +from django.conf import settings + +#----------------------------------------------------------------------------- +# load course settings + +if hasattr(settings,'COURSE_SETTINGS'): # in the future, this could be replaced by reading an XML file + COURSE_SETTINGS = settings.COURSE_SETTINGS + +elif hasattr(settings,'COURSE_NAME'): # backward compatibility + COURSE_SETTINGS = {settings.COURSE_NAME: {'number': settings.COURSE_NUMBER, + 'title': settings.COURSE_TITLE, + }, + } +else: # default to 6.002_Spring_2012 + COURSE_SETTINGS = {'6.002_Spring_2012': {'number': '6.002x', + 'title': 'Circuits and Electronics', + }, + } + +#----------------------------------------------------------------------------- +# wrapper functions around course settings + +def get_course_settings(coursename): + if not coursename: + if hasattr(settings,'COURSE_DEFAULT'): + coursename = settings.COURSE_DEFAULT + else: + coursename = '6.002_Spring_2012' + if coursename in COURSE_SETTINGS: return COURSE_SETTINGS[coursename] + coursename = coursename.replace(' ','_') + if coursename in COURSE_SETTINGS: return COURSE_SETTINGS[coursename] + return None + +def is_valid_course(coursename): + return not (get_course_settings==None) + +def get_course_property(coursename,property): + cs = get_course_settings(coursename) + if not cs: return '' # raise exception instead? + if property in cs: return cs[property] + return '' # default + +def get_course_xmlpath(coursename): + return get_course_property(coursename,'xmlpath') + +def get_course_title(coursename): + return get_course_property(coursename,'title') + +def get_course_number(coursename): + return get_course_property(coursename,'number') + diff --git a/djangoapps/multicourse/views.py b/djangoapps/multicourse/views.py new file mode 100644 index 00000000000..d0662b710ec --- /dev/null +++ b/djangoapps/multicourse/views.py @@ -0,0 +1 @@ +# multicourse/views.py diff --git a/djangoapps/simplewiki/views.py b/djangoapps/simplewiki/views.py index fcd98bfeb17..34a81e6b570 100644 --- a/djangoapps/simplewiki/views.py +++ b/djangoapps/simplewiki/views.py @@ -9,6 +9,8 @@ from django.utils import simplejson from django.utils.translation import ugettext_lazy as _ from mitxmako.shortcuts import render_to_response +from multicourse import multicourse_settings + from models import Revision, Article, CreateArticleForm, RevisionFormWithTitle, RevisionForm import wiki_settings @@ -17,6 +19,11 @@ def view(request, wiki_url): if err: return err + if 'coursename' in request.session: coursename = request.session['coursename'] + else: coursename = None + + course_number = multicourse_settings.get_course_number(coursename) + perm_err = check_permissions(request, article, check_read=True, check_deleted=True) if perm_err: return perm_err @@ -25,7 +32,7 @@ def view(request, wiki_url): 'wiki_write': article.can_write_l(request.user), 'wiki_attachments_write': article.can_attach(request.user), 'wiki_current_revision_deleted' : not (article.current_revision.deleted == 0), - 'wiki_title' : article.title + " - MITX 6.002x Wiki" + 'wiki_title' : article.title + " - MITX %s Wiki" % course_number } d.update(csrf(request)) return render_to_response('simplewiki_view.html', d) diff --git a/djangoapps/ssl_auth/__init__.py b/djangoapps/ssl_auth/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/djangoapps/ssl_auth/ssl_auth.py b/djangoapps/ssl_auth/ssl_auth.py new file mode 100755 index 00000000000..6d0bb2c5b44 --- /dev/null +++ b/djangoapps/ssl_auth/ssl_auth.py @@ -0,0 +1,281 @@ +""" +User authentication backend for ssl (no pw required) +""" + +from django.conf import settings +from django.contrib import auth +from django.contrib.auth.models import User, check_password +from django.contrib.auth.backends import ModelBackend +from django.contrib.auth.middleware import RemoteUserMiddleware +from django.core.exceptions import ImproperlyConfigured +import os, string, re +from random import choice + +from student.models import UserProfile + +#----------------------------------------------------------------------------- + +def ssl_dn_extract_info(dn): + ''' + Extract username, email address (may be anyuser@anydomain.com) and full name + from the SSL DN string. Return (user,email,fullname) if successful, and None + otherwise. + ''' + ss = re.search('/emailAddress=(.*)@([^/]+)',dn) + if ss: + user = ss.group(1) + email = "%s@%s" % (user,ss.group(2)) + else: + return None + ss = re.search('/CN=([^/]+)/',dn) + if ss: + fullname = ss.group(1) + else: + return None + return (user,email,fullname) + +def check_nginx_proxy(request): + ''' + Check for keys in the HTTP header (META) to se if we are behind an ngix reverse proxy. + If so, get user info from the SSL DN string and return that, as (user,email,fullname) + ''' + m = request.META + if m.has_key('HTTP_X_REAL_IP'): # we're behind a nginx reverse proxy, which has already done ssl auth + if not m.has_key('HTTP_SSL_CLIENT_S_DN'): + return None + dn = m['HTTP_SSL_CLIENT_S_DN'] + return ssl_dn_extract_info(dn) + return None + +#----------------------------------------------------------------------------- + +def get_ssl_username(request): + x = check_nginx_proxy(request) + if x: + return x[0] + env = request._req.subprocess_env + if env.has_key('SSL_CLIENT_S_DN_Email'): + email = env['SSL_CLIENT_S_DN_Email'] + user = email[:email.index('@')] + return user + return None + +#----------------------------------------------------------------------------- + +class NginxProxyHeaderMiddleware(RemoteUserMiddleware): + ''' + Django "middleware" function for extracting user information from HTTP request. + + ''' + # this field is generated by nginx's reverse proxy + header = 'HTTP_SSL_CLIENT_S_DN' # specify the request.META field to use + + def process_request(self, request): + # AuthenticationMiddleware is required so that request.user exists. + if not hasattr(request, 'user'): + raise ImproperlyConfigured( + "The Django remote user auth middleware requires the" + " authentication middleware to be installed. Edit your" + " MIDDLEWARE_CLASSES setting to insert" + " 'django.contrib.auth.middleware.AuthenticationMiddleware'" + " before the RemoteUserMiddleware class.") + + #raise ImproperlyConfigured('[ProxyHeaderMiddleware] request.META=%s' % repr(request.META)) + + try: + username = request.META[self.header] # try the nginx META key first + except KeyError: + try: + env = request._req.subprocess_env # else try the direct apache2 SSL key + if env.has_key('SSL_CLIENT_S_DN'): + username = env['SSL_CLIENT_S_DN'] + else: + raise ImproperlyConfigured('no ssl key, env=%s' % repr(env)) + username = '' + except: + # If specified header doesn't exist then return (leaving + # request.user set to AnonymousUser by the + # AuthenticationMiddleware). + return + # If the user is already authenticated and that user is the user we are + # getting passed in the headers, then the correct user is already + # persisted in the session and we don't need to continue. + + #raise ImproperlyConfigured('[ProxyHeaderMiddleware] username=%s' % username) + + if request.user.is_authenticated(): + if request.user.username == self.clean_username(username, request): + #raise ImproperlyConfigured('%s already authenticated (%s)' % (username,request.user.username)) + return + # We are seeing this user for the first time in this session, attempt + # to authenticate the user. + #raise ImproperlyConfigured('calling auth.authenticate, remote_user=%s' % username) + user = auth.authenticate(remote_user=username) + if user: + # User is valid. Set request.user and persist user in the session + # by logging the user in. + request.user = user + if settings.DEBUG: print "[ssl_auth.ssl_auth.NginxProxyHeaderMiddleware] logging in user=%s" % user + auth.login(request, user) + + def clean_username(self,username,request): + ''' + username is the SSL DN string - extract the actual username from it and return + ''' + info = ssl_dn_extract_info(username) + if not info: + return None + (username,email,fullname) = info + return username + +#----------------------------------------------------------------------------- + +class SSLLoginBackend(ModelBackend): + ''' + Django authentication back-end which auto-logs-in a user based on having + already authenticated with an MIT certificate (SSL). + ''' + def authenticate(self, username=None, password=None, remote_user=None): + + # remote_user is from the SSL_DN string. It will be non-empty only when + # the user has already passed the server authentication, which means + # matching with the certificate authority. + if not remote_user: + # no remote_user, so check username (but don't auto-create user) + if not username: + return None + return None # pass on to another authenticator backend + #raise ImproperlyConfigured("in SSLLoginBackend, username=%s, remote_user=%s" % (username,remote_user)) + try: + user = User.objects.get(username=username) # if user already exists don't create it + return user + except User.DoesNotExist: + return None + return None + + #raise ImproperlyConfigured("in SSLLoginBackend, username=%s, remote_user=%s" % (username,remote_user)) + #if not os.environ.has_key('HTTPS'): + # return None + #if not os.environ.get('HTTPS')=='on': # only use this back-end if HTTPS on + # return None + + def GenPasswd(length=8, chars=string.letters + string.digits): + return ''.join([choice(chars) for i in range(length)]) + + # convert remote_user to user, email, fullname + info = ssl_dn_extract_info(remote_user) + #raise ImproperlyConfigured("[SSLLoginBackend] looking up %s" % repr(info)) + if not info: + #raise ImproperlyConfigured("[SSLLoginBackend] remote_user=%s, info=%s" % (remote_user,info)) + return None + (username,email,fullname) = info + + try: + user = User.objects.get(username=username) # if user already exists don't create it + except User.DoesNotExist: + raise "User does not exist. Not creating user; potential schema consistency issues" + #raise ImproperlyConfigured("[SSLLoginBackend] creating %s" % repr(info)) + user = User(username=username, password=GenPasswd()) # create new User + user.is_staff = False + user.is_superuser = False + # get first, last name from fullname + name = fullname + if not name.count(' '): + user.first_name = " " + user.last_name = name + mn = '' + else: + user.first_name = name[:name.find(' ')] + ml = name[name.find(' '):].strip() + if ml.count(' '): + user.last_name = ml[ml.rfind(' '):] + mn = ml[:ml.rfind(' ')] + else: + user.last_name = ml + mn = '' + # set email + user.email = email + # cleanup last name + user.last_name = user.last_name.strip() + # save + user.save() + + # auto-create user profile + up = UserProfile(user=user) + up.name = fullname + up.save() + + #tui = user.get_profile() + #tui.middle_name = mn + #tui.role = 'Misc' + #tui.section = None # no section assigned at first + #tui.save() + # return None + return user + + def get_user(self, user_id): + #if not os.environ.has_key('HTTPS'): + # return None + #if not os.environ.get('HTTPS')=='on': # only use this back-end if HTTPS on + # return None + try: + return User.objects.get(pk=user_id) + except User.DoesNotExist: + return None + +#----------------------------------------------------------------------------- +# OLD! + +class AutoLoginBackend: + def authenticate(self, username=None, password=None): + raise ImproperlyConfigured("in AutoLoginBackend, username=%s" % username) + if not os.environ.has_key('HTTPS'): + return None + if not os.environ.get('HTTPS')=='on':# only use this back-end if HTTPS on + return None + + def GenPasswd(length=8, chars=string.letters + string.digits): + return ''.join([choice(chars) for i in range(length)]) + + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + user = User(username=username, password=GenPasswd()) + user.is_staff = False + user.is_superuser = False + # get first, last name + name = os.environ.get('SSL_CLIENT_S_DN_CN').strip() + if not name.count(' '): + user.first_name = " " + user.last_name = name + mn = '' + else: + user.first_name = name[:name.find(' ')] + ml = name[name.find(' '):].strip() + if ml.count(' '): + user.last_name = ml[ml.rfind(' '):] + mn = ml[:ml.rfind(' ')] + else: + user.last_name = ml + mn = '' + # get email + user.email = os.environ.get('SSL_CLIENT_S_DN_Email') + # save + user.save() + tui = user.get_profile() + tui.middle_name = mn + tui.role = 'Misc' + tui.section = None# no section assigned at first + tui.save() + # return None + return user + + def get_user(self, user_id): + if not os.environ.has_key('HTTPS'): + return None + if not os.environ.get('HTTPS')=='on':# only use this back-end if HTTPS on + return None + try: + return User.objects.get(pk=user_id) + except User.DoesNotExist: + return None diff --git a/lib/loncapa/__init__.py b/lib/loncapa/__init__.py new file mode 100644 index 00000000000..b734967d0a4 --- /dev/null +++ b/lib/loncapa/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/python + +from loncapa_check import * diff --git a/lib/loncapa/loncapa_check.py b/lib/loncapa/loncapa_check.py new file mode 100644 index 00000000000..259c7909ace --- /dev/null +++ b/lib/loncapa/loncapa_check.py @@ -0,0 +1,17 @@ +#!/usr/bin/python +# +# File: mitx/lib/loncapa/loncapa_check.py +# +# Python functions which duplicate the standard comparison functions available to LON-CAPA problems. +# Used in translating LON-CAPA problems to i4x problem specification language. + +import random + +def lc_random(lower,upper,stepsize): + ''' + like random.randrange but lower and upper can be non-integer + ''' + nstep = int((upper-lower)/(1.0*stepsize)) + choices = [lower+x*stepsize for x in range(nstep)] + return random.choice(choices) + diff --git a/lib/mitxmako/shortcuts.py b/lib/mitxmako/shortcuts.py index 432acbcba92..7286a4e259d 100644 --- a/lib/mitxmako/shortcuts.py +++ b/lib/mitxmako/shortcuts.py @@ -34,6 +34,9 @@ def render_to_string(template_name, dictionary, context=None, namespace='main'): context_dictionary.update(d) if context: context_dictionary.update(context) + ## HACK + ## We should remove this, and possible set COURSE_TITLE in the middleware from the session. + if 'COURSE_TITLE' not in context_dictionary: context_dictionary['COURSE_TITLE'] = '' # fetch and render template template = middleware.lookup[namespace].get_template(template_name) return template.render(**context_dictionary) diff --git a/lib/util/views.py b/lib/util/views.py index 6708e7c7a4e..d95f1e9a223 100644 --- a/lib/util/views.py +++ b/lib/util/views.py @@ -2,7 +2,6 @@ import datetime import json import sys -from django.conf import settings from django.conf import settings from django.contrib.auth.models import User from django.core.context_processors import csrf @@ -61,3 +60,9 @@ def send_feedback(request): def info(request): ''' Info page (link from main header) ''' return render_to_response("info.html", {}) + +def mitxhome(request): + ''' Home page (link from main header). List of courses. ''' + if settings.ENABLE_MULTICOURSE: + return render_to_response("mitxhome.html", {}) + return info(request) diff --git a/settings.py b/settings.py index 21458b5fefa..2be6be485a6 100644 --- a/settings.py +++ b/settings.py @@ -8,6 +8,7 @@ import djcelery ### Dark code. Should be enabled in local settings for devel. ENABLE_MULTICOURSE = False # set to False to disable multicourse display (see lib.util.views.mitxhome) +QUICKEDIT = False ### @@ -20,19 +21,11 @@ COURSE_TITLE = "Circuits and Electronics" COURSE_DEFAULT = '6.002_Spring_2012' -COURSE_LIST = {'6.002_Spring_2012': {'number' : '6.002x', - 'title' : 'Circuits and Electronics', - 'datapath': '6002x/', - }, - '8.02_Spring_2013': {'number' : '8.02x', - 'title' : 'Electricity & Magnetism', - 'datapath': '802x/', - }, - '8.01_Spring_2013': {'number' : '8.01x', - 'title' : 'Mechanics', - 'datapath': '801x/', - }, - } +COURSE_SETTINGS = {'6.002_Spring_2012': {'number' : '6.002x', + 'title' : 'Circuits and Electronics', + 'xmlpath': '6002x/', + } + } ROOT_URLCONF = 'urls' @@ -150,6 +143,7 @@ MIDDLEWARE_CLASSES = ( 'django.contrib.messages.middleware.MessageMiddleware', 'track.middleware.TrackMiddleware', 'mitxmako.middleware.MakoMiddleware', + #'ssl_auth.ssl_auth.NginxProxyHeaderMiddleware', # ssl authentication behind nginx proxy #'debug_toolbar.middleware.DebugToolbarMiddleware', # Uncommenting the following will prevent csrf token from being re-set if you @@ -179,6 +173,8 @@ INSTALLED_APPS = ( 'util', 'masquerade', 'django_jasmine', + #'ssl_auth', ## Broken. Disabled for now. + 'multicourse', # multiple courses # Uncomment the next line to enable the admin: # 'django.contrib.admin', # Uncomment the next line to enable admin documentation: diff --git a/templates/quickedit.html b/templates/quickedit.html new file mode 100644 index 00000000000..c08c5e3f51b --- /dev/null +++ b/templates/quickedit.html @@ -0,0 +1,95 @@ +<html> +<head> + <link rel="stylesheet" href="${ settings.LIB_URL }jquery.treeview.css" type="text/css" media="all" /> + <link rel="stylesheet" href="/static/css/codemirror.css" type="text/css" media="all" /> + + <script type="text/javascript" src="${ settings.LIB_URL }jquery-1.6.2.min.js"></script> + <script type="text/javascript" src="${ settings.LIB_URL }jquery-ui-1.8.16.custom.min.js"></script> +<script type="text/javascript" src="${ settings.LIB_URL }codemirror-compressed.js"></script> + <script type="text/javascript" src="/static/js/schematic.js"></script> +<%include file="mathjax_include.html" /> + +<script> +function postJSON(url, data, callback) { + $.ajax({type:'POST', + url: url, + dataType: 'json', + data: data, + success: callback, + headers : {'X-CSRFToken':'none'} // getCookie('csrftoken')} + }); +} +</script> + +</head> +<body> + + <!--[if lt IE 9]> + <script src="/static/js/html5shiv.js"></script> + <![endif]--> + +<style type="text/css"> + .CodeMirror {border-style: solid; + border-width: 1px;} +.CodeMirror-scroll { + height: 500; + width: 100% +} +</style> + +## ----------------------------------------------------------------------------- +## information and i4x PSL code + +<hr width="100%"> +<h2>QuickEdit</h2> +<hr width="100%"> +<ul> +<li>File = ${filename}</li> +<li>ID = ${id}</li> +</ul> + +<form method="post"> + <textarea rows="40" cols="160" name="quickedit_${id}" id="quickedit_${id}">${pxmls|h}</textarea> +<br/> +<input type="submit" value="Change Problem" name="qesubmit" /> +<input type="submit" value="Revert to original" name="qesubmit" /> +</form> + +<span>${msg|n}</span> + +## ----------------------------------------------------------------------------- +## rendered problem display + +<script> +// height: auto; +// overflow-y: hidden; +// overflow-x: auto; + +$(function(){ + var cm = CodeMirror.fromTextArea(document.getElementById("quickedit_${id}"), + { 'mode': {name: "xml", alignCDATA: true}, + lineNumbers: true + }); + +// $('.my-wymeditor').wymeditor(); + +}); +</script> + +<hr width="100%"> + +<script> +${init_js} +</script> + +<style type="text/css"> + .staff {display:none;} +} +</style> + +<form> + ${phtml} +</form> + +</body> +</html> diff --git a/urls.py b/urls.py index 6eda9953d59..f286b17760a 100644 --- a/urls.py +++ b/urls.py @@ -69,6 +69,12 @@ if settings.COURSEWARE_ENABLED: url(r'^calculate$', 'util.views.calculate'), ) +if settings.ENABLE_MULTICOURSE: + urlpatterns += (url(r'^mitxhome$', 'util.views.mitxhome'),) + +if settings.QUICKEDIT: + urlpatterns += (url(r'^quickedit/(?P<id>[^/]*)$', 'courseware.views.quickedit'),) + if settings.ASKBOT_ENABLED: urlpatterns += (url(r'^%s' % settings.ASKBOT_URL, include('askbot.urls')), \ url(r'^admin/', include(admin.site.urls)), \ -- GitLab