).
-
+
The tree should NOT contain any input elements
(such as ) 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 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 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 ."""
return etree.Element("schematic")
+
class CodeResponseXMLFactory(ResponseXMLFactory):
""" Factory for creating XML trees """
@@ -286,9 +286,9 @@ class CodeResponseXMLFactory(ResponseXMLFactory):
def create_response_element(self, **kwargs):
""" Create a 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 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 XML """
@@ -450,9 +452,9 @@ class ImageResponseXMLFactory(ResponseXMLFactory):
def create_input_element(self, **kwargs):
""" Create the 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 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 element
@@ -552,6 +555,7 @@ class JavascriptResponseXMLFactory(ResponseXMLFactory):
""" Create the element """
return etree.Element("javascriptinput")
+
class MultipleChoiceResponseXMLFactory(ResponseXMLFactory):
""" Factory for producing XML """
@@ -564,6 +568,7 @@ class MultipleChoiceResponseXMLFactory(ResponseXMLFactory):
kwargs['choice_type'] = 'multiple'
return ResponseXMLFactory.choicegroup_input_xml(**kwargs)
+
class TrueFalseResponseXMLFactory(ResponseXMLFactory):
""" Factory for producing XML """
@@ -576,6 +581,7 @@ class TrueFalseResponseXMLFactory(ResponseXMLFactory):
kwargs['choice_type'] = 'multiple'
return ResponseXMLFactory.choicegroup_input_xml(**kwargs)
+
class OptionResponseXMLFactory(ResponseXMLFactory):
""" Factory for producing XML"""
@@ -620,7 +626,7 @@ class StringResponseXMLFactory(ResponseXMLFactory):
def create_response_element(self, **kwargs):
""" Create a XML element.
-
+
Uses **kwargs:
*answer*: The correct answer (a string) [REQUIRED]
@@ -642,7 +648,7 @@ class StringResponseXMLFactory(ResponseXMLFactory):
# Create the 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 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
-
diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py
index f05f419a03..48fbfcced1 100644
--- a/common/lib/xmodule/xmodule/combined_open_ended_module.py
+++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py
@@ -8,41 +8,66 @@ from xmodule.raw_module import RawDescriptor
from .x_module import XModule
from xblock.core import Integer, Scope, BlockScope, ModelType, String, Boolean, Object, Float, List
from xmodule.open_ended_grading_classes.combined_open_ended_modulev1 import CombinedOpenEndedV1Module, CombinedOpenEndedV1Descriptor
+from collections import namedtuple
log = logging.getLogger("mitx.courseware")
-
V1_SETTINGS_ATTRIBUTES = ["display_name", "attempts", "is_graded", "accept_file_upload",
- "skip_spelling_checks", "due", "graceperiod", "max_score"]
+ "skip_spelling_checks", "due", "graceperiod", "max_score"]
V1_STUDENT_ATTRIBUTES = ["current_task_number", "task_states", "state",
- "student_attempts", "ready_to_reset"]
+ "student_attempts", "ready_to_reset"]
V1_ATTRIBUTES = V1_SETTINGS_ATTRIBUTES + V1_STUDENT_ATTRIBUTES
-VERSION_TUPLES = (
- ('1', CombinedOpenEndedV1Descriptor, CombinedOpenEndedV1Module, V1_SETTINGS_ATTRIBUTES, V1_STUDENT_ATTRIBUTES),
-)
+VersionTuple = namedtuple('VersionTuple', ['descriptor', 'module', 'settings_attributes', 'student_attributes'])
+VERSION_TUPLES = {
+ 1: VersionTuple(CombinedOpenEndedV1Descriptor, CombinedOpenEndedV1Module, V1_SETTINGS_ATTRIBUTES,
+ V1_STUDENT_ATTRIBUTES),
+}
DEFAULT_VERSION = 1
-DEFAULT_VERSION = str(DEFAULT_VERSION)
+
+
+class VersionInteger(Integer):
+ """
+ A model type that converts from strings to integers when reading from json.
+ Also does error checking to see if version is correct or not.
+ """
+
+ def from_json(self, value):
+ try:
+ value = int(value)
+ if value not in VERSION_TUPLES:
+ version_error_string = "Could not find version {0}, using version {1} instead"
+ log.error(version_error_string.format(value, DEFAULT_VERSION))
+ value = DEFAULT_VERSION
+ except:
+ value = DEFAULT_VERSION
+ return value
class CombinedOpenEndedFields(object):
display_name = String(help="Display name for this module", default="Open Ended Grading", scope=Scope.settings)
current_task_number = Integer(help="Current task that the student is on.", default=0, scope=Scope.student_state)
task_states = List(help="List of state dictionaries of each task within this module.", scope=Scope.student_state)
- state = String(help="Which step within the current task that the student is on.", default="initial", scope=Scope.student_state)
- student_attempts = Integer(help="Number of attempts taken by the student on this problem", default=0, scope=Scope.student_state)
- ready_to_reset = Boolean(help="If the problem is ready to be reset or not.", default=False, scope=Scope.student_state)
+ state = String(help="Which step within the current task that the student is on.", default="initial",
+ scope=Scope.student_state)
+ student_attempts = Integer(help="Number of attempts taken by the student on this problem", default=0,
+ scope=Scope.student_state)
+ ready_to_reset = Boolean(help="If the problem is ready to be reset or not.", default=False,
+ scope=Scope.student_state)
attempts = Integer(help="Maximum number of attempts that a student is allowed.", default=1, scope=Scope.settings)
- is_graded = Boolean(help="Whether or not the problem is graded.", default=False, scope=Scope.settings)
- accept_file_upload = Boolean(help="Whether or not the problem accepts file uploads.", default=False, scope=Scope.settings)
- skip_spelling_checks = Boolean(help="Whether or not to skip initial spelling checks.", default=True, scope=Scope.settings)
+ is_graded = Boolean(help="Whether or not the problem is graded.", default=False, scope=Scope.settings)
+ accept_file_upload = Boolean(help="Whether or not the problem accepts file uploads.", default=False,
+ scope=Scope.settings)
+ skip_spelling_checks = Boolean(help="Whether or not to skip initial spelling checks.", default=True,
+ scope=Scope.settings)
due = String(help="Date that this problem is due by", default=None, scope=Scope.settings)
- graceperiod = String(help="Amount of time after the due date that submissions will be accepted", default=None, scope=Scope.settings)
+ graceperiod = String(help="Amount of time after the due date that submissions will be accepted", default=None,
+ scope=Scope.settings)
max_score = Integer(help="Maximum score for the problem.", default=1, scope=Scope.settings)
- version = Integer(help="Current version number", default=DEFAULT_VERSION, scope=Scope.settings)
+ version = VersionInteger(help="Current version number", default=DEFAULT_VERSION, scope=Scope.settings)
data = String(help="XML data for the problem", scope=Scope.content)
@@ -130,23 +155,10 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
if self.task_states is None:
self.task_states = []
- versions = [i[0] for i in VERSION_TUPLES]
- descriptors = [i[1] for i in VERSION_TUPLES]
- modules = [i[2] for i in VERSION_TUPLES]
- settings_attributes = [i[3] for i in VERSION_TUPLES]
- student_attributes = [i[4] for i in VERSION_TUPLES]
- version_error_string = "Could not find version {0}, using version {1} instead"
+ version_tuple = VERSION_TUPLES[self.version]
- try:
- version_index = versions.index(self.version)
- except:
- #This is a dev_facing_error
- log.error(version_error_string.format(self.version, DEFAULT_VERSION))
- self.version = DEFAULT_VERSION
- version_index = versions.index(self.version)
-
- self.student_attributes = student_attributes[version_index]
- self.settings_attributes = settings_attributes[version_index]
+ self.student_attributes = version_tuple.student_attributes
+ self.settings_attributes = version_tuple.settings_attributes
attributes = self.student_attributes + self.settings_attributes
@@ -154,10 +166,11 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
'rewrite_content_links': self.rewrite_content_links,
}
instance_state = {k: getattr(self, k) for k in attributes}
- self.child_descriptor = descriptors[version_index](self.system)
- self.child_definition = descriptors[version_index].definition_from_xml(etree.fromstring(self.data), self.system)
- self.child_module = modules[version_index](self.system, location, self.child_definition, self.child_descriptor,
- instance_state=instance_state, static_data=static_data, attributes=attributes)
+ self.child_descriptor = version_tuple.descriptor(self.system)
+ self.child_definition = version_tuple.descriptor.definition_from_xml(etree.fromstring(self.data), self.system)
+ self.child_module = version_tuple.module(self.system, location, self.child_definition, self.child_descriptor,
+ instance_state=instance_state, static_data=static_data,
+ attributes=attributes)
self.save_instance_data()
def get_html(self):
diff --git a/common/lib/xmodule/xmodule/css/poll/display.scss b/common/lib/xmodule/xmodule/css/poll/display.scss
index cfc03bcf91..82c018a3a0 100644
--- a/common/lib/xmodule/xmodule/css/poll/display.scss
+++ b/common/lib/xmodule/xmodule/css/poll/display.scss
@@ -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 {
diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py
index c5e5bbfdf8..f6fa98fc28 100644
--- a/common/lib/xmodule/xmodule/modulestore/mongo.py
+++ b/common/lib/xmodule/xmodule/modulestore/mongo.py
@@ -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
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py
index b842ffe9dd..1a82e1b708 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py
@@ -25,8 +25,7 @@ class XModuleCourseFactory(Factory):
@classmethod
def _create(cls, target_class, *args, **kwargs):
- # This logic was taken from the create_new_course method in
- # cms/djangoapps/contentstore/views.py
+
template = Location('i4x', 'edx', 'templates', 'course', 'Empty')
org = kwargs.get('org')
number = kwargs.get('number')
@@ -43,8 +42,7 @@ class XModuleCourseFactory(Factory):
if display_name is not None:
new_course.display_name = display_name
- new_course.start = gmtime()
-
+ new_course.lms.start = gmtime()
new_course.tabs = [{"type": "courseware"},
{"type": "course_info", "name": "Course Info"},
{"type": "discussion", "name": "Discussion"},
@@ -81,21 +79,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)
@@ -103,7 +121,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()])
diff --git a/common/lib/xmodule/xmodule/modulestore/xml_importer.py b/common/lib/xmodule/xmodule/modulestore/xml_importer.py
index fa232596f2..6a4ce5131b 100644
--- a/common/lib/xmodule/xmodule/modulestore/xml_importer.py
+++ b/common/lib/xmodule/xmodule/modulestore/xml_importer.py
@@ -4,6 +4,8 @@ import mimetypes
from lxml.html import rewrite_links as lxml_rewrite_links
from path import path
+from xblock.core import Scope
+
from .xml import XMLModuleStore
from .exceptions import DuplicateItemError
from xmodule.modulestore import Location
@@ -201,100 +203,127 @@ 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
->
- # 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))
+
+ content = {}
+ for field in module.fields:
+ if field.scope != Scope.content:
+ continue
+ try:
+ content[field.name] = module._model_data[field.name]
+ except KeyError:
+ # Ignore any missing keys in _model_data
+ pass
+
+ if 'data' in content:
+ module_data = content['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
->
+ # 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, content)
+
+ 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
diff --git a/common/static/coffee/src/discussion/views/discussion_thread_show_view.coffee b/common/static/coffee/src/discussion/views/discussion_thread_show_view.coffee
index a5a1deac10..56525af347 100644
--- a/common/static/coffee/src/discussion/views/discussion_thread_show_view.coffee
+++ b/common/static/coffee/src/discussion/views/discussion_thread_show_view.coffee
@@ -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")
diff --git a/common/static/sass/_mixins.scss b/common/static/sass/_mixins.scss
index 76d52ed930..c1dd5b7f2d 100644
--- a/common/static/sass/_mixins.scss
+++ b/common/static/sass/_mixins.scss
@@ -1,9 +1,12 @@
+// studio - utilities - mixins and extends
+// ====================
+
// font-sizing
@function em($pxval, $base: 16) {
@return #{$pxval / $base}em;
}
-@mixin font-size($sizeValue: 1.6){
+@mixin font-size($sizeValue: 16){
font-size: $sizeValue + px;
font-size: ($sizeValue/10) + rem;
}
@@ -64,4 +67,106 @@
:-ms-input-placeholder {
color: $color;
}
+}
+
+// ====================
+
+// extends - visual
+.faded-hr-divider {
+ @include background-image(linear-gradient(180deg, rgba(200,200,200, 0) 0%,
+ rgba(200,200,200, 1) 50%,
+ rgba(200,200,200, 0)));
+ height: 1px;
+ width: 100%;
+}
+
+.faded-hr-divider-medium {
+ @include background-image(linear-gradient(180deg, rgba(240,240,240, 0) 0%,
+ rgba(240,240,240, 1) 50%,
+ rgba(240,240,240, 0)));
+ height: 1px;
+ width: 100%;
+}
+
+.faded-hr-divider-light {
+ @include background-image(linear-gradient(180deg, rgba(255,255,255, 0) 0%,
+ rgba(255,255,255, 0.8) 50%,
+ rgba(255,255,255, 0)));
+ height: 1px;
+ width: 100%;
+}
+
+.faded-vertical-divider {
+ @include background-image(linear-gradient(90deg, rgba(200,200,200, 0) 0%,
+ rgba(200,200,200, 1) 50%,
+ rgba(200,200,200, 0)));
+ height: 100%;
+ width: 1px;
+}
+
+.faded-vertical-divider-light {
+ @include background-image(linear-gradient(90deg, rgba(255,255,255, 0) 0%,
+ rgba(255,255,255, 0.6) 50%,
+ rgba(255,255,255, 0)));
+ height: 100%;
+ width: 1px;
+}
+
+.vertical-divider {
+ @extend .faded-vertical-divider;
+ position: relative;
+
+ &::after {
+ @extend .faded-vertical-divider-light;
+ content: "";
+ display: block;
+ position: absolute;
+ left: 1px;
+ }
+}
+
+.horizontal-divider {
+ border: none;
+ @extend .faded-hr-divider;
+ position: relative;
+
+ &::after {
+ @extend .faded-hr-divider-light;
+ content: "";
+ display: block;
+ position: absolute;
+ top: 1px;
+ }
+}
+
+.fade-right-hr-divider {
+ @include background-image(linear-gradient(180deg, rgba(200,200,200, 0) 0%,
+ rgba(200,200,200, 1)));
+ border: none;
+}
+
+.fade-left-hr-divider {
+ @include background-image(linear-gradient(180deg, rgba(200,200,200, 1) 0%,
+ rgba(200,200,200, 0)));
+ border: none;
+}
+
+// extends - ui
+.window {
+ @include clearfix();
+ @include border-radius(3px);
+ @include box-shadow(0 1px 1px $shadow-l1);
+ margin-bottom: $baseline;
+ border: 1px solid $gray-l2;
+ background: $white;
+}
+
+.elem-d1 {
+ @include clearfix();
+ @include box-sizing(border-box);
+}
+
+.elem-d2 {
+ @include clearfix();
+ @include box-sizing(border-box);
}
\ No newline at end of file
diff --git a/common/templates/jasmine/base.html b/common/templates/jasmine/base.html
index 96507bdebf..9a1b3bed92 100644
--- a/common/templates/jasmine/base.html
+++ b/common/templates/jasmine/base.html
@@ -13,14 +13,19 @@
+ {% load compressed %}
+ {# static files #}
+ {% for url in suite.static_files %}
+
+ {% endfor %}
+
+ {% compressed_js 'js-test-source' %}
+
{# source files #}
{% for url in suite.js_files %}
{% endfor %}
- {% load compressed %}
- {# static files #}
- {% compressed_js 'js-test-source' %}
{# spec files #}
{% compressed_js 'spec' %}
diff --git a/common/test/data/full/vertical/vertical_89.xml b/common/test/data/full/vertical/vertical_89.xml
index c2b68b6bc2..cf2dd23462 100644
--- a/common/test/data/full/vertical/vertical_89.xml
+++ b/common/test/data/full/vertical/vertical_89.xml
@@ -7,4 +7,9 @@
+
+ Have you changed your mind?
+ Yes
+ No
+
diff --git a/doc/public/internal_data_formats/sql_schema.rst b/doc/public/internal_data_formats/sql_schema.rst
index 409ec1c065..92c5c4fa0e 100644
--- a/doc/public/internal_data_formats/sql_schema.rst
+++ b/doc/public/internal_data_formats/sql_schema.rst
@@ -313,14 +313,18 @@ There is an important split in demographic data gathered for the students who si
- This student signed up before this information was collected
* - `''` (blank)
- User did not specify level of education.
+ * - `'p'`
+ - Doctorate
* - `'p_se'`
- - Doctorate in science or engineering
+ - Doctorate in science or engineering (no longer used)
* - `'p_oth'`
- - Doctorate in another field
+ - Doctorate in another field (no longer used)
* - `'m'`
- Master's or professional degree
* - `'b'`
- Bachelor's degree
+ * - `'a'`
+ - Associate's degree
* - `'hs'`
- Secondary/high school
* - `'jhs'`
@@ -624,4 +628,4 @@ The generatedcertificate table tracks certificate state for students who have be
`grade`
-------
- The grade of the student recorded at the time the certificate was generated. This may be different than the current grade since grading is only done once for a course when it ends.
\ No newline at end of file
+ The grade of the student recorded at the time the certificate was generated. This may be different than the current grade since grading is only done once for a course when it ends.
diff --git a/lms/djangoapps/courseware/features/common.py b/lms/djangoapps/courseware/features/common.py
index 2e19696ad4..7d41637c8e 100644
--- a/lms/djangoapps/courseware/features/common.py
+++ b/lms/djangoapps/courseware/features/common.py
@@ -1,10 +1,11 @@
from lettuce import world, step
-from django.core.management import call_command
from nose.tools import assert_equals, assert_in
from lettuce.django import django_url
-from django.conf import settings
from django.contrib.auth.models import User
from student.models import CourseEnrollment
+from xmodule.modulestore import Location
+from xmodule.modulestore.django import _MODULESTORES, modulestore
+from xmodule.templates import update_templates
import time
from logging import getLogger
@@ -73,7 +74,8 @@ def should_see_in_the_page(step, text):
@step('I am logged in$')
def i_am_logged_in(step):
world.create_user('robot')
- world.log_in('robot@edx.org', 'test')
+ world.log_in('robot', 'test')
+ world.browser.visit(django_url('/'))
@step('I am not logged in$')
@@ -81,12 +83,56 @@ 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 = world.CourseFactory.create(org=TEST_COURSE_ORG,
+ number=course,
+ display_name=TEST_COURSE_NAME)
+
+ # Add a section to the course to contain problems
+ section = world.ItemFactory.create(parent_location=course.location,
+ display_name=TEST_SECTION_NAME)
+
+ problem_section = world.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')
- world.log_in('robot@edx.org', 'test')
+
+ # If the user is not already enrolled, enroll the user.
+ # TODO: change to factory
+ CourseEnrollment.objects.get_or_create(user=u, course_id=course_id(course))
+
+ world.log_in('robot', 'test')
+
+
+@step(u'The course "([^"]*)" has extra tab "([^"]*)"$')
+def add_tab_to_course(step, course, extra_tab_name):
+ section_item = world.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$')
@@ -97,3 +143,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(" ", "_"))
diff --git a/lms/djangoapps/courseware/features/courses.py b/lms/djangoapps/courseware/features/courses.py
index eb5143b782..c99fb58b85 100644
--- a/lms/djangoapps/courseware/features/courses.py
+++ b/lms/djangoapps/courseware/features/courses.py
@@ -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).
@@ -82,13 +83,13 @@ def get_courseware_with_tabs(course_id):
course = get_course_by_id(course_id)
chapters = [chapter for chapter in course.get_children() if not chapter.lms.hide_from_toc]
courseware = [{'chapter_name': c.display_name_with_default,
- 'sections': [{'section_name': s.display_name_with_default,
+ 'sections': [{'section_name': s.display_name_with_default,
'clickable_tab_count': len(s.get_children()) if (type(s) == seq_module.SequenceDescriptor) else 0,
'tabs': [{'children_count': len(t.get_children()) if (type(t) == vertical_module.VerticalDescriptor) else 0,
- 'class': t.__class__.__name__}
- for t in s.get_children()]}
+ 'class': t.__class__.__name__}
+ for t in s.get_children()]}
for s in c.get_children() if not s.lms.hide_from_toc]}
- for c in chapters]
+ for c in chapters]
return courseware
@@ -167,7 +168,6 @@ def process_section(element, num_tabs=0):
assert False, "Class for element not recognized!!"
-
def process_problem(element, problem_id):
'''
Process problem attempts to
diff --git a/lms/djangoapps/courseware/features/courseware.feature b/lms/djangoapps/courseware/features/courseware.feature
deleted file mode 100644
index 279e5732c9..0000000000
--- a/lms/djangoapps/courseware/features/courseware.feature
+++ /dev/null
@@ -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
diff --git a/lms/djangoapps/courseware/features/high-level-tabs.feature b/lms/djangoapps/courseware/features/high-level-tabs.feature
index 2e9c4f1886..473f3f1572 100644
--- a/lms/djangoapps/courseware/features/high-level-tabs.feature
+++ b/lms/djangoapps/courseware/features/high-level-tabs.feature
@@ -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 am logged in
+ And I click on View Courseware
+ When I click on the "" tab
+ Then the page title should contain ""
+
+ 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 |
diff --git a/lms/djangoapps/courseware/features/homepage.feature b/lms/djangoapps/courseware/features/homepage.feature
index 06a45c4bfa..c0c1c32f02 100644
--- a/lms/djangoapps/courseware/features/homepage.feature
+++ b/lms/djangoapps/courseware/features/homepage.feature
@@ -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
diff --git a/lms/djangoapps/courseware/features/login.py b/lms/djangoapps/courseware/features/login.py
index ca7d710c61..094db078ca 100644
--- a/lms/djangoapps/courseware/features/login.py
+++ b/lms/djangoapps/courseware/features/login.py
@@ -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
diff --git a/lms/djangoapps/courseware/features/openended.feature b/lms/djangoapps/courseware/features/openended.feature
index cc9f6e1c5f..1ab496144f 100644
--- a/lms/djangoapps/courseware/features/openended.feature
+++ b/lms/djangoapps/courseware/features/openended.feature
@@ -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"
diff --git a/lms/djangoapps/courseware/features/problems.feature b/lms/djangoapps/courseware/features/problems.feature
new file mode 100644
index 0000000000..efeb338c45
--- /dev/null
+++ b/lms/djangoapps/courseware/features/problems.feature
@@ -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 "" problem
+ When I answer a "" problem "correctly"
+ Then My "" 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 "" problem
+ When I answer a "" problem "incorrectly"
+ Then My "" 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 "" problem
+ When I check a problem
+ Then My "" 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 "" problem
+ And I answer a "" problem "ly"
+ When I reset the problem
+ Then My "" 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 |
diff --git a/lms/djangoapps/courseware/features/problems.py b/lms/djangoapps/courseware/features/problems.py
new file mode 100644
index 0000000000..6b2239c38b
--- /dev/null
+++ b/lms/djangoapps/courseware/features/problems.py
@@ -0,0 +1,296 @@
+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 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 = world.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