diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 8809e19acf..2c3788256e 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -18,7 +18,8 @@ from django.core.files.temp import NamedTemporaryFile # to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz' from PIL import Image -from django.http import HttpResponse, Http404, HttpResponseBadRequest, HttpResponseForbidden +from django.http import HttpResponse, Http404, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseServerError +from django.http import HttpResponseNotFound from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied from django.core.context_processors import csrf @@ -1600,3 +1601,11 @@ def event(request): console logs don't get distracted :-) ''' return HttpResponse(True) + + +def render_404(request): + return HttpResponseNotFound(render_to_string('404.html', {})) + + +def render_500(request): + return HttpResponseServerError(render_to_string('500.html', {})) diff --git a/cms/envs/dev.py b/cms/envs/dev.py index f70f22512e..5612db1396 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -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 diff --git a/cms/static/coffee/files.json b/cms/static/coffee/files.json index 2249813b04..e7a66b5bc0 100644 --- a/cms/static/coffee/files.json +++ b/cms/static/coffee/files.json @@ -1,12 +1,12 @@ { - "js_files": [ - "/static/js/vendor/RequireJS.js", - "/static/js/vendor/jquery.min.js", - "/static/js/vendor/jquery-ui.min.js", - "/static/js/vendor/jquery.ui.draggable.js", - "/static/js/vendor/jquery.cookie.js", - "/static/js/vendor/json2.js", - "/static/js/vendor/underscore-min.js", - "/static/js/vendor/backbone-min.js" + "static_files": [ + "js/vendor/RequireJS.js", + "js/vendor/jquery.min.js", + "js/vendor/jquery-ui.min.js", + "js/vendor/jquery.ui.draggable.js", + "js/vendor/jquery.cookie.js", + "js/vendor/json2.js", + "js/vendor/underscore-min.js", + "js/vendor/backbone-min.js" ] } diff --git a/cms/templates/404.html b/cms/templates/404.html new file mode 100644 index 0000000000..a45a223bad --- /dev/null +++ b/cms/templates/404.html @@ -0,0 +1,14 @@ +<%inherit file="base.html" /> +<%block name="title">Page Not Found + +<%block name="content"> + +
+
+ +

Page not found

+

The page that you were looking for was not found. Go back to the homepage or let us know about any pages that may have been moved at technical@edx.org.

+
+
+ + \ No newline at end of file diff --git a/cms/templates/500.html b/cms/templates/500.html new file mode 100644 index 0000000000..2645b0067b --- /dev/null +++ b/cms/templates/500.html @@ -0,0 +1,13 @@ +<%inherit file="base.html" /> +<%block name="title">Server Error + +<%block name="content"> + +
+
+

Currently the edX servers are down

+

Our staff is currently working to get the site back up as soon as possible. Please email us at technical@edx.org to report any problems or downtime.

+
+
+ + \ No newline at end of file diff --git a/cms/urls.py b/cms/urls.py index 92963f2271..d050821318 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -105,3 +105,9 @@ if settings.ENABLE_JASMINE: urlpatterns = urlpatterns + (url(r'^_jasmine/', include('django_jasmine.urls')),) urlpatterns = patterns(*urlpatterns) + +#Custom error pages +handler404 = 'contentstore.views.render_404' +handler500 = 'contentstore.views.render_500' + + diff --git a/cms/xmodule_namespace.py b/cms/xmodule_namespace.py index 391cac8eca..cad3110574 100644 --- a/cms/xmodule_namespace.py +++ b/cms/xmodule_namespace.py @@ -1,14 +1,27 @@ +""" +Namespace defining common fields used by Studio for all blocks +""" + import datetime from xblock.core import Namespace, Boolean, Scope, ModelType, String class StringyBoolean(Boolean): + """ + Reads strings from JSON as booleans. + + If the string is 'true' (case insensitive), then return True, + otherwise False. + + JSON values that aren't strings are returned as is + """ def from_json(self, value): if isinstance(value, basestring): return value.lower() == 'true' return value + class DateTuple(ModelType): """ ModelType that stores datetime objects as time tuples @@ -24,6 +37,9 @@ class DateTuple(ModelType): class CmsNamespace(Namespace): + """ + Namespace with fields common to all blocks in Studio + """ is_draft = Boolean(help="Whether this module is a draft", default=False, scope=Scope.settings) published_date = DateTuple(help="Date when the module was published", scope=Scope.settings) published_by = String(help="Id of the user who published this module", scope=Scope.settings) diff --git a/common/djangoapps/terrain/browser.py b/common/djangoapps/terrain/browser.py index 0881d86124..6394959532 100644 --- a/common/djangoapps/terrain/browser.py +++ b/common/djangoapps/terrain/browser.py @@ -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...") diff --git a/common/djangoapps/terrain/factories.py b/common/djangoapps/terrain/factories.py index a531f4fd26..c36bf935f1 100644 --- a/common/djangoapps/terrain/factories.py +++ b/common/djangoapps/terrain/factories.py @@ -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()]) diff --git a/common/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py index 3dcef9b1ed..52eeb23c4a 100644 --- a/common/djangoapps/terrain/steps.py +++ b/common/djangoapps/terrain/steps.py @@ -69,6 +69,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 +85,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 +101,7 @@ def i_am_an_edx_user(step): #### helper functions + @world.absorb def scroll_to_bottom(): # Maximize the browser @@ -116,6 +110,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 +132,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 diff --git a/common/lib/capa/capa/tests/response_xml_factory.py b/common/lib/capa/capa/tests/response_xml_factory.py index 7aa299d20d..aa401b70cd 100644 --- a/common/lib/capa/capa/tests/response_xml_factory.py +++ b/common/lib/capa/capa/tests/response_xml_factory.py @@ -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. ). - + 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..1bf4763723 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -303,6 +303,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) @@ -330,7 +331,7 @@ class MongoModuleStore(ModuleStoreBase): return tree 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) @@ -387,12 +388,7 @@ class MongoModuleStore(ModuleStoreBase): resource_fs = OSFS(root) - 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': - metadata_inheritance_tree = self.get_cached_metadata_inheritance_tree(Location(item['location'])) + 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 # the 'metadata_inheritance_tree' parameter @@ -497,7 +493,10 @@ 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 @@ -560,6 +559,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) @@ -612,7 +614,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.get_cached_metadata_inheritance_tree(loc, force_refresh = True) def delete_item(self, location): """ @@ -630,9 +632,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.get_cached_metadata_inheritance_tree(Location(location), force_refresh = True) def get_parent_locations(self, location, course_id): 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/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/lms/djangoapps/courseware/features/common.py b/lms/djangoapps/courseware/features/common.py index 2e19696ad4..8fb2843656 100644 --- a/lms/djangoapps/courseware/features/common.py +++ b/lms/djangoapps/courseware/features/common.py @@ -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(" ", "_")) diff --git a/lms/djangoapps/courseware/features/courses.py b/lms/djangoapps/courseware/features/courses.py index eb5143b782..4fbbfd24f2 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). 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..931281a455 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 log 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..a7fbac49c7 --- /dev/null +++ b/lms/djangoapps/courseware/features/problems.feature @@ -0,0 +1,73 @@ +Feature: Answer choice problems + As a student in an edX course + In order to test my understanding of the material + I want to answer choice based problems + + Scenario: I can answer a problem correctly + Given 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 | + + Scenario: I can answer a problem incorrectly + Given 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 | + + 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..a6575c3d22 --- /dev/null +++ b/lms/djangoapps/courseware/features/problems.py @@ -0,0 +1,271 @@ +from lettuce import world, step +from lettuce.django import django_url +from selenium.webdriver.support.ui import Select +import random +import textwrap +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 + +# 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) + """)}}, + } + + +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'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)) + + # Submit the problem + check_problem(step) + + +@step(u'I check a problem') +def check_problem(step): + world.browser.find_by_css("input.check").click() + + +@step(u'I reset the problem') +def reset_problem(step): + world.browser.find_by_css('input.reset').click() + + +@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'], } + + 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']} + + 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 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) diff --git a/lms/djangoapps/courseware/features/registration.feature b/lms/djangoapps/courseware/features/registration.feature index d9b588534b..5933f860bb 100644 --- a/lms/djangoapps/courseware/features/registration.feature +++ b/lms/djangoapps/courseware/features/registration.feature @@ -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 diff --git a/lms/djangoapps/courseware/features/registration.py b/lms/djangoapps/courseware/features/registration.py index f585136412..94b9b50f6c 100644 --- a/lms/djangoapps/courseware/features/registration.py +++ b/lms/djangoapps/courseware/features/registration.py @@ -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') diff --git a/lms/djangoapps/courseware/features/smart-accordion.feature b/lms/djangoapps/courseware/features/smart-accordion.feature index ccf1d45601..fc51eca25d 100644 --- a/lms/djangoapps/courseware/features/smart-accordion.feature +++ b/lms/djangoapps/courseware/features/smart-accordion.feature @@ -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 \ No newline at end of file + # Then I verify all the content of each course diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index ecff14777d..e7f389696c 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -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, diff --git a/lms/djangoapps/django_comment_client/base/views.py b/lms/djangoapps/django_comment_client/base/views.py index 4d7b56122c..69609dcf01 100644 --- a/lms/djangoapps/django_comment_client/base/views.py +++ b/lms/djangoapps/django_comment_client/base/views.py @@ -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) diff --git a/lms/djangoapps/django_comment_client/forum/views.py b/lms/djangoapps/django_comment_client/forum/views.py index 301bb141be..3eee0948da 100644 --- a/lms/djangoapps/django_comment_client/forum/views.py +++ b/lms/djangoapps/django_comment_client/forum/views.py @@ -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( diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views.py index 0be5724365..671283db9f 100644 --- a/lms/djangoapps/instructor/views.py +++ b/lms/djangoapps/instructor/views.py @@ -92,9 +92,15 @@ def instructor_dashboard(request, course_id): data += compute_course_stats(course).items() if request.user.is_staff: for field in course.fields: + if getattr(field.scope, 'student', False): + continue + data.append([field.name, json.dumps(field.read_json(course))]) for namespace in course.namespaces: for field in getattr(course, namespace).fields: + if getattr(field.scope, 'student', False): + continue + data.append(["{}.{}".format(namespace, field.name), json.dumps(field.read_json(course))]) datatable['data'] = data diff --git a/lms/envs/acceptance.py b/lms/envs/acceptance.py index b6941f4a70..3dac545367 100644 --- a/lms/envs/acceptance.py +++ b/lms/envs/acceptance.py @@ -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 } } diff --git a/lms/static/coffee/files.json b/lms/static/coffee/files.json index 5dc03613b9..0efe488dd9 100644 --- a/lms/static/coffee/files.json +++ b/lms/static/coffee/files.json @@ -5,8 +5,5 @@ "/static/js/vendor/jquery-ui.min.js", "/static/js/vendor/jquery.leanModal.min.js", "/static/js/vendor/flot/jquery.flot.js" - ], - "static_files": [ - "js/application.js" ] } diff --git a/lms/static/coffee/spec/calculator_spec.coffee b/lms/static/coffee/spec/calculator_spec.coffee index 072d220a44..8258d8965a 100644 --- a/lms/static/coffee/spec/calculator_spec.coffee +++ b/lms/static/coffee/spec/calculator_spec.coffee @@ -4,9 +4,6 @@ describe 'Calculator', -> @calculator = new Calculator describe 'bind', -> - beforeEach -> - Calculator.bind() - it 'bind the calculator button', -> expect($('.calc')).toHandleWith 'click', @calculator.toggle @@ -31,12 +28,19 @@ describe 'Calculator', -> $('form#calculator').submit() describe 'toggle', -> - it 'toggle the calculator and focus the input', -> - spyOn $.fn, 'focus' - @calculator.toggle(jQuery.Event("click")) + it 'focuses the input when toggled', -> - expect($('li.calc-main')).toHaveClass('open') - expect($('#calculator_wrapper #calculator_input').focus).toHaveBeenCalled() + # Since the focus is called asynchronously, we need to + # wait until focus() is called. + didFocus = false + runs -> + spyOn($.fn, 'focus').andCallFake (elementName) -> didFocus = true + @calculator.toggle(jQuery.Event("click")) + + waitsFor (-> didFocus), "focus() should have been called on the input", 1000 + + runs -> + expect($('#calculator_wrapper #calculator_input').focus).toHaveBeenCalled() it 'toggle the close button on the calculator button', -> @calculator.toggle(jQuery.Event("click")) diff --git a/lms/static/coffee/spec/modules/tab_spec.coffee b/lms/static/coffee/spec/modules/tab_spec.coffee index 909f0d7cda..6fba470974 100644 --- a/lms/static/coffee/spec/modules/tab_spec.coffee +++ b/lms/static/coffee/spec/modules/tab_spec.coffee @@ -22,18 +22,23 @@ describe 'Tab', -> it 'bind the tabs', -> expect($.fn.tabs).toHaveBeenCalledWith show: @tab.onShow + # As of jQuery 1.9, the onShow callback is deprecated + # http://jqueryui.com/upgrade-guide/1.9/#deprecated-show-event-renamed-to-activate + # The code below tests that onShow does what is expected, + # but note that onShow will NOT be called when the user + # clicks on the tab if we're using jQuery version >= 1.9 describe 'onShow', -> beforeEach -> @tab = new Tab 1, @items - $('[href="#tab-1-0"]').click() + @tab.onShow($('#tab-1-0'), {'index': 1}) it 'replace content in the container', -> - $('[href="#tab-1-1"]').click() + @tab.onShow($('#tab-1-1'), {'index': 1}) expect($('#tab-1-0').html()).toEqual '' expect($('#tab-1-1').html()).toEqual 'Video 2' expect($('#tab-1-2').html()).toEqual '' it 'trigger contentChanged event on the element', -> spyOnEvent @tab.el, 'contentChanged' - $('[href="#tab-1-1"]').click() + @tab.onShow($('#tab-1-1'), {'index': 1}) expect('contentChanged').toHaveBeenTriggeredOn @tab.el diff --git a/lms/static/coffee/spec/navigation_spec.coffee b/lms/static/coffee/spec/navigation_spec.coffee index 1340984e52..b351164b63 100644 --- a/lms/static/coffee/spec/navigation_spec.coffee +++ b/lms/static/coffee/spec/navigation_spec.coffee @@ -32,11 +32,9 @@ describe 'Navigation', -> heightStyle: 'content' it 'binds the accordionchange event', -> - Navigation.bind() expect($('#accordion')).toHandleWith 'accordionchange', @navigation.log it 'bind the navigation toggle', -> - Navigation.bind() expect($('#open_close_accordion a')).toHandleWith 'click', @navigation.toggle describe 'when the #accordion does not exists', -> @@ -45,7 +43,6 @@ describe 'Navigation', -> it 'does not activate the accordion', -> spyOn $.fn, 'accordion' - Navigation.bind() expect($('#accordion').accordion).wasNotCalled() describe 'toggle', -> diff --git a/lms/templates/discussion/_underscore_templates.html b/lms/templates/discussion/_underscore_templates.html index 5fdfb8aa82..24e3b467be 100644 --- a/lms/templates/discussion/_underscore_templates.html +++ b/lms/templates/discussion/_underscore_templates.html @@ -45,10 +45,10 @@

${'<%- body %>'}
- % if course and has_permission(user, 'openclose_thread', course.id):
Pin Thread
+ %else: ${"<% if (pinned) { %>"}
@@ -57,9 +57,6 @@ % endif - - - ${'<% if (obj.courseware_url) { %>'}
(this post is about ${'<%- courseware_title %>'}) diff --git a/lms/xmodule_namespace.py b/lms/xmodule_namespace.py index 4c04700a31..423c0eb0ec 100644 --- a/lms/xmodule_namespace.py +++ b/lms/xmodule_namespace.py @@ -1,14 +1,28 @@ -from xblock.core import Namespace, Boolean, Scope, String, List, Float +""" +Namespace that defines fields common to all blocks used in the LMS +""" +from xblock.core import Namespace, Boolean, Scope, String, Float from xmodule.fields import Date, Timedelta class StringyBoolean(Boolean): + """ + Reads strings from JSON as booleans. + + 'true' (case insensitive) return True, other strings return False + Other types are returned unchanged + """ def from_json(self, value): if isinstance(value, basestring): return value.lower() == 'true' return value + class StringyFloat(Float): + """ + Reads values as floats. If the value parses as a float, returns + that, otherwise returns None + """ def from_json(self, value): try: return float(value) @@ -17,6 +31,9 @@ class StringyFloat(Float): class LmsNamespace(Namespace): + """ + Namespace that defines fields common to all blocks used in the LMS + """ hide_from_toc = StringyBoolean( help="Whether to display this module in the table of contents", default=False, @@ -38,8 +55,14 @@ class LmsNamespace(Namespace): source_file = String(help="DO NOT USE", scope=Scope.settings) xqa_key = String(help="DO NOT USE", scope=Scope.settings) ispublic = Boolean(help="Whether this course is open to the public, or only to admins", scope=Scope.settings) - graceperiod = Timedelta(help="Amount of time after the due date that submissions will be accepted", scope=Scope.settings) + graceperiod = Timedelta( + help="Amount of time after the due date that submissions will be accepted", + scope=Scope.settings + ) showanswer = String(help="When to show the problem answer to the student", scope=Scope.settings, default="closed") rerandomize = String(help="When to rerandomize the problem", default="always", scope=Scope.settings) - days_early_for_beta = StringyFloat(help="Number of days early to show content to beta users", default=None, scope=Scope.settings) - + days_early_for_beta = StringyFloat( + help="Number of days early to show content to beta users", + default=None, + scope=Scope.settings + )