Merge branch 'master' into feature/btalbot/studio-checklists
This commit is contained in:
@@ -41,7 +41,8 @@ disable=
|
||||
# R0902: Too many instance attributes
|
||||
# R0903: Too few public methods (1/2)
|
||||
# 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]
|
||||
|
||||
@@ -127,8 +127,7 @@ DEBUG_TOOLBAR_PANELS = (
|
||||
'debug_toolbar.panels.sql.SQLDebugPanel',
|
||||
'debug_toolbar.panels.signals.SignalDebugPanel',
|
||||
'debug_toolbar.panels.logger.LoggingPanel',
|
||||
# This is breaking Mongo updates-- Christina is investigating.
|
||||
# 'debug_toolbar_mongo.panel.MongoDebugPanel',
|
||||
'debug_toolbar_mongo.panel.MongoDebugPanel',
|
||||
|
||||
# Enabling the profiler has a weird bug as of django-debug-toolbar==0.9.4 and
|
||||
# Django=1.3.1/1.4 where requests to views get duplicated (your method gets
|
||||
@@ -143,4 +142,4 @@ DEBUG_TOOLBAR_CONFIG = {
|
||||
|
||||
# To see stacktraces for MongoDB queries, set this to True.
|
||||
# Stacktraces slow down page loads drastically (for pages with lots of queries).
|
||||
# DEBUG_TOOLBAR_MONGO_STACKTRACES = False
|
||||
DEBUG_TOOLBAR_MONGO_STACKTRACES = False
|
||||
|
||||
@@ -142,8 +142,11 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
|
||||
|
||||
onDelete: function(event) {
|
||||
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);
|
||||
this.modelDom(event).remove();
|
||||
var cacheThis = this;
|
||||
|
||||
@@ -15,6 +15,24 @@ from .models import CourseUserGroup
|
||||
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):
|
||||
"""
|
||||
Given a course id, return a boolean for whether or not the course is
|
||||
@@ -129,13 +147,7 @@ def get_cohort(user, course_id):
|
||||
return None
|
||||
|
||||
# Put user in a random group, creating it if needed
|
||||
choice = random.randrange(0, n)
|
||||
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_name = local_random().choice(choices)
|
||||
|
||||
group, created = CourseUserGroup.objects.get_or_create(
|
||||
course_id=course_id,
|
||||
|
||||
@@ -3,6 +3,11 @@ from splinter.browser import Browser
|
||||
from logging import getLogger
|
||||
import time
|
||||
|
||||
# Let the LMS and CMS do their one-time setup
|
||||
# For example, setting up mongo caches
|
||||
from lms import one_time_startup
|
||||
from cms import one_time_startup
|
||||
|
||||
logger = getLogger(__name__)
|
||||
logger.info("Loading the lettuce acceptance testing terrain file...")
|
||||
|
||||
|
||||
@@ -121,21 +121,41 @@ class XModuleItemFactory(Factory):
|
||||
@classmethod
|
||||
def _create(cls, target_class, *args, **kwargs):
|
||||
"""
|
||||
kwargs must include parent_location, template. Can contain display_name
|
||||
target_class is ignored
|
||||
Uses *kwargs*:
|
||||
|
||||
*parent_location* (required): the location of the parent module
|
||||
(e.g. the parent course or section)
|
||||
|
||||
*template* (required): the template to create the item from
|
||||
(e.g. i4x://templates/section/Empty)
|
||||
|
||||
*data* (optional): the data for the item
|
||||
(e.g. XML problem definition for a problem item)
|
||||
|
||||
*display_name* (optional): the display name of the item
|
||||
|
||||
*metadata* (optional): dictionary of metadata attributes
|
||||
|
||||
*target_class* is ignored
|
||||
"""
|
||||
|
||||
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
|
||||
|
||||
parent_location = Location(kwargs.get('parent_location'))
|
||||
template = Location(kwargs.get('template'))
|
||||
data = kwargs.get('data')
|
||||
display_name = kwargs.get('display_name')
|
||||
metadata = kwargs.get('metadata', {})
|
||||
|
||||
store = modulestore('direct')
|
||||
|
||||
# This code was based off that in cms/djangoapps/contentstore/views.py
|
||||
parent = store.get_item(parent_location)
|
||||
dest_location = parent_location._replace(category=template.category, name=uuid4().hex)
|
||||
|
||||
# If a display name is set, use that
|
||||
dest_name = display_name.replace(" ", "_") if display_name is not None else uuid4().hex
|
||||
dest_location = parent_location._replace(category=template.category,
|
||||
name=dest_name)
|
||||
|
||||
new_item = store.clone_item(template, dest_location)
|
||||
|
||||
@@ -143,7 +163,14 @@ class XModuleItemFactory(Factory):
|
||||
if display_name is not None:
|
||||
new_item.display_name = display_name
|
||||
|
||||
store.update_metadata(new_item.location.url(), own_metadata(new_item))
|
||||
# Add additional metadata or override current metadata
|
||||
item_metadata = own_metadata(new_item)
|
||||
item_metadata.update(metadata)
|
||||
store.update_metadata(new_item.location.url(), item_metadata)
|
||||
|
||||
# replace the data with the optional *data* parameter
|
||||
if data is not None:
|
||||
store.update_item(new_item.location, data)
|
||||
|
||||
if new_item.location.category not in DETACHED_CATEGORIES:
|
||||
store.update_children(parent_location, parent.children + [new_item.location.url()])
|
||||
|
||||
@@ -9,6 +9,7 @@ from bs4 import BeautifulSoup
|
||||
import time
|
||||
import re
|
||||
import os.path
|
||||
from selenium.common.exceptions import WebDriverException
|
||||
|
||||
from logging import getLogger
|
||||
logger = getLogger(__name__)
|
||||
@@ -69,6 +70,11 @@ def the_page_title_should_be(step, title):
|
||||
assert_equals(world.browser.title, title)
|
||||
|
||||
|
||||
@step(u'the page title should contain "([^"]*)"$')
|
||||
def the_page_title_should_contain(step, title):
|
||||
assert(title in world.browser.title)
|
||||
|
||||
|
||||
@step('I am a logged in user$')
|
||||
def i_am_logged_in_user(step):
|
||||
create_user('robot')
|
||||
@@ -80,18 +86,6 @@ def i_am_not_logged_in(step):
|
||||
world.browser.cookies.delete()
|
||||
|
||||
|
||||
@step('I am registered for a course$')
|
||||
def i_am_registered_for_a_course(step):
|
||||
create_user('robot')
|
||||
u = User.objects.get(username='robot')
|
||||
CourseEnrollment.objects.get_or_create(user=u, course_id='MITx/6.002x/2012_Fall')
|
||||
|
||||
|
||||
@step('I am registered for course "([^"]*)"$')
|
||||
def i_am_registered_for_course_by_id(step, course_id):
|
||||
register_by_course_id(course_id)
|
||||
|
||||
|
||||
@step('I am staff for course "([^"]*)"$')
|
||||
def i_am_staff_for_course_by_id(step, course_id):
|
||||
register_by_course_id(course_id, True)
|
||||
@@ -108,6 +102,7 @@ def i_am_an_edx_user(step):
|
||||
|
||||
#### helper functions
|
||||
|
||||
|
||||
@world.absorb
|
||||
def scroll_to_bottom():
|
||||
# Maximize the browser
|
||||
@@ -116,6 +111,11 @@ def scroll_to_bottom():
|
||||
|
||||
@world.absorb
|
||||
def create_user(uname):
|
||||
|
||||
# If the user already exists, don't try to create it again
|
||||
if len(User.objects.filter(username=uname)) > 0:
|
||||
return
|
||||
|
||||
portal_user = UserFactory.build(username=uname, email=uname + '@edx.org')
|
||||
portal_user.set_password('test')
|
||||
portal_user.save()
|
||||
@@ -133,13 +133,25 @@ def log_in(email, password):
|
||||
world.browser.visit(django_url('/'))
|
||||
world.browser.is_element_present_by_css('header.global', 10)
|
||||
world.browser.click_link_by_href('#login-modal')
|
||||
login_form = world.browser.find_by_css('form#login_form')
|
||||
|
||||
# Wait for the login dialog to load
|
||||
# This is complicated by the fact that sometimes a second #login_form
|
||||
# dialog loads, while the first one remains hidden.
|
||||
# We give them both time to load, starting with the second one.
|
||||
world.browser.is_element_present_by_css('section.content-wrapper form#login_form', wait_time=4)
|
||||
world.browser.is_element_present_by_css('form#login_form', wait_time=2)
|
||||
|
||||
# For some reason, the page sometimes includes two #login_form
|
||||
# elements, the first of which is not visible.
|
||||
# To avoid this, we always select the last of the two #login_form dialogs
|
||||
login_form = world.browser.find_by_css('form#login_form').last
|
||||
|
||||
login_form.find_by_name('email').fill(email)
|
||||
login_form.find_by_name('password').fill(password)
|
||||
login_form.find_by_name('submit').click()
|
||||
|
||||
# wait for the page to redraw
|
||||
assert world.browser.is_element_present_by_css('.content-wrapper', 10)
|
||||
assert world.browser.is_element_present_by_css('.content-wrapper', wait_time=10)
|
||||
|
||||
|
||||
@world.absorb
|
||||
@@ -203,3 +215,15 @@ def save_the_course_content(path='/tmp'):
|
||||
f = open('%s/%s' % (path, filename), 'w')
|
||||
f.write(output)
|
||||
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()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from lxml import etree
|
||||
from abc import ABCMeta, abstractmethod
|
||||
|
||||
|
||||
class ResponseXMLFactory(object):
|
||||
""" Abstract base class for capa response XML factories.
|
||||
Subclasses override create_response_element and
|
||||
@@ -13,7 +14,7 @@ class ResponseXMLFactory(object):
|
||||
""" Subclasses override to return an etree element
|
||||
representing the capa response XML
|
||||
(e.g. <numericalresponse>).
|
||||
|
||||
|
||||
The tree should NOT contain any input elements
|
||||
(such as <textline />) as these will be added later."""
|
||||
return None
|
||||
@@ -25,7 +26,7 @@ class ResponseXMLFactory(object):
|
||||
return None
|
||||
|
||||
def build_xml(self, **kwargs):
|
||||
""" Construct an XML string for a capa response
|
||||
""" Construct an XML string for a capa response
|
||||
based on **kwargs.
|
||||
|
||||
**kwargs is a dictionary that will be passed
|
||||
@@ -37,7 +38,7 @@ class ResponseXMLFactory(object):
|
||||
|
||||
*question_text*: The text of the question to display,
|
||||
wrapped in <p> tags.
|
||||
|
||||
|
||||
*explanation_text*: The detailed explanation that will
|
||||
be shown if the user answers incorrectly.
|
||||
|
||||
@@ -75,7 +76,7 @@ class ResponseXMLFactory(object):
|
||||
for i in range(0, int(num_responses)):
|
||||
response_element = self.create_response_element(**kwargs)
|
||||
root.append(response_element)
|
||||
|
||||
|
||||
# Add input elements
|
||||
for j in range(0, int(num_inputs)):
|
||||
input_element = self.create_input_element(**kwargs)
|
||||
@@ -135,7 +136,7 @@ class ResponseXMLFactory(object):
|
||||
# Names of group elements
|
||||
group_element_names = {'checkbox': 'checkboxgroup',
|
||||
'radio': 'radiogroup',
|
||||
'multiple': 'choicegroup' }
|
||||
'multiple': 'choicegroup'}
|
||||
|
||||
# Retrieve **kwargs
|
||||
choices = kwargs.get('choices', [True])
|
||||
@@ -151,13 +152,11 @@ class ResponseXMLFactory(object):
|
||||
choice_element = etree.SubElement(group_element, "choice")
|
||||
choice_element.set("correct", "true" if correct_val else "false")
|
||||
|
||||
# Add some text describing the choice
|
||||
etree.SubElement(choice_element, "startouttext")
|
||||
etree.text = "Choice description"
|
||||
etree.SubElement(choice_element, "endouttext")
|
||||
|
||||
# Add a name identifying the choice, if one exists
|
||||
# For simplicity, we use the same string as both the
|
||||
# name attribute and the text of the element
|
||||
if name:
|
||||
choice_element.text = str(name)
|
||||
choice_element.set("name", str(name))
|
||||
|
||||
return group_element
|
||||
@@ -217,7 +216,7 @@ class CustomResponseXMLFactory(ResponseXMLFactory):
|
||||
|
||||
*answer*: Inline script that calculates the answer
|
||||
"""
|
||||
|
||||
|
||||
# Retrieve **kwargs
|
||||
cfn = kwargs.get('cfn', None)
|
||||
expect = kwargs.get('expect', None)
|
||||
@@ -247,7 +246,7 @@ class SchematicResponseXMLFactory(ResponseXMLFactory):
|
||||
|
||||
def create_response_element(self, **kwargs):
|
||||
""" Create the <schematicresponse> XML element.
|
||||
|
||||
|
||||
Uses *kwargs*:
|
||||
|
||||
*answer*: The Python script used to evaluate the answer.
|
||||
@@ -274,6 +273,7 @@ class SchematicResponseXMLFactory(ResponseXMLFactory):
|
||||
For testing, we create a bare-bones version of <schematic>."""
|
||||
return etree.Element("schematic")
|
||||
|
||||
|
||||
class CodeResponseXMLFactory(ResponseXMLFactory):
|
||||
""" Factory for creating <coderesponse> XML trees """
|
||||
|
||||
@@ -286,9 +286,9 @@ class CodeResponseXMLFactory(ResponseXMLFactory):
|
||||
|
||||
def create_response_element(self, **kwargs):
|
||||
""" Create a <coderesponse> XML element:
|
||||
|
||||
|
||||
Uses **kwargs:
|
||||
|
||||
|
||||
*initial_display*: The code that initially appears in the textbox
|
||||
[DEFAULT: "Enter code here"]
|
||||
*answer_display*: The answer to display to the student
|
||||
@@ -328,6 +328,7 @@ class CodeResponseXMLFactory(ResponseXMLFactory):
|
||||
# return None here
|
||||
return None
|
||||
|
||||
|
||||
class ChoiceResponseXMLFactory(ResponseXMLFactory):
|
||||
""" Factory for creating <choiceresponse> XML trees """
|
||||
|
||||
@@ -356,13 +357,13 @@ class FormulaResponseXMLFactory(ResponseXMLFactory):
|
||||
|
||||
*num_samples*: The number of times to sample the student's answer
|
||||
to numerically compare it to the correct answer.
|
||||
|
||||
|
||||
*tolerance*: The tolerance within which answers will be accepted
|
||||
[DEFAULT: 0.01]
|
||||
[DEFAULT: 0.01]
|
||||
|
||||
*answer*: The answer to the problem. Can be a formula string
|
||||
or a Python variable defined in a script
|
||||
(e.g. "$calculated_answer" for a Python variable
|
||||
or a Python variable defined in a script
|
||||
(e.g. "$calculated_answer" for a Python variable
|
||||
called calculated_answer)
|
||||
[REQUIRED]
|
||||
|
||||
@@ -387,7 +388,7 @@ class FormulaResponseXMLFactory(ResponseXMLFactory):
|
||||
# Set the sample information
|
||||
sample_str = self._sample_str(sample_dict, num_samples, tolerance)
|
||||
response_element.set("samples", sample_str)
|
||||
|
||||
|
||||
|
||||
# Set the tolerance
|
||||
responseparam_element = etree.SubElement(response_element, "responseparam")
|
||||
@@ -408,7 +409,7 @@ class FormulaResponseXMLFactory(ResponseXMLFactory):
|
||||
|
||||
# We could sample a different range, but for simplicity,
|
||||
# we use the same sample string for the hints
|
||||
# that we used previously.
|
||||
# that we used previously.
|
||||
formulahint_element.set("samples", sample_str)
|
||||
|
||||
formulahint_element.set("answer", str(hint_prompt))
|
||||
@@ -436,10 +437,11 @@ class FormulaResponseXMLFactory(ResponseXMLFactory):
|
||||
high_range_vals = [str(f[1]) for f in sample_dict.values()]
|
||||
sample_str = (",".join(sample_dict.keys()) + "@" +
|
||||
",".join(low_range_vals) + ":" +
|
||||
",".join(high_range_vals) +
|
||||
",".join(high_range_vals) +
|
||||
"#" + str(num_samples))
|
||||
return sample_str
|
||||
|
||||
|
||||
class ImageResponseXMLFactory(ResponseXMLFactory):
|
||||
""" Factory for producing <imageresponse> XML """
|
||||
|
||||
@@ -450,9 +452,9 @@ class ImageResponseXMLFactory(ResponseXMLFactory):
|
||||
|
||||
def create_input_element(self, **kwargs):
|
||||
""" Create the <imageinput> element.
|
||||
|
||||
|
||||
Uses **kwargs:
|
||||
|
||||
|
||||
*src*: URL for the image file [DEFAULT: "/static/image.jpg"]
|
||||
|
||||
*width*: Width of the image [DEFAULT: 100]
|
||||
@@ -490,7 +492,7 @@ class ImageResponseXMLFactory(ResponseXMLFactory):
|
||||
input_element.set("src", str(src))
|
||||
input_element.set("width", str(width))
|
||||
input_element.set("height", str(height))
|
||||
|
||||
|
||||
if rectangle:
|
||||
input_element.set("rectangle", rectangle)
|
||||
|
||||
@@ -499,6 +501,7 @@ class ImageResponseXMLFactory(ResponseXMLFactory):
|
||||
|
||||
return input_element
|
||||
|
||||
|
||||
class JavascriptResponseXMLFactory(ResponseXMLFactory):
|
||||
""" Factory for producing <javascriptresponse> XML """
|
||||
|
||||
@@ -522,7 +525,7 @@ class JavascriptResponseXMLFactory(ResponseXMLFactory):
|
||||
|
||||
# Both display_src and display_class given,
|
||||
# or neither given
|
||||
assert((display_src and display_class) or
|
||||
assert((display_src and display_class) or
|
||||
(not display_src and not display_class))
|
||||
|
||||
# Create the <javascriptresponse> element
|
||||
@@ -552,6 +555,7 @@ class JavascriptResponseXMLFactory(ResponseXMLFactory):
|
||||
""" Create the <javascriptinput> element """
|
||||
return etree.Element("javascriptinput")
|
||||
|
||||
|
||||
class MultipleChoiceResponseXMLFactory(ResponseXMLFactory):
|
||||
""" Factory for producing <multiplechoiceresponse> XML """
|
||||
|
||||
@@ -564,6 +568,7 @@ class MultipleChoiceResponseXMLFactory(ResponseXMLFactory):
|
||||
kwargs['choice_type'] = 'multiple'
|
||||
return ResponseXMLFactory.choicegroup_input_xml(**kwargs)
|
||||
|
||||
|
||||
class TrueFalseResponseXMLFactory(ResponseXMLFactory):
|
||||
""" Factory for producing <truefalseresponse> XML """
|
||||
|
||||
@@ -576,6 +581,7 @@ class TrueFalseResponseXMLFactory(ResponseXMLFactory):
|
||||
kwargs['choice_type'] = 'multiple'
|
||||
return ResponseXMLFactory.choicegroup_input_xml(**kwargs)
|
||||
|
||||
|
||||
class OptionResponseXMLFactory(ResponseXMLFactory):
|
||||
""" Factory for producing <optionresponse> XML"""
|
||||
|
||||
@@ -620,7 +626,7 @@ class StringResponseXMLFactory(ResponseXMLFactory):
|
||||
|
||||
def create_response_element(self, **kwargs):
|
||||
""" Create a <stringresponse> XML element.
|
||||
|
||||
|
||||
Uses **kwargs:
|
||||
|
||||
*answer*: The correct answer (a string) [REQUIRED]
|
||||
@@ -642,7 +648,7 @@ class StringResponseXMLFactory(ResponseXMLFactory):
|
||||
# Create the <stringresponse> element
|
||||
response_element = etree.Element("stringresponse")
|
||||
|
||||
# Set the answer attribute
|
||||
# Set the answer attribute
|
||||
response_element.set("answer", str(answer))
|
||||
|
||||
# Set the case sensitivity
|
||||
@@ -667,6 +673,7 @@ class StringResponseXMLFactory(ResponseXMLFactory):
|
||||
def create_input_element(self, **kwargs):
|
||||
return ResponseXMLFactory.textline_input_xml(**kwargs)
|
||||
|
||||
|
||||
class AnnotationResponseXMLFactory(ResponseXMLFactory):
|
||||
""" Factory for creating <annotationresponse> XML trees """
|
||||
def create_response_element(self, **kwargs):
|
||||
@@ -679,17 +686,17 @@ class AnnotationResponseXMLFactory(ResponseXMLFactory):
|
||||
input_element = etree.Element("annotationinput")
|
||||
|
||||
text_children = [
|
||||
{'tag': 'title', 'text': kwargs.get('title', 'super cool annotation') },
|
||||
{'tag': 'text', 'text': kwargs.get('text', 'texty text') },
|
||||
{'tag': 'comment', 'text':kwargs.get('comment', 'blah blah erudite comment blah blah') },
|
||||
{'tag': 'comment_prompt', 'text': kwargs.get('comment_prompt', 'type a commentary below') },
|
||||
{'tag': 'tag_prompt', 'text': kwargs.get('tag_prompt', 'select one tag') }
|
||||
{'tag': 'title', 'text': kwargs.get('title', 'super cool annotation')},
|
||||
{'tag': 'text', 'text': kwargs.get('text', 'texty text')},
|
||||
{'tag': 'comment', 'text':kwargs.get('comment', 'blah blah erudite comment blah blah')},
|
||||
{'tag': 'comment_prompt', 'text': kwargs.get('comment_prompt', 'type a commentary below')},
|
||||
{'tag': 'tag_prompt', 'text': kwargs.get('tag_prompt', 'select one tag')}
|
||||
]
|
||||
|
||||
for child in text_children:
|
||||
etree.SubElement(input_element, child['tag']).text = child['text']
|
||||
|
||||
default_options = [('green', 'correct'),('eggs', 'incorrect'),('ham', 'partially-correct')]
|
||||
default_options = [('green', 'correct'),('eggs', 'incorrect'), ('ham', 'partially-correct')]
|
||||
options = kwargs.get('options', default_options)
|
||||
options_element = etree.SubElement(input_element, 'options')
|
||||
|
||||
@@ -698,4 +705,3 @@ class AnnotationResponseXMLFactory(ResponseXMLFactory):
|
||||
option_element.text = description
|
||||
|
||||
return input_element
|
||||
|
||||
|
||||
@@ -131,6 +131,7 @@ section.poll_question {
|
||||
box-shadow: rgb(97, 184, 225) 0px 1px 0px 0px inset;
|
||||
color: rgb(255, 255, 255);
|
||||
text-shadow: rgb(7, 103, 148) 0px 1px 0px;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.text {
|
||||
|
||||
@@ -8,7 +8,7 @@ from collections import namedtuple
|
||||
from fs.osfs import OSFS
|
||||
from itertools import repeat
|
||||
from path import path
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
|
||||
from importlib import import_module
|
||||
from xmodule.errortracker import null_error_tracker, exc_info_to_str
|
||||
@@ -246,6 +246,7 @@ class MongoModuleStore(ModuleStoreBase):
|
||||
self.fs_root = path(fs_root)
|
||||
self.error_tracker = error_tracker
|
||||
self.render_template = render_template
|
||||
self.ignore_write_events_on_courses = []
|
||||
|
||||
def get_metadata_inheritance_tree(self, location):
|
||||
'''
|
||||
@@ -303,6 +304,7 @@ class MongoModuleStore(ModuleStoreBase):
|
||||
# this is likely a leaf node, so let's record what metadata we need to inherit
|
||||
metadata_to_inherit[child] = my_metadata
|
||||
|
||||
|
||||
if root is not None:
|
||||
_compute_inherited_metadata(root)
|
||||
|
||||
@@ -329,8 +331,13 @@ class MongoModuleStore(ModuleStoreBase):
|
||||
|
||||
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):
|
||||
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:
|
||||
self.metadata_inheritance_cache.delete(key_name)
|
||||
|
||||
@@ -375,7 +382,7 @@ class MongoModuleStore(ModuleStoreBase):
|
||||
|
||||
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
|
||||
"""
|
||||
@@ -389,9 +396,7 @@ class MongoModuleStore(ModuleStoreBase):
|
||||
|
||||
metadata_inheritance_tree = None
|
||||
|
||||
# if we are loading a course object, there is no parent to inherit the metadata from
|
||||
# so don't bother getting it
|
||||
if item['location']['category'] != 'course':
|
||||
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
|
||||
@@ -414,7 +419,10 @@ class MongoModuleStore(ModuleStoreBase):
|
||||
"""
|
||||
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):
|
||||
'''
|
||||
@@ -497,7 +505,12 @@ class MongoModuleStore(ModuleStoreBase):
|
||||
try:
|
||||
source_item = self.collection.find_one(location_to_query(source))
|
||||
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")
|
||||
# from overriding our default value set in the init method.
|
||||
safe=self.collection.safe
|
||||
)
|
||||
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
|
||||
@@ -519,7 +532,7 @@ class MongoModuleStore(ModuleStoreBase):
|
||||
raise DuplicateItemError(location)
|
||||
|
||||
# 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):
|
||||
'''
|
||||
@@ -560,6 +573,9 @@ class MongoModuleStore(ModuleStoreBase):
|
||||
{'$set': update},
|
||||
multi=False,
|
||||
upsert=True,
|
||||
# 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.
|
||||
safe=self.collection.safe
|
||||
)
|
||||
if result['n'] == 0:
|
||||
raise ItemNotFoundError(location)
|
||||
@@ -586,7 +602,7 @@ class MongoModuleStore(ModuleStoreBase):
|
||||
|
||||
self._update_single_item(location, {'definition.children': children})
|
||||
# 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):
|
||||
"""
|
||||
@@ -612,7 +628,7 @@ class MongoModuleStore(ModuleStoreBase):
|
||||
|
||||
self._update_single_item(location, {'metadata': metadata})
|
||||
# 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):
|
||||
"""
|
||||
@@ -630,10 +646,12 @@ class MongoModuleStore(ModuleStoreBase):
|
||||
course.tabs = [tab for tab in existing_tabs if tab.get('url_slug') != location.name]
|
||||
self.update_metadata(course.location, own_metadata(course))
|
||||
|
||||
self.collection.remove({'_id': Location(location).dict()})
|
||||
self.collection.remove({'_id': Location(location).dict()},
|
||||
# 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.
|
||||
safe=self.collection.safe)
|
||||
# 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):
|
||||
'''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,
|
||||
course_items = []
|
||||
for course_id in module_store.modules.keys():
|
||||
|
||||
course_data_path = None
|
||||
course_location = None
|
||||
if target_location_namespace is not 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:
|
||||
log.debug("Scanning {0} for course module...".format(course_id))
|
||||
try:
|
||||
# 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 module is committed first into the store
|
||||
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)
|
||||
|
||||
# cdodge: more hacks (what else). Seems like we have a problem when importing a course (like 6.002) which
|
||||
# does not have any tabs defined in the policy file. The import goes fine and then displays fine in LMS,
|
||||
# but if someone tries to add a new tab in the CMS, then the LMS barfs because it expects that -
|
||||
# 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
|
||||
|
||||
|
||||
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')
|
||||
|
||||
course_items.append(module)
|
||||
|
||||
|
||||
# then import all the static content
|
||||
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
|
||||
for module in module_store.modules[course_id].itervalues():
|
||||
|
||||
if module.category == 'course':
|
||||
# we've already saved the course module up at the top of the loop
|
||||
# so just skip over it in the inner loop
|
||||
continue
|
||||
|
||||
# remap module to the new namespace
|
||||
if target_location_namespace is not None:
|
||||
module = remap_namespace(module, target_location_namespace)
|
||||
course_data_path = None
|
||||
course_location = None
|
||||
|
||||
if verbose:
|
||||
log.debug('importing module location {0}'.format(module.location))
|
||||
log.debug("Scanning {0} for course module...".format(course_id))
|
||||
|
||||
if hasattr(module, 'data'):
|
||||
module_data = module.data
|
||||
# Quick scan to get course module as we need some info from there. Also we need to make sure that the
|
||||
# course module is committed first into the store
|
||||
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
|
||||
|
||||
# cdodge: now go through any link references to '/static/' and make sure we've imported
|
||||
# it as a StaticContent asset
|
||||
try:
|
||||
remap_dict = {}
|
||||
module = remap_namespace(module, target_location_namespace)
|
||||
|
||||
# use the rewrite_links as a utility means to enumerate through all links
|
||||
# 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))
|
||||
# cdodge: more hacks (what else). Seems like we have a problem when importing a course (like 6.002) which
|
||||
# does not have any tabs defined in the policy file. The import goes fine and then displays fine in LMS,
|
||||
# but if someone tries to add a new tab in the CMS, then the LMS barfs because it expects that -
|
||||
# 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
|
||||
|
||||
for key in remap_dict.keys():
|
||||
module_data = module_data.replace(key, remap_dict[key])
|
||||
|
||||
except Exception, e:
|
||||
logging.exception("failed to rewrite links on {0}. Continuing...".format(module.location))
|
||||
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)))
|
||||
|
||||
store.update_item(module.location, module_data)
|
||||
# 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')
|
||||
|
||||
if hasattr(module, 'children') and module.children != []:
|
||||
store.update_children(module.location, module.children)
|
||||
course_items.append(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)))
|
||||
|
||||
# then import all the static content
|
||||
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
|
||||
for module in module_store.modules[course_id].itervalues():
|
||||
|
||||
if module.category == 'course':
|
||||
# we've already saved the course module up at the top of the loop
|
||||
# so just skip over it in the inner loop
|
||||
continue
|
||||
|
||||
# remap module to the new namespace
|
||||
if target_location_namespace is not None:
|
||||
module = remap_namespace(module, target_location_namespace)
|
||||
|
||||
if verbose:
|
||||
log.debug('importing module location {0}'.format(module.location))
|
||||
|
||||
if hasattr(module, 'data'):
|
||||
module_data = module.data
|
||||
|
||||
# cdodge: now go through any link references to '/static/' and make sure we've imported
|
||||
# it as a StaticContent asset
|
||||
try:
|
||||
remap_dict = {}
|
||||
|
||||
# use the rewrite_links as a utility means to enumerate through all links
|
||||
# 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))
|
||||
|
||||
for key in remap_dict.keys():
|
||||
module_data = module_data.replace(key, remap_dict[key])
|
||||
|
||||
except Exception, e:
|
||||
logging.exception("failed to rewrite links on {0}. Continuing...".format(module.location))
|
||||
|
||||
store.update_item(module.location, module_data)
|
||||
|
||||
if hasattr(module, 'children') and module.children != []:
|
||||
store.update_children(module.location, module.children)
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
@@ -128,7 +128,9 @@ if Backbone?
|
||||
type: "POST"
|
||||
success: (response, textStatus) =>
|
||||
if textStatus == 'success'
|
||||
@model.set('pinned', true)
|
||||
@model.set('pinned', true)
|
||||
error: =>
|
||||
$('.admin-pin').text("Pinning not currently available")
|
||||
|
||||
unPin: ->
|
||||
url = @model.urlFor("unPinThread")
|
||||
|
||||
@@ -5,6 +5,10 @@ from lettuce.django import django_url
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from student.models import CourseEnrollment
|
||||
from terrain.factories import CourseFactory, ItemFactory
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import _MODULESTORES, modulestore
|
||||
from xmodule.templates import update_templates
|
||||
import time
|
||||
|
||||
from logging import getLogger
|
||||
@@ -81,14 +85,57 @@ def i_am_not_logged_in(step):
|
||||
world.browser.cookies.delete()
|
||||
|
||||
|
||||
@step(u'I am registered for a course$')
|
||||
def i_am_registered_for_a_course(step):
|
||||
TEST_COURSE_ORG = 'edx'
|
||||
TEST_COURSE_NAME = 'Test Course'
|
||||
TEST_SECTION_NAME = "Problem"
|
||||
|
||||
|
||||
@step(u'The course "([^"]*)" exists$')
|
||||
def create_course(step, course):
|
||||
|
||||
# First clear the modulestore so we don't try to recreate
|
||||
# the same course twice
|
||||
# This also ensures that the necessary templates are loaded
|
||||
flush_xmodule_store()
|
||||
|
||||
# Create the course
|
||||
# We always use the same org and display name,
|
||||
# but vary the course identifier (e.g. 600x or 191x)
|
||||
course = CourseFactory.create(org=TEST_COURSE_ORG,
|
||||
number=course,
|
||||
display_name=TEST_COURSE_NAME)
|
||||
|
||||
# Add a section to the course to contain problems
|
||||
section = ItemFactory.create(parent_location=course.location,
|
||||
display_name=TEST_SECTION_NAME)
|
||||
|
||||
problem_section = ItemFactory.create(parent_location=section.location,
|
||||
template='i4x://edx/templates/sequential/Empty',
|
||||
display_name=TEST_SECTION_NAME)
|
||||
|
||||
|
||||
@step(u'I am registered for the course "([^"]*)"$')
|
||||
def i_am_registered_for_the_course(step, course):
|
||||
# Create the course
|
||||
create_course(step, course)
|
||||
|
||||
# Create the user
|
||||
world.create_user('robot')
|
||||
u = User.objects.get(username='robot')
|
||||
CourseEnrollment.objects.create(user=u, course_id='MITx/6.002x/2012_Fall')
|
||||
|
||||
# If the user is not already enrolled, enroll the user.
|
||||
CourseEnrollment.objects.get_or_create(user=u, course_id=course_id(course))
|
||||
|
||||
world.log_in('robot@edx.org', 'test')
|
||||
|
||||
|
||||
@step(u'The course "([^"]*)" has extra tab "([^"]*)"$')
|
||||
def add_tab_to_course(step, course, extra_tab_name):
|
||||
section_item = ItemFactory.create(parent_location=course_location(course),
|
||||
template="i4x://edx/templates/static_tab/Empty",
|
||||
display_name=str(extra_tab_name))
|
||||
|
||||
|
||||
@step(u'I am an edX user$')
|
||||
def i_am_an_edx_user(step):
|
||||
world.create_user('robot')
|
||||
@@ -97,3 +144,37 @@ def i_am_an_edx_user(step):
|
||||
@step(u'User "([^"]*)" is an edX user$')
|
||||
def registered_edx_user(step, uname):
|
||||
world.create_user(uname)
|
||||
|
||||
|
||||
def flush_xmodule_store():
|
||||
# Flush and initialize the module store
|
||||
# It needs the templates because it creates new records
|
||||
# by cloning from the template.
|
||||
# Note that if your test module gets in some weird state
|
||||
# (though it shouldn't), do this manually
|
||||
# from the bash shell to drop it:
|
||||
# $ mongo test_xmodule --eval "db.dropDatabase()"
|
||||
_MODULESTORES = {}
|
||||
modulestore().collection.drop()
|
||||
update_templates()
|
||||
|
||||
|
||||
def course_id(course_num):
|
||||
return "%s/%s/%s" % (TEST_COURSE_ORG, course_num,
|
||||
TEST_COURSE_NAME.replace(" ", "_"))
|
||||
|
||||
|
||||
def course_location(course_num):
|
||||
return Location(loc_or_tag="i4x",
|
||||
org=TEST_COURSE_ORG,
|
||||
course=course_num,
|
||||
category='course',
|
||||
name=TEST_COURSE_NAME.replace(" ", "_"))
|
||||
|
||||
|
||||
def section_location(course_num):
|
||||
return Location(loc_or_tag="i4x",
|
||||
org=TEST_COURSE_ORG,
|
||||
course=course_num,
|
||||
category='sequential',
|
||||
name=TEST_SECTION_NAME.replace(" ", "_"))
|
||||
|
||||
@@ -9,6 +9,7 @@ logger = getLogger(__name__)
|
||||
|
||||
## support functions
|
||||
|
||||
|
||||
def get_courses():
|
||||
'''
|
||||
Returns dict of lists of courses available, keyed by course.org (ie university).
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
Feature: View the Courseware Tab
|
||||
As a student in an edX course
|
||||
In order to work on the course
|
||||
I want to view the info on the courseware tab
|
||||
|
||||
Scenario: I can get to the courseware tab when logged in
|
||||
Given I am registered for a course
|
||||
And I log in
|
||||
And I click on View Courseware
|
||||
When I click on the "Courseware" tab
|
||||
Then the "Courseware" tab is active
|
||||
@@ -3,21 +3,18 @@ Feature: All the high level tabs should work
|
||||
As a student
|
||||
I want to navigate through the high level tabs
|
||||
|
||||
# Note this didn't work as a scenario outline because
|
||||
# before each scenario was not flushing the database
|
||||
# TODO: break this apart so that if one fails the others
|
||||
# will still run
|
||||
Scenario: A student can see all tabs of the course
|
||||
Given I am registered for a course
|
||||
And I log in
|
||||
And I click on View Courseware
|
||||
When I click on the "Courseware" tab
|
||||
Then the page title should be "6.002x Courseware"
|
||||
When I click on the "Course Info" tab
|
||||
Then the page title should be "6.002x Course Info"
|
||||
When I click on the "Textbook" tab
|
||||
Then the page title should be "6.002x Textbook"
|
||||
When I click on the "Wiki" tab
|
||||
Then the page title should be "6.002x | edX Wiki"
|
||||
When I click on the "Progress" tab
|
||||
Then the page title should be "6.002x Progress"
|
||||
Scenario: I can navigate to all high -level tabs in a course
|
||||
Given: I am registered for the course "6.002x"
|
||||
And The course "6.002x" has extra tab "Custom Tab"
|
||||
And I log in
|
||||
And I click on View Courseware
|
||||
When I click on the "<TabName>" tab
|
||||
Then the page title should contain "<PageTitle>"
|
||||
|
||||
Examples:
|
||||
| TabName | PageTitle |
|
||||
| Courseware | 6.002x Courseware |
|
||||
| Course Info | 6.002x Course Info |
|
||||
| Custom Tab | 6.002x Custom Tab |
|
||||
| Wiki | edX Wiki |
|
||||
| Progress | 6.002x Progress |
|
||||
|
||||
@@ -39,9 +39,9 @@ Feature: Homepage for web users
|
||||
| MITx |
|
||||
| HarvardX |
|
||||
| BerkeleyX |
|
||||
| UTx |
|
||||
| UTx |
|
||||
| WellesleyX |
|
||||
| GeorgetownX |
|
||||
| GeorgetownX |
|
||||
|
||||
# # TODO: Add scenario that tests the courses available
|
||||
# # using a policy or a configuration file
|
||||
|
||||
@@ -34,6 +34,7 @@ def click_the_dropdown(step):
|
||||
|
||||
#### helper functions
|
||||
|
||||
|
||||
def user_is_an_unactivated_user(uname):
|
||||
u = User.objects.get(username=uname)
|
||||
u.is_active = False
|
||||
|
||||
@@ -3,10 +3,10 @@ Feature: Open ended grading
|
||||
In order to complete the courseware questions
|
||||
I want the machine learning grading to be functional
|
||||
|
||||
# Commenting these all out right now until we can
|
||||
# Commenting these all out right now until we can
|
||||
# make a reference implementation for a course with
|
||||
# an open ended grading problem that is always available
|
||||
#
|
||||
#
|
||||
# Scenario: An answer that is too short is rejected
|
||||
# Given I navigate to an openended question
|
||||
# And I enter the answer "z"
|
||||
|
||||
77
lms/djangoapps/courseware/features/problems.feature
Normal file
77
lms/djangoapps/courseware/features/problems.feature
Normal file
@@ -0,0 +1,77 @@
|
||||
Feature: Answer problems
|
||||
As a student in an edX course
|
||||
In order to test my understanding of the material
|
||||
I want to answer problems
|
||||
|
||||
Scenario: I can answer a problem correctly
|
||||
Given External graders respond "correct"
|
||||
And I am viewing a "<ProblemType>" problem
|
||||
When I answer a "<ProblemType>" problem "correctly"
|
||||
Then My "<ProblemType>" answer is marked "correct"
|
||||
|
||||
Examples:
|
||||
| ProblemType |
|
||||
| drop down |
|
||||
| multiple choice |
|
||||
| checkbox |
|
||||
| string |
|
||||
| numerical |
|
||||
| formula |
|
||||
| script |
|
||||
| code |
|
||||
|
||||
Scenario: I can answer a problem incorrectly
|
||||
Given External graders respond "incorrect"
|
||||
And I am viewing a "<ProblemType>" problem
|
||||
When I answer a "<ProblemType>" problem "incorrectly"
|
||||
Then My "<ProblemType>" answer is marked "incorrect"
|
||||
|
||||
Examples:
|
||||
| ProblemType |
|
||||
| drop down |
|
||||
| multiple choice |
|
||||
| checkbox |
|
||||
| string |
|
||||
| numerical |
|
||||
| formula |
|
||||
| script |
|
||||
| code |
|
||||
|
||||
Scenario: I can submit a blank answer
|
||||
Given I am viewing a "<ProblemType>" problem
|
||||
When I check a problem
|
||||
Then My "<ProblemType>" answer is marked "incorrect"
|
||||
|
||||
Examples:
|
||||
| ProblemType |
|
||||
| drop down |
|
||||
| multiple choice |
|
||||
| checkbox |
|
||||
| string |
|
||||
| numerical |
|
||||
| formula |
|
||||
| script |
|
||||
|
||||
|
||||
Scenario: I can reset a problem
|
||||
Given I am viewing a "<ProblemType>" problem
|
||||
And I answer a "<ProblemType>" problem "<Correctness>ly"
|
||||
When I reset the problem
|
||||
Then My "<ProblemType>" answer is marked "unanswered"
|
||||
|
||||
Examples:
|
||||
| ProblemType | Correctness |
|
||||
| drop down | correct |
|
||||
| drop down | incorrect |
|
||||
| multiple choice | correct |
|
||||
| multiple choice | incorrect |
|
||||
| checkbox | correct |
|
||||
| checkbox | incorrect |
|
||||
| string | correct |
|
||||
| string | incorrect |
|
||||
| numerical | correct |
|
||||
| numerical | incorrect |
|
||||
| formula | correct |
|
||||
| formula | incorrect |
|
||||
| script | correct |
|
||||
| script | incorrect |
|
||||
304
lms/djangoapps/courseware/features/problems.py
Normal file
304
lms/djangoapps/courseware/features/problems.py
Normal file
@@ -0,0 +1,304 @@
|
||||
from lettuce import world, step
|
||||
from lettuce.django import django_url
|
||||
import random
|
||||
import textwrap
|
||||
import time
|
||||
from common import i_am_registered_for_the_course, TEST_SECTION_NAME, section_location
|
||||
from terrain.factories import ItemFactory
|
||||
from capa.tests.response_xml_factory import OptionResponseXMLFactory, \
|
||||
ChoiceResponseXMLFactory, MultipleChoiceResponseXMLFactory, \
|
||||
StringResponseXMLFactory, NumericalResponseXMLFactory, \
|
||||
FormulaResponseXMLFactory, CustomResponseXMLFactory, \
|
||||
CodeResponseXMLFactory
|
||||
|
||||
# Factories from capa.tests.response_xml_factory that we will use
|
||||
# to generate the problem XML, with the keyword args used to configure
|
||||
# the output.
|
||||
PROBLEM_FACTORY_DICT = {
|
||||
'drop down': {
|
||||
'factory': OptionResponseXMLFactory(),
|
||||
'kwargs': {
|
||||
'question_text': 'The correct answer is Option 2',
|
||||
'options': ['Option 1', 'Option 2', 'Option 3', 'Option 4'],
|
||||
'correct_option': 'Option 2'}},
|
||||
|
||||
'multiple choice': {
|
||||
'factory': MultipleChoiceResponseXMLFactory(),
|
||||
'kwargs': {
|
||||
'question_text': 'The correct answer is Choice 3',
|
||||
'choices': [False, False, True, False],
|
||||
'choice_names': ['choice_1', 'choice_2', 'choice_3', 'choice_4']}},
|
||||
|
||||
'checkbox': {
|
||||
'factory': ChoiceResponseXMLFactory(),
|
||||
'kwargs': {
|
||||
'question_text': 'The correct answer is Choices 1 and 3',
|
||||
'choice_type': 'checkbox',
|
||||
'choices': [True, False, True, False, False],
|
||||
'choice_names': ['Choice 1', 'Choice 2', 'Choice 3', 'Choice 4']}},
|
||||
|
||||
'string': {
|
||||
'factory': StringResponseXMLFactory(),
|
||||
'kwargs': {
|
||||
'question_text': 'The answer is "correct string"',
|
||||
'case_sensitive': False,
|
||||
'answer': 'correct string'}},
|
||||
|
||||
'numerical': {
|
||||
'factory': NumericalResponseXMLFactory(),
|
||||
'kwargs': {
|
||||
'question_text': 'The answer is pi + 1',
|
||||
'answer': '4.14159',
|
||||
'tolerance': '0.00001',
|
||||
'math_display': True}},
|
||||
|
||||
'formula': {
|
||||
'factory': FormulaResponseXMLFactory(),
|
||||
'kwargs': {
|
||||
'question_text': 'The solution is [mathjax]x^2+2x+y[/mathjax]',
|
||||
'sample_dict': {'x': (-100, 100), 'y': (-100, 100)},
|
||||
'num_samples': 10,
|
||||
'tolerance': 0.00001,
|
||||
'math_display': True,
|
||||
'answer': 'x^2+2*x+y'}},
|
||||
|
||||
'script': {
|
||||
'factory': CustomResponseXMLFactory(),
|
||||
'kwargs': {
|
||||
'question_text': 'Enter two integers that sum to 10.',
|
||||
'cfn': 'test_add_to_ten',
|
||||
'expect': '10',
|
||||
'num_inputs': 2,
|
||||
'script': textwrap.dedent("""
|
||||
def test_add_to_ten(expect,ans):
|
||||
try:
|
||||
a1=int(ans[0])
|
||||
a2=int(ans[1])
|
||||
except ValueError:
|
||||
a1=0
|
||||
a2=0
|
||||
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"}', }},
|
||||
}
|
||||
|
||||
|
||||
def add_problem_to_course(course, problem_type):
|
||||
|
||||
assert(problem_type in PROBLEM_FACTORY_DICT)
|
||||
|
||||
# Generate the problem XML using capa.tests.response_xml_factory
|
||||
factory_dict = PROBLEM_FACTORY_DICT[problem_type]
|
||||
problem_xml = factory_dict['factory'].build_xml(**factory_dict['kwargs'])
|
||||
|
||||
# Create a problem item using our generated XML
|
||||
# We set rerandomize=always in the metadata so that the "Reset" button
|
||||
# will appear.
|
||||
problem_item = ItemFactory.create(parent_location=section_location(course),
|
||||
template="i4x://edx/templates/problem/Blank_Common_Problem",
|
||||
display_name=str(problem_type),
|
||||
data=problem_xml,
|
||||
metadata={'rerandomize': 'always'})
|
||||
|
||||
|
||||
@step(u'I am viewing a "([^"]*)" problem')
|
||||
def view_problem(step, problem_type):
|
||||
i_am_registered_for_the_course(step, 'model_course')
|
||||
|
||||
# Ensure that the course has this problem type
|
||||
add_problem_to_course('model_course', problem_type)
|
||||
|
||||
# Go to the one section in the factory-created course
|
||||
# which should be loaded with the correct problem
|
||||
chapter_name = TEST_SECTION_NAME.replace(" ", "_")
|
||||
section_name = chapter_name
|
||||
url = django_url('/courses/edx/model_course/Test_Course/courseware/%s/%s' %
|
||||
(chapter_name, section_name))
|
||||
|
||||
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"')
|
||||
def answer_problem(step, problem_type, correctness):
|
||||
""" Mark a given problem type correct or incorrect, then submit it.
|
||||
|
||||
*problem_type* is a string representing the type of problem (e.g. 'drop down')
|
||||
*correctness* is in ['correct', 'incorrect']
|
||||
"""
|
||||
|
||||
assert(correctness in ['correct', 'incorrect'])
|
||||
|
||||
if problem_type == "drop down":
|
||||
select_name = "input_i4x-edx-model_course-problem-drop_down_2_1"
|
||||
option_text = 'Option 2' if correctness == 'correct' else 'Option 3'
|
||||
world.browser.select(select_name, option_text)
|
||||
|
||||
elif problem_type == "multiple choice":
|
||||
if correctness == 'correct':
|
||||
inputfield('multiple choice', choice='choice_3').check()
|
||||
else:
|
||||
inputfield('multiple choice', choice='choice_2').check()
|
||||
|
||||
elif problem_type == "checkbox":
|
||||
if correctness == 'correct':
|
||||
inputfield('checkbox', choice='choice_0').check()
|
||||
inputfield('checkbox', choice='choice_2').check()
|
||||
else:
|
||||
inputfield('checkbox', choice='choice_3').check()
|
||||
|
||||
elif problem_type == 'string':
|
||||
textvalue = 'correct string' if correctness == 'correct' else 'incorrect'
|
||||
inputfield('string').fill(textvalue)
|
||||
|
||||
elif problem_type == 'numerical':
|
||||
textvalue = "pi + 1" if correctness == 'correct' else str(random.randint(-2, 2))
|
||||
inputfield('numerical').fill(textvalue)
|
||||
|
||||
elif problem_type == 'formula':
|
||||
textvalue = "x^2+2*x+y" if correctness == 'correct' else 'x^2'
|
||||
inputfield('formula').fill(textvalue)
|
||||
|
||||
elif problem_type == 'script':
|
||||
# Correct answer is any two integers that sum to 10
|
||||
first_addend = random.randint(-100, 100)
|
||||
second_addend = 10 - first_addend
|
||||
|
||||
# If we want an incorrect answer, then change
|
||||
# the second addend so they no longer sum to 10
|
||||
if correctness == 'incorrect':
|
||||
second_addend += random.randint(1, 10)
|
||||
|
||||
inputfield('script', input_num=1).fill(str(first_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
|
||||
check_problem(step)
|
||||
|
||||
|
||||
@step(u'I check a problem')
|
||||
def check_problem(step):
|
||||
world.css_click("input.check")
|
||||
|
||||
|
||||
@step(u'I reset the problem')
|
||||
def reset_problem(step):
|
||||
world.css_click('input.reset')
|
||||
|
||||
|
||||
@step(u'My "([^"]*)" answer is marked "([^"]*)"')
|
||||
def assert_answer_mark(step, problem_type, correctness):
|
||||
""" Assert that the expected answer mark is visible for a given problem type.
|
||||
|
||||
*problem_type* is a string identifying the type of problem (e.g. 'drop down')
|
||||
*correctness* is in ['correct', 'incorrect', 'unanswered']
|
||||
|
||||
Asserting that a problem is marked 'unanswered' means that
|
||||
the problem is NOT marked correct and NOT marked incorrect.
|
||||
This can occur, for example, if the user has reset the problem. """
|
||||
|
||||
# Dictionaries that map problem types to the css selectors
|
||||
# for correct/incorrect marks.
|
||||
# The elements are lists of selectors because a particular problem type
|
||||
# might be marked in multiple ways.
|
||||
# For example, multiple choice is marked incorrect differently
|
||||
# depending on whether the user selects an incorrect
|
||||
# item or submits without selecting any item)
|
||||
correct_selectors = {'drop down': ['span.correct'],
|
||||
'multiple choice': ['label.choicegroup_correct'],
|
||||
'checkbox': ['span.correct'],
|
||||
'string': ['div.correct'],
|
||||
'numerical': ['div.correct'],
|
||||
'formula': ['div.correct'],
|
||||
'script': ['div.correct'],
|
||||
'code': ['span.correct'], }
|
||||
|
||||
incorrect_selectors = {'drop down': ['span.incorrect'],
|
||||
'multiple choice': ['label.choicegroup_incorrect',
|
||||
'span.incorrect'],
|
||||
'checkbox': ['span.incorrect'],
|
||||
'string': ['div.incorrect'],
|
||||
'numerical': ['div.incorrect'],
|
||||
'formula': ['div.incorrect'],
|
||||
'script': ['div.incorrect'],
|
||||
'code': ['span.incorrect'], }
|
||||
|
||||
assert(correctness in ['correct', 'incorrect', 'unanswered'])
|
||||
assert(problem_type in correct_selectors and problem_type in incorrect_selectors)
|
||||
|
||||
# Assert that the question has the expected mark
|
||||
# (either correct or incorrect)
|
||||
if correctness in ["correct", "incorrect"]:
|
||||
|
||||
selector_dict = correct_selectors if correctness == "correct" else incorrect_selectors
|
||||
|
||||
# At least one of the correct selectors should be present
|
||||
for sel in selector_dict[problem_type]:
|
||||
has_expected_mark = world.browser.is_element_present_by_css(sel, wait_time=4)
|
||||
|
||||
# As soon as we find the selector, break out of the loop
|
||||
if has_expected_mark:
|
||||
break
|
||||
|
||||
# Expect that we found the right mark (correct or incorrect)
|
||||
assert(has_expected_mark)
|
||||
|
||||
# Assert that the question has neither correct nor incorrect
|
||||
# because it is unanswered (possibly reset)
|
||||
else:
|
||||
# Get all the correct/incorrect selectors for this problem type
|
||||
selector_list = correct_selectors[problem_type] + incorrect_selectors[problem_type]
|
||||
|
||||
# Assert that none of the correct/incorrect selectors are present
|
||||
for sel in selector_list:
|
||||
assert(world.browser.is_element_not_present_by_css(sel, wait_time=4))
|
||||
|
||||
|
||||
def inputfield(problem_type, choice=None, input_num=1):
|
||||
""" Return the <input> element for *problem_type*.
|
||||
For example, if problem_type is 'string', return
|
||||
the text field for the string problem in the test course.
|
||||
|
||||
*choice* is the name of the checkbox input in a group
|
||||
of checkboxes. """
|
||||
|
||||
sel = ("input#input_i4x-edx-model_course-problem-%s_2_%s" %
|
||||
(problem_type.replace(" ", "_"), str(input_num)))
|
||||
|
||||
if choice is not None:
|
||||
base = "_choice_" if problem_type == "multiple choice" else "_"
|
||||
sel = sel + base + str(choice)
|
||||
|
||||
# If the input element doesn't exist, fail immediately
|
||||
assert(world.browser.is_element_present_by_css(sel, wait_time=4))
|
||||
|
||||
# Retrieve the input element
|
||||
return world.browser.find_by_css(sel)
|
||||
@@ -4,13 +4,14 @@ Feature: Register for a course
|
||||
I want to register for a class on the edX website
|
||||
|
||||
Scenario: I can register for a course
|
||||
Given I am logged in
|
||||
Given The course "6.002x" exists
|
||||
And I am logged in
|
||||
And I visit the courses page
|
||||
When I register for the course numbered "6.002x"
|
||||
When I register for the course "6.002x"
|
||||
Then I should see the course numbered "6.002x" in my dashboard
|
||||
|
||||
Scenario: I can unregister for a course
|
||||
Given I am registered for a course
|
||||
Given I am registered for the course "6.002x"
|
||||
And I visit the dashboard
|
||||
When I click the link with the text "Unregister"
|
||||
And I press the "Unregister" button in the Unenroll dialog
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from lettuce import world, step
|
||||
from lettuce.django import django_url
|
||||
from common import TEST_COURSE_ORG, TEST_COURSE_NAME
|
||||
|
||||
|
||||
@step('I register for the course numbered "([^"]*)"$')
|
||||
@step('I register for the course "([^"]*)"$')
|
||||
def i_register_for_the_course(step, course):
|
||||
courses_section = world.browser.find_by_css('section.courses')
|
||||
course_link_css = 'article[id*="%s"] > div' % course
|
||||
course_link = courses_section.find_by_css(course_link_css).first
|
||||
course_link.click()
|
||||
cleaned_name = TEST_COURSE_NAME.replace(' ', '_')
|
||||
url = django_url('courses/%s/%s/%s/about' % (TEST_COURSE_ORG, course, cleaned_name))
|
||||
world.browser.visit(url)
|
||||
|
||||
intro_section = world.browser.find_by_css('section.intro')
|
||||
register_link = intro_section.find_by_css('a.register')
|
||||
|
||||
@@ -60,4 +60,4 @@ Feature: There are courses on the homepage
|
||||
# Scenario: Navigate through course BerkeleyX/CS184.1x/2012_Fall
|
||||
# Given I am registered for course "BerkeleyX/CS184.1x/2012_Fall"
|
||||
# And I log in
|
||||
# Then I verify all the content of each course
|
||||
# Then I verify all the content of each course
|
||||
|
||||
32
lms/djangoapps/courseware/features/xqueue_setup.py
Normal file
32
lms/djangoapps/courseware/features/xqueue_setup.py
Normal file
@@ -0,0 +1,32 @@
|
||||
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()
|
||||
@@ -159,6 +159,7 @@ def grade(student, request, course, model_data_cache=None, keep_raw_scores=False
|
||||
# If we haven't seen a single problem in the section, we don't have to grade it at all! We can assume 0%
|
||||
for moduledescriptor in section['xmoduledescriptors']:
|
||||
# Create a fake key to pull out a StudentModule object from the ModelDataCache
|
||||
|
||||
key = LmsKeyValueStore.Key(
|
||||
Scope.student_state,
|
||||
student.id,
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
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
|
||||
@@ -0,0 +1,78 @@
|
||||
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)
|
||||
@@ -1,3 +1,7 @@
|
||||
"""
|
||||
Classes to provide the LMS runtime data storage to XBlocks
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections import namedtuple, defaultdict
|
||||
from itertools import chain
|
||||
@@ -14,10 +18,16 @@ from xblock.core import Scope
|
||||
|
||||
|
||||
class InvalidWriteError(Exception):
|
||||
pass
|
||||
"""
|
||||
Raised to indicate that writing to a particular key
|
||||
in the KeyValueStore is disabled
|
||||
"""
|
||||
|
||||
|
||||
def chunks(items, chunk_size):
|
||||
"""
|
||||
Yields the values from items in chunks of size chunk_size
|
||||
"""
|
||||
items = list(items)
|
||||
return (items[i:i + chunk_size] for i in xrange(0, len(items), chunk_size))
|
||||
|
||||
@@ -67,6 +77,15 @@ class ModelDataCache(object):
|
||||
"""
|
||||
|
||||
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):
|
||||
descriptors = [descriptor]
|
||||
else:
|
||||
@@ -121,7 +140,7 @@ class ModelDataCache(object):
|
||||
'module_state_key__in',
|
||||
(descriptor.location.url() for descriptor in self.descriptors),
|
||||
course_id=self.course_id,
|
||||
student=self.user,
|
||||
student=self.user.pk,
|
||||
)
|
||||
elif scope == Scope.content:
|
||||
return self._chunked_query(
|
||||
@@ -145,13 +164,13 @@ class ModelDataCache(object):
|
||||
XModuleStudentPrefsField,
|
||||
'module_type__in',
|
||||
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),
|
||||
)
|
||||
elif scope == Scope.student_info:
|
||||
return self._query(
|
||||
XModuleStudentInfoField,
|
||||
student=self.user,
|
||||
student=self.user.pk,
|
||||
field_name__in=set(field.name for field in fields),
|
||||
)
|
||||
else:
|
||||
@@ -168,6 +187,9 @@ class ModelDataCache(object):
|
||||
return scope_map
|
||||
|
||||
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:
|
||||
return (key.scope, key.block_scope_id.url())
|
||||
elif key.scope == Scope.content:
|
||||
@@ -180,6 +202,10 @@ class ModelDataCache(object):
|
||||
return (key.scope, key.field_name)
|
||||
|
||||
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:
|
||||
return (scope, field_object.module_state_key)
|
||||
elif scope == Scope.content:
|
||||
@@ -230,7 +256,7 @@ class ModelDataCache(object):
|
||||
usage_id='%s-%s' % (self.course_id, key.block_scope_id.url()),
|
||||
)
|
||||
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,
|
||||
module_type=key.block_scope_id,
|
||||
student=self.user,
|
||||
@@ -276,6 +302,7 @@ class LmsKeyValueStore(KeyValueStore):
|
||||
Scope.student_info,
|
||||
Scope.children,
|
||||
)
|
||||
|
||||
def __init__(self, descriptor_model_data, model_data_cache):
|
||||
self._descriptor_model_data = descriptor_model_data
|
||||
self._model_data_cache = model_data_cache
|
||||
@@ -357,4 +384,3 @@ class LmsKeyValueStore(KeyValueStore):
|
||||
|
||||
|
||||
LmsUsage = namedtuple('LmsUsage', 'id, def_id')
|
||||
|
||||
|
||||
@@ -116,6 +116,10 @@ def create_thread(request, course_id, commentable_id):
|
||||
|
||||
thread.save()
|
||||
|
||||
#patch for backward compatibility to comments service
|
||||
if not 'pinned' in thread.attributes:
|
||||
thread['pinned'] = False
|
||||
|
||||
if post.get('auto_subscribe', 'false').lower() == 'true':
|
||||
user = cc.User.from_django_user(request.user)
|
||||
user.follow(thread)
|
||||
|
||||
@@ -98,6 +98,11 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG
|
||||
else:
|
||||
thread['group_name'] = ""
|
||||
thread['group_string'] = "This post visible to everyone."
|
||||
|
||||
#patch for backward compatibility to comments service
|
||||
if not 'pinned' in thread:
|
||||
thread['pinned'] = False
|
||||
|
||||
|
||||
query_params['page'] = page
|
||||
query_params['num_pages'] = num_pages
|
||||
@@ -245,6 +250,11 @@ def single_thread(request, course_id, discussion_id, thread_id):
|
||||
|
||||
try:
|
||||
thread = cc.Thread.find(thread_id).retrieve(recursive=True, user_id=request.user.id)
|
||||
|
||||
#patch for backward compatibility with comments service
|
||||
if not 'pinned' in thread.attributes:
|
||||
thread['pinned'] = False
|
||||
|
||||
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err:
|
||||
log.error("Error loading single thread.")
|
||||
raise Http404
|
||||
@@ -285,6 +295,10 @@ def single_thread(request, course_id, discussion_id, thread_id):
|
||||
if thread.get('group_id') and not thread.get('group_name'):
|
||||
thread['group_name'] = get_cohort_by_id(course_id, thread.get('group_id')).name
|
||||
|
||||
#patch for backward compatibility with comments service
|
||||
if not "pinned" in thread:
|
||||
thread["pinned"] = False
|
||||
|
||||
threads = [utils.safe_content(thread) for thread in threads]
|
||||
|
||||
#recent_active_threads = cc.search_recent_active_threads(
|
||||
|
||||
@@ -8,16 +8,24 @@ from .test import *
|
||||
# otherwise the browser will not render the pages correctly
|
||||
DEBUG = True
|
||||
|
||||
# Show the courses that are in the data directory
|
||||
COURSES_ROOT = ENV_ROOT / "data"
|
||||
DATA_DIR = COURSES_ROOT
|
||||
# Use the mongo store for acceptance tests
|
||||
modulestore_options = {
|
||||
'default_class': 'xmodule.raw_module.RawDescriptor',
|
||||
'host': 'localhost',
|
||||
'db': 'test_xmodule',
|
||||
'collection': 'modulestore',
|
||||
'fs_root': GITHUB_REPO_ROOT,
|
||||
'render_template': 'mitxmako.shortcuts.render_to_string',
|
||||
}
|
||||
|
||||
MODULESTORE = {
|
||||
'default': {
|
||||
'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore',
|
||||
'OPTIONS': {
|
||||
'data_dir': DATA_DIR,
|
||||
'default_class': 'xmodule.hidden_module.HiddenDescriptor',
|
||||
}
|
||||
'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
|
||||
'OPTIONS': modulestore_options
|
||||
},
|
||||
'direct': {
|
||||
'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
|
||||
'OPTIONS': modulestore_options
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,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
|
||||
# acceptance tests. This makes them faster and more reliable
|
||||
MITX_FEATURES['STUB_VIDEO_FOR_TESTING'] = True
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 4.8 KiB |
@@ -45,10 +45,10 @@
|
||||
</header>
|
||||
|
||||
<div class="post-body">${'<%- body %>'}</div>
|
||||
|
||||
% if course and has_permission(user, 'openclose_thread', course.id):
|
||||
<div class="admin-pin discussion-pin notpinned" data-role="thread-pin" data-tooltip="pin this thread">
|
||||
<i class="icon"></i><span class="pin-label">Pin Thread</span></div>
|
||||
|
||||
%else:
|
||||
${"<% if (pinned) { %>"}
|
||||
<div class="discussion-pin notpinned" data-role="thread-pin" data-tooltip="pin this thread">
|
||||
@@ -57,9 +57,6 @@
|
||||
% endif
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
${'<% if (obj.courseware_url) { %>'}
|
||||
<div class="post-context">
|
||||
(this post is about <a href="${'<%- courseware_url%>'}">${'<%- courseware_title %>'}</a>)
|
||||
|
||||
@@ -73,41 +73,6 @@
|
||||
</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">
|
||||
<div class="inner-wrapper">
|
||||
<h3><strong>DIRECTOR OF EDUCATIONAL SERVICES</strong></h3>
|
||||
@@ -369,40 +334,6 @@
|
||||
</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">
|
||||
<div class="inner-wrapper">
|
||||
<h3><strong>SOFTWARE ENGINEER</strong></h3>
|
||||
@@ -441,7 +372,6 @@
|
||||
<section class="jobs-sidebar">
|
||||
<h2>Positions</h2>
|
||||
<nav>
|
||||
<a href="#associate-legal-counsel">Associate Legal Counsel</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="#instructional-designer">Instructional Designer</a>
|
||||
@@ -449,7 +379,6 @@
|
||||
<a href="#project-manager-pmo">Project Manager (PMO)</a>
|
||||
<a href="#director-of-product-management">Director of Product Management</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>
|
||||
</nav>
|
||||
<h2>How to Apply</h2>
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
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
|
||||
Reference in New Issue
Block a user