diff --git a/.gitignore b/.gitignore
index f98fdf7bf9..e2340d2aa7 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/calc.py b/djangoapps/courseware/capa/calc.py
index fb64c58139..42bb5c3112 100644
--- a/djangoapps/courseware/capa/calc.py
+++ b/djangoapps/courseware/capa/calc.py
@@ -78,17 +78,23 @@ def evaluator(variables, functions, string, cs=False):
# log.debug("functions: {0}".format(functions))
# log.debug("string: {0}".format(string))
+ def lower_dict(d):
+ return dict([(k.lower(), d[k]) for k in d])
+
all_variables = copy.copy(default_variables)
- all_variables.update(variables)
all_functions = copy.copy(default_functions)
+
+ if not cs:
+ all_variables = lower_dict(all_variables)
+ all_functions = lower_dict(all_functions)
+
+ all_variables.update(variables)
all_functions.update(functions)
if not cs:
string_cs = string.lower()
- for v in all_variables.keys():
- all_variables[v.lower()]=all_variables[v]
- for f in all_functions.keys():
- all_functions[f.lower()]=all_functions[f]
+ all_functions = lower_dict(all_functions)
+ all_variables = lower_dict(all_variables)
CasedLiteral = CaselessLiteral
else:
string_cs = string
diff --git a/djangoapps/courseware/capa/capa_problem.py b/djangoapps/courseware/capa/capa_problem.py
index c5a81e8100..f5739fd8b0 100644
--- a/djangoapps/courseware/capa/capa_problem.py
+++ b/djangoapps/courseware/capa/capa_problem.py
@@ -1,3 +1,12 @@
+#
+# File: courseware/capa/capa_problem.py
+#
+'''
+Main module which shows problems (of "capa" type).
+
+This is used by capa_module.
+'''
+
import copy
import logging
import math
@@ -10,32 +19,45 @@ import struct
from lxml import etree
from lxml.etree import Element
+from xml.sax.saxutils import escape, unescape
from mako.template import Template
from util import contextualize_text
-from inputtypes import textline, schematic
-from responsetypes import numericalresponse, formularesponse, customresponse, schematicresponse, StudentInputError
+import inputtypes
+from responsetypes import NumericalResponse, FormulaResponse, CustomResponse, SchematicResponse, MultipleChoiceResponse, StudentInputError, TrueFalseResponse, ExternalResponse,ImageResponse,OptionResponse
import calc
import eia
log = logging.getLogger("mitx.courseware")
-response_types = {'numericalresponse':numericalresponse,
- 'formularesponse':formularesponse,
- 'customresponse':customresponse,
- 'schematicresponse':schematicresponse}
-entry_types = ['textline', 'schematic']
-response_properties = ["responseparam", "answer"]
+response_types = {'numericalresponse':NumericalResponse,
+ 'formularesponse':FormulaResponse,
+ 'customresponse':CustomResponse,
+ 'schematicresponse':SchematicResponse,
+ 'externalresponse':ExternalResponse,
+ 'multiplechoiceresponse':MultipleChoiceResponse,
+ 'truefalseresponse':TrueFalseResponse,
+ 'imageresponse':ImageResponse,
+ 'optionresponse':OptionResponse,
+ }
+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
+
# How to convert from original XML to HTML
# We should do this with xlst later
html_transforms = {'problem': {'tag':'div'},
"numericalresponse": {'tag':'span'},
"customresponse": {'tag':'span'},
+ "externalresponse": {'tag':'span'},
"schematicresponse": {'tag':'span'},
"formularesponse": {'tag':'span'},
- "text": {'tag':'span'}}
+ "multiplechoiceresponse": {'tag':'span'},
+ "text": {'tag':'span'},
+ "math": {'tag':'span'},
+ }
global_context={'random':random,
'numpy':numpy,
@@ -47,30 +69,28 @@ global_context={'random':random,
# These should be removed from HTML output, including all subelements
html_problem_semantics = ["responseparam", "answer", "script"]
# These should be removed from HTML output, but keeping subelements
-html_skip = ["numericalresponse", "customresponse", "schematicresponse", "formularesponse", "text"]
-# These should be transformed
-html_special_response = {"textline":textline.render,
- "schematic":schematic.render}
+html_skip = ["numericalresponse", "customresponse", "schematicresponse", "formularesponse", "text","externalresponse"]
+
+# removed in MC
+## These should be transformed
+#html_special_response = {"textline":textline.render,
+# "schematic":schematic.render,
+# "textbox":textbox.render,
+# "solution":solution.render,
+# }
class LoncapaProblem(object):
- def __init__(self, filename, id=None, state=None, seed=None):
+ def __init__(self, fileobject, id, state=None, seed=None):
## Initialize class variables from state
self.seed = None
self.student_answers = dict()
self.correct_map = dict()
self.done = False
- self.filename = filename
+ self.problem_id = id
if seed != None:
self.seed = seed
- if id:
- self.problem_id = id
- else:
- print "NO ID"
- raise Exception("This should never happen (183)")
- #self.problem_id = filename
-
if state:
if 'seed' in state:
self.seed = state['seed']
@@ -81,17 +101,13 @@ class LoncapaProblem(object):
if 'done' in state:
self.done = state['done']
-# print self.seed
-
# TODO: Does this deplete the Linux entropy pool? Is this fast enough?
if not self.seed:
self.seed=struct.unpack('i', os.urandom(4))[0]
-# print filename, self.seed, seed
-
## Parse XML file
- #log.debug(u"LoncapaProblem() opening file {0}".format(filename))
- file_text = open(filename).read()
+ file_text = fileobject.read()
+ self.fileobject = fileobject # save it, so we can use for debugging information later
# Convert startouttext and endouttext to proper
# TODO: Do with XML operations
file_text = re.sub("startouttext\s*/","text",file_text)
@@ -100,6 +116,9 @@ class LoncapaProblem(object):
self.preprocess_problem(self.tree, correct_map=self.correct_map, answer_map = self.student_answers)
self.context = self.extract_context(self.tree, seed=self.seed)
+ for response in self.tree.xpath('//'+"|//".join(response_types)):
+ responder = response_types[response.tag](response, self.context)
+ responder.preprocess_response()
def get_state(self):
''' Stored per-user session data neeeded to:
@@ -111,6 +130,9 @@ class LoncapaProblem(object):
'done':self.done}
def get_max_score(self):
+ '''
+ TODO: multiple points for programming problems.
+ '''
sum = 0
for et in entry_types:
sum = sum + self.tree.xpath('count(//'+et+')')
@@ -129,41 +151,76 @@ class LoncapaProblem(object):
'total':self.get_max_score()}
def grade_answers(self, answers):
+ '''
+ Grade student responses. Called by capa_module.check_problem.
+ answers is a dict of all the entries from request.POST, but with the first part
+ of each key removed (the string before the first "_").
+
+ Thus, for example, input_ID123 -> ID123, and input_fromjs_ID123 -> fromjs_ID123
+ '''
self.student_answers = answers
context=self.extract_context(self.tree)
self.correct_map = dict()
problems_simple = self.extract_problems(self.tree)
for response in problems_simple:
grader = response_types[response.tag](response, self.context)
- results = grader.grade(answers)
+ results = grader.grade(answers) # call the responsetype instance to do the actual grading
self.correct_map.update(results)
-
return self.correct_map
def get_question_answers(self):
+ '''
+ Make a dict of (id,correct_answer) entries, for all the problems.
+ Called by "show answers" button JSON request (see capa_module)
+ '''
context=self.extract_context(self.tree)
answer_map = dict()
- problems_simple = self.extract_problems(self.tree)
+ problems_simple = self.extract_problems(self.tree) # purified (flat) XML tree of just response queries
for response in problems_simple:
- responder = response_types[response.tag](response, self.context)
+ responder = response_types[response.tag](response, self.context) # instance of numericalresponse, customresponse,...
results = responder.get_answers()
- answer_map.update(results)
+ answer_map.update(results) # dict of (id,correct_answer)
+ # example for the following:
for entry in problems_simple.xpath("//"+"|//".join(response_properties+entry_types)):
- answer = entry.get('correct_answer')
+ answer = entry.get('correct_answer') # correct answer, when specified elsewhere, eg in a textline
if answer:
answer_map[entry.get('id')] = contextualize_text(answer, self.context)
+ # include solutions from ... 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 ========
def extract_context(self, tree, seed = struct.unpack('i', os.urandom(4))[0]): # private
- ''' Problem XML goes to Python execution context. Runs everything in script tags '''
+ '''
+ Extract content of from the problem.xml file, and exec it in the
+ context of this problem. Provides ability to randomize problems, and also set
+ variables for problem answer checking.
+
+ Problem XML goes to Python execution context. Runs everything in script tags
+ '''
random.seed(self.seed)
- context = dict()
- for script in tree.xpath('/problem/script'):
- exec script.text in global_context, context
+ ### IKE: Why do we need these two lines?
+ context = {'global_context':global_context} # save global context in here also
+ global_context['context'] = context # and put link to local context in the global one
+
+ #for script in tree.xpath('/problem/script'):
+ for script in tree.findall('.//script'):
+ code = script.text
+ XMLESC = {"'": "'", """: '"'}
+ code = unescape(code,XMLESC)
+ try:
+ exec code in global_context, context
+ except Exception,err:
+ print "[courseware.capa.capa_problem.extract_context] error %s" % err
+ print "in doing exec of this code:",code
return context
def get_html(self):
@@ -175,21 +232,46 @@ class LoncapaProblem(object):
if problemtree.tag in html_problem_semantics:
return
- if problemtree.tag in html_special_response:
+ problemid = problemtree.get('id') # my ID
+
+ # used to be
+ # if problemtree.tag in html_special_response:
+
+ if hasattr(inputtypes, problemtree.tag):
+ # status is currently the answer for the problem ID for the input element,
+ # but it will turn into a dict containing both the answer and any associated message
+ # for the problem ID for the input element.
status = "unsubmitted"
- if problemtree.get('id') in self.correct_map:
+ if problemid in self.correct_map:
status = self.correct_map[problemtree.get('id')]
value = ""
- if self.student_answers and problemtree.get('id') in self.student_answers:
- value = self.student_answers[problemtree.get('id')]
+ if self.student_answers and problemid in self.student_answers:
+ value = self.student_answers[problemid]
- return html_special_response[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:
subitems = self.extract_html(item)
- if subitems:
+ if subitems is not None:
for subitem in subitems:
tree.append(subitem)
for (key,value) in problemtree.items():
@@ -210,11 +292,11 @@ class LoncapaProblem(object):
# TODO: Fix. This loses Element().tail
#if problemtree.tag in html_skip:
# return tree
-
return [tree]
def preprocess_problem(self, tree, correct_map=dict(), answer_map=dict()): # private
- ''' Assign IDs to all the responses
+ '''
+ Assign IDs to all the responses
Assign sub-IDs to all entries (textline, schematic, etc.)
Annoted correctness and value
In-place transformation
@@ -228,13 +310,21 @@ class LoncapaProblem(object):
response.attrib['state'] = correct
response_id = response_id + 1
answer_id = 1
- for entry in tree.xpath("|".join(['//'+response.tag+'[@id=$id]//'+x for x in entry_types]),
+ 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)
answer_id=answer_id+1
+ # ... may not be associated with any specific response; give IDs for those separately
+ # TODO: We should make the namespaces consistent and unique (e.g. %s_problem_%i).
+ solution_id = 1
+ for solution in tree.findall('.//solution'):
+ solution.attrib['id'] = "%s_solution_%i"%(self.problem_id, solution_id)
+ solution_id += 1
+
def extract_problems(self, problem_tree):
''' Remove layout from the problem, and give a purified XML tree of just the problems '''
problem_tree=copy.deepcopy(problem_tree)
diff --git a/djangoapps/courseware/capa/inputtypes.py b/djangoapps/courseware/capa/inputtypes.py
index 789887243d..0388b35d0b 100644
--- a/djangoapps/courseware/capa/inputtypes.py
+++ b/djangoapps/courseware/capa/inputtypes.py
@@ -1,40 +1,251 @@
+#
+# File: courseware/capa/inputtypes.py
+#
+
+'''
+Module containing the problem elements which render into input objects
+
+- textline
+- 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
+# status is currently the answer for the problem ID for the input element,
+# 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
+
from lxml.etree import Element
from lxml import etree
-from mitxmako.shortcuts import render_to_response, render_to_string
+from mitxmako.shortcuts import render_to_string
-class textline(object):
- @staticmethod
- def render(element, value, state):
+#-----------------------------------------------------------------------------
+
+def optioninput(element, value, status, msg=''):
+ '''
+ Select option input type.
+
+ Example:
+
+ The location of the sky
+ '''
+ 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
+
+ TODO: allow order of choices to be randomized, following lon-capa spec. Use "location" attribute,
+ ie random, top, bottom.
+ '''
+ eid=element.get('id')
+ if element.get('type') == "MultipleChoice":
+ type="radio"
+ elif element.get('type') == "TrueFalse":
+ type="checkbox"
+ else:
+ type="radio"
+ choices={}
+ for choice in element:
+ assert choice.tag =="choice", "only tags should be immediate children of a "
+ choices[choice.get("name")] = etree.tostring(choice[0]) # TODO: what if choice[0] has math tags in it?
+ context={'id':eid, 'value':value, 'state':status, 'type':type, 'choices':choices}
+ html=render_to_string("choicegroup.html", context)
+ return etree.XML(html)
+
+def textline(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}
+ html=render_to_string("textinput.html", context)
+ return etree.XML(html)
+
+#-----------------------------------------------------------------------------
+
+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.
+ '''
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}
- html=render_to_string("textinput.html", context)
+ dojs = element.get('dojs') # dojs is used for client-side javascript display & return
+ # when dojs=='math', a `{::}`
+ # and a hidden textarea with id=input_eid_fromjs will be output
+ context = {'id':eid, 'value':value, 'state':status, 'count':count, 'size': size,
+ 'dojs':dojs,
+ 'msg':msg,
+ }
+ html=render_to_string("jstext.html", context)
return etree.XML(html)
-class schematic(object):
- @staticmethod
- def render(element, value, state):
- eid = element.get('id')
- height = element.get('height')
- width = element.get('width')
- parts = element.get('parts')
- analyses = element.get('analyses')
- initial_value = element.get('initial_value')
- submit_analyses = element.get('submit_analyses')
- context = {
- 'id':eid,
- 'value':value,
- 'initial_value':initial_value,
- 'state':state,
- 'width':width,
- 'height':height,
- 'parts':parts,
- 'analyses':analyses,
- 'submit_analyses':submit_analyses,
- }
- html=render_to_string("schematicinput.html", context)
+#-----------------------------------------------------------------------------
+## TODO: Make a wrapper for
+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.
+
+ TODO: make this use rows and cols attribs, not size
+ '''
+ eid=element.get('id')
+ count = int(eid.split('_')[-2])-1 # HACK
+ size = element.get('size')
+ 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, status, msg=''):
+ eid = element.get('id')
+ height = element.get('height')
+ width = element.get('width')
+ parts = element.get('parts')
+ analyses = element.get('analyses')
+ initial_value = element.get('initial_value')
+ submit_analyses = element.get('submit_analyses')
+ context = {
+ 'id':eid,
+ 'value':value,
+ 'initial_value':initial_value,
+ 'state':state,
+ 'width':width,
+ 'height':height,
+ 'parts':parts,
+ 'analyses':analyses,
+ 'submit_analyses':submit_analyses,
+ }
+ html=render_to_string("schematicinput.html", context)
+ return etree.XML(html)
+
+#-----------------------------------------------------------------------------
+### TODO: Move out of inputtypes
+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.
+
+ Examples:
+
+ $\displaystyle U(r)=4 U_0
+ $r_0$
+
+ We convert these to [mathjax]...[/mathjax] and [mathjaxinline]...[/mathjaxinline]
+
+ TODO: use shorter tags (but this will require converting problem XML files!)
+ '''
+ 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 = '%s%s' % (mathstr,element.tail)
+ xhtml = etree.XML(html)
+ # xhtml.tail = element.tail # don't forget to include the tail!
+ return xhtml
+
+#-----------------------------------------------------------------------------
+
+def solution(element, value, status, msg=''):
+ '''
+ This is not really an input type. It is just a ... which is given an ID,
+ that is used for displaying an extended answer (a problem "solution") after "show answers"
+ is pressed. Note that the solution content is NOT sent with the HTML. It is obtained
+ by a JSON call.
+ '''
+ eid=element.get('id')
+ size = element.get('size')
+ context = {'id':eid,
+ 'value':value,
+ 'state':status,
+ 'size': size,
+ 'msg':msg,
+ }
+ html=render_to_string("solutionspan.html", context)
+ return etree.XML(html)
+
+#-----------------------------------------------------------------------------
+
+def imageinput(element, value, status, msg=''):
+ '''
+ Clickable image as an input field. Element should specify the image source, height, and width, eg
+
+
+ TODO: showanswer for imageimput does not work yet - need javascript to put rectangle over acceptable area of image.
+
+ '''
+ eid = element.get('id')
+ src = element.get('src')
+ height = element.get('height')
+ width = element.get('width')
+
+ # if value is of the form [x,y] then parse it and send along coordinates of previous answer
+ m = re.match('\[([0-9]+),([0-9]+)]',value.strip().replace(' ',''))
+ if m:
+ (gx,gy) = [int(x)-15 for x in m.groups()]
+ else:
+ (gx,gy) = (0,0)
+
+ context = {
+ 'id':eid,
+ 'value':value,
+ 'height': height,
+ 'width' : width,
+ 'src':src,
+ 'gx':gx,
+ 'gy':gy,
+ 'state' : status, # to change
+ 'msg': msg, # to change
+ }
+ if settings.DEBUG:
+ print '[courseware.capa.inputtypes.imageinput] context=',context
+ html=render_to_string("imageinput.html", context)
+ return etree.XML(html)
diff --git a/djangoapps/courseware/capa/responsetypes.py b/djangoapps/courseware/capa/responsetypes.py
index 62705d3a70..d9b18428fe 100644
--- a/djangoapps/courseware/capa/responsetypes.py
+++ b/djangoapps/courseware/capa/responsetypes.py
@@ -1,26 +1,38 @@
+#
+# File: courseware/capa/responsetypes.py
+#
+'''
+Problem response evaluation. Handles checking of student responses, of a variety of types.
+
+Used by capa_problem.py
+'''
+
+# standard library imports
import json
import math
import numbers
import numpy
import random
+import re
+import requests
import scipy
import traceback
+import copy
+import abc
+# specific library imports
from calc import evaluator, UndefinedVariable
from django.conf import settings
from util import contextualize_text
+from lxml import etree
+from lxml.etree import Element
+from lxml.html.soupparser import fromstring as fromstring_bs # uses Beautiful Soup!!! FIXME?
+# local imports
import calc
import eia
-# TODO: Should be the same object as in capa_problem
-global_context={'random':random,
- 'numpy':numpy,
- 'math':math,
- 'scipy':scipy,
- 'calc':calc,
- 'eia':eia}
-
+from util import contextualize_text
def compare_with_tolerance(v1, v2, tol):
''' Compare v1 to v2 with maximum tolerance tol
@@ -34,15 +46,153 @@ def compare_with_tolerance(v1, v2, tol):
tolerance = evaluator(dict(),dict(),tol)
return abs(v1-v2) <= tolerance
-class numericalresponse(object):
+class GenericResponse(object):
+ __metaclass__=abc.ABCMeta
+
+ @abc.abstractmethod
+ def grade(self, student_answers):
+ pass
+
+ @abc.abstractmethod
+ def get_answers(self):
+ pass
+
+ #not an abstract method because plenty of responses will not want to preprocess anything, and we should not require that they override this method.
+ def preprocess_response(self):
+ pass
+
+#Every response type needs methods "grade" and "get_answers"
+
+#-----------------------------------------------------------------------------
+
+class MultipleChoiceResponse(GenericResponse):
+ '''
+ Example:
+
+
+
+ `a+b`
+
+
+
+
+
+
+ TODO: handle direction and randomize
+
+ '''
+ def __init__(self, xml, context):
+ self.xml = xml
+ self.correct_choices = xml.xpath('//*[@id=$id]//choice[@correct="true"]',
+ id=xml.get('id'))
+ 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:
+ raise Exception("should have exactly one choice group per multiplechoicceresponse")
+ self.answer_id=self.answer_id[0]
+
+ def grade(self, student_answers):
+ if self.answer_id in student_answers and student_answers[self.answer_id] in self.correct_choices:
+ return {self.answer_id:'correct'}
+ else:
+ return {self.answer_id:'incorrect'}
+
+ def get_answers(self):
+ return {self.answer_id:self.correct_choices}
+
+ def preprocess_response(self):
+ '''
+ Initialize name attributes in stanzas in the in this response.
+ '''
+ i=0
+ for response in self.xml.xpath("choicegroup"):
+ 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))
+ i+=1
+ else:
+ choice.set("name", "choice_"+choice.get("name"))
+
+class TrueFalseResponse(MultipleChoiceResponse):
+ def preprocess_response(self):
+ i=0
+ for response in self.xml.xpath("choicegroup"):
+ response.set("type", "TrueFalse")
+ for choice in list(response):
+ if choice.get("name") == None:
+ choice.set("name", "choice_"+str(i))
+ i+=1
+ else:
+ choice.set("name", "choice_"+choice.get("name"))
+
+ def grade(self, student_answers):
+ correct = set(self.correct_choices)
+ answers = set(student_answers.get(self.answer_id, []))
+
+ if correct == answers:
+ return { self.answer_id : 'correct'}
+
+ return {self.answer_id : 'incorrect'}
+
+#-----------------------------------------------------------------------------
+
+class OptionResponse(GenericResponse):
+ '''
+ Example:
+
+
+ The location of the sky
+ The location of the earth
+
+
+ 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):
self.xml = xml
self.correct_answer = contextualize_text(xml.get('answer'), context)
- self.tolerance_xml = xml.xpath('//*[@id=$id]//responseparam[@type="tolerance"]/@default',
- id=xml.get('id'))[0]
- self.tolerance = contextualize_text(self.tolerance_xml, context)
- self.answer_id = xml.xpath('//*[@id=$id]//textline/@id',
- id=xml.get('id'))[0]
+ try:
+ self.tolerance_xml = xml.xpath('//*[@id=$id]//responseparam[@type="tolerance"]/@default',
+ id=xml.get('id'))[0]
+ self.tolerance = contextualize_text(self.tolerance_xml, context)
+ except Exception,err:
+ self.tolerance = 0
+ try:
+ self.answer_id = xml.xpath('//*[@id=$id]//textline/@id',
+ id=xml.get('id'))[0]
+ except Exception, err:
+ self.answer_id = None
def grade(self, student_answers):
''' Display HTML for a numeric response '''
@@ -63,7 +213,50 @@ class numericalresponse(object):
def get_answers(self):
return {self.answer_id:self.correct_answer}
-class customresponse(object):
+#-----------------------------------------------------------------------------
+
+class CustomResponse(GenericResponse):
+ '''
+ Custom response. The python code to be run should be in .... Example:
+
+
+
+
+ Suppose that \(I(t)\) rises from \(0\) to \(I_S\) at a time \(t_0 \neq 0\)
+ In the space provided below write an algebraic expression for \(I(t)\).
+
+
+
+
+ correct=['correct']
+ try:
+ r = str(submission[0])
+ except ValueError:
+ correct[0] ='incorrect'
+ r = '0'
+ if not(r=="IS*u(t-t0)"):
+ correct[0] ='incorrect'
+
+
+
+ Alternatively, the check function can be defined in Example:
+
+
+
+
+
+
+
+
+ '''
def __init__(self, xml, context):
self.xml = xml
## CRITICAL TODO: Should cover all entrytypes
@@ -72,19 +265,201 @@ class customresponse(object):
self.answer_ids = xml.xpath('//*[@id=$id]//textline/@id',
id=xml.get('id'))
self.context = context
+
+ # if has an "expect" attribute then save that
+ self.expect = xml.get('expect')
+ self.myid = xml.get('id')
+
+ # the ... stanza should be local to the current . 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 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 "_").
+ '''
+
+ 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 = ''+msg+''
+ msg = etree.tostring(fromstring_bs(msg),pretty_print=True)
+ msg = msg.replace('
','')
+ #msg = re.sub('(.*)','\\1',msg,flags=re.M|re.DOTALL) # python 2.7
+ msg = re.sub('(?ms)(.*)','\\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):
+ '''
+ 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 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',
+ id=xml.get('id'))
+ self.context = context
answer = xml.xpath('//*[@id=$id]//answer',
id=xml.get('id'))[0]
+
answer_src = answer.get('src')
if answer_src != None:
self.code = open(settings.DATA_DIR+'src/'+answer_src).read()
else:
self.code = answer.text
+ self.tests = xml.get('answer')
+
def grade(self, student_answers):
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'])
+
+ xmlstr = etree.tostring(self.xml, pretty_print=True)
+
+ payload = {'xml': xmlstr,
+ ### Question: Is this correct/what we want? Shouldn't this be a json.dumps?
+ 'LONCAPA_student_response': ''.join(submission),
+ 'LONCAPA_correct_answer': self.tests,
+ 'processor' : self.code,
+ }
+
+ # call external server; TODO: get URL from settings.py
+ r = requests.post("http://eecs1.mit.edu:8889/pyloncapa",data=payload)
+
+ rxml = etree.fromstring(r.text) # response is XML; prase it
+ ad = rxml.find('awarddetail').text
+ admap = {'EXACT_ANS':'correct', # TODO: handle other loncapa responses
+ 'WRONG_FORMAT': 'incorrect',
+ }
+ self.context['correct'] = ['correct']
+ if ad in admap:
+ self.context['correct'][0] = admap[ad]
+
+ # self.context['correct'] = ['correct','correct']
+ correct_map = dict(zip(sorted(self.answer_ids), self.context['correct']))
+
+ # TODO: separate message for each answer_id?
+ correct_map['msg'] = rxml.find('message').text.replace(' ',' ') # store message in correct_map
+
+ return correct_map
def get_answers(self):
# Since this is explicitly specified in the problem, this will
@@ -94,16 +469,27 @@ class customresponse(object):
class StudentInputError(Exception):
pass
-class formularesponse(object):
+#-----------------------------------------------------------------------------
+
+class FormulaResponse(GenericResponse):
def __init__(self, xml, context):
self.xml = xml
self.correct_answer = contextualize_text(xml.get('answer'), context)
self.samples = contextualize_text(xml.get('samples'), context)
- self.tolerance_xml = xml.xpath('//*[@id=$id]//responseparam[@type="tolerance"]/@default',
- id=xml.get('id'))[0]
- self.tolerance = contextualize_text(self.tolerance_xml, context)
- self.answer_id = xml.xpath('//*[@id=$id]//textline/@id',
- id=xml.get('id'))[0]
+ try:
+ self.tolerance_xml = xml.xpath('//*[@id=$id]//responseparam[@type="tolerance"]/@default',
+ id=xml.get('id'))[0]
+ self.tolerance = contextualize_text(self.tolerance_xml, context)
+ except Exception,err:
+ self.tolerance = 0
+
+ try:
+ self.answer_id = xml.xpath('//*[@id=$id]//textline/@id',
+ id=xml.get('id'))[0]
+ except Exception, err:
+ self.answer_id = None
+ raise Exception, "[courseware.capa.responsetypes.FormulaResponse] Error: missing answer_id!!"
+
self.context = context
ts = xml.get('type')
if ts == None:
@@ -129,7 +515,7 @@ class formularesponse(object):
for i in range(numsamples):
instructor_variables = self.strip_dict(dict(self.context))
student_variables = dict()
- for var in ranges:
+ for var in ranges: # ranges give numerical ranges for testing
value = random.uniform(*ranges[var])
instructor_variables[str(var)] = value
student_variables[str(var)] = value
@@ -164,7 +550,9 @@ class formularesponse(object):
def get_answers(self):
return {self.answer_id:self.correct_answer}
-class schematicresponse(object):
+#-----------------------------------------------------------------------------
+
+class SchematicResponse(GenericResponse):
def __init__(self, xml, context):
self.xml = xml
self.answer_ids = xml.xpath('//*[@id=$id]//schematic/@id',
@@ -179,6 +567,7 @@ class schematicresponse(object):
self.code = answer.text
def grade(self, student_answers):
+ from capa_problem import global_context
submission = [json.loads(student_answers[k]) for k in sorted(self.answer_ids)]
self.context.update({'submission':submission})
exec self.code in global_context, self.context
@@ -188,3 +577,64 @@ class schematicresponse(object):
# Since this is explicitly specified in the problem, this will
# be handled by capa_problem
return {}
+
+#-----------------------------------------------------------------------------
+
+class ImageResponse(GenericResponse):
+ """
+ Handle student response for image input: the input is a click on an image,
+ which produces an [x,y] coordinate pair. The click is correct if it falls
+ within a region specified. This region is nominally a rectangle.
+
+ Lon-CAPA requires that each has a inside it. That
+ doesn't make sense to me (Ike). Instead, let's have it such that
+ should contain one or more stanzas. Each should specify
+ a rectangle, given as an attribute, defining the correct answer.
+
+ Example:
+
+
+
+
+
+
+ """
+ def __init__(self, xml, context):
+ self.xml = xml
+ self.context = context
+ self.ielements = xml.findall('imageinput')
+ self.answer_ids = [ie.get('id') for ie in self.ielements]
+
+ def grade(self, student_answers):
+ correct_map = {}
+ expectedset = self.get_answers()
+
+ for aid in self.answer_ids: # loop through IDs of fields in our stanza
+ given = student_answers[aid] # this should be a string of the form '[x,y]'
+
+ # parse expected answer
+ # TODO: Compile regexp on file load
+ m = re.match('[\(\[]([0-9]+),([0-9]+)[\)\]]-[\(\[]([0-9]+),([0-9]+)[\)\]]',expectedset[aid].strip().replace(' ',''))
+ if not m:
+ msg = 'Error in problem specification! cannot parse rectangle in %s' % (etree.tostring(self.ielements[aid],
+ pretty_print=True))
+ raise Exception,'[capamodule.capa.responsetypes.imageinput] '+msg
+ (llx,lly,urx,ury) = [int(x) for x in m.groups()]
+
+ # parse given answer
+ m = re.match('\[([0-9]+),([0-9]+)]',given.strip().replace(' ',''))
+ if not m:
+ raise Exception,'[capamodule.capa.responsetypes.imageinput] error grading %s (input=%s)' % (err,aid,given)
+ (gx,gy) = [int(x) for x in m.groups()]
+
+ # answer is correct if (x,y) is within the specified rectangle
+ if (llx <= gx <= urx) and (lly <= gy <= ury):
+ correct_map[aid] = 'correct'
+ else:
+ correct_map[aid] = 'incorrect'
+ if settings.DEBUG:
+ print "[capamodule.capa.responsetypes.imageinput] correct_map=",correct_map
+ return correct_map
+
+ def get_answers(self):
+ return dict([(ie.get('id'),ie.get('rectangle')) for ie in self.ielements])
diff --git a/djangoapps/courseware/capa/unit.py b/djangoapps/courseware/capa/unit.py
deleted file mode 100644
index 2cbe4f93f0..0000000000
--- a/djangoapps/courseware/capa/unit.py
+++ /dev/null
@@ -1,132 +0,0 @@
-import math
-import operator
-
-from numpy import eye, array
-
-from pyparsing import Word, alphas, nums, oneOf, Literal
-from pyparsing import ZeroOrMore, OneOrMore, StringStart
-from pyparsing import StringEnd, Optional, Forward
-from pyparsing import CaselessLiteral, Group, StringEnd
-from pyparsing import NoMatch, stringEnd
-
-base_units = ['meter', 'gram', 'second', 'ampere', 'kelvin', 'mole', 'cd']
-unit_vectors = dict([(base_units[i], eye(len(base_units))[:,i]) for i in range(len(base_units))])
-
-
-def unit_evaluator(unit_string, units=unit_map):
- ''' Evaluate an expression. Variables are passed as a dictionary
- from string to value. Unary functions are passed as a dictionary
- from string to function '''
- if string.strip() == "":
- return float('nan')
- ops = { "^" : operator.pow,
- "*" : operator.mul,
- "/" : operator.truediv,
- }
- prefixes={'%':0.01,'k':1e3,'M':1e6,'G':1e9,
- 'T':1e12,#'P':1e15,'E':1e18,'Z':1e21,'Y':1e24,
- 'c':1e-2,'m':1e-3,'u':1e-6,
- 'n':1e-9,'p':1e-12}#,'f':1e-15,'a':1e-18,'z':1e-21,'y':1e-24}
-
- def super_float(text):
- ''' Like float, but with si extensions. 1k goes to 1000'''
- if text[-1] in suffixes:
- return float(text[:-1])*suffixes[text[-1]]
- else:
- return float(text)
-
- def number_parse_action(x): # [ '7' ] -> [ 7 ]
- return [super_float("".join(x))]
- def exp_parse_action(x): # [ 2 ^ 3 ^ 2 ] -> 512
- x = [e for e in x if type(e) == float] # Ignore ^
- x.reverse()
- x=reduce(lambda a,b:b**a, x)
- return x
- def parallel(x): # Parallel resistors [ 1 2 ] => 2/3
- if len(x) == 1:
- return x[0]
- if 0 in x:
- return float('nan')
- x = [1./e for e in x if type(e) == float] # Ignore ^
- return 1./sum(x)
- def sum_parse_action(x): # [ 1 + 2 - 3 ] -> 0
- total = 0.0
- op = ops['+']
- for e in x:
- if e in set('+-'):
- op = ops[e]
- else:
- total=op(total, e)
- return total
- def prod_parse_action(x): # [ 1 * 2 / 3 ] => 0.66
- prod = 1.0
- op = ops['*']
- for e in x:
- if e in set('*/'):
- op = ops[e]
- else:
- prod=op(prod, e)
- return prod
- def func_parse_action(x):
- return [functions[x[0]](x[1])]
-
- number_suffix=reduce(lambda a,b:a|b, map(Literal,suffixes.keys()), NoMatch()) # SI suffixes and percent
- (dot,minus,plus,times,div,lpar,rpar,exp)=map(Literal,".-+*/()^")
-
- number_part=Word(nums)
- inner_number = ( number_part+Optional("."+number_part) ) | ("."+number_part) # 0.33 or 7 or .34
- number=Optional(minus | plus)+ inner_number + \
- Optional(CaselessLiteral("E")+Optional("-")+number_part)+ \
- Optional(number_suffix) # 0.33k or -17
- number=number.setParseAction( number_parse_action ) # Convert to number
-
- # Predefine recursive variables
- expr = Forward()
- factor = Forward()
-
- def sreduce(f, l):
- ''' Same as reduce, but handle len 1 and len 0 lists sensibly '''
- if len(l)==0:
- return NoMatch()
- if len(l)==1:
- return l[0]
- return reduce(f, l)
-
- # Handle variables passed in. E.g. if we have {'R':0.5}, we make the substitution.
- # Special case for no variables because of how we understand PyParsing is put together
- if len(variables)>0:
- varnames = sreduce(lambda x,y:x|y, map(lambda x: CaselessLiteral(x), variables.keys()))
- varnames.setParseAction(lambda x:map(lambda y:variables[y], x))
- else:
- varnames=NoMatch()
- # Same thing for functions.
- if len(functions)>0:
- funcnames = sreduce(lambda x,y:x|y, map(lambda x: CaselessLiteral(x), functions.keys()))
- function = funcnames+lpar.suppress()+expr+rpar.suppress()
- function.setParseAction(func_parse_action)
- else:
- function = NoMatch()
-
- atom = number | varnames | lpar+expr+rpar | function
- factor << (atom + ZeroOrMore(exp+atom)).setParseAction(exp_parse_action) # 7^6
- paritem = factor + ZeroOrMore(Literal('||')+factor) # 5k || 4k
- paritem=paritem.setParseAction(parallel)
- term = paritem + ZeroOrMore((times|div)+paritem) # 7 * 5 / 4 - 3
- term = term.setParseAction(prod_parse_action)
- expr << Optional((plus|minus)) + term + ZeroOrMore((plus|minus)+term) # -5 + 4 - 3
- expr=expr.setParseAction(sum_parse_action)
- return (expr+stringEnd).parseString(string)[0]
-
-if __name__=='__main__':
- variables={'R1':2.0, 'R3':4.0}
- functions={'sin':math.sin, 'cos':math.cos}
- print "X",evaluator(variables, functions, "10000||sin(7+5)-6k")
- print "X",evaluator(variables, functions, "13")
- print evaluator({'R1': 2.0, 'R3':4.0}, {}, "13")
- #
- print evaluator({'a': 2.2997471478310274, 'k': 9, 'm': 8, 'x': 0.66009498411213041}, {}, "5")
- print evaluator({},{}, "-1")
- print evaluator({},{}, "-(7+5)")
- print evaluator({},{}, "-0.33")
- print evaluator({},{}, "-.33")
- print evaluator({},{}, "5+7 QWSEKO")
diff --git a/djangoapps/courseware/content_parser.py b/djangoapps/courseware/content_parser.py
index eb1678536d..adb10f7dfc 100644
--- a/djangoapps/courseware/content_parser.py
+++ b/djangoapps/courseware/content_parser.py
@@ -1,5 +1,13 @@
+'''
+courseware/content_parser.py
+
+This file interfaces between all courseware modules and the top-level course.xml file for a course.
+
+Does some caching (to be explained).
+
+'''
+
import hashlib
-import json
import logging
import os
import re
@@ -14,9 +22,11 @@ try: # This lets us do __name__ == ='__main__'
from student.models import UserProfile
from student.models import UserTestGroup
- from mitxmako.shortcuts import render_to_response, render_to_string
+ 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
@@ -97,20 +107,9 @@ def item(l, default="", process=lambda x:x):
def id_tag(course):
''' Tag all course elements with unique IDs '''
- old_ids = {'video':'youtube',
- 'problem':'filename',
- 'sequential':'id',
- 'html':'filename',
- 'vertical':'id',
- 'tab':'id',
- 'schematic':'id',
- 'book' : 'id'}
import courseware.modules
default_ids = courseware.modules.get_default_ids()
- #print default_ids, old_ids
- #print default_ids == old_ids
-
# Tag elements with unique IDs
elements = course.xpath("|".join(['//'+c for c in default_ids]))
for elem in elements:
@@ -153,6 +152,9 @@ def propogate_downward_tag(element, attribute_name, parent_attribute = None):
return
def user_groups(user):
+ if not user.is_authenticated():
+ return []
+
# TODO: Rewrite in Django
key = 'user_group_names_{user.id}'.format(user=user)
cache_expiration = 60 * 60 # one hour
@@ -177,15 +179,23 @@ def course_xml_process(tree):
propogate_downward_tag(tree, "due")
propogate_downward_tag(tree, "graded")
propogate_downward_tag(tree, "graceperiod")
+ propogate_downward_tag(tree, "showanswer")
+ propogate_downward_tag(tree, "rerandomize")
return tree
-def course_file(user):
+def course_file(user,coursename=None):
''' Given a user, return course.xml'''
- #import logging
- #log = logging.getLogger("tracking")
- #log.info( "DEBUG: cf:"+str(user) )
- filename = UserProfile.objects.get(user=user).courseware # user.profile_cache.courseware
+ if user.is_authenticated():
+ filename = UserProfile.objects.get(user=user).courseware # user.profile_cache.courseware
+ else:
+ filename = 'guest_course.xml'
+
+ # if a specific course is specified, then use multicourse to get the right path to the course XML directory
+ if coursename and settings.ENABLE_MULTICOURSE:
+ xp = multicourse_settings.get_course_xmlpath(coursename)
+ filename = xp + filename # prefix the filename with the path
+
groups = user_groups(user)
options = {'dev_content':settings.DEV_CONTENT,
'groups' : groups}
@@ -207,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,
@@ -223,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
@@ -236,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/global_course_settings.py b/djangoapps/courseware/global_course_settings.py
new file mode 100644
index 0000000000..f4e9696d1d
--- /dev/null
+++ b/djangoapps/courseware/global_course_settings.py
@@ -0,0 +1,28 @@
+GRADER = [
+ {
+ 'type' : "Homework",
+ 'min_count' : 12,
+ 'drop_count' : 2,
+ 'short_label' : "HW",
+ 'weight' : 0.15,
+ },
+ {
+ 'type' : "Lab",
+ 'min_count' : 12,
+ 'drop_count' : 2,
+ 'category' : "Labs",
+ 'weight' : 0.15
+ },
+ {
+ 'type' : "Midterm",
+ 'name' : "Midterm Exam",
+ 'short_label' : "Midterm",
+ 'weight' : 0.3,
+ },
+ {
+ 'type' : "Final",
+ 'name' : "Final Exam",
+ 'short_label' : "Final",
+ 'weight' : 0.4,
+ }
+]
diff --git a/djangoapps/courseware/graders.py b/djangoapps/courseware/graders.py
new file mode 100644
index 0000000000..94b2ca78cd
--- /dev/null
+++ b/djangoapps/courseware/graders.py
@@ -0,0 +1,276 @@
+import abc
+import logging
+
+from django.conf import settings
+
+from collections import namedtuple
+
+log = logging.getLogger("mitx.courseware")
+
+# This is a tuple for holding scores, either from problems or sections.
+# Section either indicates the name of the problem or the name of the section
+Score = namedtuple("Score", "earned possible graded section")
+
+def grader_from_conf(conf):
+ """
+ This creates a CourseGrader from a configuration (such as in course_settings.py).
+ The conf can simply be an instance of CourseGrader, in which case no work is done.
+ More commonly, the conf is a list of dictionaries. A WeightedSubsectionsGrader
+ with AssignmentFormatGrader's or SingleSectionGrader's as subsections will be
+ generated. Every dictionary should contain the parameters for making either a
+ AssignmentFormatGrader or SingleSectionGrader, in addition to a 'weight' key.
+ """
+ if isinstance(conf, CourseGrader):
+ return conf
+
+ subgraders = []
+ for subgraderconf in conf:
+ subgraderconf = subgraderconf.copy()
+ weight = subgraderconf.pop("weight", 0)
+ try:
+ if 'min_count' in subgraderconf:
+ #This is an AssignmentFormatGrader
+ subgrader = AssignmentFormatGrader(**subgraderconf)
+ subgraders.append( (subgrader, subgrader.category, weight) )
+ elif 'name' in subgraderconf:
+ #This is an SingleSectionGrader
+ subgrader = SingleSectionGrader(**subgraderconf)
+ subgraders.append( (subgrader, subgrader.category, weight) )
+ else:
+ raise ValueError("Configuration has no appropriate grader class.")
+
+ except (TypeError, ValueError) as error:
+ errorString = "Unable to parse grader configuration:\n " + str(subgraderconf) + "\n Error was:\n " + str(error)
+ log.critical(errorString)
+ raise ValueError(errorString)
+
+ return WeightedSubsectionsGrader( subgraders )
+
+
+class CourseGrader(object):
+ """
+ A course grader takes the totaled scores for each graded section (that a student has
+ started) in the course. From these scores, the grader calculates an overall percentage
+ grade. The grader should also generate information about how that score was calculated,
+ to be displayed in graphs or charts.
+
+ A grader has one required method, grade(), which is passed a grade_sheet. The grade_sheet
+ contains scores for all graded section that the student has started. If a student has
+ a score of 0 for that section, it may be missing from the grade_sheet. The grade_sheet
+ is keyed by section format. Each value is a list of Score namedtuples for each section
+ that has the matching section format.
+
+ The grader outputs a dictionary with the following keys:
+ - percent: Contaisn a float value, which is the final percentage score for the student.
+ - section_breakdown: This is a list of dictionaries which provide details on sections
+ that were graded. These are used for display in a graph or chart. The format for a
+ section_breakdown dictionary is explained below.
+ - grade_breakdown: This is a list of dictionaries which provide details on the contributions
+ of the final percentage grade. This is a higher level breakdown, for when the grade is constructed
+ of a few very large sections (such as Homeworks, Labs, a Midterm, and a Final). The format for
+ a grade_breakdown is explained below. This section is optional.
+
+ A dictionary in the section_breakdown list has the following keys:
+ percent: A float percentage for the section.
+ label: A short string identifying the section. Preferably fixed-length. E.g. "HW 3".
+ detail: A string explanation of the score. E.g. "Homework 1 - Ohms Law - 83% (5/6)"
+ category: A string identifying the category. Items with the same category are grouped together
+ in the display (for example, by color).
+ prominent: A boolean value indicating that this section should be displayed as more prominent
+ than other items.
+
+ A dictionary in the grade_breakdown list has the following keys:
+ percent: A float percentage in the breakdown. All percents should add up to the final percentage.
+ detail: A string explanation of this breakdown. E.g. "Homework - 10% of a possible 15%"
+ category: A string identifying the category. Items with the same category are grouped together
+ in the display (for example, by color).
+
+
+ """
+
+ __metaclass__ = abc.ABCMeta
+
+ @abc.abstractmethod
+ def grade(self, grade_sheet):
+ raise NotImplementedError
+
+class WeightedSubsectionsGrader(CourseGrader):
+ """
+ This grader takes a list of tuples containing (grader, category_name, weight) and computes
+ a final grade by totalling the contribution of each sub grader and multiplying it by the
+ given weight. For example, the sections may be
+ [ (homeworkGrader, "Homework", 0.15), (labGrader, "Labs", 0.15), (midtermGrader, "Midterm", 0.30), (finalGrader, "Final", 0.40) ]
+ All items in section_breakdown for each subgrader will be combined. A grade_breakdown will be
+ composed using the score from each grader.
+
+ Note that the sum of the weights is not take into consideration. If the weights add up to
+ a value > 1, the student may end up with a percent > 100%. This allows for sections that
+ are extra credit.
+ """
+ def __init__(self, sections):
+ self.sections = sections
+
+ def grade(self, grade_sheet):
+ total_percent = 0.0
+ section_breakdown = []
+ grade_breakdown = []
+
+ for subgrader, category, weight in self.sections:
+ subgrade_result = subgrader.grade(grade_sheet)
+
+ weightedPercent = subgrade_result['percent'] * weight
+ section_detail = "{0} = {1:.1%} of a possible {2:.0%}".format(category, weightedPercent, weight)
+
+ total_percent += weightedPercent
+ section_breakdown += subgrade_result['section_breakdown']
+ grade_breakdown.append( {'percent' : weightedPercent, 'detail' : section_detail, 'category' : category} )
+
+ return {'percent' : total_percent,
+ 'section_breakdown' : section_breakdown,
+ 'grade_breakdown' : grade_breakdown}
+
+
+class SingleSectionGrader(CourseGrader):
+ """
+ This grades a single section with the format 'type' and the name 'name'.
+
+ If the name is not appropriate for the short short_label or category, they each may
+ be specified individually.
+ """
+ def __init__(self, type, name, short_label = None, category = None):
+ self.type = type
+ self.name = name
+ self.short_label = short_label or name
+ self.category = category or name
+
+ def grade(self, grade_sheet):
+ foundScore = None
+ if self.type in grade_sheet:
+ for score in grade_sheet[self.type]:
+ if score.section == self.name:
+ foundScore = score
+ break
+
+ if foundScore:
+ percent = foundScore.earned / float(foundScore.possible)
+ detail = "{name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format( name = self.name,
+ percent = percent,
+ earned = float(foundScore.earned),
+ possible = float(foundScore.possible))
+
+ else:
+ percent = 0.0
+ detail = "{name} - 0% (?/?)".format(name = self.name)
+
+ if settings.GENERATE_PROFILE_SCORES:
+ points_possible = random.randrange(50, 100)
+ points_earned = random.randrange(40, points_possible)
+ percent = points_earned / float(points_possible)
+ detail = "{name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format( name = self.name,
+ percent = percent,
+ earned = float(points_earned),
+ possible = float(points_possible))
+
+
+
+
+ breakdown = [{'percent': percent, 'label': self.short_label, 'detail': detail, 'category': self.category, 'prominent': True}]
+
+ return {'percent' : percent,
+ 'section_breakdown' : breakdown,
+ #No grade_breakdown here
+ }
+
+class AssignmentFormatGrader(CourseGrader):
+ """
+ Grades all sections matching the format 'type' with an equal weight. A specified
+ number of lowest scores can be dropped from the calculation. The minimum number of
+ sections in this format must be specified (even if those sections haven't been
+ written yet).
+
+ min_count defines how many assignments are expected throughout the course. Placeholder
+ scores (of 0) will be inserted if the number of matching sections in the course is < min_count.
+ If there number of matching sections in the course is > min_count, min_count will be ignored.
+
+ category should be presentable to the user, but may not appear. When the grade breakdown is
+ displayed, scores from the same category will be similar (for example, by color).
+
+ section_type is a string that is the type of a singular section. For example, for Labs it
+ would be "Lab". This defaults to be the same as category.
+
+ short_label is similar to section_type, but shorter. For example, for Homework it would be
+ "HW".
+
+ """
+ def __init__(self, type, min_count, drop_count, category = None, section_type = None, short_label = None):
+ self.type = type
+ self.min_count = min_count
+ self.drop_count = drop_count
+ self.category = category or self.type
+ self.section_type = section_type or self.type
+ self.short_label = short_label or self.type
+
+ def grade(self, grade_sheet):
+ def totalWithDrops(breakdown, drop_count):
+ #create an array of tuples with (index, mark), sorted by mark['percent'] descending
+ sorted_breakdown = sorted( enumerate(breakdown), key=lambda x: -x[1]['percent'] )
+ # A list of the indices of the dropped scores
+ dropped_indices = []
+ if drop_count > 0:
+ dropped_indices = [x[0] for x in sorted_breakdown[-drop_count:]]
+ aggregate_score = 0
+ for index, mark in enumerate(breakdown):
+ if index not in dropped_indices:
+ aggregate_score += mark['percent']
+
+ if (len(breakdown) - drop_count > 0):
+ aggregate_score /= len(breakdown) - drop_count
+
+ return aggregate_score, dropped_indices
+
+ #Figure the homework scores
+ scores = grade_sheet.get(self.type, [])
+ breakdown = []
+ for i in range( max(self.min_count, len(scores)) ):
+ if i < len(scores):
+ percentage = scores[i].earned / float(scores[i].possible)
+ summary = "{section_type} {index} - {name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format(index = i+1,
+ section_type = self.section_type,
+ name = scores[i].section,
+ percent = percentage,
+ earned = float(scores[i].earned),
+ possible = float(scores[i].possible) )
+ else:
+ percentage = 0
+ summary = "{section_type} {index} Unreleased - 0% (?/?)".format(index = i+1, section_type = self.section_type)
+
+ if settings.GENERATE_PROFILE_SCORES:
+ points_possible = random.randrange(10, 50)
+ points_earned = random.randrange(5, points_possible)
+ percentage = points_earned / float(points_possible)
+ summary = "{section_type} {index} - {name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format(index = i+1,
+ section_type = self.section_type,
+ name = "Randomly Generated",
+ percent = percentage,
+ earned = float(points_earned),
+ possible = float(points_possible) )
+
+ short_label = "{short_label} {index:02d}".format(index = i+1, short_label = self.short_label)
+
+ breakdown.append( {'percent': percentage, 'label': short_label, 'detail': summary, 'category': self.category} )
+
+ total_percent, dropped_indices = totalWithDrops(breakdown, self.drop_count)
+
+ for dropped_index in dropped_indices:
+ breakdown[dropped_index]['mark'] = {'detail': "The lowest {drop_count} {section_type} scores are dropped.".format(drop_count = self.drop_count, section_type=self.section_type) }
+
+
+ total_detail = "{section_type} Average = {percent:.0%}".format(percent = total_percent, section_type = self.section_type)
+ total_label = "{short_label} Avg".format(short_label = self.short_label)
+ breakdown.append( {'percent': total_percent, 'label': total_label, 'detail': total_detail, 'category': self.category, 'prominent': True} )
+
+
+ return {'percent' : total_percent,
+ 'section_breakdown' : breakdown,
+ #No grade_breakdown here
+ }
diff --git a/djangoapps/courseware/grades.py b/djangoapps/courseware/grades.py
index 68e0f11dcd..2013dc28b5 100644
--- a/djangoapps/courseware/grades.py
+++ b/djangoapps/courseware/grades.py
@@ -1,52 +1,73 @@
+"""
+Course settings module. The settings are based of django.conf. All settings in
+courseware.global_course_settings are first applied, and then any settings
+in the settings.DATA_DIR/course_settings.py are applied. A setting must be
+in ALL_CAPS.
+
+Settings are used by calling
+
+from courseware import course_settings
+
+Note that courseware.course_settings is not a module -- it's an object. So
+importing individual settings is not possible:
+
+from courseware.course_settings import GRADER # This won't work.
+
+"""
+
+from lxml import etree
+import random
+import imp
+import logging
+import sys
+import types
+
+from django.conf import settings
+
+from courseware import global_course_settings
+from courseware import graders
+from courseware.graders import Score
+from models import StudentModule
import courseware.content_parser as content_parser
import courseware.modules
-import logging
-import random
-import urllib
-from collections import namedtuple
-from django.conf import settings
-from lxml import etree
-from models import StudentModule
-from student.models import UserProfile
+_log = logging.getLogger("mitx.courseware")
-log = logging.getLogger("mitx.courseware")
-
-Score = namedtuple("Score", "earned possible weight graded section")
-
-def get_grade(user, problem, cache):
- ## HACK: assumes max score is fixed per problem
- id = problem.get('id')
- correct = 0
-
- # If the ID is not in the cache, add the item
- if id not in cache:
- module = StudentModule(module_type = 'problem', # TODO: Move into StudentModule.__init__?
- module_id = id,
- student = user,
- state = None,
- grade = 0,
- max_grade = None,
- done = 'i')
- cache[id] = module
-
- # Grab the # correct from cache
- if id in cache:
- response = cache[id]
- if response.grade!=None:
- correct=response.grade
+class Settings(object):
+ def __init__(self):
+ # update this dict from global settings (but only for ALL_CAPS settings)
+ for setting in dir(global_course_settings):
+ if setting == setting.upper():
+ setattr(self, setting, getattr(global_course_settings, setting))
- # Grab max grade from cache, or if it doesn't exist, compute and save to DB
- if id in cache and response.max_grade != None:
- total = response.max_grade
- else:
- total=courseware.modules.capa_module.Module(etree.tostring(problem), "id").max_score()
- response.max_grade = total
- response.save()
+
+ data_dir = settings.DATA_DIR
+
+ fp = None
+ try:
+ fp, pathname, description = imp.find_module("course_settings", [data_dir])
+ mod = imp.load_module("course_settings", fp, pathname, description)
+ except Exception as e:
+ _log.exception("Unable to import course settings file from " + data_dir + ". Error: " + str(e))
+ mod = types.ModuleType('course_settings')
+ finally:
+ if fp:
+ fp.close()
+
+ for setting in dir(mod):
+ if setting == setting.upper():
+ setting_value = getattr(mod, setting)
+ setattr(self, setting, setting_value)
+
+ # Here is where we should parse any configurations, so that we can fail early
+ self.GRADER = graders.grader_from_conf(self.GRADER)
- return (correct, total)
+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:
@@ -54,11 +75,9 @@ def grade_sheet(student):
each containing an array of sections, each containing an array of scores. This contains information for graded and ungraded
problems, and is good for displaying a course summary with due dates, etc.
- - grade_summary is a summary of how the final grade breaks down. It is an array of "sections". Each section can either be
- a conglomerate of scores (like labs or homeworks) which has subscores and a totalscore, or a section can be all from one assignment
- (such as a midterm or final) and only has a totalscore. Each section has a weight that shows how it contributes to the total grade.
+ - 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)
@@ -68,7 +87,6 @@ def grade_sheet(student):
response_by_id[response.module_id] = response
-
totaled_scores = {}
chapters=[]
for c in xmlChapters:
@@ -85,33 +103,29 @@ def grade_sheet(student):
scores=[]
if len(problems)>0:
for p in problems:
- (correct,total) = get_grade(student, p, response_by_id)
- # id = p.get('id')
- # correct = 0
- # if id in response_by_id:
- # response = response_by_id[id]
- # if response.grade!=None:
- # correct=response.grade
-
- # total=courseware.modules.capa_module.Module(etree.tostring(p), "id").max_score() # TODO: Add state. Not useful now, but maybe someday problems will have randomized max scores?
- # print correct, total
+ (correct,total) = get_score(student, p, response_by_id, coursename=coursename)
+
if settings.GENERATE_PROFILE_SCORES:
if total > 1:
correct = random.randrange( max(total-2, 1) , total + 1 )
else:
correct = total
- scores.append( Score(int(correct),total, float(p.get("weight", total)), graded, p.get("name")) )
+
+ if not total > 0:
+ #We simply cannot grade a problem that is 12/0, because we might need it as a percentage
+ graded = False
+ scores.append( Score(correct,total, graded, p.get("name")) )
- section_total, graded_total = aggregate_scores(scores)
+ section_total, graded_total = aggregate_scores(scores, s.get("name"))
#Add the graded total to totaled_scores
- format = s.get('format') if s.get('format') else ""
- subtitle = s.get('subtitle') if s.get('subtitle') else format
+ format = s.get('format', "")
+ subtitle = s.get('subtitle', format)
if format and graded_total[1] > 0:
format_scores = totaled_scores.get(format, [])
format_scores.append( graded_total )
totaled_scores[ format ] = format_scores
- score={'section':s.get("name"),
+ section_score={'section':s.get("name"),
'scores':scores,
'section_total' : section_total,
'format' : format,
@@ -119,154 +133,79 @@ def grade_sheet(student):
'due' : s.get("due") or "",
'graded' : graded,
}
- sections.append(score)
+ sections.append(section_score)
chapters.append({'course':course,
'chapter' : c.get("name"),
'sections' : sections,})
- grade_summary = grade_summary_6002x(totaled_scores)
- return {'courseware_summary' : chapters, #all assessments as they appear in the course definition
- 'grade_summary' : grade_summary, #graded assessments only
- }
-
-def aggregate_scores(scores):
- scores = filter( lambda score: score.possible > 0, scores )
+
+ grader = course_settings.GRADER
+ grade_summary = grader.grade(totaled_scores)
- total_correct_graded = sum((score.earned*1.0/score.possible)*score.weight for score in scores if score.graded)
- total_possible_graded = sum(score.weight for score in scores if score.graded)
- total_correct = sum((score.earned*1.0/score.possible)*score.weight for score in scores)
- total_possible = sum(score.weight for score in scores)
+ return {'courseware_summary' : chapters,
+ 'grade_summary' : grade_summary}
+
+def aggregate_scores(scores, section_name = "summary"):
+ total_correct_graded = sum(score.earned for score in scores if score.graded)
+ total_possible_graded = sum(score.possible for score in scores if score.graded)
+
+ total_correct = sum(score.earned for score in scores)
+ total_possible = sum(score.possible for score in scores)
+
#regardless of whether or not it is graded
all_total = Score(total_correct,
total_possible,
- 1,
False,
- "summary")
+ section_name)
#selecting only graded things
graded_total = Score(total_correct_graded,
total_possible_graded,
- 1,
True,
- "summary")
+ section_name)
return all_total, graded_total
+
-def grade_summary_6002x(totaled_scores):
- """
- This function takes the a dictionary of (graded) section scores, and applies the course grading rules to create
- the grade_summary. For 6.002x this means homeworks and labs all have equal weight, with the lowest 2 of each
- being dropped. There is one midterm and one final.
- """
+def get_score(user, problem, cache, coursename=None):
+ ## HACK: assumes max score is fixed per problem
+ id = problem.get('id')
+ correct = 0.0
- def totalWithDrops(scores, drop_count):
- #Note that this key will sort the list descending
- sorted_scores = sorted( enumerate(scores), key=lambda x: -x[1]['percentage'] )
- # A list of the indices of the dropped scores
- dropped_indices = [score[0] for score in sorted_scores[-drop_count:]]
- aggregate_score = 0
- for index, score in enumerate(scores):
- if index not in dropped_indices:
- aggregate_score += score['percentage']
+ # If the ID is not in the cache, add the item
+ if id not in cache:
+ module = StudentModule(module_type = 'problem', # TODO: Move into StudentModule.__init__?
+ module_id = id,
+ student = user,
+ state = None,
+ grade = 0,
+ max_grade = None,
+ done = 'i')
+ cache[id] = module
+
+ # Grab the # correct from cache
+ if id in cache:
+ response = cache[id]
+ if response.grade!=None:
+ correct=float(response.grade)
- aggregate_score /= len(scores) - drop_count
+ # Grab max grade from cache, or if it doesn't exist, compute and save to DB
+ if id in cache and response.max_grade != None:
+ total = response.max_grade
+ else:
+ ## HACK 1: We shouldn't specifically reference capa_module
+ ## HACK 2: Backwards-compatibility: This should be written when a grade is saved, and removed from the system
+ from module_render import I4xSystem
+ 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()
- return aggregate_score, dropped_indices
-
- #Figure the homework scores
- homework_scores = totaled_scores['Homework'] if 'Homework' in totaled_scores else []
- homework_percentages = []
- for i in range(12):
- if i < len(homework_scores):
- percentage = homework_scores[i].earned / float(homework_scores[i].possible)
- summary = "Homework {0} - {1} - {2:.0%} ({3:g}/{4:g})".format( i + 1, homework_scores[i].section , percentage, homework_scores[i].earned, homework_scores[i].possible )
- else:
- percentage = 0
- summary = "Unreleased Homework {0} - 0% (?/?)".format(i + 1)
-
- if settings.GENERATE_PROFILE_SCORES:
- points_possible = random.randrange(10, 50)
- points_earned = random.randrange(5, points_possible)
- percentage = points_earned / float(points_possible)
- summary = "Random Homework - {0:.0%} ({1:g}/{2:g})".format( percentage, points_earned, points_possible )
-
- label = "HW {0:02d}".format(i + 1)
-
- homework_percentages.append( {'percentage': percentage, 'summary': summary, 'label' : label} )
- homework_total, homework_dropped_indices = totalWithDrops(homework_percentages, 2)
-
- #Figure the lab scores
- lab_scores = totaled_scores['Lab'] if 'Lab' in totaled_scores else []
- lab_percentages = []
- for i in range(12):
- if i < len(lab_scores):
- percentage = lab_scores[i].earned / float(lab_scores[i].possible)
- summary = "Lab {0} - {1} - {2:.0%} ({3:g}/{4:g})".format( i + 1, lab_scores[i].section , percentage, lab_scores[i].earned, lab_scores[i].possible )
- else:
- percentage = 0
- summary = "Unreleased Lab {0} - 0% (?/?)".format(i + 1)
-
- if settings.GENERATE_PROFILE_SCORES:
- points_possible = random.randrange(10, 50)
- points_earned = random.randrange(5, points_possible)
- percentage = points_earned / float(points_possible)
- summary = "Random Lab - {0:.0%} ({1:g}/{2:g})".format( percentage, points_earned, points_possible )
-
- label = "Lab {0:02d}".format(i + 1)
-
- lab_percentages.append( {'percentage': percentage, 'summary': summary, 'label' : label} )
- lab_total, lab_dropped_indices = totalWithDrops(lab_percentages, 2)
-
-
- #TODO: Pull this data about the midterm and final from the databse. It should be exactly similar to above, but we aren't sure how exams will be done yet.
- #This is a hack, but I have no intention of having this function be useful for anything but 6.002x anyway, so I don't want to make it pretty.
- midterm_score = totaled_scores['Midterm'][0] if 'Midterm' in totaled_scores else Score('?', '?', '?', True, "?")
- midterm_percentage = midterm_score.earned * 1.0 / midterm_score.possible if 'Midterm' in totaled_scores else 0
-
- final_score = totaled_scores['Final'][0] if 'Final' in totaled_scores else Score('?', '?', '?', True, "?")
- final_percentage = final_score.earned * 1.0 / final_score.possible if 'Final' in totaled_scores else 0
-
- if settings.GENERATE_PROFILE_SCORES:
- midterm_score = Score(random.randrange(50, 150), 150, 150, True, "?")
- midterm_percentage = midterm_score.earned / float(midterm_score.possible)
-
- final_score = Score(random.randrange(100, 300), 300, 300, True, "?")
- final_percentage = final_score.earned / float(final_score.possible)
-
-
- grade_summary = [
- {
- 'category': 'Homework',
- 'subscores' : homework_percentages,
- 'dropped_indices' : homework_dropped_indices,
- 'totalscore' : homework_total,
- 'totalscore_summary' : "Homework Average - {0:.0%}".format(homework_total),
- 'totallabel' : 'HW Avg',
- 'weight' : 0.15,
- },
- {
- 'category': 'Labs',
- 'subscores' : lab_percentages,
- 'dropped_indices' : lab_dropped_indices,
- 'totalscore' : lab_total,
- 'totalscore_summary' : "Lab Average - {0:.0%}".format(lab_total),
- 'totallabel' : 'Lab Avg',
- 'weight' : 0.15,
- },
- {
- 'category': 'Midterm',
- 'totalscore' : midterm_percentage,
- 'totalscore_summary' : "Midterm - {0:.0%} ({1}/{2})".format(midterm_percentage, midterm_score.earned, midterm_score.possible),
- 'totallabel' : 'Midterm',
- 'weight' : 0.30,
- },
- {
- 'category': 'Final',
- 'totalscore' : final_percentage,
- 'totalscore_summary' : "Final - {0:.0%} ({1}/{2})".format(final_percentage, final_score.earned, final_score.possible),
- 'totallabel' : 'Final',
- 'weight' : 0.40,
- }
- ]
-
- return grade_summary
+ #Now we re-weight the problem, if specified
+ weight = problem.get("weight", None)
+ if weight:
+ weight = float(weight)
+ correct = correct * weight / total
+ total = weight
+
+ return (correct, total)
diff --git a/djangoapps/courseware/management/commands/check_course.py b/djangoapps/courseware/management/commands/check_course.py
index 4d0b9840ab..2f069ee5f3 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 mitx.courseware.content_parser import course_file
-import mitx.courseware.module_render
-import mitx.courseware.modules
+from courseware.content_parser import course_file
+import courseware.module_render
+import 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=mitx.courseware.modules.modx_modules[module.tag]
+ module_class = courseware.modules.modx_modules[module.tag]
# TODO: Abstract this out in render_module.py
try:
- 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'})
+ 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 38f9e0211b..0e317f7004 100644
--- a/djangoapps/courseware/module_render.py
+++ b/djangoapps/courseware/module_render.py
@@ -1,35 +1,44 @@
-import StringIO
-import json
import logging
-import os
-import sys
-import sys
-import urllib
-import uuid
from lxml import etree
-from django.conf import settings
-from django.contrib.auth.models import User
-from django.core.context_processors import csrf
-from django.db import connection
from django.http import Http404
from django.http import HttpResponse
from django.shortcuts import redirect
from django.template import Context
from django.template import Context, loader
-from mitxmako.shortcuts import render_to_response, render_to_string
+
+from fs.osfs import OSFS
+
+from django.conf import settings
+from mitxmako.shortcuts import render_to_string
+
from models import StudentModule
-from student.models import UserProfile
import track.views
-import courseware.content_parser as content_parser
-
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
+ if not filestore:
+ 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
# Additional lookup would require a DB hit the way Django
@@ -51,60 +60,19 @@ def make_track_function(request):
return track.views.server_track(request, event_type, event, page='x_module')
return f
-def modx_dispatch(request, module=None, dispatch=None, id=None):
- ''' Generic view for extensions. '''
- if not request.user.is_authenticated():
- return redirect('/')
-
- # Grab the student information for the module from the database
- s = StudentModule.objects.filter(student=request.user,
- module_id=id)
- #s = StudentModule.get_with_caching(request.user, id)
- if len(s) == 0 or s is None:
- log.debug("Couldnt find module for user and id " + str(module) + " " + str(request.user) + " "+ str(id))
- raise Http404
- s = s[0]
-
- oldgrade = s.grade
- oldstate = s.state
-
- dispatch=dispatch.split('?')[0]
-
- ajax_url = '/modx/'+module+'/'+id+'/'
-
- # Grab the XML corresponding to the request from course.xml
- xml = content_parser.module_xml(request.user, module, 'id', id)
-
- # Create the module
- instance=courseware.modules.get_module_class(module)(xml,
- id,
- ajax_url=ajax_url,
- state=oldstate,
- track_function = make_track_function(request),
- render_function = None)
- # Let the module handle the AJAX
- ajax_return=instance.handle_ajax(dispatch, request.POST)
- # Save the state back to the database
- s.state=instance.get_state()
- if instance.get_score():
- s.grade=instance.get_score()['score']
- if s.grade != oldgrade or s.state != oldstate:
- s.save()
- # Return whatever the module wanted to return to the client/caller
- return HttpResponse(ajax_return)
-
def grade_histogram(module_id):
''' Print out a histogram of grades on a given problem.
Part of staff member debug info.
'''
- from django.db import connection, transaction
+ from django.db import connection
cursor = connection.cursor()
cursor.execute("select courseware_studentmodule.grade,COUNT(courseware_studentmodule.student_id) from courseware_studentmodule where courseware_studentmodule.module_id=%s group by courseware_studentmodule.grade", [module_id])
grades = list(cursor.fetchall())
- print grades
grades.sort(key=lambda x:x[0]) # Probably not necessary
+ if (len(grades) == 1 and grades[0][0] == None):
+ return []
return grades
def render_x_module(user, request, xml_module, module_object_preload):
@@ -125,31 +93,51 @@ 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 = '/modx/'+module_type+'/'+module_id+'/'
- instance=module_class(etree.tostring(xml_module),
+ ajax_url = settings.MITX_ROOT_URL + '/modx/'+module_type+'/'+module_id+'/'
+ system = I4xSystem(track_function = make_track_function(request),
+ render_function = lambda x: render_module(user, request, x, module_object_preload),
+ ajax_url = ajax_url,
+ filestore = None
+ )
+ instance=module_class(system,
+ etree.tostring(xml_module),
module_id,
- ajax_url=ajax_url,
- state=state,
- track_function = make_track_function(request),
- render_function = lambda x: render_module(user, request, x, module_object_preload))
+ state=state)
- # If instance wasn't already in the database, create it
- if not smod:
+ # If instance wasn't already in the database, and this
+ # isn't a guest user, create it
+ if not smod and user.is_authenticated():
smod=StudentModule(student=user,
module_type = module_type,
module_id=module_id,
state=instance.get_state())
smod.save()
module_object_preload.append(smod)
+
# Grab content
content = instance.get_html()
+ init_js = instance.get_init_js()
+ destory_js = instance.get_destroy_js()
+
+ # special extra information about each problem, only for users who are staff
if user.is_staff:
+ histogram = grade_histogram(module_id)
+ render_histogram = len(histogram) > 0
content=content+render_to_string("staff_problem_info.html", {'xml':etree.tostring(xml_module),
- 'histogram':grade_histogram(module_id)})
+ 'module_id' : module_id,
+ 'render_histogram' : render_histogram})
+ if render_histogram:
+ init_js = init_js+render_to_string("staff_problem_histogram.js", {'histogram' : histogram,
+ 'module_id' : module_id})
+
content = {'content':content,
- "destroy_js":instance.get_destroy_js(),
- 'init_js':instance.get_init_js(),
+ "destroy_js":destory_js,
+ 'init_js':init_js,
'type':module_type}
return content
diff --git a/djangoapps/courseware/modules/capa_module.py b/djangoapps/courseware/modules/capa_module.py
index 86958dcf1c..0b75faa491 100644
--- a/djangoapps/courseware/modules/capa_module.py
+++ b/djangoapps/courseware/modules/capa_module.py
@@ -16,13 +16,12 @@ import traceback
from lxml import etree
## TODO: Abstract out from Django
-from django.conf import settings
-from mitxmako.shortcuts import render_to_response, render_to_string
-from django.http import Http404
+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")
@@ -92,10 +91,13 @@ class Module(XModule):
# User submitted a problem, and hasn't reset. We don't want
# more submissions.
if self.lcp.done and self.rerandomize == "always":
- #print "!"
check_button = False
save_button = False
+ # Only show the reset button if pressing it will show different values
+ if self.rerandomize != 'always':
+ reset_button = False
+
# User hasn't submitted an answer yet -- we don't want resets
if not self.lcp.done:
reset_button = False
@@ -114,25 +116,26 @@ 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 = '
'.format(id=self.item_id)+html+"
"
return html
- def __init__(self, xml, item_id, ajax_url=None, track_url=None, state=None, track_function=None, render_function = None, meta = None):
- XModule.__init__(self, xml, item_id, ajax_url, track_url, state, track_function, render_function)
+ def __init__(self, system, xml, item_id, state=None):
+ XModule.__init__(self, system, xml, item_id, state)
self.attempts = 0
self.max_attempts = None
@@ -185,17 +188,24 @@ class Module(XModule):
if state!=None and 'attempts' in state:
self.attempts=state['attempts']
- self.filename=content_parser.item(dom2.xpath('/problem/@filename'))
- filename=settings.DATA_DIR+"/problems/"+self.filename+".xml"
+ self.filename="problems/"+content_parser.item(dom2.xpath('/problem/@filename'))+".xml"
self.name=content_parser.item(dom2.xpath('/problem/@name'))
self.weight=content_parser.item(dom2.xpath('/problem/@weight'))
if self.rerandomize == 'never':
seed = 1
else:
seed = None
- self.lcp=LoncapaProblem(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):
+ '''
+ This is called by courseware.module_render, to handle an AJAX call. "get" is request.POST
+ '''
if dispatch=='problem_get':
response = self.get_problem(get)
elif False: #self.close_date >
@@ -241,16 +251,22 @@ class Module(XModule):
return True
if self.show_answer == 'closed' and not self.closed():
return False
- print "aa", self.show_answer
- raise Http404
+ if self.show_answer == 'always':
+ return True
+ raise self.system.exception404 #TODO: Not 404
def get_answer(self, get):
- if not self.answer_available():
- raise Http404
- else:
- return json.dumps(self.lcp.get_question_answers(),
- cls=ComplexEncoder)
+ '''
+ For the "show answer" button.
+ TODO: show answer events should be logged here, not just in the problem.js
+ '''
+ if not self.answer_available():
+ raise self.system.exception404
+ else:
+ answers = self.lcp.get_question_answers()
+ return json.dumps(answers,
+ cls=ComplexEncoder)
# Figure out if we should move these to capa_problem?
def get_problem(self, get):
@@ -265,66 +281,56 @@ class Module(XModule):
event_info['state'] = self.lcp.get_state()
event_info['filename'] = self.filename
+ # make a dict of all the student responses ("answers").
answers=dict()
# input_resistor_1 ==> resistor_1
for key in get:
answers['_'.join(key.split('_')[1:])]=get[key]
-# print "XXX", answers, get
-
event_info['answers']=answers
# Too late. Cannot submit
if self.closed():
event_info['failure']='closed'
self.tracker('save_problem_check_fail', event_info)
- print "cp"
- raise Http404
+ raise self.system.exception404
# Problem submitted. Student should reset before checking
# again.
if self.lcp.done and self.rerandomize == "always":
event_info['failure']='unreset'
self.tracker('save_problem_check_fail', event_info)
- print "cpdr"
- raise Http404
+ raise self.system.exception404
try:
old_state = self.lcp.get_state()
lcp_id = self.lcp.problem_id
- filename = self.lcp.filename
correct_map = self.lcp.grade_answers(answers)
except StudentInputError as inst:
- self.lcp = LoncapaProblem(filename, id=lcp_id, state=old_state)
+ self.lcp = LoncapaProblem(self.filestore.open(self.filename), id=lcp_id, state=old_state)
traceback.print_exc()
-# print {'error':sys.exc_info(),
-# 'answers':answers,
-# 'seed':self.lcp.seed,
-# 'filename':self.lcp.filename}
return json.dumps({'success':inst.message})
except:
- self.lcp = LoncapaProblem(filename, id=lcp_id, state=old_state)
+ self.lcp = LoncapaProblem(self.filestore.open(self.filename), id=lcp_id, state=old_state)
traceback.print_exc()
+ raise Exception,"error in capa_module"
return json.dumps({'success':'Unknown Error'})
-
self.attempts = self.attempts + 1
self.lcp.done=True
-
+
success = 'correct'
for i in correct_map:
if correct_map[i]!='correct':
success = 'incorrect'
- js=json.dumps({'correct_map' : correct_map,
- 'success' : success})
-
event_info['correct_map']=correct_map
event_info['success']=success
self.tracker('save_problem_check', event_info)
- return js
+ return json.dumps({'success': success,
+ 'contents': self.get_problem_html(encapsulate=False)})
def save_problem(self, get):
event_info = dict()
@@ -382,8 +388,7 @@ class Module(XModule):
self.lcp.questions=dict() # Detailed info about questions in problem instance. TODO: Should be by id and not lid.
self.lcp.seed=None
- filename=settings.DATA_DIR+"problems/"+self.filename+".xml"
- self.lcp=LoncapaProblem(filename, self.item_id, self.lcp.get_state())
+ self.lcp=LoncapaProblem(self.filestore.open(self.filename), self.item_id, self.lcp.get_state())
event_info['new_state']=self.lcp.get_state()
self.tracker('reset_problem', event_info)
diff --git a/djangoapps/courseware/modules/html_module.py b/djangoapps/courseware/modules/html_module.py
index 4194f73e74..77bcbb4bbc 100644
--- a/djangoapps/courseware/modules/html_module.py
+++ b/djangoapps/courseware/modules/html_module.py
@@ -1,7 +1,5 @@
import json
-## TODO: Abstract out from Django
-from django.conf import settings
from mitxmako.shortcuts import render_to_response, render_to_string
from x_module import XModule
@@ -24,13 +22,13 @@ class Module(XModule):
textlist=[i for i in textlist if type(i)==str]
return "".join(textlist)
try:
- filename=settings.DATA_DIR+"html/"+self.filename
- return open(filename).read()
+ filename="html/"+self.filename
+ return self.filestore.open(filename).read()
except: # For backwards compatibility. TODO: Remove
return render_to_string(self.filename, {'id': self.item_id})
- def __init__(self, xml, item_id, ajax_url=None, track_url=None, state=None, track_function=None, render_function = None):
- XModule.__init__(self, xml, item_id, ajax_url, track_url, state, track_function, render_function)
+ def __init__(self, system, xml, item_id, state=None):
+ XModule.__init__(self, system, xml, item_id, state)
xmltree=etree.fromstring(xml)
self.filename = None
filename_l=xmltree.xpath("/html/@filename")
diff --git a/djangoapps/courseware/modules/schematic_module.py b/djangoapps/courseware/modules/schematic_module.py
index e253f1acc6..5fef265e01 100644
--- a/djangoapps/courseware/modules/schematic_module.py
+++ b/djangoapps/courseware/modules/schematic_module.py
@@ -19,6 +19,6 @@ class Module(XModule):
def get_html(self):
return ''.format(item_id=self.item_id)
- def __init__(self, xml, item_id, ajax_url=None, track_url=None, state=None, render_function = None):
- XModule.__init__(self, xml, item_id, ajax_url, track_url, state, render_function)
+ def __init__(self, system, xml, item_id, state=None):
+ XModule.__init__(self, system, xml, item_id, state)
diff --git a/djangoapps/courseware/modules/seq_module.py b/djangoapps/courseware/modules/seq_module.py
index 02796a9768..00350f0c62 100644
--- a/djangoapps/courseware/modules/seq_module.py
+++ b/djangoapps/courseware/modules/seq_module.py
@@ -2,10 +2,7 @@ import json
from lxml import etree
-## TODO: Abstract out from Django
-from django.http import Http404
-from django.conf import settings
-from mitxmako.shortcuts import render_to_response, render_to_string
+from mitxmako.shortcuts import render_to_string
from x_module import XModule
@@ -38,12 +35,10 @@ class Module(XModule):
return self.destroy_js
def handle_ajax(self, dispatch, get):
- print "GET", get
- print "DISPATCH", dispatch
if dispatch=='goto_position':
self.position = int(get['position'])
return json.dumps({'success':True})
- raise Http404()
+ raise self.system.exception404
def render(self):
if self.rendered:
@@ -107,14 +102,13 @@ class Module(XModule):
self.rendered = True
-
- def __init__(self, xml, item_id, ajax_url=None, track_url=None, state=None, track_function=None, render_function = None):
- XModule.__init__(self, xml, item_id, ajax_url, track_url, state, track_function, render_function)
- self.xmltree=etree.fromstring(xml)
+ def __init__(self, system, xml, item_id, state=None):
+ XModule.__init__(self, system, xml, item_id, state)
+ self.xmltree = etree.fromstring(xml)
self.position = 1
- if state!=None:
+ if state != None:
state = json.loads(state)
if 'position' in state: self.position = int(state['position'])
diff --git a/djangoapps/courseware/modules/template_module.py b/djangoapps/courseware/modules/template_module.py
index d9dbb613f0..41556eb9d4 100644
--- a/djangoapps/courseware/modules/template_module.py
+++ b/djangoapps/courseware/modules/template_module.py
@@ -14,16 +14,16 @@ class Module(XModule):
@classmethod
def get_xml_tags(c):
+ ## TODO: Abstract out from filesystem
tags = os.listdir(settings.DATA_DIR+'/custom_tags')
return tags
def get_html(self):
return self.html
- def __init__(self, xml, item_id, ajax_url=None, track_url=None, state=None, track_function=None, render_function = None):
- XModule.__init__(self, xml, item_id, ajax_url, track_url, state, track_function, render_function)
+ def __init__(self, system, xml, item_id, state=None):
+ XModule.__init__(self, system, xml, item_id, state)
xmltree = etree.fromstring(xml)
filename = xmltree.tag
params = dict(xmltree.items())
-# print params
self.html = render_to_string(filename, params, namespace = 'custom_tags')
diff --git a/djangoapps/courseware/modules/vertical_module.py b/djangoapps/courseware/modules/vertical_module.py
index c068cb9a76..f64e45fe7f 100644
--- a/djangoapps/courseware/modules/vertical_module.py
+++ b/djangoapps/courseware/modules/vertical_module.py
@@ -1,7 +1,5 @@
import json
-## TODO: Abstract out from Django
-from django.conf import settings
from mitxmako.shortcuts import render_to_response, render_to_string
from x_module import XModule
@@ -26,8 +24,9 @@ class Module(XModule):
def get_destroy_js(self):
return self.destroy_js_text
- def __init__(self, xml, item_id, ajax_url=None, track_url=None, state=None, track_function=None, render_function = None):
- XModule.__init__(self, xml, item_id, ajax_url, track_url, state, track_function, render_function)
+
+ def __init__(self, system, xml, item_id, state=None):
+ XModule.__init__(self, system, xml, item_id, state)
xmltree=etree.fromstring(xml)
self.contents=[(e.get("name"),self.render_function(e)) \
for e in xmltree]
diff --git a/djangoapps/courseware/modules/video_module.py b/djangoapps/courseware/modules/video_module.py
index 2063c18953..c678838f2b 100644
--- a/djangoapps/courseware/modules/video_module.py
+++ b/djangoapps/courseware/modules/video_module.py
@@ -3,8 +3,6 @@ import logging
from lxml import etree
-## TODO: Abstract out from Django
-from django.conf import settings
from mitxmako.shortcuts import render_to_response, render_to_string
from x_module import XModule
@@ -42,7 +40,8 @@ class Module(XModule):
return render_to_string('video.html',{'streams':self.video_list(),
'id':self.item_id,
'position':self.position,
- 'name':self.name})
+ 'name':self.name,
+ 'annotations':self.annotations})
def get_init_js(self):
'''JavaScript code to be run when problem is shown. Be aware
@@ -52,19 +51,23 @@ class Module(XModule):
log.debug(u"INIT POSITION {0}".format(self.position))
return render_to_string('video_init.js',{'streams':self.video_list(),
'id':self.item_id,
- 'position':self.position})
+ 'position':self.position})+self.annotations_init
def get_destroy_js(self):
- return "videoDestroy(\"{0}\");".format(self.item_id)
+ return "videoDestroy(\"{0}\");".format(self.item_id)+self.annotations_destroy
- def __init__(self, xml, item_id, ajax_url=None, track_url=None, state=None, track_function=None, render_function = None):
- XModule.__init__(self, xml, item_id, ajax_url, track_url, state, track_function, render_function)
- self.youtube = etree.XML(xml).get('youtube')
- self.name = etree.XML(xml).get('name')
+ def __init__(self, system, xml, item_id, state=None):
+ XModule.__init__(self, system, xml, item_id, state)
+ xmltree=etree.fromstring(xml)
+ self.youtube = xmltree.get('youtube')
+ self.name = xmltree.get('name')
self.position = 0
if state != None:
state = json.loads(state)
if 'position' in state:
self.position = int(float(state['position']))
- #log.debug("POSITION IN STATE")
- #log.debug(u"LOAD POSITION {0}".format(self.position))
+
+ self.annotations=[(e.get("name"),self.render_function(e)) \
+ for e in xmltree]
+ self.annotations_init="".join([e[1]['init_js'] for e in self.annotations if 'init_js' in e[1]])
+ self.annotations_destroy="".join([e[1]['destroy_js'] for e in self.annotations if 'destroy_js' in e[1]])
diff --git a/djangoapps/courseware/modules/x_module.py b/djangoapps/courseware/modules/x_module.py
index 4a379253c4..9e70a16891 100644
--- a/djangoapps/courseware/modules/x_module.py
+++ b/djangoapps/courseware/modules/x_module.py
@@ -45,13 +45,17 @@ class XModule(object):
get is a dictionary-like object '''
return ""
- def __init__(self, xml, item_id, ajax_url=None, track_url=None, state=None, track_function=None, render_function = None):
+ def __init__(self, system, xml, item_id, track_url=None, state=None):
''' In most cases, you must pass state or xml'''
self.xml = xml
self.item_id = item_id
- self.ajax_url = ajax_url
- self.track_url = track_url
self.state = state
- self.tracker = track_function
- self.render_function = render_function
+ if system:
+ ## These are temporary; we really should go
+ ## through self.system.
+ self.ajax_url = system.ajax_url
+ self.tracker = system.track_function
+ self.filestore = system.filestore
+ self.render_function = system.render_function
+ self.system = system
diff --git a/djangoapps/courseware/test_files/imageresponse.xml b/djangoapps/courseware/test_files/imageresponse.xml
new file mode 100644
index 0000000000..72bf06401a
--- /dev/null
+++ b/djangoapps/courseware/test_files/imageresponse.xml
@@ -0,0 +1,15 @@
+
+
+Two skiers are on frictionless black diamond ski slopes.
+Hello
+
+
+
+Click on the image where the top skier will stop momentarily if the top skier starts from rest.
+
+Click on the image where the lower skier will stop momentarily if the lower skier starts from rest.
+
+
Use conservation of energy.
+
+
+
\ No newline at end of file
diff --git a/djangoapps/courseware/test_files/multi_bare.xml b/djangoapps/courseware/test_files/multi_bare.xml
new file mode 100644
index 0000000000..20bc8f853d
--- /dev/null
+++ b/djangoapps/courseware/test_files/multi_bare.xml
@@ -0,0 +1,21 @@
+
+
+
+
+ This is foil One.
+
+
+ This is foil Two.
+
+
+ This is foil Three.
+
+
+ This is foil Four.
+
+
+ This is foil Five.
+
+
+
+
diff --git a/djangoapps/courseware/test_files/multichoice.xml b/djangoapps/courseware/test_files/multichoice.xml
new file mode 100644
index 0000000000..60bf02ec59
--- /dev/null
+++ b/djangoapps/courseware/test_files/multichoice.xml
@@ -0,0 +1,21 @@
+
+
+
+
+ This is foil One.
+
+
+ This is foil Two.
+
+
+ This is foil Three.
+
+
+ This is foil Four.
+
+
+ This is foil Five.
+
+
+
+
diff --git a/djangoapps/courseware/test_files/optionresponse.xml b/djangoapps/courseware/test_files/optionresponse.xml
new file mode 100644
index 0000000000..99a17e8fac
--- /dev/null
+++ b/djangoapps/courseware/test_files/optionresponse.xml
@@ -0,0 +1,63 @@
+
+
+
+Why do bicycles benefit from having larger wheels when going up a bump as shown in the picture?
+Assume that for both bicycles:
+1.) The tires have equal air pressure.
+2.) The bicycles never leave the contact with the bump.
+3.) The bicycles have the same mass. The bicycle tires (regardless of size) have the same mass.
+
+
+
+
+
+
+
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.
+
+
+
+
+
+
+
The bicycles with larger wheels always have a smaller vertical displacement regardless of speed.
+
+
+
+
+
+
+
The bicycles with larger wheels experience a force backward with less magnitude for the same amount of time.
+
+
+
+
+
+
+
The bicycles with larger wheels experience a force backward with less magnitude for a greater amount of time.
+
+
+
+
+
+
+
The bicycles with larger wheels have more kinetic energy turned into gravitational potential energy.
+
+
+
+
+
+
+
The bicycles with larger wheels have more rotational kinetic energy, so the horizontal velocity of the biker changes less.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/djangoapps/courseware/test_files/truefalse.xml b/djangoapps/courseware/test_files/truefalse.xml
new file mode 100644
index 0000000000..60018f7a2d
--- /dev/null
+++ b/djangoapps/courseware/test_files/truefalse.xml
@@ -0,0 +1,21 @@
+
+
+
+
+ This is foil One.
+
+
+ This is foil Two.
+
+
+ This is foil Three.
+
+
+ This is foil Four.
+
+
+ This is foil Five.
+
+
+
+
diff --git a/djangoapps/courseware/tests.py b/djangoapps/courseware/tests.py
index 7eb6aa27de..682927efb7 100644
--- a/djangoapps/courseware/tests.py
+++ b/djangoapps/courseware/tests.py
@@ -1,10 +1,14 @@
import unittest
+import os
import numpy
import courseware.modules
import courseware.capa.calc as calc
-from grades import Score, aggregate_scores
+import courseware.capa.capa_problem as lcp
+import courseware.graders as graders
+from courseware.graders import Score, CourseGrader, WeightedSubsectionsGrader, SingleSectionGrader, AssignmentFormatGrader
+from courseware.grades import aggregate_scores
class ModelsTest(unittest.TestCase):
def setUp(self):
@@ -33,6 +37,11 @@ class ModelsTest(unittest.TestCase):
self.assertTrue(abs(calc.evaluator(variables, functions, "k*T/q-0.025"))<0.001)
self.assertTrue(abs(calc.evaluator(variables, functions, "e^(j*pi)")+1)<0.00001)
self.assertTrue(abs(calc.evaluator(variables, functions, "j||1")-0.5-0.5j)<0.00001)
+ variables['t'] = 1.0
+ self.assertTrue(abs(calc.evaluator(variables, functions, "t")-1.0)<0.00001)
+ self.assertTrue(abs(calc.evaluator(variables, functions, "T")-1.0)<0.00001)
+ self.assertTrue(abs(calc.evaluator(variables, functions, "t", cs=True)-1.0)<0.00001)
+ self.assertTrue(abs(calc.evaluator(variables, functions, "T", cs=True)-298)<0.2)
exception_happened = False
try:
calc.evaluator({},{}, "5+7 QWSEKO")
@@ -54,42 +63,284 @@ class ModelsTest(unittest.TestCase):
exception_happened = True
self.assertTrue(exception_happened)
-class GraderTest(unittest.TestCase):
+#-----------------------------------------------------------------------------
+# tests of capa_problem inputtypes
+
+class MultiChoiceTest(unittest.TestCase):
+ def test_MC_grade(self):
+ multichoice_file = os.path.dirname(__file__)+"/test_files/multichoice.xml"
+ test_lcp = lcp.LoncapaProblem(open(multichoice_file), '1')
+ correct_answers = {'1_2_1':'choice_foil3'}
+ self.assertEquals(test_lcp.grade_answers(correct_answers)['1_2_1'], 'correct')
+ false_answers = {'1_2_1':'choice_foil2'}
+ self.assertEquals(test_lcp.grade_answers(false_answers)['1_2_1'], 'incorrect')
+
+ def test_MC_bare_grades(self):
+ multichoice_file = os.path.dirname(__file__)+"/test_files/multi_bare.xml"
+ test_lcp = lcp.LoncapaProblem(open(multichoice_file), '1')
+ correct_answers = {'1_2_1':'choice_2'}
+ self.assertEquals(test_lcp.grade_answers(correct_answers)['1_2_1'], 'correct')
+ false_answers = {'1_2_1':'choice_1'}
+ self.assertEquals(test_lcp.grade_answers(false_answers)['1_2_1'], 'incorrect')
+
+ def test_TF_grade(self):
+ truefalse_file = os.getcwd()+"/djangoapps/courseware/test_files/truefalse.xml"
+ test_lcp = lcp.LoncapaProblem(open(truefalse_file), '1')
+ correct_answers = {'1_2_1':['choice_foil2', 'choice_foil1']}
+ self.assertEquals(test_lcp.grade_answers(correct_answers)['1_2_1'], 'correct')
+ false_answers = {'1_2_1':['choice_foil1']}
+ self.assertEquals(test_lcp.grade_answers(false_answers)['1_2_1'], 'incorrect')
+ false_answers = {'1_2_1':['choice_foil1', 'choice_foil3']}
+ self.assertEquals(test_lcp.grade_answers(false_answers)['1_2_1'], 'incorrect')
+ false_answers = {'1_2_1':['choice_foil3']}
+ 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):
def test_weighted_grading(self):
scores = []
Score.__sub__=lambda me, other: (me.earned - other.earned) + (me.possible - other.possible)
all, graded = aggregate_scores(scores)
- self.assertEqual(all, Score(earned=0, possible=0, weight=1, graded=False, section="summary"))
- self.assertEqual(graded, Score(earned=0, possible=0, weight=1, graded=True, section="summary"))
+ self.assertEqual(all, Score(earned=0, possible=0, graded=False, section="summary"))
+ self.assertEqual(graded, Score(earned=0, possible=0, graded=True, section="summary"))
- scores.append(Score(earned=0, possible=5, weight=1, graded=False, section="summary"))
+ scores.append(Score(earned=0, possible=5, graded=False, section="summary"))
all, graded = aggregate_scores(scores)
- self.assertEqual(all, Score(earned=0, possible=1, weight=1, graded=False, section="summary"))
- self.assertEqual(graded, Score(earned=0, possible=0, weight=1, graded=True, section="summary"))
+ self.assertEqual(all, Score(earned=0, possible=5, graded=False, section="summary"))
+ self.assertEqual(graded, Score(earned=0, possible=0, graded=True, section="summary"))
- scores.append(Score(earned=3, possible=5, weight=1, graded=True, section="summary"))
+ scores.append(Score(earned=3, possible=5, graded=True, section="summary"))
all, graded = aggregate_scores(scores)
- self.assertAlmostEqual(all, Score(earned=3.0/5, possible=2, weight=1, graded=False, section="summary"))
- self.assertAlmostEqual(graded, Score(earned=3.0/5, possible=1, weight=1, graded=True, section="summary"))
+ self.assertAlmostEqual(all, Score(earned=3, possible=10, graded=False, section="summary"))
+ self.assertAlmostEqual(graded, Score(earned=3, possible=5, graded=True, section="summary"))
- scores.append(Score(earned=2, possible=5, weight=2, graded=True, section="summary"))
+ scores.append(Score(earned=2, possible=5, graded=True, section="summary"))
all, graded = aggregate_scores(scores)
- self.assertAlmostEqual(all, Score(earned=7.0/5, possible=4, weight=1, graded=False, section="summary"))
- self.assertAlmostEqual(graded, Score(earned=7.0/5, possible=3, weight=1, graded=True, section="summary"))
+ self.assertAlmostEqual(all, Score(earned=5, possible=15, graded=False, section="summary"))
+ self.assertAlmostEqual(graded, Score(earned=5, possible=10, graded=True, section="summary"))
- scores.append(Score(earned=2, possible=5, weight=0, graded=True, section="summary"))
- all, graded = aggregate_scores(scores)
- self.assertAlmostEqual(all, Score(earned=7.0/5, possible=4, weight=1, graded=False, section="summary"))
- self.assertAlmostEqual(graded, Score(earned=7.0/5, possible=3, weight=1, graded=True, section="summary"))
+class GraderTest(unittest.TestCase):
- scores.append(Score(earned=2, possible=5, weight=3, graded=False, section="summary"))
- all, graded = aggregate_scores(scores)
- self.assertAlmostEqual(all, Score(earned=13.0/5, possible=7, weight=1, graded=False, section="summary"))
- self.assertAlmostEqual(graded, Score(earned=7.0/5, possible=3, weight=1, graded=True, section="summary"))
+ empty_gradesheet = {
+ }
+
+ incomplete_gradesheet = {
+ 'Homework': [],
+ 'Lab': [],
+ 'Midterm' : [],
+ }
+
+ test_gradesheet = {
+ 'Homework': [Score(earned=2, possible=20.0, graded=True, section='hw1'),
+ Score(earned=16, possible=16.0, graded=True, section='hw2')],
+ #The dropped scores should be from the assignments that don't exist yet
+
+ 'Lab': [Score(earned=1, possible=2.0, graded=True, section='lab1'), #Dropped
+ Score(earned=1, possible=1.0, graded=True, section='lab2'),
+ Score(earned=1, possible=1.0, graded=True, section='lab3'),
+ Score(earned=5, possible=25.0, graded=True, section='lab4'), #Dropped
+ Score(earned=3, possible=4.0, graded=True, section='lab5'), #Dropped
+ Score(earned=6, possible=7.0, graded=True, section='lab6'),
+ Score(earned=5, possible=6.0, graded=True, section='lab7')],
+
+ 'Midterm' : [Score(earned=50.5, possible=100, graded=True, section="Midterm Exam"),],
+ }
+
+ def test_SingleSectionGrader(self):
+ midtermGrader = graders.SingleSectionGrader("Midterm", "Midterm Exam")
+ lab4Grader = graders.SingleSectionGrader("Lab", "lab4")
+ badLabGrader = graders.SingleSectionGrader("Lab", "lab42")
+
+ for graded in [midtermGrader.grade(self.empty_gradesheet),
+ midtermGrader.grade(self.incomplete_gradesheet),
+ badLabGrader.grade(self.test_gradesheet)]:
+ self.assertEqual( len(graded['section_breakdown']), 1 )
+ self.assertEqual( graded['percent'], 0.0 )
+
+ graded = midtermGrader.grade(self.test_gradesheet)
+ self.assertAlmostEqual( graded['percent'], 0.505 )
+ self.assertEqual( len(graded['section_breakdown']), 1 )
+
+ graded = lab4Grader.grade(self.test_gradesheet)
+ self.assertAlmostEqual( graded['percent'], 0.2 )
+ self.assertEqual( len(graded['section_breakdown']), 1 )
+
+ def test_AssignmentFormatGrader(self):
+ homeworkGrader = graders.AssignmentFormatGrader("Homework", 12, 2)
+ noDropGrader = graders.AssignmentFormatGrader("Homework", 12, 0)
+ #Even though the minimum number is 3, this should grade correctly when 7 assignments are found
+ overflowGrader = graders.AssignmentFormatGrader("Lab", 3, 2)
+ labGrader = graders.AssignmentFormatGrader("Lab", 7, 3)
+
+
+ #Test the grading of an empty gradesheet
+ for graded in [ homeworkGrader.grade(self.empty_gradesheet),
+ noDropGrader.grade(self.empty_gradesheet),
+ homeworkGrader.grade(self.incomplete_gradesheet),
+ noDropGrader.grade(self.incomplete_gradesheet) ]:
+ self.assertAlmostEqual( graded['percent'], 0.0 )
+ #Make sure the breakdown includes 12 sections, plus one summary
+ self.assertEqual( len(graded['section_breakdown']), 12 + 1 )
+
+
+ graded = homeworkGrader.grade(self.test_gradesheet)
+ self.assertAlmostEqual( graded['percent'], 0.11 ) # 100% + 10% / 10 assignments
+ self.assertEqual( len(graded['section_breakdown']), 12 + 1 )
+
+ graded = noDropGrader.grade(self.test_gradesheet)
+ self.assertAlmostEqual( graded['percent'], 0.0916666666666666 ) # 100% + 10% / 12 assignments
+ self.assertEqual( len(graded['section_breakdown']), 12 + 1 )
+
+ graded = overflowGrader.grade(self.test_gradesheet)
+ self.assertAlmostEqual( graded['percent'], 0.8880952380952382 ) # 100% + 10% / 5 assignments
+ self.assertEqual( len(graded['section_breakdown']), 7 + 1 )
+
+ graded = labGrader.grade(self.test_gradesheet)
+ self.assertAlmostEqual( graded['percent'], 0.9226190476190477 )
+ self.assertEqual( len(graded['section_breakdown']), 7 + 1 )
+
+
+ def test_WeightedSubsectionsGrader(self):
+ #First, a few sub graders
+ homeworkGrader = graders.AssignmentFormatGrader("Homework", 12, 2)
+ labGrader = graders.AssignmentFormatGrader("Lab", 7, 3)
+ midtermGrader = graders.SingleSectionGrader("Midterm", "Midterm Exam")
+
+ weightedGrader = graders.WeightedSubsectionsGrader( [(homeworkGrader, homeworkGrader.category, 0.25), (labGrader, labGrader.category, 0.25),
+ (midtermGrader, midtermGrader.category, 0.5)] )
+
+ overOneWeightsGrader = graders.WeightedSubsectionsGrader( [(homeworkGrader, homeworkGrader.category, 0.5), (labGrader, labGrader.category, 0.5),
+ (midtermGrader, midtermGrader.category, 0.5)] )
+
+ #The midterm should have all weight on this one
+ zeroWeightsGrader = graders.WeightedSubsectionsGrader( [(homeworkGrader, homeworkGrader.category, 0.0), (labGrader, labGrader.category, 0.0),
+ (midtermGrader, midtermGrader.category, 0.5)] )
+
+ #This should always have a final percent of zero
+ allZeroWeightsGrader = graders.WeightedSubsectionsGrader( [(homeworkGrader, homeworkGrader.category, 0.0), (labGrader, labGrader.category, 0.0),
+ (midtermGrader, midtermGrader.category, 0.0)] )
+
+ emptyGrader = graders.WeightedSubsectionsGrader( [] )
+
+ graded = weightedGrader.grade(self.test_gradesheet)
+ self.assertAlmostEqual( graded['percent'], 0.5106547619047619 )
+ self.assertEqual( len(graded['section_breakdown']), (12 + 1) + (7+1) + 1 )
+ self.assertEqual( len(graded['grade_breakdown']), 3 )
+
+ graded = overOneWeightsGrader.grade(self.test_gradesheet)
+ self.assertAlmostEqual( graded['percent'], 0.7688095238095238 )
+ self.assertEqual( len(graded['section_breakdown']), (12 + 1) + (7+1) + 1 )
+ self.assertEqual( len(graded['grade_breakdown']), 3 )
+
+ graded = zeroWeightsGrader.grade(self.test_gradesheet)
+ self.assertAlmostEqual( graded['percent'], 0.2525 )
+ self.assertEqual( len(graded['section_breakdown']), (12 + 1) + (7+1) + 1 )
+ self.assertEqual( len(graded['grade_breakdown']), 3 )
+
+
+ graded = allZeroWeightsGrader.grade(self.test_gradesheet)
+ self.assertAlmostEqual( graded['percent'], 0.0 )
+ self.assertEqual( len(graded['section_breakdown']), (12 + 1) + (7+1) + 1 )
+ self.assertEqual( len(graded['grade_breakdown']), 3 )
+
+ for graded in [ weightedGrader.grade(self.empty_gradesheet),
+ weightedGrader.grade(self.incomplete_gradesheet),
+ zeroWeightsGrader.grade(self.empty_gradesheet),
+ allZeroWeightsGrader.grade(self.empty_gradesheet)]:
+ self.assertAlmostEqual( graded['percent'], 0.0 )
+ self.assertEqual( len(graded['section_breakdown']), (12 + 1) + (7+1) + 1 )
+ self.assertEqual( len(graded['grade_breakdown']), 3 )
+
+
+ graded = emptyGrader.grade(self.test_gradesheet)
+ self.assertAlmostEqual( graded['percent'], 0.0 )
+ self.assertEqual( len(graded['section_breakdown']), 0 )
+ self.assertEqual( len(graded['grade_breakdown']), 0 )
+
+
+
+ def test_graderFromConf(self):
+
+ #Confs always produce a graders.WeightedSubsectionsGrader, so we test this by repeating the test
+ #in test_graders.WeightedSubsectionsGrader, but generate the graders with confs.
+
+ weightedGrader = graders.grader_from_conf([
+ {
+ 'type' : "Homework",
+ 'min_count' : 12,
+ 'drop_count' : 2,
+ 'short_label' : "HW",
+ 'weight' : 0.25,
+ },
+ {
+ 'type' : "Lab",
+ 'min_count' : 7,
+ 'drop_count' : 3,
+ 'category' : "Labs",
+ 'weight' : 0.25
+ },
+ {
+ 'type' : "Midterm",
+ 'name' : "Midterm Exam",
+ 'short_label' : "Midterm",
+ 'weight' : 0.5,
+ },
+ ])
+
+ emptyGrader = graders.grader_from_conf([])
+
+ graded = weightedGrader.grade(self.test_gradesheet)
+ self.assertAlmostEqual( graded['percent'], 0.5106547619047619 )
+ self.assertEqual( len(graded['section_breakdown']), (12 + 1) + (7+1) + 1 )
+ self.assertEqual( len(graded['grade_breakdown']), 3 )
+
+ graded = emptyGrader.grade(self.test_gradesheet)
+ self.assertAlmostEqual( graded['percent'], 0.0 )
+ self.assertEqual( len(graded['section_breakdown']), 0 )
+ self.assertEqual( len(graded['grade_breakdown']), 0 )
+
+ #Test that graders can also be used instead of lists of dictionaries
+ homeworkGrader = graders.AssignmentFormatGrader("Homework", 12, 2)
+ homeworkGrader2 = graders.grader_from_conf(homeworkGrader)
+
+ graded = homeworkGrader2.grade(self.test_gradesheet)
+ self.assertAlmostEqual( graded['percent'], 0.11 )
+ self.assertEqual( len(graded['section_breakdown']), 12 + 1 )
+
+ #TODO: How do we test failure cases? The parser only logs an error when it can't parse something. Maybe it should throw exceptions?
- scores.append(Score(earned=2, possible=5, weight=.5, graded=True, section="summary"))
- all, graded = aggregate_scores(scores)
- self.assertAlmostEqual(all, Score(earned=14.0/5, possible=7.5, weight=1, graded=False, section="summary"))
- self.assertAlmostEqual(graded, Score(earned=8.0/5, possible=3.5, weight=1, graded=True, section="summary"))
diff --git a/djangoapps/courseware/views.py b/djangoapps/courseware/views.py
index f7d1ba9293..5f67e599c0 100644
--- a/djangoapps/courseware/views.py
+++ b/djangoapps/courseware/views.py
@@ -1,31 +1,25 @@
-import json
import logging
-import os
-import random
-import sys
-import StringIO
import urllib
-import uuid
from django.conf import settings
from django.core.context_processors import csrf
from django.contrib.auth.models import User
-from django.http import HttpResponse, Http404
+from django.contrib.auth.decorators import login_required
+from django.http import Http404, HttpResponse
from django.shortcuts import redirect
-from django.template import Context, loader
from mitxmako.shortcuts import render_to_response, render_to_string
#from django.views.decorators.csrf import ensure_csrf_cookie
-from django.db import connection
from django.views.decorators.cache import cache_control
from lxml import etree
-from module_render import render_module, modx_dispatch
+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.capa_module
+import courseware.modules
import courseware.grades as grades
@@ -40,22 +34,26 @@ 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]
return render_to_response('gradebook.html',{'students':student_info})
+@login_required
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def profile(request, student_id = None):
''' User profile. Show username, location, etc, as well as grades .
We need to allow the user to change some of these settings .'''
- if not request.user.is_authenticated():
- return redirect('/')
if student_id == None:
student = request.user
@@ -67,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,
@@ -75,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)
@@ -84,8 +85,8 @@ def render_accordion(request,course,chapter,section):
parameter. Returns (initialization_javascript, content)'''
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']:
@@ -96,19 +97,21 @@ def render_accordion(request,course,chapter,section):
['format_url_params',content_parser.format_url_params],
['csrf',csrf(request)['csrf_token']]] + \
template_imports.items())
- return {'init_js':render_to_string('accordion_init.js',context),
- 'content':render_to_string('accordion.html',context)}
+ return render_to_string('accordion.html',context)
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def render_section(request, section):
''' TODO: Consolidate with index
'''
user = request.user
- if not settings.COURSEWARE_ENABLED or not user.is_authenticated():
+ 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
@@ -116,16 +119,19 @@ def render_section(request, section):
module_ids = dom.xpath("//@id")
- module_object_preload = list(StudentModule.objects.filter(student=user,
- module_id__in=module_ids))
+ if user.is_authenticated():
+ module_object_preload = list(StudentModule.objects.filter(student=user,
+ module_id__in=module_ids))
+ else:
+ module_object_preload = []
module=render_module(user, request, dom, module_object_preload)
if 'init_js' not in module:
module['init_js']=''
- context={'init':accordion['init_js']+module['init_js'],
- 'accordion':accordion['content'],
+ context={'init':module['init_js'],
+ 'accordion':accordion,
'content':module['content'],
'csrf':csrf(request)['csrf_token']}
@@ -134,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 or not user.is_authenticated():
+ 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
@@ -148,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:
@@ -170,8 +186,11 @@ def index(request, course="6.002 Spring 2012", chapter="Using the System", secti
module_ids = dom.xpath("//course[@name=$course]/chapter[@name=$chapter]//section[@name=$section]//@id",
course=course, chapter=chapter, section=section)
- module_object_preload = list(StudentModule.objects.filter(student=user,
- module_id__in=module_ids))
+ if user.is_authenticated():
+ module_object_preload = list(StudentModule.objects.filter(student=user,
+ module_id__in=module_ids))
+ else:
+ module_object_preload = []
module=render_module(user, request, module, module_object_preload)
@@ -179,10 +198,156 @@ def index(request, course="6.002 Spring 2012", chapter="Using the System", secti
if 'init_js' not in module:
module['init_js']=''
- context={'init':accordion['init_js']+module['init_js'],
- 'accordion':accordion['content'],
+ 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)
return result
+
+
+def modx_dispatch(request, module=None, dispatch=None, id=None):
+ ''' Generic view for extensions. '''
+ if not request.user.is_authenticated():
+ return redirect('/')
+
+ # Grab the student information for the module from the database
+ s = StudentModule.objects.filter(student=request.user,
+ module_id=id)
+ #s = StudentModule.get_with_caching(request.user, id)
+ if len(s) == 0 or s is None:
+ log.debug("Couldnt find module for user and id " + str(module) + " " + str(request.user) + " "+ str(id))
+ raise Http404
+ s = s[0]
+
+ oldgrade = s.grade
+ oldstate = s.state
+
+ dispatch=dispatch.split('?')[0]
+
+ 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, coursename)
+
+ # Create the module
+ system = I4xSystem(track_function = make_track_function(request),
+ render_function = None,
+ ajax_url = ajax_url,
+ filestore = None
+ )
+ instance=courseware.modules.get_module_class(module)(system,
+ xml,
+ id,
+ state=oldstate)
+ # Let the module handle the AJAX
+ ajax_return=instance.handle_ajax(dispatch, request.POST)
+ # Save the state back to the database
+ s.state=instance.get_state()
+ if instance.get_score():
+ s.grade=instance.get_score()['score']
+ if s.grade != oldgrade or s.state != oldstate:
+ s.save()
+ # 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 '\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 \"%s\"" % err
+
+ if isok:
+ filename = instance.lcp.fileobject.name
+ fp = open(filename,'w') # TODO - replace with filestore call?
+ fp.write(newcode)
+ fp.close()
+ msg = "Problem changed! (%s)" % 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 0000000000..e69de29bb2
diff --git a/djangoapps/multicourse/multicourse_settings.py b/djangoapps/multicourse/multicourse_settings.py
new file mode 100644
index 0000000000..99c9ef8620
--- /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 0000000000..d0662b710e
--- /dev/null
+++ b/djangoapps/multicourse/views.py
@@ -0,0 +1 @@
+# multicourse/views.py
diff --git a/djangoapps/simplewiki/models.py b/djangoapps/simplewiki/models.py
index 71a57c601c..ade95ed491 100644
--- a/djangoapps/simplewiki/models.py
+++ b/djangoapps/simplewiki/models.py
@@ -9,7 +9,7 @@ from django.db.models import signals
from django.utils.translation import ugettext_lazy as _
from markdown import markdown
-from settings import *
+from wiki_settings import *
from util.cache import cache
diff --git a/djangoapps/simplewiki/templatetags/simplewiki_utils.py b/djangoapps/simplewiki/templatetags/simplewiki_utils.py
index 1534a7b401..18c6332b1a 100644
--- a/djangoapps/simplewiki/templatetags/simplewiki_utils.py
+++ b/djangoapps/simplewiki/templatetags/simplewiki_utils.py
@@ -3,7 +3,7 @@ from django.conf import settings
from django.template.defaultfilters import stringfilter
from django.utils.http import urlquote as django_urlquote
-from simplewiki.settings import *
+from simplewiki.wiki_settings import *
register = template.Library()
diff --git a/djangoapps/simplewiki/views.py b/djangoapps/simplewiki/views.py
index 7d743139ba..34a81e6b57 100644
--- a/djangoapps/simplewiki/views.py
+++ b/djangoapps/simplewiki/views.py
@@ -1,36 +1,29 @@
# -*- coding: utf-8 -*-
-import types
-
-from django.conf import settings
+from django.conf import settings as settings
from django.contrib.auth.decorators import login_required
from django.core.context_processors import csrf
-from django.core.urlresolvers import get_callable
from django.core.urlresolvers import reverse
from django.db.models import Q
-from django.http import Http404, HttpResponse, HttpResponseRedirect, HttpResponseServerError, HttpResponseForbidden, HttpResponseNotAllowed
-from django.http import HttpResponse
-from django.shortcuts import get_object_or_404
-from django.shortcuts import redirect
-from django.template import Context
-from django.template import RequestContext, Context, loader
+from django.http import HttpResponse, HttpResponseRedirect
from django.utils import simplejson
from django.utils.translation import ugettext_lazy as _
-from mitxmako.shortcuts import render_to_response, render_to_string
-from mako.lookup import TemplateLookup
-from mako.template import Template
-import mitxmako.middleware
+from mitxmako.shortcuts import render_to_response
-from models import * # TODO: Clean up
-from settings import *
+from multicourse import multicourse_settings
+
+from models import Revision, Article, CreateArticleForm, RevisionFormWithTitle, RevisionForm
+import wiki_settings
def view(request, wiki_url):
- if not request.user.is_authenticated():
- return redirect('/')
-
(article, path, err) = fetch_from_url(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
@@ -39,15 +32,12 @@ 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)
def view_revision(request, revision_number, wiki_url, revision=None):
- if not request.user.is_authenticated():
- return redirect('/')
-
(article, path, err) = fetch_from_url(request, wiki_url)
if err:
return err
@@ -76,8 +66,6 @@ def view_revision(request, revision_number, wiki_url, revision=None):
def root_redirect(request):
- if not request.user.is_authenticated():
- return redirect('/')
try:
root = Article.get_root()
except:
@@ -87,8 +75,6 @@ def root_redirect(request):
return HttpResponseRedirect(reverse('wiki_view', args=(root.get_url())))
def create(request, wiki_url):
- if not request.user.is_authenticated():
- return redirect('/')
url_path = get_url_path(wiki_url)
@@ -161,9 +147,6 @@ def create(request, wiki_url):
return render_to_response('simplewiki_edit.html', d)
def edit(request, wiki_url):
- if not request.user.is_authenticated():
- return redirect('/')
-
(article, path, err) = fetch_from_url(request, wiki_url)
if err:
return err
@@ -173,7 +156,7 @@ def edit(request, wiki_url):
if perm_err:
return perm_err
- if WIKI_ALLOW_TITLE_EDIT:
+ if wiki_settings.WIKI_ALLOW_TITLE_EDIT:
EditForm = RevisionFormWithTitle
else:
EditForm = RevisionForm
@@ -195,7 +178,7 @@ def edit(request, wiki_url):
if not request.user.is_anonymous():
new_revision.revision_user = request.user
new_revision.save()
- if WIKI_ALLOW_TITLE_EDIT:
+ if wiki_settings.WIKI_ALLOW_TITLE_EDIT:
new_revision.article.title = f.cleaned_data['title']
new_revision.article.save()
return HttpResponseRedirect(reverse('wiki_view', args=(article.get_url(),)))
@@ -215,9 +198,6 @@ def edit(request, wiki_url):
return render_to_response('simplewiki_edit.html', d)
def history(request, wiki_url, page=1):
- if not request.user.is_authenticated():
- return redirect('/')
-
(article, path, err) = fetch_from_url(request, wiki_url)
if err:
return err
@@ -302,9 +282,6 @@ def history(request, wiki_url, page=1):
def revision_feed(request, page=1):
- if not request.user.is_superuser:
- return redirect('/')
-
page_size = 10
try:
@@ -332,8 +309,6 @@ def revision_feed(request, page=1):
return render_to_response('simplewiki_revision_feed.html', d)
def search_articles(request):
- if not request.user.is_authenticated():
- return redirect('/')
# 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.
# Adding some context to results (eg where matches were) would also be nice.
@@ -380,9 +355,6 @@ def search_articles(request):
def search_add_related(request, wiki_url):
- if not request.user.is_authenticated():
- return redirect('/')
-
(article, path, err) = fetch_from_url(request, wiki_url)
if err:
return err
@@ -435,9 +407,6 @@ def add_related(request, wiki_url):
return HttpResponseRedirect(reverse('wiki_view', args=(article.get_url(),)))
def remove_related(request, wiki_url, related_id):
- if not request.user.is_authenticated():
- return redirect('/')
-
(article, path, err) = fetch_from_url(request, wiki_url)
if err:
return err
@@ -457,8 +426,6 @@ def remove_related(request, wiki_url, related_id):
return HttpResponseRedirect(reverse('wiki_view', args=(article.get_url(),)))
def random_article(request):
- if not request.user.is_authenticated():
- return redirect('/')
from random import randint
num_arts = Article.objects.count()
article = Article.objects.all()[randint(0, num_arts-1)]
@@ -470,8 +437,6 @@ def encode_err(request, url):
return render_to_response('simplewiki_error.html', d)
def not_found(request, wiki_url):
- if not request.user.is_authenticated():
- return redirect('/')
"""Generate a NOT FOUND message for some URL"""
d = {'wiki_err_notfound': True,
'wiki_url': wiki_url}
@@ -543,17 +508,22 @@ def check_permissions(request, article, check_read=False, check_write=False, che
# LOGIN PROTECTION #
####################
-if WIKI_REQUIRE_LOGIN_VIEW:
- view = login_required(view)
- history = login_required(history)
-# search_related = login_required(search_related)
-# wiki_encode_err = login_required(wiki_encode_err)
+if wiki_settings.WIKI_REQUIRE_LOGIN_VIEW:
+ view = login_required(view)
+ history = login_required(history)
+ search_articles = login_required(search_articles)
+ root_redirect = login_required(root_redirect)
+ revision_feed = login_required(revision_feed)
+ random_article = login_required(random_article)
+ search_add_related = login_required(search_add_related)
+ not_found = login_required(not_found)
+ view_revision = login_required(view_revision)
-if WIKI_REQUIRE_LOGIN_EDIT:
+if wiki_settings.WIKI_REQUIRE_LOGIN_EDIT:
create = login_required(create)
edit = login_required(edit)
add_related = login_required(add_related)
remove_related = login_required(remove_related)
-if WIKI_CONTEXT_PREPROCESSORS:
- settings.TEMPLATE_CONTEXT_PROCESSORS = settings.TEMPLATE_CONTEXT_PROCESSORS + WIKI_CONTEXT_PREPROCESSORS
+if wiki_settings.WIKI_CONTEXT_PREPROCESSORS:
+ settings.TEMPLATE_CONTEXT_PROCESSORS += wiki_settings.WIKI_CONTEXT_PREPROCESSORS
diff --git a/djangoapps/simplewiki/views_attachments.py b/djangoapps/simplewiki/views_attachments.py
index 205f836ab9..e75802413f 100644
--- a/djangoapps/simplewiki/views_attachments.py
+++ b/djangoapps/simplewiki/views_attachments.py
@@ -3,14 +3,21 @@ import os
from django.contrib.auth.decorators import login_required
from django.core.servers.basehttp import FileWrapper
from django.db.models.fields.files import FieldFile
-from django.http import HttpResponse, HttpResponseRedirect, HttpResponseForbidden, Http404
+from django.http import HttpResponse, HttpResponseForbidden, Http404
from django.template import loader, Context
-from settings import * # TODO: Clean up
-from models import Article, ArticleAttachment, get_attachment_filepath
-from views import not_found, check_permissions, get_url_path, fetch_from_url
+from models import ArticleAttachment, get_attachment_filepath
+from views import check_permissions, fetch_from_url
-from simplewiki.settings import WIKI_ALLOW_ANON_ATTACHMENTS
+from wiki_settings import (
+ WIKI_ALLOW_ANON_ATTACHMENTS,
+ WIKI_ALLOW_ATTACHMENTS,
+ WIKI_ATTACHMENTS_MAX,
+ WIKI_ATTACHMENTS_ROOT,
+ WIKI_ATTACHMENTS_ALLOWED_EXTENSIONS,
+ WIKI_REQUIRE_LOGIN_VIEW,
+ WIKI_REQUIRE_LOGIN_EDIT,
+)
def add_attachment(request, wiki_url):
diff --git a/djangoapps/simplewiki/settings.py b/djangoapps/simplewiki/wiki_settings.py
similarity index 100%
rename from djangoapps/simplewiki/settings.py
rename to djangoapps/simplewiki/wiki_settings.py
diff --git a/djangoapps/ssl_auth/__init__.py b/djangoapps/ssl_auth/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/djangoapps/ssl_auth/ssl_auth.py b/djangoapps/ssl_auth/ssl_auth.py
new file mode 100755
index 0000000000..8c96d4b9a6
--- /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/djangoapps/staticbook/views.py b/djangoapps/staticbook/views.py
index f7ad8fab63..84fcc79c1f 100644
--- a/djangoapps/staticbook/views.py
+++ b/djangoapps/staticbook/views.py
@@ -1,14 +1,8 @@
-# Create your views here.
-import os
-
-from django.conf import settings
-from django.http import Http404
-from django.shortcuts import redirect
-from mitxmako.shortcuts import render_to_response, render_to_string
+from django.contrib.auth.decorators import login_required
+from mitxmako.shortcuts import render_to_response
+@login_required
def index(request, page=0):
- if not request.user.is_authenticated():
- return redirect('/')
return render_to_response('staticbook.html',{'page':int(page)})
def index_shifted(request, page):
diff --git a/djangoapps/student/views.py b/djangoapps/student/views.py
index a7b02b5bb1..eb71f5ba6a 100644
--- a/djangoapps/student/views.py
+++ b/djangoapps/student/views.py
@@ -10,14 +10,14 @@ from django.conf import settings
from django.contrib.auth import logout, authenticate, login
from django.contrib.auth.forms import PasswordResetForm
from django.contrib.auth.models import User
+from django.contrib.auth.decorators import login_required
from django.core.context_processors import csrf
from django.core.mail import send_mail
from django.core.validators import validate_email, validate_slug, ValidationError
-from django.db import connection
+from django.db import IntegrityError
from django.http import HttpResponse, Http404
from django.shortcuts import redirect
from mitxmako.shortcuts import render_to_response, render_to_string
-from mako import exceptions
from django_future.csrf import ensure_csrf_cookie
@@ -93,12 +93,11 @@ def logout_user(request):
logout(request)
return redirect('/')
+@login_required
@ensure_csrf_cookie
def change_setting(request):
''' JSON call to change a profile setting: Right now, location and language
'''
- if not request.user.is_authenticated():
- return redirect('/')
up = UserProfile.objects.get(user=request.user) #request.user.profile_cache
if 'location' in request.POST:
up.location=request.POST['location']
@@ -162,15 +161,6 @@ def create_account(request, post_override=None):
- # Confirm username and e-mail are unique. TODO: This should be in a transaction
- if len(User.objects.filter(username=post_vars['username']))>0:
- js['value']="An account with this username already exists."
- return HttpResponse(json.dumps(js))
-
- if len(User.objects.filter(email=post_vars['email']))>0:
- js['value']="An account with this e-mail already exists."
- return HttpResponse(json.dumps(js))
-
u=User(username=post_vars['username'],
email=post_vars['email'],
is_active=False)
@@ -178,7 +168,20 @@ def create_account(request, post_override=None):
r=Registration()
# TODO: Rearrange so that if part of the process fails, the whole process fails.
# Right now, we can have e.g. no registration e-mail sent out and a zombie account
- u.save()
+ try:
+ u.save()
+ except IntegrityError:
+ # 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."
+ return HttpResponse(json.dumps(js))
+
+ if len(User.objects.filter(email=post_vars['email']))>0:
+ js['value']="An account with this e-mail already exists."
+ return HttpResponse(json.dumps(js))
+
+ raise
+
r.register(u)
up = UserProfile(user=u)
diff --git a/envs/aws.py b/envs/aws.py
index 9ce621c2bd..7363cbb3f7 100644
--- a/envs/aws.py
+++ b/envs/aws.py
@@ -8,7 +8,8 @@ Common traits:
"""
import json
-from common import *
+from envs.logsettings import get_logger_config
+from envs.common import *
############################### ALWAYS THE SAME ################################
DEBUG = False
@@ -24,7 +25,6 @@ with open(ENV_ROOT / "env.json") as env_file:
ENV_TOKENS = json.load(env_file)
SITE_NAME = ENV_TOKENS['SITE_NAME']
-CSRF_COOKIE_DOMAIN = ENV_TOKENS['CSRF_COOKIE_DOMAIN']
BOOK_URL = ENV_TOKENS['BOOK_URL']
MEDIA_URL = ENV_TOKENS['MEDIA_URL']
@@ -32,10 +32,10 @@ LOG_DIR = ENV_TOKENS['LOG_DIR']
CACHES = ENV_TOKENS['CACHES']
-LOGGING = logsettings.get_logger_config(LOG_DIR,
- logging_env=ENV_TOKENS['LOGGING_ENV'],
- syslog_addr=(ENV_TOKENS['SYSLOG_SERVER'], 514),
- debug=False)
+LOGGING = get_logger_config(LOG_DIR,
+ logging_env=ENV_TOKENS['LOGGING_ENV'],
+ syslog_addr=(ENV_TOKENS['SYSLOG_SERVER'], 514),
+ debug=False)
############################## SECURE AUTH ITEMS ###############################
# Secret things: passwords, access keys, etc.
@@ -47,4 +47,4 @@ SECRET_KEY = AUTH_TOKENS['SECRET_KEY']
AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"]
AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"]
-DATABASES = AUTH_TOKENS['DATABASES']
\ No newline at end of file
+DATABASES = AUTH_TOKENS['DATABASES']
diff --git a/envs/common.py b/envs/common.py
index 4694e1f5fd..6c596dc7fa 100644
--- a/envs/common.py
+++ b/envs/common.py
@@ -24,8 +24,7 @@ import tempfile
import djcelery
from path import path
-from askbotsettings import * # this is where LIVESETTINGS_OPTIONS comes from
-import logsettings
+from envs.askbotsettings import * # this is where LIVESETTINGS_OPTIONS comes from
################################### FEATURES ###################################
COURSEWARE_ENABLED = True
@@ -81,6 +80,7 @@ TEMPLATE_DIRS = (
TEMPLATE_CONTEXT_PROCESSORS = (
'django.core.context_processors.request',
'askbot.context.application_settings',
+ 'django.contrib.messages.context_processors.messages',
#'django.core.context_processors.i18n',
'askbot.user_messages.context_processors.user_messages',#must be before auth
'django.core.context_processors.auth', #this is required for admin
@@ -113,7 +113,6 @@ TEMPLATE_DEBUG = False
# Site info
SITE_ID = 1
SITE_NAME = "localhost:8000"
-CSRF_COOKIE_DOMAIN = '127.0.0.1'
HTTPS = 'on'
ROOT_URLCONF = 'mitx.urls'
IGNORABLE_404_ENDS = ('favicon.ico')
@@ -134,7 +133,7 @@ STATIC_ROOT = ENV_ROOT / "staticfiles" # We don't run collectstatic -- this is t
# FIXME: We should iterate through the courses we have, adding the static
# contents for each of them. (Right now we just use symlinks.)
-STATICFILES_DIRS = (
+STATICFILES_DIRS = [
PROJECT_ROOT / "static",
ASKBOT_ROOT / "askbot" / "skins",
("circuits", DATA_DIR / "images"),
@@ -143,7 +142,7 @@ STATICFILES_DIRS = (
# This is how you would use the textbook images locally
# ("book", ENV_ROOT / "book_images")
-)
+]
# Locale/Internationalization
TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
@@ -151,6 +150,9 @@ LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html
USE_I18N = True
USE_L10N = True
+# Messages
+MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage'
+
#################################### AWS #######################################
# S3BotoStorage insists on a timeout for uploaded assets. We should make it
# permanent instead, but rather than trying to figure out exactly where that
@@ -179,8 +181,8 @@ CELERY_ALWAYS_EAGER = True
djcelery.setup_loader()
################################# SIMPLEWIKI ###################################
-WIKI_REQUIRE_LOGIN_EDIT = True
-WIKI_REQUIRE_LOGIN_VIEW = True
+SIMPLE_WIKI_REQUIRE_LOGIN_EDIT = True
+SIMPLE_WIKI_REQUIRE_LOGIN_VIEW = False
################################# Middleware ###################################
# List of finder classes that know how to find static files in
diff --git a/envs/content.py b/envs/content.py
index c02e7bb72e..495bbeedac 100644
--- a/envs/content.py
+++ b/envs/content.py
@@ -2,7 +2,7 @@
These are debug machines used for content creators, so they're kind of a cross
between dev machines and AWS machines.
"""
-from aws import *
+from envs.aws import *
DEBUG = True
TEMPLATE_DEBUG = True
diff --git a/envs/dev.py b/envs/dev.py
index 757bd1164a..b459f0a34f 100644
--- a/envs/dev.py
+++ b/envs/dev.py
@@ -7,16 +7,17 @@ sessions. Assumes structure:
/mitx # The location of this repo
/log # Where we're going to write log files
"""
-from common import *
+from envs.common import *
+from envs.logsettings import get_logger_config
DEBUG = True
PIPELINE = True
TEMPLATE_DEBUG = True
-LOGGING = logsettings.get_logger_config(ENV_ROOT / "log",
- logging_env="dev",
- tracking_filename="tracking.log",
- debug=True)
+LOGGING = get_logger_config(ENV_ROOT / "log",
+ logging_env="dev",
+ tracking_filename="tracking.log",
+ debug=True)
DATABASES = {
'default': {
@@ -74,7 +75,8 @@ DEBUG_TOOLBAR_PANELS = (
############################ FILE UPLOADS (ASKBOT) #############################
DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage'
MEDIA_ROOT = ENV_ROOT / "uploads"
-MEDIA_URL = "/discussion/upfiles/"
+MEDIA_URL = "/static/uploads/"
+STATICFILES_DIRS.append(("uploads", MEDIA_ROOT))
FILE_UPLOAD_TEMP_DIR = ENV_ROOT / "uploads"
FILE_UPLOAD_HANDLERS = (
'django.core.files.uploadhandler.MemoryFileUploadHandler',
diff --git a/envs/devplus.py b/envs/devplus.py
index 2ec38b53a0..4f763b925c 100644
--- a/envs/devplus.py
+++ b/envs/devplus.py
@@ -13,7 +13,7 @@ Dir structure:
/log # Where we're going to write log files
"""
-from dev import *
+from envs.dev import *
DATABASES = {
'default': {
diff --git a/envs/static.py b/envs/static.py
new file mode 100644
index 0000000000..65309c4795
--- /dev/null
+++ b/envs/static.py
@@ -0,0 +1,59 @@
+"""
+This config file runs the simplest dev environment using sqlite, and db-based
+sessions. Assumes structure:
+
+/envroot/
+ /db # This is where it'll write the database file
+ /mitx # The location of this repo
+ /log # Where we're going to write log files
+"""
+from envs.common import *
+from envs.logsettings import get_logger_config
+
+STATIC_GRAB = True
+
+LOGGING = get_logger_config(ENV_ROOT / "log",
+ logging_env="dev",
+ tracking_filename="tracking.log",
+ debug=False)
+
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.sqlite3',
+ 'NAME': ENV_ROOT / "db" / "mitx.db",
+ }
+}
+
+CACHES = {
+ # This is the cache used for most things. Askbot will not work without a
+ # functioning cache -- it relies on caching to load its settings in places.
+ # In staging/prod envs, the sessions also live here.
+ 'default': {
+ 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
+ 'LOCATION': 'mitx_loc_mem_cache'
+ },
+
+ # The general cache is what you get if you use our util.cache. It's used for
+ # things like caching the course.xml file for different A/B test groups.
+ # We set it to be a DummyCache to force reloading of course.xml in dev.
+ # In staging environments, we would grab VERSION from data uploaded by the
+ # push process.
+ 'general': {
+ 'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
+ 'KEY_PREFIX': 'general',
+ 'VERSION': 4,
+ }
+}
+
+# Dummy secret key for dev
+SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
+
+############################ FILE UPLOADS (ASKBOT) #############################
+DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage'
+MEDIA_ROOT = ENV_ROOT / "uploads"
+MEDIA_URL = "/discussion/upfiles/"
+FILE_UPLOAD_TEMP_DIR = ENV_ROOT / "uploads"
+FILE_UPLOAD_HANDLERS = (
+ 'django.core.files.uploadhandler.MemoryFileUploadHandler',
+ 'django.core.files.uploadhandler.TemporaryFileUploadHandler',
+)
diff --git a/envs/test.py b/envs/test.py
new file mode 100644
index 0000000000..bf83511786
--- /dev/null
+++ b/envs/test.py
@@ -0,0 +1,84 @@
+"""
+This config file runs the simplest dev environment using sqlite, and db-based
+sessions. Assumes structure:
+
+/envroot/
+ /db # This is where it'll write the database file
+ /mitx # The location of this repo
+ /log # Where we're going to write log files
+"""
+from envs.common import *
+from envs.logsettings import get_logger_config
+import os
+
+INSTALLED_APPS = [
+ app
+ for app
+ in INSTALLED_APPS
+ if not app.startswith('askbot')
+]
+
+# Nose Test Runner
+INSTALLED_APPS += ['django_nose']
+NOSE_ARGS = ['--cover-erase', '--with-xunit', '--with-xcoverage', '--cover-html', '--cover-inclusive']
+for app in os.listdir(PROJECT_ROOT / 'djangoapps'):
+ NOSE_ARGS += ['--cover-package', app]
+TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
+
+# Local Directories
+TEST_ROOT = path("test_root")
+COURSES_ROOT = TEST_ROOT / "data"
+DATA_DIR = COURSES_ROOT
+MAKO_TEMPLATES['course'] = [DATA_DIR]
+MAKO_TEMPLATES['sections'] = [DATA_DIR / 'sections']
+MAKO_TEMPLATES['custom_tags'] = [DATA_DIR / 'custom_tags']
+MAKO_TEMPLATES['main'] = [PROJECT_ROOT / 'templates',
+ DATA_DIR / 'info',
+ DATA_DIR / 'problems']
+
+LOGGING = get_logger_config(TEST_ROOT / "log",
+ logging_env="dev",
+ tracking_filename="tracking.log",
+ debug=True)
+
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.sqlite3',
+ 'NAME': PROJECT_ROOT / "db" / "mitx.db",
+ }
+}
+
+CACHES = {
+ # This is the cache used for most things. Askbot will not work without a
+ # functioning cache -- it relies on caching to load its settings in places.
+ # In staging/prod envs, the sessions also live here.
+ 'default': {
+ 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
+ 'LOCATION': 'mitx_loc_mem_cache'
+ },
+
+ # The general cache is what you get if you use our util.cache. It's used for
+ # things like caching the course.xml file for different A/B test groups.
+ # We set it to be a DummyCache to force reloading of course.xml in dev.
+ # In staging environments, we would grab VERSION from data uploaded by the
+ # push process.
+ 'general': {
+ 'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
+ 'KEY_PREFIX': 'general',
+ 'VERSION': 4,
+ }
+}
+
+# Dummy secret key for dev
+SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
+
+############################ FILE UPLOADS (ASKBOT) #############################
+DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage'
+MEDIA_ROOT = PROJECT_ROOT / "uploads"
+MEDIA_URL = "/static/uploads/"
+STATICFILES_DIRS.append(("uploads", MEDIA_ROOT))
+FILE_UPLOAD_TEMP_DIR = PROJECT_ROOT / "uploads"
+FILE_UPLOAD_HANDLERS = (
+ 'django.core.files.uploadhandler.MemoryFileUploadHandler',
+ 'django.core.files.uploadhandler.TemporaryFileUploadHandler',
+)
diff --git a/fixtures/anonymize_fixtures.py b/fixtures/anonymize_fixtures.py
new file mode 100755
index 0000000000..ba62652de5
--- /dev/null
+++ b/fixtures/anonymize_fixtures.py
@@ -0,0 +1,98 @@
+#! /usr/bin/env python
+
+import sys
+import json
+import random
+import copy
+from collections import defaultdict
+from argparse import ArgumentParser, FileType
+from datetime import datetime
+
+def generate_user(user_number):
+ return {
+ "pk": user_number,
+ "model": "auth.user",
+ "fields": {
+ "status": "w",
+ "last_name": "Last",
+ "gold": 0,
+ "is_staff": False,
+ "user_permissions": [],
+ "interesting_tags": "",
+ "email_key": None,
+ "date_joined": "2012-04-26 11:36:39",
+ "first_name": "",
+ "email_isvalid": False,
+ "avatar_type": "n",
+ "website": "",
+ "is_superuser": False,
+ "date_of_birth": None,
+ "last_login": "2012-04-26 11:36:48",
+ "location": "",
+ "new_response_count": 0,
+ "email": "user{num}@example.com".format(num=user_number),
+ "username": "user{num}".format(num=user_number),
+ "is_active": True,
+ "consecutive_days_visit_count": 0,
+ "email_tag_filter_strategy": 1,
+ "groups": [],
+ "password": "sha1$90e6f$562a1d783a0c47ce06ebf96b8c58123a0671bbf0",
+ "silver": 0,
+ "bronze": 0,
+ "questions_per_page": 10,
+ "about": "",
+ "show_country": True,
+ "country": "",
+ "display_tag_filter_strategy": 0,
+ "seen_response_count": 0,
+ "real_name": "",
+ "ignored_tags": "",
+ "reputation": 1,
+ "gravatar": "366d981a10116969c568a18ee090f44c",
+ "last_seen": "2012-04-26 11:36:39"
+ }
+ }
+
+
+def parse_args(args=sys.argv[1:]):
+ parser = ArgumentParser()
+ parser.add_argument('-d', '--data', type=FileType('r'), default=sys.stdin)
+ parser.add_argument('-o', '--output', type=FileType('w'), default=sys.stdout)
+ parser.add_argument('count', type=int)
+ return parser.parse_args(args)
+
+
+def main(args=sys.argv[1:]):
+ args = parse_args(args)
+
+ data = json.load(args.data)
+ unique_students = set(entry['fields']['student'] for entry in data)
+ if args.count > len(unique_students) * 0.1:
+ raise Exception("Can't be sufficiently anonymous selecting {count} of {unique} students".format(
+ count=args.count, unique=len(unique_students)))
+
+ by_problems = defaultdict(list)
+ for entry in data:
+ by_problems[entry['fields']['module_id']].append(entry)
+
+ out_data = []
+ out_pk = 1
+ for name, answers in by_problems.items():
+ for student_id in xrange(args.count):
+ sample = random.choice(answers)
+ data = copy.deepcopy(sample)
+ data["fields"]["student"] = student_id + 1
+ data["fields"]["created"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ data["fields"]["modified"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ data["pk"] = out_pk
+ out_pk += 1
+ out_data.append(data)
+
+ for student_id in xrange(args.count):
+ out_data.append(generate_user(student_id))
+
+ json.dump(out_data, args.output, indent=2)
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/lib/loncapa/__init__.py b/lib/loncapa/__init__.py
new file mode 100644
index 0000000000..b734967d0a
--- /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 0000000000..259c7909ac
--- /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 ca626b5c85..7286a4e259 100644
--- a/lib/mitxmako/shortcuts.py
+++ b/lib/mitxmako/shortcuts.py
@@ -27,12 +27,16 @@ def render_to_string(template_name, dictionary, context=None, namespace='main'):
# collapse context_instance to a single dictionary for mako
context_dictionary = {}
context_instance['settings'] = settings
+ context_instance['MITX_ROOT_URL'] = settings.MITX_ROOT_URL
for d in mitxmako.middleware.requestcontext:
context_dictionary.update(d)
for d in context_instance:
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/sympy_check/__init__.py b/lib/sympy_check/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/lib/sympy_check/formula.py b/lib/sympy_check/formula.py
new file mode 100644
index 0000000000..44bd020c4e
--- /dev/null
+++ b/lib/sympy_check/formula.py
@@ -0,0 +1,461 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+#
+# File: formula.py
+# Date: 04-May-12
+# Author: I. Chuang
+#
+# flexible python representation of a symbolic mathematical formula.
+# Acceptes Presentation MathML, Content MathML (and could also do OpenMath)
+# Provides sympy representation.
+
+import os, sys, string, re
+import operator
+import sympy
+from sympy.printing.latex import LatexPrinter
+from sympy.printing.str import StrPrinter
+from sympy import latex, sympify
+from sympy.physics.quantum.qubit import *
+from sympy.physics.quantum.state import *
+# from sympy import exp, pi, I
+# from sympy.core.operations import LatticeOp
+# import sympy.physics.quantum.qubit
+
+import urllib
+from xml.sax.saxutils import escape, unescape
+import sympy
+import unicodedata
+from lxml import etree
+#import subprocess
+import requests
+from copy import deepcopy
+
+print "[lib.sympy_check.formula] Warning: Dark code. Needs review before enabling in prod."
+
+os.environ['PYTHONIOENCODING'] = 'utf-8'
+
+#-----------------------------------------------------------------------------
+
+class dot(sympy.operations.LatticeOp): # my dot product
+ zero = sympy.Symbol('dotzero')
+ identity = sympy.Symbol('dotidentity')
+
+#class dot(sympy.Mul): # my dot product
+# is_Mul = False
+
+def _print_dot(self,expr):
+ return '{((%s) \cdot (%s))}' % (expr.args[0],expr.args[1])
+
+LatexPrinter._print_dot = _print_dot
+
+#-----------------------------------------------------------------------------
+# unit vectors (for 8.02)
+
+def _print_hat(self,expr): return '\\hat{%s}' % str(expr.args[0]).lower()
+
+LatexPrinter._print_hat = _print_hat
+StrPrinter._print_hat = _print_hat
+
+#-----------------------------------------------------------------------------
+# helper routines
+
+def to_latex(x):
+ if x==None: return ''
+ # LatexPrinter._print_dot = _print_dot
+ xs = latex(x)
+ xs = xs.replace(r'\XI','XI') # workaround for strange greek
+ #return '' % (xs[1:-1])
+ if xs[0]=='$':
+ return '[mathjax]%s[/mathjax] ' % (xs[1:-1]) # for sympy v6
+ return '[mathjax]%s[/mathjax] ' % (xs) # for sympy v7
+
+def my_evalf(expr,chop=False):
+ if type(expr)==list:
+ try:
+ return [x.evalf(chop=chop) for x in expr]
+ except:
+ return expr
+ try:
+ return expr.evalf(chop=chop)
+ except:
+ return expr
+
+#-----------------------------------------------------------------------------
+# my version of sympify to import expression into sympy
+
+def my_sympify(expr,normphase=False,matrix=False,abcsym=False,do_qubit=False,symtab=None):
+ # make all lowercase real?
+ if symtab:
+ varset = symtab
+ else:
+ varset = {'p':sympy.Symbol('p'),
+ 'g':sympy.Symbol('g'),
+ 'e':sympy.E, # for exp
+ 'i':sympy.I, # lowercase i is also sqrt(-1)
+ 'Q':sympy.Symbol('Q'), # otherwise it is a sympy "ask key"
+ #'X':sympy.sympify('Matrix([[0,1],[1,0]])'),
+ #'Y':sympy.sympify('Matrix([[0,-I],[I,0]])'),
+ #'Z':sympy.sympify('Matrix([[1,0],[0,-1]])'),
+ 'ZZ':sympy.Symbol('ZZ'), # otherwise it is the PythonIntegerRing
+ 'XI':sympy.Symbol('XI'), # otherwise it is the capital \XI
+ 'hat':sympy.Function('hat'), # for unit vectors (8.02)
+ }
+ if do_qubit: # turn qubit(...) into Qubit instance
+ varset.update({'qubit':sympy.physics.quantum.qubit.Qubit,
+ 'Ket':sympy.physics.quantum.state.Ket,
+ 'dot':dot,
+ 'bit':sympy.Function('bit'),
+ })
+ if abcsym: # consider all lowercase letters as real symbols, in the parsing
+ for letter in string.lowercase:
+ if letter in varset: # exclude those already done
+ continue
+ varset.update({letter:sympy.Symbol(letter,real=True)})
+
+ sexpr = sympify(expr,locals=varset)
+ if normphase: # remove overall phase if sexpr is a list
+ if type(sexpr)==list:
+ if sexpr[0].is_number:
+ ophase = sympy.sympify('exp(-I*arg(%s))' % sexpr[0])
+ sexpr = [ sympy.Mul(x,ophase) for x in sexpr ]
+
+ def to_matrix(x): # if x is a list of lists, and is rectangular, then return Matrix(x)
+ if not type(x)==list:
+ return x
+ for row in x:
+ if (not type(row)==list):
+ return x
+ rdim = len(x[0])
+ for row in x:
+ if not len(row)==rdim:
+ return x
+ return sympy.Matrix(x)
+
+ if matrix:
+ sexpr = to_matrix(sexpr)
+ return sexpr
+
+#-----------------------------------------------------------------------------
+# class for symbolic mathematical formulas
+
+class formula(object):
+ '''
+ Representation of a mathematical formula object. Accepts mathml math expression for constructing,
+ and can produce sympy translation. The formula may or may not include an assignment (=).
+ '''
+ def __init__(self,expr,asciimath=''):
+ self.expr = expr.strip()
+ self.asciimath = asciimath
+ self.the_cmathml = None
+ self.the_sympy = None
+
+ def is_presentation_mathml(self):
+ return 'f-2"
+ # this is really terrible for turning into cmathml.
+ # undo this here.
+ def fix_pmathml(xml):
+ for k in xml:
+ tag = gettag(k)
+ if tag=='mrow':
+ if len(k)==2:
+ if gettag(k[0])=='mi' and k[0].text in ['f','g'] and gettag(k[1])=='mo':
+ idx = xml.index(k)
+ xml.insert(idx,deepcopy(k[0])) # drop the container
+ xml.insert(idx+1,deepcopy(k[1]))
+ xml.remove(k)
+ fix_pmathml(k)
+
+ fix_pmathml(xml)
+
+ # hat i is turned into i^ ; mangle this into hat(f)
+ # hat i also somtimes turned into j^
+
+ def fix_hat(xml):
+ for k in xml:
+ tag = gettag(k)
+ if tag=='mover':
+ if len(k)==2:
+ if gettag(k[0])=='mi' and gettag(k[1])=='mo' and str(k[1].text)=='^':
+ newk = etree.Element('mi')
+ newk.text = 'hat(%s)' % k[0].text
+ xml.replace(k,newk)
+ if gettag(k[0])=='mrow' and gettag(k[0][0])=='mi' and gettag(k[1])=='mo' and str(k[1].text)=='^':
+ newk = etree.Element('mi')
+ newk.text = 'hat(%s)' % k[0][0].text
+ xml.replace(k,newk)
+ fix_hat(k)
+ fix_hat(xml)
+
+ self.xml = xml
+ return self.xml
+
+ def get_content_mathml(self):
+ if self.the_cmathml: return self.the_cmathml
+
+ # pre-process the presentation mathml before sending it to snuggletex to convert to content mathml
+ xml = self.preprocess_pmathml(self.expr)
+ pmathml = etree.tostring(xml,pretty_print=True)
+ self.the_pmathml = pmathml
+
+ # convert to cmathml
+ self.the_cmathml = self.GetContentMathML(self.asciimath,pmathml)
+ return self.the_cmathml
+
+ cmathml = property(get_content_mathml,None,None,'content MathML representation')
+
+ def make_sympy(self,xml=None):
+ '''
+ Return sympy expression for the math formula
+ '''
+
+ if self.the_sympy: return self.the_sympy
+
+ if xml==None: # root
+ if not self.is_mathml():
+ return my_sympify(self.expr)
+ if self.is_presentation_mathml():
+ xml = etree.fromstring(str(self.cmathml))
+ xml = self.fix_greek_in_mathml(xml)
+ self.the_sympy = self.make_sympy(xml[0])
+ else:
+ xml = etree.fromstring(self.expr)
+ xml = self.fix_greek_in_mathml(xml)
+ self.the_sympy = self.make_sympy(xml[0])
+ return self.the_sympy
+
+ def gettag(x):
+ return re.sub('{http://[^}]+}','',x.tag)
+
+ # simple math
+ def op_divide(*args):
+ if not len(args)==2:
+ raise Exception,'divide given wrong number of arguments!'
+ # print "divide: arg0=%s, arg1=%s" % (args[0],args[1])
+ return sympy.Mul(args[0],sympy.Pow(args[1],-1))
+
+ def op_plus(*args): return sum(args)
+ def op_times(*args): return reduce(operator.mul,args)
+
+ def op_minus(*args):
+ if len(args)==1:
+ return -args[0]
+ if not len(args)==2:
+ raise Exception,'minus given wrong number of arguments!'
+ #return sympy.Add(args[0],-args[1])
+ return args[0]-args[1]
+
+ opdict = {'plus': op_plus,
+ 'divide' : operator.div,
+ 'times' : op_times,
+ 'minus' : op_minus,
+ #'plus': sympy.Add,
+ #'divide' : op_divide,
+ #'times' : sympy.Mul,
+ 'minus' : op_minus,
+ 'root' : sympy.sqrt,
+ 'power' : sympy.Pow,
+ 'sin': sympy.sin,
+ 'cos': sympy.cos,
+ }
+
+ # simple sumbols
+ nums1dict = {'pi': sympy.pi,
+ }
+
+ def parsePresentationMathMLSymbol(xml):
+ '''
+ Parse , , , and
+ '''
+ tag = gettag(xml)
+ if tag=='mn': return xml.text
+ elif tag=='mi': return xml.text
+ elif tag=='msub': return '_'.join([parsePresentationMathMLSymbol(y) for y in xml])
+ elif tag=='msup': return '^'.join([parsePresentationMathMLSymbol(y) for y in xml])
+ raise Exception,'[parsePresentationMathMLSymbol] unknown tag %s' % tag
+
+ # parser tree for content MathML
+ tag = gettag(xml)
+ print "tag = ",tag
+
+ # first do compound objects
+
+ if tag=='apply': # apply operator
+ opstr = gettag(xml[0])
+ if opstr in opdict:
+ op = opdict[opstr]
+ args = [ self.make_sympy(x) for x in xml[1:]]
+ return op(*args)
+ else:
+ raise Exception,'[formula]: unknown operator tag %s' % (opstr)
+
+ elif tag=='list': # square bracket list
+ if gettag(xml[0])=='matrix':
+ return self.make_sympy(xml[0])
+ else:
+ return [ self.make_sympy(x) for x in xml ]
+
+ elif tag=='matrix':
+ return sympy.Matrix([ self.make_sympy(x) for x in xml ])
+
+ elif tag=='vector':
+ return [ self.make_sympy(x) for x in xml ]
+
+ # atoms are below
+
+ elif tag=='cn': # number
+ return sympy.sympify(xml.text)
+ return float(xml.text)
+
+ elif tag=='ci': # variable (symbol)
+ if len(xml)>0 and (gettag(xml[0])=='msub' or gettag(xml[0])=='msup'):
+ usym = parsePresentationMathMLSymbol(xml[0])
+ sym = sympy.Symbol(str(usym))
+ else:
+ usym = unicode(xml.text)
+ if 'hat' in usym:
+ sym = my_sympify(usym)
+ else:
+ sym = sympy.Symbol(str(usym))
+ return sym
+
+ else: # unknown tag
+ raise Exception,'[formula] unknown tag %s' % tag
+
+ sympy = property(make_sympy,None,None,'sympy representation')
+
+ def GetContentMathML(self,asciimath,mathml):
+ # URL = 'http://192.168.1.2:8080/snuggletex-webapp-1.2.2/ASCIIMathMLUpConversionDemo'
+ URL = 'http://127.0.0.1:8080/snuggletex-webapp-1.2.2/ASCIIMathMLUpConversionDemo'
+
+ if 1:
+ payload = {'asciiMathInput':asciimath,
+ 'asciiMathML':mathml,
+ #'asciiMathML':unicode(mathml).encode('utf-8'),
+ }
+ headers = {'User-Agent':"Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.13) Gecko/20080311 Firefox/2.0.0.13"}
+ r = requests.post(URL,data=payload,headers=headers)
+ r.encoding = 'utf-8'
+ ret = r.text
+ #print "encoding: ",r.encoding
+
+ # return ret
+
+ mode = 0
+ cmathml = []
+ for k in ret.split('\n'):
+ if 'conversion to Content MathML' in k:
+ mode = 1
+ continue
+ if mode==1:
+ if '
Maxima Input Form
' in k:
+ mode = 0
+ continue
+ cmathml.append(k)
+ # return '\n'.join(cmathml)
+ cmathml = '\n'.join(cmathml[2:])
+ cmathml = ''
+ # print cmathml
+ #return unicode(cmathml)
+ return cmathml
+
+#-----------------------------------------------------------------------------
+
+def test1():
+ xmlstr = '''
+
+ '''
+ return formula(xmlstr)
+
+def test2():
+ xmlstr = u'''
+
+ '''
+ return formula(xmlstr)
+
+def test3():
+ xmlstr = '''
+
+ '''
+ return formula(xmlstr)
+
+def test4():
+ xmlstr = u'''
+
+'''
+ return formula(xmlstr)
diff --git a/lib/sympy_check/sympy_check2.py b/lib/sympy_check/sympy_check2.py
new file mode 100644
index 0000000000..abb70ed165
--- /dev/null
+++ b/lib/sympy_check/sympy_check2.py
@@ -0,0 +1,271 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+#
+# File: sympy_check2.py
+# Date: 02-May-12
+# Author: I. Chuang
+#
+# Use sympy to check for expression equality
+#
+# Takes in math expressions given as Presentation MathML (from ASCIIMathML), converts to Content MathML using SnuggleTeX
+
+import os, sys, string, re
+import traceback
+from formula import *
+
+#-----------------------------------------------------------------------------
+# check function interface
+
+def sympy_check(expect,ans,adict={},symtab=None,extra_options=None):
+
+ options = {'__MATRIX__':False,'__ABC__':False,'__LOWER__':False}
+ if extra_options: options.update(extra_options)
+ for op in options: # find options in expect string
+ if op in expect:
+ expect = expect.replace(op,'')
+ options[op] = True
+ expect = expect.replace('__OR__','__or__') # backwards compatibility
+
+ if options['__LOWER__']:
+ expect = expect.lower()
+ ans = ans.lower()
+
+ try:
+ ret = check(expect,ans,
+ matrix=options['__MATRIX__'],
+ abcsym=options['__ABC__'],
+ symtab=symtab,
+ )
+ except Exception, err:
+ return {'ok': False,
+ 'msg': 'Error %s Failed in evaluating check(%s,%s)' % (err,expect,ans)
+ }
+ return ret
+
+#-----------------------------------------------------------------------------
+# pretty generic checking function
+
+def check(expect,given,numerical=False,matrix=False,normphase=False,abcsym=False,do_qubit=True,symtab=None,dosimplify=False):
+ """
+ Returns dict with
+
+ 'ok': True if check is good, False otherwise
+ 'msg': response message (in HTML)
+
+ "expect" may have multiple possible acceptable answers, separated by "__OR__"
+
+ """
+
+ if "__or__" in expect: # if multiple acceptable answers
+ eset = expect.split('__or__') # then see if any match
+ for eone in eset:
+ ret = check(eone,given,numerical,matrix,normphase,abcsym,do_qubit,symtab,dosimplify)
+ if ret['ok']:
+ return ret
+ return ret
+
+ flags = {}
+ if "__autonorm__" in expect:
+ flags['autonorm']=True
+ expect = expect.replace('__autonorm__','')
+ matrix = True
+
+ threshold = 1.0e-3
+ if "__threshold__" in expect:
+ (expect,st) = expect.split('__threshold__')
+ threshold = float(st)
+ numerical=True
+
+ if str(given)=='' and not (str(expect)==''):
+ return {'ok': False, 'msg': ''}
+
+ try:
+ xgiven = my_sympify(given,normphase,matrix,do_qubit=do_qubit,abcsym=abcsym,symtab=symtab)
+ except Exception,err:
+ return {'ok': False,'msg': 'Error %s in evaluating your expression "%s"' % (err,given)}
+
+ try:
+ xexpect = my_sympify(expect,normphase,matrix,do_qubit=do_qubit,abcsym=abcsym,symtab=symtab)
+ except Exception,err:
+ return {'ok': False,'msg': 'Error %s in evaluating OUR expression "%s"' % (err,expect)}
+
+ if 'autonorm' in flags: # normalize trace of matrices
+ try:
+ xgiven /= xgiven.trace()
+ except Exception, err:
+ return {'ok': False,'msg': 'Error %s in normalizing trace of your expression %s' % (err,to_latex(xgiven))}
+ try:
+ xexpect /= xexpect.trace()
+ except Exception, err:
+ return {'ok': False,'msg': 'Error %s in normalizing trace of OUR expression %s' % (err,to_latex(xexpect))}
+
+ msg = 'Your expression was evaluated as ' + to_latex(xgiven)
+ # msg += ' Expected ' + to_latex(xexpect)
+
+ # msg += " flags=%s" % flags
+
+ if matrix and numerical:
+ xgiven = my_evalf(xgiven,chop=True)
+ dm = my_evalf(sympy.Matrix(xexpect)-sympy.Matrix(xgiven),chop=True)
+ msg += " = " + to_latex(xgiven)
+ if abs(dm.vec().norm().evalf())expect='%s', given='%s'" % (expect,given) # debugging
+ # msg += " dot test " + to_latex(dot(sympy.Symbol('x'),sympy.Symbol('y')))
+ return {'ok': False,'msg': msg }
+
+#-----------------------------------------------------------------------------
+# Check function interface, which takes pmathml input
+
+def sympy_check2(expect,ans,adict={},abname=''):
+
+ msg = ''
+ # msg += 'abname=%s' % abname
+ # msg += 'adict=%s' % (repr(adict).replace('<','<'))
+
+ threshold = 1.0e-3
+ DEBUG = True
+
+ # parse expected answer
+ try:
+ fexpect = my_sympify(str(expect))
+ except Exception,err:
+ msg += '
Error %s in parsing OUR expected answer "%s"
' % (err,expect)
+ return {'ok':False,'msg':msg}
+
+ # if expected answer is a number, try parsing provided answer as a number also
+ try:
+ fans = my_sympify(str(ans))
+ except Exception,err:
+ fans = None
+
+ if fexpect.is_number and fans and fans.is_number:
+ if abs(abs(fans-fexpect)/fexpect)You entered: %s
diff --git a/templates/accordion_init.js b/templates/accordion_init.js
deleted file mode 100644
index 7c4ca2603c..0000000000
--- a/templates/accordion_init.js
+++ /dev/null
@@ -1,19 +0,0 @@
-$("#accordion").accordion({
- active: ${ active_chapter },
- header: 'h3',
- autoHeight: false,
-});
-
-$("#open_close_accordion a").click(function(){
- if ($(".course-wrapper").hasClass("closed")){
- $(".course-wrapper").removeClass("closed");
- } else {
- $(".course-wrapper").addClass("closed");
- }
-});
-
-$('.ui-accordion').bind('accordionchange', function(event, ui) {
- var event_data = {'newheader':ui.newHeader.text(),
- 'oldheader':ui.oldHeader.text()};
- log_event('accordion', event_data);
-});
diff --git a/templates/choicegroup.html b/templates/choicegroup.html
new file mode 100644
index 0000000000..938ce5b535
--- /dev/null
+++ b/templates/choicegroup.html
@@ -0,0 +1,21 @@
+
diff --git a/templates/coffee/README.md b/templates/coffee/README.md
new file mode 100644
index 0000000000..6c26529a5b
--- /dev/null
+++ b/templates/coffee/README.md
@@ -0,0 +1,59 @@
+CoffeeScript
+============
+
+This folder contains the CoffeeScript file that will be compiled to the static
+directory. By default, we're compile and merge all the files ending `.coffee`
+into `static/js/application.js`.
+
+Install the Compiler
+--------------------
+
+CoffeeScript compiler are written in JavaScript. You'll need to install Node and
+npm (Node Package Manager) to be able to install the CoffeeScript compiler.
+
+### Mac OS X
+
+Install Node via Homebrew, then use npm:
+
+ brew install node
+ curl http://npmjs.org/install.sh | sh
+ npm install -g git://github.com/jashkenas/coffee-script.git
+
+(Note that we're using the edge version of CoffeeScript for now, as there was
+some issue with directory watching in 1.3.1.)
+
+Try to run `coffee` and make sure you get a coffee prompt.
+
+### Debian/Ubuntu
+
+Conveniently, you can install Node via `apt-get`, then use npm:
+
+ sudo apt-get install nodejs npm &&
+ sudo npm install -g git://github.com/jashkenas/coffee-script.git
+
+Compiling
+---------
+
+Run this command in the `mitx` directory to easily make the compiler watch for
+changes in your file, and join the result into `application.js`:
+
+ coffee -j static/js/application.js -cw templates/coffee/src
+
+Please note that the compiler will not be able to detect the file that get added
+after you've ran the command, so you'll need to restart the compiler if there's
+a new CoffeeScript file.
+
+Testing
+=======
+
+We're also using Jasmine to unit-testing the JavaScript files. All the specs are
+written in CoffeeScript for the consistency. Because of the limitation of
+`django-jasmine` plugin, we'll need to also running another compiler to compile
+the test file.
+
+Using this command to compile the test files:
+
+ coffee -cw templates/coffee/spec/*.coffee
+
+Then start the server in debug mode, navigate to http://127.0.0.1:8000/_jasmine
+to see the test result.
diff --git a/templates/coffee/files.json b/templates/coffee/files.json
new file mode 100644
index 0000000000..bfae4dfe87
--- /dev/null
+++ b/templates/coffee/files.json
@@ -0,0 +1,10 @@
+{
+ "js_files": [
+ "/static/js/jquery-1.6.2.min.js",
+ "/static/js/jquery-ui-1.8.16.custom.min.js",
+ "/static/js/jquery.leanModal.js"
+ ],
+ "static_files": [
+ "js/application.js"
+ ]
+}
diff --git a/templates/coffee/fixtures/accordion.html b/templates/coffee/fixtures/accordion.html
new file mode 100644
index 0000000000..148c245c8f
--- /dev/null
+++ b/templates/coffee/fixtures/accordion.html
@@ -0,0 +1,6 @@
+
Enrollment requires a modern web browser with JavaScript enabled. You don't have this. You can’t enroll without upgrading, since you couldn’t take the course without upgrading. Feel free to download the latest version of Mozilla Firefox or Google Chrome, for free, to enroll and take this course.
- Please note that 6.002x has already started.
- Several assignment due dates for 6.002x have already passed. It is now impossible for newly enrolled students to get 100% of the points in the course, although new students can still earn points for assignments whose due dates have not passed, and students have access to all of the course material that has been released for the course.
-
+Please note that 6.002x has now passed its half-way point. The midterm exam and several assignment due dates for 6.002x have already passed. It is now impossible for newly enrolled students to earn a passing grade and a completion certificate for the course. However, new students have access to all of the course material that has been released for the course, so you are welcome to enroll and browse the course.