diff --git a/common/djangoapps/student/management/commands/add_to_group.py b/common/djangoapps/student/management/commands/add_to_group.py new file mode 100644 index 0000000000..209d25da85 --- /dev/null +++ b/common/djangoapps/student/management/commands/add_to_group.py @@ -0,0 +1,62 @@ +from optparse import make_option + +from django.core.management.base import BaseCommand, CommandError +from django.contrib.auth.models import User, Group + +class Command(BaseCommand): + option_list = BaseCommand.option_list + ( + make_option('--list', + action='store_true', + dest='list', + default=False, + help='List available groups'), + make_option('--create', + action='store_true', + dest='create', + default=False, + help='Create the group if it does not exist'), + make_option('--remove', + action='store_true', + dest='remove', + default=False, + help='Remove the user from the group instead of adding it'), + ) + + args = ' ' + help = 'Add a user to a group' + + def print_groups(self): + print 'Groups available:' + for group in Group.objects.all().distinct(): + print ' ', group.name + + def handle(self, *args, **options): + if options['list']: + self.print_groups() + return + + if len(args) != 2: + raise CommandError('Usage is add_to_group {0}'.format(self.args)) + + name_or_email, group_name = args + + if '@' in name_or_email: + user = User.objects.get(email=name_or_email) + else: + user = User.objects.get(username=name_or_email) + + try: + group = Group.objects.get(name=group_name) + except Group.DoesNotExist: + if options['create']: + group = Group(name=group_name) + group.save() + else: + raise CommandError('Group {} does not exist'.format(group_name)) + + if options['remove']: + user.groups.remove(group) + else: + user.groups.add(group) + + print 'Success!' diff --git a/common/djangoapps/student/management/commands/set_staff.py b/common/djangoapps/student/management/commands/set_staff.py index 5e7df1f585..30d0483f50 100644 --- a/common/djangoapps/student/management/commands/set_staff.py +++ b/common/djangoapps/student/management/commands/set_staff.py @@ -1,37 +1,47 @@ +from optparse import make_option + from django.contrib.auth.models import User from django.core.management.base import BaseCommand, CommandError import re class Command(BaseCommand): + option_list = BaseCommand.option_list + ( + make_option('--unset', + action='store_true', + dest='unset', + default=False, + help='Set is_staff to False instead of True'), + ) - args = '' + args = ' [user|email ...]>' help = """ - This command will set isstaff to true for one or more users. + This command will set is_staff to true for one or more users. Lookup by username or email address, assumes usernames do not look like email addresses. """ - def handle(self, *args, **kwargs): - + def handle(self, *args, **options): if len(args) < 1: - print Command.help - return + raise CommandError('Usage is set_staff {0}'.format(self.args)) for user in args: - if re.match('[^@]+@[^@]+\.[^@]+', user): try: v = User.objects.get(email=user) except: - raise CommandError("User {0} does not exist".format( - user)) + raise CommandError("User {0} does not exist".format(user)) else: try: v = User.objects.get(username=user) except: - raise CommandError("User {0} does not exist".format( - user)) + raise CommandError("User {0} does not exist".format(user)) + + if options['unset']: + v.is_staff = False + else: + v.is_staff = True - v.is_staff = True v.save() + + print 'Success!' diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index b2fcc73ca3..2b940fe982 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -39,6 +39,8 @@ from collections import namedtuple from courseware.courses import get_courses_by_university from courseware.access import has_access +from statsd import statsd + log = logging.getLogger("mitx.student") Article = namedtuple('Article', 'title url author image deck publication publish_date') @@ -204,7 +206,13 @@ def change_enrollment(request): return {'success': False, 'error': 'enrollment in {} not allowed at this time' .format(course.display_name)} - + + org, course_num, run=course_id.split("/") + statsd.increment("common.student.enrollment", + tags=["org:{0}".format(org), + "course:{0}".format(course_num), + "run:{0}".format(run)]) + enrollment, created = CourseEnrollment.objects.get_or_create(user=user, course_id=course.id) return {'success': True} @@ -212,6 +220,13 @@ def change_enrollment(request): try: enrollment = CourseEnrollment.objects.get(user=user, course_id=course_id) enrollment.delete() + + org, course_num, run=course_id.split("/") + statsd.increment("common.student.unenrollment", + tags=["org:{0}".format(org), + "course:{0}".format(course_num), + "run:{0}".format(run)]) + return {'success': True} except CourseEnrollment.DoesNotExist: return {'success': False, 'error': 'You are not enrolled for this course.'} @@ -260,7 +275,9 @@ def login_user(request, error=""): log.info("Login success - {0} ({1})".format(username, email)) try_change_enrollment(request) - + + statsd.increment("common.student.successful_login") + return HttpResponse(json.dumps({'success': True})) log.warning("Login failed - Account not active for user {0}, resending activation".format(username)) @@ -466,7 +483,9 @@ def create_account(request, post_override=None): log.debug('bypassing activation email') login_user.is_active = True login_user.save() - + + statsd.increment("common.student.account_created") + js = {'success': True} return HttpResponse(json.dumps(js), mimetype="application/json") diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index d868e66ec5..451891d067 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -32,10 +32,13 @@ from xml.sax.saxutils import unescape import chem import chem.chemcalc +import chem.chemtools + import calc from correctmap import CorrectMap import eia import inputtypes +import customrender from util import contextualize_text, convert_files_to_filenames import xqueue_interface @@ -45,22 +48,8 @@ import responsetypes # dict of tagname, Response Class -- this should come from auto-registering response_tag_dict = dict([(x.response_tag, x) for x in responsetypes.__all__]) -# Different ways students can input code -entry_types = ['textline', - 'schematic', - 'textbox', - 'imageinput', - 'optioninput', - 'choicegroup', - 'radiogroup', - 'checkboxgroup', - 'filesubmission', - 'javascriptinput', - 'crystallography', - 'chemicalequationinput',] - # extra things displayed after "show answers" is pressed -solution_types = ['solution'] +solution_tags = ['solution'] # these get captured as student responses response_properties = ["codeparam", "responseparam", "answer"] @@ -77,7 +66,8 @@ global_context = {'random': random, 'scipy': scipy, 'calc': calc, 'eia': eia, - 'chemcalc': chem.chemcalc} + 'chemcalc': chem.chemcalc, + 'chemtools': chem.chemtools} # These should be removed from HTML output, including all subelements html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup"] @@ -305,7 +295,7 @@ class LoncapaProblem(object): answer_map.update(results) # include solutions from ... stanzas - for entry in self.tree.xpath("//" + "|//".join(solution_types)): + for entry in self.tree.xpath("//" + "|//".join(solution_tags)): answer = etree.tostring(entry) if answer: answer_map[entry.get('id')] = contextualize_text(answer, self.context) @@ -483,7 +473,7 @@ class LoncapaProblem(object): problemid = problemtree.get('id') # my ID - if problemtree.tag in inputtypes.registered_input_tags(): + if problemtree.tag in inputtypes.registry.registered_tags(): # If this is an inputtype subtree, let it render itself. status = "unsubmitted" msg = '' @@ -509,7 +499,7 @@ class LoncapaProblem(object): 'hint': hint, 'hintmode': hintmode,}} - input_type_cls = inputtypes.get_class_for_tag(problemtree.tag) + input_type_cls = inputtypes.registry.get_class_for_tag(problemtree.tag) the_input = input_type_cls(self.system, problemtree, state) return the_input.get_html() @@ -517,9 +507,15 @@ class LoncapaProblem(object): if problemtree in self.responders: return self.responders[problemtree].render_html(self._extract_html) + # let each custom renderer render itself: + if problemtree.tag in customrender.registry.registered_tags(): + renderer_class = customrender.registry.get_class_for_tag(problemtree.tag) + renderer = renderer_class(self.system, problemtree) + return renderer.get_html() + + # otherwise, render children recursively, and copy over attributes tree = etree.Element(problemtree.tag) for item in problemtree: - # render child recursively item_xhtml = self._extract_html(item) if item_xhtml is not None: tree.append(item_xhtml) @@ -556,11 +552,12 @@ class LoncapaProblem(object): response_id += 1 answer_id = 1 + input_tags = inputtypes.registry.registered_tags() inputfields = tree.xpath("|".join(['//' + response.tag + '[@id=$id]//' + x - for x in (entry_types + solution_types)]), + for x in (input_tags + solution_tags)]), id=response_id_str) - # assign one answer_id for each entry_type or solution_type + # assign one answer_id for each input type or solution type for entry in inputfields: entry.attrib['response_id'] = str(response_id) entry.attrib['answer_id'] = str(answer_id) diff --git a/common/lib/capa/capa/chem/chemtools.py b/common/lib/capa/capa/chem/chemtools.py new file mode 100644 index 0000000000..ad6633fc00 --- /dev/null +++ b/common/lib/capa/capa/chem/chemtools.py @@ -0,0 +1,206 @@ +"""This module originally includes functions for grading Vsepr problems. + +Also, may be this module is the place for other chemistry-related grade functions. TODO: discuss it. +""" + +import json +import unittest +import itertools + + +def vsepr_parse_user_answer(user_input): + """ + user_input is json generated by vsepr.js from dictionary. + There are must be only two keys in original user_input dictionary: "geometry" and "atoms". + Format: u'{"geometry": "AX3E0","atoms":{"c0": "B","p0": "F","p1": "B","p2": "F"}}' + Order of elements inside "atoms" subdict does not matters. + Return dict from parsed json. + + "Atoms" subdict stores positions of atoms in molecule. + General types of positions: + c0 - central atom + p0..pN - peripheral atoms + a0..aN - axial atoms + e0..eN - equatorial atoms + + Each position is dictionary key, i.e. user_input["atoms"]["c0"] is central atom, user_input["atoms"]["a0"] is one of axial atoms. + + Special position only for AX6 (Octahedral) geometry: + e10, e12 - atom pairs opposite the central atom, + e20, e22 - atom pairs opposite the central atom, + e1 and e2 pairs lying crosswise in equatorial plane. + + In user_input["atoms"] may be only 3 set of keys: + (c0,p0..pN), + (c0, a0..aN, e0..eN), + (c0, a0, a1, e10,e11,e20,e21) - if geometry is AX6. + """ + return json.loads(user_input) + + +def vsepr_build_correct_answer(geometry, atoms): + """ + geometry is string. + atoms is dict of atoms with proper positions. + Example: + + correct_answer = vsepr_build_correct_answer(geometry="AX4E0", atoms={"c0": "N", "p0": "H", "p1": "(ep)", "p2": "H", "p3": "H"}) + + returns a dictionary composed from input values: + {'geometry': geometry, 'atoms': atoms} + """ + return {'geometry': geometry, 'atoms': atoms} + + +def vsepr_grade(user_input, correct_answer, convert_to_peripheral=False): + """ + This function does comparison between user_input and correct_answer. + + Comparison is successful if all steps are successful: + + 1) geometries are equal + 2) central atoms (index in dictionary 'c0') are equal + 3): + In next steps there is comparing of corresponding subsets of atom positions: equatorial (e0..eN), axial (a0..aN) or peripheral (p0..pN) + + If convert_to_peripheral is True, then axial and equatorial positions are converted to peripheral. + This means that user_input from: + "atoms":{"c0": "Br","a0": "test","a1": "(ep)","e10": "H","e11": "(ep)","e20": "H","e21": "(ep)"}}' after parsing to json + is converted to: + {"c0": "Br", "p0": "(ep)", "p1": "test", "p2": "H", "p3": "H", "p4": "(ep)", "p6": "(ep)"} + i.e. aX and eX -> pX + + So if converted, p subsets are compared, + if not a and e subsets are compared + If all subsets are equal, grade succeeds. + + There is also one special case for AX6 geometry. + In this case user_input["atoms"] contains special 3 symbol keys: e10, e12, e20, and e21. + Correct answer for this geometry can be of 3 types: + 1) c0 and peripheral + 2) c0 and axial and equatorial + 3) c0 and axial and equatorial-subset-1 (e1X) and equatorial-subset-2 (e2X) + + If correct answer is type 1 or 2, then user_input is converted from type 3 to type 2 (or to type 1 if convert_to_peripheral is True) + + If correct_answer is type 3, then we done special case comparison. We have 3 sets of atoms positions both in user_input and correct_answer: axial, eq-1 and eq-2. + Answer will be correct if these sets are equals for one of permutations. For example, if : + user_axial = correct_eq-1 + user_eq-1 = correct-axial + user_eq-2 = correct-eq-2 + + """ + if user_input['geometry'] != correct_answer['geometry']: + return False + + if user_input['atoms']['c0'] != correct_answer['atoms']['c0']: + return False + + if convert_to_peripheral: + # convert user_input from (a,e,e1,e2) to (p) + # correct_answer must be set in (p) using this flag + c0 = user_input['atoms'].pop('c0') + user_input['atoms'] = {'p' + str(i): v for i, v in enumerate(user_input['atoms'].values())} + user_input['atoms']['c0'] = c0 + + # special case for AX6 + if 'e10' in correct_answer['atoms']: # need check e1x, e2x symmetry for AX6.. + a_user = {} + a_correct = {} + for ea_position in ['a', 'e1', 'e2']: # collecting positions: + a_user[ea_position] = [v for k, v in user_input['atoms'].items() if k.startswith(ea_position)] + a_correct[ea_position] = [v for k, v in correct_answer['atoms'].items() if k.startswith(ea_position)] + + correct = [sorted(a_correct['a'])] + [sorted(a_correct['e1'])] + [sorted(a_correct['e2'])] + for permutation in itertools.permutations(['a', 'e1', 'e2']): + if correct == [sorted(a_user[permutation[0]])] + [sorted(a_user[permutation[1]])] + [sorted(a_user[permutation[2]])]: + return True + return False + + else: # no need to check e1x,e2x symmetry - convert them to ex + if 'e10' in user_input['atoms']: # e1x exists, it is AX6.. case + e_index = 0 + for k, v in user_input['atoms'].items(): + if len(k) == 3: # e1x + del user_input['atoms'][k] + user_input['atoms']['e' + str(e_index)] = v + e_index += 1 + + # common case + for ea_position in ['p', 'a', 'e']: + # collecting atoms: + a_user = [v for k, v in user_input['atoms'].items() if k.startswith(ea_position)] + a_correct = [v for k, v in correct_answer['atoms'].items() if k.startswith(ea_position)] + # print a_user, a_correct + if len(a_user) != len(a_correct): + return False + if sorted(a_user) != sorted(a_correct): + return False + + return True + + +class Test_Grade(unittest.TestCase): + ''' test grade function ''' + + def test_incorrect_geometry(self): + correct_answer = vsepr_build_correct_answer(geometry="AX4E0", atoms={"c0": "N", "p0": "H", "p1": "(ep)", "p2": "H", "p3": "H"}) + user_answer = vsepr_parse_user_answer(u'{"geometry": "AX3E0","atoms":{"c0": "B","p0": "F","p1": "B","p2": "F"}}') + self.assertFalse(vsepr_grade(user_answer, correct_answer)) + + def test_correct_answer_p(self): + correct_answer = vsepr_build_correct_answer(geometry="AX4E0", atoms={"c0": "N", "p0": "H", "p1": "(ep)", "p2": "H", "p3": "H"}) + user_answer = vsepr_parse_user_answer(u'{"geometry": "AX4E0","atoms":{"c0": "N","p0": "H","p1": "(ep)","p2": "H", "p3": "H"}}') + self.assertTrue(vsepr_grade(user_answer, correct_answer)) + + def test_correct_answer_ae(self): + correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "test", "a1": "(ep)", "e0": "H", "e1": "H", "e2": "(ep)", "e3": "(ep)"}) + user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "test","a1": "(ep)","e10": "H","e11": "H","e20": "(ep)","e21": "(ep)"}}') + self.assertTrue(vsepr_grade(user_answer, correct_answer)) + + def test_correct_answer_ae_convert_to_p_but_input_not_in_p(self): + correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "test", "e0": "H", "e1": "H", "e2": "(ep)", "e3": "(ep)"}) + user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "test","a1": "(ep)","e10": "H","e11": "(ep)","e20": "H","e21": "(ep)"}}') + self.assertFalse(vsepr_grade(user_answer, correct_answer, convert_to_peripheral=True)) + + def test_correct_answer_ae_convert_to_p(self): + correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "p0": "(ep)", "p1": "test", "p2": "H", "p3": "H", "p4": "(ep)", "p6": "(ep)"}) + user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "test","a1": "(ep)","e10": "H","e11": "(ep)","e20": "H","e21": "(ep)"}}') + self.assertTrue(vsepr_grade(user_answer, correct_answer, convert_to_peripheral=True)) + + def test_correct_answer_e1e2_in_a(self): + correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "(ep)", "e10": "H", "e11": "H", "e20": "H", "e21": "H"}) + user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "(ep)","a1": "(ep)","e10": "H","e11": "H","e20": "H","e21": "H"}}') + self.assertTrue(vsepr_grade(user_answer, correct_answer)) + + def test_correct_answer_e1e2_in_e1(self): + correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "(ep)", "e10": "H", "e11": "H", "e20": "H", "e21": "H"}) + user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "H","a1": "H","e10": "(ep)","e11": "(ep)","e20": "H","e21": "H"}}') + self.assertTrue(vsepr_grade(user_answer, correct_answer)) + + def test_correct_answer_e1e2_in_e2(self): + correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "(ep)", "e10": "H", "e11": "H", "e20": "H", "e21": "H"}) + user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "H","a1": "H","e10": "H","e11": "H","e20": "(ep)","e21": "(ep)"}}') + self.assertTrue(vsepr_grade(user_answer, correct_answer)) + + def test_incorrect_answer_e1e2(self): + correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "(ep)", "e10": "H", "e11": "H", "e20": "H", "e21": "H"}) + user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "H","a1": "H","e10": "(ep)","e11": "H","e20": "H","e21": "(ep)"}}') + self.assertFalse(vsepr_grade(user_answer, correct_answer)) + + def test_incorrect_c0(self): + correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "test", "e0": "H", "e1": "H", "e2": "H", "e3": "(ep)"}) + user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "H","a0": "test","a1": "(ep)","e0": "H","e1": "H","e2": "(ep)","e3": "H"}}') + self.assertFalse(vsepr_grade(user_answer, correct_answer)) + + +def suite(): + + testcases = [Test_Grade] + suites = [] + for testcase in testcases: + suites.append(unittest.TestLoader().loadTestsFromTestCase(testcase)) + return unittest.TestSuite(suites) + +if __name__ == "__main__": + unittest.TextTestRunner(verbosity=2).run(suite()) diff --git a/common/lib/capa/capa/correctmap.py b/common/lib/capa/capa/correctmap.py index 227f85bc8e..c7386219b1 100644 --- a/common/lib/capa/capa/correctmap.py +++ b/common/lib/capa/capa/correctmap.py @@ -3,7 +3,6 @@ # # Used by responsetypes and capa_problem - class CorrectMap(object): """ Stores map between answer_id and response evaluation result for each question @@ -69,7 +68,7 @@ class CorrectMap(object): correct_map is saved by LMS as a plaintext JSON dump of the correctmap dict. This means that when the definition of CorrectMap (e.g. its properties) are altered, - an existing correct_map dict not coincide with the newest CorrectMap format as + an existing correct_map dict will not coincide with the newest CorrectMap format as defined by self.set. For graceful migration, feed the contents of each correct map to self.set, rather than diff --git a/common/lib/capa/capa/customrender.py b/common/lib/capa/capa/customrender.py new file mode 100644 index 0000000000..ef1044e8b1 --- /dev/null +++ b/common/lib/capa/capa/customrender.py @@ -0,0 +1,100 @@ +""" +This has custom renderers: classes that know how to render certain problem tags (e.g. and +) to html. + +These tags do not have state, so they just get passed the system (for access to render_template), +and the xml element. +""" + +from registry import TagRegistry + +import logging +import re +import shlex # for splitting quoted strings +import json + +from lxml import etree +import xml.sax.saxutils as saxutils +from registry import TagRegistry + +log = logging.getLogger('mitx.' + __name__) + +registry = TagRegistry() + +#----------------------------------------------------------------------------- +class MathRenderer(object): + tags = ['math'] + + def __init__(self, system, xml): + ''' + Render math using latex-like formatting. + + 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!) + ''' + self.system = system + self.xml = xml + + mathstr = re.sub('\$(.*)\$', r'[mathjaxinline]\1[/mathjaxinline]', xml.text) + mtag = 'mathjax' + if not r'\displaystyle' in mathstr: + mtag += 'inline' + else: + mathstr = mathstr.replace(r'\displaystyle', '') + self.mathstr = mathstr.replace('mathjaxinline]', '%s]' % mtag) + + + def get_html(self): + """ + Return the contents of this tag, rendered to html, as an etree element. + """ + # TODO: why are there nested html tags here?? Why are there html tags at all, in fact? + html = '%s%s' % ( + self.mathstr, saxutils.escape(self.xml.tail)) + try: + xhtml = etree.XML(html) + except Exception as err: + if self.system.DEBUG: + msg = '

Error %s

' % ( + str(err).replace('<', '<')) + msg += ('

Failed to construct math expression from

%s

' % + html.replace('<', '<')) + msg += "
" + log.error(msg) + return etree.XML(msg) + else: + raise + return xhtml + + +registry.register(MathRenderer) + +#----------------------------------------------------------------------------- + +class SolutionRenderer(object): + ''' + A solution 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 rendered and returned in the HTML. It is obtained by an + ajax call. + ''' + tags = ['solution'] + + def __init__(self, system, xml): + self.system = system + self.id = xml.get('id') + + def get_html(self): + context = {'id': self.id} + html = self.system.render_template("solutionspan.html", context) + return etree.XML(html) + +registry.register(SolutionRenderer) + diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 0e993c1366..de29b5e664 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -6,11 +6,9 @@ Module containing the problem elements which render into input objects - textline -- textbox (change this to textarea?) -- schemmatic -- choicegroup -- radiogroup -- checkboxgroup +- textbox (aka codeinput) +- schematic +- choicegroup (aka radiogroup, checkboxgroup) - javascriptinput - imageinput (for clickable image) - optioninput (for option list) @@ -23,64 +21,34 @@ Each input type takes the xml tree as 'element', the previous answer as 'value', 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. +# TODO: there is a lot of repetitive "grab these elements from xml attributes, with these defaults, +# put them in the context" code. Refactor so class just specifies required and optional attrs (with +# defaults for latter), and InputTypeBase does the right thing. +# TODO: Quoting and unquoting is handled in a pretty ad-hoc way. Also something that could be done +# properly once in InputTypeBase. + +# Possible todo: make inline the default for textlines and other "one-line" inputs. It probably +# makes sense, but a bunch of problems have markup that assumes block. Bigger TODO: figure out a +# general css and layout strategy for capa, document it, then implement it. + + + +import json import logging +from lxml import etree import re import shlex # for splitting quoted strings -import json - -from lxml import etree +import sys import xml.sax.saxutils as saxutils +from registry import TagRegistry + log = logging.getLogger('mitx.' + __name__) ######################################################################### -_TAGS_TO_CLASSES = {} - -def register_input_class(cls): - """ - Register cls as a supported input type. It is expected to have the same constructor as - InputTypeBase, and to define cls.tags as a list of tags that it implements. - - If an already-registered input type has claimed one of those tags, will raise ValueError. - - If there are no tags in cls.tags, will also raise ValueError. - """ - - # Do all checks and complain before changing any state. - if len(cls.tags) == 0: - raise ValueError("No supported tags for class {0}".format(cls.__name__)) - - for t in cls.tags: - if t in _TAGS_TO_CLASSES: - other_cls = _TAGS_TO_CLASSES[t] - if cls == other_cls: - # registering the same class multiple times seems silly, but ok - continue - raise ValueError("Tag {0} already registered by class {1}. Can't register for class {2}" - .format(t, other_cls.__name__, cls.__name__)) - - # Ok, should be good to change state now. - for t in cls.tags: - _TAGS_TO_CLASSES[t] = cls - -def registered_input_tags(): - """ - Get a list of all the xml tags that map to known input types. - """ - return _TAGS_TO_CLASSES.keys() - - -def get_class_for_tag(tag): - """ - For any tag in registered_input_tags(), return the corresponding class. Otherwise, will raise KeyError. - """ - return _TAGS_TO_CLASSES[tag] - +registry = TagRegistry() class InputTypeBase(object): """ @@ -93,16 +61,18 @@ class InputTypeBase(object): """ Instantiate an InputType class. Arguments: - - system : ModuleSystem instance which provides OS, rendering, and user context. Specifically, must - have a render_template function. + - system : ModuleSystem instance which provides OS, rendering, and user context. + Specifically, must have a render_template function. - xml : Element tree of this Input element - state : a dictionary with optional keys: - * 'value' - * 'id' + * 'value' -- the current value of this input + (what the student entered last time) + * 'id' -- the id of this input, typically + "{problem-location}_{response-num}_{input-num}" * 'status' (answered, unanswered, unsubmitted) * 'feedback' (dictionary containing keys for hints, errors, or other - feedback from previous attempt. Specifically 'message', 'hint', 'hintmode'. If 'hintmode' - is 'always', the hint is always displayed.) + feedback from previous attempt. Specifically 'message', 'hint', + 'hintmode'. If 'hintmode' is 'always', the hint is always displayed.) """ self.xml = xml @@ -132,6 +102,26 @@ class InputTypeBase(object): self.status = state.get('status', 'unanswered') + # Call subclass "constructor" -- means they don't have to worry about calling + # super().__init__, and are isolated from changes to the input constructor interface. + try: + self.setup() + except Exception as err: + # Something went wrong: add xml to message, but keep the traceback + msg = "Error in xml '{x}': {err} ".format(x=etree.tostring(xml), err=str(err)) + raise Exception, msg, sys.exc_info()[2] + + + def setup(self): + """ + InputTypes should override this to do any needed initialization. It is called after the + constructor, so all base attributes will be set. + + If this method raises an exception, it will be wrapped with a message that includes the + problem xml. + """ + pass + def _get_render_context(self): """ Abstract method. Subclasses should implement to return the dictionary @@ -146,40 +136,13 @@ class InputTypeBase(object): Return the html for this input, as an etree element. """ if self.template is None: - raise NotImplementedError("no rendering template specified for class {0}".format(self.__class__)) + raise NotImplementedError("no rendering template specified for class {0}" + .format(self.__class__)) html = self.system.render_template(self.template, self._get_render_context()) return etree.XML(html) -## TODO: Remove once refactor is complete -def make_class_for_render_function(fn): - """ - Take an old-style render function, return a new-style input class. - """ - - class Impl(InputTypeBase): - """ - Inherit all the constructor logic from InputTypeBase... - """ - tags = [fn.__name__] - def get_html(self): - """...delegate to the render function to do the work""" - return fn(self.xml, self.value, self.status, self.system.render_template, self.msg) - - # don't want all the classes to be called Impl (confuses register_input_class). - Impl.__name__ = fn.__name__.capitalize() - return Impl - - -def _reg(fn): - """ - Register an old-style inputtype render function as a new-style subclass of InputTypeBase. - This will go away once converting all input types to the new format is complete. (TODO) - """ - register_input_class(make_class_for_render_function(fn)) - - #----------------------------------------------------------------------------- @@ -195,100 +158,98 @@ class OptionInput(InputTypeBase): template = "optioninput.html" tags = ['optioninput'] + def setup(self): + # Extract the options... + options = self.xml.get('options') + if not options: + raise ValueError("optioninput: Missing 'options' specification.") + + # parse the set of possible options + oset = shlex.shlex(options[1:-1]) + oset.quotes = "'" + oset.whitespace = "," + oset = [x[1:-1] for x in list(oset)] + + # make ordered list with (key, value) same + self.osetdict = [(oset[x], oset[x]) for x in range(len(oset))] + # TODO: allow ordering to be randomized + def _get_render_context(self): - return _optioninput(self.xml, self.value, self.status, self.system.render_template, self.msg) + context = { + 'id': self.id, + 'value': self.value, + 'status': self.status, + 'msg': self.msg, + 'options': self.osetdict, + 'inline': self.xml.get('inline',''), + } + return context -def optioninput(element, value, status, render_template, msg=''): - context = _optioninput(element, value, status, render_template, msg) - html = render_template("optioninput.html", context) - return etree.XML(html) - -def _optioninput(element, value, status, render_template, 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)) - - # parse the set of possible options - oset = shlex.shlex(options[1:-1]) - oset.quotes = "'" - oset.whitespace = "," - oset = [x[1:-1] for x in list(oset)] - - # make ordered list with (key, value) same - osetdict = [(oset[x], oset[x]) for x in range(len(oset))] - # TODO: allow ordering to be randomized - - context = {'id': eid, - 'value': value, - 'state': status, - 'msg': msg, - 'options': osetdict, - 'inline': element.get('inline',''), - } - return context - -register_input_class(OptionInput) +registry.register(OptionInput) #----------------------------------------------------------------------------- # TODO: consolidate choicegroup, radiogroup, checkboxgroup after discussion of # desired semantics. -# @register_render_function -def choicegroup(element, value, status, render_template, msg=''): - ''' - Radio button inputs: multiple choice or true/false + +class ChoiceGroup(InputTypeBase): + """ + Radio button or checkbox 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": - element_type = "radio" - elif element.get('type') == "TrueFalse": - element_type = "checkbox" - else: - element_type = "radio" - choices = [] - for choice in element: - if not choice.tag == 'choice': - raise Exception("[courseware.capa.inputtypes.choicegroup] " - "Error: only tags should be immediate children " - "of a , found %s instead" % choice.tag) - ctext = "" - # TODO: what if choice[0] has math tags in it? - ctext += ''.join([etree.tostring(x) for x in choice]) - if choice.text is not None: - # TODO: fix order? - ctext += choice.text - choices.append((choice.get("name"), ctext)) - context = {'id': eid, - 'value': value, - 'state': status, - 'input_type': element_type, - 'choices': choices, - 'name_array_suffix': ''} - html = render_template("choicegroup.html", context) - return etree.XML(html) -_reg(choicegroup) + Example: + + + + This is foil One. + + + This is foil Two. + + + This is foil Three. + + + """ + template = "choicegroup.html" + tags = ['choicegroup', 'radiogroup', 'checkboxgroup'] + + def setup(self): + # suffix is '' or [] to change the way the input is handled in --as a scalar or vector + # value. (VS: would be nice to make this less hackish). + if self.tag == 'choicegroup': + self.suffix = '' + self.element_type = "radio" + elif self.tag == 'radiogroup': + self.element_type = "radio" + self.suffix = '[]' + elif self.tag == 'checkboxgroup': + self.element_type = "checkbox" + self.suffix = '[]' + else: + raise Exception("ChoiceGroup: unexpected tag {0}".format(self.tag)) + + self.choices = extract_choices(self.xml) + + def _get_render_context(self): + context = {'id': self.id, + 'value': self.value, + 'status': self.status, + 'input_type': self.element_type, + 'choices': self.choices, + 'name_array_suffix': self.suffix} + return context -#----------------------------------------------------------------------------- def extract_choices(element): ''' - Extracts choices for a few input types, such as radiogroup and - checkboxgroup. + Extracts choices for a few input types, such as ChoiceGroup, RadioGroup and + CheckboxGroup. + + returns list of (choice_name, choice_text) tuples TODO: allow order of choices to be randomized, following lon-capa spec. Use "location" attribute, ie random, top, bottom. @@ -297,380 +258,258 @@ def extract_choices(element): choices = [] for choice in element: - if not choice.tag == 'choice': - raise Exception("[courseware.capa.inputtypes.extract_choices] \ - Expected a tag; got %s instead" - % choice.tag) + if choice.tag != 'choice': + raise Exception( + "[capa.inputtypes.extract_choices] Expected a tag; got %s instead" + % choice.tag) choice_text = ''.join([etree.tostring(x) for x in choice]) + if choice.text is not None: + # TODO: fix order? + choice_text += choice.text choices.append((choice.get("name"), choice_text)) return choices -# TODO: consolidate choicegroup, radiogroup, checkboxgroup after discussion of -# desired semantics. -def radiogroup(element, value, status, render_template, msg=''): - ''' - Radio button inputs: (multiple choice) - ''' - - eid = element.get('id') - - choices = extract_choices(element) - - context = {'id': eid, - 'value': value, - 'state': status, - 'input_type': 'radio', - 'choices': choices, - 'name_array_suffix': '[]'} - - html = render_template("choicegroup.html", context) - return etree.XML(html) +registry.register(ChoiceGroup) -_reg(radiogroup) +#----------------------------------------------------------------------------- -# TODO: consolidate choicegroup, radiogroup, checkboxgroup after discussion of -# desired semantics. -def checkboxgroup(element, value, status, render_template, msg=''): - ''' - Checkbox inputs: (select one or more choices) - ''' - eid = element.get('id') - - choices = extract_choices(element) - - context = {'id': eid, - 'value': value, - 'state': status, - 'input_type': 'checkbox', - 'choices': choices, - 'name_array_suffix': '[]'} - - html = render_template("choicegroup.html", context) - return etree.XML(html) - -_reg(checkboxgroup) - -def javascriptinput(element, value, status, render_template, msg='null'): - ''' +class JavascriptInput(InputTypeBase): + """ Hidden field for javascript to communicate via; also loads the required scripts for rendering the problem and passes data to the problem. - ''' - eid = element.get('id') - params = element.get('params') - problem_state = element.get('problem_state') - display_class = element.get('display_class') - display_file = element.get('display_file') - # Need to provide a value that JSON can parse if there is no - # student-supplied value yet. - if value == "": - value = 'null' + TODO (arjun?): document this in detail. Initial notes: + - display_class is a subclass of XProblemClassDisplay (see + xmodule/xmodule/js/src/capa/display.coffee), + - display_file is the js script to be in /static/js/ where display_class is defined. + """ - escapedict = {'"': '"'} - value = saxutils.escape(value, escapedict) - msg = saxutils.escape(msg, escapedict) - context = {'id': eid, - 'params': params, - 'display_file': display_file, - 'display_class': display_class, - 'problem_state': problem_state, + template = "javascriptinput.html" + tags = ['javascriptinput'] + + def setup(self): + # Need to provide a value that JSON can parse if there is no + # student-supplied value yet. + if self.value == "": + self.value = 'null' + + self.params = self.xml.get('params') + self.problem_state = self.xml.get('problem_state') + self.display_class = self.xml.get('display_class') + self.display_file = self.xml.get('display_file') + + + def _get_render_context(self): + escapedict = {'"': '"'} + value = saxutils.escape(self.value, escapedict) + msg = saxutils.escape(self.msg, escapedict) + + context = {'id': self.id, + 'params': self.params, + 'display_file': self.display_file, + 'display_class': self.display_class, + 'problem_state': self.problem_state, 'value': value, 'evaluation': msg, } - html = render_template("javascriptinput.html", context) - return etree.XML(html) + return context -_reg(javascriptinput) +registry.register(JavascriptInput) -def textline(element, value, status, render_template, msg=""): - ''' - Simple text line input, with optional size specification. - ''' - # TODO: 'dojs' flag is temporary, for backwards compatibility with 8.02x - if element.get('math') or element.get('dojs'): - return textline_dynamath(element, value, status, render_template, msg) - eid = element.get('id') - if eid is None: - msg = 'textline has no id: it probably appears outside of a known response type' - msg += "\nSee problem XML source line %s" % getattr(element, 'sourceline', '') - raise Exception(msg) +#----------------------------------------------------------------------------- - count = int(eid.split('_')[-2]) - 1 # HACK - size = element.get('size') - # if specified, then textline is hidden and id is stored in div of name given by hidden - hidden = element.get('hidden', '') +class TextLine(InputTypeBase): + """ - # Escape answers with quotes, so they don't crash the system! - escapedict = {'"': '"'} - value = saxutils.escape(value, escapedict) + """ - context = {'id': eid, - 'value': value, - 'state': status, - 'count': count, - 'size': size, - 'msg': msg, - 'hidden': hidden, - 'inline': element.get('inline',''), + template = "textline.html" + tags = ['textline'] + + def setup(self): + self.size = self.xml.get('size') + + # if specified, then textline is hidden and input id is stored + # in div with name=self.hidden. + self.hidden = self.xml.get('hidden', False) + + self.inline = self.xml.get('inline', False) + + # TODO: 'dojs' flag is temporary, for backwards compatibility with 8.02x + self.do_math = bool(self.xml.get('math') or self.xml.get('dojs')) + # TODO: do math checking using ajax instead of using js, so + # that we only have one math parser. + self.preprocessor = None + if self.do_math: + # Preprocessor to insert between raw input and Mathjax + self.preprocessor = {'class_name': self.xml.get('preprocessorClassName',''), + 'script_src': self.xml.get('preprocessorSrc','')} + if '' in self.preprocessor.values(): + self.preprocessor = None + + + + def _get_render_context(self): + # Escape answers with quotes, so they don't crash the system! + escapedict = {'"': '"'} + value = saxutils.escape(self.value, escapedict) + + context = {'id': self.id, + 'value': value, + 'status': self.status, + 'size': self.size, + 'msg': self.msg, + 'hidden': self.hidden, + 'inline': self.inline, + 'do_math': self.do_math, + 'preprocessor': self.preprocessor, } + return context - html = render_template("textinput.html", context) - try: - xhtml = etree.XML(html) - except Exception as err: - # TODO: needs to be self.system.DEBUG - but can't access system - if True: - log.debug('[inputtypes.textline] failed to parse XML for:\n%s' % html) - raise - return xhtml - -_reg(textline) +registry.register(TextLine) #----------------------------------------------------------------------------- +class FileSubmission(InputTypeBase): + """ + Upload some files (e.g. for programming assignments) + """ -def textline_dynamath(element, value, status, render_template, msg=''): - ''' - Text line input with dynamic math display (equation rendered on client in real time - during input). - ''' - # TODO: Make a wrapper for - # TODO: Make an AJAX loop to confirm equation is okay in real-time as user types - ''' - textline is used for simple one-line inputs, like formularesponse and symbolicresponse. - uses a `{::}` - and a hidden textarea with id=input_eid_fromjs for the mathjax rendering and return. - ''' - eid = element.get('id') - count = int(eid.split('_')[-2]) - 1 # HACK - size = element.get('size') - # if specified, then textline is hidden and id is stored in div of name given by hidden - hidden = element.get('hidden', '') + template = "filesubmission.html" + tags = ['filesubmission'] - # Preprocessor to insert between raw input and Mathjax - preprocessor = {'class_name': element.get('preprocessorClassName',''), - 'script_src': element.get('preprocessorSrc','')} - if '' in preprocessor.values(): - preprocessor = None + # pulled out for testing + submitted_msg = ("Your file(s) have been submitted; as soon as your submission is" + " graded, this message will be replaced with the grader's feedback.") - # Escape characters in student input for safe XML parsing - escapedict = {'"': '"'} - value = saxutils.escape(value, escapedict) + def setup(self): + escapedict = {'"': '"'} + self.allowed_files = json.dumps(self.xml.get('allowed_files', '').split()) + self.allowed_files = saxutils.escape(self.allowed_files, escapedict) + self.required_files = json.dumps(self.xml.get('required_files', '').split()) + self.required_files = saxutils.escape(self.required_files, escapedict) - context = {'id': eid, - 'value': value, - 'state': status, - 'count': count, - 'size': size, - 'msg': msg, - 'hidden': hidden, - 'preprocessor': preprocessor,} - html = render_template("textinput_dynamath.html", context) - return etree.XML(html) + # Check if problem has been queued + self.queue_len = 0 + # Flag indicating that the problem has been queued, 'msg' is length of queue + if self.status == 'incomplete': + self.status = 'queued' + self.queue_len = self.msg + self.msg = FileSubmission.submitted_msg + + def _get_render_context(self): + + context = {'id': self.id, + 'status': self.status, + 'msg': self.msg, + 'value': self.value, + 'queue_len': self.queue_len, + 'allowed_files': self.allowed_files, + 'required_files': self.required_files,} + return context + +registry.register(FileSubmission) #----------------------------------------------------------------------------- -def filesubmission(element, value, status, render_template, msg=''): - ''' - Upload a single file (e.g. for programming assignments) - ''' - eid = element.get('id') - escapedict = {'"': '"'} - allowed_files = json.dumps(element.get('allowed_files', '').split()) - allowed_files = saxutils.escape(allowed_files, escapedict) - required_files = json.dumps(element.get('required_files', '').split()) - required_files = saxutils.escape(required_files, escapedict) - # Check if problem has been queued - queue_len = 0 - # Flag indicating that the problem has been queued, 'msg' is length of queue - if status == 'incomplete': - status = 'queued' - queue_len = msg - msg = 'Submitted to grader.' +class CodeInput(InputTypeBase): + """ + A text area input for code--uses codemirror, does syntax highlighting, special tab handling, + etc. + """ - context = { 'id': eid, - 'state': status, - 'msg': msg, - 'value': value, - 'queue_len': queue_len, - 'allowed_files': allowed_files, - 'required_files': required_files,} - html = render_template("filesubmission.html", context) - return etree.XML(html) - -_reg(filesubmission) + template = "codeinput.html" + tags = ['codeinput', + 'textbox', # Another (older) name--at some point we may want to make it use a + # non-codemirror editor. + ] -#----------------------------------------------------------------------------- -## TODO: Make a wrapper for -def textbox(element, value, status, render_template, 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. + def setup(self): + self.rows = self.xml.get('rows') or '30' + self.cols = self.xml.get('cols') or '80' + # if specified, then textline is hidden and id is stored in div of name given by hidden + self.hidden = self.xml.get('hidden', '') - ''' - eid = element.get('id') - count = int(eid.split('_')[-2]) - 1 # HACK - size = element.get('size') - rows = element.get('rows') or '30' - cols = element.get('cols') or '80' - # if specified, then textline is hidden and id is stored in div of name given by hidden - hidden = element.get('hidden', '') + # if no student input yet, then use the default input given by the problem + if not self.value: + self.value = self.xml.text - # if no student input yet, then use the default input given by the problem - if not value: - value = element.text + # Check if problem has been queued + self.queue_len = 0 + # Flag indicating that the problem has been queued, 'msg' is length of queue + if self.status == 'incomplete': + self.status = 'queued' + self.queue_len = self.msg + self.msg = 'Submitted to grader.' - # Check if problem has been queued - queue_len = 0 - # Flag indicating that the problem has been queued, 'msg' is length of queue - if status == 'incomplete': - status = 'queued' - queue_len = msg - msg = 'Submitted to grader.' + # For CodeMirror + self.mode = self.xml.get('mode', 'python') + self.linenumbers = self.xml.get('linenumbers', 'true') + self.tabsize = int(self.xml.get('tabsize', '4')) - # For CodeMirror - mode = element.get('mode','python') - linenumbers = element.get('linenumbers','true') - tabsize = element.get('tabsize','4') - tabsize = int(tabsize) + def _get_render_context(self): - context = {'id': eid, - 'value': value, - 'state': status, - 'count': count, - 'size': size, - 'msg': msg, - 'mode': mode, - 'linenumbers': linenumbers, - 'rows': rows, - 'cols': cols, - 'hidden': hidden, - 'tabsize': tabsize, - 'queue_len': queue_len, + context = {'id': self.id, + 'value': self.value, + 'status': self.status, + 'msg': self.msg, + 'mode': self.mode, + 'linenumbers': self.linenumbers, + 'rows': self.rows, + 'cols': self.cols, + 'hidden': self.hidden, + 'tabsize': self.tabsize, + 'queue_len': self.queue_len, } - html = render_template("textbox.html", context) - try: - xhtml = etree.XML(html) - except Exception as err: - newmsg = 'error %s in rendering message' % (str(err).replace('<', '<')) - newmsg += '
Original message: %s' % msg.replace('<', '<') - context['msg'] = newmsg - html = render_template("textbox.html", context) - xhtml = etree.XML(html) - return xhtml + return context +registry.register(CodeInput) -_reg(textbox) #----------------------------------------------------------------------------- -def schematic(element, value, status, render_template, 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': status, - 'width': width, - 'height': height, - 'parts': parts, - 'analyses': analyses, - 'submit_analyses': submit_analyses, - } - html = render_template("schematicinput.html", context) - return etree.XML(html) +class Schematic(InputTypeBase): + """ + """ -_reg(schematic) + template = "schematicinput.html" + tags = ['schematic'] -#----------------------------------------------------------------------------- -### TODO: Move out of inputtypes -def math(element, value, status, render_template, msg=''): - ''' - This is not really an input type. It is a convention from Lon-CAPA, used for - displaying a math equation. + def setup(self): + self.height = self.xml.get('height') + self.width = self.xml.get('width') + self.parts = self.xml.get('parts') + self.analyses = self.xml.get('analyses') + self.initial_value = self.xml.get('initial_value') + self.submit_analyses = self.xml.get('submit_analyses') - Examples: - $\displaystyle U(r)=4 U_0 - $r_0$ + def _get_render_context(self): - We convert these to [mathjax]...[/mathjax] and [mathjaxinline]...[/mathjaxinline] + context = {'id': self.id, + 'value': self.value, + 'initial_value': self.initial_value, + 'status': self.status, + 'width': self.width, + 'height': self.height, + 'parts': self.parts, + 'analyses': self.analyses, + 'submit_analyses': self.submit_analyses,} + return context - 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_template("mathstring.html", {'mathstr':mathstr, - # 'isinline':isinline,'tail':element.tail}) - - html = '%s%s' % (mathstr, saxutils.escape(element.tail)) - try: - xhtml = etree.XML(html) - except Exception as err: - if False: # TODO needs to be self.system.DEBUG - but can't access system - msg = '

Error %s

' % str(err).replace('<', '<') - msg += ('

Failed to construct math expression from

%s

' % - html.replace('<', '<')) - msg += "
" - log.error(msg) - return etree.XML(msg) - else: - raise - # xhtml.tail = element.tail # don't forget to include the tail! - return xhtml - -_reg(math) +registry.register(Schematic) #----------------------------------------------------------------------------- - -def solution(element, value, status, render_template, 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 an ajax call. - ''' - eid = element.get('id') - size = element.get('size') - context = {'id': eid, - 'value': value, - 'state': status, - 'size': size, - 'msg': msg, - } - html = render_template("solutionspan.html", context) - return etree.XML(html) - -_reg(solution) - -#----------------------------------------------------------------------------- - - -def imageinput(element, value, status, render_template, msg=''): - ''' +class ImageInput(InputTypeBase): + """ Clickable image as an input field. Element should specify the image source, height, and width, e.g. @@ -678,79 +517,117 @@ def imageinput(element, value, status, render_template, msg=''): 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) + template = "imageinput.html" + tags = ['imageinput'] - context = { - 'id': eid, - 'value': value, - 'height': height, - 'width': width, - 'src': src, - 'gx': gx, - 'gy': gy, - 'state': status, # to change - 'msg': msg, # to change - } - html = render_template("imageinput.html", context) - return etree.XML(html) + def setup(self): + self.src = self.xml.get('src') + self.height = self.xml.get('height') + self.width = self.xml.get('width') -_reg(imageinput) + # 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]+)]', self.value.strip().replace(' ', '')) + if m: + # Note: we subtract 15 to compensate for the size of the dot on the screen. + # (is a 30x30 image--lms/static/green-pointer.png). + (self.gx, self.gy) = [int(x) - 15 for x in m.groups()] + else: + (self.gx, self.gy) = (0, 0) + + + def _get_render_context(self): + + context = {'id': self.id, + 'value': self.value, + 'height': self.height, + 'width': self.width, + 'src': self.src, + 'gx': self.gx, + 'gy': self.gy, + 'status': self.status, + 'msg': self.msg, + } + return context + +registry.register(ImageInput) #----------------------------------------------------------------------------- -def crystallography(element, value, status, render_template, msg=''): - eid = element.get('id') - if eid is None: - msg = 'cryst has no id: it probably appears outside of a known response type' - msg += "\nSee problem XML source line %s" % getattr(element, 'sourceline', '') - raise Exception(msg) - height = element.get('height') - width = element.get('width') - display_file = element.get('display_file') - count = int(eid.split('_')[-2]) - 1 # HACK - size = element.get('size') - # if specified, then textline is hidden and id is stored in div of name given by hidden - hidden = element.get('hidden', '') - # Escape answers with quotes, so they don't crash the system! - escapedict = {'"': '"'} - value = saxutils.escape(value, escapedict) +class Crystallography(InputTypeBase): + """ + An input for crystallography -- user selects 3 points on the axes, and we get a plane. - context = {'id': eid, - 'value': value, - 'state': status, - 'count': count, - 'size': size, - 'msg': msg, - 'hidden': hidden, - 'inline': element.get('inline', ''), - 'width': width, - 'height': height, - 'display_file': display_file, + TODO: what's the actual value format? + """ + + template = "crystallography.html" + tags = ['crystallography'] + + + def setup(self): + self.height = self.xml.get('height') + self.width = self.xml.get('width') + self.size = self.xml.get('size') + + # if specified, then textline is hidden and id is stored in div of name given by hidden + self.hidden = self.xml.get('hidden', '') + + # Escape answers with quotes, so they don't crash the system! + escapedict = {'"': '"'} + self.value = saxutils.escape(self.value, escapedict) + + def _get_render_context(self): + context = {'id': self.id, + 'value': self.value, + 'status': self.status, + 'size': self.size, + 'msg': self.msg, + 'hidden': self.hidden, + 'width': self.width, + 'height': self.height, } + return context - html = render_template("crystallography.html", context) - try: - xhtml = etree.XML(html) - except Exception as err: - # TODO: needs to be self.system.DEBUG - but can't access system - if True: - log.debug('[inputtypes.textline] failed to parse XML for:\n%s' % html) - raise - return xhtml +registry.register(Crystallography) -_reg(crystallography) +# ------------------------------------------------------------------------- +class VseprInput(InputTypeBase): + """ + Input for molecular geometry--show possible structures, let student + pick structure and label positions with atoms or electron pairs. + """ + + template = 'vsepr_input.html' + tags = ['vsepr_input'] + + def setup(self): + self.height = self.xml.get('height') + self.width = self.xml.get('width') + + # Escape answers with quotes, so they don't crash the system! + escapedict = {'"': '"'} + self.value = saxutils.escape(self.value, escapedict) + + self.molecules = self.xml.get('molecules') + self.geometries = self.xml.get('geometries') + + def _get_render_context(self): + + context = {'id': self.id, + 'value': self.value, + 'status': self.status, + 'msg': self.msg, + 'width': self.width, + 'height': self.height, + 'molecules': self.molecules, + 'geometries': self.geometries, + } + return context + +registry.register(VseprInput) #-------------------------------------------------------------------------------- @@ -769,15 +646,17 @@ class ChemicalEquationInput(InputTypeBase): template = "chemicalequationinput.html" tags = ['chemicalequationinput'] + def setup(self): + self.size = self.xml.get('size', '20') + def _get_render_context(self): - size = self.xml.get('size', '20') context = { 'id': self.id, 'value': self.value, 'status': self.status, - 'size': size, + 'size': self.size, 'previewer': '/static/js/capa/chemical_equation_preview.js', } return context -register_input_class(ChemicalEquationInput) +registry.register(ChemicalEquationInput) diff --git a/common/lib/capa/capa/registry.py b/common/lib/capa/capa/registry.py new file mode 100644 index 0000000000..94a2853dec --- /dev/null +++ b/common/lib/capa/capa/registry.py @@ -0,0 +1,49 @@ +class TagRegistry(object): + """ + A registry mapping tags to handlers. + + (A dictionary with some extra error checking.) + """ + def __init__(self): + self._mapping = {} + + def register(self, cls): + """ + Register cls as a supported tag type. It is expected to define cls.tags as a list of tags + that it implements. + + If an already-registered type has registered one of those tags, will raise ValueError. + + If there are no tags in cls.tags, will also raise ValueError. + """ + + # Do all checks and complain before changing any state. + if len(cls.tags) == 0: + raise ValueError("No tags specified for class {0}".format(cls.__name__)) + + for t in cls.tags: + if t in self._mapping: + other_cls = self._mapping[t] + if cls == other_cls: + # registering the same class multiple times seems silly, but ok + continue + raise ValueError("Tag {0} already registered by class {1}." + " Can't register for class {2}" + .format(t, other_cls.__name__, cls.__name__)) + + # Ok, should be good to change state now. + for t in cls.tags: + self._mapping[t] = cls + + def registered_tags(self): + """ + Get a list of all the tags that have been registered. + """ + return self._mapping.keys() + + def get_class_for_tag(self, tag): + """ + For any tag in registered_tags(), returns the corresponding class. Otherwise, will raise + KeyError. + """ + return self._mapping[tag] diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 46b683cf3c..70373e0a47 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -81,7 +81,7 @@ class LoncapaResponse(object): by __init__ - check_hint_condition : check to see if the student's answers satisfy a particular - condition for a hint to be displayed + condition for a hint to be displayed - render_html : render this Response as HTML (must return XHTML-compliant string) - __unicode__ : unicode representation of this Response @@ -148,6 +148,7 @@ class LoncapaResponse(object): # for convenience self.answer_id = self.answer_ids[0] + # map input_id -> maxpoints self.maxpoints = dict() for inputfield in self.inputfields: # By default, each answerfield is worth 1 point @@ -284,17 +285,14 @@ class LoncapaResponse(object): (correctness, npoints, msg) for each answer_id. Arguments: - - student_answers : dict of (answer_id,answer) where answer = student input (string) - - - old_cmap : previous CorrectMap (may be empty); useful for analyzing or - recording history of responses + - student_answers : dict of (answer_id, answer) where answer = student input (string) ''' pass @abc.abstractmethod def get_answers(self): ''' - Return a dict of (answer_id,answer_text) for each answer for this question. + Return a dict of (answer_id, answer_text) for each answer for this question. ''' pass @@ -871,7 +869,8 @@ def sympy_check2(): """}] response_tag = 'customresponse' - allowed_inputfields = ['textline', 'textbox', 'crystallography', 'chemicalequationinput'] + + allowed_inputfields = ['textline', 'textbox', 'crystallography', 'chemicalequationinput', 'vsepr_input'] def setup_response(self): xml = self.xml @@ -1720,7 +1719,7 @@ class ImageResponse(LoncapaResponse): """ 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. + within a region specified. This region is a union of rectangles. Lon-CAPA requires that each has a inside it. That doesn't make sense to me (Ike). Instead, let's have it such that @@ -1730,6 +1729,7 @@ class ImageResponse(LoncapaResponse): snippets = [{'snippet': ''' + '''}] response_tag = 'imageresponse' @@ -1746,20 +1746,10 @@ class ImageResponse(LoncapaResponse): 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]' + correct_map.set(aid, 'incorrect') if not given: # No answer to parse. Mark as incorrect and move on - correct_map.set(aid, 'incorrect') continue - # 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: @@ -1767,11 +1757,24 @@ class ImageResponse(LoncapaResponse): 'error grading %s (input=%s)' % (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.set(aid, 'correct') - else: - correct_map.set(aid, 'incorrect') + # Check whether given point lies in any of the solution rectangles + solution_rectangles = expectedset[aid].split(';') + for solution_rectangle in solution_rectangles: + # parse expected answer + # TODO: Compile regexp on file load + m = re.match('[\(\[]([0-9]+),([0-9]+)[\)\]]-[\(\[]([0-9]+),([0-9]+)[\)\]]', + solution_rectangle.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()] + + # answer is correct if (x,y) is within the specified rectangle + if (llx <= gx <= urx) and (lly <= gy <= ury): + correct_map.set(aid, 'correct') + break + return correct_map def get_answers(self): diff --git a/common/lib/capa/capa/templates/choicegroup.html b/common/lib/capa/capa/templates/choicegroup.html index ce1b6d9476..457d9e7817 100644 --- a/common/lib/capa/capa/templates/choicegroup.html +++ b/common/lib/capa/capa/templates/choicegroup.html @@ -1,12 +1,12 @@
- % if state == 'unsubmitted': + % if status == 'unsubmitted': - % elif state == 'correct': + % elif status == 'correct': - % elif state == 'incorrect': + % elif status == 'incorrect': - % elif state == 'incomplete': + % elif status == 'incomplete': % endif
diff --git a/common/lib/capa/capa/templates/textbox.html b/common/lib/capa/capa/templates/codeinput.html similarity index 90% rename from common/lib/capa/capa/templates/textbox.html rename to common/lib/capa/capa/templates/codeinput.html index 6e44712d9f..5c2ff2aca5 100644 --- a/common/lib/capa/capa/templates/textbox.html +++ b/common/lib/capa/capa/templates/codeinput.html @@ -6,13 +6,13 @@ >${value|h}
- % if state == 'unsubmitted': + % if status == 'unsubmitted': Unanswered - % elif state == 'correct': + % elif status == 'correct': Correct - % elif state == 'incorrect': + % elif status == 'incorrect': Incorrect - % elif state == 'queued': + % elif status == 'queued': Queued % endif @@ -21,7 +21,7 @@
% endif -

${state}

+

${status}

diff --git a/common/lib/capa/capa/templates/crystallography.html b/common/lib/capa/capa/templates/crystallography.html index 1fc638b356..f46e2f753a 100644 --- a/common/lib/capa/capa/templates/crystallography.html +++ b/common/lib/capa/capa/templates/crystallography.html @@ -1,19 +1,19 @@ -<% doinline = "inline" if inline else "" %> - -
+
-
+
+
+
- % if state == 'unsubmitted': -
- % elif state == 'correct': -
- % elif state == 'incorrect': -
- % elif state == 'incomplete': -
+ % if status == 'unsubmitted': +
+ % elif status == 'correct': +
+ % elif status == 'incorrect': +
+ % elif status == 'incomplete': +
% endif % if hidden:
@@ -29,13 +29,13 @@ />

- % if state == 'unsubmitted': + % if status == 'unsubmitted': unanswered - % elif state == 'correct': + % elif status == 'correct': correct - % elif state == 'incorrect': + % elif status == 'incorrect': incorrect - % elif state == 'incomplete': + % elif status == 'incomplete': incomplete % endif

@@ -45,7 +45,7 @@ % if msg: ${msg|n} % endif -% if state in ['unsubmitted', 'correct', 'incorrect', 'incomplete'] or hidden: +% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
% endif
diff --git a/common/lib/capa/capa/templates/filesubmission.html b/common/lib/capa/capa/templates/filesubmission.html index 630a3222dc..2572b25f8a 100644 --- a/common/lib/capa/capa/templates/filesubmission.html +++ b/common/lib/capa/capa/templates/filesubmission.html @@ -1,16 +1,16 @@
- % if state == 'unsubmitted': + % if status == 'unsubmitted': Unanswered - % elif state == 'correct': + % elif status == 'correct': Correct - % elif state == 'incorrect': + % elif status == 'incorrect': Incorrect - % elif state == 'queued': + % elif status == 'queued': Queued % endif -

${state}

+

${status}

diff --git a/common/lib/capa/capa/templates/imageinput.html b/common/lib/capa/capa/templates/imageinput.html index ceda98ee8f..97279c1b8c 100644 --- a/common/lib/capa/capa/templates/imageinput.html +++ b/common/lib/capa/capa/templates/imageinput.html @@ -4,13 +4,13 @@
- % if state == 'unsubmitted': + % if status == 'unsubmitted': - % elif state == 'correct': + % elif status == 'correct': - % elif state == 'incorrect': + % elif status == 'incorrect': - % elif state == 'incomplete': + % elif status == 'incomplete': % endif diff --git a/common/lib/capa/capa/templates/jstextline.html b/common/lib/capa/capa/templates/jstextline.html index 045f329ad4..7e5fd94a4b 100644 --- a/common/lib/capa/capa/templates/jstextline.html +++ b/common/lib/capa/capa/templates/jstextline.html @@ -18,13 +18,13 @@ % endif - % if state == 'unsubmitted': + % if status == 'unsubmitted': - % elif state == 'correct': + % elif status == 'correct': - % elif state == 'incorrect': + % elif status == 'incorrect': - % elif state == 'incomplete': + % elif status == 'incomplete': % endif % if msg: diff --git a/common/lib/capa/capa/templates/optioninput.html b/common/lib/capa/capa/templates/optioninput.html index 06c7ed1257..ac62c0c9cb 100644 --- a/common/lib/capa/capa/templates/optioninput.html +++ b/common/lib/capa/capa/templates/optioninput.html @@ -12,13 +12,13 @@ - % if state == 'unsubmitted': + % if status == 'unsubmitted': - % elif state == 'correct': + % elif status == 'correct': - % elif state == 'incorrect': + % elif status == 'incorrect': - % elif state == 'incomplete': + % elif status == 'incomplete': % endif
diff --git a/common/lib/capa/capa/templates/schematicinput.html b/common/lib/capa/capa/templates/schematicinput.html index f79dc66d24..ff8cc64073 100644 --- a/common/lib/capa/capa/templates/schematicinput.html +++ b/common/lib/capa/capa/templates/schematicinput.html @@ -12,13 +12,13 @@ - % if state == 'unsubmitted': + % if status == 'unsubmitted': - % elif state == 'correct': + % elif status == 'correct': - % elif state == 'incorrect': + % elif status == 'incorrect': - % elif state == 'incomplete': + % elif status == 'incomplete': % endif diff --git a/common/lib/capa/capa/templates/textinput.html b/common/lib/capa/capa/templates/textinput.html deleted file mode 100644 index 9b66654117..0000000000 --- a/common/lib/capa/capa/templates/textinput.html +++ /dev/null @@ -1,46 +0,0 @@ -<% doinline = "inline" if inline else "" %> - -
- % if state == 'unsubmitted': -
- % elif state == 'correct': -
- % elif state == 'incorrect': -
- % elif state == 'incomplete': -
- % endif - % if hidden: -
- % endif - - - -

- % if state == 'unsubmitted': - unanswered - % elif state == 'correct': - correct - % elif state == 'incorrect': - incorrect - % elif state == 'incomplete': - incomplete - % endif -

- -

- - % if msg: - ${msg|n} - % endif -% if state in ['unsubmitted', 'correct', 'incorrect', 'incomplete'] or hidden: -
-% endif -
diff --git a/common/lib/capa/capa/templates/textinput_dynamath.html b/common/lib/capa/capa/templates/textinput_dynamath.html deleted file mode 100644 index d1de22ef27..0000000000 --- a/common/lib/capa/capa/templates/textinput_dynamath.html +++ /dev/null @@ -1,50 +0,0 @@ -### -### version of textline.html which does dynamic math -### -
- - % if preprocessor is not None: -
-
- % endif - - % if state == 'unsubmitted': -
- % elif state == 'correct': -
- % elif state == 'incorrect': -
- % elif state == 'incomplete': -
- % endif - % if hidden: -
- % endif - - -

- % if state == 'unsubmitted': - unanswered - % elif state == 'correct': - correct - % elif state == 'incorrect': - incorrect - % elif state == 'incomplete': - incomplete - % endif -

- -

- -
`{::}`
- -
- - % if msg: - ${msg|n} - % endif -
diff --git a/common/lib/capa/capa/templates/textline.html b/common/lib/capa/capa/templates/textline.html new file mode 100644 index 0000000000..97c512fc00 --- /dev/null +++ b/common/lib/capa/capa/templates/textline.html @@ -0,0 +1,64 @@ +<% doinline = "inline" if inline else "" %> + +
+ + % if preprocessor is not None: +
+
+ % endif + + % if status == 'unsubmitted': +
+ % elif status == 'correct': +
+ % elif status == 'incorrect': +
+ % elif status == 'incomplete': +
+ % endif + % if hidden: +
+ % endif + + + +

+ % if status == 'unsubmitted': + unanswered + % elif status == 'correct': + correct + % elif status == 'incorrect': + incorrect + % elif status == 'incomplete': + incomplete + % endif +

+ +

+ + % if do_math: +
`{::}`
+ + + % endif + +% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: +
+% endif + + % if msg: + ${msg|n} + % endif + +
diff --git a/common/lib/capa/capa/templates/vsepr_input.html b/common/lib/capa/capa/templates/vsepr_input.html new file mode 100644 index 0000000000..eaa6ac2174 --- /dev/null +++ b/common/lib/capa/capa/templates/vsepr_input.html @@ -0,0 +1,48 @@ +
+
+
+ + +
+
+ +
+ +
+ + % if status == 'unsubmitted': +
+ % elif status == 'correct': +
+ % elif status == 'incorrect': +
+ % elif status == 'incomplete': +
+ % endif + + + +

+ % if status == 'unsubmitted': + unanswered + % elif status == 'correct': + correct + % elif status == 'incorrect': + incorrect + % elif status == 'incomplete': + incomplete + % endif +

+ +

+ + % if msg: + ${msg|n} + % endif +% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: +
+% endif +
diff --git a/common/lib/capa/capa/tests/__init__.py b/common/lib/capa/capa/tests/__init__.py index c72d2a1538..b06975f6ce 100644 --- a/common/lib/capa/capa/tests/__init__.py +++ b/common/lib/capa/capa/tests/__init__.py @@ -4,13 +4,23 @@ import os from mock import Mock +import xml.sax.saxutils as saxutils + TEST_DIR = os.path.dirname(os.path.realpath(__file__)) +def tst_render_template(template, context): + """ + A test version of render to template. Renders to the repr of the context, completely ignoring + the template name. To make the output valid xml, quotes the content, and wraps it in a
+ """ + return '
{0}
'.format(saxutils.escape(repr(context))) + + test_system = Mock( ajax_url='courses/course_id/modx/a_location', track_function=Mock(), get_module=Mock(), - render_template=Mock(), + render_template=tst_render_template, replace_urls=Mock(), user=Mock(), filestore=fs.osfs.OSFS(os.path.join(TEST_DIR, "test_files")), diff --git a/common/lib/capa/capa/tests/test_customrender.py b/common/lib/capa/capa/tests/test_customrender.py new file mode 100644 index 0000000000..7208ab2941 --- /dev/null +++ b/common/lib/capa/capa/tests/test_customrender.py @@ -0,0 +1,76 @@ +from lxml import etree +import unittest +import xml.sax.saxutils as saxutils + +from . import test_system +from capa import customrender + +# just a handy shortcut +lookup_tag = customrender.registry.get_class_for_tag + +def extract_context(xml): + """ + Given an xml element corresponding to the output of test_system.render_template, get back the + original context + """ + return eval(xml.text) + +def quote_attr(s): + return saxutils.quoteattr(s)[1:-1] # don't want the outer quotes + +class HelperTest(unittest.TestCase): + ''' + Make sure that our helper function works! + ''' + def check(self, d): + xml = etree.XML(test_system.render_template('blah', d)) + self.assertEqual(d, extract_context(xml)) + + def test_extract_context(self): + self.check({}) + self.check({1, 2}) + self.check({'id', 'an id'}) + self.check({'with"quote', 'also"quote'}) + + +class SolutionRenderTest(unittest.TestCase): + ''' + Make sure solutions render properly. + ''' + + def test_rendering(self): + solution = 'To compute unicorns, count them.' + xml_str = """{s}""".format(s=solution) + element = etree.fromstring(xml_str) + + renderer = lookup_tag('solution')(test_system, element) + + self.assertEqual(renderer.id, 'solution_12') + + # our test_system "renders" templates to a div with the repr of the context + xml = renderer.get_html() + context = extract_context(xml) + self.assertEqual(context, {'id' : 'solution_12'}) + + +class MathRenderTest(unittest.TestCase): + ''' + Make sure math renders properly. + ''' + + def check_parse(self, latex_in, mathjax_out): + xml_str = """{tex}""".format(tex=latex_in) + element = etree.fromstring(xml_str) + + renderer = lookup_tag('math')(test_system, element) + + self.assertEqual(renderer.mathstr, mathjax_out) + + def test_parsing(self): + self.check_parse('$abc$', '[mathjaxinline]abc[/mathjaxinline]') + self.check_parse('$abc', '$abc') + self.check_parse(r'$\displaystyle 2+2$', '[mathjax] 2+2[/mathjax]') + + + # NOTE: not testing get_html yet because I don't understand why it's doing what it's doing. + diff --git a/common/lib/capa/capa/tests/test_files/imageresponse.xml b/common/lib/capa/capa/tests/test_files/imageresponse.xml index 72bf06401a..34dba37e3b 100644 --- a/common/lib/capa/capa/tests/test_files/imageresponse.xml +++ b/common/lib/capa/capa/tests/test_files/imageresponse.xml @@ -8,8 +8,14 @@ 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. + +Click on either of the two positions as discussed previously. + +Click on either of the two positions as discussed previously. + +Click on either of the two positions as discussed previously.

Use conservation of energy.

- \ No newline at end of file + diff --git a/common/lib/capa/capa/tests/test_inputtypes.py b/common/lib/capa/capa/tests/test_inputtypes.py index 9ef642d468..b3d7702246 100644 --- a/common/lib/capa/capa/tests/test_inputtypes.py +++ b/common/lib/capa/capa/tests/test_inputtypes.py @@ -1,50 +1,30 @@ """ -Tests of input types (and actually responsetypes too) +Tests of input types. + +TODO: +- test unicode in values, parameters, etc. +- test various html escapes +- test funny xml chars -- should never get xml parse error if things are escaped properly. """ -from datetime import datetime -import json -from mock import Mock -from nose.plugins.skip import SkipTest -import os +from lxml import etree import unittest +import xml.sax.saxutils as saxutils from . import test_system from capa import inputtypes -from lxml import etree - -def tst_render_template(template, context): - """ - A test version of render to template. Renders to the repr of the context, completely ignoring the template name. - """ - return repr(context) +# just a handy shortcut +lookup_tag = inputtypes.registry.get_class_for_tag -system = Mock(render_template=tst_render_template) +def quote_attr(s): + return saxutils.quoteattr(s)[1:-1] # don't want the outer quotes class OptionInputTest(unittest.TestCase): ''' Make sure option inputs work ''' - def test_rendering_new(self): - xml = """""" - element = etree.fromstring(xml) - - value = 'Down' - status = 'answered' - context = inputtypes._optioninput(element, value, status, test_system.render_template) - print 'context: ', context - - expected = {'value': 'Down', - 'options': [('Up', 'Up'), ('Down', 'Down')], - 'state': 'answered', - 'msg': '', - 'inline': '', - 'id': 'sky_input'} - - self.assertEqual(context, expected) - def test_rendering(self): xml_str = """""" @@ -53,16 +33,451 @@ class OptionInputTest(unittest.TestCase): state = {'value': 'Down', 'id': 'sky_input', 'status': 'answered'} - option_input = inputtypes.OptionInput(system, element, state) + option_input = lookup_tag('optioninput')(test_system, element, state) context = option_input._get_render_context() expected = {'value': 'Down', 'options': [('Up', 'Up'), ('Down', 'Down')], - 'state': 'answered', + 'status': 'answered', 'msg': '', 'inline': '', 'id': 'sky_input'} self.assertEqual(context, expected) +class ChoiceGroupTest(unittest.TestCase): + ''' + Test choice groups, radio groups, and checkbox groups + ''' + + def check_group(self, tag, expected_input_type, expected_suffix): + xml_str = """ + <{tag}> + This is foil One. + This is foil Two. + This is foil Three. + + """.format(tag=tag) + + element = etree.fromstring(xml_str) + + state = {'value': 'foil3', + 'id': 'sky_input', + 'status': 'answered'} + + the_input = lookup_tag(tag)(test_system, element, state) + + context = the_input._get_render_context() + + expected = {'id': 'sky_input', + 'value': 'foil3', + 'status': 'answered', + 'input_type': expected_input_type, + 'choices': [('foil1', 'This is foil One.'), + ('foil2', 'This is foil Two.'), + ('foil3', 'This is foil Three.'),], + 'name_array_suffix': expected_suffix, # what is this for?? + } + + self.assertEqual(context, expected) + + def test_choicegroup(self): + self.check_group('choicegroup', 'radio', '') + + def test_radiogroup(self): + self.check_group('radiogroup', 'radio', '[]') + + def test_checkboxgroup(self): + self.check_group('checkboxgroup', 'checkbox', '[]') + + + +class JavascriptInputTest(unittest.TestCase): + ''' + The javascript input is a pretty straightforward pass-thru, but test it anyway + ''' + + def test_rendering(self): + params = "(1,2,3)" + + problem_state = "abc12',12&hi" + display_class = "a_class" + display_file = "my_files/hi.js" + + xml_str = """""".format( + params=params, + ps=quote_attr(problem_state), + dc=display_class, df=display_file) + + element = etree.fromstring(xml_str) + + state = {'value': '3',} + the_input = lookup_tag('javascriptinput')(test_system, element, state) + + context = the_input._get_render_context() + + expected = {'id': 'prob_1_2', + 'params': params, + 'display_file': display_file, + 'display_class': display_class, + 'problem_state': problem_state, + 'value': '3', + 'evaluation': '',} + + self.assertEqual(context, expected) + + +class TextLineTest(unittest.TestCase): + ''' + Check that textline inputs work, with and without math. + ''' + + def test_rendering(self): + size = "42" + xml_str = """""".format(size=size) + + element = etree.fromstring(xml_str) + + state = {'value': 'BumbleBee',} + the_input = lookup_tag('textline')(test_system, element, state) + + context = the_input._get_render_context() + + expected = {'id': 'prob_1_2', + 'value': 'BumbleBee', + 'status': 'unanswered', + 'size': size, + 'msg': '', + 'hidden': False, + 'inline': False, + 'do_math': False, + 'preprocessor': None} + self.assertEqual(context, expected) + + + def test_math_rendering(self): + size = "42" + preprocessorClass = "preParty" + script = "foo/party.js" + + xml_str = """""".format(size=size, pp=preprocessorClass, sc=script) + + element = etree.fromstring(xml_str) + + state = {'value': 'BumbleBee',} + the_input = lookup_tag('textline')(test_system, element, state) + + context = the_input._get_render_context() + + expected = {'id': 'prob_1_2', + 'value': 'BumbleBee', + 'status': 'unanswered', + 'size': size, + 'msg': '', + 'hidden': False, + 'inline': False, + 'do_math': True, + 'preprocessor': {'class_name': preprocessorClass, + 'script_src': script}} + self.assertEqual(context, expected) + + +class FileSubmissionTest(unittest.TestCase): + ''' + Check that file submission inputs work + ''' + + def test_rendering(self): + allowed_files = "runme.py nooooo.rb ohai.java" + required_files = "cookies.py" + + xml_str = """""".format(af=allowed_files, + rf=required_files,) + + + element = etree.fromstring(xml_str) + + escapedict = {'"': '"'} + esc = lambda s: saxutils.escape(s, escapedict) + + state = {'value': 'BumbleBee.py', + 'status': 'incomplete', + 'feedback' : {'message': '3'}, } + input_class = lookup_tag('filesubmission') + the_input = input_class(test_system, element, state) + + context = the_input._get_render_context() + + expected = {'id': 'prob_1_2', + 'status': 'queued', + 'msg': input_class.submitted_msg, + 'value': 'BumbleBee.py', + 'queue_len': '3', + 'allowed_files': esc('["runme.py", "nooooo.rb", "ohai.java"]'), + 'required_files': esc('["cookies.py"]')} + + self.assertEqual(context, expected) + + +class CodeInputTest(unittest.TestCase): + ''' + Check that codeinput inputs work + ''' + + def test_rendering(self): + mode = "parrot" + linenumbers = 'false' + rows = '37' + cols = '11' + tabsize = '7' + + xml_str = """""".format(m=mode, c=cols, r=rows, ln=linenumbers, ts=tabsize) + + element = etree.fromstring(xml_str) + + escapedict = {'"': '"'} + esc = lambda s: saxutils.escape(s, escapedict) + + state = {'value': 'print "good evening"', + 'status': 'incomplete', + 'feedback' : {'message': '3'}, } + + the_input = lookup_tag('codeinput')(test_system, element, state) + + context = the_input._get_render_context() + + expected = {'id': 'prob_1_2', + 'value': 'print "good evening"', + 'status': 'queued', + 'msg': 'Submitted to grader.', + 'mode': mode, + 'linenumbers': linenumbers, + 'rows': rows, + 'cols': cols, + 'hidden': '', + 'tabsize': int(tabsize), + 'queue_len': '3', + } + + self.assertEqual(context, expected) + + +class SchematicTest(unittest.TestCase): + ''' + Check that schematic inputs work + ''' + + def test_rendering(self): + height = '12' + width = '33' + parts = 'resistors, capacitors, and flowers' + analyses = 'fast, slow, and pink' + initial_value = 'two large batteries' + submit_analyses = 'maybe' + + + xml_str = """""".format(h=height, w=width, p=parts, a=analyses, + iv=initial_value, sa=submit_analyses) + + element = etree.fromstring(xml_str) + + value = 'three resistors and an oscilating pendulum' + state = {'value': value, + 'status': 'unsubmitted'} + + the_input = lookup_tag('schematic')(test_system, element, state) + + context = the_input._get_render_context() + + expected = {'id': 'prob_1_2', + 'value': value, + 'initial_value': initial_value, + 'status': 'unsubmitted', + 'width': width, + 'height': height, + 'parts': parts, + 'analyses': analyses, + 'submit_analyses': submit_analyses, + } + + self.assertEqual(context, expected) + + +class ImageInputTest(unittest.TestCase): + ''' + Check that image inputs work + ''' + + def check(self, value, egx, egy): + height = '78' + width = '427' + src = 'http://www.edx.org/cowclicker.jpg' + + xml_str = """""".format(s=src, h=height, w=width) + + element = etree.fromstring(xml_str) + + state = {'value': value, + 'status': 'unsubmitted'} + + the_input = lookup_tag('imageinput')(test_system, element, state) + + context = the_input._get_render_context() + + expected = {'id': 'prob_1_2', + 'value': value, + 'status': 'unsubmitted', + 'width': width, + 'height': height, + 'src': src, + 'gx': egx, + 'gy': egy, + 'msg': ''} + + self.assertEqual(context, expected) + + def test_with_value(self): + # Check that compensating for the dot size works properly. + self.check('[50,40]', 35, 25) + + def test_without_value(self): + self.check('', 0, 0) + + def test_corrupt_values(self): + self.check('[12', 0, 0) + self.check('[12, a]', 0, 0) + self.check('[12 10]', 0, 0) + self.check('[12]', 0, 0) + self.check('[12 13 14]', 0, 0) + + + +class CrystallographyTest(unittest.TestCase): + ''' + Check that crystallography inputs work + ''' + + def test_rendering(self): + height = '12' + width = '33' + size = '10' + + xml_str = """""".format(h=height, w=width, s=size) + + element = etree.fromstring(xml_str) + + value = 'abc' + state = {'value': value, + 'status': 'unsubmitted'} + + the_input = lookup_tag('crystallography')(test_system, element, state) + + context = the_input._get_render_context() + + expected = {'id': 'prob_1_2', + 'value': value, + 'status': 'unsubmitted', + 'size': size, + 'msg': '', + 'hidden': '', + 'width': width, + 'height': height, + } + + self.assertEqual(context, expected) + + +class VseprTest(unittest.TestCase): + ''' + Check that vsepr inputs work + ''' + + def test_rendering(self): + height = '12' + width = '33' + molecules = "H2O, C2O" + geometries = "AX12,TK421" + + xml_str = """""".format(h=height, w=width, m=molecules, g=geometries) + + element = etree.fromstring(xml_str) + + value = 'abc' + state = {'value': value, + 'status': 'unsubmitted'} + + the_input = lookup_tag('vsepr_input')(test_system, element, state) + + context = the_input._get_render_context() + + expected = {'id': 'prob_1_2', + 'value': value, + 'status': 'unsubmitted', + 'msg': '', + 'width': width, + 'height': height, + 'molecules': molecules, + 'geometries': geometries, + } + + self.assertEqual(context, expected) + + + +class ChemicalEquationTest(unittest.TestCase): + ''' + Check that chemical equation inputs work. + ''' + + def test_rendering(self): + size = "42" + xml_str = """""".format(size=size) + + element = etree.fromstring(xml_str) + + state = {'value': 'H2OYeah',} + the_input = lookup_tag('chemicalequationinput')(test_system, element, state) + + context = the_input._get_render_context() + + expected = {'id': 'prob_1_2', + 'value': 'H2OYeah', + 'status': 'unanswered', + 'size': size, + 'previewer': '/static/js/capa/chemical_equation_preview.js', + } + self.assertEqual(context, expected) + diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index f2fa873080..bcac555b5e 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -53,12 +53,22 @@ class ImageResponseTest(unittest.TestCase): imageresponse_file = os.path.dirname(__file__) + "/test_files/imageresponse.xml" test_lcp = lcp.LoncapaProblem(open(imageresponse_file).read(), '1', system=test_system) correct_answers = {'1_2_1': '(490,11)-(556,98)', - '1_2_2': '(242,202)-(296,276)'} + '1_2_2': '(242,202)-(296,276)', + '1_2_3': '(490,11)-(556,98);(242,202)-(296,276)', + '1_2_4': '(490,11)-(556,98);(242,202)-(296,276)', + '1_2_5': '(490,11)-(556,98);(242,202)-(296,276)', + } test_answers = {'1_2_1': '[500,20]', '1_2_2': '[250,300]', + '1_2_3': '[500,20]', + '1_2_4': '[250,250]', + '1_2_5': '[10,10]', } self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_1'), 'correct') self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_2'), 'incorrect') + self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_3'), 'correct') + self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_4'), 'correct') + self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_5'), 'incorrect') class SymbolicResponseTest(unittest.TestCase): diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index c123756655..0441357427 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -26,7 +26,7 @@ setup( "image = xmodule.backcompat_module:TranslateCustomTagDescriptor", "error = xmodule.error_module:ErrorDescriptor", "problem = xmodule.capa_module:CapaDescriptor", - "problemset = xmodule.vertical_module:VerticalDescriptor", + "problemset = xmodule.seq_module:SequenceDescriptor", "section = xmodule.backcompat_module:SemanticSectionDescriptor", "sequential = xmodule.seq_module:SequenceDescriptor", "slides = xmodule.backcompat_module:TranslateCustomTagDescriptor", diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index d75e0ff860..f7e93a3262 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -120,6 +120,8 @@ class CapaModule(XModule): self.show_answer = self.metadata.get('showanswer', 'closed') + self.force_save_button = self.metadata.get('force_save_button', 'false') + if self.show_answer == "": self.show_answer = "closed" @@ -320,9 +322,10 @@ class CapaModule(XModule): if not self.lcp.done: reset_button = False - # We don't need a "save" button if infinite number of attempts and - # non-randomized - if self.max_attempts is None and self.rerandomize != "always": + # We may not need a "save" button if infinite number of attempts and + # non-randomized. The problem author can force it. It's a bit weird for + # randomization to control this; should perhaps be cleaned up. + if (self.force_save_button == "false") and (self.max_attempts is None and self.rerandomize != "always"): save_button = False context = {'problem': content, @@ -538,15 +541,9 @@ class CapaModule(XModule): lcp_id = self.lcp.problem_id correct_map = self.lcp.grade_answers(answers) except StudentInputError as inst: - # TODO (vshnayder): why is this line here? - #self.lcp = LoncapaProblem(self.definition['data'], - # id=lcp_id, state=old_state, system=self.system) log.exception("StudentInputError in capa_module:problem_check") return {'success': inst.message} except Exception, err: - # TODO: why is this line here? - #self.lcp = LoncapaProblem(self.definition['data'], - # id=lcp_id, state=old_state, system=self.system) if self.system.DEBUG: msg = "Error checking problem: " + str(err) msg += '\nTraceback:\n' + traceback.format_exc() diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index a1549185b1..e8b7d5c1ab 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -30,13 +30,13 @@ class CourseDescriptor(SequenceDescriptor): self.book_url = book_url self.table_of_contents = self._get_toc_from_s3() self.start_page = int(self.table_of_contents[0].attrib['page']) - + # The last page should be the last element in the table of contents, # but it may be nested. So recurse all the way down the last element last_el = self.table_of_contents[-1] while last_el.getchildren(): last_el = last_el[-1] - + self.end_page = int(last_el.attrib['page']) @property @@ -94,6 +94,7 @@ class CourseDescriptor(SequenceDescriptor): self.enrollment_start = self._try_parse_time("enrollment_start") self.enrollment_end = self._try_parse_time("enrollment_end") + self.end = self._try_parse_time("end") # NOTE: relies on the modulestore to call set_grading_policy() right after # init. (Modulestore is in charge of figuring out where to load the policy from) @@ -237,6 +238,16 @@ class CourseDescriptor(SequenceDescriptor): return definition + def has_ended(self): + """ + Returns True if the current time is after the specified course end date. + Returns False if there is no end date specified. + """ + if self.end_date is None: + return False + + return time.gmtime() > self.end + def has_started(self): return time.gmtime() > self.start @@ -346,7 +357,8 @@ class CourseDescriptor(SequenceDescriptor): @property def start_date_text(self): - return time.strftime("%b %d, %Y", self.start) + displayed_start = self._try_parse_time('advertised_start') or self.start + return time.strftime("%b %d, %Y", displayed_start) # An extra property is used rather than the wiki_slug/number because # there are courses that change the number for different runs. This allows @@ -374,6 +386,21 @@ class CourseDescriptor(SequenceDescriptor): more sensible framework later.""" return self.metadata.get('discussion_link', None) + @property + def forum_posts_allowed(self): + try: + blackout_periods = [(parse_time(start), parse_time(end)) + for start, end + in self.metadata.get('discussion_blackouts', [])] + now = time.gmtime() + for start, end in blackout_periods: + if start <= now <= end: + return False + except: + log.exception("Error parsing discussion_blackouts for course {0}".format(self.id)) + + return True + @property def hide_progress_tab(self): """TODO: same as above, intended to let internal CS50 hide the progress tab diff --git a/common/lib/xmodule/xmodule/css/video/display.scss b/common/lib/xmodule/xmodule/css/video/display.scss index af204b2cc1..bf575e74a3 100644 --- a/common/lib/xmodule/xmodule/css/video/display.scss +++ b/common/lib/xmodule/xmodule/css/video/display.scss @@ -359,6 +359,34 @@ div.video { } } + a.quality_control { + background: url(../images/hd.png) center no-repeat; + border-right: 1px solid #000; + @include box-shadow(1px 0 0 #555, inset 1px 0 0 #555); + color: #797979; + display: block; + float: left; + line-height: 46px; //height of play pause buttons + margin-left: 0; + padding: 0 lh(.5); + text-indent: -9999px; + @include transition(); + width: 30px; + + &:hover { + background-color: #444; + color: #fff; + text-decoration: none; + } + + &.active { + background-color: #F44; + color: #0ff; + text-decoration: none; + } + } + + a.hide-subtitles { background: url('../images/cc.png') center no-repeat; color: #797979; diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index cc389c3fc9..1c0ace9e59 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -216,7 +216,9 @@ class @Problem for choice in value @$("label[for='input_#{key}_#{choice}']").attr correct_answer: 'true' else - @$("#answer_#{key}, #solution_#{key}").html(value) + answer = @$("#answer_#{key}, #solution_#{key}") + answer.html(value) + Collapsible.setCollapsibles(answer) # TODO remove the above once everything is extracted into its own # inputtype functions. diff --git a/common/lib/xmodule/xmodule/js/src/capa/imageinput.js b/common/lib/xmodule/xmodule/js/src/capa/imageinput.js index 5b4978ee11..173cb50f54 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/imageinput.js +++ b/common/lib/xmodule/xmodule/js/src/capa/imageinput.js @@ -11,8 +11,8 @@ function image_input_click(id,event){ iidiv = document.getElementById("imageinput_"+id); - pos_x = event.offsetX?(event.offsetX):event.pageX-document.iidiv.offsetLeft; - pos_y = event.offsetY?(event.offsetY):event.pageY-document.iidiv.offsetTop; + pos_x = event.offsetX?(event.offsetX):event.pageX-iidiv.offsetLeft; + pos_y = event.offsetY?(event.offsetY):event.pageY-iidiv.offsetTop; result = "[" + pos_x + "," + pos_y + "]"; cx = (pos_x-15) +"px"; cy = (pos_y-15) +"px" ; diff --git a/common/lib/xmodule/xmodule/js/src/video/display/video_caption.coffee b/common/lib/xmodule/xmodule/js/src/video/display/video_caption.coffee index b1e41afc3c..cdd74c5d07 100644 --- a/common/lib/xmodule/xmodule/js/src/video/display/video_caption.coffee +++ b/common/lib/xmodule/xmodule/js/src/video/display/video_caption.coffee @@ -22,7 +22,7 @@ class @VideoCaption extends Subview """ @$('.video-controls .secondary-controls').append """ Captions - """ + """#" @$('.subtitles').css maxHeight: @$('.video-wrapper').height() - 5 @fetchCaption() @@ -144,7 +144,7 @@ class @VideoCaption extends Subview @el.removeClass('closed') @scrollCaption() $.cookie('hide_captions', hide_captions, expires: 3650, path: '/') - + captionHeight: -> if @el.hasClass('fullscreen') $(window).height() - @$('.video-controls').height() diff --git a/common/lib/xmodule/xmodule/js/src/video/display/video_control.coffee b/common/lib/xmodule/xmodule/js/src/video/display/video_control.coffee index 5053f1dcb1..856549c3e2 100644 --- a/common/lib/xmodule/xmodule/js/src/video/display/video_control.coffee +++ b/common/lib/xmodule/xmodule/js/src/video/display/video_control.coffee @@ -16,7 +16,7 @@ class @VideoControl extends Subview Fill Browser
- """ + """#" unless onTouchBasedDevice() @$('.video_control').addClass('play').html('Play') diff --git a/common/lib/xmodule/xmodule/js/src/video/display/video_player.coffee b/common/lib/xmodule/xmodule/js/src/video/display/video_player.coffee index bb89def63d..8829e25dac 100644 --- a/common/lib/xmodule/xmodule/js/src/video/display/video_player.coffee +++ b/common/lib/xmodule/xmodule/js/src/video/display/video_player.coffee @@ -9,6 +9,7 @@ class @VideoPlayer extends Subview bind: -> $(@control).bind('play', @play) .bind('pause', @pause) + $(@qualityControl).bind('changeQuality', @handlePlaybackQualityChange) $(@caption).bind('seek', @onSeek) $(@speedControl).bind('speedChange', @onSpeedChange) $(@progressSlider).bind('seek', @onSeek) @@ -25,6 +26,7 @@ class @VideoPlayer extends Subview render: -> @control = new VideoControl el: @$('.video-controls') + @qualityControl = new VideoQualityControl el: @$('.secondary-controls') @caption = new VideoCaption el: @el youtubeId: @video.youtubeId('1.0') @@ -41,10 +43,12 @@ class @VideoPlayer extends Subview rel: 0 showinfo: 0 enablejsapi: 1 + modestbranding: 1 videoId: @video.youtubeId() events: onReady: @onReady onStateChange: @onStateChange + onPlaybackQualityChange: @onPlaybackQualityChange @caption.hideCaptions(@['video'].hide_captions) addToolTip: -> @@ -53,7 +57,7 @@ class @VideoPlayer extends Subview my: 'top right' at: 'top center' - onReady: => + onReady: (event) => unless onTouchBasedDevice() $('.video-load-complete:first').data('video').player.play() @@ -68,6 +72,13 @@ class @VideoPlayer extends Subview when YT.PlayerState.ENDED @onEnded() + onPlaybackQualityChange: (event, value) => + quality = @player.getPlaybackQuality() + @qualityControl.onQualityChange(quality) + + handlePlaybackQualityChange: (event, value) => + @player.setPlaybackQuality(value) + onUnstarted: => @control.pause() @caption.pause() diff --git a/common/lib/xmodule/xmodule/js/src/video/display/video_quality_control.coffee b/common/lib/xmodule/xmodule/js/src/video/display/video_quality_control.coffee new file mode 100644 index 0000000000..f8f6167075 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/video/display/video_quality_control.coffee @@ -0,0 +1,26 @@ +class @VideoQualityControl extends Subview + initialize: -> + @quality = null; + + bind: -> + @$('.quality_control').click @toggleQuality + + render: -> + @el.append """ + HD + """#" + + onQualityChange: (value) -> + @quality = value + if @quality in ['hd720', 'hd1080', 'highres'] + @el.addClass('active') + else + @el.removeClass('active') + + toggleQuality: (event) => + event.preventDefault() + if @quality in ['hd720', 'hd1080', 'highres'] + newQuality = 'large' + else + newQuality = 'hd720' + $(@).trigger('changeQuality', newQuality) \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/js/src/video/display/video_volume_control.coffee b/common/lib/xmodule/xmodule/js/src/video/display/video_volume_control.coffee index 10d3f6b044..096b50042d 100644 --- a/common/lib/xmodule/xmodule/js/src/video/display/video_volume_control.coffee +++ b/common/lib/xmodule/xmodule/js/src/video/display/video_volume_control.coffee @@ -17,7 +17,7 @@ class @VideoVolumeControl extends Subview
- """ + """#" @slider = @$('.volume-slider').slider orientation: "vertical" range: "min" diff --git a/common/static/images/capa/vsepr/AX2E0-3D-balls.png b/common/static/images/capa/vsepr/AX2E0-3D-balls.png new file mode 100644 index 0000000000..b5a0161dad Binary files /dev/null and b/common/static/images/capa/vsepr/AX2E0-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX2E1-3D-balls.png b/common/static/images/capa/vsepr/AX2E1-3D-balls.png new file mode 100644 index 0000000000..8035c953b3 Binary files /dev/null and b/common/static/images/capa/vsepr/AX2E1-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX2E2-3D-balls.png b/common/static/images/capa/vsepr/AX2E2-3D-balls.png new file mode 100644 index 0000000000..35ad434bb6 Binary files /dev/null and b/common/static/images/capa/vsepr/AX2E2-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX2E3-3D-balls.png b/common/static/images/capa/vsepr/AX2E3-3D-balls.png new file mode 100644 index 0000000000..7535de598b Binary files /dev/null and b/common/static/images/capa/vsepr/AX2E3-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX3E0-3D-balls.png b/common/static/images/capa/vsepr/AX3E0-3D-balls.png new file mode 100644 index 0000000000..ce52fdf916 Binary files /dev/null and b/common/static/images/capa/vsepr/AX3E0-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX3E1-3D-balls.png b/common/static/images/capa/vsepr/AX3E1-3D-balls.png new file mode 100644 index 0000000000..e50a46b500 Binary files /dev/null and b/common/static/images/capa/vsepr/AX3E1-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX3E2-3D-balls.png b/common/static/images/capa/vsepr/AX3E2-3D-balls.png new file mode 100644 index 0000000000..120dcad363 Binary files /dev/null and b/common/static/images/capa/vsepr/AX3E2-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX4E0-3D-balls.png b/common/static/images/capa/vsepr/AX4E0-3D-balls.png new file mode 100644 index 0000000000..17e5817eaf Binary files /dev/null and b/common/static/images/capa/vsepr/AX4E0-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX4E1-3D-balls.png b/common/static/images/capa/vsepr/AX4E1-3D-balls.png new file mode 100644 index 0000000000..4877e00576 Binary files /dev/null and b/common/static/images/capa/vsepr/AX4E1-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX4E2-3D-balls.png b/common/static/images/capa/vsepr/AX4E2-3D-balls.png new file mode 100644 index 0000000000..8ee822ad66 Binary files /dev/null and b/common/static/images/capa/vsepr/AX4E2-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX5E1-3D-balls.png b/common/static/images/capa/vsepr/AX5E1-3D-balls.png new file mode 100644 index 0000000000..de86f6fdaf Binary files /dev/null and b/common/static/images/capa/vsepr/AX5E1-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX5E2-3D-balls.png b/common/static/images/capa/vsepr/AX5E2-3D-balls.png new file mode 100644 index 0000000000..2e1db7a8e3 Binary files /dev/null and b/common/static/images/capa/vsepr/AX5E2-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX6E0-3D-balls.png b/common/static/images/capa/vsepr/AX6E0-3D-balls.png new file mode 100644 index 0000000000..d878d1dd4d Binary files /dev/null and b/common/static/images/capa/vsepr/AX6E0-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX6E1-3D-balls.png b/common/static/images/capa/vsepr/AX6E1-3D-balls.png new file mode 100644 index 0000000000..3734b696e0 Binary files /dev/null and b/common/static/images/capa/vsepr/AX6E1-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX7E0-3D-balls.png b/common/static/images/capa/vsepr/AX7E0-3D-balls.png new file mode 100644 index 0000000000..37935492aa Binary files /dev/null and b/common/static/images/capa/vsepr/AX7E0-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX8E0-3D-balls.png b/common/static/images/capa/vsepr/AX8E0-3D-balls.png new file mode 100644 index 0000000000..bbbc9608c3 Binary files /dev/null and b/common/static/images/capa/vsepr/AX8E0-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX9E0-3D-balls.png b/common/static/images/capa/vsepr/AX9E0-3D-balls.png new file mode 100644 index 0000000000..75d2449ed6 Binary files /dev/null and b/common/static/images/capa/vsepr/AX9E0-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Bent-3D-balls.png b/common/static/images/capa/vsepr/Bent-3D-balls.png new file mode 100644 index 0000000000..aec734c57c Binary files /dev/null and b/common/static/images/capa/vsepr/Bent-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Linear-3D-balls.png b/common/static/images/capa/vsepr/Linear-3D-balls.png new file mode 100644 index 0000000000..0876c8a9a5 Binary files /dev/null and b/common/static/images/capa/vsepr/Linear-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Linear-stick.png b/common/static/images/capa/vsepr/Linear-stick.png new file mode 100644 index 0000000000..d4310bedc0 Binary files /dev/null and b/common/static/images/capa/vsepr/Linear-stick.png differ diff --git a/common/static/images/capa/vsepr/Octahedral-3D-balls.png b/common/static/images/capa/vsepr/Octahedral-3D-balls.png new file mode 100644 index 0000000000..4b7809d03c Binary files /dev/null and b/common/static/images/capa/vsepr/Octahedral-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Octahedral-stick.png b/common/static/images/capa/vsepr/Octahedral-stick.png new file mode 100644 index 0000000000..1053d63b5b Binary files /dev/null and b/common/static/images/capa/vsepr/Octahedral-stick.png differ diff --git a/common/static/images/capa/vsepr/Pentagonal-bipyramidal-3D-balls.png b/common/static/images/capa/vsepr/Pentagonal-bipyramidal-3D-balls.png new file mode 100644 index 0000000000..d518717a52 Binary files /dev/null and b/common/static/images/capa/vsepr/Pentagonal-bipyramidal-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Pentagonal-planar-3D-balls.png b/common/static/images/capa/vsepr/Pentagonal-planar-3D-balls.png new file mode 100644 index 0000000000..1c47a26816 Binary files /dev/null and b/common/static/images/capa/vsepr/Pentagonal-planar-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Pentagonal-pyramidal-3D-balls.png b/common/static/images/capa/vsepr/Pentagonal-pyramidal-3D-balls.png new file mode 100644 index 0000000000..b63d3cd494 Binary files /dev/null and b/common/static/images/capa/vsepr/Pentagonal-pyramidal-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Pyramidal-3D-balls.png b/common/static/images/capa/vsepr/Pyramidal-3D-balls.png new file mode 100644 index 0000000000..3fb93f096f Binary files /dev/null and b/common/static/images/capa/vsepr/Pyramidal-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Seesaw-3D-balls.png b/common/static/images/capa/vsepr/Seesaw-3D-balls.png new file mode 100644 index 0000000000..a6598fde77 Binary files /dev/null and b/common/static/images/capa/vsepr/Seesaw-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Square-antiprismatic-3D-balls.png b/common/static/images/capa/vsepr/Square-antiprismatic-3D-balls.png new file mode 100644 index 0000000000..e36cdce783 Binary files /dev/null and b/common/static/images/capa/vsepr/Square-antiprismatic-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Square-planar-3D-balls.png b/common/static/images/capa/vsepr/Square-planar-3D-balls.png new file mode 100644 index 0000000000..1647eab389 Binary files /dev/null and b/common/static/images/capa/vsepr/Square-planar-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/T-shaped-3D-balls.png b/common/static/images/capa/vsepr/T-shaped-3D-balls.png new file mode 100644 index 0000000000..59cac0d4f5 Binary files /dev/null and b/common/static/images/capa/vsepr/T-shaped-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Tetrahedral-3D-balls.png b/common/static/images/capa/vsepr/Tetrahedral-3D-balls.png new file mode 100644 index 0000000000..835ed26b89 Binary files /dev/null and b/common/static/images/capa/vsepr/Tetrahedral-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Tetrahedral-stick.png b/common/static/images/capa/vsepr/Tetrahedral-stick.png new file mode 100644 index 0000000000..6266bb8238 Binary files /dev/null and b/common/static/images/capa/vsepr/Tetrahedral-stick.png differ diff --git a/common/static/images/capa/vsepr/Trigonal-3D-balls.png b/common/static/images/capa/vsepr/Trigonal-3D-balls.png new file mode 100644 index 0000000000..65cca2f10f Binary files /dev/null and b/common/static/images/capa/vsepr/Trigonal-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Trigonal-bipyramidal-3D-balls.png b/common/static/images/capa/vsepr/Trigonal-bipyramidal-3D-balls.png new file mode 100644 index 0000000000..1caed952ed Binary files /dev/null and b/common/static/images/capa/vsepr/Trigonal-bipyramidal-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Trigonal-bipyramidal-stick.png b/common/static/images/capa/vsepr/Trigonal-bipyramidal-stick.png new file mode 100644 index 0000000000..0f99d4fbc4 Binary files /dev/null and b/common/static/images/capa/vsepr/Trigonal-bipyramidal-stick.png differ diff --git a/common/static/images/capa/vsepr/Trigonal-planar-stick.png b/common/static/images/capa/vsepr/Trigonal-planar-stick.png new file mode 100644 index 0000000000..b73d8a90de Binary files /dev/null and b/common/static/images/capa/vsepr/Trigonal-planar-stick.png differ diff --git a/common/static/images/hd.png b/common/static/images/hd.png new file mode 100644 index 0000000000..d6b8c1b7d1 Binary files /dev/null and b/common/static/images/hd.png differ diff --git a/create-dev-env.sh b/create-dev-env.sh index f7df66a3bf..5691315039 100755 --- a/create-dev-env.sh +++ b/create-dev-env.sh @@ -190,7 +190,7 @@ case `uname -s` in } distro=`lsb_release -cs` case $distro in - maya|lisa|natty|oneiric|precise) + maya|lisa|natty|oneiric|precise|quantal) output "Installing ubuntu requirements" sudo apt-get -y update sudo apt-get -y install $APT_PKGS diff --git a/doc/xml-format.md b/doc/xml-format.md index 2c06e28fb6..0491641903 100644 --- a/doc/xml-format.md +++ b/doc/xml-format.md @@ -250,8 +250,11 @@ Values are dictionaries of the form {"metadata-key" : "metadata-value"}. Supported fields at the course level: * "start" -- specify the start date for the course. Format-by-example: "2012-09-05T12:00". +* "advertised_start" -- specify what you want displayed as the start date of the course in the course listing and course about pages. This can be useful if you want to let people in early before the formal start. Format-by-example: "2012-09-05T12:00". * "enrollment_start", "enrollment_end" -- when can students enroll? (if not specified, can enroll anytime). Same format as "start". +* "end" -- specify the end date for the course. Format-by-example: "2012-11-05T12:00". * "tabs" -- have custom tabs in the courseware. See below for details on config. +* "discussion_blackouts" -- An array of time intervals during which you want to disable a student's ability to create or edit posts in the forum. Moderators, Community TAs, and Admins are unaffected. You might use this during exam periods, but please be aware that the forum is often a very good place to catch mistakes and clarify points to students. The better long term solution would be to have better flagging/moderation mechanisms, but this is the hammer we have today. Format by example: [["2012-10-29T04:00", "2012-11-03T04:00"], ["2012-12-30T04:00", "2013-01-02T04:00"]] * TODO: there are others ### Grading policy file contents @@ -308,7 +311,7 @@ __Inherited:__ * `start` -- when this content should be shown to students. Note that anyone with staff access to the course will always see everything. * `showanswer` - When to show answer. For 'attempted', will show answer after first attempt. Values: never, attempted, answered, closed. Default: closed. Optional. * `graded` - Whether this section will count towards the students grade. "true" or "false". Defaults to "false". -* `rerandomise` - Randomize question on each attempt. Values: 'always' (students see a different version of the problem after each attempt to solve it) +* `rerandomize` - Randomize question on each attempt. Values: 'always' (students see a different version of the problem after each attempt to solve it) 'onreset' (randomize question when reset button is pressed by the student) 'never' (all students see the same version of the problem) 'per_student' (individual students see the same version of the problem each time the look at it, but that version is different from what other students see) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index b16f1665fe..98c46640a4 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -28,6 +28,8 @@ from xmodule.x_module import ModuleSystem from xmodule.error_module import ErrorDescriptor, NonStaffErrorDescriptor from xmodule_modifiers import replace_course_urls, replace_static_urls, add_histogram, wrap_xmodule +from statsd import statsd + log = logging.getLogger("mitx.courseware") @@ -335,7 +337,7 @@ def xqueue_callback(request, course_id, userid, id, dispatch): ''' # Test xqueue package, which we expect to be: # xpackage = {'xqueue_header': json.dumps({'lms_key':'secretkey',...}), - # 'xqueue_body' : 'Message from grader} + # 'xqueue_body' : 'Message from grader'} get = request.POST.copy() for key in ['xqueue_header', 'xqueue_body']: if not get.has_key(key): @@ -370,7 +372,8 @@ def xqueue_callback(request, course_id, userid, id, dispatch): # We go through the "AJAX" path # So far, the only dispatch from xqueue will be 'score_update' try: - ajax_return = instance.handle_ajax(dispatch, get) # Can ignore the "ajax" return in 'xqueue_callback' + # Can ignore the return value--not used for xqueue_callback + instance.handle_ajax(dispatch, get) except: log.exception("error processing ajax call") raise @@ -382,6 +385,15 @@ def xqueue_callback(request, course_id, userid, id, dispatch): if instance_module.grade != oldgrade or instance_module.state != old_instance_state: instance_module.save() + #Bin score into range and increment stats + score_bucket=get_score_bucket(instance_module.grade, instance_module.max_grade) + org, course_num, run=course_id.split("/") + statsd.increment("lms.courseware.question_answered", + tags=["org:{0}".format(org), + "course:{0}".format(course_num), + "run:{0}".format(run), + "score_bucket:{0}".format(score_bucket), + "type:xqueue"]) return HttpResponse("") @@ -466,6 +478,17 @@ def modx_dispatch(request, dispatch, location, course_id): instance_module.max_grade != old_instance_max_grade): instance_module.save() + #Bin score into range and increment stats + score_bucket=get_score_bucket(instance_module.grade, instance_module.max_grade) + org, course_num, run=course_id.split("/") + statsd.increment("lms.courseware.question_answered", + tags=["org:{0}".format(org), + "course:{0}".format(course_num), + "run:{0}".format(run), + "score_bucket:{0}".format(score_bucket), + "type:ajax"]) + + if shared_module is not None: shared_module.state = instance.get_shared_state() if shared_module.state != old_shared_state: @@ -511,4 +534,17 @@ def preview_chemcalc(request): return HttpResponse(json.dumps(result)) +def get_score_bucket(grade,max_grade): + """ + Function to split arbitrary score ranges into 3 buckets. + Used with statsd tracking. + """ + score_bucket="incorrect" + if(grade>0 and grade <%inherit file="../courseware/course_navigation.html" /> <%block name="extratabs"> +% if has_permission(user, 'create_thread', course.id):
  • New Post
  • +% endif \ No newline at end of file diff --git a/lms/templates/discussion/_discussion_module.html b/lms/templates/discussion/_discussion_module.html index bb172093f6..8fced92f7f 100644 --- a/lms/templates/discussion/_discussion_module.html +++ b/lms/templates/discussion/_discussion_module.html @@ -3,4 +3,6 @@ diff --git a/lms/templates/discussion/_underscore_templates.html b/lms/templates/discussion/_underscore_templates.html index 0a691ac36f..be238811c2 100644 --- a/lms/templates/discussion/_underscore_templates.html +++ b/lms/templates/discussion/_underscore_templates.html @@ -1,3 +1,5 @@ +<%! from django_comment_client.permissions import has_permission %> + @@ -75,6 +79,7 @@
    1. + % if course is UNDEFINED or has_permission(user, 'create_sub_comment', course.id):
        Submit
        + % endif
      diff --git a/lms/templates/google_analytics.html b/lms/templates/google_analytics.html new file mode 100644 index 0000000000..273fbac970 --- /dev/null +++ b/lms/templates/google_analytics.html @@ -0,0 +1,11 @@ + diff --git a/lms/templates/main.html b/lms/templates/main.html index f234aa72cf..87490a07e4 100644 --- a/lms/templates/main.html +++ b/lms/templates/main.html @@ -21,20 +21,9 @@ % if not course: - + <%include file="google_analytics.html" /> % endif + diff --git a/lms/templates/portal/course_about.html b/lms/templates/portal/course_about.html index 6fced73565..b35c7a1b6f 100644 --- a/lms/templates/portal/course_about.html +++ b/lms/templates/portal/course_about.html @@ -7,7 +7,12 @@ <%inherit file="../main.html" /> +<%block name="headextra"> + <%include file="../google_analytics.html" /> + + <%block name="js_extra"> + % if not registered: %if user.is_authenticated(): ## If the user is authenticated, clicking the enroll button just submits a form diff --git a/lms/templates/static_templates/jobs.html b/lms/templates/static_templates/jobs.html index f2b3b69cab..c78ce266ed 100644 --- a/lms/templates/static_templates/jobs.html +++ b/lms/templates/static_templates/jobs.html @@ -1,5 +1,4 @@ <%namespace name='static' file='../static_content.html'/> - <%inherit file="../main.html" /> <%block name="title">Jobs @@ -50,12 +49,79 @@

      If you are interested in this position, please send an email to jobs@edx.org

      +
      +
      +

      Learning Designer/Interaction Learning Designer

      +

      The Learning Designer will work as part of the content and development team to plan, develop and deliver highly engaging and media rich online courses. The learning designer will be a flexible thinker, able to determine and apply sound pedagogical strategies to unique situations and a diverse set of academic disciplines. This is a 6-12 months contract opportunity.

      +

      Specific Responsibilities include:

      +
        +
      • Work with producers, product developers and course staff on implementing instructional design approaches in the development of media and other course materials.
      • +
      • Articulate learning objectives and align them to content design strategy and assessments.
      • +
      • Write effective instructional text, and audio and video scripts.
      • +
      • Coordinate workflows with video and content development team
      • +
      • Identify best practices and share these with the course staff and faculty as needed.
      • +
      • Create course communication style guides. Train and coach teaching staff on best practices for communication and discussion management.
      • +
      • Develop use case guides as needed on the use of edX courseware and new technologies.
      • +
      • Serve as a liaison to instructional design teams located at X universities.
      • +
      • Design peer review processes to be used by learners in selected courses.
      • +
      • Ability to apply game-based learning theory and design into selected courses as appropriate.
      • +
      • Use learning analytics and metrics to inform course design and revision process.
      • +
      • Work closely with the Content Research Director on articulating best practices for MOOC teaching and learning and course design.
      • +
      • Assist in the development of pilot courses used for sponsored research initiatives.
      • +
      +

      Qualifications:

      +

      Master's Degree in Educational Technology, Instructional Design or related field. Experience in higher education with additional experience in a start-up or research environment desirable. Excellent interpersonal and communication (written and verbal), project management, problem-solving and time management skills. The ability to be flexible with projects and to work on multiple courses essential. Ability to meet deadlines and manage expectations of constituents. Capacity to develop new and relevant technology skills.  Experience using game theory design and learning analytics to inform instructional design decisions and strategy.

      +

      Technical Skills:

      +

      Video and screencasting experience. LMS Platform experience, xml, HTML, CSS, Adobe Design Suite, Camtasia or Captivate experience. Experience with web 2.0 collaboration tools.

      +

      If you are interested in this position, please send an email to jobs@edx.org

      +
      +
      +
      +
      +

      Production Coordinator

      +

      The Production Coordinator supports video editors and course staff in all video related tasks, such as ingesting footage, transcoding, tracking live dates, transcriptions, organizing project deliverables and archiving completed projects.

      +

      Primary responsibilities:

      +
        +
      • organize, track, and manage video and associated assets across the video workflow
      • +
      • manage project data and spreadsheets
      • +
      • route incoming source footage, and apply metadata tags
      • +
      • run encoding/transcoding jobs
      • +
      • prepare and process associated video assets, such as slides and image files
      • +
      • manage the transcription process
      • +
          +
        • traffic files among project staff and video transcription services
        • +
        • coordinate transcript reviews with course staff
        • +
        • integrate transcripts in course pages
        • +
        +
      • other video-related tasks as assigned.
      • +
      +
      +

      Qualifications

      +

      The ideal candidate for the Production Coordinator position will have

      +
        +
      • relentless attention to detail
      • +
      • ability to communicate and collaborate effectively across the organization
      • +
      • knowledge and understanding of digital media production tools and processes
      • +
      • experience with compression techniques, image processing, and presentation software preferred
      • +
      • proficiency with standard office applications
      • +
          +
        • spreadsheets
        • +
        • word processing
        • +
        • presentation
        • +
        +
      • experience with web publishing, e.g., HTML, XML, CSS, a plus
      • +
      +

      If you are interested in this position, please send an email to jobs@edx.org

      +
      +

      Positions

      How to Apply

      E-mail your resume, coverletter and any other materials to jobs@edx.org

      diff --git a/prod-requirements.txt b/prod-requirements.txt index 98545552cb..20c3e9b068 100644 --- a/prod-requirements.txt +++ b/prod-requirements.txt @@ -50,3 +50,4 @@ pystache==0.3.1 python-openid==2.2.5 South==0.7.5 Unidecode==0.04.9 +dogstatsd-python==0.2.1 diff --git a/requirements.txt b/requirements.txt index 2ebca50bc5..ebc818b29d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -50,3 +50,5 @@ pygraphviz -r repo-requirements.txt pil nltk +dogstatsd-python +MySQL-python