Commit 2876554c by Brian Talbot

Merge branch 'master' into fix/btalbot/studio-sasscleanup

parents 3b80a90b 93eebdcd
...@@ -41,7 +41,8 @@ disable= ...@@ -41,7 +41,8 @@ disable=
# R0902: Too many instance attributes # R0902: Too many instance attributes
# R0903: Too few public methods (1/2) # R0903: Too few public methods (1/2)
# R0904: Too many public methods # R0904: Too many public methods
W0141,W0142,R0201,R0901,R0902,R0903,R0904 # R0913: Too many arguments
W0141,W0142,R0201,R0901,R0902,R0903,R0904,R0913
[REPORTS] [REPORTS]
......
...@@ -142,8 +142,11 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({ ...@@ -142,8 +142,11 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
onDelete: function(event) { onDelete: function(event) {
event.preventDefault(); event.preventDefault();
// TODO ask for confirmation
// remove the dom element and delete the model if (!confirm('Are you sure you want to delete this update? This action cannot be undone.')) {
return;
}
var targetModel = this.eventModel(event); var targetModel = this.eventModel(event);
this.modelDom(event).remove(); this.modelDom(event).remove();
var cacheThis = this; var cacheThis = this;
......
...@@ -15,6 +15,24 @@ from .models import CourseUserGroup ...@@ -15,6 +15,24 @@ from .models import CourseUserGroup
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# tl;dr: global state is bad. capa reseeds random every time a problem is loaded. Even
# if and when that's fixed, it's a good idea to have a local generator to avoid any other
# code that messes with the global random module.
_local_random = None
def local_random():
"""
Get the local random number generator. In a function so that we don't run
random.Random() at import time.
"""
# ironic, isn't it?
global _local_random
if _local_random is None:
_local_random = random.Random()
return _local_random
def is_course_cohorted(course_id): def is_course_cohorted(course_id):
""" """
Given a course id, return a boolean for whether or not the course is Given a course id, return a boolean for whether or not the course is
...@@ -129,13 +147,7 @@ def get_cohort(user, course_id): ...@@ -129,13 +147,7 @@ def get_cohort(user, course_id):
return None return None
# Put user in a random group, creating it if needed # Put user in a random group, creating it if needed
choice = random.randrange(0, n) group_name = local_random().choice(choices)
group_name = choices[choice]
# Victor: we are seeing very strange behavior on prod, where almost all users
# end up in the same group. Log at INFO to try to figure out what's going on.
log.info("DEBUG: adding user {0} to cohort {1}. choice={2}".format(
user, group_name,choice))
group, created = CourseUserGroup.objects.get_or_create( group, created = CourseUserGroup.objects.get_or_create(
course_id=course_id, course_id=course_id,
......
...@@ -9,6 +9,7 @@ from bs4 import BeautifulSoup ...@@ -9,6 +9,7 @@ from bs4 import BeautifulSoup
import time import time
import re import re
import os.path import os.path
from selenium.common.exceptions import WebDriverException
from logging import getLogger from logging import getLogger
logger = getLogger(__name__) logger = getLogger(__name__)
...@@ -214,3 +215,15 @@ def save_the_course_content(path='/tmp'): ...@@ -214,3 +215,15 @@ def save_the_course_content(path='/tmp'):
f = open('%s/%s' % (path, filename), 'w') f = open('%s/%s' % (path, filename), 'w')
f.write(output) f.write(output)
f.close f.close
@world.absorb
def css_click(css_selector):
try:
world.browser.find_by_css(css_selector).click()
except WebDriverException:
# Occassionally, MathJax or other JavaScript can cover up
# an element temporarily.
# If this happens, wait a second, then try again
time.sleep(1)
world.browser.find_by_css(css_selector).click()
...@@ -8,7 +8,7 @@ from collections import namedtuple ...@@ -8,7 +8,7 @@ from collections import namedtuple
from fs.osfs import OSFS from fs.osfs import OSFS
from itertools import repeat from itertools import repeat
from path import path from path import path
from datetime import datetime, timedelta from datetime import datetime
from importlib import import_module from importlib import import_module
from xmodule.errortracker import null_error_tracker, exc_info_to_str from xmodule.errortracker import null_error_tracker, exc_info_to_str
...@@ -246,6 +246,7 @@ class MongoModuleStore(ModuleStoreBase): ...@@ -246,6 +246,7 @@ class MongoModuleStore(ModuleStoreBase):
self.fs_root = path(fs_root) self.fs_root = path(fs_root)
self.error_tracker = error_tracker self.error_tracker = error_tracker
self.render_template = render_template self.render_template = render_template
self.ignore_write_events_on_courses = []
def get_metadata_inheritance_tree(self, location): def get_metadata_inheritance_tree(self, location):
''' '''
...@@ -330,6 +331,11 @@ class MongoModuleStore(ModuleStoreBase): ...@@ -330,6 +331,11 @@ class MongoModuleStore(ModuleStoreBase):
return tree return tree
def refresh_cached_metadata_inheritance_tree(self, location):
pseudo_course_id = '/'.join([location.org, location.course])
if pseudo_course_id not in self.ignore_write_events_on_courses:
self.get_cached_metadata_inheritance_tree(location, force_refresh = True)
def clear_cached_metadata_inheritance_tree(self, location): def clear_cached_metadata_inheritance_tree(self, location):
key_name = '{0}/{1}'.format(location.org, location.course) key_name = '{0}/{1}'.format(location.org, location.course)
if self.metadata_inheritance_cache is not None: if self.metadata_inheritance_cache is not None:
...@@ -376,7 +382,7 @@ class MongoModuleStore(ModuleStoreBase): ...@@ -376,7 +382,7 @@ class MongoModuleStore(ModuleStoreBase):
return data return data
def _load_item(self, item, data_cache): def _load_item(self, item, data_cache, should_apply_metadata_inheritence=True):
""" """
Load an XModuleDescriptor from item, using the children stored in data_cache Load an XModuleDescriptor from item, using the children stored in data_cache
""" """
...@@ -388,7 +394,10 @@ class MongoModuleStore(ModuleStoreBase): ...@@ -388,7 +394,10 @@ class MongoModuleStore(ModuleStoreBase):
resource_fs = OSFS(root) resource_fs = OSFS(root)
metadata_inheritance_tree = self.get_cached_metadata_inheritance_tree(Location(item['location'])) metadata_inheritance_tree = None
if should_apply_metadata_inheritence:
metadata_inheritance_tree = self.get_cached_metadata_inheritance_tree(Location(item['location']))
# TODO (cdodge): When the 'split module store' work has been completed, we should remove # TODO (cdodge): When the 'split module store' work has been completed, we should remove
# the 'metadata_inheritance_tree' parameter # the 'metadata_inheritance_tree' parameter
...@@ -410,7 +419,10 @@ class MongoModuleStore(ModuleStoreBase): ...@@ -410,7 +419,10 @@ class MongoModuleStore(ModuleStoreBase):
""" """
data_cache = self._cache_children(items, depth) data_cache = self._cache_children(items, depth)
return [self._load_item(item, data_cache) for item in items] # if we are loading a course object, if we're not prefetching children (depth != 0) then don't
# bother with the metadata inheritence
return [self._load_item(item, data_cache,
should_apply_metadata_inheritence=(item['location']['category'] != 'course' or depth != 0)) for item in items]
def get_courses(self): def get_courses(self):
''' '''
...@@ -493,10 +505,12 @@ class MongoModuleStore(ModuleStoreBase): ...@@ -493,10 +505,12 @@ class MongoModuleStore(ModuleStoreBase):
try: try:
source_item = self.collection.find_one(location_to_query(source)) source_item = self.collection.find_one(location_to_query(source))
source_item['_id'] = Location(location).dict() source_item['_id'] = Location(location).dict()
self.collection.insert(source_item, self.collection.insert(
source_item,
# Must include this to avoid the django debug toolbar (which defines the deprecated "safe=False") # Must include this to avoid the django debug toolbar (which defines the deprecated "safe=False")
# from overriding our default value set in the init method. # from overriding our default value set in the init method.
safe=self.collection.safe) safe=self.collection.safe
)
item = self._load_items([source_item])[0] item = self._load_items([source_item])[0]
# VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so # VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so
...@@ -518,7 +532,7 @@ class MongoModuleStore(ModuleStoreBase): ...@@ -518,7 +532,7 @@ class MongoModuleStore(ModuleStoreBase):
raise DuplicateItemError(location) raise DuplicateItemError(location)
# recompute (and update) the metadata inheritance tree which is cached # recompute (and update) the metadata inheritance tree which is cached
self.get_cached_metadata_inheritance_tree(Location(location), force_refresh = True) self.refresh_cached_metadata_inheritance_tree(Location(location))
def get_course_for_item(self, location, depth=0): def get_course_for_item(self, location, depth=0):
''' '''
...@@ -588,7 +602,7 @@ class MongoModuleStore(ModuleStoreBase): ...@@ -588,7 +602,7 @@ class MongoModuleStore(ModuleStoreBase):
self._update_single_item(location, {'definition.children': children}) self._update_single_item(location, {'definition.children': children})
# recompute (and update) the metadata inheritance tree which is cached # recompute (and update) the metadata inheritance tree which is cached
self.get_cached_metadata_inheritance_tree(Location(location), force_refresh = True) self.refresh_cached_metadata_inheritance_tree(Location(location))
def update_metadata(self, location, metadata): def update_metadata(self, location, metadata):
""" """
...@@ -614,7 +628,7 @@ class MongoModuleStore(ModuleStoreBase): ...@@ -614,7 +628,7 @@ class MongoModuleStore(ModuleStoreBase):
self._update_single_item(location, {'metadata': metadata}) self._update_single_item(location, {'metadata': metadata})
# recompute (and update) the metadata inheritance tree which is cached # recompute (and update) the metadata inheritance tree which is cached
self.get_cached_metadata_inheritance_tree(loc, force_refresh = True) self.refresh_cached_metadata_inheritance_tree(loc)
def delete_item(self, location): def delete_item(self, location):
""" """
...@@ -637,8 +651,7 @@ class MongoModuleStore(ModuleStoreBase): ...@@ -637,8 +651,7 @@ class MongoModuleStore(ModuleStoreBase):
# from overriding our default value set in the init method. # from overriding our default value set in the init method.
safe=self.collection.safe) safe=self.collection.safe)
# recompute (and update) the metadata inheritance tree which is cached # recompute (and update) the metadata inheritance tree which is cached
self.get_cached_metadata_inheritance_tree(Location(location), force_refresh = True) self.refresh_cached_metadata_inheritance_tree(Location(location))
def get_parent_locations(self, location, course_id): def get_parent_locations(self, location, course_id):
'''Find all locations that are the parents of this location in this '''Find all locations that are the parents of this location in this
......
...@@ -201,100 +201,117 @@ def import_from_xml(store, data_dir, course_dirs=None, ...@@ -201,100 +201,117 @@ def import_from_xml(store, data_dir, course_dirs=None,
course_items = [] course_items = []
for course_id in module_store.modules.keys(): for course_id in module_store.modules.keys():
course_data_path = None if target_location_namespace is not None:
course_location = None pseudo_course_id = '/'.join([target_location_namespace.org, target_location_namespace.course])
else:
course_id_components = course_id.split('/')
pseudo_course_id = '/'.join([course_id_components[0], course_id_components[1]])
if verbose: try:
log.debug("Scanning {0} for course module...".format(course_id)) # turn off all write signalling while importing as this is a high volume operation
if pseudo_course_id not in store.ignore_write_events_on_courses:
store.ignore_write_events_on_courses.append(pseudo_course_id)
# Quick scan to get course module as we need some info from there. Also we need to make sure that the course_data_path = None
# course module is committed first into the store course_location = None
for module in module_store.modules[course_id].itervalues():
if module.category == 'course':
course_data_path = path(data_dir) / module.data_dir
course_location = module.location
module = remap_namespace(module, target_location_namespace) if verbose:
log.debug("Scanning {0} for course module...".format(course_id))
# cdodge: more hacks (what else). Seems like we have a problem when importing a course (like 6.002) which # Quick scan to get course module as we need some info from there. Also we need to make sure that the
# does not have any tabs defined in the policy file. The import goes fine and then displays fine in LMS, # course module is committed first into the store
# but if someone tries to add a new tab in the CMS, then the LMS barfs because it expects that - for module in module_store.modules[course_id].itervalues():
# if there is *any* tabs - then there at least needs to be some predefined ones if module.category == 'course':
if module.tabs is None or len(module.tabs) == 0: course_data_path = path(data_dir) / module.data_dir
module.tabs = [{"type": "courseware"}, course_location = module.location
{"type": "course_info", "name": "Course Info"},
{"type": "discussion", "name": "Discussion"},
{"type": "wiki", "name": "Wiki"}] # note, add 'progress' when we can support it on Edge
module = remap_namespace(module, target_location_namespace)
if hasattr(module, 'data'): # cdodge: more hacks (what else). Seems like we have a problem when importing a course (like 6.002) which
store.update_item(module.location, module.data) # does not have any tabs defined in the policy file. The import goes fine and then displays fine in LMS,
store.update_children(module.location, module.children) # but if someone tries to add a new tab in the CMS, then the LMS barfs because it expects that -
store.update_metadata(module.location, dict(own_metadata(module))) # if there is *any* tabs - then there at least needs to be some predefined ones
if module.tabs is None or len(module.tabs) == 0:
module.tabs = [{"type": "courseware"},
{"type": "course_info", "name": "Course Info"},
{"type": "discussion", "name": "Discussion"},
{"type": "wiki", "name": "Wiki"}] # note, add 'progress' when we can support it on Edge
# a bit of a hack, but typically the "course image" which is shown on marketing pages is hard coded to /images/course_image.jpg
# so let's make sure we import in case there are no other references to it in the modules
verify_content_links(module, course_data_path, static_content_store, '/static/images/course_image.jpg')
course_items.append(module) if hasattr(module, 'data'):
store.update_item(module.location, module.data)
store.update_children(module.location, module.children)
store.update_metadata(module.location, dict(own_metadata(module)))
# a bit of a hack, but typically the "course image" which is shown on marketing pages is hard coded to /images/course_image.jpg
# so let's make sure we import in case there are no other references to it in the modules
verify_content_links(module, course_data_path, static_content_store, '/static/images/course_image.jpg')
# then import all the static content course_items.append(module)
if static_content_store is not None:
_namespace_rename = target_location_namespace if target_location_namespace is not None else course_location
# first pass to find everything in /static/
import_static_content(module_store.modules[course_id], course_location, course_data_path, static_content_store,
_namespace_rename, subpath='static', verbose=verbose)
# finally loop through all the modules # then import all the static content
for module in module_store.modules[course_id].itervalues(): if static_content_store is not None:
_namespace_rename = target_location_namespace if target_location_namespace is not None else course_location
if module.category == 'course': # first pass to find everything in /static/
# we've already saved the course module up at the top of the loop import_static_content(module_store.modules[course_id], course_location, course_data_path, static_content_store,
# so just skip over it in the inner loop _namespace_rename, subpath='static', verbose=verbose)
continue
# remap module to the new namespace # finally loop through all the modules
if target_location_namespace is not None: for module in module_store.modules[course_id].itervalues():
module = remap_namespace(module, target_location_namespace)
if verbose: if module.category == 'course':
log.debug('importing module location {0}'.format(module.location)) # we've already saved the course module up at the top of the loop
# so just skip over it in the inner loop
continue
if hasattr(module, 'data'): # remap module to the new namespace
module_data = module.data if target_location_namespace is not None:
module = remap_namespace(module, target_location_namespace)
# cdodge: now go through any link references to '/static/' and make sure we've imported if verbose:
# it as a StaticContent asset log.debug('importing module location {0}'.format(module.location))
try:
remap_dict = {} if hasattr(module, 'data'):
module_data = module.data
# use the rewrite_links as a utility means to enumerate through all links # cdodge: now go through any link references to '/static/' and make sure we've imported
# in the module data. We use that to load that reference into our asset store # it as a StaticContent asset
# IMPORTANT: There appears to be a bug in lxml.rewrite_link which makes us not be able to try:
# do the rewrites natively in that code. remap_dict = {}
# For example, what I'm seeing is <img src='foo.jpg' /> -> <img src='bar.jpg'>
# Note the dropped element closing tag. This causes the LMS to fail when rendering modules - that's
# no good, so we have to do this kludge
if isinstance(module_data, str) or isinstance(module_data, unicode): # some module 'data' fields are non strings which blows up the link traversal code
lxml_rewrite_links(module_data, lambda link: verify_content_links(module, course_data_path,
static_content_store, link, remap_dict))
for key in remap_dict.keys(): # use the rewrite_links as a utility means to enumerate through all links
module_data = module_data.replace(key, remap_dict[key]) # in the module data. We use that to load that reference into our asset store
# IMPORTANT: There appears to be a bug in lxml.rewrite_link which makes us not be able to
# do the rewrites natively in that code.
# For example, what I'm seeing is <img src='foo.jpg' /> -> <img src='bar.jpg'>
# Note the dropped element closing tag. This causes the LMS to fail when rendering modules - that's
# no good, so we have to do this kludge
if isinstance(module_data, str) or isinstance(module_data, unicode): # some module 'data' fields are non strings which blows up the link traversal code
lxml_rewrite_links(module_data, lambda link: verify_content_links(module, course_data_path,
static_content_store, link, remap_dict))
except Exception, e: for key in remap_dict.keys():
logging.exception("failed to rewrite links on {0}. Continuing...".format(module.location)) module_data = module_data.replace(key, remap_dict[key])
store.update_item(module.location, module_data) except Exception, e:
logging.exception("failed to rewrite links on {0}. Continuing...".format(module.location))
if hasattr(module, 'children') and module.children != []: store.update_item(module.location, module_data)
store.update_children(module.location, module.children)
# NOTE: It's important to use own_metadata here to avoid writing if hasattr(module, 'children') and module.children != []:
# inherited metadata everywhere. store.update_children(module.location, module.children)
store.update_metadata(module.location, dict(own_metadata(module)))
# NOTE: It's important to use own_metadata here to avoid writing
# inherited metadata everywhere.
store.update_metadata(module.location, dict(own_metadata(module)))
finally:
# turn back on all write signalling
if pseudo_course_id in store.ignore_write_events_on_courses:
store.ignore_write_events_on_courses.remove(pseudo_course_id)
store.refresh_cached_metadata_inheritance_tree(target_location_namespace if
target_location_namespace is not None else course_location)
return module_store, course_items return module_store, course_items
......
Feature: Answer choice problems Feature: Answer problems
As a student in an edX course As a student in an edX course
In order to test my understanding of the material In order to test my understanding of the material
I want to answer choice based problems I want to answer problems
Scenario: I can answer a problem correctly Scenario: I can answer a problem correctly
Given I am viewing a "<ProblemType>" problem Given External graders respond "correct"
And I am viewing a "<ProblemType>" problem
When I answer a "<ProblemType>" problem "correctly" When I answer a "<ProblemType>" problem "correctly"
Then My "<ProblemType>" answer is marked "correct" Then My "<ProblemType>" answer is marked "correct"
...@@ -17,9 +18,11 @@ Feature: Answer choice problems ...@@ -17,9 +18,11 @@ Feature: Answer choice problems
| numerical | | numerical |
| formula | | formula |
| script | | script |
| code |
Scenario: I can answer a problem incorrectly Scenario: I can answer a problem incorrectly
Given I am viewing a "<ProblemType>" problem Given External graders respond "incorrect"
And I am viewing a "<ProblemType>" problem
When I answer a "<ProblemType>" problem "incorrectly" When I answer a "<ProblemType>" problem "incorrectly"
Then My "<ProblemType>" answer is marked "incorrect" Then My "<ProblemType>" answer is marked "incorrect"
...@@ -32,6 +35,7 @@ Feature: Answer choice problems ...@@ -32,6 +35,7 @@ Feature: Answer choice problems
| numerical | | numerical |
| formula | | formula |
| script | | script |
| code |
Scenario: I can submit a blank answer Scenario: I can submit a blank answer
Given I am viewing a "<ProblemType>" problem Given I am viewing a "<ProblemType>" problem
......
from lettuce import world, step from lettuce import world, step
from lettuce.django import django_url from lettuce.django import django_url
from selenium.webdriver.support.ui import Select
import random import random
import textwrap import textwrap
import time
from common import i_am_registered_for_the_course, TEST_SECTION_NAME, section_location from common import i_am_registered_for_the_course, TEST_SECTION_NAME, section_location
from terrain.factories import ItemFactory from terrain.factories import ItemFactory
from capa.tests.response_xml_factory import OptionResponseXMLFactory, \ from capa.tests.response_xml_factory import OptionResponseXMLFactory, \
ChoiceResponseXMLFactory, MultipleChoiceResponseXMLFactory, \ ChoiceResponseXMLFactory, MultipleChoiceResponseXMLFactory, \
StringResponseXMLFactory, NumericalResponseXMLFactory, \ StringResponseXMLFactory, NumericalResponseXMLFactory, \
FormulaResponseXMLFactory, CustomResponseXMLFactory FormulaResponseXMLFactory, CustomResponseXMLFactory, \
CodeResponseXMLFactory
# Factories from capa.tests.response_xml_factory that we will use # Factories from capa.tests.response_xml_factory that we will use
# to generate the problem XML, with the keyword args used to configure # to generate the problem XML, with the keyword args used to configure
...@@ -78,6 +79,12 @@ PROBLEM_FACTORY_DICT = { ...@@ -78,6 +79,12 @@ PROBLEM_FACTORY_DICT = {
a2=0 a2=0
return (a1+a2)==int(expect) return (a1+a2)==int(expect)
""")}}, """)}},
'code': {
'factory': CodeResponseXMLFactory(),
'kwargs': {
'question_text': 'Submit code to an external grader',
'initial_display': 'print "Hello world!"',
'grader_payload': '{"grader": "ps1/Spring2013/test_grader.py"}', }},
} }
...@@ -116,6 +123,19 @@ def view_problem(step, problem_type): ...@@ -116,6 +123,19 @@ def view_problem(step, problem_type):
world.browser.visit(url) world.browser.visit(url)
@step(u'External graders respond "([^"]*)"')
def set_external_grader_response(step, correctness):
assert(correctness in ['correct', 'incorrect'])
response_dict = {'correct': True if correctness == 'correct' else False,
'score': 1 if correctness == 'correct' else 0,
'msg': 'Your problem was graded %s' % correctness}
# Set the fake xqueue server to always respond
# correct/incorrect when asked to grade a problem
world.xqueue_server.set_grade_response(response_dict)
@step(u'I answer a "([^"]*)" problem "([^"]*)ly"') @step(u'I answer a "([^"]*)" problem "([^"]*)ly"')
def answer_problem(step, problem_type, correctness): def answer_problem(step, problem_type, correctness):
""" Mark a given problem type correct or incorrect, then submit it. """ Mark a given problem type correct or incorrect, then submit it.
...@@ -169,18 +189,29 @@ def answer_problem(step, problem_type, correctness): ...@@ -169,18 +189,29 @@ def answer_problem(step, problem_type, correctness):
inputfield('script', input_num=1).fill(str(first_addend)) inputfield('script', input_num=1).fill(str(first_addend))
inputfield('script', input_num=2).fill(str(second_addend)) inputfield('script', input_num=2).fill(str(second_addend))
elif problem_type == 'code':
# The fake xqueue server is configured to respond
# correct / incorrect no matter what we submit.
# Furthermore, since the inline code response uses
# JavaScript to make the code display nicely, it's difficult
# to programatically input text
# (there's not <textarea> we can just fill text into)
# For this reason, we submit the initial code in the response
# (configured in the problem XML above)
pass
# Submit the problem # Submit the problem
check_problem(step) check_problem(step)
@step(u'I check a problem') @step(u'I check a problem')
def check_problem(step): def check_problem(step):
world.browser.find_by_css("input.check").click() world.css_click("input.check")
@step(u'I reset the problem') @step(u'I reset the problem')
def reset_problem(step): def reset_problem(step):
world.browser.find_by_css('input.reset').click() world.css_click('input.reset')
@step(u'My "([^"]*)" answer is marked "([^"]*)"') @step(u'My "([^"]*)" answer is marked "([^"]*)"')
...@@ -207,7 +238,8 @@ def assert_answer_mark(step, problem_type, correctness): ...@@ -207,7 +238,8 @@ def assert_answer_mark(step, problem_type, correctness):
'string': ['div.correct'], 'string': ['div.correct'],
'numerical': ['div.correct'], 'numerical': ['div.correct'],
'formula': ['div.correct'], 'formula': ['div.correct'],
'script': ['div.correct'], } 'script': ['div.correct'],
'code': ['span.correct'], }
incorrect_selectors = {'drop down': ['span.incorrect'], incorrect_selectors = {'drop down': ['span.incorrect'],
'multiple choice': ['label.choicegroup_incorrect', 'multiple choice': ['label.choicegroup_incorrect',
...@@ -216,7 +248,8 @@ def assert_answer_mark(step, problem_type, correctness): ...@@ -216,7 +248,8 @@ def assert_answer_mark(step, problem_type, correctness):
'string': ['div.incorrect'], 'string': ['div.incorrect'],
'numerical': ['div.incorrect'], 'numerical': ['div.incorrect'],
'formula': ['div.incorrect'], 'formula': ['div.incorrect'],
'script': ['div.incorrect']} 'script': ['div.incorrect'],
'code': ['span.incorrect'], }
assert(correctness in ['correct', 'incorrect', 'unanswered']) assert(correctness in ['correct', 'incorrect', 'unanswered'])
assert(problem_type in correct_selectors and problem_type in incorrect_selectors) assert(problem_type in correct_selectors and problem_type in incorrect_selectors)
......
from courseware.mock_xqueue_server.mock_xqueue_server import MockXQueueServer
from lettuce import before, after, world
from django.conf import settings
import threading
@before.all
def setup_mock_xqueue_server():
# Retrieve the local port from settings
server_port = settings.XQUEUE_PORT
# Create the mock server instance
server = MockXQueueServer(server_port)
# Start the server running in a separate daemon thread
# Because the thread is a daemon, it will terminate
# when the main thread terminates.
server_thread = threading.Thread(target=server.serve_forever)
server_thread.daemon = True
server_thread.start()
# Store the server instance in lettuce's world
# so that other steps can access it
# (and we can shut it down later)
world.xqueue_server = server
@after.all
def teardown_mock_xqueue_server(total):
# Stop the xqueue server and free up the port
world.xqueue_server.shutdown()
from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
import json
import urllib
import urlparse
import threading
from logging import getLogger
logger = getLogger(__name__)
class MockXQueueRequestHandler(BaseHTTPRequestHandler):
'''
A handler for XQueue POST requests.
'''
protocol = "HTTP/1.0"
def do_HEAD(self):
self._send_head()
def do_POST(self):
'''
Handle a POST request from the client
Sends back an immediate success/failure response.
It then POSTS back to the client
with grading results, as configured in MockXQueueServer.
'''
self._send_head()
# Retrieve the POST data
post_dict = self._post_dict()
# Log the request
logger.debug("XQueue received POST request %s to path %s" %
(str(post_dict), self.path))
# Respond only to grading requests
if self._is_grade_request():
try:
xqueue_header = json.loads(post_dict['xqueue_header'])
xqueue_body = json.loads(post_dict['xqueue_body'])
callback_url = xqueue_header['lms_callback_url']
except KeyError:
# If the message doesn't have a header or body,
# then it's malformed.
# Respond with failure
error_msg = "XQueue received invalid grade request"
self._send_immediate_response(False, message=error_msg)
except ValueError:
# If we could not decode the body or header,
# respond with failure
error_msg = "XQueue could not decode grade request"
self._send_immediate_response(False, message=error_msg)
else:
# Send an immediate response of success
# The grade request is formed correctly
self._send_immediate_response(True)
# Wait a bit before POSTing back to the callback url with the
# grade result configured by the server
# Otherwise, the problem will not realize it's
# queued and it will keep waiting for a response
# indefinitely
delayed_grade_func = lambda: self._send_grade_response(callback_url,
xqueue_header)
timer = threading.Timer(2, delayed_grade_func)
timer.start()
# If we get a request that's not to the grading submission
# URL, return an error
else:
error_message = "Invalid request URL"
self._send_immediate_response(False, message=error_message)
def _send_head(self):
'''
Send the response code and MIME headers
'''
if self._is_grade_request():
self.send_response(200)
else:
self.send_response(500)
self.send_header('Content-type', 'text/plain')
self.end_headers()
def _post_dict(self):
'''
Retrieve the POST parameters from the client as a dictionary
'''
try:
length = int(self.headers.getheader('content-length'))
post_dict = urlparse.parse_qs(self.rfile.read(length))
# The POST dict will contain a list of values
# for each key.
# None of our parameters are lists, however,
# so we map [val] --> val
# If the list contains multiple entries,
# we pick the first one
post_dict = dict(map(lambda (key, list_val): (key, list_val[0]),
post_dict.items()))
except:
# We return an empty dict here, on the assumption
# that when we later check that the request has
# the correct fields, it won't find them,
# and will therefore send an error response
return {}
return post_dict
def _send_immediate_response(self, success, message=""):
'''
Send an immediate success/failure message
back to the client
'''
# Send the response indicating success/failure
response_str = json.dumps({'return_code': 0 if success else 1,
'content': message})
# Log the response
logger.debug("XQueue: sent response %s" % response_str)
self.wfile.write(response_str)
def _send_grade_response(self, postback_url, xqueue_header):
'''
POST the grade response back to the client
using the response provided by the server configuration
'''
response_dict = {'xqueue_header': json.dumps(xqueue_header),
'xqueue_body': json.dumps(self.server.grade_response())}
# Log the response
logger.debug("XQueue: sent grading response %s" % str(response_dict))
MockXQueueRequestHandler.post_to_url(postback_url, response_dict)
def _is_grade_request(self):
return 'xqueue/submit' in self.path
@staticmethod
def post_to_url(url, param_dict):
'''
POST *param_dict* to *url*
We make this a separate function so we can easily patch
it during testing.
'''
urllib.urlopen(url, urllib.urlencode(param_dict))
class MockXQueueServer(HTTPServer):
'''
A mock XQueue grading server that responds
to POST requests to localhost.
'''
def __init__(self, port_num,
grade_response_dict={'correct': True, 'score': 1, 'msg': ''}):
'''
Initialize the mock XQueue server instance.
*port_num* is the localhost port to listen to
*grade_response_dict* is a dictionary that will be JSON-serialized
and sent in response to XQueue grading requests.
'''
self.set_grade_response(grade_response_dict)
handler = MockXQueueRequestHandler
address = ('', port_num)
HTTPServer.__init__(self, address, handler)
def shutdown(self):
'''
Stop the server and free up the port
'''
# First call superclass shutdown()
HTTPServer.shutdown(self)
# We also need to manually close the socket
self.socket.close()
def grade_response(self):
return self._grade_response
def set_grade_response(self, grade_response_dict):
# Check that the grade response has the right keys
assert('correct' in grade_response_dict and
'score' in grade_response_dict and
'msg' in grade_response_dict)
# Wrap the message in <div> tags to ensure that it is valid XML
grade_response_dict['msg'] = "<div>%s</div>" % grade_response_dict['msg']
# Save the response dictionary
self._grade_response = grade_response_dict
import mock
import unittest
import threading
import json
import urllib
import urlparse
import time
from mock_xqueue_server import MockXQueueServer, MockXQueueRequestHandler
class MockXQueueServerTest(unittest.TestCase):
'''
A mock version of the XQueue server that listens on a local
port and responds with pre-defined grade messages.
Used for lettuce BDD tests in lms/courseware/features/problems.feature
and lms/courseware/features/problems.py
This is temporary and will be removed when XQueue is
rewritten using celery.
'''
def setUp(self):
# Create the server
server_port = 8034
self.server_url = 'http://127.0.0.1:%d' % server_port
self.server = MockXQueueServer(server_port,
{'correct': True, 'score': 1, 'msg': ''})
# Start the server in a separate daemon thread
server_thread = threading.Thread(target=self.server.serve_forever)
server_thread.daemon = True
server_thread.start()
def tearDown(self):
# Stop the server, freeing up the port
self.server.shutdown()
def test_grade_request(self):
# Patch post_to_url() so we can intercept
# outgoing POST requests from the server
MockXQueueRequestHandler.post_to_url = mock.Mock()
# Send a grade request
callback_url = 'http://127.0.0.1:8000/test_callback'
grade_header = json.dumps({'lms_callback_url': callback_url,
'lms_key': 'test_queuekey',
'queue_name': 'test_queue'})
grade_body = json.dumps({'student_info': 'test',
'grader_payload': 'test',
'student_response': 'test'})
grade_request = {'xqueue_header': grade_header,
'xqueue_body': grade_body}
response_handle = urllib.urlopen(self.server_url + '/xqueue/submit',
urllib.urlencode(grade_request))
response_dict = json.loads(response_handle.read())
# Expect that the response is success
self.assertEqual(response_dict['return_code'], 0)
# Wait a bit before checking that the server posted back
time.sleep(3)
# Expect that the server tries to post back the grading info
xqueue_body = json.dumps({'correct': True, 'score': 1,
'msg': '<div></div>'})
expected_callback_dict = {'xqueue_header': grade_header,
'xqueue_body': xqueue_body}
MockXQueueRequestHandler.post_to_url.assert_called_with(callback_url,
expected_callback_dict)
"""
Classes to provide the LMS runtime data storage to XBlocks
"""
import json import json
from collections import namedtuple, defaultdict from collections import namedtuple, defaultdict
from itertools import chain from itertools import chain
...@@ -14,10 +18,16 @@ from xblock.core import Scope ...@@ -14,10 +18,16 @@ from xblock.core import Scope
class InvalidWriteError(Exception): class InvalidWriteError(Exception):
pass """
Raised to indicate that writing to a particular key
in the KeyValueStore is disabled
"""
def chunks(items, chunk_size): def chunks(items, chunk_size):
"""
Yields the values from items in chunks of size chunk_size
"""
items = list(items) items = list(items)
return (items[i:i + chunk_size] for i in xrange(0, len(items), chunk_size)) return (items[i:i + chunk_size] for i in xrange(0, len(items), chunk_size))
...@@ -67,6 +77,15 @@ class ModelDataCache(object): ...@@ -67,6 +77,15 @@ class ModelDataCache(object):
""" """
def get_child_descriptors(descriptor, depth, descriptor_filter): def get_child_descriptors(descriptor, depth, descriptor_filter):
"""
Return a list of all child descriptors down to the specified depth
that match the descriptor filter. Includes `descriptor`
descriptor: The parent to search inside
depth: The number of levels to descend, or None for infinite depth
descriptor_filter(descriptor): A function that returns True
if descriptor should be included in the results
"""
if descriptor_filter(descriptor): if descriptor_filter(descriptor):
descriptors = [descriptor] descriptors = [descriptor]
else: else:
...@@ -121,7 +140,7 @@ class ModelDataCache(object): ...@@ -121,7 +140,7 @@ class ModelDataCache(object):
'module_state_key__in', 'module_state_key__in',
(descriptor.location.url() for descriptor in self.descriptors), (descriptor.location.url() for descriptor in self.descriptors),
course_id=self.course_id, course_id=self.course_id,
student=self.user, student=self.user.pk,
) )
elif scope == Scope.content: elif scope == Scope.content:
return self._chunked_query( return self._chunked_query(
...@@ -145,13 +164,13 @@ class ModelDataCache(object): ...@@ -145,13 +164,13 @@ class ModelDataCache(object):
XModuleStudentPrefsField, XModuleStudentPrefsField,
'module_type__in', 'module_type__in',
set(descriptor.location.category for descriptor in self.descriptors), set(descriptor.location.category for descriptor in self.descriptors),
student=self.user, student=self.user.pk,
field_name__in=set(field.name for field in fields), field_name__in=set(field.name for field in fields),
) )
elif scope == Scope.student_info: elif scope == Scope.student_info:
return self._query( return self._query(
XModuleStudentInfoField, XModuleStudentInfoField,
student=self.user, student=self.user.pk,
field_name__in=set(field.name for field in fields), field_name__in=set(field.name for field in fields),
) )
else: else:
...@@ -168,6 +187,9 @@ class ModelDataCache(object): ...@@ -168,6 +187,9 @@ class ModelDataCache(object):
return scope_map return scope_map
def _cache_key_from_kvs_key(self, key): def _cache_key_from_kvs_key(self, key):
"""
Return the key used in the ModelDataCache for the specified KeyValueStore key
"""
if key.scope == Scope.student_state: if key.scope == Scope.student_state:
return (key.scope, key.block_scope_id.url()) return (key.scope, key.block_scope_id.url())
elif key.scope == Scope.content: elif key.scope == Scope.content:
...@@ -180,6 +202,10 @@ class ModelDataCache(object): ...@@ -180,6 +202,10 @@ class ModelDataCache(object):
return (key.scope, key.field_name) return (key.scope, key.field_name)
def _cache_key_from_field_object(self, scope, field_object): def _cache_key_from_field_object(self, scope, field_object):
"""
Return the key used in the ModelDataCache for the specified scope and
field
"""
if scope == Scope.student_state: if scope == Scope.student_state:
return (scope, field_object.module_state_key) return (scope, field_object.module_state_key)
elif scope == Scope.content: elif scope == Scope.content:
...@@ -230,7 +256,7 @@ class ModelDataCache(object): ...@@ -230,7 +256,7 @@ class ModelDataCache(object):
usage_id='%s-%s' % (self.course_id, key.block_scope_id.url()), usage_id='%s-%s' % (self.course_id, key.block_scope_id.url()),
) )
elif key.scope == Scope.student_preferences: elif key.scope == Scope.student_preferences:
field_object, _= XModuleStudentPrefsField.objects.get_or_create( field_object, _ = XModuleStudentPrefsField.objects.get_or_create(
field_name=key.field_name, field_name=key.field_name,
module_type=key.block_scope_id, module_type=key.block_scope_id,
student=self.user, student=self.user,
...@@ -276,6 +302,7 @@ class LmsKeyValueStore(KeyValueStore): ...@@ -276,6 +302,7 @@ class LmsKeyValueStore(KeyValueStore):
Scope.student_info, Scope.student_info,
Scope.children, Scope.children,
) )
def __init__(self, descriptor_model_data, model_data_cache): def __init__(self, descriptor_model_data, model_data_cache):
self._descriptor_model_data = descriptor_model_data self._descriptor_model_data = descriptor_model_data
self._model_data_cache = model_data_cache self._model_data_cache = model_data_cache
...@@ -357,4 +384,3 @@ class LmsKeyValueStore(KeyValueStore): ...@@ -357,4 +384,3 @@ class LmsKeyValueStore(KeyValueStore):
LmsUsage = namedtuple('LmsUsage', 'id, def_id') LmsUsage = namedtuple('LmsUsage', 'id, def_id')
...@@ -40,6 +40,18 @@ DATABASES = { ...@@ -40,6 +40,18 @@ DATABASES = {
} }
} }
# Set up XQueue information so that the lms will send
# requests to a mock XQueue server running locally
XQUEUE_PORT = 8027
XQUEUE_INTERFACE = {
"url": "http://127.0.0.1:%d" % XQUEUE_PORT,
"django_auth": {
"username": "lms",
"password": "***REMOVED***"
},
"basic_auth": ('anant', 'agarwal'),
}
# Do not display the YouTube videos in the browser while running the # Do not display the YouTube videos in the browser while running the
# acceptance tests. This makes them faster and more reliable # acceptance tests. This makes them faster and more reliable
MITX_FEATURES['STUB_VIDEO_FOR_TESTING'] = True MITX_FEATURES['STUB_VIDEO_FOR_TESTING'] = True
......
...@@ -73,41 +73,6 @@ ...@@ -73,41 +73,6 @@
</article> </article>
--> -->
<article id="associate-legal-counsel" class="job">
<div class="inner-wrapper">
<h3><strong>ASSOCIATE LEGAL COUNSEL</strong></h3>
<p>We are seeking a talented lawyer with the ability to operate independently in a fast-paced environment and work proactively with all members of the edX team. You must have thorough knowledge of intellectual property law, contracts and licensing. </p>
<p><strong>Key Responsibilities: </strong></p>
<ul>
<li>Drive the negotiating, reviewing, drafting and overseeing of a wide range of transactional arrangements, including collaborations related to the provision of online education, inbound and outbound licensing of intellectual property, strategic partnerships, nondisclosure agreements, and services agreements.</li>
<li>Provide counseling on the legal implications/considerations of business and technical strategies and projects, with special emphasis on regulations related to higher education, data security and privacy.</li>
<li>Provide advice and support company-wide on a variety of legal issues in a timely and effective manner.</li>
<li>Assist on other matters as needed.</li>
</ul>
<p><strong>Requirements:</strong></p>
<ul>
<li>JD from an accredited law school</li>
<li>Massachusetts bar admission required</li>
<li>2-3 years of transactional experience at a major law firm and/or as an in-house counselor</li>
<li>Substantial IP licensing experience</li>
<li>Knowledge of copyright, trademark and patent law</li>
<li>Experience with open source content and open source software preferred</li>
<li>Outstanding communications skills (written and oral)</li>
<li>Experience with drafting and legal review of Internet privacy policies and terms of use.</li>
<li>Understanding of how to balance legal risks with business objectives</li>
<li>Ability to develop an innovative approach to legal issues in support of strategic business initiatives</li>
<li>An internal business and customer focused proactive attitude with ability to prioritize effectively</li>
<li>Experience with higher education preferred but not required</li>
</ul>
<p>If you are interested in this position, please send an email to <a href="mailto:jobs@edx.org">jobs@edx.org</a>.</p>
</div>
</article>
<article id="director-of-education-services" class="job"> <article id="director-of-education-services" class="job">
<div class="inner-wrapper"> <div class="inner-wrapper">
<h3><strong>DIRECTOR OF EDUCATIONAL SERVICES</strong></h3> <h3><strong>DIRECTOR OF EDUCATIONAL SERVICES</strong></h3>
...@@ -369,40 +334,6 @@ ...@@ -369,40 +334,6 @@
</article> </article>
<article id="director-engineering-open-source" class="job">
<div class="inner-wrapper">
<h3><strong>DIRECTOR ENGINEERING, OPEN SOURCE COMMUNITY MANAGER</strong></h3>
<p>In edX courses, students make (and break) electronic circuits, they manipulate molecules on the fly and they do it all at once, in their tens of thousands. We have great Professors and great Universities. But we can’t possibly keep up with all the great ideas out there, so we’re making our platform open source, to turn up the volume on great education. To do that well, we’ll need a Director of Engineering who can lead our Open Source Community efforts.</p>
<p><strong>Responsibilities:</strong></p>
<ul>
<li>Define and implement software design standards that make the open source community most welcome and productive.</li>
<li>Work with others to establish the governance standards for the edX Open Source Platform, establish the infrastructure, and manage the team to deliver releases and leverage our University partners and stakeholders to</li> make the edX platform the world’s best learning platform.
<li>Help the organization recognize the benefits and limitations inherent in open source solutions.</li>
<li>Establish best practices and key tool usage, especially those based on industry standards.</li>
<li>Provide visibility for the leadership team into the concerns and challenges faced by the open source community.</li>
<li>Foster a thriving community by providing the communication, documentation and feedback that they need to be enthusiastic.</li>
<li>Maximize the good code design coming from the open source community.</li>
<li>Provide the wit and firmness that the community needs to channel their energy productively.</li>
<li>Tactfully balance the internal needs of the organization to pursue new opportunities with the community’s need to participate in the platform’s evolution.</li>
<li>Shorten lines of communication and build trust across entire team</li>
</ul>
<p><strong>Qualifications:</strong></p>
<ul>
<li>Bachelors, preferably Masters in Computer Science</li>
<li>Solid communication skills, especially written</li>
<li>Committed to Agile practice, Scrum and Kanban</li>
<li>Charm and humor</li>
<li>Deep familiarity with Open Source, participant and contributor</li>
<li>Python, Django, Javascript</li>
<li>Commitment to support your technical recommendations, both within and beyond the organization.</li>
</ul>
<p>If you are interested in this position, please send an email to <a href="mailto:jobs@edx.org">jobs@edx.org</a>.</p>
</div>
</article>
<article id="software-engineer" class="job"> <article id="software-engineer" class="job">
<div class="inner-wrapper"> <div class="inner-wrapper">
<h3><strong>SOFTWARE ENGINEER</strong></h3> <h3><strong>SOFTWARE ENGINEER</strong></h3>
...@@ -441,7 +372,6 @@ ...@@ -441,7 +372,6 @@
<section class="jobs-sidebar"> <section class="jobs-sidebar">
<h2>Positions</h2> <h2>Positions</h2>
<nav> <nav>
<a href="#associate-legal-counsel">Associate Legal Counsel</a>
<a href="#director-of-education-services">Director of Education Services</a> <a href="#director-of-education-services">Director of Education Services</a>
<a href="#manager-of-training-services">Manager of Training Services</a> <a href="#manager-of-training-services">Manager of Training Services</a>
<a href="#instructional-designer">Instructional Designer</a> <a href="#instructional-designer">Instructional Designer</a>
...@@ -449,7 +379,6 @@ ...@@ -449,7 +379,6 @@
<a href="#project-manager-pmo">Project Manager (PMO)</a> <a href="#project-manager-pmo">Project Manager (PMO)</a>
<a href="#director-of-product-management">Director of Product Management</a> <a href="#director-of-product-management">Director of Product Management</a>
<a href="#content-engineer">Content Engineer</a> <a href="#content-engineer">Content Engineer</a>
<a href="#director-engineering-open-source">Director Engineering, Open Source Community Manager</a>
<a href="#software-engineer">Software Engineer</a> <a href="#software-engineer">Software Engineer</a>
</nav> </nav>
<h2>How to Apply</h2> <h2>How to Apply</h2>
......
Django==1.3.1
flup==1.0.3.dev-20110405
lxml==2.3.4
Mako==0.7.0
Markdown==2.1.1
markdown2==1.4.2
python-memcached==1.48
numpy==1.6.1
Pygments==1.5
boto==2.3.0
django-storages==1.1.4
django-masquerade==0.1.5
fs==0.4.0
django-jasmine==0.3.2
path.py==2.2.2
requests==0.12.1
BeautifulSoup==3.2.1
BeautifulSoup4==4.1.1
newrelic==1.3.0.289
ipython==0.12.1
django-pipeline==1.2.12
django-staticfiles==1.2.1
glob2==0.3
sympy==0.7.1
pymongo==2.2.1
rednose==0.3.3
mock==0.8.0
GitPython==0.3.2.RC1
PyYAML==3.10
feedparser==5.1.2
MySQL-python==1.2.3
matplotlib==1.1.0
scipy==0.10.1
akismet==0.2.0
Coffin==0.3.6
django-celery==2.2.7
django-countries==1.0.5
django-followit==0.0.3
django-keyedcache==1.4-6
django-kombu==0.9.2
django-mako==0.1.5pre
django-recaptcha-works==0.3.4
django-robots==0.8.1
django-ses==0.4.1
django-threaded-multihost==1.4-1
html5lib==0.90
Jinja2==2.6
oauth2==1.5.211
pystache==0.3.1
python-openid==2.2.5
South==0.7.5
Unidecode==0.04.9
dogstatsd-python==0.2.1
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment