From c2e5c607136e93a8c63d1aa073fe0ef1756a24f3 Mon Sep 17 00:00:00 2001
From: Vik Paruchuri
Date: Thu, 13 Jun 2013 15:34:00 -0400
Subject: [PATCH 01/73] Remove correct/incorrect message, and auto-add in
needed markdown for studio
---
.../lib/xmodule/xmodule/js/src/combinedopenended/edit.coffee | 4 ++++
lms/templates/combinedopenended/openended/open_ended.html | 4 ----
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/edit.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/edit.coffee
index 1b7f9bb4fb..2f58e86cb2 100644
--- a/common/lib/xmodule/xmodule/js/src/combinedopenended/edit.coffee
+++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/edit.coffee
@@ -50,6 +50,10 @@ Write a persuasive essay to a newspaper reflecting your vies on censorship in li
mode: null
})
@setCurrentEditor(@markdown_editor)
+ selection = @markdown_editor.getSelection()
+ #Auto-add in the needed template if it isn't already in there.
+ if(@markdown_editor.getValue() == "")
+ @markdown_editor.setValue(OpenEndedMarkdownEditingDescriptor.promptTemplate + "\n" + OpenEndedMarkdownEditingDescriptor.rubricTemplate + "\n" + OpenEndedMarkdownEditingDescriptor.tasksTemplate)
# Add listeners for toolbar buttons (only present for markdown editor)
@element.on('click', '.xml-tab', @onShowXMLButton)
@element.on('click', '.format-buttons a', @onToolbarButton)
diff --git a/lms/templates/combinedopenended/openended/open_ended.html b/lms/templates/combinedopenended/openended/open_ended.html
index 9fb136cee6..909ef15838 100644
--- a/lms/templates/combinedopenended/openended/open_ended.html
+++ b/lms/templates/combinedopenended/openended/open_ended.html
@@ -10,10 +10,6 @@
% if state == 'initial':
Unanswered
- % elif state in ['done', 'post_assessment'] and correct == 'correct':
-
Correct
- % elif state in ['done', 'post_assessment'] and correct == 'incorrect':
-
Incorrect.
% elif state == 'assessing':
Submitted for grading.
% if eta_message is not None:
From cd49e1bb686f747969d13e875111f448cf5b71bb Mon Sep 17 00:00:00 2001
From: Vik Paruchuri
Date: Thu, 13 Jun 2013 16:00:28 -0400
Subject: [PATCH 02/73] Check to see if a location is valid before displaying
it for instructor grading
---
.../open_ended_grading/staff_grading_service.py | 11 ++++++++++-
lms/djangoapps/open_ended_grading/utils.py | 16 ++++++++++++++++
lms/djangoapps/open_ended_grading/views.py | 2 +-
3 files changed, 27 insertions(+), 2 deletions(-)
create mode 100644 lms/djangoapps/open_ended_grading/utils.py
diff --git a/lms/djangoapps/open_ended_grading/staff_grading_service.py b/lms/djangoapps/open_ended_grading/staff_grading_service.py
index 2c611b4481..a2d905be0b 100644
--- a/lms/djangoapps/open_ended_grading/staff_grading_service.py
+++ b/lms/djangoapps/open_ended_grading/staff_grading_service.py
@@ -15,6 +15,7 @@ from xmodule.course_module import CourseDescriptor
from student.models import unique_id_for_user
from xmodule.x_module import ModuleSystem
from mitxmako.shortcuts import render_to_string
+from utils import does_location_exist
log = logging.getLogger(__name__)
@@ -240,7 +241,6 @@ def get_next(request, course_id):
return HttpResponse(_get_next(course_id, grader_id, location),
mimetype="application/json")
-
def get_problem_list(request, course_id):
"""
Get all the problems for the given course id
@@ -266,6 +266,15 @@ def get_problem_list(request, course_id):
_check_access(request.user, course_id)
try:
response = staff_grading_service().get_problem_list(course_id, unique_id_for_user(request.user))
+ response = json.loads(response)
+ problem_list = response['problem_list']
+ valid_problem_list = []
+ for i in xrange(0,len(problem_list)):
+ if does_location_exist(course_id, problem_list[i]['location']):
+ valid_problem_list.append(problem_list[i])
+ response['problem_list'] = valid_problem_list
+ response = json.dumps(response)
+
return HttpResponse(response,
mimetype="application/json")
except GradingServiceError:
diff --git a/lms/djangoapps/open_ended_grading/utils.py b/lms/djangoapps/open_ended_grading/utils.py
new file mode 100644
index 0000000000..7634977397
--- /dev/null
+++ b/lms/djangoapps/open_ended_grading/utils.py
@@ -0,0 +1,16 @@
+from xmodule.modulestore import search
+from xmodule.modulestore.django import modulestore
+from xmodule.modulestore.exceptions import ItemNotFoundError
+
+def does_location_exist(course_id, location):
+ """
+ Checks to see if a valid module exists at a given location (ie has not been deleted)
+ course_id - string course id
+ location - string location
+ """
+ try:
+ search.path_to_location(modulestore(), course_id, location)
+ return True
+ except ItemNotFoundError:
+ #If the problem cannot be found at the location received from the grading controller server, it has been deleted by the course author.
+ return False
diff --git a/lms/djangoapps/open_ended_grading/views.py b/lms/djangoapps/open_ended_grading/views.py
index a914e434a9..797c90f9a4 100644
--- a/lms/djangoapps/open_ended_grading/views.py
+++ b/lms/djangoapps/open_ended_grading/views.py
@@ -179,6 +179,7 @@ def student_problem_list(request, course_id):
error_text = ""
problem_list = []
base_course_url = reverse('courses')
+ list_to_remove = []
try:
#Get list of all open ended problems that the grading server knows about
@@ -192,7 +193,6 @@ def student_problem_list(request, course_id):
problem_list = problem_list_dict['problem_list']
#A list of problems to remove (problems that can't be found in the course)
- list_to_remove = []
for i in xrange(0, len(problem_list)):
try:
#Try to load each problem in the courseware to get links to them
From 9b14ea790c30f25792c05bf6901c3ae44cc4c603 Mon Sep 17 00:00:00 2001
From: Vik Paruchuri
Date: Thu, 27 Jun 2013 16:20:58 -0400
Subject: [PATCH 03/73] Properly increment student attempts
---
.../open_ended_grading_classes/combined_open_ended_modulev1.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
index 1fe62035e6..c99d0915cb 100644
--- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
+++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
@@ -653,6 +653,7 @@ class CombinedOpenEndedV1Module():
).format(self.student_attempts, self.attempts)
}
self.state = self.INITIAL
+ self.student_attempts +=1
self.ready_to_reset = False
for i in xrange(0, len(self.task_xml)):
self.current_task_number = i
From 8a6c8b5ab0ac79d5e458e95fc743351dd0c6f8cc Mon Sep 17 00:00:00 2001
From: Vik Paruchuri
Date: Fri, 28 Jun 2013 14:10:17 -0400
Subject: [PATCH 04/73] Add test for incrementing student attempts
---
.../combined_open_ended_modulev1.py | 4 +-
.../xmodule/tests/test_combined_open_ended.py | 51 +++++++++++++++++++
.../SampleQuestion1Attempt.xml | 24 +++++++++
.../test/data/open_ended/course/2012_Fall.xml | 1 +
4 files changed, 78 insertions(+), 2 deletions(-)
create mode 100644 common/test/data/open_ended/combinedopenended/SampleQuestion1Attempt.xml
diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
index c99d0915cb..62c9417660 100644
--- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
+++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
@@ -643,7 +643,8 @@ class CombinedOpenEndedV1Module():
if not self.ready_to_reset:
return self.out_of_sync_error(data)
- if self.student_attempts > self.attempts:
+ self.student_attempts +=1
+ if self.student_attempts >= self.attempts:
return {
'success': False,
# This is a student_facing_error
@@ -653,7 +654,6 @@ class CombinedOpenEndedV1Module():
).format(self.student_attempts, self.attempts)
}
self.state = self.INITIAL
- self.student_attempts +=1
self.ready_to_reset = False
for i in xrange(0, len(self.task_xml)):
self.current_task_number = i
diff --git a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
index e1f8d135de..dc0023f80d 100644
--- a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
+++ b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
@@ -629,3 +629,54 @@ class OpenEndedModuleXmlTest(unittest.TestCase, DummyModulestore):
#reset the problem
module.handle_ajax("reset", {})
self.assertEqual(module.state, "initial")
+
+class OpenEndedModuleXmlTest(unittest.TestCase, DummyModulestore):
+ """
+ Test the student flow in the combined open ended xmodule
+ """
+ problem_location = Location(["i4x", "edX", "open_ended", "combinedopenended", "SampleQuestion1Attempt"])
+ answer = "blah blah"
+ assessment = [0, 1]
+ hint = "blah"
+
+ def setUp(self):
+ self.test_system = get_test_system()
+ self.test_system.xqueue['interface'] = Mock(
+ send_to_queue=Mock(side_effect=[1, "queued"])
+ )
+ self.setup_modulestore(COURSE)
+
+ def test_reset_fail(self):
+ """
+ Test the flow of the module if we complete the self assessment step and then reset
+ @return:
+ """
+ assessment = [0, 1]
+ module = self.get_module_from_location(self.problem_location, COURSE)
+
+ #Simulate a student saving an answer
+ module.handle_ajax("save_answer", {"student_answer": self.answer})
+ status = module.handle_ajax("get_status", {})
+ self.assertTrue(isinstance(status, basestring))
+
+ #Mock a student submitting an assessment
+ assessment_dict = MockQueryDict()
+ assessment_dict.update({'assessment': sum(assessment), 'score_list[]': assessment})
+ module.handle_ajax("save_assessment", assessment_dict)
+ task_one_json = json.loads(module.task_states[0])
+ self.assertEqual(json.loads(task_one_json['child_history'][0]['post_assessment']), assessment)
+ status = module.handle_ajax("get_status", {})
+ self.assertTrue(isinstance(status, basestring))
+
+ #Move to the next step in the problem
+ module.handle_ajax("next_problem", {})
+ self.assertEqual(module.current_task_number, 0)
+
+ html = module.get_html()
+ self.assertTrue(isinstance(html, basestring))
+
+ rubric = module.handle_ajax("get_combined_rubric", {})
+ self.assertTrue(isinstance(rubric, basestring))
+ self.assertEqual(module.state, "done")
+ reset_data = json.loads(module.handle_ajax("reset", {}))
+ self.assertEqual(reset_data['success'], False)
diff --git a/common/test/data/open_ended/combinedopenended/SampleQuestion1Attempt.xml b/common/test/data/open_ended/combinedopenended/SampleQuestion1Attempt.xml
new file mode 100644
index 0000000000..9bfabca191
--- /dev/null
+++ b/common/test/data/open_ended/combinedopenended/SampleQuestion1Attempt.xml
@@ -0,0 +1,24 @@
+
+
+
+
+ Writing Applications
+
+
+
+
+ Language Conventions
+
+
+
+
+
+
+
Censorship in the Libraries
+
"All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us." --Katherine Paterson, Author
+
Write a persuasive essay to a newspaper reflecting your vies on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading.
+
+
+
+
+
\ No newline at end of file
diff --git a/common/test/data/open_ended/course/2012_Fall.xml b/common/test/data/open_ended/course/2012_Fall.xml
index 32c810174b..609d12f94c 100644
--- a/common/test/data/open_ended/course/2012_Fall.xml
+++ b/common/test/data/open_ended/course/2012_Fall.xml
@@ -1,6 +1,7 @@
+
From b38a36d44a5600c44da9840b59eb2dbef08a6bdf Mon Sep 17 00:00:00 2001
From: Vik Paruchuri
Date: Fri, 28 Jun 2013 14:38:13 -0400
Subject: [PATCH 05/73] Fix test
---
.../lib/xmodule/xmodule/tests/test_combined_open_ended.py | 8 ++++++--
.../open_ended_grading/staff_grading_service.py | 5 +++++
2 files changed, 11 insertions(+), 2 deletions(-)
diff --git a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
index dc0023f80d..af1b6aa12b 100644
--- a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
+++ b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
@@ -630,9 +630,9 @@ class OpenEndedModuleXmlTest(unittest.TestCase, DummyModulestore):
module.handle_ajax("reset", {})
self.assertEqual(module.state, "initial")
-class OpenEndedModuleXmlTest(unittest.TestCase, DummyModulestore):
+class OpenEndedModuleXmlAttemptTest(unittest.TestCase, DummyModulestore):
"""
- Test the student flow in the combined open ended xmodule
+ Test if student is able to reset the problem
"""
problem_location = Location(["i4x", "edX", "open_ended", "combinedopenended", "SampleQuestion1Attempt"])
answer = "blah blah"
@@ -649,6 +649,7 @@ class OpenEndedModuleXmlTest(unittest.TestCase, DummyModulestore):
def test_reset_fail(self):
"""
Test the flow of the module if we complete the self assessment step and then reset
+ Since the problem only allows one attempt, should fail.
@return:
"""
assessment = [0, 1]
@@ -675,8 +676,11 @@ class OpenEndedModuleXmlTest(unittest.TestCase, DummyModulestore):
html = module.get_html()
self.assertTrue(isinstance(html, basestring))
+ #Module should now be done
rubric = module.handle_ajax("get_combined_rubric", {})
self.assertTrue(isinstance(rubric, basestring))
self.assertEqual(module.state, "done")
+
+ #Try to reset, should fail because only 1 attempt is allowed
reset_data = json.loads(module.handle_ajax("reset", {}))
self.assertEqual(reset_data['success'], False)
diff --git a/lms/djangoapps/open_ended_grading/staff_grading_service.py b/lms/djangoapps/open_ended_grading/staff_grading_service.py
index eda78d0b09..a6a2847bc1 100644
--- a/lms/djangoapps/open_ended_grading/staff_grading_service.py
+++ b/lms/djangoapps/open_ended_grading/staff_grading_service.py
@@ -270,6 +270,11 @@ def get_problem_list(request, course_id):
problem_list = response['problem_list']
valid_problem_list = []
for i in xrange(0,len(problem_list)):
+ #Needed to ensure that the 'location' key can be accessed
+ try:
+ problem_list[i] = json.loads(problem_list[i])
+ except Exception:
+ pass
if does_location_exist(course_id, problem_list[i]['location']):
valid_problem_list.append(problem_list[i])
response['problem_list'] = valid_problem_list
From 8930e753e4c225a16e862825aac84cb15469fb8d Mon Sep 17 00:00:00 2001
From: Vik Paruchuri
Date: Fri, 28 Jun 2013 14:59:39 -0400
Subject: [PATCH 06/73] Set default weights
---
.../xmodule/combined_open_ended_module.py | 70 ++++++++++++++-----
.../xmodule/xmodule/peer_grading_module.py | 29 +++++---
2 files changed, 75 insertions(+), 24 deletions(-)
diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py
index 52d98f032e..fdc85369a1 100644
--- a/common/lib/xmodule/xmodule/combined_open_ended_module.py
+++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py
@@ -51,47 +51,85 @@ class CombinedOpenEndedFields(object):
display_name = String(
display_name="Display Name",
help="This name appears in the horizontal navigation at the top of the page.",
- default="Open Ended Grading", scope=Scope.settings
+ default="Open Ended Grading",
+ scope=Scope.settings
+ )
+ current_task_number = Integer(
+ help="Current task that the student is on.",
+ default=0,
+ scope=Scope.user_state
+ )
+ task_states = List(
+ help="List of state dictionaries of each task within this module.",
+ scope=Scope.user_state
+ )
+ state = String(
+ help="Which step within the current task that the student is on.",
+ default="initial",
+ scope=Scope.user_state
+ )
+ student_attempts = Integer(
+ help="Number of attempts taken by the student on this problem",
+ default=0,
+ scope=Scope.user_state
)
- current_task_number = Integer(help="Current task that the student is on.", default=0, scope=Scope.user_state)
- task_states = List(help="List of state dictionaries of each task within this module.", scope=Scope.user_state)
- state = String(help="Which step within the current task that the student is on.", default="initial",
- scope=Scope.user_state)
- student_attempts = Integer(help="Number of attempts taken by the student on this problem", default=0,
- scope=Scope.user_state)
ready_to_reset = Boolean(
- help="If the problem is ready to be reset or not.", default=False,
+ help="If the problem is ready to be reset or not.",
+ default=False,
scope=Scope.user_state
)
attempts = Integer(
display_name="Maximum Attempts",
- help="The number of times the student can try to answer this problem.", default=1,
+ help="The number of times the student can try to answer this problem.",
+ default=1,
scope=Scope.settings, values={"min" : 1 }
)
- is_graded = Boolean(display_name="Graded", help="Whether or not the problem is graded.", default=False, scope=Scope.settings)
+ is_graded = Boolean(
+ display_name="Graded",
+ help="Whether or not the problem is graded.",
+ default=False,
+ scope=Scope.settings
+ )
accept_file_upload = Boolean(
display_name="Allow File Uploads",
- help="Whether or not the student can submit files as a response.", default=False, scope=Scope.settings
+ help="Whether or not the student can submit files as a response.",
+ default=False,
+ scope=Scope.settings
)
skip_spelling_checks = Boolean(
display_name="Disable Quality Filter",
help="If False, the Quality Filter is enabled and submissions with poor spelling, short length, or poor grammar will not be peer reviewed.",
default=False, scope=Scope.settings
)
- due = Date(help="Date that this problem is due by", default=None, scope=Scope.settings)
+ due = Date(
+ 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
)
- version = VersionInteger(help="Current version number", default=DEFAULT_VERSION, scope=Scope.settings)
- data = String(help="XML data for the problem", scope=Scope.content)
+ version = VersionInteger(
+ help="Current version number",
+ default=DEFAULT_VERSION,
+ scope=Scope.settings
+ )
+ data = String(
+ help="XML data for the problem",
+ scope=Scope.content
+ )
weight = Float(
display_name="Problem Weight",
help="Defines the number of points each problem is worth. If the value is not set, each problem is worth one point.",
- scope=Scope.settings, values={"min" : 0 , "step": ".1"}
+ scope=Scope.settings, values={"min" : 0 , "step": ".1"},
+ default=1
+ )
+ markdown = String(
+ help="Markdown source of this module",
+ scope=Scope.settings
)
- markdown = String(help="Markdown source of this module", scope=Scope.settings)
class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py
index 7df444a892..f92971796c 100644
--- a/common/lib/xmodule/xmodule/peer_grading_module.py
+++ b/common/lib/xmodule/xmodule/peer_grading_module.py
@@ -32,23 +32,35 @@ class PeerGradingFields(object):
display_name="Show Single Problem",
help='When True, only the single problem specified by "Link to Problem Location" is shown. '
'When False, a panel is displayed with all problems available for peer grading.',
- default=USE_FOR_SINGLE_LOCATION, scope=Scope.settings
+ default=USE_FOR_SINGLE_LOCATION,
+ scope=Scope.settings
)
link_to_location = String(
display_name="Link to Problem Location",
help='The location of the problem being graded. Only used when "Show Single Problem" is True.',
- default=LINK_TO_LOCATION, scope=Scope.settings
+ default=LINK_TO_LOCATION,
+ scope=Scope.settings
)
is_graded = Boolean(
display_name="Graded",
help='Defines whether the student gets credit for grading this problem. Only used when "Show Single Problem" is True.',
- default=IS_GRADED, scope=Scope.settings
+ default=IS_GRADED,
+ scope=Scope.settings
+ )
+ due_date = Date(
+ help="Due date that should be displayed.",
+ default=None,
+ scope=Scope.settings)
+ grace_period_string = String(
+ help="Amount of grace to give on the due date.",
+ default=None,
+ scope=Scope.settings
)
- due_date = Date(help="Due date that should be displayed.", default=None, scope=Scope.settings)
- grace_period_string = String(help="Amount of grace to give on the due date.", default=None, scope=Scope.settings)
max_grade = Integer(
- help="The maximum grade that a student can receive for this problem.", default=MAX_SCORE,
- scope=Scope.settings, values={"min": 0}
+ help="The maximum grade that a student can receive for this problem.",
+ default=MAX_SCORE,
+ scope=Scope.settings,
+ values={"min": 0}
)
student_data_for_location = Dict(
help="Student data for a given peer grading problem.",
@@ -57,7 +69,8 @@ class PeerGradingFields(object):
weight = Float(
display_name="Problem Weight",
help="Defines the number of points each problem is worth. If the value is not set, each problem is worth one point.",
- scope=Scope.settings, values={"min": 0, "step": ".1"}
+ scope=Scope.settings, values={"min": 0, "step": ".1"},
+ default=1
)
From 2246944594cf2199e4c57fe371755011b4de5be8 Mon Sep 17 00:00:00 2001
From: Vik Paruchuri
Date: Fri, 28 Jun 2013 15:09:19 -0400
Subject: [PATCH 07/73] Set a default weight
---
.../xmodule/combined_open_ended_module.py | 9 ++++++---
.../combined_open_ended_modulev1.py | 11 ++++++++---
.../lib/xmodule/xmodule/peer_grading_module.py | 16 ++++++++++------
3 files changed, 24 insertions(+), 12 deletions(-)
diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py
index fdc85369a1..736b82e21d 100644
--- a/common/lib/xmodule/xmodule/combined_open_ended_module.py
+++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py
@@ -82,7 +82,8 @@ class CombinedOpenEndedFields(object):
display_name="Maximum Attempts",
help="The number of times the student can try to answer this problem.",
default=1,
- scope=Scope.settings, values={"min" : 1 }
+ scope=Scope.settings,
+ values={"min" : 1 }
)
is_graded = Boolean(
display_name="Graded",
@@ -99,7 +100,8 @@ class CombinedOpenEndedFields(object):
skip_spelling_checks = Boolean(
display_name="Disable Quality Filter",
help="If False, the Quality Filter is enabled and submissions with poor spelling, short length, or poor grammar will not be peer reviewed.",
- default=False, scope=Scope.settings
+ default=False,
+ scope=Scope.settings
)
due = Date(
help="Date that this problem is due by",
@@ -123,7 +125,8 @@ class CombinedOpenEndedFields(object):
weight = Float(
display_name="Problem Weight",
help="Defines the number of points each problem is worth. If the value is not set, each problem is worth one point.",
- scope=Scope.settings, values={"min" : 0 , "step": ".1"},
+ scope=Scope.settings,
+ values={"min" : 0 , "step": ".1"},
default=1
)
markdown = String(
diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
index 62c9417660..926bd780f7 100644
--- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
+++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
@@ -727,7 +727,12 @@ class CombinedOpenEndedV1Module():
"""
max_score = None
score = None
- if self.is_scored and self.weight is not None:
+
+ #The old default was None, so set to 1 if it is the old default weight
+ weight = self.weight
+ if weight is None:
+ weight = 1
+ if self.is_scored:
# Finds the maximum score of all student attempts and keeps it.
score_mat = []
for i in xrange(0, len(self.task_states)):
@@ -740,7 +745,7 @@ class CombinedOpenEndedV1Module():
for z in xrange(0, len(score)):
if score[z] is None:
score[z] = 0
- score[z] *= float(self.weight)
+ score[z] *= float(weight)
score_mat.append(score)
if len(score_mat) > 0:
@@ -754,7 +759,7 @@ class CombinedOpenEndedV1Module():
if max_score is not None:
# Weight the max score if it is not None
- max_score *= float(self.weight)
+ max_score *= float(weight)
else:
# Without a max_score, we cannot have a score!
score = None
diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py
index f92971796c..6164a4d635 100644
--- a/common/lib/xmodule/xmodule/peer_grading_module.py
+++ b/common/lib/xmodule/xmodule/peer_grading_module.py
@@ -57,7 +57,7 @@ class PeerGradingFields(object):
scope=Scope.settings
)
max_grade = Integer(
- help="The maximum grade that a student can receive for this problem.",
+ help="The maximum grade that a student can receive for this problem.",
default=MAX_SCORE,
scope=Scope.settings,
values={"min": 0}
@@ -214,6 +214,11 @@ class PeerGradingModule(PeerGradingFields, XModule):
def get_score(self):
max_score = None
score = None
+ weight = self.weight
+
+ #The old default was None, so set to 1 if it is the old default weight
+ if weight is None:
+ weight = 1
score_dict = {
'score': score,
'total': max_score,
@@ -238,11 +243,10 @@ class PeerGradingModule(PeerGradingFields, XModule):
# Ensures that once a student receives a final score for peer grading, that it does not change.
self.student_data_for_location = response
- if self.weight is not None:
- score = int(count_graded >= count_required and count_graded > 0) * float(self.weight)
- total = self.max_grade * float(self.weight)
- score_dict['score'] = score
- score_dict['total'] = total
+ score = int(count_graded >= count_required and count_graded > 0) * float(weight)
+ total = self.max_grade * float(weight)
+ score_dict['score'] = score
+ score_dict['total'] = total
return score_dict
From b1653f056143216ec0026c2f6efcc30df2e2a680 Mon Sep 17 00:00:00 2001
From: Frances Botsford
Date: Thu, 11 Jul 2013 11:19:39 -0400
Subject: [PATCH 08/73] layout cleanup on LMS PDF Textbook viewer
---
common/static/css/pdfviewer.css | 3 +-
lms/static/sass/course/_textbook.scss | 59 +++++++++++++++++++----
lms/templates/static_pdfbook.html | 67 ++++++++++++++-------------
3 files changed, 87 insertions(+), 42 deletions(-)
diff --git a/common/static/css/pdfviewer.css b/common/static/css/pdfviewer.css
index 656bc47c29..8b0253261b 100644
--- a/common/static/css/pdfviewer.css
+++ b/common/static/css/pdfviewer.css
@@ -100,7 +100,7 @@ select {
.toolbar {
/* position: absolute; */
left: 0;
- right: 0;
+ right: 0;
height: 32px;
z-index: 9999;
cursor: default;
@@ -185,6 +185,7 @@ select {
margin: 0;
}
+.splitToolbarButton > .toolbarButton, /*added */
.splitToolbarButton:hover > .toolbarButton,
.splitToolbarButton:focus > .toolbarButton,
.splitToolbarButton.toggled > .toolbarButton,
diff --git a/lms/static/sass/course/_textbook.scss b/lms/static/sass/course/_textbook.scss
index bc9da1f43f..08f1a853dc 100644
--- a/lms/static/sass/course/_textbook.scss
+++ b/lms/static/sass/course/_textbook.scss
@@ -1,13 +1,46 @@
div.book-wrapper {
- display: table;
- table-layout: fixed;
- padding: 1em 8em;
+ max-width: 1150px;
+ margin: 0 auto;
+ width: 100%;
+ background-color: $white;
+ #toolbarViewer {
+ padding: 0 ($baseline/2);
+
+ #toolbarViewerLeft {
+ display: inline-block;
+ }
+
+ .outerCenter {
+ display: inline-block;
+ float: right !important;
+ right: auto;
+
+ .innerCenter {
+ right: auto;
+ }
+
+ .dropdownToolbarButton {
+ margin: 3px 2px 4px 0;
+ }
+ }
+
+
+ }
#open_close_accordion {
display: none;
}
+ .pdfbook-wrap {
+ display: table;
+ width: 100%;
+ }
+
+ .pdfbook-wrap-inner {
+ display: table-row;
+ }
+
section.book-sidebar {
@extend .sidebar;
@extend .tran;
@@ -44,14 +77,17 @@ div.book-wrapper {
li {
background: none;
border-bottom: 0;
- padding-left: lh();
+ padding-left: ($baseline/2);
a {
- padding: 0;
@include clearfix;
+ padding: 0;
+ color: $link-color;
+ cursor: pointer;
&:hover {
background-color: transparent;
+ color: $link-hover;
.page-number {
opacity: 1.0;
@@ -84,7 +120,7 @@ div.book-wrapper {
> li {
padding: 5px 6px;
- margin: 0 16px 5px 25px;
+ margin: ($baseline/4) ($baseline/2);
}
}
}
@@ -158,18 +194,20 @@ div.book-wrapper {
}
section.page {
- border: 1px solid $border-color;
+ border-left: 1px solid $border-color;
background-color: #fff;
position: relative;
text-align: center;
- padding: lh();
- margin-right:10%;
border-radius: 0 3px 3px 0;
img {
max-width: 100%;
}
+ #viewer {
+ padding: $baseline;
+ }
+
div {
text-align: left;
line-height: 1.6em;
@@ -214,3 +252,6 @@ div.book-wrapper {
}
}
}
+
+
+
diff --git a/lms/templates/static_pdfbook.html b/lms/templates/static_pdfbook.html
index 565a59977a..a8608b2877 100644
--- a/lms/templates/static_pdfbook.html
+++ b/lms/templates/static_pdfbook.html
@@ -11,7 +11,7 @@
<%static:js group='courseware'/>
-
+
%block>
<%block name="js_extra">
@@ -35,10 +35,10 @@
%if page is not None:
options.pageNum = ${page};
%endif
-
+
$('#outerContainer').PDFViewer(options);
});
-
+
%block>
<%include file="/courseware/course_navigation.html" args="active_page='pdftextbook/{0}'.format(book_index)" />
@@ -91,40 +91,43 @@
- An image mapped input problem presents an image for the student. Input is
- given by the location of mouse clicks on the image. Correctness of input can be evaluated based on expected dimensions of a rectangle.
-
-
-
Which object in this image is required by the fire code?
-
-
-
-
-
-
Explanation
-
The fire code requires that all exits be clearly marked, so the red exit sign is the correct answer.
-
-
+
+ An image mapped input problem presents an image for the student.
+ Input is given by the location of mouse clicks on the image.
+ Correctness of input can be evaluated based on expected dimensions of a rectangle.
+
+
Which animal shown below is a kitten?
+
+
+
+
+
+
Explanation
+
The animal on the right is a kitten. The animal on the left is a puppy, not a kitten.
+
+
-
children: []
From 7c5943a87c0a28aced02e3210b0078e5958c79c4 Mon Sep 17 00:00:00 2001
From: Greg Price
Date: Fri, 21 Jun 2013 13:05:48 -0400
Subject: [PATCH 15/73] Tweak behavior of submit buttons on register and login
pages
The buttons are not re-enabled if the registration/login submission succeeds
(and, therefore, the user is about to be directed to another page). Also, any
error message that is present does not disappear immediately before the page
redirects.
---
lms/templates/login.html | 4 ++--
lms/templates/register.html | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/lms/templates/login.html b/lms/templates/login.html
index 671ce5a3e7..5ab63a86c2 100644
--- a/lms/templates/login.html
+++ b/lms/templates/login.html
@@ -38,13 +38,12 @@
toggleSubmitButton(false);
});
- $('#login-form').on('ajax:complete', function() {
+ $('#login-form').on('ajax:error', function() {
toggleSubmitButton(true);
});
$('#login-form').on('ajax:success', function(event, json, xhr) {
if(json.success) {
- $('.message.submission-error').removeClass('is-shown');
var u=decodeURI(window.location.search);
next=u.split("next=")[1];
if (next) {
@@ -53,6 +52,7 @@
location.href="${reverse('dashboard')}";
}
} else {
+ toggleSubmitButton(true);
$('.message.submission-error').addClass('is-shown').focus();
$('.message.submission-error .message-copy').html(json.value);
}
diff --git a/lms/templates/register.html b/lms/templates/register.html
index 1c2490c0a0..57a9ffa843 100644
--- a/lms/templates/register.html
+++ b/lms/templates/register.html
@@ -45,15 +45,15 @@
toggleSubmitButton(false);
});
- $('#register-form').on('ajax:complete', function() {
+ $('#register-form').on('ajax:error', function() {
toggleSubmitButton(true);
});
$('#register-form').on('ajax:success', function(event, json, xhr) {
if(json.success) {
- $('.message.submission-error').removeClass('is-shown');
location.href="${reverse('dashboard')}";
} else {
+ toggleSubmitButton(true);
$('.status.message.submission-error').addClass('is-shown').focus();
$('.status.message.submission-error .message-copy').html(json.value).stop().css("display", "block");
$(".field-error").removeClass('field-error');
From c770458e6a7171e265d05f617c3abceeedc8c518 Mon Sep 17 00:00:00 2001
From: Renzo Lucioni
Date: Mon, 15 Jul 2013 13:31:56 -0400
Subject: [PATCH 16/73] Reinstate problem_check event as Problem Checked event
---
common/lib/xmodule/xmodule/js/src/capa/display.coffee | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee
index e29276936b..fbbba36134 100644
--- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee
+++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee
@@ -162,8 +162,9 @@ class @Problem
# maybe preferable to consolidate all dispatches to use FormData
###
check_fd: =>
- # Calling check from check_fd will result in firing the 'problem_check' event twice, since it is also called in the check function.
- #Logger.log 'problem_check', @answers
+ # Calling check from check_fd will result in firing the 'problem_check' event twice,
+ # since it is also called in the check function.
+ # Logger.log 'problem_check', @answers
# If there are no file inputs in the problem, we can fall back on @check
if $('input:file').length == 0
@@ -239,6 +240,11 @@ class @Problem
check: =>
@check_waitfor()
Logger.log 'problem_check', @answers
+
+ # Segment.io
+ analytics.track "Problem Checked",
+ answers: @answers
+
$.postWithPrefix "#{@url}/problem_check", @answers, (response) =>
switch response.success
when 'incorrect', 'correct'
From 27b7e35bd36500ec36327ca91ee194645f6b94f5 Mon Sep 17 00:00:00 2001
From: Frances Botsford
Date: Mon, 15 Jul 2013 13:54:04 -0400
Subject: [PATCH 17/73] i18n the PDF prev/next button text
---
lms/templates/static_pdfbook.html | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/lms/templates/static_pdfbook.html b/lms/templates/static_pdfbook.html
index a8608b2877..754e519113 100644
--- a/lms/templates/static_pdfbook.html
+++ b/lms/templates/static_pdfbook.html
@@ -1,3 +1,4 @@
+<%! from django.utils.translation import ugettext as _ %>
<%inherit file="main.html" />
<%namespace name='static' file='static_content.html'/>
<%block name="title">
@@ -116,10 +117,10 @@
From 92ae50d415ad19c6742d1797c64dc7618a53b5fd Mon Sep 17 00:00:00 2001
From: Renzo Lucioni
Date: Mon, 15 Jul 2013 15:18:01 -0400
Subject: [PATCH 18/73] Remove unused code and include problem ID of data we
send
---
common/lib/xmodule/xmodule/js/src/capa/display.coffee | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee
index fbbba36134..b7dbf6864d 100644
--- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee
+++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee
@@ -22,7 +22,6 @@ class @Problem
@$('section.action input:button').click @refreshAnswers
@$('section.action input.check').click @check_fd
- #@$('section.action input.check').click @check
@$('section.action input.reset').click @reset
@$('section.action button.show').click @show
@$('section.action input.save').click @save
@@ -162,10 +161,6 @@ class @Problem
# maybe preferable to consolidate all dispatches to use FormData
###
check_fd: =>
- # Calling check from check_fd will result in firing the 'problem_check' event twice,
- # since it is also called in the check function.
- # Logger.log 'problem_check', @answers
-
# If there are no file inputs in the problem, we can fall back on @check
if $('input:file').length == 0
@check()
@@ -243,6 +238,7 @@ class @Problem
# Segment.io
analytics.track "Problem Checked",
+ problem_id: @id
answers: @answers
$.postWithPrefix "#{@url}/problem_check", @answers, (response) =>
From 22302e3a46d09672245a12d2115aa8bd9e011482 Mon Sep 17 00:00:00 2001
From: Calen Pennington
Date: Mon, 15 Jul 2013 16:47:32 -0400
Subject: [PATCH 19/73] Make jasmine tests quieter
---
.gitignore | 1 +
rakelib/helpers.rb | 15 +++++++++++----
rakelib/jasmine.rake | 13 +++++++++----
3 files changed, 21 insertions(+), 8 deletions(-)
diff --git a/.gitignore b/.gitignore
index b1a36e5f2e..4fd90cfe03 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,3 +45,4 @@ node_modules
autodeploy.properties
.ws_migrations_complete
.vagrant/
+logs
diff --git a/rakelib/helpers.rb b/rakelib/helpers.rb
index 3373214a19..925b7e1b28 100644
--- a/rakelib/helpers.rb
+++ b/rakelib/helpers.rb
@@ -52,8 +52,14 @@ end
# Runs Process.spawn, and kills the process at the end of the rake process
# Expects the same arguments as Process.spawn
-def background_process(*command)
- pid = Process.spawn({}, *command, {:pgroup => true})
+def background_process(command, logfile=nil)
+ spawn_opts = {:pgroup => true}
+ if !logfile.nil?
+ puts "Running '#{command.join(' ')}', redirecting output to #{logfile}".red
+ spawn_opts[[:err, :out]] = [logfile, 'a']
+ end
+ pid = Process.spawn({}, *command, spawn_opts)
+ command = [*command]
at_exit do
puts "Ending process and children"
@@ -88,9 +94,10 @@ end
# Runs a command as a background process, as long as no other processes
# tagged with the same tag are running
-def singleton_process(*command)
+def singleton_process(command, logfile=nil)
+ command = [*command]
if Sys::ProcTable.ps.select {|proc| proc.cmdline.include?(command.join(' '))}.empty?
- background_process(*command)
+ background_process(command, logfile)
else
puts "Process '#{command.join(' ')} already running, skipping".blue
end
diff --git a/rakelib/jasmine.rake b/rakelib/jasmine.rake
index ff72161937..5a0c4acedc 100644
--- a/rakelib/jasmine.rake
+++ b/rakelib/jasmine.rake
@@ -8,6 +8,11 @@ PREFERRED_METHOD = PHANTOMJS_PATH.nil? ? 'browser' : 'phantomjs'
if PHANTOMJS_PATH.nil?
puts("phantomjs not found on path. Set $PHANTOMJS_PATH. Using browser for jasmine tests".blue)
end
+LOGDIR = 'logs/jasmine'
+
+CLOBBER.include(LOGDIR)
+
+directory LOGDIR
def django_for_jasmine(system, django_reload)
if !django_reload
@@ -17,7 +22,7 @@ def django_for_jasmine(system, django_reload)
port = 10000 + rand(40000)
jasmine_url = "http://localhost:#{port}/_jasmine/"
- background_process(*django_admin(system, 'jasmine', 'runserver', '-v', '0', port.to_s, reload_arg).split(' '))
+ background_process(django_admin(system, 'jasmine', 'runserver', '-v', '0', port.to_s, reload_arg).split(' '), "#{LOGDIR}/django.log")
up = false
start_time = Time.now
@@ -80,7 +85,7 @@ end
namespace :jasmine do
namespace system do
desc "Open jasmine tests for #{system} in your default browser"
- task :browser => [:clean_reports_dir] do
+ task :browser => [:clean_reports_dir, LOGDIR] do
Rake::Task[:assets].invoke(system, 'jasmine')
django_for_jasmine(system, true) do |jasmine_url|
jasmine_browser(jasmine_url)
@@ -88,7 +93,7 @@ end
end
desc "Open jasmine tests for #{system} in your default browser, and dynamically recompile coffeescript"
- task :'browser:watch' => [:clean_reports_dir, :'assets:coffee:_watch'] do
+ task :'browser:watch' => [:clean_reports_dir, :'assets:coffee:_watch', LOGDIR] do
django_for_jasmine(system, true) do |jasmine_url|
jasmine_browser(jasmine_url, jitter=0, wait=0)
end
@@ -97,7 +102,7 @@ end
end
desc "Use phantomjs to run jasmine tests for #{system} from the console"
- task :phantomjs => [:clean_reports_dir] do
+ task :phantomjs => [:clean_reports_dir, LOGDIR] do
Rake::Task[:assets].invoke(system, 'jasmine')
phantomjs = ENV['PHANTOMJS_PATH'] || 'phantomjs'
django_for_jasmine(system, false) do |jasmine_url|
From ef8618f7adb0f8e8764703d3b3b4fda799f93af3 Mon Sep 17 00:00:00 2001
From: Don Mitchell
Date: Mon, 24 Jun 2013 12:52:50 -0400
Subject: [PATCH 20/73] Make DraftModuleStore mongo only
DraftModuleStore was originally designed as a mixin, but never used that
way, and with the upcoming changes to use the versioned module store,
never will be. This changes removes a circular dependency between
mongo.py and draft.py.
---
common/lib/xmodule/xmodule/modulestore/tests/django_utils.py | 2 +-
lms/djangoapps/courseware/tests/tests.py | 5 ++---
2 files changed, 3 insertions(+), 4 deletions(-)
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py
index 6c5c1f66ca..c32d0bca4c 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py
@@ -48,7 +48,7 @@ def draft_mongo_store_config(data_dir):
return {
'default': {
- 'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
+ 'ENGINE': 'xmodule.modulestore.mongo.draft.DraftModuleStore',
'OPTIONS': modulestore_options
},
'direct': {
diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py
index 17cc848ad3..bbdf97ca9b 100644
--- a/lms/djangoapps/courseware/tests/tests.py
+++ b/lms/djangoapps/courseware/tests/tests.py
@@ -8,7 +8,6 @@ from django.core.urlresolvers import reverse
from django.test.utils import override_settings
import xmodule.modulestore.django
-
from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore.django import modulestore
from xmodule.modulestore import Location
@@ -134,7 +133,7 @@ class TestCoursesLoadTestCase_XmlModulestore(PageLoaderTestCase):
def setUp(self):
super(TestCoursesLoadTestCase_XmlModulestore, self).setUp()
self.setup_user()
- xmodule.modulestore.django._MODULESTORES = {}
+ xmodule.modulestore.django._MODULESTORES.clear()
def test_toy_course_loads(self):
module_class = 'xmodule.hidden_module.HiddenDescriptor'
@@ -155,7 +154,7 @@ class TestCoursesLoadTestCase_MongoModulestore(PageLoaderTestCase):
def setUp(self):
super(TestCoursesLoadTestCase_MongoModulestore, self).setUp()
self.setup_user()
- xmodule.modulestore.django._MODULESTORES = {}
+ xmodule.modulestore.django._MODULESTORES.clear()
modulestore().collection.drop()
def test_toy_course_loads(self):
From 35f66f84695baa88e7acd26c359523ecd278e608 Mon Sep 17 00:00:00 2001
From: David Ormsbee
Date: Tue, 16 Jul 2013 09:20:35 -0400
Subject: [PATCH 21/73] Update MathJax to the latest stable version (2.2), to
fix IE10 bug LMS-194
---
common/templates/mathjax_include.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/common/templates/mathjax_include.html b/common/templates/mathjax_include.html
index 803f2145a4..0ddbd68eee 100644
--- a/common/templates/mathjax_include.html
+++ b/common/templates/mathjax_include.html
@@ -33,4 +33,4 @@
-
+
From 3a095acced7fcc5d040681d4fbd923c9d25ec52c Mon Sep 17 00:00:00 2001
From: James Tauber
Date: Tue, 16 Jul 2013 13:38:34 -0400
Subject: [PATCH 22/73] changed transifex project to edx-platform
---
.tx/config | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/.tx/config b/.tx/config
index 540c4732af..9288418924 100644
--- a/.tx/config
+++ b/.tx/config
@@ -1,25 +1,25 @@
[main]
host = https://www.transifex.com
-[edx-studio.django-partial]
+[edx-platform.django-partial]
file_filter = conf/locale//LC_MESSAGES/django-partial.po
source_file = conf/locale/en/LC_MESSAGES/django-partial.po
source_lang = en
type = PO
-[edx-studio.djangojs]
+[edx-platform.djangojs]
file_filter = conf/locale//LC_MESSAGES/djangojs.po
source_file = conf/locale/en/LC_MESSAGES/djangojs.po
source_lang = en
type = PO
-[edx-studio.mako]
+[edx-platform.mako]
file_filter = conf/locale//LC_MESSAGES/mako.po
source_file = conf/locale/en/LC_MESSAGES/mako.po
source_lang = en
type = PO
-[edx-studio.messages]
+[edx-platform.messages]
file_filter = conf/locale//LC_MESSAGES/messages.po
source_file = conf/locale/en/LC_MESSAGES/messages.po
source_lang = en
From 8c904f31a90af89dea026489d8e316e93cb85c20 Mon Sep 17 00:00:00 2001
From: Don Mitchell
Date: Tue, 16 Jul 2013 14:22:42 -0400
Subject: [PATCH 23/73] Move defaults from yaml templates to field definitions.
This standardizes the XModule field default values to be the same as the
values that are presented by studio when a component is added to a
course.
---
.../features/discussion-editor.py | 6 +-
.../contentstore/features/html-editor.py | 2 +-
.../contentstore/features/problem-editor.py | 14 +-
.../contentstore/features/video-editor.py | 2 +-
.../contentstore/tests/test_contentstore.py | 24 +-
.../tests/test_course_settings.py | 2 -
common/djangoapps/xmodule_modifiers.py | 2 +-
.../lib/xmodule/xmodule/annotatable_module.py | 27 +-
common/lib/xmodule/xmodule/capa_module.py | 27 +-
.../xmodule/combined_open_ended_module.py | 54 +++-
common/lib/xmodule/xmodule/course_module.py | 254 ++++++++++++++----
.../lib/xmodule/xmodule/discussion_module.py | 22 +-
common/lib/xmodule/xmodule/html_module.py | 81 +++++-
.../xmodule/xmodule/peer_grading_module.py | 9 +
common/lib/xmodule/xmodule/raw_module.py | 2 +-
.../xmodule/templates/about/empty.yaml | 6 +-
.../xmodule/templates/about/overview.yaml | 79 +++---
.../templates/annotatable/default.yaml | 21 +-
.../templates/combinedopenended/default.yaml | 38 +--
.../xmodule/templates/course/empty.yaml | 125 +--------
.../xmodule/templates/courseinfo/empty.yaml | 6 +-
.../xmodule/templates/default/empty.yaml | 6 +-
.../xmodule/templates/discussion/default.yaml | 10 +-
.../xmodule/templates/html/announcement.yaml | 3 +-
.../xmodule/xmodule/templates/html/empty.yaml | 8 +-
.../xmodule/templates/html/latex_html.yaml | 14 +-
.../templates/peer_grading/default.yaml | 10 +-
.../templates/problem/blank_common.yaml | 5 +
.../templates/problem/circuitschematic.yaml | 111 ++++----
.../xmodule/templates/problem/empty.yaml | 12 +-
.../xmodule/templates/statictab/empty.yaml | 6 +-
.../xmodule/templates/video/default.yaml | 6 +-
.../xmodule/templates/videoalpha/default.yaml | 12 +-
.../xmodule/templates/word_cloud/default.yaml | 6 +-
common/lib/xmodule/xmodule/video_module.py | 15 ++
.../lib/xmodule/xmodule/videoalpha_module.py | 16 +-
.../lib/xmodule/xmodule/word_cloud_module.py | 10 +-
common/lib/xmodule/xmodule/x_module.py | 126 ++++++---
lms/xmodule_namespace.py | 8 +-
39 files changed, 699 insertions(+), 488 deletions(-)
create mode 100644 common/lib/xmodule/xmodule/templates/problem/blank_common.yaml
diff --git a/cms/djangoapps/contentstore/features/discussion-editor.py b/cms/djangoapps/contentstore/features/discussion-editor.py
index ae3da3c458..a4a4b71668 100644
--- a/cms/djangoapps/contentstore/features/discussion-editor.py
+++ b/cms/djangoapps/contentstore/features/discussion-editor.py
@@ -17,9 +17,9 @@ def i_created_discussion_tag(step):
def i_see_only_the_settings_and_values(step):
world.verify_all_setting_entries(
[
- ['Category', "Week 1", True],
- ['Display Name', "Discussion Tag", True],
- ['Subcategory', "Topic-Level Student-Visible Label", True]
+ ['Category', "Week 1", False],
+ ['Display Name', "Discussion Tag", False],
+ ['Subcategory', "Topic-Level Student-Visible Label", False]
])
diff --git a/cms/djangoapps/contentstore/features/html-editor.py b/cms/djangoapps/contentstore/features/html-editor.py
index 054c0ea642..53462ba094 100644
--- a/cms/djangoapps/contentstore/features/html-editor.py
+++ b/cms/djangoapps/contentstore/features/html-editor.py
@@ -14,4 +14,4 @@ def i_created_blank_html_page(step):
@step('I see only the HTML display name setting$')
def i_see_only_the_html_display_name(step):
- world.verify_all_setting_entries([['Display Name', "Blank HTML Page", True]])
+ world.verify_all_setting_entries([['Display Name', "Blank HTML Page", False]])
diff --git a/cms/djangoapps/contentstore/features/problem-editor.py b/cms/djangoapps/contentstore/features/problem-editor.py
index 5d12b23d90..15f5da95e9 100644
--- a/cms/djangoapps/contentstore/features/problem-editor.py
+++ b/cms/djangoapps/contentstore/features/problem-editor.py
@@ -18,8 +18,9 @@ def i_created_blank_common_problem(step):
world.create_component_instance(
step,
'.large-problem-icon',
- 'i4x://edx/templates/problem/Blank_Common_Problem',
- '.xmodule_CapaModule'
+ 'problem',
+ '.xmodule_CapaModule',
+ 'blank_common.yaml'
)
@@ -32,11 +33,12 @@ def i_edit_and_select_settings(step):
def i_see_five_settings_with_values(step):
world.verify_all_setting_entries(
[
- [DISPLAY_NAME, "Blank Common Problem", True],
+ [DISPLAY_NAME, "New problem", True],
[MAXIMUM_ATTEMPTS, "", False],
[PROBLEM_WEIGHT, "", False],
- [RANDOMIZATION, "Never", True],
- [SHOW_ANSWER, "Finished", True]
+ # Not sure why these are True other than via inspection
+ [RANDOMIZATION, "Always", True],
+ [SHOW_ANSWER, "Closed", True]
])
@@ -203,7 +205,7 @@ def verify_modified_display_name_with_special_chars():
def verify_unset_display_name():
- world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, '', False)
+ world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, 'Blank Advanced Problem', False)
def set_weight(weight):
diff --git a/cms/djangoapps/contentstore/features/video-editor.py b/cms/djangoapps/contentstore/features/video-editor.py
index a6865fdd6d..e0f76b30ad 100644
--- a/cms/djangoapps/contentstore/features/video-editor.py
+++ b/cms/djangoapps/contentstore/features/video-editor.py
@@ -7,7 +7,7 @@ from lettuce import world, step
@step('I see the correct settings and default values$')
def i_see_the_correct_settings_and_values(step):
world.verify_all_setting_entries([['Default Speed', 'OEoXaMPEzfM', False],
- ['Display Name', 'default', True],
+ ['Display Name', 'Video Title', False],
['Download Track', '', False],
['Download Video', '', False],
['Show Captions', 'True', False],
diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py
index be122fa1a4..6099b60eb1 100644
--- a/cms/djangoapps/contentstore/tests/test_contentstore.py
+++ b/cms/djangoapps/contentstore/tests/test_contentstore.py
@@ -135,7 +135,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
self.check_components_on_page(ADVANCED_COMPONENT_TYPES, ['Video Alpha',
'Word cloud',
'Annotation',
- 'Open Ended Response',
+ 'Open Ended Grading',
'Peer Grading Interface'])
def test_advanced_components_require_two_clicks(self):
@@ -1271,6 +1271,28 @@ class ContentStoreTest(ModuleStoreTestCase):
self.assertEqual(timedelta(1), new_module.lms.graceperiod)
+ def test_default_metadata_inheritance(self):
+ course = CourseFactory.create()
+ vertical = ItemFactory.create(parent_location=course.location)
+ course.children.append(vertical)
+ # in memory
+ self.assertIsNotNone(course.start)
+ self.assertEqual(course.start, vertical.lms.start)
+ self.assertEqual(course.textbooks, [])
+ self.assertIn('GRADER', course.grading_policy)
+ self.assertIn('GRADE_CUTOFFS', course.grading_policy)
+ self.assertGreaterEqual(len(course.checklists), 4)
+
+ # by fetching
+ module_store = modulestore('direct')
+ fetched_course = module_store.get_item(course.location)
+ fetched_item = module_store.get_item(vertical.location)
+ self.assertIsNotNone(fetched_course.start)
+ self.assertEqual(course.start, fetched_course.start)
+ self.assertEqual(fetched_course.start, fetched_item.lms.start)
+ self.assertEqual(course.textbooks, fetched_course.textbooks)
+ # is this test too strict? i.e., it requires the dicts to be ==
+ self.assertEqual(course.checklists, fetched_course.checklists)
class TemplateTestCase(ModuleStoreTestCase):
diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py
index 21d7d69d41..6c23e68240 100644
--- a/cms/djangoapps/contentstore/tests/test_course_settings.py
+++ b/cms/djangoapps/contentstore/tests/test_course_settings.py
@@ -36,7 +36,6 @@ class CourseDetailsTestCase(CourseTestCase):
self.assertIsNone(details.enrollment_start, "enrollment_start date somehow initialized " + str(details.enrollment_start))
self.assertIsNone(details.enrollment_end, "enrollment_end date somehow initialized " + str(details.enrollment_end))
self.assertIsNone(details.syllabus, "syllabus somehow initialized" + str(details.syllabus))
- self.assertEqual(details.overview, "", "overview somehow initialized" + details.overview)
self.assertIsNone(details.intro_video, "intro_video somehow initialized" + str(details.intro_video))
self.assertIsNone(details.effort, "effort somehow initialized" + str(details.effort))
@@ -49,7 +48,6 @@ class CourseDetailsTestCase(CourseTestCase):
self.assertIsNone(jsondetails['enrollment_start'], "enrollment_start date somehow initialized ")
self.assertIsNone(jsondetails['enrollment_end'], "enrollment_end date somehow initialized ")
self.assertIsNone(jsondetails['syllabus'], "syllabus somehow initialized")
- self.assertEqual(jsondetails['overview'], "", "overview somehow initialized")
self.assertIsNone(jsondetails['intro_video'], "intro_video somehow initialized")
self.assertIsNone(jsondetails['effort'], "effort somehow initialized")
diff --git a/common/djangoapps/xmodule_modifiers.py b/common/djangoapps/xmodule_modifiers.py
index 7a74e75591..3914892bbf 100644
--- a/common/djangoapps/xmodule_modifiers.py
+++ b/common/djangoapps/xmodule_modifiers.py
@@ -120,7 +120,7 @@ def add_histogram(get_html, module, user):
# doesn't like symlinks)
filepath = filename
data_dir = osfs.root_path.rsplit('/')[-1]
- giturl = getattr(module.lms, 'giturl', '') or 'https://github.com/MITx'
+ giturl = module.lms.giturl or 'https://github.com/MITx'
edit_link = "%s/%s/tree/master/%s" % (giturl, data_dir, filepath)
else:
edit_link = False
diff --git a/common/lib/xmodule/xmodule/annotatable_module.py b/common/lib/xmodule/xmodule/annotatable_module.py
index e8674360c3..8b1bbc71d3 100644
--- a/common/lib/xmodule/xmodule/annotatable_module.py
+++ b/common/lib/xmodule/xmodule/annotatable_module.py
@@ -6,12 +6,37 @@ from pkg_resources import resource_string
from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
from xblock.core import Scope, String
+import textwrap
log = logging.getLogger(__name__)
class AnnotatableFields(object):
- data = String(help="XML data for the annotation", scope=Scope.content)
+ data = String(help="XML data for the annotation", scope=Scope.content,
+ default=textwrap.dedent(
+ """\
+
+
+
Enter your (optional) instructions for the exercise in HTML format.
+
Annotations are specified by an <annotation> tag which may may have the following attributes:
+
+
title (optional). Title of the annotation. Defaults to Commentary if omitted.
+
body (required). Text of the annotation.
+
problem (optional). Numeric index of the problem associated with this annotation. This is a zero-based index, so the first problem on the page would have problem="0".
+
highlight (optional). Possible values: yellow, red, orange, green, blue, or purple. Defaults to yellow if this attribute is omitted.
+
+
+
Add your HTML with annotation spans here.
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut sodales laoreet est, egestas gravida felis egestas nec. Aenean at volutpat erat. Cras commodo viverra nibh in aliquam.
+
Nulla facilisi. Pellentesque id vestibulum libero. Suspendisse potenti. Morbi scelerisque nisi vitae felis dictum mattis. Nam sit amet magna elit. Nullam volutpat cursus est, sit amet sagittis odio vulputate et. Curabitur euismod, orci in vulputate imperdiet, augue lorem tempor purus, id aliquet augue turpis a est. Aenean a sagittis libero. Praesent fringilla pretium magna, non condimentum risus elementum nec. Pellentesque faucibus elementum pharetra. Pellentesque vitae metus eros.
+
+ """))
+ display_name = String(
+ display_name="Display Name",
+ help="Display name for this module",
+ scope=Scope.settings,
+ default='Annotation',
+ )
class AnnotatableModule(AnnotatableFields, XModule):
diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py
index eeb8f19439..752f0d3362 100644
--- a/common/lib/xmodule/xmodule/capa_module.py
+++ b/common/lib/xmodule/xmodule/capa_module.py
@@ -77,6 +77,14 @@ class CapaFields(object):
"""
Define the possible fields for a Capa problem
"""
+ display_name = String(
+ display_name="Display Name",
+ help="This name appears in the horizontal navigation at the top of the page.",
+ scope=Scope.settings,
+ # it'd be nice to have a useful default but it screws up other things; so,
+ # use display_name_with_default for those
+ default="Blank Advanced Problem"
+ )
attempts = Integer(help="Number of attempts taken by the student on this problem",
default=0, scope=Scope.user_state)
max_attempts = Integer(
@@ -94,7 +102,8 @@ class CapaFields(object):
display_name="Show Answer",
help=("Defines when to show the answer to the problem. "
"A default value can be set in Advanced Settings."),
- scope=Scope.settings, default="closed",
+ scope=Scope.settings,
+ default="closed",
values=[
{"display_name": "Always", "value": "always"},
{"display_name": "Answered", "value": "answered"},
@@ -106,21 +115,24 @@ class CapaFields(object):
)
force_save_button = Boolean(
help="Whether to force the save button to appear on the page",
- scope=Scope.settings, default=False
+ scope=Scope.settings,
+ default=False
)
rerandomize = Randomization(
display_name="Randomization",
help="Defines how often inputs are randomized when a student loads the problem. "
- "This setting only applies to problems that can have randomly generated numeric values. "
- "A default value can be set in Advanced Settings.",
- default="always", scope=Scope.settings, values=[
+ "This setting only applies to problems that can have randomly generated numeric values. "
+ "A default value can be set in Advanced Settings.",
+ default="always",
+ scope=Scope.settings,
+ values=[
{"display_name": "Always", "value": "always"},
{"display_name": "On Reset", "value": "onreset"},
{"display_name": "Never", "value": "never"},
{"display_name": "Per Student", "value": "per_student"}
]
)
- data = String(help="XML data for the problem", scope=Scope.content)
+ data = String(help="XML data for the problem", scope=Scope.content, default="")
correct_map = Dict(help="Dictionary with the correctness of current student answers",
scope=Scope.user_state, default={})
input_state = Dict(help="Dictionary for maintaining the state of inputtypes", scope=Scope.user_state)
@@ -134,13 +146,12 @@ class CapaFields(object):
values={"min": 0, "step": .1},
scope=Scope.settings
)
- markdown = String(help="Markdown source of this module", scope=Scope.settings)
+ markdown = String(help="Markdown source of this module", default="", scope=Scope.settings)
source_code = String(
help="Source code for LaTeX and Word problems. This feature is not well-supported.",
scope=Scope.settings
)
-
class CapaModule(CapaFields, XModule):
"""
An XModule implementing LonCapa format problems, implemented by way of
diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py
index 52d98f032e..13f00bb77e 100644
--- a/common/lib/xmodule/xmodule/combined_open_ended_module.py
+++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py
@@ -9,6 +9,7 @@ from xblock.core import Integer, Scope, String, List, Float, Boolean
from xmodule.open_ended_grading_classes.combined_open_ended_modulev1 import CombinedOpenEndedV1Module, CombinedOpenEndedV1Descriptor
from collections import namedtuple
from .fields import Date
+import textwrap
log = logging.getLogger("mitx.courseware")
@@ -27,6 +28,38 @@ VERSION_TUPLES = {
}
DEFAULT_VERSION = 1
+DEFAULT_DATA = textwrap.dedent("""\
+
+
+
+
+ Category 1
+
+
+
+
+
+
+
Why is the sky blue?
+
+
+
+
+
+
+
+ Enter essay here.
+ This is the answer.
+ {"grader_settings" : "peer_grading.conf", "problem_id" : "700x/Demo"}
+
+
+
+
+""")
class VersionInteger(Integer):
@@ -85,13 +118,30 @@ class CombinedOpenEndedFields(object):
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)
+ data = String(help="XML data for the problem", scope=Scope.content,
+ default=DEFAULT_DATA)
weight = Float(
display_name="Problem Weight",
help="Defines the number of points each problem is worth. If the value is not set, each problem is worth one point.",
scope=Scope.settings, values={"min" : 0 , "step": ".1"}
)
- markdown = String(help="Markdown source of this module", scope=Scope.settings)
+ markdown = String(
+ help="Markdown source of this module",
+ default=textwrap.dedent("""\
+ [rubric]
+ + Category 1
+ - The response does not incorporate what is needed for a one response.
+ - The response is correct for category 1.
+ [rubric]
+ [prompt]
+
Why is the sky blue?
+ [prompt]
+ [tasks]
+ (Self), ({1-2}AI)
+ [tasks]
+ """),
+ scope=Scope.settings
+ )
class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py
index d75033c8a0..ceadee1c15 100644
--- a/common/lib/xmodule/xmodule/course_module.py
+++ b/common/lib/xmodule/xmodule/course_module.py
@@ -145,16 +145,56 @@ class TextbookList(List):
class CourseFields(object):
- textbooks = TextbookList(help="List of pairs of (title, url) for textbooks used in this course", scope=Scope.content)
+ textbooks = TextbookList(help="List of pairs of (title, url) for textbooks used in this course",
+ default=[], scope=Scope.content)
wiki_slug = String(help="Slug that points to the wiki for this course", scope=Scope.content)
enrollment_start = Date(help="Date that enrollment for this class is opened", scope=Scope.settings)
enrollment_end = Date(help="Date that enrollment for this class is closed", scope=Scope.settings)
- start = Date(help="Start time when this module is visible", scope=Scope.settings)
+ start = Date(help="Start time when this module is visible",
+ # using now(UTC()) resulted in fractional seconds which screwed up comparisons and anyway w/b the
+ # time of first invocation of this stmt on the server
+ default=datetime.fromtimestamp(0, UTC()),
+ scope=Scope.settings)
end = Date(help="Date that this class ends", scope=Scope.settings)
advertised_start = String(help="Date that this course is advertised to start", scope=Scope.settings)
- grading_policy = Dict(help="Grading policy definition for this class", scope=Scope.content)
+ grading_policy = Dict(help="Grading policy definition for this class",
+ default={"GRADER": [
+ {
+ "type": "Homework",
+ "min_count": 12,
+ "drop_count": 2,
+ "short_label": "HW",
+ "weight": 0.15
+ },
+ {
+ "type": "Lab",
+ "min_count": 12,
+ "drop_count": 2,
+ "weight": 0.15
+ },
+ {
+ "type": "Midterm Exam",
+ "short_label": "Midterm",
+ "min_count": 1,
+ "drop_count": 0,
+ "weight": 0.3
+ },
+ {
+ "type": "Final Exam",
+ "short_label": "Final",
+ "min_count": 1,
+ "drop_count": 0,
+ "weight": 0.4
+ }
+ ],
+ "GRADE_CUTOFFS": {
+ "Pass": 0.5
+ }},
+ scope=Scope.content)
show_calculator = Boolean(help="Whether to show the calculator in this course", default=False, scope=Scope.settings)
- display_name = String(help="Display name for this module", scope=Scope.settings)
+ display_name = String(
+ help="Display name for this module", default="Empty",
+ display_name="Display Name", scope=Scope.settings)
tabs = List(help="List of tabs to enable in this course", scope=Scope.settings)
end_of_course_survey_url = String(help="Url for the end-of-course survey", scope=Scope.settings)
discussion_blackouts = List(help="List of pairs of start/end dates for discussion blackouts", scope=Scope.settings)
@@ -175,7 +215,125 @@ class CourseFields(object):
allow_anonymous_to_peers = Boolean(scope=Scope.settings, default=False)
advanced_modules = List(help="Beta modules used in your course", scope=Scope.settings)
has_children = True
- checklists = List(scope=Scope.settings)
+ checklists = List(scope=Scope.settings,
+ default=[
+ {"short_description" : "Getting Started With Studio",
+ "items" : [{"short_description": "Add Course Team Members",
+ "long_description": "Grant your collaborators permission to edit your course so you can work together.",
+ "is_checked": False,
+ "action_url": "ManageUsers",
+ "action_text": "Edit Course Team",
+ "action_external": False},
+ {"short_description": "Set Important Dates for Your Course",
+ "long_description": "Establish your course's student enrollment and launch dates on the Schedule and Details page.",
+ "is_checked": False,
+ "action_url": "SettingsDetails",
+ "action_text": "Edit Course Details & Schedule",
+ "action_external": False},
+ {"short_description": "Draft Your Course's Grading Policy",
+ "long_description": "Set up your assignment types and grading policy even if you haven't created all your assignments.",
+ "is_checked": False,
+ "action_url": "SettingsGrading",
+ "action_text": "Edit Grading Settings",
+ "action_external": False},
+ {"short_description": "Explore the Other Studio Checklists",
+ "long_description": "Discover other available course authoring tools, and find help when you need it.",
+ "is_checked": False,
+ "action_url": "",
+ "action_text": "",
+ "action_external": False}]
+ },
+ {"short_description" : "Draft a Rough Course Outline",
+ "items" : [{"short_description": "Create Your First Section and Subsection",
+ "long_description": "Use your course outline to build your first Section and Subsection.",
+ "is_checked": False,
+ "action_url": "CourseOutline",
+ "action_text": "Edit Course Outline",
+ "action_external": False},
+ {"short_description": "Set Section Release Dates",
+ "long_description": "Specify the release dates for each Section in your course. Sections become visible to students on their release dates.",
+ "is_checked": False,
+ "action_url": "CourseOutline",
+ "action_text": "Edit Course Outline",
+ "action_external": False},
+ {"short_description": "Designate a Subsection as Graded",
+ "long_description": "Set a Subsection to be graded as a specific assignment type. Assignments within graded Subsections count toward a student's final grade.",
+ "is_checked": False,
+ "action_url": "CourseOutline",
+ "action_text": "Edit Course Outline",
+ "action_external": False},
+ {"short_description": "Reordering Course Content",
+ "long_description": "Use drag and drop to reorder the content in your course.",
+ "is_checked": False,
+ "action_url": "CourseOutline",
+ "action_text": "Edit Course Outline",
+ "action_external": False},
+ {"short_description": "Renaming Sections",
+ "long_description": "Rename Sections by clicking the Section name from the Course Outline.",
+ "is_checked": False,
+ "action_url": "CourseOutline",
+ "action_text": "Edit Course Outline",
+ "action_external": False},
+ {"short_description": "Deleting Course Content",
+ "long_description": "Delete Sections, Subsections, or Units you don't need anymore. Be careful, as there is no Undo function.",
+ "is_checked": False,
+ "action_url": "CourseOutline",
+ "action_text": "Edit Course Outline",
+ "action_external": False},
+ {"short_description": "Add an Instructor-Only Section to Your Outline",
+ "long_description": "Some course authors find using a section for unsorted, in-progress work useful. To do this, create a section and set the release date to the distant future.",
+ "is_checked": False,
+ "action_url": "CourseOutline",
+ "action_text": "Edit Course Outline",
+ "action_external": False}]
+ },
+ {"short_description" : "Explore edX's Support Tools",
+ "items" : [{"short_description": "Explore the Studio Help Forum",
+ "long_description": "Access the Studio Help forum from the menu that appears when you click your user name in the top right corner of Studio.",
+ "is_checked": False,
+ "action_url": "http://help.edge.edx.org/",
+ "action_text": "Visit Studio Help",
+ "action_external": True},
+ {"short_description": "Enroll in edX 101",
+ "long_description": "Register for edX 101, edX's primer for course creation.",
+ "is_checked": False,
+ "action_url": "https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about",
+ "action_text": "Register for edX 101",
+ "action_external": True},
+ {"short_description": "Download the Studio Documentation",
+ "long_description": "Download the searchable Studio reference documentation in PDF form.",
+ "is_checked": False,
+ "action_url": "http://files.edx.org/Getting_Started_with_Studio.pdf",
+ "action_text": "Download Documentation",
+ "action_external": True}]
+ },
+ {"short_description" : "Draft Your Course About Page",
+ "items" : [{"short_description": "Draft a Course Description",
+ "long_description": "Courses on edX have an About page that includes a course video, description, and more. Draft the text students will read before deciding to enroll in your course.",
+ "is_checked": False,
+ "action_url": "SettingsDetails",
+ "action_text": "Edit Course Schedule & Details",
+ "action_external": False},
+ {"short_description": "Add Staff Bios",
+ "long_description": "Showing prospective students who their instructor will be is helpful. Include staff bios on the course About page.",
+ "is_checked": False,
+ "action_url": "SettingsDetails",
+ "action_text": "Edit Course Schedule & Details",
+ "action_external": False},
+ {"short_description": "Add Course FAQs",
+ "long_description": "Include a short list of frequently asked questions about your course.",
+ "is_checked": False,
+ "action_url": "SettingsDetails",
+ "action_text": "Edit Course Schedule & Details",
+ "action_external": False},
+ {"short_description": "Add Course Prerequisites",
+ "long_description": "Let students know what knowledge and/or skills they should have before they enroll in your course.",
+ "is_checked": False,
+ "action_url": "SettingsDetails",
+ "action_text": "Edit Course Schedule & Details",
+ "action_external": False}]
+ }
+ ])
info_sidebar_name = String(scope=Scope.settings, default='Course Handouts')
show_timezone = Boolean(help="True if timezones should be shown on dates in the courseware", scope=Scope.settings, default=True)
enrollment_domain = String(help="External login method associated with user accounts allowed to register in course",
@@ -220,18 +378,16 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
self.wiki_slug = self.location.course
msg = None
- if self.start is None:
- msg = "Course loaded without a valid start date. id = %s" % self.id
- self.start = datetime.now(UTC())
- log.critical(msg)
- self.system.error_tracker(msg)
# NOTE: relies on the modulestore to call set_grading_policy() right after
# init. (Modulestore is in charge of figuring out where to load the policy from)
# NOTE (THK): This is a last-minute addition for Fall 2012 launch to dynamically
# disable the syllabus content for courses that do not provide a syllabus
- self.syllabus_present = self.system.resources_fs.exists(path('syllabus'))
+ if self.system.resources_fs is None:
+ self.syllabus_present = False
+ else:
+ self.syllabus_present = self.system.resources_fs.exists(path('syllabus'))
self._grading_policy = {}
self.set_grading_policy(self.grading_policy)
@@ -252,42 +408,33 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
log.error(msg)
continue
- def default_grading_policy(self):
- """
- Return a dict which is a copy of the default grading policy
- """
- return {"GRADER": [
- {
- "type": "Homework",
- "min_count": 12,
- "drop_count": 2,
- "short_label": "HW",
- "weight": 0.15
- },
- {
- "type": "Lab",
- "min_count": 12,
- "drop_count": 2,
- "weight": 0.15
- },
- {
- "type": "Midterm Exam",
- "short_label": "Midterm",
- "min_count": 1,
- "drop_count": 0,
- "weight": 0.3
- },
- {
- "type": "Final Exam",
- "short_label": "Final",
- "min_count": 1,
- "drop_count": 0,
- "weight": 0.4
- }
- ],
- "GRADE_CUTOFFS": {
- "Pass": 0.5
- }}
+ # TODO check that this is still needed here and can't be by defaults.
+ if self.tabs is None:
+ # When calling the various _tab methods, can omit the 'type':'blah' from the
+ # first arg, since that's only used for dispatch
+ tabs = []
+ tabs.append({'type': 'courseware'})
+ tabs.append({'type': 'course_info', 'name': 'Course Info'})
+
+ if self.syllabus_present:
+ tabs.append({'type': 'syllabus'})
+
+ tabs.append({'type': 'textbooks'})
+
+ # # If they have a discussion link specified, use that even if we feature
+ # # flag discussions off. Disabling that is mostly a server safety feature
+ # # at this point, and we don't need to worry about external sites.
+ if self.discussion_link:
+ tabs.append({'type': 'external_discussion', 'link': self.discussion_link})
+ else:
+ tabs.append({'type': 'discussion', 'name': 'Discussion'})
+
+ tabs.append({'type': 'wiki', 'name': 'Wiki'})
+
+ if not self.hide_progress_tab:
+ tabs.append({'type': 'progress', 'name': 'Progress'})
+
+ self.tabs = tabs
def set_grading_policy(self, course_policy):
"""
@@ -298,7 +445,13 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
course_policy = {}
# Load the global settings as a dictionary
- grading_policy = self.default_grading_policy()
+ grading_policy = self.grading_policy
+ # BOY DO I HATE THIS grading_policy CODE ACROBATICS YET HERE I ADD MORE (dhm)--this fixes things persisted w/
+ # defective grading policy values (but not None)
+ if 'GRADER' not in grading_policy:
+ grading_policy['GRADER'] = CourseFields.grading_policy.default['GRADER']
+ if 'GRADE_CUTOFFS' not in grading_policy:
+ grading_policy['GRADE_CUTOFFS'] = CourseFields.grading_policy.default['GRADE_CUTOFFS']
# Override any global settings with the course settings
grading_policy.update(course_policy)
@@ -354,10 +507,6 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
system.error_tracker("Unable to decode grading policy as json")
policy = {}
- # cdodge: import the grading policy information that is on disk and put into the
- # descriptor 'definition' bucket as a dictionary so that it is persisted in the DB
- instance.grading_policy = policy
-
# now set the current instance. set_grading_policy() will apply some inheritance rules
instance.set_grading_policy(policy)
@@ -661,6 +810,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
if isinstance(self.advertised_start, basestring):
return try_parse_iso_8601(self.advertised_start)
elif self.advertised_start is None and self.start is None:
+ # TODO this is an impossible state since the init function forces start to have a value
return 'TBD'
else:
return (self.advertised_start or self.start).strftime("%b %d, %Y")
diff --git a/common/lib/xmodule/xmodule/discussion_module.py b/common/lib/xmodule/xmodule/discussion_module.py
index aef4821839..fac6a498e5 100644
--- a/common/lib/xmodule/xmodule/discussion_module.py
+++ b/common/lib/xmodule/xmodule/discussion_module.py
@@ -4,17 +4,27 @@ from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
from xmodule.editing_module import MetadataOnlyEditingDescriptor
from xblock.core import String, Scope
+from uuid import uuid4
class DiscussionFields(object):
- discussion_id = String(scope=Scope.settings)
+ discussion_id = String(scope=Scope.settings, default="$$GUID$$")
+ display_name = String(
+ display_name="Display Name",
+ help="Display name for this module",
+ default="Discussion Tag",
+ scope=Scope.settings)
+ data = String(help="XML data for the problem", scope=Scope.content,
+ default="")
discussion_category = String(
display_name="Category",
+ default="Week 1",
help="A category name for the discussion. This name appears in the left pane of the discussion forum for the course.",
scope=Scope.settings
)
discussion_target = String(
display_name="Subcategory",
+ default="Topic-Level Student-Visible Label",
help="A subcategory name for the discussion. This name appears in the left pane of the discussion forum for the course.",
scope=Scope.settings
)
@@ -36,9 +46,15 @@ class DiscussionModule(DiscussionFields, XModule):
class DiscussionDescriptor(DiscussionFields, MetadataOnlyEditingDescriptor, RawDescriptor):
- module_class = DiscussionModule
- template_dir_name = "discussion"
+ def __init__(self, *args, **kwargs):
+ super(DiscussionDescriptor, self).__init__(*args, **kwargs)
+ # is this too late? i.e., will it get persisted and stay static w/ the first value
+ # any code references. I believe so.
+ if self.discussion_id == '$$GUID$$':
+ self.discussion_id = uuid4().hex
+
+ module_class = DiscussionModule
# The discussion XML format uses `id` and `for` attributes,
# but these would overload other module attributes, so we prefix them
# for actual use in the code
diff --git a/common/lib/xmodule/xmodule/html_module.py b/common/lib/xmodule/xmodule/html_module.py
index 0f7e789906..9ff2e4d9a8 100644
--- a/common/lib/xmodule/xmodule/html_module.py
+++ b/common/lib/xmodule/xmodule/html_module.py
@@ -13,12 +13,21 @@ from xmodule.html_checker import check_html
from xmodule.stringify import stringify_children
from xmodule.x_module import XModule
from xmodule.xml_module import XmlDescriptor, name_to_pathname
+import textwrap
log = logging.getLogger("mitx.courseware")
class HtmlFields(object):
- data = String(help="Html contents to display for this module", scope=Scope.content)
+ display_name = String(
+ display_name="Display Name",
+ help="This name appears in the horizontal navigation at the top of the page.",
+ scope=Scope.settings,
+ # it'd be nice to have a useful default but it screws up other things; so,
+ # use display_name_with_default for those
+ default="Blank HTML Page"
+ )
+ data = String(help="Html contents to display for this module", default="", scope=Scope.content)
source_code = String(help="Source code for LaTeX documents. This feature is not well-supported.", scope=Scope.settings)
@@ -32,7 +41,7 @@ class HtmlModule(HtmlFields, XModule):
css = {'scss': [resource_string(__name__, 'css/html/display.scss')]}
def get_html(self):
- if self.system.anonymous_student_id:
+ if self.system.anonymous_student_id:
return self.data.replace("%%USER_ID%%", self.system.anonymous_student_id)
return self.data
@@ -169,26 +178,88 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor):
elt.set("filename", relname)
return elt
+class AboutFields(object):
+ display_name = String(
+ help="Display name for this module",
+ scope=Scope.settings,
+ default="overview",
+ )
+ data = String(
+ help="Html contents to display for this module",
+ default="",
+ scope=Scope.content
+ )
-class AboutDescriptor(HtmlDescriptor):
+class AboutModule(AboutFields, HtmlModule):
+ """
+ Overriding defaults but otherwise treated as HtmlModule.
+ """
+ pass
+
+class AboutDescriptor(AboutFields, HtmlDescriptor):
"""
These pieces of course content are treated as HtmlModules but we need to overload where the templates are located
in order to be able to create new ones
"""
template_dir_name = "about"
+ module_class = AboutModule
+
+class StaticTabFields(object):
+ """
+ The overrides for Static Tabs
+ """
+ display_name = String(
+ display_name="Display Name",
+ help="This name appears in the horizontal navigation at the top of the page.",
+ scope=Scope.settings,
+ default="Empty",
+ )
+ data = String(
+ default=textwrap.dedent("""\
+
This is where you can add additional pages to your courseware. Click the 'edit' button to begin editing.
+ """),
+ scope=Scope.content,
+ help="HTML for the additional pages"
+ )
-class StaticTabDescriptor(HtmlDescriptor):
+class StaticTabModule(StaticTabFields, HtmlModule):
+ """
+ Supports the field overrides
+ """
+ pass
+
+class StaticTabDescriptor(StaticTabFields, HtmlDescriptor):
"""
These pieces of course content are treated as HtmlModules but we need to overload where the templates are located
in order to be able to create new ones
"""
template_dir_name = "statictab"
+ module_class = StaticTabModule
-class CourseInfoDescriptor(HtmlDescriptor):
+class CourseInfoFields(object):
+ """
+ Field overrides
+ """
+ data = String(
+ help="Html contents to display for this module",
+ default="",
+ scope=Scope.content
+ )
+
+
+class CourseInfoModule(CourseInfoFields, HtmlModule):
+ """
+ Just to support xblock field overrides
+ """
+ pass
+
+
+class CourseInfoDescriptor(CourseInfoFields, HtmlDescriptor):
"""
These pieces of course content are treated as HtmlModules but we need to overload where the templates are located
in order to be able to create new ones
"""
template_dir_name = "courseinfo"
+ module_class = CourseInfoModule
diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py
index 7df444a892..c88a2e1b38 100644
--- a/common/lib/xmodule/xmodule/peer_grading_module.py
+++ b/common/lib/xmodule/xmodule/peer_grading_module.py
@@ -59,6 +59,15 @@ class PeerGradingFields(object):
help="Defines the number of points each problem is worth. If the value is not set, each problem is worth one point.",
scope=Scope.settings, values={"min": 0, "step": ".1"}
)
+ display_name = String(
+ display_name="Display Name",
+ help="Display name for this module",
+ scope=Scope.settings,
+ default="Peer Grading Interface"
+ )
+ data = String(help="Html contents to display for this module",
+ default='',
+ scope=Scope.content)
class PeerGradingModule(PeerGradingFields, XModule):
diff --git a/common/lib/xmodule/xmodule/raw_module.py b/common/lib/xmodule/xmodule/raw_module.py
index 554be73926..4c6c719224 100644
--- a/common/lib/xmodule/xmodule/raw_module.py
+++ b/common/lib/xmodule/xmodule/raw_module.py
@@ -13,7 +13,7 @@ class RawDescriptor(XmlDescriptor, XMLEditingDescriptor):
Module that provides a raw editing view of its data and children. It
requires that the definition xml is valid.
"""
- data = String(help="XML data for the module", scope=Scope.content)
+ data = String(help="XML data for the module", default="", scope=Scope.content)
@classmethod
def definition_from_xml(cls, xml_object, system):
diff --git a/common/lib/xmodule/xmodule/templates/about/empty.yaml b/common/lib/xmodule/xmodule/templates/about/empty.yaml
index fa3ed606bd..0967ef424b 100644
--- a/common/lib/xmodule/xmodule/templates/about/empty.yaml
+++ b/common/lib/xmodule/xmodule/templates/about/empty.yaml
@@ -1,5 +1 @@
----
-metadata:
- display_name: Empty
-data: "
This is where you can add additional information about your course.
"
-children: []
\ No newline at end of file
+{}
diff --git a/common/lib/xmodule/xmodule/templates/about/overview.yaml b/common/lib/xmodule/xmodule/templates/about/overview.yaml
index 0031ebffaf..9b2e895526 100644
--- a/common/lib/xmodule/xmodule/templates/about/overview.yaml
+++ b/common/lib/xmodule/xmodule/templates/about/overview.yaml
@@ -3,51 +3,50 @@ metadata:
display_name: overview
data: |
-
-
About This Course
-
Include your long course description here. The long course description should contain 150-400 words.
+
+
About This Course
+
Include your long course description here. The long course description should contain 150-400 words.
-
This is paragraph 2 of the long course description. Add more paragraphs as needed. Make sure to enclose them in paragraph tags.
-
+
This is paragraph 2 of the long course description. Add more paragraphs as needed. Make sure to enclose them in paragraph tags.
+
-
-
Prerequisites
-
Add information about course prerequisites here.
-
+
+
Prerequisites
+
Add information about course prerequisites here.
+
-
-
Course Staff
-
-
-
-
+
+
Course Staff
+
+
+
+
-
Staff Member #1
-
Biography of instructor/staff member #1
-
+
Staff Member #1
+
Biography of instructor/staff member #1
+
-
-
-
-
+
+
+
+
-
Staff Member #2
-
Biography of instructor/staff member #2
-
-
+
Staff Member #2
+
Biography of instructor/staff member #2
+
+
-
-
-
Frequently Asked Questions
-
-
Do I need to buy a textbook?
-
No, a free online version of Chemistry: Principles, Patterns, and Applications, First Edition by Bruce Averill and Patricia Eldredge will be available, though you can purchase a printed version (published by FlatWorld Knowledge) if you’d like.
-
+
+
+
Frequently Asked Questions
+
+
Do I need to buy a textbook?
+
No, a free online version of Chemistry: Principles, Patterns, and Applications, First Edition by Bruce Averill and Patricia Eldredge will be available, though you can purchase a printed version (published by FlatWorld Knowledge) if you’d like.
Enter your (optional) instructions for the exercise in HTML format.
-
Annotations are specified by an <annotation> tag which may may have the following attributes:
-
-
title (optional). Title of the annotation. Defaults to Commentary if omitted.
-
body (required). Text of the annotation.
-
problem (optional). Numeric index of the problem associated with this annotation. This is a zero-based index, so the first problem on the page would have problem="0".
-
highlight (optional). Possible values: yellow, red, orange, green, blue, or purple. Defaults to yellow if this attribute is omitted.
-
-
-
Add your HTML with annotation spans here.
-
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut sodales laoreet est, egestas gravida felis egestas nec. Aenean at volutpat erat. Cras commodo viverra nibh in aliquam.
-
Nulla facilisi. Pellentesque id vestibulum libero. Suspendisse potenti. Morbi scelerisque nisi vitae felis dictum mattis. Nam sit amet magna elit. Nullam volutpat cursus est, sit amet sagittis odio vulputate et. Curabitur euismod, orci in vulputate imperdiet, augue lorem tempor purus, id aliquet augue turpis a est. Aenean a sagittis libero. Praesent fringilla pretium magna, non condimentum risus elementum nec. Pellentesque faucibus elementum pharetra. Pellentesque vitae metus eros.
-
-
-
-
-
-
-
- Enter essay here.
- This is the answer.
- {"grader_settings" : "peer_grading.conf", "problem_id" : "700x/Demo"}
-
-
-
-
-
-children: []
+{}
diff --git a/common/lib/xmodule/xmodule/templates/course/empty.yaml b/common/lib/xmodule/xmodule/templates/course/empty.yaml
index 89f1bfcf21..0967ef424b 100644
--- a/common/lib/xmodule/xmodule/templates/course/empty.yaml
+++ b/common/lib/xmodule/xmodule/templates/course/empty.yaml
@@ -1,124 +1 @@
----
-metadata:
- display_name: Empty
- start: 2020-10-10T10:00
- checklists: [
- {"short_description" : "Getting Started With Studio",
- "items" : [{"short_description": "Add Course Team Members",
- "long_description": "Grant your collaborators permission to edit your course so you can work together.",
- "is_checked": false,
- "action_url": "ManageUsers",
- "action_text": "Edit Course Team",
- "action_external": false},
- {"short_description": "Set Important Dates for Your Course",
- "long_description": "Establish your course's student enrollment and launch dates on the Schedule and Details page.",
- "is_checked": false,
- "action_url": "SettingsDetails",
- "action_text": "Edit Course Details & Schedule",
- "action_external": false},
- {"short_description": "Draft Your Course's Grading Policy",
- "long_description": "Set up your assignment types and grading policy even if you haven't created all your assignments.",
- "is_checked": false,
- "action_url": "SettingsGrading",
- "action_text": "Edit Grading Settings",
- "action_external": false},
- {"short_description": "Explore the Other Studio Checklists",
- "long_description": "Discover other available course authoring tools, and find help when you need it.",
- "is_checked": false,
- "action_url": "",
- "action_text": "",
- "action_external": false}]
- },
- {"short_description" : "Draft a Rough Course Outline",
- "items" : [{"short_description": "Create Your First Section and Subsection",
- "long_description": "Use your course outline to build your first Section and Subsection.",
- "is_checked": false,
- "action_url": "CourseOutline",
- "action_text": "Edit Course Outline",
- "action_external": false},
- {"short_description": "Set Section Release Dates",
- "long_description": "Specify the release dates for each Section in your course. Sections become visible to students on their release dates.",
- "is_checked": false,
- "action_url": "CourseOutline",
- "action_text": "Edit Course Outline",
- "action_external": false},
- {"short_description": "Designate a Subsection as Graded",
- "long_description": "Set a Subsection to be graded as a specific assignment type. Assignments within graded Subsections count toward a student's final grade.",
- "is_checked": false,
- "action_url": "CourseOutline",
- "action_text": "Edit Course Outline",
- "action_external": false},
- {"short_description": "Reordering Course Content",
- "long_description": "Use drag and drop to reorder the content in your course.",
- "is_checked": false,
- "action_url": "CourseOutline",
- "action_text": "Edit Course Outline",
- "action_external": false},
- {"short_description": "Renaming Sections",
- "long_description": "Rename Sections by clicking the Section name from the Course Outline.",
- "is_checked": false,
- "action_url": "CourseOutline",
- "action_text": "Edit Course Outline",
- "action_external": false},
- {"short_description": "Deleting Course Content",
- "long_description": "Delete Sections, Subsections, or Units you don't need anymore. Be careful, as there is no Undo function.",
- "is_checked": false,
- "action_url": "CourseOutline",
- "action_text": "Edit Course Outline",
- "action_external": false},
- {"short_description": "Add an Instructor-Only Section to Your Outline",
- "long_description": "Some course authors find using a section for unsorted, in-progress work useful. To do this, create a section and set the release date to the distant future.",
- "is_checked": false,
- "action_url": "CourseOutline",
- "action_text": "Edit Course Outline",
- "action_external": false}]
- },
- {"short_description" : "Explore edX's Support Tools",
- "items" : [{"short_description": "Explore the Studio Help Forum",
- "long_description": "Access the Studio Help forum from the menu that appears when you click your user name in the top right corner of Studio.",
- "is_checked": false,
- "action_url": "http://help.edge.edx.org/",
- "action_text": "Visit Studio Help",
- "action_external": true},
- {"short_description": "Enroll in edX 101",
- "long_description": "Register for edX 101, edX's primer for course creation.",
- "is_checked": false,
- "action_url": "https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about",
- "action_text": "Register for edX 101",
- "action_external": true},
- {"short_description": "Download the Studio Documentation",
- "long_description": "Download the searchable Studio reference documentation in PDF form.",
- "is_checked": false,
- "action_url": "http://files.edx.org/Getting_Started_with_Studio.pdf",
- "action_text": "Download Documentation",
- "action_external": true}]
- },
- {"short_description" : "Draft Your Course About Page",
- "items" : [{"short_description": "Draft a Course Description",
- "long_description": "Courses on edX have an About page that includes a course video, description, and more. Draft the text students will read before deciding to enroll in your course.",
- "is_checked": false,
- "action_url": "SettingsDetails",
- "action_text": "Edit Course Schedule & Details",
- "action_external": false},
- {"short_description": "Add Staff Bios",
- "long_description": "Showing prospective students who their instructor will be is helpful. Include staff bios on the course About page.",
- "is_checked": false,
- "action_url": "SettingsDetails",
- "action_text": "Edit Course Schedule & Details",
- "action_external": false},
- {"short_description": "Add Course FAQs",
- "long_description": "Include a short list of frequently asked questions about your course.",
- "is_checked": false,
- "action_url": "SettingsDetails",
- "action_text": "Edit Course Schedule & Details",
- "action_external": false},
- {"short_description": "Add Course Prerequisites",
- "long_description": "Let students know what knowledge and/or skills they should have before they enroll in your course.",
- "is_checked": false,
- "action_url": "SettingsDetails",
- "action_text": "Edit Course Schedule & Details",
- "action_external": false}]
- }
- ]
-data: { 'textbooks' : [ ], 'wiki_slug' : null }
-children: []
+{}
diff --git a/common/lib/xmodule/xmodule/templates/courseinfo/empty.yaml b/common/lib/xmodule/xmodule/templates/courseinfo/empty.yaml
index c6958ed887..0967ef424b 100644
--- a/common/lib/xmodule/xmodule/templates/courseinfo/empty.yaml
+++ b/common/lib/xmodule/xmodule/templates/courseinfo/empty.yaml
@@ -1,5 +1 @@
----
-metadata:
- display_name: Empty
-data: ""
-children: []
\ No newline at end of file
+{}
diff --git a/common/lib/xmodule/xmodule/templates/default/empty.yaml b/common/lib/xmodule/xmodule/templates/default/empty.yaml
index a2fb2b5832..0967ef424b 100644
--- a/common/lib/xmodule/xmodule/templates/default/empty.yaml
+++ b/common/lib/xmodule/xmodule/templates/default/empty.yaml
@@ -1,5 +1 @@
----
-metadata:
- display_name: Empty
-data: ""
-children: []
+{}
diff --git a/common/lib/xmodule/xmodule/templates/discussion/default.yaml b/common/lib/xmodule/xmodule/templates/discussion/default.yaml
index 049e34b3e7..0967ef424b 100644
--- a/common/lib/xmodule/xmodule/templates/discussion/default.yaml
+++ b/common/lib/xmodule/xmodule/templates/discussion/default.yaml
@@ -1,9 +1 @@
----
-metadata:
- display_name: Discussion Tag
- for: Topic-Level Student-Visible Label
- id: $$GUID$$
- discussion_category: Week 1
-data: |
-
-children: []
+{}
diff --git a/common/lib/xmodule/xmodule/templates/html/announcement.yaml b/common/lib/xmodule/xmodule/templates/html/announcement.yaml
index 82fe8fbc03..30a8ccb41e 100644
--- a/common/lib/xmodule/xmodule/templates/html/announcement.yaml
+++ b/common/lib/xmodule/xmodule/templates/html/announcement.yaml
@@ -1,7 +1,6 @@
---
metadata:
- display_name: Announcement
-
+ display_name: Announcement
data: |
diff --git a/common/lib/xmodule/xmodule/templates/html/empty.yaml b/common/lib/xmodule/xmodule/templates/html/empty.yaml
index 40b005af28..0967ef424b 100644
--- a/common/lib/xmodule/xmodule/templates/html/empty.yaml
+++ b/common/lib/xmodule/xmodule/templates/html/empty.yaml
@@ -1,7 +1 @@
----
-metadata:
- display_name: Blank HTML Page
-
-data: |
-
-children: []
\ No newline at end of file
+{}
diff --git a/common/lib/xmodule/xmodule/templates/html/latex_html.yaml b/common/lib/xmodule/xmodule/templates/html/latex_html.yaml
index ff92f2aead..ba5c4b5c06 100644
--- a/common/lib/xmodule/xmodule/templates/html/latex_html.yaml
+++ b/common/lib/xmodule/xmodule/templates/html/latex_html.yaml
@@ -1,16 +1,16 @@
---
metadata:
display_name: E-text Written in LaTeX
- source_code: |
- \subsection{Example of E-text in LaTeX}
+source_code: |
+ \subsection{Example of E-text in LaTeX}
- It is very convenient to write complex equations in LaTeX.
+ It is very convenient to write complex equations in LaTeX.
- \begin{equation}
- x = \frac{-b\pm\sqrt{b^2-4*a*c}}{2a}
- \end{equation}
+ \begin{equation}
+ x = \frac{-b\pm\sqrt{b^2-4*a*c}}{2a}
+ \end{equation}
- Seize the moment.
+ Seize the moment.
data: |
diff --git a/common/lib/xmodule/xmodule/templates/peer_grading/default.yaml b/common/lib/xmodule/xmodule/templates/peer_grading/default.yaml
index 5d88a18ad8..0967ef424b 100644
--- a/common/lib/xmodule/xmodule/templates/peer_grading/default.yaml
+++ b/common/lib/xmodule/xmodule/templates/peer_grading/default.yaml
@@ -1,9 +1 @@
----
-metadata:
- display_name: Peer Grading Interface
- max_grade: 1
-data: |
-
-
-
-children: []
+{}
diff --git a/common/lib/xmodule/xmodule/templates/problem/blank_common.yaml b/common/lib/xmodule/xmodule/templates/problem/blank_common.yaml
new file mode 100644
index 0000000000..3dcac29aba
--- /dev/null
+++ b/common/lib/xmodule/xmodule/templates/problem/blank_common.yaml
@@ -0,0 +1,5 @@
+---
+metadata:
+ display_name: Blank Common Problem
+ markdown: ""
+data: ""
diff --git a/common/lib/xmodule/xmodule/templates/problem/circuitschematic.yaml b/common/lib/xmodule/xmodule/templates/problem/circuitschematic.yaml
index 56f802a6a3..3b051f2ba8 100644
--- a/common/lib/xmodule/xmodule/templates/problem/circuitschematic.yaml
+++ b/common/lib/xmodule/xmodule/templates/problem/circuitschematic.yaml
@@ -5,59 +5,58 @@ metadata:
rerandomize: never
showanswer: finished
data: |
-
- Please make a voltage divider that splits the provided voltage evenly.
-
-
-
-
-
-
- dc_value = "dc analysis not found"
- for response in submission[0]:
- if response[0] == 'dc':
- for node in response[1:]:
- dc_value = node['output']
-
- if dc_value == .5:
- correct = ['correct']
- else:
- correct = ['incorrect']
-
-
-
-
Make a high pass filter
-
-
-
-
- ac_values = None
- for response in submission[0]:
- if response[0] == 'ac':
- for node in response[1:]:
- ac_values = node['NodeA']
- print "the ac analysis value:", ac_values
- if ac_values == None:
- correct = ['incorrect']
- elif ac_values[0][1] < ac_values[1][1]:
- correct = ['correct']
- else:
- correct = ['incorrect']
-
-
-
-
-
-
Explanation
-
A voltage divider that evenly divides the input voltage can be formed with two identically valued resistors, with the sampled voltage taken in between the two.
-
-
A simple high-pass filter without any further constaints can be formed by simply putting a resister in series with a capacitor. The actual values of the components do not really matter in order to meet the constraints of the problem.
-
-
-
-
-children: []
+
+ Please make a voltage divider that splits the provided voltage evenly.
+
+
+
+
+
+
+ dc_value = "dc analysis not found"
+ for response in submission[0]:
+ if response[0] == 'dc':
+ for node in response[1:]:
+ dc_value = node['output']
+
+ if dc_value == .5:
+ correct = ['correct']
+ else:
+ correct = ['incorrect']
+
+
+
+
Make a high pass filter
+
+
+
+
+ ac_values = None
+ for response in submission[0]:
+ if response[0] == 'ac':
+ for node in response[1:]:
+ ac_values = node['NodeA']
+ print "the ac analysis value:", ac_values
+ if ac_values == None:
+ correct = ['incorrect']
+ elif ac_values[0][1] < ac_values[1][1]:
+ correct = ['correct']
+ else:
+ correct = ['incorrect']
+
+
+
+
+
+
Explanation
+
A voltage divider that evenly divides the input voltage can be formed with two identically valued resistors, with the sampled voltage taken in between the two.
+
+
A simple high-pass filter without any further constaints can be formed by simply putting a resister in series with a capacitor. The actual values of the components do not really matter in order to meet the constraints of the problem.
This is where you can add additional pages to your courseware. Click the 'edit' button to begin editing.
"
-children: []
\ No newline at end of file
+{}
\ No newline at end of file
diff --git a/common/lib/xmodule/xmodule/templates/video/default.yaml b/common/lib/xmodule/xmodule/templates/video/default.yaml
index 048e7396c7..0967ef424b 100644
--- a/common/lib/xmodule/xmodule/templates/video/default.yaml
+++ b/common/lib/xmodule/xmodule/templates/video/default.yaml
@@ -1,5 +1 @@
----
-metadata:
- display_name: default
-data: ""
-children: []
+{}
diff --git a/common/lib/xmodule/xmodule/templates/videoalpha/default.yaml b/common/lib/xmodule/xmodule/templates/videoalpha/default.yaml
index 1c25b272a3..0967ef424b 100644
--- a/common/lib/xmodule/xmodule/templates/videoalpha/default.yaml
+++ b/common/lib/xmodule/xmodule/templates/videoalpha/default.yaml
@@ -1,11 +1 @@
----
-metadata:
- display_name: Video Alpha
- version: 1
-data: |
-
-
-
-
-
-children: []
+{}
diff --git a/common/lib/xmodule/xmodule/templates/word_cloud/default.yaml b/common/lib/xmodule/xmodule/templates/word_cloud/default.yaml
index 53e9eeaae4..0967ef424b 100644
--- a/common/lib/xmodule/xmodule/templates/word_cloud/default.yaml
+++ b/common/lib/xmodule/xmodule/templates/word_cloud/default.yaml
@@ -1,5 +1 @@
----
-metadata:
- display_name: Word cloud
-data: {}
-children: []
+{}
diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py
index 3c6203107d..ebff888f34 100644
--- a/common/lib/xmodule/xmodule/video_module.py
+++ b/common/lib/xmodule/xmodule/video_module.py
@@ -21,6 +21,17 @@ log = logging.getLogger(__name__)
class VideoFields(object):
"""Fields for `VideoModule` and `VideoDescriptor`."""
+ display_name = String(
+ display_name="Display Name",
+ help="This name appears in the horizontal navigation at the top of the page.",
+ scope=Scope.settings,
+ # it'd be nice to have a useful default but it screws up other things; so,
+ # use display_name_with_default for those
+ default="Video Title"
+ )
+ data = String(help="XML data for the problem",
+ default='',
+ scope=Scope.content)
position = Integer(help="Current position in the video", scope=Scope.user_state, default=0)
show_captions = Boolean(help="This controls whether or not captions are shown by default.", display_name="Show Captions", scope=Scope.settings, default=True)
youtube_id_1_0 = String(help="This is the Youtube ID reference for the normal speed video.", display_name="Default Speed", scope=Scope.settings, default="OEoXaMPEzfM")
@@ -129,6 +140,10 @@ def _parse_video_xml(video, xml_data):
display_name = xml.get('display_name')
if display_name:
video.display_name = display_name
+ elif video.url_name is not None:
+ # copies the logic of display_name_with_default in order that studio created videos will have an
+ # initial non guid name
+ video.display_name = video.url_name.replace('_', ' ')
youtube = xml.get('youtube')
if youtube:
diff --git a/common/lib/xmodule/xmodule/videoalpha_module.py b/common/lib/xmodule/xmodule/videoalpha_module.py
index 3b5b90e674..33945c33fc 100644
--- a/common/lib/xmodule/xmodule/videoalpha_module.py
+++ b/common/lib/xmodule/xmodule/videoalpha_module.py
@@ -28,15 +28,27 @@ from xblock.core import Integer, Scope, String
import datetime
import time
+import textwrap
log = logging.getLogger(__name__)
class VideoAlphaFields(object):
"""Fields for `VideoAlphaModule` and `VideoAlphaDescriptor`."""
- data = String(help="XML data for the problem", scope=Scope.content)
+ data = String(help="XML data for the problem",
+ default=textwrap.dedent('''\
+
+
+
+
+ '''),
+ scope=Scope.content)
position = Integer(help="Current position in the video", scope=Scope.user_state, default=0)
- display_name = String(help="Display name for this module", scope=Scope.settings)
+ display_name = String(
+ display_name="Display Name", help="Display name for this module",
+ default="Video Alpha",
+ scope=Scope.settings
+ )
class VideoAlphaModule(VideoAlphaFields, XModule):
diff --git a/common/lib/xmodule/xmodule/word_cloud_module.py b/common/lib/xmodule/xmodule/word_cloud_module.py
index a7f3f92795..004e6ed320 100644
--- a/common/lib/xmodule/xmodule/word_cloud_module.py
+++ b/common/lib/xmodule/xmodule/word_cloud_module.py
@@ -14,7 +14,7 @@ from xmodule.raw_module import RawDescriptor
from xmodule.editing_module import MetadataOnlyEditingDescriptor
from xmodule.x_module import XModule
-from xblock.core import Scope, Dict, Boolean, List, Integer
+from xblock.core import Scope, Dict, Boolean, List, Integer, String
log = logging.getLogger(__name__)
@@ -31,6 +31,12 @@ def pretty_bool(value):
class WordCloudFields(object):
"""XFields for word cloud."""
+ display_name = String(
+ display_name="Display Name",
+ help="Display name for this module",
+ scope=Scope.settings,
+ default="Word cloud"
+ )
num_inputs = Integer(
display_name="Inputs",
help="Number of text boxes available for students to input words/sentences.",
@@ -234,7 +240,7 @@ class WordCloudModule(WordCloudFields, XModule):
return self.content
-class WordCloudDescriptor(MetadataOnlyEditingDescriptor, RawDescriptor, WordCloudFields):
+class WordCloudDescriptor(WordCloudFields, MetadataOnlyEditingDescriptor, RawDescriptor):
"""Descriptor for WordCloud Xmodule."""
module_class = WordCloudModule
template_dir_name = 'word_cloud'
diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py
index 0f5bbf4f2e..aee8e26171 100644
--- a/common/lib/xmodule/xmodule/x_module.py
+++ b/common/lib/xmodule/xmodule/x_module.py
@@ -7,8 +7,8 @@ from lxml import etree
from collections import namedtuple
from pkg_resources import resource_listdir, resource_string, resource_isdir
-from xmodule.modulestore import Location
-from xmodule.modulestore.exceptions import ItemNotFoundError
+from xmodule.modulestore import inheritance, Location
+from xmodule.modulestore.exceptions import ItemNotFoundError, InsufficientSpecificationError
from xblock.core import XBlock, Scope, String, Integer, Float, ModelType
@@ -101,6 +101,8 @@ class XModuleFields(object):
display_name="Display Name",
help="This name appears in the horizontal navigation at the top of the page.",
scope=Scope.settings,
+ # it'd be nice to have a useful default but it screws up other things; so,
+ # use display_name_with_default for those
default=None
)
@@ -113,6 +115,14 @@ class XModuleFields(object):
scope=Scope.content,
default=Location(None),
)
+ # Please note that in order to be compatible with XBlocks more generally,
+ # the LMS and CMS shouldn't be using this field. It's only for internal
+ # consumption by the XModules themselves
+ category = String(
+ display_name="xmodule category",
+ help="This is the category id for the XModule. It's for internal use only",
+ scope=Scope.content,
+ )
class XModule(XModuleFields, HTMLSnippet, XBlock):
@@ -148,8 +158,16 @@ class XModule(XModuleFields, HTMLSnippet, XBlock):
self._model_data = model_data
self.system = runtime
self.descriptor = descriptor
- self.url_name = self.location.name
- self.category = self.location.category
+ # LMS tests don't require descriptor but really it's required
+ if descriptor:
+ self.url_name = descriptor.url_name
+ # don't need to set category as it will automatically get from descriptor
+ elif isinstance(self.location, Location):
+ self.url_name = self.location.name
+ if not hasattr(self, 'category'):
+ self.category = self.location.category
+ else:
+ raise InsufficientSpecificationError()
self._loaded_children = None
@property
@@ -290,36 +308,67 @@ Template = namedtuple("Template", "metadata data children")
class ResourceTemplates(object):
+ """
+ Gets the templates associated w/ a containing cls. The cls must have a 'template_dir_name' attribute.
+ It finds the templates as directly in this directory under 'templates'.
+ """
@classmethod
def templates(cls):
"""
- Returns a list of Template objects that describe possible templates that can be used
- to create a module of this type.
- If no templates are provided, there will be no way to create a module of
- this type
+ Returns a list of dictionary field: value objects that describe possible templates that can be used
+ to seed a module of this type.
Expects a class attribute template_dir_name that defines the directory
inside the 'templates' resource directory to pull templates from
"""
templates = []
- dirname = os.path.join('templates', cls.template_dir_name)
- if not resource_isdir(__name__, dirname):
- log.warning("No resource directory {dir} found when loading {cls_name} templates".format(
- dir=dirname,
- cls_name=cls.__name__,
- ))
- return []
-
- for template_file in resource_listdir(__name__, dirname):
- if not template_file.endswith('.yaml'):
- log.warning("Skipping unknown template file %s" % template_file)
- continue
- template_content = resource_string(__name__, os.path.join(dirname, template_file))
- template = yaml.safe_load(template_content)
- templates.append(Template(**template))
+ dirname = cls.get_template_dir()
+ if dirname is not None:
+ for template_file in resource_listdir(__name__, dirname):
+ if not template_file.endswith('.yaml'):
+ log.warning("Skipping unknown template file %s", template_file)
+ continue
+ template_content = resource_string(__name__, os.path.join(dirname, template_file))
+ template = yaml.safe_load(template_content)
+ template['template_id'] = template_file
+ templates.append(template)
return templates
+ @classmethod
+ def get_template_dir(cls):
+ if getattr(cls, 'template_dir_name', None):
+ dirname = os.path.join('templates', getattr(cls, 'template_dir_name'))
+ if not resource_isdir(__name__, dirname):
+ log.warning("No resource directory {dir} found when loading {cls_name} templates".format(
+ dir=dirname,
+ cls_name=cls.__name__,
+ ))
+ return None
+ else:
+ return dirname
+ else:
+ return None
+
+ @classmethod
+ def get_template(cls, template_id):
+ """
+ Get a single template by the given id (which is the file name identifying it w/in the class's
+ template_dir_name)
+
+ """
+ dirname = cls.get_template_dir()
+ if dirname is not None:
+ try:
+ template_content = resource_string(__name__, os.path.join(dirname, template_id))
+ except IOError:
+ return None
+ template = yaml.safe_load(template_content)
+ template['template_id'] = template_id
+ return template
+ else:
+ return None
+
class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock):
"""
@@ -346,9 +395,6 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock):
# be equal
equality_attributes = ('_model_data', 'location')
- # Name of resource directory to load templates from
- template_dir_name = "default"
-
# Class level variable
# True if this descriptor always requires recalculation of grades, for
@@ -386,8 +432,12 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock):
"""
super(XModuleDescriptor, self).__init__(*args, **kwargs)
self.system = self.runtime
- self.url_name = self.location.name
- self.category = self.location.category
+ if isinstance(self.location, Location):
+ self.url_name = self.location.name
+ if not hasattr(self, 'category'):
+ self.category = self.location.category
+ else:
+ raise InsufficientSpecificationError()
self._child_instances = None
@property
@@ -419,11 +469,14 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock):
if self._child_instances is None:
self._child_instances = []
for child_loc in self.children:
- try:
- child = self.system.load_item(child_loc)
- except ItemNotFoundError:
- log.exception('Unable to load item {loc}, skipping'.format(loc=child_loc))
- continue
+ if isinstance(child_loc, XModuleDescriptor):
+ child = child_loc
+ else:
+ try:
+ child = self.system.load_item(child_loc)
+ except ItemNotFoundError:
+ log.exception('Unable to load item {loc}, skipping'.format(loc=child_loc))
+ continue
self._child_instances.append(child)
return self._child_instances
@@ -591,6 +644,13 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock):
"""
return [('{}', '{}')]
+ @property
+ def xblock_kvs(self):
+ """
+ Use w/ caution. Really intended for use by the persistence layer.
+ """
+ return self._model_data._kvs
+
# =============================== BUILTIN METHODS ==========================
def __eq__(self, other):
eq = (self.__class__ == other.__class__ and
diff --git a/lms/xmodule_namespace.py b/lms/xmodule_namespace.py
index aaef0b76db..a78e27e5af 100644
--- a/lms/xmodule_namespace.py
+++ b/lms/xmodule_namespace.py
@@ -3,6 +3,8 @@ 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
+from datetime import datetime
+from pytz import UTC
class LmsNamespace(Namespace):
@@ -25,7 +27,11 @@ class LmsNamespace(Namespace):
scope=Scope.settings,
)
- start = Date(help="Start time when this module is visible", scope=Scope.settings)
+ start = Date(
+ help="Start time when this module is visible",
+ default=datetime.fromtimestamp(0, UTC),
+ scope=Scope.settings
+ )
due = Date(help="Date that this problem is due by", scope=Scope.settings)
source_file = String(help="source file name (eg for latex)", scope=Scope.settings)
giturl = String(help="url root for course data git repository", scope=Scope.settings)
From 3722685e1a185ff4a0a085bb380ed793cf6d1e59 Mon Sep 17 00:00:00 2001
From: Don Mitchell
Date: Mon, 24 Jun 2013 15:44:49 -0400
Subject: [PATCH 24/73] No longer persist XModule templates
Instead, we use XModule field default values when creating an empty
XModule. Driven by this use case, we also allow for XModules to be
created in memory without being persisted to the database at all. This
necessitates a change to the Modulestore api, replacing clone_item with
create_draft and save_xmodule.
---
CHANGELOG.rst | 7 +
README.md | 1 -
.../contentstore/course_info_model.py | 4 +-
.../contentstore/features/common.py | 2 +-
.../component_settings_editor_helpers.py | 16 +-
.../features/discussion-editor.py | 4 +-
.../contentstore/features/html-editor.py | 2 +-
.../contentstore/features/problem-editor.py | 2 +-
.../features/studio-overview-togglesection.py | 8 +-
cms/djangoapps/contentstore/features/video.py | 2 +-
.../management/commands/update_templates.py | 10 -
.../contentstore/module_info_model.py | 24 +--
.../contentstore/tests/test_contentstore.py | 108 ++++------
.../tests/test_course_settings.py | 1 +
.../contentstore/tests/test_i18n.py | 1 -
.../contentstore/tests/test_item.py | 196 +++++++++++++++++-
cms/djangoapps/contentstore/tests/utils.py | 1 -
cms/djangoapps/contentstore/utils.py | 8 +-
.../contentstore/views/checklist.py | 6 +-
.../contentstore/views/component.py | 65 ++++--
cms/djangoapps/contentstore/views/course.py | 34 ++-
cms/djangoapps/contentstore/views/item.py | 75 ++++---
cms/djangoapps/contentstore/views/user.py | 2 +-
.../models/settings/course_grading.py | 10 +-
.../coffee/src/views/module_edit.coffee | 17 +-
cms/static/coffee/src/views/tabs.coffee | 4 +-
cms/static/coffee/src/views/unit.coffee | 4 +-
cms/static/js/base.js | 22 +-
cms/templates/index.html | 4 +-
cms/templates/overview.html | 12 +-
cms/templates/unit.html | 99 +++++----
cms/templates/widgets/units.html | 2 +-
cms/urls.py | 2 +-
common/djangoapps/terrain/course_helpers.py | 2 -
common/djangoapps/tests.py | 6 +-
common/lib/xmodule/xmodule/abtest_module.py | 2 -
.../lib/xmodule/xmodule/annotatable_module.py | 1 -
.../xmodule/combined_open_ended_module.py | 1 -
common/lib/xmodule/xmodule/course_module.py | 2 -
common/lib/xmodule/xmodule/error_module.py | 7 +-
common/lib/xmodule/xmodule/foldit_module.py | 1 -
common/lib/xmodule/xmodule/gst_module.py | 1 -
common/lib/xmodule/xmodule/html_module.py | 4 +-
.../spec/combinedopenended/edit_spec.coffee | 4 +-
.../xmodule/js/spec/problem/edit_spec.coffee | 4 +-
.../js/src/combinedopenended/edit.coffee | 3 +-
.../xmodule/js/src/problem/edit.coffee | 5 +-
.../xmodule/xmodule/modulestore/__init__.py | 9 +-
.../xmodule/xmodule/modulestore/mongo/base.py | 173 ++++++++++++----
.../xmodule/modulestore/mongo/draft.py | 58 ++++--
.../xmodule/modulestore/tests/django_utils.py | 20 --
.../xmodule/modulestore/tests/factories.py | 120 +++--------
.../xmodule/modulestore/tests/test_mongo.py | 4 +-
common/lib/xmodule/xmodule/modulestore/xml.py | 5 +-
.../combined_open_ended_modulev1.py | 1 -
.../open_ended_module.py | 1 -
.../self_assessment_module.py | 1 -
.../xmodule/xmodule/peer_grading_module.py | 1 -
common/lib/xmodule/xmodule/poll_module.py | 1 -
common/lib/xmodule/xmodule/templates.py | 96 +--------
.../xmodule/templates/about/empty.yaml | 1 -
.../templates/annotatable/default.yaml | 1 -
.../templates/combinedopenended/default.yaml | 1 -
.../xmodule/templates/course/empty.yaml | 1 -
.../xmodule/templates/courseinfo/empty.yaml | 1 -
.../xmodule/templates/default/empty.yaml | 1 -
.../xmodule/templates/discussion/default.yaml | 1 -
.../xmodule/templates/html/announcement.yaml | 1 -
.../xmodule/xmodule/templates/html/empty.yaml | 1 -
.../xmodule/templates/html/everything.yaml | 33 ---
.../xmodule/templates/html/latex_html.yaml | 1 -
.../templates/peer_grading/default.yaml | 1 -
.../templates/problem/circuitschematic.yaml | 2 +-
.../templates/problem/customgrader.yaml | 77 ++++---
.../xmodule/templates/problem/empty.yaml | 1 -
.../templates/problem/emptyadvanced.yaml | 10 -
.../templates/problem/forumularesponse.yaml | 3 +-
.../templates/problem/imageresponse.yaml | 4 +-
.../templates/problem/latex_problem.yaml | 2 +-
.../templates/problem/multiplechoice.yaml | 13 +-
.../templates/problem/numericalresponse.yaml | 22 +-
.../templates/problem/optionresponse.yaml | 11 +-
.../templates/problem/problem_with_hint.yaml | 3 +-
.../templates/problem/string_response.yaml | 11 +-
.../templates/sequence/with_video.yaml | 7 -
.../xmodule/templates/statictab/empty.yaml | 1 -
.../xmodule/templates/video/default.yaml | 1 -
.../xmodule/templates/videoalpha/default.yaml | 1 -
.../xmodule/templates/word_cloud/default.yaml | 1 -
.../lib/xmodule/xmodule/tests/test_logic.py | 3 +-
.../xmodule/xmodule/tests/test_xml_module.py | 1 +
common/lib/xmodule/xmodule/video_module.py | 1 -
.../lib/xmodule/xmodule/videoalpha_module.py | 1 -
common/lib/xmodule/xmodule/xml_module.py | 1 +
lms/djangoapps/courseware/features/common.py | 5 +-
.../courseware/features/navigation.py | 11 +-
.../courseware/features/problems.py | 2 +-
.../courseware/features/problems_setup.py | 6 +-
lms/djangoapps/courseware/features/video.py | 7 +-
lms/djangoapps/courseware/tests/__init__.py | 12 +-
.../tests/test_submitting_problems.py | 6 +-
.../courseware/tests/test_video_mongo.py | 2 +-
.../courseware/tests/test_videoalpha_mongo.py | 2 +-
lms/djangoapps/courseware/tests/tests.py | 9 +-
.../instructor/tests/test_gradebook.py | 10 +-
.../instructor_task/tests/test_base.py | 4 +-
.../instructor_task/tests/test_integration.py | 4 +-
lms/djangoapps/open_ended_grading/tests.py | 2 +-
rakelib/django.rake | 5 -
109 files changed, 807 insertions(+), 789 deletions(-)
delete mode 100644 cms/djangoapps/contentstore/management/commands/update_templates.py
delete mode 100644 common/lib/xmodule/xmodule/templates/about/empty.yaml
delete mode 100644 common/lib/xmodule/xmodule/templates/annotatable/default.yaml
delete mode 100644 common/lib/xmodule/xmodule/templates/combinedopenended/default.yaml
delete mode 100644 common/lib/xmodule/xmodule/templates/course/empty.yaml
delete mode 100644 common/lib/xmodule/xmodule/templates/courseinfo/empty.yaml
delete mode 100644 common/lib/xmodule/xmodule/templates/default/empty.yaml
delete mode 100644 common/lib/xmodule/xmodule/templates/discussion/default.yaml
delete mode 100644 common/lib/xmodule/xmodule/templates/html/empty.yaml
delete mode 100644 common/lib/xmodule/xmodule/templates/html/everything.yaml
delete mode 100644 common/lib/xmodule/xmodule/templates/peer_grading/default.yaml
delete mode 100644 common/lib/xmodule/xmodule/templates/problem/empty.yaml
delete mode 100644 common/lib/xmodule/xmodule/templates/problem/emptyadvanced.yaml
delete mode 100644 common/lib/xmodule/xmodule/templates/sequence/with_video.yaml
delete mode 100644 common/lib/xmodule/xmodule/templates/statictab/empty.yaml
delete mode 100644 common/lib/xmodule/xmodule/templates/video/default.yaml
delete mode 100644 common/lib/xmodule/xmodule/templates/videoalpha/default.yaml
delete mode 100644 common/lib/xmodule/xmodule/templates/word_cloud/default.yaml
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 4d117a9c73..04c8a5baae 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -43,6 +43,13 @@ history of background tasks for a given problem and student.
Blades: Small UX fix on capa multiple-choice problems. Make labels only
as wide as the text to reduce accidental choice selections.
+Studio:
+- use xblock field defaults to initialize all new instances' fields and
+only use templates as override samples.
+- create new instances via in memory create_xmodule and related methods rather
+than cloning a db record.
+- have an explicit method for making a draft copy as distinct from making a new module.
+
Studio: Remove XML from the video component editor. All settings are
moved to be edited as metadata.
diff --git a/README.md b/README.md
index e533459c8b..2208fe1cad 100644
--- a/README.md
+++ b/README.md
@@ -239,7 +239,6 @@ CMS templates. Fortunately, `rake` will do all of this for you! Just run:
$ rake django-admin[syncdb]
$ rake django-admin[migrate]
- $ rake cms:update_templates
If you are running these commands using the [`zsh`](http://www.zsh.org/) shell,
zsh will assume that you are doing
diff --git a/cms/djangoapps/contentstore/course_info_model.py b/cms/djangoapps/contentstore/course_info_model.py
index ada3873992..7e1e6470ff 100644
--- a/cms/djangoapps/contentstore/course_info_model.py
+++ b/cms/djangoapps/contentstore/course_info_model.py
@@ -20,8 +20,8 @@ def get_course_updates(location):
try:
course_updates = modulestore('direct').get_item(location)
except ItemNotFoundError:
- template = Location(['i4x', 'edx', "templates", 'course_info', "Empty"])
- course_updates = modulestore('direct').clone_item(template, Location(location))
+ modulestore('direct').create_and_save_xmodule(location)
+ course_updates = modulestore('direct').get_item(location)
# current db rep: {"_id" : locationjson, "definition" : { "data" : "[
date
content
]"} "metadata" : ignored}
location_base = course_updates.location.url()
diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py
index cb24af47e0..756adad7c4 100644
--- a/cms/djangoapps/contentstore/features/common.py
+++ b/cms/djangoapps/contentstore/features/common.py
@@ -208,7 +208,7 @@ def set_date_and_time(date_css, desired_date, time_css, desired_time):
def i_created_a_video_component(step):
world.create_component_instance(
step, '.large-video-icon',
- 'i4x://edx/templates/video/default',
+ 'video',
'.xmodule_VideoModule'
)
diff --git a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py
index 43164f62be..2f1788c6a4 100644
--- a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py
+++ b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py
@@ -7,9 +7,9 @@ from terrain.steps import reload_the_page
@world.absorb
-def create_component_instance(step, component_button_css, instance_id, expected_css):
+def create_component_instance(step, component_button_css, category, expected_css, boilerplate=None):
click_new_component_button(step, component_button_css)
- click_component_from_menu(instance_id, expected_css)
+ click_component_from_menu(category, boilerplate, expected_css)
@world.absorb
@@ -19,7 +19,7 @@ def click_new_component_button(step, component_button_css):
@world.absorb
-def click_component_from_menu(instance_id, expected_css):
+def click_component_from_menu(category, boilerplate, expected_css):
"""
Creates a component from `instance_id`. For components with more
than one template, clicks on `elem_css` to create the new
@@ -27,11 +27,13 @@ def click_component_from_menu(instance_id, expected_css):
as the user clicks the appropriate button, so we assert that the
expected component is present.
"""
- elem_css = "a[data-location='%s']" % instance_id
+ if boilerplate:
+ elem_css = "a[data-category='{}'][data-boilerplate='{}']".format(category, boilerplate)
+ else:
+ elem_css = "a[data-category='{}']:not([data-boilerplate])".format(category)
elements = world.css_find(elem_css)
- assert(len(elements) == 1)
- if elements[0]['id'] == instance_id: # If this is a component with multiple templates
- world.css_click(elem_css)
+ assert_equal(len(elements), 1)
+ world.css_click(elem_css)
assert_equal(1, len(world.css_find(expected_css)))
diff --git a/cms/djangoapps/contentstore/features/discussion-editor.py b/cms/djangoapps/contentstore/features/discussion-editor.py
index a4a4b71668..8e4becb62e 100644
--- a/cms/djangoapps/contentstore/features/discussion-editor.py
+++ b/cms/djangoapps/contentstore/features/discussion-editor.py
@@ -8,7 +8,7 @@ from lettuce import world, step
def i_created_discussion_tag(step):
world.create_component_instance(
step, '.large-discussion-icon',
- 'i4x://edx/templates/discussion/Discussion_Tag',
+ 'discussion',
'.xmodule_DiscussionModule'
)
@@ -26,5 +26,5 @@ def i_see_only_the_settings_and_values(step):
@step('creating a discussion takes a single click')
def discussion_takes_a_single_click(step):
assert(not world.is_css_present('.xmodule_DiscussionModule'))
- world.css_click("a[data-location='i4x://edx/templates/discussion/Discussion_Tag']")
+ world.css_click("a[data-category='discussion']")
assert(world.is_css_present('.xmodule_DiscussionModule'))
diff --git a/cms/djangoapps/contentstore/features/html-editor.py b/cms/djangoapps/contentstore/features/html-editor.py
index 53462ba094..b03388c89a 100644
--- a/cms/djangoapps/contentstore/features/html-editor.py
+++ b/cms/djangoapps/contentstore/features/html-editor.py
@@ -7,7 +7,7 @@ from lettuce import world, step
@step('I have created a Blank HTML Page$')
def i_created_blank_html_page(step):
world.create_component_instance(
- step, '.large-html-icon', 'i4x://edx/templates/html/Blank_HTML_Page',
+ step, '.large-html-icon', 'html',
'.xmodule_HtmlModule'
)
diff --git a/cms/djangoapps/contentstore/features/problem-editor.py b/cms/djangoapps/contentstore/features/problem-editor.py
index 15f5da95e9..99b693225d 100644
--- a/cms/djangoapps/contentstore/features/problem-editor.py
+++ b/cms/djangoapps/contentstore/features/problem-editor.py
@@ -158,7 +158,7 @@ def create_latex_problem(step):
world.click_new_component_button(step, '.large-problem-icon')
# Go to advanced tab.
world.css_click('#ui-id-2')
- world.click_component_from_menu("i4x://edx/templates/problem/Problem_Written_in_LaTeX", '.xmodule_CapaModule')
+ world.click_component_from_menu("problem", "latex_problem.yaml", '.xmodule_CapaModule')
@step('I edit and compile the High Level Source')
diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py
index 9ab17fbdac..41e39513ea 100644
--- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py
+++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py
@@ -22,7 +22,7 @@ def have_a_course_with_1_section(step):
section = world.ItemFactory.create(parent_location=course.location)
subsection1 = world.ItemFactory.create(
parent_location=section.location,
- template='i4x://edx/templates/sequential/Empty',
+ category='sequential',
display_name='Subsection One',)
@@ -33,18 +33,18 @@ def have_a_course_with_two_sections(step):
section = world.ItemFactory.create(parent_location=course.location)
subsection1 = world.ItemFactory.create(
parent_location=section.location,
- template='i4x://edx/templates/sequential/Empty',
+ category='sequential',
display_name='Subsection One',)
section2 = world.ItemFactory.create(
parent_location=course.location,
display_name='Section Two',)
subsection2 = world.ItemFactory.create(
parent_location=section2.location,
- template='i4x://edx/templates/sequential/Empty',
+ category='sequential',
display_name='Subsection Alpha',)
subsection3 = world.ItemFactory.create(
parent_location=section2.location,
- template='i4x://edx/templates/sequential/Empty',
+ category='sequential',
display_name='Subsection Beta',)
diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py
index cb59193f17..a6a362befc 100644
--- a/cms/djangoapps/contentstore/features/video.py
+++ b/cms/djangoapps/contentstore/features/video.py
@@ -14,7 +14,7 @@ def does_not_autoplay(_step):
@step('creating a video takes a single click')
def video_takes_a_single_click(_step):
assert(not world.is_css_present('.xmodule_VideoModule'))
- world.css_click("a[data-location='i4x://edx/templates/video/default']")
+ world.css_click("a[data-category='video']")
assert(world.is_css_present('.xmodule_VideoModule'))
diff --git a/cms/djangoapps/contentstore/management/commands/update_templates.py b/cms/djangoapps/contentstore/management/commands/update_templates.py
deleted file mode 100644
index 36348314b9..0000000000
--- a/cms/djangoapps/contentstore/management/commands/update_templates.py
+++ /dev/null
@@ -1,10 +0,0 @@
-from xmodule.templates import update_templates
-from xmodule.modulestore.django import modulestore
-from django.core.management.base import BaseCommand
-
-
-class Command(BaseCommand):
- help = 'Imports and updates the Studio component templates from the code pack and put in the DB'
-
- def handle(self, *args, **options):
- update_templates(modulestore('direct'))
diff --git a/cms/djangoapps/contentstore/module_info_model.py b/cms/djangoapps/contentstore/module_info_model.py
index 726d4bb0ce..bce4b0326c 100644
--- a/cms/djangoapps/contentstore/module_info_model.py
+++ b/cms/djangoapps/contentstore/module_info_model.py
@@ -3,13 +3,13 @@ from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore import Location
-def get_module_info(store, location, parent_location=None, rewrite_static_links=False):
+def get_module_info(store, location, rewrite_static_links=False):
try:
module = store.get_item(location)
except ItemNotFoundError:
# create a new one
- template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty'])
- module = store.clone_item(template_location, location)
+ store.create_and_save_xmodule(location)
+ module = store.get_item(location)
data = module.data
if rewrite_static_links:
@@ -29,7 +29,8 @@ def get_module_info(store, location, parent_location=None, rewrite_static_links=
'id': module.location.url(),
'data': data,
# TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata
- 'metadata': module._model_data._kvs._metadata
+ # what's the intent here? all metadata incl inherited & namespaced?
+ 'metadata': module.xblock_kvs._metadata
}
@@ -37,14 +38,11 @@ def set_module_info(store, location, post_data):
module = None
try:
module = store.get_item(location)
- except:
- pass
-
- if module is None:
- # new module at this location
- # presume that we have an 'Empty' template
- template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty'])
- module = store.clone_item(template_location, location)
+ except ItemNotFoundError:
+ # new module at this location: almost always used for the course about pages; thus, no parent. (there
+ # are quite a handful of about page types available for a course and only the overview is pre-created)
+ store.create_and_save_xmodule(location)
+ module = store.get_item(location)
if post_data.get('data') is not None:
data = post_data['data']
@@ -79,4 +77,4 @@ def set_module_info(store, location, post_data):
# commit to datastore
# TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata
- store.update_metadata(location, module._model_data._kvs._metadata)
+ store.update_metadata(location, module.xblock_kvs._metadata)
diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py
index 6099b60eb1..fa7c45cb1d 100644
--- a/cms/djangoapps/contentstore/tests/test_contentstore.py
+++ b/cms/djangoapps/contentstore/tests/test_contentstore.py
@@ -24,12 +24,11 @@ from auth.authz import add_user_to_creator_group
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
-from xmodule.modulestore import Location
+from xmodule.modulestore import Location, mongo
from xmodule.modulestore.store_utilities import clone_course
from xmodule.modulestore.store_utilities import delete_course
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore, _CONTENTSTORE
-from xmodule.templates import update_templates
from xmodule.modulestore.xml_exporter import export_to_xml
from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint
from xmodule.modulestore.inheritance import own_metadata
@@ -183,7 +182,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None])
- draft_store.clone_item(html_module.location, html_module.location)
+ draft_store.convert_to_draft(html_module.location)
# now query get_items() to get this location with revision=None, this should just
# return back a single item (not 2)
@@ -215,7 +214,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
self.assertEqual(html_module.lms.graceperiod, course.lms.graceperiod)
self.assertNotIn('graceperiod', own_metadata(html_module))
- draft_store.clone_item(html_module.location, html_module.location)
+ draft_store.convert_to_draft(html_module.location)
# refetch to check metadata
html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None])
@@ -233,7 +232,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
self.assertNotIn('graceperiod', own_metadata(html_module))
# put back in draft and change metadata and see if it's now marked as 'own_metadata'
- draft_store.clone_item(html_module.location, html_module.location)
+ draft_store.convert_to_draft(html_module.location)
html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None])
new_graceperiod = timedelta(hours=1)
@@ -255,7 +254,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
draft_store.publish(html_module.location, 0)
# and re-read and verify 'own-metadata'
- draft_store.clone_item(html_module.location, html_module.location)
+ draft_store.convert_to_draft(html_module.location)
html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None])
self.assertIn('graceperiod', own_metadata(html_module))
@@ -278,7 +277,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
)
# put into draft
- modulestore('draft').clone_item(problem.location, problem.location)
+ modulestore('draft').convert_to_draft(problem.location)
# make sure we can query that item and verify that it is a draft
draft_problem = modulestore('draft').get_item(
@@ -574,7 +573,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
def test_clone_course(self):
course_data = {
- 'template': 'i4x://edx/templates/course/Empty',
'org': 'MITx',
'number': '999',
'display_name': 'Robot Super Course',
@@ -614,10 +612,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
location = Location('i4x://MITx/999/chapter/neuvo')
- self.assertRaises(InvalidVersionError, draft_store.clone_item, 'i4x://edx/templates/chapter/Empty',
- location)
- direct_store.clone_item('i4x://edx/templates/chapter/Empty', location)
- self.assertRaises(InvalidVersionError, draft_store.clone_item, location, location)
+ # Ensure draft mongo store does not allow us to create chapters either directly or via convert to draft
+ self.assertRaises(InvalidVersionError, draft_store.create_and_save_xmodule, location)
+ direct_store.create_and_save_xmodule(location)
+ self.assertRaises(InvalidVersionError, draft_store.convert_to_draft, location)
self.assertRaises(InvalidVersionError, draft_store.update_item, location, 'chapter data')
@@ -687,26 +685,35 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
import_from_xml(module_store, 'common/test/data/', ['toy'])
location = CourseDescriptor.id_to_location('edX/toy/2012_Fall')
- # get a vertical (and components in it) to put into 'draft'
- vertical = module_store.get_item(Location(['i4x', 'edX', 'toy',
- 'vertical', 'vertical_test', None]), depth=1)
-
- draft_store.clone_item(vertical.location, vertical.location)
-
+ # get a vertical (and components in it) to copy into an orphan sub dag
+ vertical = module_store.get_item(
+ Location(['i4x', 'edX', 'toy', 'vertical', 'vertical_test', None]),
+ depth=1
+ )
# We had a bug where orphaned draft nodes caused export to fail. This is here to cover that case.
- draft_store.clone_item(vertical.location, Location(['i4x', 'edX', 'toy',
- 'vertical', 'no_references', 'draft']))
+ vertical.location = mongo.draft.as_draft(vertical.location.replace(name='no_references'))
+ draft_store.save_xmodule(vertical)
+ orphan_vertical = draft_store.get_item(vertical.location)
+ self.assertEqual(orphan_vertical.location.name, 'no_references')
+ # get the original vertical (and components in it) to put into 'draft'
+ vertical = module_store.get_item(
+ Location(['i4x', 'edX', 'toy', 'vertical', 'vertical_test', None]),
+ depth=1)
+ self.assertEqual(len(orphan_vertical.children), len(vertical.children))
+ draft_store.convert_to_draft(vertical.location)
for child in vertical.get_children():
- draft_store.clone_item(child.location, child.location)
+ draft_store.convert_to_draft(child.location)
root_dir = path(mkdtemp_clean())
- # now create a private vertical
- private_vertical = draft_store.clone_item(vertical.location,
- Location(['i4x', 'edX', 'toy', 'vertical', 'a_private_vertical', None]))
+ # now create a new/different private (draft only) vertical
+ vertical.location = mongo.draft.as_draft(Location(['i4x', 'edX', 'toy', 'vertical', 'a_private_vertical', None]))
+ draft_store.save_xmodule(vertical)
+ private_vertical = draft_store.get_item(vertical.location)
+ vertical = None # blank out b/c i destructively manipulated its location 2 lines above
- # add private to list of children
+ # add the new private to list of children
sequential = module_store.get_item(Location(['i4x', 'edX', 'toy',
'sequential', 'vertical_sequential', None]))
private_location_no_draft = private_vertical.location.replace(revision=None)
@@ -885,7 +892,6 @@ class ContentStoreTest(ModuleStoreTestCase):
self.client.login(username=uname, password=password)
self.course_data = {
- 'template': 'i4x://edx/templates/course/Empty',
'org': 'MITx',
'number': '999',
'display_name': 'Robot Super Course',
@@ -1029,17 +1035,17 @@ class ContentStoreTest(ModuleStoreTestCase):
html=True
)
- def test_clone_item(self):
+ def test_create_item(self):
"""Test cloning an item. E.g. creating a new section"""
CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
section_data = {
'parent_location': 'i4x://MITx/999/course/Robot_Super_Course',
- 'template': 'i4x://edx/templates/chapter/Empty',
+ 'category': 'chapter',
'display_name': 'Section One',
}
- resp = self.client.post(reverse('clone_item'), section_data)
+ resp = self.client.post(reverse('create_item'), section_data)
self.assertEqual(resp.status_code, 200)
data = parse_json(resp)
@@ -1054,14 +1060,14 @@ class ContentStoreTest(ModuleStoreTestCase):
problem_data = {
'parent_location': 'i4x://MITx/999/course/Robot_Super_Course',
- 'template': 'i4x://edx/templates/problem/Blank_Common_Problem'
+ 'category': 'problem'
}
- resp = self.client.post(reverse('clone_item'), problem_data)
+ resp = self.client.post(reverse('create_item'), problem_data)
self.assertEqual(resp.status_code, 200)
payload = parse_json(resp)
- problem_loc = payload['id']
+ problem_loc = Location(payload['id'])
problem = get_modulestore(problem_loc).get_item(problem_loc)
# should be a CapaDescriptor
self.assertIsInstance(problem, CapaDescriptor, "New problem is not a CapaDescriptor")
@@ -1194,10 +1200,9 @@ class ContentStoreTest(ModuleStoreTestCase):
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
new_component_location = Location('i4x', 'edX', '999', 'discussion', 'new_component')
- source_template_location = Location('i4x', 'edx', 'templates', 'discussion', 'Discussion_Tag')
# crate a new module and add it as a child to a vertical
- module_store.clone_item(source_template_location, new_component_location)
+ module_store.create_and_save_xmodule(new_component_location)
new_discussion_item = module_store.get_item(new_component_location)
@@ -1218,10 +1223,9 @@ class ContentStoreTest(ModuleStoreTestCase):
module_store.modulestore_update_signal.connect(_signal_hander)
new_component_location = Location('i4x', 'edX', '999', 'html', 'new_component')
- source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page')
# crate a new module
- module_store.clone_item(source_template_location, new_component_location)
+ module_store.create_and_save_xmodule(new_component_location)
finally:
module_store.modulestore_update_signal = None
@@ -1239,14 +1243,14 @@ class ContentStoreTest(ModuleStoreTestCase):
# let's assert on the metadata_inheritance on an existing vertical
for vertical in verticals:
self.assertEqual(course.lms.xqa_key, vertical.lms.xqa_key)
+ self.assertEqual(course.start, vertical.lms.start)
self.assertGreater(len(verticals), 0)
new_component_location = Location('i4x', 'edX', 'toy', 'html', 'new_component')
- source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page')
# crate a new module and add it as a child to a vertical
- module_store.clone_item(source_template_location, new_component_location)
+ module_store.create_and_save_xmodule(new_component_location)
parent = verticals[0]
module_store.update_children(parent.location, parent.children + [new_component_location.url()])
@@ -1256,6 +1260,8 @@ class ContentStoreTest(ModuleStoreTestCase):
# check for grace period definition which should be defined at the course level
self.assertEqual(parent.lms.graceperiod, new_module.lms.graceperiod)
+ self.assertEqual(parent.lms.start, new_module.lms.start)
+ self.assertEqual(course.start, new_module.lms.start)
self.assertEqual(course.lms.xqa_key, new_module.lms.xqa_key)
@@ -1293,29 +1299,3 @@ class ContentStoreTest(ModuleStoreTestCase):
self.assertEqual(course.textbooks, fetched_course.textbooks)
# is this test too strict? i.e., it requires the dicts to be ==
self.assertEqual(course.checklists, fetched_course.checklists)
-
-class TemplateTestCase(ModuleStoreTestCase):
-
- def test_template_cleanup(self):
- module_store = modulestore('direct')
-
- # insert a bogus template in the store
- bogus_template_location = Location('i4x', 'edx', 'templates', 'html', 'bogus')
- source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page')
-
- module_store.clone_item(source_template_location, bogus_template_location)
-
- verify_create = module_store.get_item(bogus_template_location)
- self.assertIsNotNone(verify_create)
-
- # now run cleanup
- update_templates(modulestore('direct'))
-
- # now try to find dangling template, it should not be in DB any longer
- asserted = False
- try:
- verify_create = module_store.get_item(bogus_template_location)
- except ItemNotFoundError:
- asserted = True
-
- self.assertTrue(asserted)
diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py
index 6c23e68240..fc04ad0a58 100644
--- a/cms/djangoapps/contentstore/tests/test_course_settings.py
+++ b/cms/djangoapps/contentstore/tests/test_course_settings.py
@@ -19,6 +19,7 @@ from xmodule.modulestore.tests.factories import CourseFactory
from models.settings.course_metadata import CourseMetadata
from xmodule.modulestore.xml_importer import import_from_xml
+from xmodule.modulestore.django import modulestore
from xmodule.fields import Date
from .utils import CourseTestCase
diff --git a/cms/djangoapps/contentstore/tests/test_i18n.py b/cms/djangoapps/contentstore/tests/test_i18n.py
index a292b7316e..88df19ec2d 100644
--- a/cms/djangoapps/contentstore/tests/test_i18n.py
+++ b/cms/djangoapps/contentstore/tests/test_i18n.py
@@ -35,7 +35,6 @@ class InternationalizationTest(ModuleStoreTestCase):
self.user.save()
self.course_data = {
- 'template': 'i4x://edx/templates/course/Empty',
'org': 'MITx',
'number': '999',
'display_name': 'Robot Super Course',
diff --git a/cms/djangoapps/contentstore/tests/test_item.py b/cms/djangoapps/contentstore/tests/test_item.py
index 4e6c951d9b..2b514c0726 100644
--- a/cms/djangoapps/contentstore/tests/test_item.py
+++ b/cms/djangoapps/contentstore/tests/test_item.py
@@ -1,6 +1,9 @@
from contentstore.tests.test_course_settings import CourseTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from django.core.urlresolvers import reverse
+from xmodule.capa_module import CapaDescriptor
+import json
+from xmodule.modulestore.django import modulestore
class DeleteItem(CourseTestCase):
@@ -11,14 +14,199 @@ class DeleteItem(CourseTestCase):
def testDeleteStaticPage(self):
# Add static tab
- data = {
+ data = json.dumps({
'parent_location': 'i4x://mitX/333/course/Dummy_Course',
- 'template': 'i4x://edx/templates/static_tab/Empty'
- }
+ 'category': 'static_tab'
+ })
- resp = self.client.post(reverse('clone_item'), data)
+ resp = self.client.post(reverse('create_item'), data,
+ content_type="application/json")
self.assertEqual(resp.status_code, 200)
# Now delete it. There was a bug that the delete was failing (static tabs do not exist in draft modulestore).
resp = self.client.post(reverse('delete_item'), resp.content, "application/json")
self.assertEqual(resp.status_code, 200)
+
+
+class TestCreateItem(CourseTestCase):
+ """
+ Test the create_item handler thoroughly
+ """
+ def response_id(self, response):
+ """
+ Get the id from the response payload
+ :param response:
+ """
+ parsed = json.loads(response.content)
+ return parsed['id']
+
+ def test_create_nicely(self):
+ """
+ Try the straightforward use cases
+ """
+ # create a chapter
+ display_name = 'Nicely created'
+ resp = self.client.post(
+ reverse('create_item'),
+ json.dumps({
+ 'parent_location': self.course_location.url(),
+ 'display_name': display_name,
+ 'category': 'chapter'
+ }),
+ content_type="application/json"
+ )
+ self.assertEqual(resp.status_code, 200)
+
+ # get the new item and check its category and display_name
+ chap_location = self.response_id(resp)
+ new_obj = modulestore().get_item(chap_location)
+ self.assertEqual(new_obj.category, 'chapter')
+ self.assertEqual(new_obj.display_name, display_name)
+ self.assertEqual(new_obj.location.org, self.course_location.org)
+ self.assertEqual(new_obj.location.course, self.course_location.course)
+
+ # get the course and ensure it now points to this one
+ course = modulestore().get_item(self.course_location)
+ self.assertIn(chap_location, course.children)
+
+ # use default display name
+ resp = self.client.post(
+ reverse('create_item'),
+ json.dumps({
+ 'parent_location': chap_location,
+ 'category': 'vertical'
+ }),
+ content_type="application/json"
+ )
+ self.assertEqual(resp.status_code, 200)
+
+ vert_location = self.response_id(resp)
+
+ # create problem w/ boilerplate
+ template_id = 'multiplechoice.yaml'
+ resp = self.client.post(
+ reverse('create_item'),
+ json.dumps({
+ 'parent_location': vert_location,
+ 'category': 'problem',
+ 'boilerplate': template_id
+ }),
+ content_type="application/json"
+ )
+ self.assertEqual(resp.status_code, 200)
+ prob_location = self.response_id(resp)
+ problem = modulestore('draft').get_item(prob_location)
+ # ensure it's draft
+ self.assertTrue(problem.is_draft)
+ # check against the template
+ template = CapaDescriptor.get_template(template_id)
+ self.assertEqual(problem.data, template['data'])
+ self.assertEqual(problem.display_name, template['metadata']['display_name'])
+ self.assertEqual(problem.markdown, template['metadata']['markdown'])
+
+ def test_create_item_negative(self):
+ """
+ Negative tests for create_item
+ """
+ # non-existent boilerplate: creates a default
+ resp = self.client.post(
+ reverse('create_item'),
+ json.dumps(
+ {'parent_location': self.course_location.url(),
+ 'category': 'problem',
+ 'boilerplate': 'nosuchboilerplate.yaml'
+ }),
+ content_type="application/json"
+ )
+ self.assertEqual(resp.status_code, 200)
+
+class TestEditItem(CourseTestCase):
+ """
+ Test contentstore.views.item.save_item
+ """
+ def response_id(self, response):
+ """
+ Get the id from the response payload
+ :param response:
+ """
+ parsed = json.loads(response.content)
+ return parsed['id']
+
+ def setUp(self):
+ """ Creates the test course structure and a couple problems to 'edit'. """
+ super(TestEditItem, self).setUp()
+ # create a chapter
+ display_name = 'chapter created'
+ resp = self.client.post(
+ reverse('create_item'),
+ json.dumps(
+ {'parent_location': self.course_location.url(),
+ 'display_name': display_name,
+ 'category': 'chapter'
+ }),
+ content_type="application/json"
+ )
+ chap_location = self.response_id(resp)
+ resp = self.client.post(
+ reverse('create_item'),
+ json.dumps(
+ {'parent_location': chap_location,
+ 'category': 'vertical'
+ }),
+ content_type="application/json"
+ )
+ vert_location = self.response_id(resp)
+ # create problem w/ boilerplate
+ template_id = 'multiplechoice.yaml'
+ resp = self.client.post(
+ reverse('create_item'),
+ json.dumps({'parent_location': vert_location,
+ 'category': 'problem',
+ 'boilerplate': template_id
+ }),
+ content_type="application/json"
+ )
+ self.problems = [self.response_id(resp)]
+
+ def test_delete_field(self):
+ """
+ Sending null in for a field 'deletes' it
+ """
+ self.client.post(
+ reverse('save_item'),
+ json.dumps({
+ 'id': self.problems[0],
+ 'metadata': {'rerandomize': 'onreset'}
+ }),
+ content_type="application/json"
+ )
+ problem = modulestore('draft').get_item(self.problems[0])
+ self.assertEqual(problem.rerandomize, 'onreset')
+ self.client.post(
+ reverse('save_item'),
+ json.dumps({
+ 'id': self.problems[0],
+ 'metadata': {'rerandomize': None}
+ }),
+ content_type="application/json"
+ )
+ problem = modulestore('draft').get_item(self.problems[0])
+ self.assertEqual(problem.rerandomize, 'never')
+
+
+ def test_null_field(self):
+ """
+ Sending null in for a field 'deletes' it
+ """
+ problem = modulestore('draft').get_item(self.problems[0])
+ self.assertIsNotNone(problem.markdown)
+ self.client.post(
+ reverse('save_item'),
+ json.dumps({
+ 'id': self.problems[0],
+ 'nullout': ['markdown']
+ }),
+ content_type="application/json"
+ )
+ problem = modulestore('draft').get_item(self.problems[0])
+ self.assertIsNo/ne(problem.markdown)
diff --git a/cms/djangoapps/contentstore/tests/utils.py b/cms/djangoapps/contentstore/tests/utils.py
index bc9e9e8bae..a3f211a703 100644
--- a/cms/djangoapps/contentstore/tests/utils.py
+++ b/cms/djangoapps/contentstore/tests/utils.py
@@ -54,7 +54,6 @@ class CourseTestCase(ModuleStoreTestCase):
self.client.login(username=uname, password=password)
self.course = CourseFactory.create(
- template='i4x://edx/templates/course/Empty',
org='MITx',
number='999',
display_name='Robot Super Course',
diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py
index 5fa0d949b0..4973bddaca 100644
--- a/cms/djangoapps/contentstore/utils.py
+++ b/cms/djangoapps/contentstore/utils.py
@@ -19,14 +19,14 @@ NOTES_PANEL = {"name": _("My Notes"), "type": "notes"}
EXTRA_TAB_PANELS = dict([(p['type'], p) for p in [OPEN_ENDED_PANEL, NOTES_PANEL]])
-def get_modulestore(location):
+def get_modulestore(category_or_location):
"""
Returns the correct modulestore to use for modifying the specified location
"""
- if not isinstance(location, Location):
- location = Location(location)
+ if isinstance(category_or_location, Location):
+ category_or_location = category_or_location.category
- if location.category in DIRECT_ONLY_CATEGORIES:
+ if category_or_location in DIRECT_ONLY_CATEGORIES:
return modulestore('direct')
else:
return modulestore()
diff --git a/cms/djangoapps/contentstore/views/checklist.py b/cms/djangoapps/contentstore/views/checklist.py
index fa0a7b7b62..fdb5857ba7 100644
--- a/cms/djangoapps/contentstore/views/checklist.py
+++ b/cms/djangoapps/contentstore/views/checklist.py
@@ -7,11 +7,11 @@ from django.views.decorators.http import require_http_methods
from django_future.csrf import ensure_csrf_cookie
from mitxmako.shortcuts import render_to_response
-from xmodule.modulestore import Location
from xmodule.modulestore.inheritance import own_metadata
from ..utils import get_modulestore, get_url_reverse
from .access import get_location_and_verify_access
+from xmodule.course_module import CourseDescriptor
__all__ = ['get_checklists', 'update_checklist']
@@ -28,13 +28,11 @@ def get_checklists(request, org, course, name):
modulestore = get_modulestore(location)
course_module = modulestore.get_item(location)
- new_course_template = Location('i4x', 'edx', 'templates', 'course', 'Empty')
- template_module = modulestore.get_item(new_course_template)
# If course was created before checklists were introduced, copy them over from the template.
copied = False
if not course_module.checklists:
- course_module.checklists = template_module.checklists
+ course_module.checklists = CourseDescriptor.checklists.default
copied = True
checklists, modified = expand_checklist_action_urls(course_module)
diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py
index 30958d5866..13eca522dd 100644
--- a/cms/djangoapps/contentstore/views/component.py
+++ b/cms/djangoapps/contentstore/views/component.py
@@ -26,6 +26,8 @@ from models.settings.course_grading import CourseGradingModel
from .requests import _xmodule_recurse
from .access import has_access
+from xmodule.x_module import XModuleDescriptor
+from xblock.plugin import PluginMissingError
__all__ = ['OPEN_ENDED_COMPONENT_TYPES',
'ADVANCED_COMPONENT_POLICY_KEY',
@@ -101,7 +103,7 @@ def edit_subsection(request, location):
return render_to_response('edit_subsection.html',
{'subsection': item,
'context_course': course,
- 'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
+ 'new_unit_category': 'vertical',
'lms_link': lms_link,
'preview_link': preview_link,
'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
@@ -134,10 +136,27 @@ def edit_unit(request, location):
item = modulestore().get_item(location, depth=1)
except ItemNotFoundError:
return HttpResponseBadRequest()
-
lms_link = get_lms_link_for_item(item.location, course_id=course.location.course_id)
component_templates = defaultdict(list)
+ for category in COMPONENT_TYPES:
+ component_class = XModuleDescriptor.load_class(category)
+ # add the default template
+ has_markdown = hasattr(component_class, 'markdown') and component_class.markdown is not None
+ component_templates[category].append((
+ component_class.display_name.default or 'Blank',
+ category,
+ has_markdown,
+ None # no boilerplate for overrides
+ ))
+ # add boilerplates
+ for template in component_class.templates():
+ component_templates[category].append((
+ template['metadata'].get('display_name'),
+ category,
+ template['metadata'].get('markdown') is not None,
+ template.get('template_id')
+ ))
# Check if there are any advanced modules specified in the course policy. These modules
# should be specified as a list of strings, where the strings are the names of the modules
@@ -145,29 +164,29 @@ def edit_unit(request, location):
course_advanced_keys = course.advanced_modules
# Set component types according to course policy file
- component_types = list(COMPONENT_TYPES)
if isinstance(course_advanced_keys, list):
- course_advanced_keys = [c for c in course_advanced_keys if c in ADVANCED_COMPONENT_TYPES]
- if len(course_advanced_keys) > 0:
- component_types.append(ADVANCED_COMPONENT_CATEGORY)
+ for category in course_advanced_keys:
+ if category in ADVANCED_COMPONENT_TYPES:
+ # Do I need to allow for boilerplates or just defaults on the class? i.e., can an advanced
+ # have more than one entry in the menu? one for default and others for prefilled boilerplates?
+ try:
+ component_class = XModuleDescriptor.load_class(category)
+
+ component_templates['advanced'].append((
+ component_class.display_name.default or category,
+ category,
+ hasattr(component_class, 'markdown') and component_class.markdown is not None,
+ None # don't override default data
+ ))
+ except PluginMissingError:
+ # dhm: I got this once but it can happen any time the course author configures
+ # an advanced component which does not exist on the server. This code here merely
+ # prevents any authors from trying to instantiate the non-existent component type
+ # by not showing it in the menu
+ pass
else:
log.error("Improper format for course advanced keys! {0}".format(course_advanced_keys))
- templates = modulestore().get_items(Location('i4x', 'edx', 'templates'))
- for template in templates:
- category = template.location.category
-
- if category in course_advanced_keys:
- category = ADVANCED_COMPONENT_CATEGORY
-
- if category in component_types:
- # This is a hack to create categories for different xmodules
- component_templates[category].append((
- template.display_name_with_default,
- template.location.url(),
- hasattr(template, 'markdown') and template.markdown is not None
- ))
-
components = [
component.location.url()
for component
@@ -219,7 +238,7 @@ def edit_unit(request, location):
'subsection': containing_subsection,
'release_date': get_default_time_display(containing_subsection.lms.start) if containing_subsection.lms.start is not None else None,
'section': containing_section,
- 'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
+ 'new_unit_category': 'vertical',
'unit_state': unit_state,
'published_date': get_default_time_display(item.cms.published_date) if item.cms.published_date is not None else None
})
@@ -253,7 +272,7 @@ def create_draft(request):
# This clones the existing item location to a draft location (the draft is implicit,
# because modulestore is a Draft modulestore)
- modulestore().clone_item(location, location)
+ modulestore().convert_to_draft(location)
return HttpResponse()
diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py
index f8de053d95..4c95d6e06e 100644
--- a/cms/djangoapps/contentstore/views/course.py
+++ b/cms/djangoapps/contentstore/views/course.py
@@ -82,10 +82,11 @@ def course_index(request, org, course, name):
'sections': sections,
'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
'parent_location': course.location,
- 'new_section_template': Location('i4x', 'edx', 'templates', 'chapter', 'Empty'),
- 'new_subsection_template': Location('i4x', 'edx', 'templates', 'sequential', 'Empty'), # for now they are the same, but the could be different at some point...
+ 'new_section_category': 'chapter',
+ 'new_subsection_category': 'sequential',
'upload_asset_callback_url': upload_asset_callback_url,
- 'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty')
+ 'new_unit_category': 'vertical',
+ 'category': 'vertical'
})
@@ -98,12 +99,6 @@ def create_new_course(request):
if not is_user_in_creator_group(request.user):
raise PermissionDenied()
- # This logic is repeated in xmodule/modulestore/tests/factories.py
- # so if you change anything here, you need to also change it there.
- # TODO: write a test that creates two courses, one with the factory and
- # the other with this method, then compare them to make sure they are
- # equivalent.
- template = Location(request.POST['template'])
org = request.POST.get('org')
number = request.POST.get('number')
display_name = request.POST.get('display_name')
@@ -121,29 +116,26 @@ def create_new_course(request):
existing_course = modulestore('direct').get_item(dest_location)
except ItemNotFoundError:
pass
-
if existing_course is not None:
return JsonResponse({'ErrMsg': 'There is already a course defined with this name.'})
course_search_location = ['i4x', dest_location.org, dest_location.course, 'course', None]
courses = modulestore().get_items(course_search_location)
-
if len(courses) > 0:
return JsonResponse({'ErrMsg': 'There is already a course defined with the same organization and course number.'})
- new_course = modulestore('direct').clone_item(template, dest_location)
+ # instantiate the CourseDescriptor and then persist it
+ # note: no system to pass
+ if display_name is None:
+ metadata = {}
+ else:
+ metadata = {'display_name': display_name}
+ modulestore('direct').create_and_save_xmodule(dest_location, metadata=metadata)
+ new_course = modulestore('direct').get_item(dest_location)
# clone a default 'about' module as well
-
- about_template_location = Location(['i4x', 'edx', 'templates', 'about', 'overview'])
dest_about_location = dest_location._replace(category='about', name='overview')
- modulestore('direct').clone_item(about_template_location, dest_about_location)
-
- if display_name is not None:
- new_course.display_name = display_name
-
- # set a default start date to now
- new_course.start = datetime.datetime.now(UTC())
+ modulestore('direct').create_and_save_xmodule(dest_about_location, system=new_course.system)
initialize_course_tabs(new_course)
diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py
index abc5f48564..90dae10c23 100644
--- a/cms/djangoapps/contentstore/views/item.py
+++ b/cms/djangoapps/contentstore/views/item.py
@@ -13,16 +13,26 @@ from util.json_request import expect_json
from ..utils import get_modulestore
from .access import has_access
from .requests import _xmodule_recurse
+from xmodule.x_module import XModuleDescriptor
-__all__ = ['save_item', 'clone_item', 'delete_item']
+__all__ = ['save_item', 'create_item', 'delete_item']
# cdodge: these are categories which should not be parented, they are detached from the hierarchy
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
-
@login_required
@expect_json
def save_item(request):
+ """
+ Will carry a json payload with these possible fields
+ :id (required): the id
+ :data (optional): the new value for the data
+ :metadata (optional): new values for the metadata fields.
+ Any whose values are None will be deleted not set to None! Absent ones will be left alone
+ :nullout (optional): which metadata fields to set to None
+ """
+ # The nullout is a bit of a temporary copout until we can make module_edit.coffee and the metadata editors a
+ # little smarter and able to pass something more akin to {unset: [field, field]}
item_location = request.POST['id']
# check permissions for this user within this course
@@ -42,30 +52,25 @@ def save_item(request):
children = request.POST['children']
store.update_children(item_location, children)
- # cdodge: also commit any metadata which might have been passed along in the
- # POST from the client, if it is there
- # NOTE, that the postback is not the complete metadata, as there's system metadata which is
- # not presented to the end-user for editing. So let's fetch the original and
- # 'apply' the submitted metadata, so we don't end up deleting system metadata
- if request.POST.get('metadata') is not None:
- posted_metadata = request.POST['metadata']
- # fetch original
+ # cdodge: also commit any metadata which might have been passed along
+ if request.POST.get('nullout') is not None or request.POST.get('metadata') is not None:
+ # the postback is not the complete metadata, as there's system metadata which is
+ # not presented to the end-user for editing. So let's fetch the original and
+ # 'apply' the submitted metadata, so we don't end up deleting system metadata
existing_item = modulestore().get_item(item_location)
+ for metadata_key in request.POST.get('nullout', []):
+ setattr(existing_item, metadata_key, None)
# update existing metadata with submitted metadata (which can be partial)
- # IMPORTANT NOTE: if the client passed pack 'null' (None) for a piece of metadata that means 'remove it'
- for metadata_key, value in posted_metadata.items():
+ # IMPORTANT NOTE: if the client passed 'null' (None) for a piece of metadata that means 'remove it'. If
+ # the intent is to make it None, use the nullout field
+ for metadata_key, value in request.POST.get('metadata', {}).items():
- if posted_metadata[metadata_key] is None:
- # remove both from passed in collection as well as the collection read in from the modulestore
- if metadata_key in existing_item._model_data:
- del existing_item._model_data[metadata_key]
- del posted_metadata[metadata_key]
+ if value is None:
+ delattr(existing_item, metadata_key)
else:
- existing_item._model_data[metadata_key] = value
-
+ setattr(existing_item, metadata_key, value)
# commit to datastore
- # TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata
store.update_metadata(item_location, own_metadata(existing_item))
return HttpResponse()
@@ -73,28 +78,38 @@ def save_item(request):
@login_required
@expect_json
-def clone_item(request):
+def create_item(request):
parent_location = Location(request.POST['parent_location'])
- template = Location(request.POST['template'])
+ category = request.POST['category']
display_name = request.POST.get('display_name')
if not has_access(request.user, parent_location):
raise PermissionDenied()
- parent = get_modulestore(template).get_item(parent_location)
- dest_location = parent_location._replace(category=template.category, name=uuid4().hex)
+ parent = get_modulestore(category).get_item(parent_location)
+ dest_location = parent_location.replace(category=category, name=uuid4().hex)
- new_item = get_modulestore(template).clone_item(template, dest_location)
+ # get the metadata, display_name, and definition from the request
+ metadata = {}
+ data = None
+ template_id = request.POST.get('boilerplate')
+ if template_id is not None:
+ clz = XModuleDescriptor.load_class(category)
+ if clz is not None:
+ template = clz.get_template(template_id)
+ if template is not None:
+ metadata = template.get('metadata', {})
+ data = template.get('data')
- # replace the display name with an optional parameter passed in from the caller
if display_name is not None:
- new_item.display_name = display_name
+ metadata['display_name'] = display_name
- get_modulestore(template).update_metadata(new_item.location.url(), own_metadata(new_item))
+ get_modulestore(category).create_and_save_xmodule(dest_location, definition_data=data,
+ metadata=metadata, system=parent.system)
- if new_item.location.category not in DETACHED_CATEGORIES:
- get_modulestore(parent.location).update_children(parent_location, parent.children + [new_item.location.url()])
+ if category not in DETACHED_CATEGORIES:
+ get_modulestore(parent.location).update_children(parent_location, parent.children + [dest_location.url()])
return HttpResponse(json.dumps({'id': dest_location.url()}))
diff --git a/cms/djangoapps/contentstore/views/user.py b/cms/djangoapps/contentstore/views/user.py
index 948ed614d2..ee6b0bf84d 100644
--- a/cms/djangoapps/contentstore/views/user.py
+++ b/cms/djangoapps/contentstore/views/user.py
@@ -27,6 +27,7 @@ def index(request):
# filter out courses that we don't have access too
def course_filter(course):
return (has_access(request.user, course.location)
+ # TODO remove this condition when templates purged from db
and course.location.course != 'templates'
and course.location.org != ''
and course.location.course != ''
@@ -34,7 +35,6 @@ def index(request):
courses = filter(course_filter, courses)
return render_to_response('index.html', {
- 'new_course_template': Location('i4x', 'edx', 'templates', 'course', 'Empty'),
'courses': [(course.display_name,
get_url_reverse('CourseOutline', course),
get_lms_link_for_item(course.location, course_id=course.location.course_id))
diff --git a/cms/djangoapps/models/settings/course_grading.py b/cms/djangoapps/models/settings/course_grading.py
index 4ea9f2f5db..e529a284c6 100644
--- a/cms/djangoapps/models/settings/course_grading.py
+++ b/cms/djangoapps/models/settings/course_grading.py
@@ -9,7 +9,7 @@ class CourseGradingModel(object):
"""
def __init__(self, course_descriptor):
self.course_location = course_descriptor.location
- self.graders = [CourseGradingModel.jsonize_grader(i, grader) for i, grader in enumerate(course_descriptor.raw_grader)] # weights transformed to ints [0..100]
+ self.graders = [CourseGradingModel.jsonize_grader(i, grader) for i, grader in enumerate(course_descriptor.raw_grader)] # weights transformed to ints [0..100]
self.grade_cutoffs = course_descriptor.grade_cutoffs
self.grace_period = CourseGradingModel.convert_set_grace_period(course_descriptor)
@@ -81,7 +81,7 @@ class CourseGradingModel(object):
Decode the json into CourseGradingModel and save any changes. Returns the modified model.
Probably not the usual path for updates as it's too coarse grained.
"""
- course_location = jsondict['course_location']
+ course_location = Location(jsondict['course_location'])
descriptor = get_modulestore(course_location).get_item(course_location)
graders_parsed = [CourseGradingModel.parse_grader(jsonele) for jsonele in jsondict['graders']]
@@ -89,7 +89,7 @@ class CourseGradingModel(object):
descriptor.raw_grader = graders_parsed
descriptor.grade_cutoffs = jsondict['grade_cutoffs']
- get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
+ get_modulestore(course_location).update_item(course_location, descriptor.xblock_kvs._data)
CourseGradingModel.update_grace_period_from_json(course_location, jsondict['grace_period'])
return CourseGradingModel.fetch(course_location)
@@ -209,7 +209,7 @@ class CourseGradingModel(object):
descriptor = get_modulestore(location).get_item(location)
return {"graderType": descriptor.lms.format if descriptor.lms.format is not None else 'Not Graded',
"location": location,
- "id": 99 # just an arbitrary value to
+ "id": 99 # just an arbitrary value to
}
@staticmethod
@@ -232,7 +232,7 @@ class CourseGradingModel(object):
# 5 hours 59 minutes 59 seconds => converted to iso format
rawgrace = descriptor.lms.graceperiod
if rawgrace:
- hours_from_days = rawgrace.days*24
+ hours_from_days = rawgrace.days * 24
seconds = rawgrace.seconds
hours_from_seconds = int(seconds / 3600)
hours = hours_from_days + hours_from_seconds
diff --git a/cms/static/coffee/src/views/module_edit.coffee b/cms/static/coffee/src/views/module_edit.coffee
index 5154591d6f..62083fa26d 100644
--- a/cms/static/coffee/src/views/module_edit.coffee
+++ b/cms/static/coffee/src/views/module_edit.coffee
@@ -56,14 +56,15 @@ class CMS.Views.ModuleEdit extends Backbone.View
changedMetadata: ->
return _.extend(@metadataEditor.getModifiedMetadataValues(), @customMetadata())
- cloneTemplate: (parent, template) ->
- $.post("/clone_item", {
- parent_location: parent
- template: template
- }, (data) =>
- @model.set(id: data.id)
- @$el.data('id', data.id)
- @render()
+ createItem: (parent, payload) ->
+ payload.parent_location = parent
+ $.post(
+ "/create_item"
+ payload
+ (data) =>
+ @model.set(id: data.id)
+ @$el.data('id', data.id)
+ @render()
)
render: ->
diff --git a/cms/static/coffee/src/views/tabs.coffee b/cms/static/coffee/src/views/tabs.coffee
index 1034fc988e..58f52f27a3 100644
--- a/cms/static/coffee/src/views/tabs.coffee
+++ b/cms/static/coffee/src/views/tabs.coffee
@@ -55,9 +55,9 @@ class CMS.Views.TabsEdit extends Backbone.View
editor.$el.removeClass('new')
, 500)
- editor.cloneTemplate(
+ editor.createItem(
@model.get('id'),
- 'i4x://edx/templates/static_tab/Empty'
+ {category: 'static_tab'}
)
analytics.track "Added Static Page",
diff --git a/cms/static/coffee/src/views/unit.coffee b/cms/static/coffee/src/views/unit.coffee
index 058bcf0ce1..774ef04f6d 100644
--- a/cms/static/coffee/src/views/unit.coffee
+++ b/cms/static/coffee/src/views/unit.coffee
@@ -89,9 +89,9 @@ class CMS.Views.UnitEdit extends Backbone.View
@$newComponentItem.before(editor.$el)
- editor.cloneTemplate(
+ editor.createItem(
@$el.data('id'),
- $(event.currentTarget).data('location')
+ $(event.currentTarget).data()
)
analytics.track "Added a Component",
diff --git a/cms/static/js/base.js b/cms/static/js/base.js
index b53d116808..3d8cd7684e 100644
--- a/cms/static/js/base.js
+++ b/cms/static/js/base.js
@@ -338,7 +338,7 @@ function createNewUnit(e) {
e.preventDefault();
var parent = $(this).data('parent');
- var template = $(this).data('template');
+ var category = $(this).data('category');
analytics.track('Created a Unit', {
'course': course_location_analytics,
@@ -346,9 +346,9 @@ function createNewUnit(e) {
});
- $.post('/clone_item', {
+ $.post('/create_item', {
'parent_location': parent,
- 'template': template,
+ 'category': category,
'display_name': 'New Unit'
},
@@ -551,7 +551,7 @@ function saveNewSection(e) {
var $saveButton = $(this).find('.new-section-name-save');
var parent = $saveButton.data('parent');
- var template = $saveButton.data('template');
+ var category = $saveButton.data('category');
var display_name = $(this).find('.new-section-name').val();
analytics.track('Created a Section', {
@@ -559,9 +559,9 @@ function saveNewSection(e) {
'display_name': display_name
});
- $.post('/clone_item', {
+ $.post('/create_item', {
'parent_location': parent,
- 'template': template,
+ 'category': category,
'display_name': display_name,
},
@@ -595,7 +595,6 @@ function saveNewCourse(e) {
e.preventDefault();
var $newCourse = $(this).closest('.new-course');
- var template = $(this).find('.new-course-save').data('template');
var org = $newCourse.find('.new-course-org').val();
var number = $newCourse.find('.new-course-number').val();
var display_name = $newCourse.find('.new-course-name').val();
@@ -612,7 +611,6 @@ function saveNewCourse(e) {
});
$.post('/create_new_course', {
- 'template': template,
'org': org,
'number': number,
'display_name': display_name
@@ -646,7 +644,7 @@ function addNewSubsection(e) {
var parent = $(this).parents("section.branch").data("id");
$saveButton.data('parent', parent);
- $saveButton.data('template', $(this).data('template'));
+ $saveButton.data('category', $(this).data('category'));
$newSubsection.find('.new-subsection-form').bind('submit', saveNewSubsection);
$cancelButton.bind('click', cancelNewSubsection);
@@ -659,7 +657,7 @@ function saveNewSubsection(e) {
e.preventDefault();
var parent = $(this).find('.new-subsection-name-save').data('parent');
- var template = $(this).find('.new-subsection-name-save').data('template');
+ var category = $(this).find('.new-subsection-name-save').data('category');
var display_name = $(this).find('.new-subsection-name-input').val();
analytics.track('Created a Subsection', {
@@ -668,9 +666,9 @@ function saveNewSubsection(e) {
});
- $.post('/clone_item', {
+ $.post('/create_item', {
'parent_location': parent,
- 'template': template,
+ 'category': category,
'display_name': display_name
},
diff --git a/cms/templates/index.html b/cms/templates/index.html
index 57921641ce..f0baef4f09 100644
--- a/cms/templates/index.html
+++ b/cms/templates/index.html
@@ -25,8 +25,8 @@
-
-
+
+
diff --git a/cms/templates/overview.html b/cms/templates/overview.html
index ab7788c64a..56836b00ad 100644
--- a/cms/templates/overview.html
+++ b/cms/templates/overview.html
@@ -1,4 +1,4 @@
-<%! from django.utils.translation import ugettext as _ %>
+/<%! from django.utils.translation import ugettext as _ %>
<%inherit file="base.html" />
<%!
import logging
@@ -66,7 +66,8 @@
@@ -83,8 +84,9 @@
Click here to set the section name
+ % for name, category, has_markdown, boilerplate_name in sorted(templates):
+ % if has_markdown or type != "problem":
+ % if boilerplate_name is None:
+
+ % endif
% endfor
diff --git a/cms/templates/widgets/units.html b/cms/templates/widgets/units.html
index 5ac05e79eb..62c1fb62d7 100644
--- a/cms/templates/widgets/units.html
+++ b/cms/templates/widgets/units.html
@@ -34,7 +34,7 @@ This def will enumerate through a passed in subsection and list all of the units
% endfor
diff --git a/cms/urls.py b/cms/urls.py
index 6db07dc2ed..711742d89f 100644
--- a/cms/urls.py
+++ b/cms/urls.py
@@ -17,7 +17,7 @@ urlpatterns = ('', # nopep8
url(r'^preview_component/(?P.*?)$', 'contentstore.views.preview_component', name='preview_component'),
url(r'^save_item$', 'contentstore.views.save_item', name='save_item'),
url(r'^delete_item$', 'contentstore.views.delete_item', name='delete_item'),
- url(r'^clone_item$', 'contentstore.views.clone_item', name='clone_item'),
+ url(r'^create_item$', 'contentstore.views.create_item', name='create_item'),
url(r'^create_draft$', 'contentstore.views.create_draft', name='create_draft'),
url(r'^publish_draft$', 'contentstore.views.publish_draft', name='publish_draft'),
url(r'^unpublish_unit$', 'contentstore.views.unpublish_unit', name='unpublish_unit'),
diff --git a/common/djangoapps/terrain/course_helpers.py b/common/djangoapps/terrain/course_helpers.py
index 27bf95099d..c62b1a1e79 100644
--- a/common/djangoapps/terrain/course_helpers.py
+++ b/common/djangoapps/terrain/course_helpers.py
@@ -12,7 +12,6 @@ from django.contrib.sessions.middleware import SessionMiddleware
from student.models import CourseEnrollment
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore
-from xmodule.templates import update_templates
from urllib import quote_plus
@@ -84,5 +83,4 @@ def clear_courses():
# from the bash shell to drop it:
# $ mongo test_xmodule --eval "db.dropDatabase()"
modulestore().collection.drop()
- update_templates(modulestore('direct'))
contentstore().fs_files.drop()
diff --git a/common/djangoapps/tests.py b/common/djangoapps/tests.py
index 8e78ee7f37..4a61a106d4 100644
--- a/common/djangoapps/tests.py
+++ b/common/djangoapps/tests.py
@@ -23,15 +23,15 @@ class TestXmoduleModfiers(ModuleStoreTestCase):
number='313', display_name='histogram test')
section = ItemFactory.create(
parent_location=course.location, display_name='chapter hist',
- template='i4x://edx/templates/chapter/Empty')
+ category='chapter')
problem = ItemFactory.create(
parent_location=section.location, display_name='problem hist 1',
- template='i4x://edx/templates/problem/Blank_Common_Problem')
+ category='problem')
problem.has_score = False # don't trip trying to retrieve db data
late_problem = ItemFactory.create(
parent_location=section.location, display_name='problem hist 2',
- template='i4x://edx/templates/problem/Blank_Common_Problem')
+ category='problem')
late_problem.lms.start = datetime.datetime.now(UTC) + datetime.timedelta(days=32)
late_problem.has_score = False
diff --git a/common/lib/xmodule/xmodule/abtest_module.py b/common/lib/xmodule/xmodule/abtest_module.py
index 2e61076e94..53f080eb3a 100644
--- a/common/lib/xmodule/xmodule/abtest_module.py
+++ b/common/lib/xmodule/xmodule/abtest_module.py
@@ -80,8 +80,6 @@ class ABTestModule(ABTestFields, XModule):
class ABTestDescriptor(ABTestFields, RawDescriptor, XmlDescriptor):
module_class = ABTestModule
- template_dir_name = "abtest"
-
@classmethod
def definition_from_xml(cls, xml_object, system):
"""
diff --git a/common/lib/xmodule/xmodule/annotatable_module.py b/common/lib/xmodule/xmodule/annotatable_module.py
index 8b1bbc71d3..f80e3e488e 100644
--- a/common/lib/xmodule/xmodule/annotatable_module.py
+++ b/common/lib/xmodule/xmodule/annotatable_module.py
@@ -150,5 +150,4 @@ class AnnotatableModule(AnnotatableFields, XModule):
class AnnotatableDescriptor(AnnotatableFields, RawDescriptor):
module_class = AnnotatableModule
- template_dir_name = "annotatable"
mako_template = "widgets/raw-edit.html"
diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py
index 13f00bb77e..60103a2126 100644
--- a/common/lib/xmodule/xmodule/combined_open_ended_module.py
+++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py
@@ -290,7 +290,6 @@ class CombinedOpenEndedDescriptor(CombinedOpenEndedFields, RawDescriptor):
has_score = True
always_recalculate_grades = True
- template_dir_name = "combinedopenended"
#Specify whether or not to pass in S3 interface
needs_s3_interface = True
diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py
index ceadee1c15..d4aac5b0ae 100644
--- a/common/lib/xmodule/xmodule/course_module.py
+++ b/common/lib/xmodule/xmodule/course_module.py
@@ -366,8 +366,6 @@ class CourseFields(object):
class CourseDescriptor(CourseFields, SequenceDescriptor):
module_class = SequenceModule
- template_dir_name = 'course'
-
def __init__(self, *args, **kwargs):
"""
Expects the same arguments as XModuleDescriptor.__init__
diff --git a/common/lib/xmodule/xmodule/error_module.py b/common/lib/xmodule/xmodule/error_module.py
index a37081c447..db1f998187 100644
--- a/common/lib/xmodule/xmodule/error_module.py
+++ b/common/lib/xmodule/xmodule/error_module.py
@@ -96,6 +96,7 @@ class ErrorDescriptor(ErrorFields, JSONEditingDescriptor):
'contents': contents,
'display_name': 'Error: ' + location.name,
'location': location,
+ 'category': 'error'
}
return cls(
system,
@@ -109,12 +110,12 @@ class ErrorDescriptor(ErrorFields, JSONEditingDescriptor):
}
@classmethod
- def from_json(cls, json_data, system, error_msg='Error not available'):
+ def from_json(cls, json_data, system, location, error_msg='Error not available'):
return cls._construct(
system,
- json.dumps(json_data, indent=4),
+ json.dumps(json_data, skipkeys=False, indent=4),
error_msg,
- location=Location(json_data['location']),
+ location=location
)
@classmethod
diff --git a/common/lib/xmodule/xmodule/foldit_module.py b/common/lib/xmodule/xmodule/foldit_module.py
index fdab14b58d..c4e9e2d35c 100644
--- a/common/lib/xmodule/xmodule/foldit_module.py
+++ b/common/lib/xmodule/xmodule/foldit_module.py
@@ -184,7 +184,6 @@ class FolditDescriptor(FolditFields, XmlDescriptor, EditingDescriptor):
filename_extension = "xml"
has_score = True
- template_dir_name = "foldit"
js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]}
js_module_name = "HTMLEditingDescriptor"
diff --git a/common/lib/xmodule/xmodule/gst_module.py b/common/lib/xmodule/xmodule/gst_module.py
index e101d90b4c..5a4930ff95 100644
--- a/common/lib/xmodule/xmodule/gst_module.py
+++ b/common/lib/xmodule/xmodule/gst_module.py
@@ -141,7 +141,6 @@ class GraphicalSliderToolModule(GraphicalSliderToolFields, XModule):
class GraphicalSliderToolDescriptor(GraphicalSliderToolFields, MakoModuleDescriptor, XmlDescriptor):
module_class = GraphicalSliderToolModule
- template_dir_name = 'graphical_slider_tool'
@classmethod
def definition_from_xml(cls, xml_object, system):
diff --git a/common/lib/xmodule/xmodule/html_module.py b/common/lib/xmodule/xmodule/html_module.py
index 9ff2e4d9a8..2c7c9e0b01 100644
--- a/common/lib/xmodule/xmodule/html_module.py
+++ b/common/lib/xmodule/xmodule/html_module.py
@@ -234,7 +234,7 @@ class StaticTabDescriptor(StaticTabFields, HtmlDescriptor):
These pieces of course content are treated as HtmlModules but we need to overload where the templates are located
in order to be able to create new ones
"""
- template_dir_name = "statictab"
+ template_dir_name = None
module_class = StaticTabModule
@@ -261,5 +261,5 @@ class CourseInfoDescriptor(CourseInfoFields, HtmlDescriptor):
These pieces of course content are treated as HtmlModules but we need to overload where the templates are located
in order to be able to create new ones
"""
- template_dir_name = "courseinfo"
+ template_dir_name = None
module_class = CourseInfoModule
diff --git a/common/lib/xmodule/xmodule/js/spec/combinedopenended/edit_spec.coffee b/common/lib/xmodule/xmodule/js/spec/combinedopenended/edit_spec.coffee
index aa077da450..d859a59dda 100644
--- a/common/lib/xmodule/xmodule/js/spec/combinedopenended/edit_spec.coffee
+++ b/common/lib/xmodule/xmodule/js/spec/combinedopenended/edit_spec.coffee
@@ -11,13 +11,13 @@ describe 'OpenEndedMarkdownEditingDescriptor', ->
@descriptor = new OpenEndedMarkdownEditingDescriptor($('.combinedopenended-editor'))
@descriptor.createXMLEditor('replace with markdown')
saveResult = @descriptor.save()
- expect(saveResult.metadata.markdown).toEqual(null)
+ expect(saveResult.nullout).toEqual(['markdown'])
expect(saveResult.data).toEqual('replace with markdown')
it 'saves xml from the xml editor', ->
loadFixtures 'combinedopenended-without-markdown.html'
@descriptor = new OpenEndedMarkdownEditingDescriptor($('.combinedopenended-editor'))
saveResult = @descriptor.save()
- expect(saveResult.metadata.markdown).toEqual(null)
+ expect(saveResult.nullout).toEqual(['markdown'])
expect(saveResult.data).toEqual('xml only')
describe 'insertPrompt', ->
diff --git a/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee b/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee
index 5161e658e7..1df9587037 100644
--- a/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee
+++ b/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee
@@ -11,13 +11,13 @@ describe 'MarkdownEditingDescriptor', ->
@descriptor = new MarkdownEditingDescriptor($('.problem-editor'))
@descriptor.createXMLEditor('replace with markdown')
saveResult = @descriptor.save()
- expect(saveResult.metadata.markdown).toEqual(null)
+ expect(saveResult.nullout).toEqual(['markdown'])
expect(saveResult.data).toEqual('replace with markdown')
it 'saves xml from the xml editor', ->
loadFixtures 'problem-without-markdown.html'
@descriptor = new MarkdownEditingDescriptor($('.problem-editor'))
saveResult = @descriptor.save()
- expect(saveResult.metadata.markdown).toEqual(null)
+ expect(saveResult.nullout).toEqual(['markdown'])
expect(saveResult.data).toEqual('xml only')
describe 'insertMultipleChoice', ->
diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/edit.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/edit.coffee
index 1b7f9bb4fb..4cdd571c65 100644
--- a/common/lib/xmodule/xmodule/js/src/combinedopenended/edit.coffee
+++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/edit.coffee
@@ -153,8 +153,7 @@ Write a persuasive essay to a newspaper reflecting your vies on censorship in li
else
{
data: @xml_editor.getValue()
- metadata:
- markdown: null
+ nullout: ['markdown']
}
@insertRubric: (selectedText) ->
diff --git a/common/lib/xmodule/xmodule/js/src/problem/edit.coffee b/common/lib/xmodule/xmodule/js/src/problem/edit.coffee
index b723f230e9..bd2871eb61 100644
--- a/common/lib/xmodule/xmodule/js/src/problem/edit.coffee
+++ b/common/lib/xmodule/xmodule/js/src/problem/edit.coffee
@@ -123,9 +123,8 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
}
else
{
- data: @xml_editor.getValue()
- metadata:
- markdown: null
+ data: @xml_editor.getValue()
+ nullout: ['markdown']
}
@insertMultipleChoice: (selectedText) ->
diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py
index 2fa12e2e90..eb721dfc99 100644
--- a/common/lib/xmodule/xmodule/modulestore/__init__.py
+++ b/common/lib/xmodule/xmodule/modulestore/__init__.py
@@ -310,14 +310,7 @@ class ModuleStore(object):
"""
raise NotImplementedError
- def clone_item(self, source, location):
- """
- Clone a new item that is a copy of the item at the location `source`
- and writes it to `location`
- """
- raise NotImplementedError
-
- def update_item(self, location, data):
+ def update_item(self, location, data, allow_not_found=False):
"""
Set the data in the item specified by the location to
data
diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/base.py b/common/lib/xmodule/xmodule/modulestore/mongo/base.py
index f56393d75e..0b1c601a2f 100644
--- a/common/lib/xmodule/xmodule/modulestore/mongo/base.py
+++ b/common/lib/xmodule/xmodule/modulestore/mongo/base.py
@@ -33,7 +33,7 @@ from xblock.runtime import DbModel, KeyValueStore, InvalidScopeError
from xblock.core import Scope
from xmodule.modulestore import ModuleStoreBase, Location, namedtuple_to_son
-from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateItemError
+from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.inheritance import own_metadata, INHERITABLE_METADATA, inherit_metadata
log = logging.getLogger(__name__)
@@ -62,11 +62,12 @@ class MongoKeyValueStore(KeyValueStore):
A KeyValueStore that maps keyed data access to one of the 3 data areas
known to the MongoModuleStore (data, children, and metadata)
"""
- def __init__(self, data, children, metadata, location):
+ def __init__(self, data, children, metadata, location, category):
self._data = data
self._children = children
self._metadata = metadata
self._location = location
+ self._category = category
def get(self, key):
if key.scope == Scope.children:
@@ -78,6 +79,8 @@ class MongoKeyValueStore(KeyValueStore):
elif key.scope == Scope.content:
if key.field_name == 'location':
return self._location
+ elif key.field_name == 'category':
+ return self._category
elif key.field_name == 'data' and not isinstance(self._data, dict):
return self._data
else:
@@ -93,6 +96,8 @@ class MongoKeyValueStore(KeyValueStore):
elif key.scope == Scope.content:
if key.field_name == 'location':
self._location = value
+ elif key.field_name == 'category':
+ self._category = value
elif key.field_name == 'data' and not isinstance(self._data, dict):
self._data = value
else:
@@ -109,6 +114,8 @@ class MongoKeyValueStore(KeyValueStore):
elif key.scope == Scope.content:
if key.field_name == 'location':
self._location = Location(None)
+ elif key.field_name == 'category':
+ self._category = None
elif key.field_name == 'data' and not isinstance(self._data, dict):
self._data = None
else:
@@ -123,7 +130,10 @@ class MongoKeyValueStore(KeyValueStore):
return key.field_name in self._metadata
elif key.scope == Scope.content:
if key.field_name == 'location':
+ # WHY TRUE? if it's been deleted should it be False?
return True
+ elif key.field_name == 'category':
+ return self._category is not None
elif key.field_name == 'data' and not isinstance(self._data, dict):
return True
else:
@@ -185,8 +195,9 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
else:
# load the module and apply the inherited metadata
try:
+ category = json_data['location']['category']
class_ = XModuleDescriptor.load_class(
- json_data['location']['category'],
+ category,
self.default_class
)
definition = json_data.get('definition', {})
@@ -201,9 +212,12 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
definition.get('children', []),
metadata,
location,
+ category
)
model_data = DbModel(kvs, class_, None, MongoUsage(self.course_id, location))
+ model_data['category'] = category
+ model_data['location'] = location
module = class_(self, model_data)
if self.cached_metadata is not None:
# parent container pointers don't differentiate between draft and non-draft
@@ -217,6 +231,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
return ErrorDescriptor.from_json(
json_data,
self,
+ json_data['location'],
error_msg=exc_info_to_str(sys.exc_info())
)
@@ -582,51 +597,97 @@ class MongoModuleStore(ModuleStoreBase):
modules = self._load_items(list(items), depth)
return modules
- def clone_item(self, source, location):
+ def create_xmodule(self, location, definition_data=None, metadata=None, system=None):
"""
- Clone a new item that is a copy of the item at the location `source`
- and writes it to `location`
+ Create the new xmodule but don't save it. Returns the new module.
+
+ :param location: a Location--must have a category
+ :param definition_data: can be empty. The initial definition_data for the kvs
+ :param metadata: can be empty, the initial metadata for the kvs
+ :param system: if you already have an xmodule from the course, the xmodule.system value
"""
- item = None
- try:
- source_item = self.collection.find_one(location_to_query(source))
-
- # allow for some programmatically generated substitutions in metadata, e.g. Discussion_id's should be auto-generated
- for key in source_item['metadata'].keys():
- if source_item['metadata'][key] == '$$GUID$$':
- source_item['metadata'][key] = uuid4().hex
-
- source_item['_id'] = Location(location).dict()
- 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
+ if not isinstance(location, Location):
+ location = Location(location)
+ # differs from split mongo in that I believe most of this logic should be above the persistence
+ # layer but added it here to enable quick conversion. I'll need to reconcile these.
+ if metadata is None:
+ metadata = {}
+ if system is None:
+ system = CachingDescriptorSystem(
+ self,
+ {},
+ self.default_class,
+ None,
+ self.error_tracker,
+ self.render_template,
+ {}
)
- item = self._load_items([source_item])[0]
+ xblock_class = XModuleDescriptor.load_class(location.category, self.default_class)
+ if definition_data is None:
+ if hasattr(xblock_class, 'data') and getattr(xblock_class, 'data').default is not None:
+ definition_data = getattr(xblock_class, 'data').default
+ else:
+ definition_data = {}
+ dbmodel = self._create_new_model_data(location.category, location, definition_data, metadata)
+ xmodule = xblock_class(system, dbmodel)
+ # force inherited fields w/ defaults to take the defaults so the children can inherit
+ for attr in INHERITABLE_METADATA:
+ if hasattr(xmodule, attr):
+ xmodule._model_data[attr] = getattr(xmodule, attr)
+ return xmodule
- # VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so
- # if we add one then we need to also add it to the policy information (i.e. metadata)
- # we should remove this once we can break this reference from the course to static tabs
- if location.category == 'static_tab':
- course = self.get_course_for_item(item.location)
- existing_tabs = course.tabs or []
- existing_tabs.append({
- 'type': 'static_tab',
- 'name': item.display_name,
- 'url_slug': item.location.name
- })
- course.tabs = existing_tabs
- self.update_metadata(course.location, course._model_data._kvs._metadata)
-
- except pymongo.errors.DuplicateKeyError:
- raise DuplicateItemError(location)
+ def save_xmodule(self, xmodule):
+ """
+ Save the given xmodule (will either create or update based on whether id already exists).
+ Pulls out the data definition v metadata v children locally but saves it all.
+ :param xmodule:
+ """
+ # split mongo's persist_dag is more general and useful.
+ self.collection.save({
+ '_id': xmodule.location.dict(),
+ 'metadata': own_metadata(xmodule),
+ 'definition': {
+ 'data': xmodule.xblock_kvs._data,
+ 'children': xmodule.children if xmodule.has_children else []
+ }
+ })
# recompute (and update) the metadata inheritance tree which is cached
- self.refresh_cached_metadata_inheritance_tree(Location(location))
- self.fire_updated_modulestore_signal(get_course_id_no_run(Location(location)), Location(location))
+ self.refresh_cached_metadata_inheritance_tree(xmodule.location)
+ self.fire_updated_modulestore_signal(get_course_id_no_run(xmodule.location), xmodule.location)
- return item
+ def create_and_save_xmodule(self, location, definition_data=None, metadata=None, system=None):
+ """
+ Create the new xmodule and save it. Does not return the new module because if the caller
+ will insert it as a child, it's inherited metadata will completely change. The difference
+ between this and just doing create_xmodule and save_xmodule is this ensures static_tabs get
+ pointed to by the course.
+
+ :param location: a Location--must have a category
+ :param definition_data: can be empty. The initial definition_data for the kvs
+ :param metadata: can be empty, the initial metadata for the kvs
+ :param system: if you already have an xmodule from the course, the xmodule.system value
+ """
+ # differs from split mongo in that I believe most of this logic should be above the persistence
+ # layer but added it here to enable quick conversion. I'll need to reconcile these.
+ new_object = self.create_xmodule(location, definition_data, metadata, system)
+ location = new_object.location
+ self.save_xmodule(new_object)
+
+ # VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so
+ # if we add one then we need to also add it to the policy information (i.e. metadata)
+ # we should remove this once we can break this reference from the course to static tabs
+ # TODO move this special casing to app tier (similar to attaching new element to parent)
+ if location.category == 'static_tab':
+ course = self.get_course_for_item(location)
+ existing_tabs = course.tabs or []
+ existing_tabs.append({
+ 'type': 'static_tab',
+ 'name': new_object.display_name,
+ 'url_slug': new_object.location.name
+ })
+ course.tabs = existing_tabs
+ self.update_metadata(course.location, course.xblock_kvs._metadata)
def fire_updated_modulestore_signal(self, course_id, location):
"""
@@ -683,7 +744,7 @@ class MongoModuleStore(ModuleStoreBase):
if result['n'] == 0:
raise ItemNotFoundError(location)
- def update_item(self, location, data):
+ def update_item(self, location, data, allow_not_found=False):
"""
Set the data in the item specified by the location to
data
@@ -691,8 +752,11 @@ class MongoModuleStore(ModuleStoreBase):
location: Something that can be passed to Location
data: A nested dictionary of problem data
"""
-
- self._update_single_item(location, {'definition.data': data})
+ try:
+ self._update_single_item(location, {'definition.data': data})
+ except ItemNotFoundError:
+ if not allow_not_found:
+ raise
def update_children(self, location, children):
"""
@@ -775,3 +839,24 @@ class MongoModuleStore(ModuleStoreBase):
are loaded on demand, rather than up front
"""
return {}
+
+ def _create_new_model_data(self, category, location, definition_data, metadata):
+ """
+ To instantiate a new xmodule which will be saved latter, set up the dbModel and kvs
+ """
+ kvs = MongoKeyValueStore(
+ definition_data,
+ [],
+ metadata,
+ location,
+ category
+ )
+
+ class_ = XModuleDescriptor.load_class(
+ category,
+ self.default_class
+ )
+ model_data = DbModel(kvs, class_, None, MongoUsage(None, location))
+ model_data['category'] = category
+ model_data['location'] = location
+ return model_data
diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/draft.py b/common/lib/xmodule/xmodule/modulestore/mongo/draft.py
index f34c3a53f9..d289e03739 100644
--- a/common/lib/xmodule/xmodule/modulestore/mongo/draft.py
+++ b/common/lib/xmodule/xmodule/modulestore/mongo/draft.py
@@ -8,11 +8,12 @@ and otherwise returns i4x://org/course/cat/name).
from datetime import datetime
-from xmodule.modulestore import Location, namedtuple_to_son
-from xmodule.modulestore.exceptions import ItemNotFoundError
-from xmodule.modulestore.inheritance import own_metadata
from xmodule.exceptions import InvalidVersionError
-from xmodule.modulestore.mongo.base import MongoModuleStore
+from xmodule.modulestore import Location, namedtuple_to_son
+from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateItemError
+from xmodule.modulestore.inheritance import own_metadata
+from xmodule.modulestore.mongo.base import location_to_query, get_course_id_no_run, MongoModuleStore
+import pymongo
from pytz import UTC
DRAFT = 'draft'
@@ -92,6 +93,21 @@ class DraftModuleStore(MongoModuleStore):
except ItemNotFoundError:
return wrap_draft(super(DraftModuleStore, self).get_instance(course_id, location, depth=depth))
+ def create_xmodule(self, location, definition_data=None, metadata=None, system=None):
+ """
+ Create the new xmodule but don't save it. Returns the new module with a draft locator
+
+ :param location: a Location--must have a category
+ :param definition_data: can be empty. The initial definition_data for the kvs
+ :param metadata: can be empty, the initial metadata for the kvs
+ :param system: if you already have an xmodule from the course, the xmodule.system value
+ """
+ draft_loc = as_draft(location)
+ if draft_loc.category in DIRECT_ONLY_CATEGORIES:
+ raise InvalidVersionError(location)
+ return super(DraftModuleStore, self).create_xmodule(draft_loc, definition_data, metadata, system)
+
+
def get_items(self, location, course_id=None, depth=0):
"""
Returns a list of XModuleDescriptor instances for the items
@@ -119,14 +135,26 @@ class DraftModuleStore(MongoModuleStore):
]
return [wrap_draft(item) for item in draft_items + non_draft_items]
- def clone_item(self, source, location):
+ def convert_to_draft(self, source_location):
"""
- Clone a new item that is a copy of the item at the location `source`
- and writes it to `location`
+ Create a copy of the source and mark its revision as draft.
+
+ :param source: the location of the source (its revision must be None)
"""
- if Location(location).category in DIRECT_ONLY_CATEGORIES:
- raise InvalidVersionError(location)
- return wrap_draft(super(DraftModuleStore, self).clone_item(source, as_draft(location)))
+ original = self.collection.find_one(location_to_query(source_location))
+ draft_location = as_draft(source_location)
+ if draft_location.category in DIRECT_ONLY_CATEGORIES:
+ raise InvalidVersionError(source_location)
+ original['_id'] = draft_location.dict()
+ try:
+ self.collection.insert(original)
+ except pymongo.errors.DuplicateKeyError:
+ raise DuplicateItemError(original['_id'])
+
+ self.refresh_cached_metadata_inheritance_tree(draft_location)
+ self.fire_updated_modulestore_signal(get_course_id_no_run(draft_location), draft_location)
+
+ return self._load_items([original])[0]
def update_item(self, location, data, allow_not_found=False):
"""
@@ -140,7 +168,7 @@ class DraftModuleStore(MongoModuleStore):
try:
draft_item = self.get_item(location)
if not getattr(draft_item, 'is_draft', False):
- self.clone_item(location, draft_loc)
+ self.convert_to_draft(location)
except ItemNotFoundError, e:
if not allow_not_found:
raise e
@@ -158,7 +186,7 @@ class DraftModuleStore(MongoModuleStore):
draft_loc = as_draft(location)
draft_item = self.get_item(location)
if not getattr(draft_item, 'is_draft', False):
- self.clone_item(location, draft_loc)
+ self.convert_to_draft(location)
return super(DraftModuleStore, self).update_children(draft_loc, children)
@@ -174,7 +202,7 @@ class DraftModuleStore(MongoModuleStore):
draft_item = self.get_item(location)
if not getattr(draft_item, 'is_draft', False):
- self.clone_item(location, draft_loc)
+ self.convert_to_draft(location)
if 'is_draft' in metadata:
del metadata['is_draft']
@@ -218,9 +246,7 @@ class DraftModuleStore(MongoModuleStore):
"""
Turn the published version into a draft, removing the published version
"""
- if Location(location).category in DIRECT_ONLY_CATEGORIES:
- raise InvalidVersionError(location)
- super(DraftModuleStore, self).clone_item(location, as_draft(location))
+ self.convert_to_draft(location)
super(DraftModuleStore, self).delete_item(location)
def _query_children_for_cache_children(self, items):
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py
index c32d0bca4c..564aac141d 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py
@@ -5,7 +5,6 @@ from django.test import TestCase
from django.conf import settings
import xmodule.modulestore.django
-from xmodule.templates import update_templates
from unittest.util import safe_repr
@@ -110,22 +109,6 @@ class ModuleStoreTestCase(TestCase):
modulestore.collection.remove(query)
modulestore.collection.drop()
- @staticmethod
- def load_templates_if_necessary():
- """
- Load templates into the direct modulestore only if they do not already exist.
- We need the templates, because they are copied to create
- XModules such as sections and problems.
- """
- modulestore = xmodule.modulestore.django.modulestore('direct')
-
- # Count the number of templates
- query = {"_id.course": "templates"}
- num_templates = modulestore.collection.find(query).count()
-
- if num_templates < 1:
- update_templates(modulestore)
-
@classmethod
def setUpClass(cls):
"""
@@ -169,9 +152,6 @@ class ModuleStoreTestCase(TestCase):
# Flush anything that is not a template
ModuleStoreTestCase.flush_mongo_except_templates()
- # Check that we have templates loaded; if not, load them
- ModuleStoreTestCase.load_templates_if_necessary()
-
# Call superclass implementation
super(ModuleStoreTestCase, self)._pre_setup()
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py
index 457a88482a..9a0c87ff97 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py
@@ -1,15 +1,13 @@
-from factory import Factory, lazy_attribute_sequence, lazy_attribute
-from uuid import uuid4
import datetime
+from factory import Factory, LazyAttributeSequence
+from uuid import uuid4
+from pytz import UTC
+
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
-from xmodule.modulestore.inheritance import own_metadata
-from xmodule.x_module import ModuleSystem
-from mitxmako.shortcuts import render_to_string
-from xblock.runtime import InvalidScopeError
-from pytz import UTC
-
+from xmodule.course_module import CourseDescriptor
+from xblock.core import Scope
class XModuleCourseFactory(Factory):
"""
@@ -21,9 +19,8 @@ class XModuleCourseFactory(Factory):
@classmethod
def _create(cls, target_class, **kwargs):
- template = Location('i4x', 'edx', 'templates', 'course', 'Empty')
org = kwargs.pop('org', None)
- number = kwargs.pop('number', None)
+ number = kwargs.pop('number', kwargs.pop('course', None))
display_name = kwargs.pop('display_name', None)
location = Location('i4x', org, number, 'course', Location.clean(display_name))
@@ -33,7 +30,7 @@ class XModuleCourseFactory(Factory):
store = modulestore()
# Write the data to the mongo datastore
- new_course = store.clone_item(template, location)
+ new_course = store.create_xmodule(location)
# This metadata code was copied from cms/djangoapps/contentstore/views.py
if display_name is not None:
@@ -56,13 +53,7 @@ class XModuleCourseFactory(Factory):
setattr(new_course, k, v)
# Update the data in the mongo datastore
- store.update_metadata(new_course.location, own_metadata(new_course))
- store.update_item(new_course.location, new_course._model_data._kvs._data)
-
- # update_item updates the the course as it exists in the modulestore, but doesn't
- # update the instance we are working with, so have to refetch the course after updating it.
- new_course = store.get_instance(new_course.id, new_course.location)
-
+ store.save_xmodule(new_course)
return new_course
@@ -73,7 +64,6 @@ class Course:
class CourseFactory(XModuleCourseFactory):
FACTORY_FOR = Course
- template = 'i4x://edx/templates/course/Empty'
org = 'MITx'
number = '999'
display_name = 'Robot Super Course'
@@ -86,18 +76,14 @@ class XModuleItemFactory(Factory):
ABSTRACT_FACTORY = True
- display_name = None
+ parent_location = 'i4x://MITx/999/course/Robot_Super_Course'
+ category = 'problem'
+ display_name = LazyAttributeSequence(lambda o, n: "{} {}".format(o.category, n))
- @lazy_attribute
- def category(attr):
- template = Location(attr.template)
- return template.category
-
- @lazy_attribute
- def location(attr):
- parent = Location(attr.parent_location)
- dest_name = attr.display_name.replace(" ", "_") if attr.display_name is not None else uuid4().hex
- return parent._replace(category=attr.category, name=dest_name)
+ @staticmethod
+ def location(parent, category, display_name):
+ dest_name = display_name.replace(" ", "_") if display_name is not None else uuid4().hex
+ return Location(parent).replace(category=category, name=dest_name)
@classmethod
def _create(cls, target_class, **kwargs):
@@ -107,8 +93,7 @@ class XModuleItemFactory(Factory):
*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)
+ category: the category of the resulting item.
*data* (optional): the data for the item
(e.g. XML problem definition for a problem item)
@@ -121,41 +106,32 @@ class XModuleItemFactory(Factory):
"""
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
-
+ # catch any old style users before they get into trouble
+ assert not 'template' in kwargs
parent_location = Location(kwargs.get('parent_location'))
- template = Location(kwargs.get('template'))
data = kwargs.get('data')
+ category = kwargs.get('category')
display_name = kwargs.get('display_name')
metadata = kwargs.get('metadata', {})
+ location = kwargs.get('location', XModuleItemFactory.location(parent_location, category, display_name))
+ assert location != parent_location
store = modulestore('direct')
# This code was based off that in cms/djangoapps/contentstore/views.py
parent = store.get_item(parent_location)
- new_item = store.clone_item(template, kwargs.get('location'))
-
# replace the display name with an optional parameter passed in from the caller
if display_name is not None:
- new_item.display_name = display_name
+ metadata['display_name'] = display_name
+ # note that location comes from above lazy_attribute
+ store.create_and_save_xmodule(location, metadata=metadata, definition_data=data)
- # 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)
+ if location.category not in DETACHED_CATEGORIES:
+ parent.children.append(location.url())
+ store.update_children(parent_location, parent.children)
- # 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()])
-
- # update_children updates the the item as it exists in the modulestore, but doesn't
- # update the instance we are working with, so have to refetch the item after updating it.
- new_item = store.get_item(new_item.location)
-
- return new_item
+ return store.get_item(location)
class Item:
@@ -164,40 +140,4 @@ class Item:
class ItemFactory(XModuleItemFactory):
FACTORY_FOR = Item
-
- parent_location = 'i4x://MITx/999/course/Robot_Super_Course'
- template = 'i4x://edx/templates/chapter/Empty'
-
- @lazy_attribute_sequence
- def display_name(attr, n):
- return "{} {}".format(attr.category.title(), n)
-
-
-def get_test_xmodule_for_descriptor(descriptor):
- """
- Attempts to create an xmodule which responds usually correctly from the descriptor. Not guaranteed.
-
- :param descriptor:
- """
- module_sys = ModuleSystem(
- ajax_url='',
- track_function=None,
- get_module=None,
- render_template=render_to_string,
- replace_urls=None,
- xblock_model_data=_test_xblock_model_data_accessor(descriptor)
- )
- return descriptor.xmodule(module_sys)
-
-
-def _test_xblock_model_data_accessor(descriptor):
- simple_map = {}
- for field in descriptor.fields:
- try:
- simple_map[field.name] = getattr(descriptor, field.name)
- except InvalidScopeError:
- simple_map[field.name] = field.default
- for field in descriptor.module_class.fields:
- if field.name not in simple_map:
- simple_map[field.name] = field.default
- return lambda o: simple_map
+ category = 'chapter'
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py
index 44e69fb0ed..c149724cc7 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py
@@ -9,7 +9,6 @@ from xblock.runtime import KeyValueStore, InvalidScopeError
from xmodule.modulestore import Location
from xmodule.modulestore.mongo import MongoModuleStore, MongoKeyValueStore
from xmodule.modulestore.xml_importer import import_from_xml
-from xmodule.templates import update_templates
from .test_modulestore import check_path_to_location
from . import DATA_DIR
@@ -51,7 +50,6 @@ class TestMongoModuleStore(object):
# Explicitly list the courses to load (don't want the big one)
courses = ['toy', 'simple']
import_from_xml(store, DATA_DIR, courses)
- update_templates(store)
return store
@staticmethod
@@ -126,7 +124,7 @@ class TestMongoKeyValueStore(object):
self.location = Location('i4x://org/course/category/name@version')
self.children = ['i4x://org/course/child/a', 'i4x://org/course/child/b']
self.metadata = {'meta': 'meta_val'}
- self.kvs = MongoKeyValueStore(self.data, self.children, self.metadata, self.location)
+ self.kvs = MongoKeyValueStore(self.data, self.children, self.metadata, self.location, 'category')
def _check_read(self, key, expected_value):
assert_equals(expected_value, self.kvs.get(key))
diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py
index 26c8b9bfca..012740ff9a 100644
--- a/common/lib/xmodule/xmodule/modulestore/xml.py
+++ b/common/lib/xmodule/xmodule/modulestore/xml.py
@@ -463,7 +463,10 @@ class XMLModuleStore(ModuleStoreBase):
# tabs are referenced in policy.json through a 'slug' which is just the filename without the .html suffix
slug = os.path.splitext(os.path.basename(filepath))[0]
loc = Location('i4x', course_descriptor.location.org, course_descriptor.location.course, category, slug)
- module = HtmlDescriptor(system, {'data': html, 'location': loc})
+ module = HtmlDescriptor(
+ system,
+ {'data': html, 'location': loc, 'category': category}
+ )
# VS[compat]:
# Hack because we need to pull in the 'display_name' for static tabs (because we need to edit them)
# from the course policy
diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
index 1fe62035e6..60eb9dc749 100644
--- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
+++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
@@ -810,7 +810,6 @@ class CombinedOpenEndedV1Descriptor():
filename_extension = "xml"
has_score = True
- template_dir_name = "combinedopenended"
def __init__(self, system):
self.system = system
diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py
index 0f0851fbf7..8d8a85f788 100644
--- a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py
+++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py
@@ -730,7 +730,6 @@ class OpenEndedDescriptor():
filename_extension = "xml"
has_score = True
- template_dir_name = "openended"
def __init__(self, system):
self.system = system
diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py
index a5498289e2..1262e1f68f 100644
--- a/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py
+++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py
@@ -287,7 +287,6 @@ class SelfAssessmentDescriptor():
filename_extension = "xml"
has_score = True
- template_dir_name = "selfassessment"
def __init__(self, system):
self.system = system
diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py
index c88a2e1b38..03003ed1e5 100644
--- a/common/lib/xmodule/xmodule/peer_grading_module.py
+++ b/common/lib/xmodule/xmodule/peer_grading_module.py
@@ -613,7 +613,6 @@ class PeerGradingDescriptor(PeerGradingFields, RawDescriptor):
has_score = True
always_recalculate_grades = True
- template_dir_name = "peer_grading"
#Specify whether or not to pass in open ended interface
needs_open_ended_interface = True
diff --git a/common/lib/xmodule/xmodule/poll_module.py b/common/lib/xmodule/xmodule/poll_module.py
index ca12f239ab..8e7407752a 100644
--- a/common/lib/xmodule/xmodule/poll_module.py
+++ b/common/lib/xmodule/xmodule/poll_module.py
@@ -140,7 +140,6 @@ class PollDescriptor(PollFields, MakoModuleDescriptor, XmlDescriptor):
_child_tag_name = 'answer'
module_class = PollModule
- template_dir_name = 'poll'
@classmethod
def definition_from_xml(cls, xml_object, system):
diff --git a/common/lib/xmodule/xmodule/templates.py b/common/lib/xmodule/xmodule/templates.py
index 6479b3df24..8e350bb618 100644
--- a/common/lib/xmodule/xmodule/templates.py
+++ b/common/lib/xmodule/xmodule/templates.py
@@ -1,34 +1,18 @@
"""
-This module handles loading xmodule templates from disk into the modulestore.
-These templates are used by the CMS to provide baseline content that
-can be cloned when adding new modules to a course.
+This module handles loading xmodule templates
+These templates are used by the CMS to provide content that overrides xmodule defaults for
+samples.
-`Template`s are defined in x_module. They contain 3 attributes:
- metadata: A dictionary with the template metadata. This should contain
- any values for fields
- * with scope Scope.settings
- * that have values different than the field defaults
- * and that are to be editable in Studio
- data: A JSON value that defines the template content. This should be a dictionary
- containing values for fields
- * with scope Scope.content
- * that have values different than the field defaults
- * and that are to be editable in Studio
- or, if the module uses a single Scope.content String field named `data`, this
- should be a string containing the contents of that field
- children: A list of Location urls that define the template children
-
-Templates are defined on XModuleDescriptor types, in the template attribute.
+``Template``s are defined in x_module. They contain 2 attributes:
+ :metadata: A dictionary with the template metadata
+ :data: A JSON value that defines the template content
"""
-
+# should this move to cms since it's really only for module crud?
import logging
-from fs.memoryfs import MemoryFS
from collections import defaultdict
from .x_module import XModuleDescriptor
-from .mako_module import MakoDescriptorSystem
-from .modulestore import Location
log = logging.getLogger(__name__)
@@ -37,73 +21,9 @@ def all_templates():
"""
Returns all templates for enabled modules, grouped by descriptor type
"""
-
+ # TODO use memcache to memoize w/ expiration
templates = defaultdict(list)
for category, descriptor in XModuleDescriptor.load_classes():
templates[category] = descriptor.templates()
return templates
-
-
-class TemplateTestSystem(MakoDescriptorSystem):
- """
- This system exists to help verify that XModuleDescriptors can be instantiated
- from their defined templates before we load the templates into the modulestore.
- """
- def __init__(self):
- super(TemplateTestSystem, self).__init__(
- lambda *a, **k: None,
- MemoryFS(),
- lambda msg: None,
- render_template=lambda *a, **k: None,
- )
-
-
-def update_templates(modulestore):
- """
- Updates the set of templates in the modulestore with all templates currently
- available from the installed plugins
- """
-
- # cdodge: build up a list of all existing templates. This will be used to determine which
- # templates have been removed from disk - and thus we need to remove from the DB
- templates_to_delete = modulestore.get_items(['i4x', 'edx', 'templates', None, None, None])
-
- for category, templates in all_templates().items():
- for template in templates:
- if 'display_name' not in template.metadata:
- log.warning('No display_name specified in template {0}, skipping'.format(template))
- continue
-
- template_location = Location('i4x', 'edx', 'templates', category, Location.clean_for_url_name(template.metadata['display_name']))
-
- try:
- json_data = {
- 'definition': {
- 'data': template.data,
- 'children': template.children
- },
- 'metadata': template.metadata
- }
- json_data['location'] = template_location.dict()
-
- XModuleDescriptor.load_from_json(json_data, TemplateTestSystem())
- except:
- log.warning('Unable to instantiate {cat} from template {template}, skipping'.format(
- cat=category,
- template=template
- ), exc_info=True)
- continue
-
- modulestore.update_item(template_location, template.data)
- modulestore.update_children(template_location, template.children)
- modulestore.update_metadata(template_location, template.metadata)
-
- # remove template from list of templates to delete
- templates_to_delete = [t for t in templates_to_delete if t.location != template_location]
-
- # now remove all templates which appear to have removed from disk
- if len(templates_to_delete) > 0:
- logging.debug('deleting dangling templates = {0}'.format(templates_to_delete))
- for template in templates_to_delete:
- modulestore.delete_item(template.location)
diff --git a/common/lib/xmodule/xmodule/templates/about/empty.yaml b/common/lib/xmodule/xmodule/templates/about/empty.yaml
deleted file mode 100644
index 0967ef424b..0000000000
--- a/common/lib/xmodule/xmodule/templates/about/empty.yaml
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/common/lib/xmodule/xmodule/templates/annotatable/default.yaml b/common/lib/xmodule/xmodule/templates/annotatable/default.yaml
deleted file mode 100644
index 0967ef424b..0000000000
--- a/common/lib/xmodule/xmodule/templates/annotatable/default.yaml
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/common/lib/xmodule/xmodule/templates/combinedopenended/default.yaml b/common/lib/xmodule/xmodule/templates/combinedopenended/default.yaml
deleted file mode 100644
index 0967ef424b..0000000000
--- a/common/lib/xmodule/xmodule/templates/combinedopenended/default.yaml
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/common/lib/xmodule/xmodule/templates/course/empty.yaml b/common/lib/xmodule/xmodule/templates/course/empty.yaml
deleted file mode 100644
index 0967ef424b..0000000000
--- a/common/lib/xmodule/xmodule/templates/course/empty.yaml
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/common/lib/xmodule/xmodule/templates/courseinfo/empty.yaml b/common/lib/xmodule/xmodule/templates/courseinfo/empty.yaml
deleted file mode 100644
index 0967ef424b..0000000000
--- a/common/lib/xmodule/xmodule/templates/courseinfo/empty.yaml
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/common/lib/xmodule/xmodule/templates/default/empty.yaml b/common/lib/xmodule/xmodule/templates/default/empty.yaml
deleted file mode 100644
index 0967ef424b..0000000000
--- a/common/lib/xmodule/xmodule/templates/default/empty.yaml
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/common/lib/xmodule/xmodule/templates/discussion/default.yaml b/common/lib/xmodule/xmodule/templates/discussion/default.yaml
deleted file mode 100644
index 0967ef424b..0000000000
--- a/common/lib/xmodule/xmodule/templates/discussion/default.yaml
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/common/lib/xmodule/xmodule/templates/html/announcement.yaml b/common/lib/xmodule/xmodule/templates/html/announcement.yaml
index 30a8ccb41e..c0ecc61524 100644
--- a/common/lib/xmodule/xmodule/templates/html/announcement.yaml
+++ b/common/lib/xmodule/xmodule/templates/html/announcement.yaml
@@ -21,4 +21,3 @@ data: |
-children: []
diff --git a/common/lib/xmodule/xmodule/templates/html/empty.yaml b/common/lib/xmodule/xmodule/templates/html/empty.yaml
deleted file mode 100644
index 0967ef424b..0000000000
--- a/common/lib/xmodule/xmodule/templates/html/empty.yaml
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/common/lib/xmodule/xmodule/templates/html/everything.yaml b/common/lib/xmodule/xmodule/templates/html/everything.yaml
deleted file mode 100644
index 348ce64fa1..0000000000
--- a/common/lib/xmodule/xmodule/templates/html/everything.yaml
+++ /dev/null
@@ -1,33 +0,0 @@
----
-metadata:
- display_name: Announcement
-
-data: |
-
Heading of document
-
First subheading
-
This is a paragraph. It will take care of line breaks for you.
HTML only parses the location
-
- of tags for inserting line breaks into your doc, not
- line
- breaks
- you
- add
- yourself.
-
-
Links
-
You can refer to other parts of the internet with a link, to other parts of your course by prepending your link with /course/
-
Now a list:
-
-
An item
-
Another item
-
And yet another
-
-
This list has an ordering
-
-
An item
-
Another item
-
Yet another item
-
-
Note, we have a lot of standard edX styles, so please try to avoid any custom styling, and make sure that you make a note of any custom styling that you do yourself so that we can incorporate it into
- tools that other people can use.
-children: []
diff --git a/common/lib/xmodule/xmodule/templates/html/latex_html.yaml b/common/lib/xmodule/xmodule/templates/html/latex_html.yaml
index ba5c4b5c06..2db7e98c65 100644
--- a/common/lib/xmodule/xmodule/templates/html/latex_html.yaml
+++ b/common/lib/xmodule/xmodule/templates/html/latex_html.yaml
@@ -19,4 +19,3 @@ data: |
It is very convenient to write complex equations in LaTeX.
-children: []
diff --git a/common/lib/xmodule/xmodule/templates/peer_grading/default.yaml b/common/lib/xmodule/xmodule/templates/peer_grading/default.yaml
deleted file mode 100644
index 0967ef424b..0000000000
--- a/common/lib/xmodule/xmodule/templates/peer_grading/default.yaml
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/common/lib/xmodule/xmodule/templates/problem/circuitschematic.yaml b/common/lib/xmodule/xmodule/templates/problem/circuitschematic.yaml
index 3b051f2ba8..1717bb91ad 100644
--- a/common/lib/xmodule/xmodule/templates/problem/circuitschematic.yaml
+++ b/common/lib/xmodule/xmodule/templates/problem/circuitschematic.yaml
@@ -1,9 +1,9 @@
-
---
metadata:
display_name: Circuit Schematic Builder
rerandomize: never
showanswer: finished
+ markdown: !!null
data: |
Please make a voltage divider that splits the provided voltage evenly.
diff --git a/common/lib/xmodule/xmodule/templates/problem/customgrader.yaml b/common/lib/xmodule/xmodule/templates/problem/customgrader.yaml
index 48feef481b..05de74f28c 100644
--- a/common/lib/xmodule/xmodule/templates/problem/customgrader.yaml
+++ b/common/lib/xmodule/xmodule/templates/problem/customgrader.yaml
@@ -1,50 +1,47 @@
---
metadata:
display_name: Custom Python-Evaluated Input
- rerandomize: never
- showanswer: finished
+ markdown: !!null
data: |
-
-
- A custom python-evaluated input problem accepts one or more lines of text input from the
- student, and evaluates the inputs for correctness based on evaluation using a
- python script embedded within the problem.
-
+
+
+ A custom python-evaluated input problem accepts one or more lines of text input from the
+ student, and evaluates the inputs for correctness based on evaluation using a
+ python script embedded within the problem.
+
-
+
-
Enter two integers which sum to 10:
-
-
-
-
+
Enter two integers which sum to 10:
+
+
+
+
-
Enter two integers which sum to 20:
-
-
-
-
-
-
-
-
Explanation
-
Any set of integers on the line \(y = 10 - x\) and \(y = 20 - x\) satisfy these constraints.
-
-
-
-
-
-children: []
+
Enter two integers which sum to 20:
+
+
+
+
+
+
+
+
Explanation
+
Any set of integers on the line \(y = 10 - x\) and \(y = 20 - x\) satisfy these constraints.
-
-
-children: []
diff --git a/common/lib/xmodule/xmodule/templates/problem/latex_problem.yaml b/common/lib/xmodule/xmodule/templates/problem/latex_problem.yaml
index 82d7e8c1ae..097055cfe3 100644
--- a/common/lib/xmodule/xmodule/templates/problem/latex_problem.yaml
+++ b/common/lib/xmodule/xmodule/templates/problem/latex_problem.yaml
@@ -85,6 +85,7 @@ metadata:
can contain equations: $\alpha = \frac{2}{\sqrt{1+\gamma}}$ }
This is some text after the showhide example.
+ markdown: !!null
data: |
@@ -214,4 +215,3 @@ data: |
-children: []
diff --git a/common/lib/xmodule/xmodule/templates/problem/multiplechoice.yaml b/common/lib/xmodule/xmodule/templates/problem/multiplechoice.yaml
index 10d51de280..202fc03b44 100644
--- a/common/lib/xmodule/xmodule/templates/problem/multiplechoice.yaml
+++ b/common/lib/xmodule/xmodule/templates/problem/multiplechoice.yaml
@@ -3,33 +3,25 @@ metadata:
display_name: Multiple Choice
rerandomize: never
showanswer: finished
- markdown:
- "A multiple choice problem presents radio buttons for student input. Students can only select a single
+ markdown: |
+ A multiple choice problem presents radio buttons for student input. Students can only select a single
option presented. Multiple Choice questions have been the subject of many areas of research due to the early
invention and adoption of bubble sheets.
-
One of the main elements that goes into a good multiple choice question is the existence of good distractors.
That is, each of the alternate responses presented to the student should be the result of a plausible mistake
that a student might make.
-
What Apple device competed with the portable CD player?
-
( ) The iPad
-
( ) Napster
-
(x) The iPod
-
( ) The vegetable peeler
-
[explanation]
The release of the iPod allowed consumers to carry their entire music library with them in a
format that did not rely on fragile and energy-intensive spinning disks.
[explanation]
- "
data: |
@@ -54,4 +46,3 @@ data: |
-children: []
diff --git a/common/lib/xmodule/xmodule/templates/problem/numericalresponse.yaml b/common/lib/xmodule/xmodule/templates/problem/numericalresponse.yaml
index 548fd94fab..9b2ddec2a7 100644
--- a/common/lib/xmodule/xmodule/templates/problem/numericalresponse.yaml
+++ b/common/lib/xmodule/xmodule/templates/problem/numericalresponse.yaml
@@ -3,43 +3,33 @@ metadata:
display_name: Numerical Input
rerandomize: never
showanswer: finished
- markdown:
- "A numerical input problem accepts a line of text input from the
+ markdown: |
+ A numerical input problem accepts a line of text input from the
student, and evaluates the input for correctness based on its
numerical value.
-
The answer is correct if it is within a specified numerical tolerance
of the expected answer.
-
Enter the numerical value of Pi:
-
= 3.14159 +- .02
-
Enter the approximate value of 502*9:
-
= 4518 +- 15%
-
-
+
Enter the number of fingers on a human hand:
-
= 5
-
[explanation]
Pi, or the the ratio between a circle's circumference to its diameter, is an irrational number
known to extreme precision. It is value is approximately equal to 3.14.
-
+
Although you can get an exact value by typing 502*9 into a calculator, the result will be close to
500*10, or 5,000. The grader accepts any response within 15% of the true value, 4518, so that you
can use any estimation technique that you like.
-
+
If you look at your hand, you can count that you have five fingers.
[explanation]
- "
-
data: |
@@ -83,5 +73,3 @@ data: |
-
-children: []
diff --git a/common/lib/xmodule/xmodule/templates/problem/optionresponse.yaml b/common/lib/xmodule/xmodule/templates/problem/optionresponse.yaml
index c2edfb1cbc..8e59f8ae4d 100644
--- a/common/lib/xmodule/xmodule/templates/problem/optionresponse.yaml
+++ b/common/lib/xmodule/xmodule/templates/problem/optionresponse.yaml
@@ -3,19 +3,16 @@ metadata:
display_name: Dropdown
rerandomize: never
showanswer: finished
- markdown:
- "Dropdown problems give a limited set of options for students to respond with, and present those options
+ markdown: |
+ Dropdown problems give a limited set of options for students to respond with, and present those options
in a format that encourages them to search for a specific answer rather than being immediately presented
with options from which to recognize the correct answer.
-
The answer options and the identification of the correct answer is defined in the optioninput tag.
-
Translation between Dropdown and __________ is extremely straightforward:
-
- [[(Multiple Choice), Text Input, Numerical Input, External Response, Image Response]]
+ [[(Multiple Choice), Text Input, Numerical Input, External Response, Image Response]]
[explanation]
Multiple Choice also allows students to select from a variety of pre-written responses, although the
@@ -23,7 +20,6 @@ metadata:
slightly because students are more likely to think of an answer and then search for it rather than
relying purely on recognition to answer the question.
[explanation]
- "
data: |
Dropdown problems give a limited set of options for students to respond with, and present those options
@@ -45,4 +41,3 @@ data: |
-children: []
diff --git a/common/lib/xmodule/xmodule/templates/problem/problem_with_hint.yaml b/common/lib/xmodule/xmodule/templates/problem/problem_with_hint.yaml
index 73a94ed941..0d93cd3c5e 100644
--- a/common/lib/xmodule/xmodule/templates/problem/problem_with_hint.yaml
+++ b/common/lib/xmodule/xmodule/templates/problem/problem_with_hint.yaml
@@ -46,7 +46,7 @@ metadata:
enter your answer in upper or lower case, with or without quotes.
\edXabox{type="custom" cfn='test_str' expect='python' hintfn='hint_fn'}
-
+ markdown: !!null
data: |
@@ -92,4 +92,3 @@ data: |
-children: []
diff --git a/common/lib/xmodule/xmodule/templates/problem/string_response.yaml b/common/lib/xmodule/xmodule/templates/problem/string_response.yaml
index 64e3dc062f..9c59ae3bc2 100644
--- a/common/lib/xmodule/xmodule/templates/problem/string_response.yaml
+++ b/common/lib/xmodule/xmodule/templates/problem/string_response.yaml
@@ -3,16 +3,13 @@ metadata:
display_name: Text Input
rerandomize: never
showanswer: finished
- # Note, the extra newlines are needed to make the yaml parser add blank lines instead of folding
- markdown:
- "A text input problem accepts a line of text from the
+ markdown: |
+ A text input problem accepts a line of text from the
student, and evaluates the input for correctness based on an expected
answer.
-
The answer is correct if it matches every character of the expected answer. This can be a problem with
international spelling, dates, or anything where the format of the answer is not clear.
-
Which US state has Lansing as its capital?
@@ -23,9 +20,8 @@ metadata:
Lansing is the capital of Michigan, although it is not Michgan's largest city,
or even the seat of the county in which it resides.
[explanation]
- "
data: |
-
+
A text input problem accepts a line of text from the
@@ -46,4 +42,3 @@ data: |
")
+ model_data['category'] = 'test'
return XmlDescriptor(runtime=system, model_data=model_data).editable_metadata_fields
def get_descriptor(self, model_data):
diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py
index ebff888f34..381cc9a622 100644
--- a/common/lib/xmodule/xmodule/video_module.py
+++ b/common/lib/xmodule/xmodule/video_module.py
@@ -97,7 +97,6 @@ class VideoDescriptor(VideoFields,
MetadataOnlyEditingDescriptor,
RawDescriptor):
module_class = VideoModule
- template_dir_name = "video"
def __init__(self, *args, **kwargs):
super(VideoDescriptor, self).__init__(*args, **kwargs)
diff --git a/common/lib/xmodule/xmodule/videoalpha_module.py b/common/lib/xmodule/xmodule/videoalpha_module.py
index 33945c33fc..d8ed8949f1 100644
--- a/common/lib/xmodule/xmodule/videoalpha_module.py
+++ b/common/lib/xmodule/xmodule/videoalpha_module.py
@@ -179,4 +179,3 @@ class VideoAlphaModule(VideoAlphaFields, XModule):
class VideoAlphaDescriptor(VideoAlphaFields, RawDescriptor):
"""Descriptor for `VideoAlphaModule`."""
module_class = VideoAlphaModule
- template_dir_name = "videoalpha"
diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py
index 0528bbfb6c..882e308c77 100644
--- a/common/lib/xmodule/xmodule/xml_module.py
+++ b/common/lib/xmodule/xmodule/xml_module.py
@@ -356,6 +356,7 @@ class XmlDescriptor(XModuleDescriptor):
if key not in set(f.name for f in cls.fields + cls.lms.fields):
model_data['xml_attributes'][key] = value
model_data['location'] = location
+ model_data['category'] = xml_object.tag
return cls(
system,
diff --git a/lms/djangoapps/courseware/features/common.py b/lms/djangoapps/courseware/features/common.py
index 0aa079ebac..55e82e0e90 100644
--- a/lms/djangoapps/courseware/features/common.py
+++ b/lms/djangoapps/courseware/features/common.py
@@ -10,7 +10,6 @@ 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
from xmodule.course_module import CourseDescriptor
from courseware.courses import get_course_by_id
from xmodule import seq_module, vertical_module
@@ -39,7 +38,7 @@ def create_course(step, course):
display_name='Test Section')
problem_section = world.ItemFactory.create(parent_location=world.scenario_dict['SECTION'].location,
- template='i4x://edx/templates/sequential/Empty',
+ category='sequential'
display_name='Test Section')
@@ -62,7 +61,7 @@ def i_am_registered_for_the_course(step, course):
@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",
+ category="static_tab",
display_name=str(extra_tab_name))
diff --git a/lms/djangoapps/courseware/features/navigation.py b/lms/djangoapps/courseware/features/navigation.py
index 7c2474ae1a..c87e6122a4 100644
--- a/lms/djangoapps/courseware/features/navigation.py
+++ b/lms/djangoapps/courseware/features/navigation.py
@@ -24,11 +24,11 @@ def view_course_multiple_sections(step):
display_name=section_name(2))
place1 = world.ItemFactory.create(parent_location=section1.location,
- template='i4x://edx/templates/sequential/Empty',
+ category='sequential',
display_name=subsection_name(1))
place2 = world.ItemFactory.create(parent_location=section2.location,
- template='i4x://edx/templates/sequential/Empty',
+ category='sequential',
display_name=subsection_name(2))
add_problem_to_course_section('model_course', 'multiple choice', place1.location)
@@ -46,7 +46,7 @@ def view_course_multiple_subsections(step):
display_name=section_name(1))
place1 = world.ItemFactory.create(parent_location=section1.location,
- template='i4x://edx/templates/sequential/Empty',
+ category='sequential',
display_name=subsection_name(1))
place2 = world.ItemFactory.create(parent_location=section1.location,
@@ -66,7 +66,7 @@ def view_course_multiple_sequences(step):
display_name=section_name(1))
place1 = world.ItemFactory.create(parent_location=section1.location,
- template='i4x://edx/templates/sequential/Empty',
+ category='sequential',
display_name=subsection_name(1))
add_problem_to_course_section('model_course', 'multiple choice', place1.location)
@@ -177,9 +177,8 @@ def add_problem_to_course_section(course, problem_type, parent_location, extraMe
# Create a problem item using our generated XML
# We set rerandomize=always in the metadata so that the "Reset" button
# will appear.
- template_name = "i4x://edx/templates/problem/Blank_Common_Problem"
world.ItemFactory.create(parent_location=parent_location,
- template=template_name,
+ category='problem',
display_name=str(problem_type),
data=problem_xml,
metadata=metadata)
diff --git a/lms/djangoapps/courseware/features/problems.py b/lms/djangoapps/courseware/features/problems.py
index 82bb4959a8..e0c3c004da 100644
--- a/lms/djangoapps/courseware/features/problems.py
+++ b/lms/djangoapps/courseware/features/problems.py
@@ -17,7 +17,7 @@ def view_problem_with_attempts(step, problem_type, attempts):
i_am_registered_for_the_course(step, 'model_course')
# Ensure that the course has this problem type
- add_problem_to_course(world.scenario_dict['COURSE'].number, problem_type, {'attempts': attempts})
+ add_problem_to_course(world.scenario_dict['COURSE'].number, problem_type, {'max_attempts': attempts})
# Go to the one section in the factory-created course
# which should be loaded with the correct problem
diff --git a/lms/djangoapps/courseware/features/problems_setup.py b/lms/djangoapps/courseware/features/problems_setup.py
index 1805da55d0..6086d7fa5e 100644
--- a/lms/djangoapps/courseware/features/problems_setup.py
+++ b/lms/djangoapps/courseware/features/problems_setup.py
@@ -273,9 +273,9 @@ def add_problem_to_course(course, problem_type, extraMeta=None):
# Create a problem item using our generated XML
# We set rerandomize=always in the metadata so that the "Reset" button
# will appear.
- template_name = "i4x://edx/templates/problem/Blank_Common_Problem"
- world.ItemFactory.create(parent_location=section_location(course),
- template=template_name,
+ category_name = "problem"
+ return world.ItemFactory.create(parent_location=section_location(course),
+ category=category_name,
display_name=str(problem_type),
data=problem_xml,
metadata=metadata)
diff --git a/lms/djangoapps/courseware/features/video.py b/lms/djangoapps/courseware/features/video.py
index 6b05af51b5..f95ffd9917 100644
--- a/lms/djangoapps/courseware/features/video.py
+++ b/lms/djangoapps/courseware/features/video.py
@@ -43,14 +43,13 @@ def view_videoalpha(step):
def add_video_to_course(course):
- template_name = 'i4x://edx/templates/video/default'
world.ItemFactory.create(parent_location=section_location(course),
- template=template_name,
+ category='video',
display_name='Video')
def add_videoalpha_to_course(course):
- template_name = 'i4x://edx/templates/videoalpha/Video_Alpha'
+ category = 'videoalpha'
world.ItemFactory.create(parent_location=section_location(course),
- template=template_name,
+ category=category,
display_name='Video Alpha')
diff --git a/lms/djangoapps/courseware/tests/__init__.py b/lms/djangoapps/courseware/tests/__init__.py
index 0abbaa02cf..31fe376d69 100644
--- a/lms/djangoapps/courseware/tests/__init__.py
+++ b/lms/djangoapps/courseware/tests/__init__.py
@@ -29,17 +29,17 @@ class BaseTestXmodule(ModuleStoreTestCase):
2. create, enrol and login users for this course;
Any xmodule should overwrite only next parameters for test:
- 1. TEMPLATE_NAME
+ 1. CATEGORY
2. DATA
3. MODEL_DATA
- This class should not contain any tests, because TEMPLATE_NAME
+ This class should not contain any tests, because CATEGORY
should be defined in child class.
"""
USER_COUNT = 2
# Data from YAML common/lib/xmodule/xmodule/templates/NAME/default.yaml
- TEMPLATE_NAME = ""
+ CATEGORY = ""
DATA = ''
MODEL_DATA = {'data': ''}
@@ -53,11 +53,11 @@ class BaseTestXmodule(ModuleStoreTestCase):
chapter = ItemFactory.create(
parent_location=self.course.location,
- template="i4x://edx/templates/sequential/Empty",
+ category="sequential",
)
section = ItemFactory.create(
parent_location=chapter.location,
- template="i4x://edx/templates/sequential/Empty"
+ category="sequential"
)
# username = robot{0}, password = 'test'
@@ -71,7 +71,7 @@ class BaseTestXmodule(ModuleStoreTestCase):
self.item_descriptor = ItemFactory.create(
parent_location=section.location,
- template=self.TEMPLATE_NAME,
+ category=self.CATEGORY,
data=self.DATA
)
diff --git a/lms/djangoapps/courseware/tests/test_submitting_problems.py b/lms/djangoapps/courseware/tests/test_submitting_problems.py
index 83ae7dc73e..7e9b55a4fb 100644
--- a/lms/djangoapps/courseware/tests/test_submitting_problems.py
+++ b/lms/djangoapps/courseware/tests/test_submitting_problems.py
@@ -130,7 +130,7 @@ class TestSubmittingProblems(ModuleStoreTestCase, LoginEnrollmentTestCase):
problem = ItemFactory.create(
parent_location=section_location,
- template=problem_template,
+ category='problem',
data=prob_xml,
metadata={'randomize': 'always'},
display_name=name
@@ -149,13 +149,13 @@ class TestSubmittingProblems(ModuleStoreTestCase, LoginEnrollmentTestCase):
if not(hasattr(self, 'chapter')):
self.chapter = ItemFactory.create(
parent_location=self.course.location,
- template="i4x://edx/templates/chapter/Empty",
+ category='chapter'
)
section = ItemFactory.create(
parent_location=self.chapter.location,
display_name=name,
- template="i4x://edx/templates/sequential/Empty",
+ category='sequential',
metadata={'graded': True, 'format': section_format}
)
diff --git a/lms/djangoapps/courseware/tests/test_video_mongo.py b/lms/djangoapps/courseware/tests/test_video_mongo.py
index a0fdecc77a..829308423c 100644
--- a/lms/djangoapps/courseware/tests/test_video_mongo.py
+++ b/lms/djangoapps/courseware/tests/test_video_mongo.py
@@ -7,7 +7,7 @@ from . import BaseTestXmodule
class TestVideo(BaseTestXmodule):
"""Integration tests: web client + mongo."""
- TEMPLATE_NAME = "i4x://edx/templates/video/default"
+ TEMPLATE_NAME = "video"
DATA = ''
def test_handle_ajax_dispatch(self):
diff --git a/lms/djangoapps/courseware/tests/test_videoalpha_mongo.py b/lms/djangoapps/courseware/tests/test_videoalpha_mongo.py
index 182cbab9e7..d5afb1a78c 100644
--- a/lms/djangoapps/courseware/tests/test_videoalpha_mongo.py
+++ b/lms/djangoapps/courseware/tests/test_videoalpha_mongo.py
@@ -9,7 +9,7 @@ from django.conf import settings
class TestVideo(BaseTestXmodule):
"""Integration tests: web client + mongo."""
- TEMPLATE_NAME = "i4x://edx/templates/videoalpha/Video_Alpha"
+ CATEGORY = "videoalpha"
DATA = SOURCE_XML
MODEL_DATA = {
'data': DATA
diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py
index bbdf97ca9b..fbe2c05ada 100644
--- a/lms/djangoapps/courseware/tests/tests.py
+++ b/lms/djangoapps/courseware/tests/tests.py
@@ -15,10 +15,11 @@ from xmodule.modulestore.xml_importer import import_from_xml
from xmodule.modulestore.xml import XMLModuleStore
from helpers import LoginEnrollmentTestCase
-from modulestore_config import TEST_DATA_DIR,\
- TEST_DATA_XML_MODULESTORE,\
- TEST_DATA_MONGO_MODULESTORE,\
+from modulestore_config import TEST_DATA_DIR, \
+ TEST_DATA_XML_MODULESTORE, \
+ TEST_DATA_MONGO_MODULESTORE, \
TEST_DATA_DRAFT_MONGO_MODULESTORE
+import xmodule
class ActivateLoginTest(LoginEnrollmentTestCase):
@@ -50,7 +51,7 @@ class PageLoaderTestCase(LoginEnrollmentTestCase):
"""
Choose a page in the course randomly, and assert that it loads.
"""
- # enroll in the course before trying to access pages
+ # enroll in the course before trying to access pages
courses = module_store.get_courses()
self.assertEqual(len(courses), 1)
course = courses[0]
diff --git a/lms/djangoapps/instructor/tests/test_gradebook.py b/lms/djangoapps/instructor/tests/test_gradebook.py
index 5ed0c1d1af..2677df59b2 100644
--- a/lms/djangoapps/instructor/tests/test_gradebook.py
+++ b/lms/djangoapps/instructor/tests/test_gradebook.py
@@ -34,11 +34,11 @@ class TestGradebook(ModuleStoreTestCase):
self.course = CourseFactory.create(**kwargs)
chapter = ItemFactory.create(
parent_location=self.course.location,
- template="i4x://edx/templates/sequential/Empty",
+ category="sequential",
)
section = ItemFactory.create(
parent_location=chapter.location,
- template="i4x://edx/templates/sequential/Empty",
+ category="sequential",
metadata={'graded': True, 'format': 'Homework'}
)
@@ -47,11 +47,11 @@ class TestGradebook(ModuleStoreTestCase):
for user in self.users:
CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
- for i in xrange(USER_COUNT-1):
- template_name = "i4x://edx/templates/problem/Blank_Common_Problem"
+ for i in xrange(USER_COUNT - 1):
+ category = "problem"
item = ItemFactory.create(
parent_location=section.location,
- template=template_name,
+ category=category,
data=StringResponseXMLFactory().build_xml(answer='foo'),
metadata={'rerandomize': 'always'}
)
diff --git a/lms/djangoapps/instructor_task/tests/test_base.py b/lms/djangoapps/instructor_task/tests/test_base.py
index 5e51b9fdeb..b67453e997 100644
--- a/lms/djangoapps/instructor_task/tests/test_base.py
+++ b/lms/djangoapps/instructor_task/tests/test_base.py
@@ -119,7 +119,7 @@ class InstructorTaskModuleTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase)
# add a sequence to the course to which the problems can be added
self.problem_section = ItemFactory.create(parent_location=chapter.location,
- template='i4x://edx/templates/sequential/Empty',
+ category='sequential',
display_name=TEST_SECTION_NAME)
@staticmethod
@@ -169,7 +169,7 @@ class InstructorTaskModuleTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase)
'num_responses': 2}
problem_xml = factory.build_xml(**factory_args)
ItemFactory.create(parent_location=self.problem_section.location,
- template="i4x://edx/templates/problem/Blank_Common_Problem",
+ category="problem",
display_name=str(problem_url_name),
data=problem_xml)
diff --git a/lms/djangoapps/instructor_task/tests/test_integration.py b/lms/djangoapps/instructor_task/tests/test_integration.py
index 9b56663753..fb8cd44169 100644
--- a/lms/djangoapps/instructor_task/tests/test_integration.py
+++ b/lms/djangoapps/instructor_task/tests/test_integration.py
@@ -243,7 +243,7 @@ class TestRescoringTask(TestIntegrationTask):
grader_payload=grader_payload,
num_responses=2)
ItemFactory.create(parent_location=self.problem_section.location,
- template="i4x://edx/templates/problem/Blank_Common_Problem",
+ category="problem",
display_name=str(problem_url_name),
data=problem_xml)
@@ -293,7 +293,7 @@ class TestRescoringTask(TestIntegrationTask):
# Per-student rerandomization will at least generate different seeds for different users, so
# we get a little more test coverage.
ItemFactory.create(parent_location=self.problem_section.location,
- template="i4x://edx/templates/problem/Blank_Common_Problem",
+ category="problem",
display_name=str(problem_url_name),
data=problem_xml,
metadata={"rerandomize": "per_student"})
diff --git a/lms/djangoapps/open_ended_grading/tests.py b/lms/djangoapps/open_ended_grading/tests.py
index a46b4b12fe..b2529e38db 100644
--- a/lms/djangoapps/open_ended_grading/tests.py
+++ b/lms/djangoapps/open_ended_grading/tests.py
@@ -161,7 +161,7 @@ class TestPeerGradingService(LoginEnrollmentTestCase):
self.course_id = "edX/toy/2012_Fall"
self.toy = modulestore().get_course(self.course_id)
location = "i4x://edX/toy/peergrading/init"
- model_data = {'data': "", 'location': location}
+ model_data = {'data': "", 'location': location, 'category':'peergrading'}
self.mock_service = peer_grading_service.MockPeerGradingService()
self.system = ModuleSystem(
ajax_url=location,
diff --git a/rakelib/django.rake b/rakelib/django.rake
index eeb8135d4d..53c299ac9f 100644
--- a/rakelib/django.rake
+++ b/rakelib/django.rake
@@ -111,11 +111,6 @@ namespace :cms do
end
end
- desc "Imports all the templates from the code pack"
- task :update_templates do
- sh(django_admin(:cms, :dev, :update_templates))
- end
-
desc "Import course data within the given DATA_DIR variable"
task :xlint do
if ENV['DATA_DIR'] and ENV['COURSE_DIR']
From 8b5979641b95d8046da41433355452895446d6fe Mon Sep 17 00:00:00 2001
From: Calen Pennington
Date: Wed, 10 Jul 2013 13:32:00 -0400
Subject: [PATCH 25/73] Change default rerandomize and showanswer
The defaults used to be rerandomize=always, showanswer=closed. This is
preserved for capa problems being imported from XML. However, for
courses, and for problems created in Studio, the default has been
changed to never/finished, to match the previous defaults used by
Studio.
---
.../contentstore/features/problem-editor.py | 9 ++++-----
.../contentstore/tests/test_course_settings.py | 2 +-
common/lib/xmodule/xmodule/capa_module.py | 18 ++++++++++++++++--
.../templates/problem/circuitschematic.yaml | 2 --
.../templates/problem/forumularesponse.yaml | 2 --
.../templates/problem/imageresponse.yaml | 2 --
.../templates/problem/multiplechoice.yaml | 2 --
.../templates/problem/numericalresponse.yaml | 2 --
.../templates/problem/optionresponse.yaml | 2 --
.../templates/problem/string_response.yaml | 2 --
.../xmodule/xmodule/tests/test_capa_module.py | 14 +++++++-------
lms/xmodule_namespace.py | 12 ++++++++++--
12 files changed, 38 insertions(+), 31 deletions(-)
diff --git a/cms/djangoapps/contentstore/features/problem-editor.py b/cms/djangoapps/contentstore/features/problem-editor.py
index 99b693225d..64b2ec9b5c 100644
--- a/cms/djangoapps/contentstore/features/problem-editor.py
+++ b/cms/djangoapps/contentstore/features/problem-editor.py
@@ -33,12 +33,11 @@ def i_edit_and_select_settings(step):
def i_see_five_settings_with_values(step):
world.verify_all_setting_entries(
[
- [DISPLAY_NAME, "New problem", True],
+ [DISPLAY_NAME, "Blank Common Problem", True],
[MAXIMUM_ATTEMPTS, "", False],
[PROBLEM_WEIGHT, "", False],
- # Not sure why these are True other than via inspection
- [RANDOMIZATION, "Always", True],
- [SHOW_ANSWER, "Closed", True]
+ [RANDOMIZATION, "Never", False],
+ [SHOW_ANSWER, "Finished", False]
])
@@ -96,7 +95,7 @@ def my_change_to_randomization_is_persisted(step):
def i_can_revert_to_default_for_randomization(step):
world.revert_setting_entry(RANDOMIZATION)
world.save_component_and_reopen(step)
- world.verify_setting_entry(world.get_setting_entry(RANDOMIZATION), RANDOMIZATION, "Always", False)
+ world.verify_setting_entry(world.get_setting_entry(RANDOMIZATION), RANDOMIZATION, "Never", False)
@step('I can set the weight to "(.*)"?')
diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py
index fc04ad0a58..44eb16436d 100644
--- a/cms/djangoapps/contentstore/tests/test_course_settings.py
+++ b/cms/djangoapps/contentstore/tests/test_course_settings.py
@@ -351,7 +351,7 @@ class CourseMetadataEditingTest(CourseTestCase):
self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value")
self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field')
# check for deletion effectiveness
- self.assertEqual('closed', test_model['showanswer'], 'showanswer field still in')
+ self.assertEqual('finished', test_model['showanswer'], 'showanswer field still in')
self.assertEqual(None, test_model['xqa_key'], 'xqa_key field still in')
diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py
index 752f0d3362..40c69e9169 100644
--- a/common/lib/xmodule/xmodule/capa_module.py
+++ b/common/lib/xmodule/xmodule/capa_module.py
@@ -103,7 +103,7 @@ class CapaFields(object):
help=("Defines when to show the answer to the problem. "
"A default value can be set in Advanced Settings."),
scope=Scope.settings,
- default="closed",
+ default="finished",
values=[
{"display_name": "Always", "value": "always"},
{"display_name": "Answered", "value": "answered"},
@@ -123,7 +123,7 @@ class CapaFields(object):
help="Defines how often inputs are randomized when a student loads the problem. "
"This setting only applies to problems that can have randomly generated numeric values. "
"A default value can be set in Advanced Settings.",
- default="always",
+ default="never",
scope=Scope.settings,
values=[
{"display_name": "Always", "value": "always"},
@@ -1112,6 +1112,20 @@ class CapaDescriptor(CapaFields, RawDescriptor):
path[8:],
]
+ @classmethod
+ def from_xml(cls, xml_data, system, org=None, course=None):
+ """
+ Augment regular translation w/ setting the pre-Studio defaults.
+ """
+ problem = super(CapaDescriptor, cls).from_xml(xml_data, system, org, course)
+ # pylint: disable=W0212
+ if 'showanswer' not in problem._model_data:
+ problem.showanswer = "closed"
+ if 'rerandomize' not in problem._model_data:
+ problem.rerandomize = "always"
+ return problem
+
+
@property
def non_editable_metadata_fields(self):
non_editable_fields = super(CapaDescriptor, self).non_editable_metadata_fields
diff --git a/common/lib/xmodule/xmodule/templates/problem/circuitschematic.yaml b/common/lib/xmodule/xmodule/templates/problem/circuitschematic.yaml
index 1717bb91ad..051dfe1912 100644
--- a/common/lib/xmodule/xmodule/templates/problem/circuitschematic.yaml
+++ b/common/lib/xmodule/xmodule/templates/problem/circuitschematic.yaml
@@ -1,8 +1,6 @@
---
metadata:
display_name: Circuit Schematic Builder
- rerandomize: never
- showanswer: finished
markdown: !!null
data: |
diff --git a/common/lib/xmodule/xmodule/templates/problem/forumularesponse.yaml b/common/lib/xmodule/xmodule/templates/problem/forumularesponse.yaml
index 4cf877bd1f..807a7833e4 100644
--- a/common/lib/xmodule/xmodule/templates/problem/forumularesponse.yaml
+++ b/common/lib/xmodule/xmodule/templates/problem/forumularesponse.yaml
@@ -1,8 +1,6 @@
---
metadata:
display_name: Math Expression Input
- rerandomize: never
- showanswer: finished
markdown: !!null
data: |
diff --git a/common/lib/xmodule/xmodule/templates/problem/imageresponse.yaml b/common/lib/xmodule/xmodule/templates/problem/imageresponse.yaml
index 566997671d..44bcc56d80 100644
--- a/common/lib/xmodule/xmodule/templates/problem/imageresponse.yaml
+++ b/common/lib/xmodule/xmodule/templates/problem/imageresponse.yaml
@@ -1,8 +1,6 @@
---
metadata:
display_name: Image Mapped Input
- rerandomize: never
- showanswer: finished
markdown: !!null
data: |
diff --git a/common/lib/xmodule/xmodule/templates/problem/multiplechoice.yaml b/common/lib/xmodule/xmodule/templates/problem/multiplechoice.yaml
index 202fc03b44..68845564d5 100644
--- a/common/lib/xmodule/xmodule/templates/problem/multiplechoice.yaml
+++ b/common/lib/xmodule/xmodule/templates/problem/multiplechoice.yaml
@@ -1,8 +1,6 @@
---
metadata:
display_name: Multiple Choice
- rerandomize: never
- showanswer: finished
markdown: |
A multiple choice problem presents radio buttons for student input. Students can only select a single
option presented. Multiple Choice questions have been the subject of many areas of research due to the early
diff --git a/common/lib/xmodule/xmodule/templates/problem/numericalresponse.yaml b/common/lib/xmodule/xmodule/templates/problem/numericalresponse.yaml
index 9b2ddec2a7..e97a54b460 100644
--- a/common/lib/xmodule/xmodule/templates/problem/numericalresponse.yaml
+++ b/common/lib/xmodule/xmodule/templates/problem/numericalresponse.yaml
@@ -1,8 +1,6 @@
---
metadata:
display_name: Numerical Input
- rerandomize: never
- showanswer: finished
markdown: |
A numerical input problem accepts a line of text input from the
student, and evaluates the input for correctness based on its
diff --git a/common/lib/xmodule/xmodule/templates/problem/optionresponse.yaml b/common/lib/xmodule/xmodule/templates/problem/optionresponse.yaml
index 8e59f8ae4d..44af7a850a 100644
--- a/common/lib/xmodule/xmodule/templates/problem/optionresponse.yaml
+++ b/common/lib/xmodule/xmodule/templates/problem/optionresponse.yaml
@@ -1,8 +1,6 @@
---
metadata:
display_name: Dropdown
- rerandomize: never
- showanswer: finished
markdown: |
Dropdown problems give a limited set of options for students to respond with, and present those options
in a format that encourages them to search for a specific answer rather than being immediately presented
diff --git a/common/lib/xmodule/xmodule/templates/problem/string_response.yaml b/common/lib/xmodule/xmodule/templates/problem/string_response.yaml
index 9c59ae3bc2..cf95fe8331 100644
--- a/common/lib/xmodule/xmodule/templates/problem/string_response.yaml
+++ b/common/lib/xmodule/xmodule/templates/problem/string_response.yaml
@@ -1,8 +1,6 @@
---
metadata:
display_name: Text Input
- rerandomize: never
- showanswer: finished
markdown: |
A text input problem accepts a line of text from the
student, and evaluates the input for correctness based on an expected
diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py
index 1e84174291..0f3dfa5b85 100644
--- a/common/lib/xmodule/xmodule/tests/test_capa_module.py
+++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py
@@ -636,10 +636,10 @@ class CapaModuleTest(unittest.TestCase):
# Expect that the problem was reset
module.new_lcp.assert_called_once_with(None)
- module.choose_new_seed.assert_called_once_with()
def test_reset_problem_closed(self):
- module = CapaFactory.create()
+ # pre studio default
+ module = CapaFactory.create(rerandomize="always")
# Simulate that the problem is closed
with patch('xmodule.capa_module.CapaModule.closed') as mock_closed:
@@ -900,13 +900,13 @@ class CapaModuleTest(unittest.TestCase):
module = CapaFactory.create(done=False)
self.assertFalse(module.should_show_reset_button())
- # Otherwise, DO show the reset button
- module = CapaFactory.create(done=True)
+ # pre studio default value, DO show the reset button
+ module = CapaFactory.create(rerandomize="always", done=True)
self.assertTrue(module.should_show_reset_button())
# If survey question for capa (max_attempts = 0),
# DO show the reset button
- module = CapaFactory.create(max_attempts=0, done=True)
+ module = CapaFactory.create(rerandomize="always", max_attempts=0, done=True)
self.assertTrue(module.should_show_reset_button())
def test_should_show_save_button(self):
@@ -940,8 +940,8 @@ class CapaModuleTest(unittest.TestCase):
module = CapaFactory.create(max_attempts=None, rerandomize="per_student", done=True)
self.assertFalse(module.should_show_save_button())
- # Otherwise, DO show the save button
- module = CapaFactory.create(done=False)
+ # pre-studio default, DO show the save button
+ module = CapaFactory.create(rerandomize="always", done=False)
self.assertTrue(module.should_show_save_button())
# If we're not randomizing and we have limited attempts, then we can save
diff --git a/lms/xmodule_namespace.py b/lms/xmodule_namespace.py
index a78e27e5af..d57ad9ce52 100644
--- a/lms/xmodule_namespace.py
+++ b/lms/xmodule_namespace.py
@@ -41,8 +41,16 @@ class LmsNamespace(Namespace):
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)
+ showanswer = String(
+ help="When to show the problem answer to the student",
+ scope=Scope.settings,
+ default="finished"
+ )
+ rerandomize = String(
+ help="When to rerandomize the problem",
+ default="never",
+ scope=Scope.settings
+ )
days_early_for_beta = Float(
help="Number of days early to show content to beta users",
default=None,
From 4b5347885967a9915dae9d158344200d6b194c36 Mon Sep 17 00:00:00 2001
From: Calen Pennington
Date: Wed, 10 Jul 2013 16:31:34 -0400
Subject: [PATCH 26/73] Require markdown to be explicitly set
---
cms/djangoapps/contentstore/views/component.py | 5 ++---
common/lib/xmodule/xmodule/capa_module.py | 2 +-
2 files changed, 3 insertions(+), 4 deletions(-)
diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py
index 13eca522dd..505a93903a 100644
--- a/cms/djangoapps/contentstore/views/component.py
+++ b/cms/djangoapps/contentstore/views/component.py
@@ -142,11 +142,10 @@ def edit_unit(request, location):
for category in COMPONENT_TYPES:
component_class = XModuleDescriptor.load_class(category)
# add the default template
- has_markdown = hasattr(component_class, 'markdown') and component_class.markdown is not None
component_templates[category].append((
component_class.display_name.default or 'Blank',
category,
- has_markdown,
+ False, # No defaults have markdown (hardcoded current default)
None # no boilerplate for overrides
))
# add boilerplates
@@ -175,7 +174,7 @@ def edit_unit(request, location):
component_templates['advanced'].append((
component_class.display_name.default or category,
category,
- hasattr(component_class, 'markdown') and component_class.markdown is not None,
+ False,
None # don't override default data
))
except PluginMissingError:
diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py
index 40c69e9169..ef3e1999c8 100644
--- a/common/lib/xmodule/xmodule/capa_module.py
+++ b/common/lib/xmodule/xmodule/capa_module.py
@@ -146,7 +146,7 @@ class CapaFields(object):
values={"min": 0, "step": .1},
scope=Scope.settings
)
- markdown = String(help="Markdown source of this module", default="", scope=Scope.settings)
+ markdown = String(help="Markdown source of this module", default=None, scope=Scope.settings)
source_code = String(
help="Source code for LaTeX and Word problems. This feature is not well-supported.",
scope=Scope.settings
From f6b7b2678d9e19b046df05d75ca93164640f7c29 Mon Sep 17 00:00:00 2001
From: Calen Pennington
Date: Wed, 10 Jul 2013 16:35:16 -0400
Subject: [PATCH 27/73] Set about page default content from overview.yml
template
---
cms/djangoapps/contentstore/views/course.py | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py
index 4c95d6e06e..0e16624c42 100644
--- a/cms/djangoapps/contentstore/views/course.py
+++ b/cms/djangoapps/contentstore/views/course.py
@@ -45,6 +45,7 @@ from .component import (
from django_comment_common.utils import seed_permissions_roles
import datetime
from django.utils.timezone import UTC
+from xmodule.html_module import AboutDescriptor
__all__ = ['course_index', 'create_new_course', 'course_info',
'course_info_updates', 'get_course_settings',
'course_config_graders_page',
@@ -133,9 +134,14 @@ def create_new_course(request):
modulestore('direct').create_and_save_xmodule(dest_location, metadata=metadata)
new_course = modulestore('direct').get_item(dest_location)
- # clone a default 'about' module as well
- dest_about_location = dest_location._replace(category='about', name='overview')
- modulestore('direct').create_and_save_xmodule(dest_about_location, system=new_course.system)
+ # clone a default 'about' overview module as well
+ dest_about_location = dest_location.replace(category='about', name='overview')
+ overview_template = AboutDescriptor.get_template('overview.yaml')
+ modulestore('direct').create_and_save_xmodule(
+ dest_about_location,
+ system=new_course.system,
+ definition_data=overview_template.get('data')
+ )
initialize_course_tabs(new_course)
From 0a38e490e345e4886c8d28273d6851ce77aac19b Mon Sep 17 00:00:00 2001
From: Calen Pennington
Date: Wed, 10 Jul 2013 16:35:39 -0400
Subject: [PATCH 28/73] Fix pep8/pylint errors
---
common/lib/xmodule/xmodule/combined_open_ended_module.py | 3 ++-
common/lib/xmodule/xmodule/html_module.py | 4 ++--
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py
index 60103a2126..c4215f709c 100644
--- a/common/lib/xmodule/xmodule/combined_open_ended_module.py
+++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py
@@ -84,7 +84,8 @@ class CombinedOpenEndedFields(object):
display_name = String(
display_name="Display Name",
help="This name appears in the horizontal navigation at the top of the page.",
- default="Open Ended Grading", scope=Scope.settings
+ default="Open Ended Grading",
+ scope=Scope.settings
)
current_task_number = Integer(help="Current task that the student is on.", default=0, scope=Scope.user_state)
task_states = List(help="List of state dictionaries of each task within this module.", scope=Scope.user_state)
diff --git a/common/lib/xmodule/xmodule/html_module.py b/common/lib/xmodule/xmodule/html_module.py
index 2c7c9e0b01..0536f5ef5e 100644
--- a/common/lib/xmodule/xmodule/html_module.py
+++ b/common/lib/xmodule/xmodule/html_module.py
@@ -167,9 +167,9 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor):
pathname=pathname)
resource_fs.makedir(os.path.dirname(filepath), recursive=True, allow_recreate=True)
- with resource_fs.open(filepath, 'w') as file:
+ with resource_fs.open(filepath, 'w') as filestream:
html_data = self.data.encode('utf-8')
- file.write(html_data)
+ filestream.write(html_data)
# write out the relative name
relname = path(pathname).basename()
From 92391a22fc58d61c18b52888cdb532fdd4627fae Mon Sep 17 00:00:00 2001
From: Calen Pennington
Date: Thu, 11 Jul 2013 08:42:24 -0400
Subject: [PATCH 29/73] Don't force field inheritance of default values
---
.../xmodule/xmodule/modulestore/mongo/base.py | 4 ----
common/lib/xmodule/xmodule/tests/test_import.py | 17 +++--------------
2 files changed, 3 insertions(+), 18 deletions(-)
diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/base.py b/common/lib/xmodule/xmodule/modulestore/mongo/base.py
index 0b1c601a2f..ab63243aaf 100644
--- a/common/lib/xmodule/xmodule/modulestore/mongo/base.py
+++ b/common/lib/xmodule/xmodule/modulestore/mongo/base.py
@@ -630,10 +630,6 @@ class MongoModuleStore(ModuleStoreBase):
definition_data = {}
dbmodel = self._create_new_model_data(location.category, location, definition_data, metadata)
xmodule = xblock_class(system, dbmodel)
- # force inherited fields w/ defaults to take the defaults so the children can inherit
- for attr in INHERITABLE_METADATA:
- if hasattr(xmodule, attr):
- xmodule._model_data[attr] = getattr(xmodule, attr)
return xmodule
def save_xmodule(self, xmodule):
diff --git a/common/lib/xmodule/xmodule/tests/test_import.py b/common/lib/xmodule/xmodule/tests/test_import.py
index 30c8939b5b..2fe9d70627 100644
--- a/common/lib/xmodule/xmodule/tests/test_import.py
+++ b/common/lib/xmodule/xmodule/tests/test_import.py
@@ -156,11 +156,7 @@ class ImportTestCase(BaseCourseTestCase):
child = descriptor.get_children()[0]
self.assertEqual(child.lms.due, ImportTestCase.date.from_json(v))
self.assertEqual(child._inheritable_metadata, child._inherited_metadata)
- self.assertEqual(2, len(child._inherited_metadata))
- self.assertLessEqual(
- ImportTestCase.date.from_json(child._inherited_metadata['start']),
- datetime.datetime.now(UTC())
- )
+ self.assertEqual(1, len(child._inherited_metadata))
self.assertEqual(v, child._inherited_metadata['due'])
# Now export and check things
@@ -218,10 +214,8 @@ class ImportTestCase(BaseCourseTestCase):
self.assertEqual(child.lms.due, None)
# pylint: disable=W0212
self.assertEqual(child._inheritable_metadata, child._inherited_metadata)
- self.assertEqual(1, len(child._inherited_metadata))
- # why do these tests look in the internal structure v just calling child.start?
self.assertLessEqual(
- ImportTestCase.date.from_json(child._inherited_metadata['start']),
+ child.lms.start,
datetime.datetime.now(UTC())
)
@@ -249,12 +243,7 @@ class ImportTestCase(BaseCourseTestCase):
self.assertEqual(descriptor.lms.due, ImportTestCase.date.from_json(course_due))
self.assertEqual(child.lms.due, ImportTestCase.date.from_json(child_due))
# Test inherited metadata. Due does not appear here (because explicitly set on child).
- self.assertEqual(1, len(child._inherited_metadata))
- self.assertLessEqual(
- ImportTestCase.date.from_json(child._inherited_metadata['start']),
- datetime.datetime.now(UTC()))
- # Test inheritable metadata. This has the course inheritable value for due.
- self.assertEqual(2, len(child._inheritable_metadata))
+ self.assertEqual(1, len(child._inheritable_metadata))
self.assertEqual(course_due, child._inheritable_metadata['due'])
def test_is_pointer_tag(self):
From be4fbc562e30b9dbe745b4552047aa4a92b451e8 Mon Sep 17 00:00:00 2001
From: Don Mitchell
Date: Tue, 16 Jul 2013 16:00:28 -0400
Subject: [PATCH 30/73] Refactor tests to no longer use templates
Add boilerplate option to ItemFactory
Minor start date fix to not use microsecs
---
.../contentstore/tests/test_contentstore.py | 20 +++++++-------
.../contentstore/tests/test_item.py | 14 +++++-----
.../xmodule/modulestore/tests/factories.py | 27 +++++++++++++------
.../tests/test_submitting_problems.py | 24 ++++++++---------
4 files changed, 48 insertions(+), 37 deletions(-)
diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py
index fa7c45cb1d..f67af41cdc 100644
--- a/cms/djangoapps/contentstore/tests/test_contentstore.py
+++ b/cms/djangoapps/contentstore/tests/test_contentstore.py
@@ -308,12 +308,14 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None])
- ItemFactory.create(parent_location=course_location,
- template="i4x://edx/templates/static_tab/Empty",
- display_name="Static_1")
- ItemFactory.create(parent_location=course_location,
- template="i4x://edx/templates/static_tab/Empty",
- display_name="Static_2")
+ ItemFactory.create(
+ parent_location=course_location,
+ category="static_tab",
+ display_name="Static_1")
+ ItemFactory.create(
+ parent_location=course_location,
+ category="static_tab",
+ display_name="Static_2")
course = module_store.get_item(Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None]))
@@ -370,7 +372,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None])
chapterloc = ItemFactory.create(parent_location=course_location, display_name="Chapter").location
- ItemFactory.create(parent_location=chapterloc, template='i4x://edx/templates/sequential/Empty', display_name="Sequential")
+ ItemFactory.create(parent_location=chapterloc, category='sequential', display_name="Sequential")
sequential = direct_store.get_item(Location(['i4x', 'edX', '999', 'sequential', 'Sequential', None]))
chapter = direct_store.get_item(Location(['i4x', 'edX', '999', 'chapter', 'Chapter', None]))
@@ -650,9 +652,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
vertical = module_store.get_item(Location(['i4x', 'edX', 'toy',
'vertical', 'vertical_test', None]), depth=1)
- draft_store.clone_item(vertical.location, vertical.location)
+ draft_store.convert_to_draft(vertical.location)
for child in vertical.get_children():
- draft_store.clone_item(child.location, child.location)
+ draft_store.convert_to_draft(child.location)
# delete the course
delete_course(module_store, content_store, location, commit=True)
diff --git a/cms/djangoapps/contentstore/tests/test_item.py b/cms/djangoapps/contentstore/tests/test_item.py
index 2b514c0726..cd203e6af7 100644
--- a/cms/djangoapps/contentstore/tests/test_item.py
+++ b/cms/djangoapps/contentstore/tests/test_item.py
@@ -49,7 +49,7 @@ class TestCreateItem(CourseTestCase):
resp = self.client.post(
reverse('create_item'),
json.dumps({
- 'parent_location': self.course_location.url(),
+ 'parent_location': self.course.location.url(),
'display_name': display_name,
'category': 'chapter'
}),
@@ -62,11 +62,11 @@ class TestCreateItem(CourseTestCase):
new_obj = modulestore().get_item(chap_location)
self.assertEqual(new_obj.category, 'chapter')
self.assertEqual(new_obj.display_name, display_name)
- self.assertEqual(new_obj.location.org, self.course_location.org)
- self.assertEqual(new_obj.location.course, self.course_location.course)
+ self.assertEqual(new_obj.location.org, self.course.location.org)
+ self.assertEqual(new_obj.location.course, self.course.location.course)
# get the course and ensure it now points to this one
- course = modulestore().get_item(self.course_location)
+ course = modulestore().get_item(self.course.location)
self.assertIn(chap_location, course.children)
# use default display name
@@ -112,7 +112,7 @@ class TestCreateItem(CourseTestCase):
resp = self.client.post(
reverse('create_item'),
json.dumps(
- {'parent_location': self.course_location.url(),
+ {'parent_location': self.course.location.url(),
'category': 'problem',
'boilerplate': 'nosuchboilerplate.yaml'
}),
@@ -140,7 +140,7 @@ class TestEditItem(CourseTestCase):
resp = self.client.post(
reverse('create_item'),
json.dumps(
- {'parent_location': self.course_location.url(),
+ {'parent_location': self.course.location.url(),
'display_name': display_name,
'category': 'chapter'
}),
@@ -209,4 +209,4 @@ class TestEditItem(CourseTestCase):
content_type="application/json"
)
problem = modulestore('draft').get_item(self.problems[0])
- self.assertIsNo/ne(problem.markdown)
+ self.assertIsNone(problem.markdown)
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py
index 9a0c87ff97..be705149f3 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py
@@ -8,6 +8,7 @@ from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.course_module import CourseDescriptor
from xblock.core import Scope
+from xmodule.x_module import XModuleDescriptor
class XModuleCourseFactory(Factory):
"""
@@ -36,7 +37,7 @@ class XModuleCourseFactory(Factory):
if display_name is not None:
new_course.display_name = display_name
- new_course.lms.start = datetime.datetime.now(UTC)
+ new_course.lms.start = datetime.datetime.now(UTC).replace(microsecond=0)
new_course.tabs = kwargs.pop(
'tabs',
[
@@ -88,21 +89,23 @@ class XModuleItemFactory(Factory):
@classmethod
def _create(cls, target_class, **kwargs):
"""
- Uses *kwargs*:
+ Uses ``**kwargs``:
- *parent_location* (required): the location of the parent module
+ :parent_location: (required): the location of the parent module
(e.g. the parent course or section)
- category: the category of the resulting item.
+ :category: the category of the resulting item.
- *data* (optional): the data for the item
+ :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
+ :display_name: (optional): the display name of the item
- *metadata* (optional): dictionary of metadata attributes
+ :metadata: (optional): dictionary of metadata attributes
- *target_class* is ignored
+ :boilerplate: (optional) the boilerplate for overriding field values
+
+ :target_class: is ignored
"""
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
@@ -115,6 +118,14 @@ class XModuleItemFactory(Factory):
metadata = kwargs.get('metadata', {})
location = kwargs.get('location', XModuleItemFactory.location(parent_location, category, display_name))
assert location != parent_location
+ if kwargs.get('boilerplate') is not None:
+ template_id = kwargs.get('boilerplate')
+ clz = XModuleDescriptor.load_class(category)
+ template = clz.get_template(template_id)
+ assert template is not None
+ metadata.update(template.get('metadata', {}))
+ if not isinstance(data, basestring):
+ data.update(template.get('data'))
store = modulestore('direct')
diff --git a/lms/djangoapps/courseware/tests/test_submitting_problems.py b/lms/djangoapps/courseware/tests/test_submitting_problems.py
index 7e9b55a4fb..0ed37cdd5c 100644
--- a/lms/djangoapps/courseware/tests/test_submitting_problems.py
+++ b/lms/djangoapps/courseware/tests/test_submitting_problems.py
@@ -59,7 +59,7 @@ class TestSubmittingProblems(ModuleStoreTestCase, LoginEnrollmentTestCase):
Returns the url of the problem given the problem's name
"""
- return "i4x://"+self.course.org+"/{}/problem/{}".format(self.COURSE_SLUG, problem_url_name)
+ return "i4x://" + self.course.org + "/{}/problem/{}".format(self.COURSE_SLUG, problem_url_name)
def modx_url(self, problem_location, dispatch):
"""
@@ -119,7 +119,6 @@ class TestSubmittingProblems(ModuleStoreTestCase, LoginEnrollmentTestCase):
num_input: the number of input fields to create in the problem
"""
- problem_template = "i4x://edx/templates/problem/Blank_Common_Problem"
prob_xml = OptionResponseXMLFactory().build_xml(
question_text='The correct answer is Correct',
num_inputs=num_inputs,
@@ -405,7 +404,7 @@ class TestCourseGrader(TestSubmittingProblems):
# Get both parts correct
self.submit_question_answer('H1P1', {'2_1': 'Correct', '2_2': 'Correct'})
self.check_grade_percent(0.25)
- self.assertEqual(self.earned_hw_scores(), [2.0]) # Order matters
+ self.assertEqual(self.earned_hw_scores(), [2.0]) # Order matters
self.assertEqual(self.score_for_hw('homework'), [2.0])
def test_weighted_exam(self):
@@ -443,7 +442,7 @@ class TestCourseGrader(TestSubmittingProblems):
self.assertEqual(self.score_for_hw('homework1'), [1.0, 0.0])
self.assertEqual(self.score_for_hw('homework2'), [1.0, 1.0])
- self.assertEqual(self.earned_hw_scores(), [1.0, 2.0, 0]) # Order matters
+ self.assertEqual(self.earned_hw_scores(), [1.0, 2.0, 0]) # Order matters
self.check_grade_percent(0.75)
def test_dropping_nochange(self):
@@ -457,7 +456,7 @@ class TestCourseGrader(TestSubmittingProblems):
self.assertEqual(self.score_for_hw('homework1'), [1.0, 0.0])
self.assertEqual(self.score_for_hw('homework2'), [1.0, 1.0])
self.assertEqual(self.score_for_hw('homework3'), [1.0, 0.0])
- self.assertEqual(self.earned_hw_scores(), [1.0, 2.0, 1.0]) # Order matters
+ self.assertEqual(self.earned_hw_scores(), [1.0, 2.0, 1.0]) # Order matters
self.check_grade_percent(0.75)
def test_dropping_all_correct(self):
@@ -471,7 +470,7 @@ class TestCourseGrader(TestSubmittingProblems):
self.submit_question_answer(name, {'2_1': 'Correct'})
self.check_grade_percent(1.0)
- self.assertEqual(self.earned_hw_scores(), [1.0, 2.0, 2.0]) # Order matters
+ self.assertEqual(self.earned_hw_scores(), [1.0, 2.0, 2.0]) # Order matters
self.assertEqual(self.score_for_hw('homework3'), [1.0, 1.0])
@@ -579,13 +578,13 @@ class TestPythonGradedResponse(TestSubmittingProblems):
set up an example Circuit_Schematic_Builder problem
"""
- schematic_template = "i4x://edx/templates/problem/Circuit_Schematic_Builder"
script = self.SCHEMATIC_SCRIPT
xmldata = SchematicResponseXMLFactory().build_xml(answer=script)
ItemFactory.create(
parent_location=self.section.location,
- template=schematic_template,
+ category='problem',
+ boilerplate='circuitschematic.yaml',
display_name=name,
data=xmldata
)
@@ -602,14 +601,14 @@ class TestPythonGradedResponse(TestSubmittingProblems):
set up an example custom response problem using a check function
"""
- custom_template = "i4x://edx/templates/problem/Custom_Python-Evaluated_Input"
test_csv = self.CUSTOM_RESPONSE_SCRIPT
expect = self.CUSTOM_RESPONSE_CORRECT
cfn_problem_xml = CustomResponseXMLFactory().build_xml(script=test_csv, cfn='test_csv', expect=expect)
ItemFactory.create(
parent_location=self.section.location,
- template=custom_template,
+ category='problem',
+ boilerplate='customgrader.yaml',
data=cfn_problem_xml,
display_name=name
)
@@ -628,13 +627,12 @@ class TestPythonGradedResponse(TestSubmittingProblems):
script = self.COMPUTED_ANSWER_SCRIPT
- custom_template = "i4x://edx/templates/problem/Custom_Python-Evaluated_Input"
-
computed_xml = CustomResponseXMLFactory().build_xml(answer=script)
ItemFactory.create(
parent_location=self.section.location,
- template=custom_template,
+ category='problem',
+ boilerplate='customgrader.yaml',
data=computed_xml,
display_name=name
)
From a512337881b56344ee7599b72947b43240c771e8 Mon Sep 17 00:00:00 2001
From: Lyla Fischer
Date: Tue, 16 Jul 2013 16:18:27 -0400
Subject: [PATCH 31/73] removed soon-to-be-universally-fixed defaults
---
.../lib/xmodule/xmodule/templates/problem/drag_and_drop.yaml | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/common/lib/xmodule/xmodule/templates/problem/drag_and_drop.yaml b/common/lib/xmodule/xmodule/templates/problem/drag_and_drop.yaml
index 3705004316..c0a45ec21c 100644
--- a/common/lib/xmodule/xmodule/templates/problem/drag_and_drop.yaml
+++ b/common/lib/xmodule/xmodule/templates/problem/drag_and_drop.yaml
@@ -1,8 +1,7 @@
---
metadata:
display_name: Drag and Drop
- rerandomize: never
- showanswer: finished
+ markdown: !!null
data: |
Here's an example of a "Drag and Drop" question set. Click and drag each word in the scrollbar below, up to the numbered bucket which matches the number of letters in the word.
@@ -40,5 +39,3 @@ data: |
-
-children: []
From ecbf148688fc3d54c9ccf4ea268bbf4216f1fc29 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Thu, 20 Jun 2013 11:46:16 -0400
Subject: [PATCH 32/73] add a new django-admin command to dump out a course
structure document. This is response to an emergency request from Harvard
researchers.
---
.../commands/dump_course_structure.py | 55 +++++++++++++++++++
1 file changed, 55 insertions(+)
create mode 100644 cms/djangoapps/contentstore/management/commands/dump_course_structure.py
diff --git a/cms/djangoapps/contentstore/management/commands/dump_course_structure.py b/cms/djangoapps/contentstore/management/commands/dump_course_structure.py
new file mode 100644
index 0000000000..d9b7c55cbd
--- /dev/null
+++ b/cms/djangoapps/contentstore/management/commands/dump_course_structure.py
@@ -0,0 +1,55 @@
+from django.core.management.base import BaseCommand, CommandError
+from xmodule.course_module import CourseDescriptor
+from xmodule.modulestore.django import modulestore
+from json import dumps
+from xmodule.modulestore.inheritance import own_metadata
+from django.conf import settings
+
+filter_list = ['xml_attributes', 'checklists']
+
+
+class Command(BaseCommand):
+ help = '''Write out to stdout a structural and metadata information about a course in a flat dictionary serialized
+ in a JSON format. This can be used for analytics.'''
+
+ def handle(self, *args, **options):
+ if len(args) < 2 or len(args) > 3:
+ raise CommandError("dump_course_structure requires two or more arguments: ||")
+
+ course_id = args[0]
+ outfile = args[1]
+
+ # use a user-specified database name, if present
+ # this is useful for doing dumps from databases restored from prod backups
+ if len(args) == 3:
+ settings.MODULESTORE['direct']['OPTIONS']['db'] = args[2]
+
+ loc = CourseDescriptor.id_to_location(course_id)
+
+ store = modulestore()
+
+ course = None
+ try:
+ course = store.get_item(loc, depth=4)
+ except:
+ print 'Could not find course at {0}'.format(course_id)
+ return
+
+ info = {}
+
+ def dump_into_dict(module, info):
+ filtered_metadata = dict((key, value) for key, value in own_metadata(module).iteritems()
+ if key not in filter_list)
+ info[module.location.url()] = {
+ 'category': module.location.category,
+ 'children': module.children if hasattr(module, 'children') else [],
+ 'metadata': filtered_metadata
+ }
+
+ for child in module.get_children():
+ dump_into_dict(child, info)
+
+ dump_into_dict(course, info)
+
+ with open(outfile, 'w') as f:
+ f.write(dumps(info))
From a47daf26e45455f596498bde8e44af2dec63f6e6 Mon Sep 17 00:00:00 2001
From: Lyla Fischer
Date: Tue, 16 Jul 2013 18:22:48 -0400
Subject: [PATCH 33/73] removed uneeded defaults from imageresponse
---
.../lib/xmodule/xmodule/templates/problem/imageresponse.yaml | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/common/lib/xmodule/xmodule/templates/problem/imageresponse.yaml b/common/lib/xmodule/xmodule/templates/problem/imageresponse.yaml
index d9acb9359c..ab804178ca 100644
--- a/common/lib/xmodule/xmodule/templates/problem/imageresponse.yaml
+++ b/common/lib/xmodule/xmodule/templates/problem/imageresponse.yaml
@@ -1,8 +1,7 @@
---
metadata:
display_name: Image Mapped Input
- rerandomize: never
- showanswer: finished
+ markdown: !!null
data: |
@@ -21,5 +20,3 @@ data: |
-
-children: []
From 5b367e8321279d65771da91b465a1253a00f7b01 Mon Sep 17 00:00:00 2001
From: Don Mitchell
Date: Wed, 17 Jul 2013 09:22:34 -0400
Subject: [PATCH 34/73] Missing comma on template -> category refactoring
And several pep8 changes
---
lms/djangoapps/courseware/features/common.py | 21 ++++++++++----------
1 file changed, 11 insertions(+), 10 deletions(-)
diff --git a/lms/djangoapps/courseware/features/common.py b/lms/djangoapps/courseware/features/common.py
index 55e82e0e90..7632f22d6a 100644
--- a/lms/djangoapps/courseware/features/common.py
+++ b/lms/djangoapps/courseware/features/common.py
@@ -5,11 +5,10 @@ from __future__ import absolute_import
from lettuce import world, step
from nose.tools import assert_equals, assert_in
-from lettuce.django import django_url
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.modulestore.django import modulestore
from xmodule.course_module import CourseDescriptor
from courseware.courses import get_course_by_id
from xmodule import seq_module, vertical_module
@@ -19,7 +18,7 @@ logger = getLogger(__name__)
@step(u'The course "([^"]*)" exists$')
-def create_course(step, course):
+def create_course(_step, course):
# First clear the modulestore so we don't try to recreate
# the same course twice
@@ -37,9 +36,10 @@ def create_course(step, course):
world.scenario_dict['SECTION'] = world.ItemFactory.create(parent_location=world.scenario_dict['COURSE'].location,
display_name='Test Section')
- problem_section = world.ItemFactory.create(parent_location=world.scenario_dict['SECTION'].location,
- category='sequential'
- display_name='Test Section')
+ world.ItemFactory.create(
+ parent_location=world.scenario_dict['SECTION'].location,
+ category='sequential',
+ display_name='Test Section')
@step(u'I am registered for the course "([^"]*)"$')
@@ -59,10 +59,11 @@ def i_am_registered_for_the_course(step, course):
@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),
- category="static_tab",
- display_name=str(extra_tab_name))
+def add_tab_to_course(_step, course, extra_tab_name):
+ world.ItemFactory.create(
+ parent_location=course_location(course),
+ category="static_tab",
+ display_name=str(extra_tab_name))
def course_id(course_num):
From dfa435012b75b187bd8ed7e510ce1518a5a70048 Mon Sep 17 00:00:00 2001
From: Adam Palay
Date: Wed, 3 Jul 2013 18:01:11 -0400
Subject: [PATCH 35/73] notify students for NotFoundErrors from capa_module,
improve error logging
---
common/lib/xmodule/xmodule/capa_module.py | 19 +++-
lms/djangoapps/courseware/module_render.py | 11 ++-
.../tests/test_submitting_problems.py | 96 +++++++++++++++++--
3 files changed, 109 insertions(+), 17 deletions(-)
diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py
index ef3e1999c8..51c1a396c3 100644
--- a/common/lib/xmodule/xmodule/capa_module.py
+++ b/common/lib/xmodule/xmodule/capa_module.py
@@ -152,6 +152,7 @@ class CapaFields(object):
scope=Scope.settings
)
+
class CapaModule(CapaFields, XModule):
"""
An XModule implementing LonCapa format problems, implemented by way of
@@ -552,6 +553,16 @@ class CapaModule(CapaFields, XModule):
'ungraded_response': self.handle_ungraded_response
}
+ generic_error_message = (
+ "We're sorry, there was an error with processing your request. "
+ "Please try reloading your page and trying again."
+ )
+
+ not_found_error_message = (
+ "The state of this problem has changed since you loaded this page. "
+ "Please refresh your page."
+ )
+
if dispatch not in handlers:
return 'Error'
@@ -559,9 +570,14 @@ class CapaModule(CapaFields, XModule):
try:
result = handlers[dispatch](data)
+
+ except NotFoundError as err:
+ _, _, traceback_obj = sys.exc_info()
+ raise ProcessingError, (not_found_error_message, err), traceback_obj
+
except Exception as err:
_, _, traceback_obj = sys.exc_info()
- raise ProcessingError(err.message, traceback_obj)
+ raise ProcessingError, (generic_error_message, err), traceback_obj
after = self.get_progress()
@@ -1125,7 +1141,6 @@ class CapaDescriptor(CapaFields, RawDescriptor):
problem.rerandomize = "always"
return problem
-
@property
def non_editable_metadata_fields(self):
non_editable_fields = super(CapaDescriptor, self).non_editable_metadata_fields
diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py
index db7ba1641e..c343701a94 100644
--- a/lms/djangoapps/courseware/module_render.py
+++ b/lms/djangoapps/courseware/module_render.py
@@ -10,7 +10,7 @@ from django.core.cache import cache
from django.core.exceptions import PermissionDenied
from django.core.urlresolvers import reverse
from django.http import Http404
-from django.http import HttpResponse, HttpResponseBadRequest
+from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
import pyparsing
@@ -38,6 +38,7 @@ from courseware.masquerade import setup_masquerade
from courseware.model_data import LmsKeyValueStore, LmsUsage, ModelDataCache
from courseware.models import StudentModule
from util.sandboxing import can_execute_unsafe_code
+from util.json_request import JsonResponse
log = logging.getLogger(__name__)
@@ -509,11 +510,11 @@ def modx_dispatch(request, dispatch, location, course_id):
log.exception("Module indicating to user that request doesn't exist")
raise Http404
- # For XModule-specific errors, we respond with 400
- except ProcessingError:
- log.warning("Module encountered an error while prcessing AJAX call",
+ # For XModule-specific errors, we log the error and respond with an error message
+ except ProcessingError as err:
+ log.warning("Module encountered an error while processing AJAX call",
exc_info=True)
- return HttpResponseBadRequest()
+ return JsonResponse(object={'success': err.args[0]}, status=200)
# If any other error occurred, re-raise it to trigger a 500 response
except:
diff --git a/lms/djangoapps/courseware/tests/test_submitting_problems.py b/lms/djangoapps/courseware/tests/test_submitting_problems.py
index 0ed37cdd5c..9081a910c9 100644
--- a/lms/djangoapps/courseware/tests/test_submitting_problems.py
+++ b/lms/djangoapps/courseware/tests/test_submitting_problems.py
@@ -107,6 +107,15 @@ class TestSubmittingProblems(ModuleStoreTestCase, LoginEnrollmentTestCase):
resp = self.client.post(modx_url)
return resp
+ def show_question_answer(self, problem_url_name):
+ """
+ Shows the answer to the current student.
+ """
+ problem_location = self.problem_location(problem_url_name)
+ modx_url = self.modx_url(problem_location, 'problem_show')
+ resp = self.client.post(modx_url)
+ return resp
+
def add_dropdown_to_section(self, section_location, name, num_inputs=2):
"""
Create and return a dropdown problem.
@@ -131,7 +140,7 @@ class TestSubmittingProblems(ModuleStoreTestCase, LoginEnrollmentTestCase):
parent_location=section_location,
category='problem',
data=prob_xml,
- metadata={'randomize': 'always'},
+ metadata={'rerandomize': 'always'},
display_name=name
)
@@ -139,7 +148,7 @@ class TestSubmittingProblems(ModuleStoreTestCase, LoginEnrollmentTestCase):
self.refresh_course()
return problem
- def add_graded_section_to_course(self, name, section_format='Homework'):
+ def add_graded_section_to_course(self, name, section_format='Homework', late=False, reset=False, showanswer=False):
"""
Creates a graded homework section within a chapter and returns the section.
"""
@@ -151,12 +160,44 @@ class TestSubmittingProblems(ModuleStoreTestCase, LoginEnrollmentTestCase):
category='chapter'
)
- section = ItemFactory.create(
- parent_location=self.chapter.location,
- display_name=name,
- category='sequential',
- metadata={'graded': True, 'format': section_format}
- )
+ if late:
+ section = ItemFactory.create(
+ parent_location=self.chapter.location,
+ display_name=name,
+ category='sequential',
+ metadata={'graded': True, 'format': section_format, 'due': '2013-05-20T23:30'}
+ )
+ elif reset:
+ section = ItemFactory.create(
+ parent_location=self.chapter.location,
+ display_name=name,
+ category='sequential',
+ rerandomize='always',
+ metadata={
+ 'graded': True,
+ 'format': section_format,
+ }
+ )
+
+ elif showanswer:
+ section = ItemFactory.create(
+ parent_location=self.chapter.location,
+ display_name=name,
+ category='sequential',
+ showanswer='never',
+ metadata={
+ 'graded': True,
+ 'format': section_format,
+ }
+ )
+
+ else:
+ section = ItemFactory.create(
+ parent_location=self.chapter.location,
+ display_name=name,
+ category='sequential',
+ metadata={'graded': True, 'format': section_format}
+ )
# now that we've added the problem and section to the course
# we fetch the course from the database so the object we are
@@ -257,7 +298,7 @@ class TestCourseGrader(TestSubmittingProblems):
hw_section = next(section for section in sections_list if section.get('url_name') == hw_url_name)
return [s.earned for s in hw_section['scores']]
- def basic_setup(self):
+ def basic_setup(self, late=False, reset=False, showanswer=False):
"""
Set up a simple course for testing basic grading functionality.
"""
@@ -278,7 +319,7 @@ class TestCourseGrader(TestSubmittingProblems):
self.add_grading_policy(grading_policy)
# set up a simple course with four problems
- self.homework = self.add_graded_section_to_course('homework')
+ self.homework = self.add_graded_section_to_course('homework', late=late, reset=reset, showanswer=showanswer)
self.add_dropdown_to_section(self.homework.location, 'p1', 1)
self.add_dropdown_to_section(self.homework.location, 'p2', 1)
self.add_dropdown_to_section(self.homework.location, 'p3', 1)
@@ -346,6 +387,41 @@ class TestCourseGrader(TestSubmittingProblems):
self.add_dropdown_to_section(self.homework3.location, self.hw3_names[0], 1)
self.add_dropdown_to_section(self.homework3.location, self.hw3_names[1], 1)
+ def test_submission_late(self):
+ """Test problem for due date in the past"""
+ self.basic_setup(late=True)
+ resp = self.submit_question_answer('p1', {'2_1': 'Correct'})
+ self.assertEqual(resp.status_code, 200)
+ err_msg = (
+ "The state of this problem has changed since you loaded this page. "
+ "Please refresh your page."
+ )
+ self.assertEqual(json.loads(resp.content).get("success"), err_msg)
+
+ def test_submission_reset(self):
+ """Test problem ProcessingErrors due to resets"""
+ self.basic_setup(reset=True)
+ resp = self.submit_question_answer('p1', {'2_1': 'Correct'})
+ # submit a second time to draw NotFoundError
+ resp = self.submit_question_answer('p1', {'2_1': 'Correct'})
+ self.assertEqual(resp.status_code, 200)
+ err_msg = (
+ "The state of this problem has changed since you loaded this page. "
+ "Please refresh your page."
+ )
+ self.assertEqual(json.loads(resp.content).get("success"), err_msg)
+
+ def test_submission_show_answer(self):
+ """Test problem for ProcessingErrors due to showing answer"""
+ self.basic_setup(showanswer=True)
+ resp = self.show_question_answer('p1')
+ self.assertEqual(resp.status_code, 200)
+ err_msg = (
+ "The state of this problem has changed since you loaded this page. "
+ "Please refresh your page."
+ )
+ self.assertEqual(json.loads(resp.content).get("success"), err_msg)
+
def test_none_grade(self):
"""
Check grade is 0 to begin with.
From 8325e28a10249c383a35165592c8627987d52eea Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Tue, 16 Jul 2013 10:49:41 -0400
Subject: [PATCH 36/73] add a default XML serialization if the 'data' field is
empty or None. Some XModules now - like video XModule - do not have XML set
in the 'data' field
---
.../contentstore/tests/test_contentstore.py | 31 +++++++++++++++++++
common/lib/xmodule/xmodule/video_module.py | 7 +++++
.../test/data/toy/vertical/vertical_test.xml | 1 +
3 files changed, 39 insertions(+)
diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py
index be122fa1a4..0166d7574e 100644
--- a/cms/djangoapps/contentstore/tests/test_contentstore.py
+++ b/cms/djangoapps/contentstore/tests/test_contentstore.py
@@ -792,6 +792,37 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
shutil.rmtree(root_dir)
+ def test_export_course_with_metadata_only_video(self):
+ module_store = modulestore('direct')
+ draft_store = modulestore('draft')
+ content_store = contentstore()
+
+ import_from_xml(module_store, 'common/test/data/', ['toy'])
+ location = CourseDescriptor.id_to_location('edX/toy/2012_Fall')
+
+ # create a new video module and add it as a child to a vertical
+ # this re-creates a bug whereby since the video template doesn't have
+ # anything in 'data' field, the export was blowing up
+ verticals = module_store.get_items(['i4x', 'edX', 'toy', 'vertical', None, None])
+
+ self.assertGreater(len(verticals), 0)
+
+ new_component_location = Location('i4x', 'edX', 'toy', 'video', 'new_component')
+ source_template_location = Location('i4x', 'edx', 'templates', 'video', 'default')
+
+ module_store.clone_item(source_template_location, new_component_location)
+ parent = verticals[0]
+ module_store.update_children(parent.location, parent.children + [new_component_location.url()])
+
+ root_dir = path(mkdtemp_clean())
+
+ print 'Exporting to tempdir = {0}'.format(root_dir)
+
+ # export out to a tempdir
+ export_to_xml(module_store, content_store, location, root_dir, 'test_export', draft_modulestore=draft_store)
+
+ shutil.rmtree(root_dir)
+
def test_course_handouts_rewrites(self):
module_store = modulestore('direct')
diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py
index 3c6203107d..e2023eb2a8 100644
--- a/common/lib/xmodule/xmodule/video_module.py
+++ b/common/lib/xmodule/xmodule/video_module.py
@@ -118,6 +118,13 @@ class VideoDescriptor(VideoFields,
_parse_video_xml(video, xml_data)
return video
+ def definition_to_xml(self, resource_fs):
+ """
+ Override the base implementation. We don't actually have anything in the 'data' field
+ (it's an empty string), so we just return a simple XML element
+ """
+ return etree.fromstring('')
+
def _parse_video_xml(video, xml_data):
"""
diff --git a/common/test/data/toy/vertical/vertical_test.xml b/common/test/data/toy/vertical/vertical_test.xml
index e801a4ac86..f241ef7906 100644
--- a/common/test/data/toy/vertical/vertical_test.xml
+++ b/common/test/data/toy/vertical/vertical_test.xml
@@ -1,4 +1,5 @@
+
Have you changed your mind?
Yes
From 139b12f25b090d09a9edb767baacf08d5fa48232 Mon Sep 17 00:00:00 2001
From: Xavier Antoviaque
Date: Thu, 18 Jul 2013 09:14:09 -0300
Subject: [PATCH 37/73] Install: Fixes "Don't know how to build task
'cms:update_templates'"
Remove the `update_templates command from the installation script, this
command was removed by
https://github.com/edx/edx-platform/commit/3722685e1a185ff4a0a085bb380ed793cf6d1e59
---
scripts/create-dev-env.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/scripts/create-dev-env.sh b/scripts/create-dev-env.sh
index a28ee1a8b4..8699cc7656 100755
--- a/scripts/create-dev-env.sh
+++ b/scripts/create-dev-env.sh
@@ -497,7 +497,7 @@ mkdir -p "$BASE/data"
rake django-admin[syncdb,lms,dev,--noinput]
rake django-admin[migrate]
-rake cms:update_templates
+
# Configure Git
output "Fixing your git default settings"
From 66287d0d9955c1524a08ecc0250f1ea74a168678 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Wed, 17 Jul 2013 18:35:49 -0400
Subject: [PATCH 38/73] fix test to use the new create_item method rather than
clone_item, which has been deprecated, and thus breaking the test
---
cms/djangoapps/contentstore/tests/test_contentstore.py | 7 ++-----
common/lib/xmodule/xmodule/video_module.py | 2 +-
2 files changed, 3 insertions(+), 6 deletions(-)
diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py
index 200f2575df..500db414f4 100644
--- a/cms/djangoapps/contentstore/tests/test_contentstore.py
+++ b/cms/djangoapps/contentstore/tests/test_contentstore.py
@@ -816,12 +816,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
self.assertGreater(len(verticals), 0)
- new_component_location = Location('i4x', 'edX', 'toy', 'video', 'new_component')
- source_template_location = Location('i4x', 'edx', 'templates', 'video', 'default')
-
- module_store.clone_item(source_template_location, new_component_location)
parent = verticals[0]
- module_store.update_children(parent.location, parent.children + [new_component_location.url()])
+
+ ItemFactory.create(parent_location=parent.location, category="video", display_name="untitled")
root_dir = path(mkdtemp_clean())
diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py
index ecc1813c4c..5354297c2b 100644
--- a/common/lib/xmodule/xmodule/video_module.py
+++ b/common/lib/xmodule/xmodule/video_module.py
@@ -133,7 +133,7 @@ class VideoDescriptor(VideoFields,
Override the base implementation. We don't actually have anything in the 'data' field
(it's an empty string), so we just return a simple XML element
"""
- return etree.fromstring('')
+ return etree.Element('video')
def _parse_video_xml(video, xml_data):
From 99ff3719cc4ac2a63a3fc80397b354b95a728f50 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Thu, 18 Jul 2013 10:35:50 -0400
Subject: [PATCH 39/73] remove the video from the toy course for now as the
import-export-import isn't totally equivalence.
---
common/test/data/toy/vertical/vertical_test.xml | 1 -
1 file changed, 1 deletion(-)
diff --git a/common/test/data/toy/vertical/vertical_test.xml b/common/test/data/toy/vertical/vertical_test.xml
index f241ef7906..e801a4ac86 100644
--- a/common/test/data/toy/vertical/vertical_test.xml
+++ b/common/test/data/toy/vertical/vertical_test.xml
@@ -1,5 +1,4 @@
-
Have you changed your mind?
Yes
From 07aad296841bf741e300f5f77ac826c4f9eaa9dc Mon Sep 17 00:00:00 2001
From: Ned Batchelder
Date: Fri, 12 Jul 2013 17:58:44 -0400
Subject: [PATCH 40/73] The clean_history management command to remove excess
courseware_studentmodulehistory records.
---
.../management/commands/clean_history.py | 217 +++++++++
.../management/tests/test_clean_history.py | 459 ++++++++++++++++++
2 files changed, 676 insertions(+)
create mode 100644 lms/djangoapps/courseware/management/commands/clean_history.py
create mode 100644 lms/djangoapps/courseware/management/tests/test_clean_history.py
diff --git a/lms/djangoapps/courseware/management/commands/clean_history.py b/lms/djangoapps/courseware/management/commands/clean_history.py
new file mode 100644
index 0000000000..2c243a43be
--- /dev/null
+++ b/lms/djangoapps/courseware/management/commands/clean_history.py
@@ -0,0 +1,217 @@
+"""A command to clean the StudentModuleHistory table.
+
+When we added XBlock storage, each field modification wrote a new history row
+to the db. Now that we have bulk saves to avoid that database hammering, we
+need to clean out the unnecessary rows from the database.
+
+This command that does that.
+
+"""
+
+import datetime
+import json
+from optparse import make_option
+import traceback
+
+from django.core.management.base import NoArgsCommand
+from django.db import connection
+
+
+class Command(NoArgsCommand):
+ """The actual clean_history command to clean history rows."""
+
+ help = "Deletes unneeded rows from the StudentModuleHistory table."
+
+ option_list = NoArgsCommand.option_list + (
+ make_option(
+ '--dry-run',
+ action='store_true',
+ default=False,
+ help="Don't change the database, just show what would be done.",
+ ),
+ )
+
+ def handle_noargs(self, **options):
+ smhc = StudentModuleHistoryCleaner(
+ dry_run=options["dry_run"],
+ )
+ smhc.main()
+
+
+class StudentModuleHistoryCleaner(object):
+ """Logic to clean rows from the StudentModuleHistory table."""
+
+ DELETE_GAP_SECS = 0.5 # Rows this close can be discarded.
+ STATE_FILE = "clean_history.json"
+ BATCH_SIZE = 100
+
+ def __init__(self, dry_run=False):
+ self.dry_run = dry_run
+ self.next_student_module_id = 0
+ self.last_student_module_id = 0
+
+ def main(self, batch_size=None):
+ """Invoked from the management command to do all the work."""
+
+ batch_size = batch_size or self.BATCH_SIZE
+
+ self.last_student_module_id = self.get_last_student_module_id()
+ self.load_state()
+
+ while self.next_student_module_id <= self.last_student_module_id:
+ for smid in self.module_ids_to_check(batch_size):
+ try:
+ self.clean_one_student_module(smid)
+ except Exception: # pylint: disable=W0703
+ trace = traceback.format_exc()
+ self.say("Couldn't clean student_module_id {}:\n{}".format(smid, trace))
+ self.commit()
+ self.save_state()
+
+ def say(self, message):
+ """
+ Display a message to the user.
+
+ The message will have a trailing newline added to it.
+
+ """
+ print message
+
+ def commit(self):
+ """
+ Commit the transaction.
+ """
+ self.say("Committing")
+ connection.commit()
+
+ def load_state(self):
+ """
+ Load the latest state from disk.
+ """
+ try:
+ state_file = open(self.STATE_FILE)
+ except IOError:
+ self.say("No stored state")
+ self.next_student_module_id = 0
+ else:
+ with state_file:
+ state = json.load(state_file)
+ self.say(
+ "Loaded stored state: {}".format(
+ json.dumps(state, sort_keys=True)
+ )
+ )
+ self.next_student_module_id = state['next_student_module_id']
+
+ def save_state(self):
+ """
+ Save the state to disk.
+ """
+ state = {
+ 'next_student_module_id': self.next_student_module_id,
+ }
+ with open(self.STATE_FILE, "w") as state_file:
+ json.dump(state, state_file)
+ self.say("Saved state: {}".format(json.dumps(state, sort_keys=True)))
+
+ def get_last_student_module_id(self):
+ """
+ Return the id of the last student_module.
+ """
+ cursor = connection.cursor()
+ cursor.execute("""
+ SELECT max(student_module_id) FROM courseware_studentmodulehistory
+ """)
+ last = cursor.fetchone()[0]
+ self.say("Last student_module_id is {}".format(last))
+ return last
+
+ def module_ids_to_check(self, batch_size):
+ """Produce a sequence of student module ids to check.
+
+ `batch_size` is how many module ids to produce, max.
+
+ The sequence starts with `next_student_module_id`, and goes up to
+ and including `last_student_module_id`.
+
+ `next_student_module_id` is updated as each id is yielded.
+
+ """
+ start = self.next_student_module_id
+ for smid in range(start, start+batch_size):
+ if smid > self.last_student_module_id:
+ break
+ yield smid
+ self.next_student_module_id = smid+1
+
+ def get_history_for_student_modules(self, student_module_id):
+ """
+ Get the history rows for a student module.
+
+ ```student_module_id```: the id of the student module we're
+ interested in.
+
+ Return a list: [(id, created), ...], all the rows of history.
+
+ """
+ cursor = connection.cursor()
+ cursor.execute("""
+ SELECT id, created FROM courseware_studentmodulehistory
+ WHERE student_module_id = %s
+ ORDER BY created
+ """,
+ [student_module_id]
+ )
+ history = cursor.fetchall()
+ return history
+
+ def delete_history(self, ids_to_delete):
+ """
+ Delete history rows.
+
+ ```ids_to_delete```: a non-empty list (or set...) of history row ids to delete.
+
+ """
+ assert ids_to_delete
+ cursor = connection.cursor()
+ cursor.execute("""
+ DELETE FROM courseware_studentmodulehistory
+ WHERE id IN ({ids})
+ """.format(ids=",".join(str(i) for i in ids_to_delete))
+ )
+
+ def clean_one_student_module(self, student_module_id):
+ """Clean one StudentModule's-worth of history.
+
+ `student_module_id`: the id of the StudentModule to process.
+
+ """
+ delete_gap = datetime.timedelta(seconds=self.DELETE_GAP_SECS)
+
+ history = self.get_history_for_student_modules(student_module_id)
+ if not history:
+ self.say("No history for student_module_id {}".format(student_module_id))
+ return
+
+ ids_to_delete = []
+ next_created = None
+ for history_id, created in reversed(history):
+ if next_created is not None:
+ # Compare this timestamp with the next one.
+ if (next_created - created) < delete_gap:
+ # This row is followed closely by another, we can discard
+ # this one.
+ ids_to_delete.append(history_id)
+
+ next_created = created
+
+ verb = "Would have deleted" if self.dry_run else "Deleting"
+ self.say("{verb} {to_delete} rows of {total} for student_module_id {id}".format(
+ verb=verb,
+ to_delete=len(ids_to_delete),
+ total=len(history),
+ id=student_module_id,
+ ))
+
+ if ids_to_delete and not self.dry_run:
+ self.delete_history(ids_to_delete)
diff --git a/lms/djangoapps/courseware/management/tests/test_clean_history.py b/lms/djangoapps/courseware/management/tests/test_clean_history.py
new file mode 100644
index 0000000000..cf681a7b0a
--- /dev/null
+++ b/lms/djangoapps/courseware/management/tests/test_clean_history.py
@@ -0,0 +1,459 @@
+"""Test the clean_history management command."""
+
+import fnmatch
+from mock import Mock
+import os.path
+import textwrap
+
+import dateutil.parser
+
+from django.test import TestCase
+from django.db import connection
+
+from courseware.management.commands.clean_history import StudentModuleHistoryCleaner
+
+# In lots of places in this file, smhc == StudentModuleHistoryCleaner
+
+def parse_date(sdate):
+ """Parse a string date into a datetime."""
+ parsed = dateutil.parser.parse(sdate)
+ parsed = parsed.replace(tzinfo=dateutil.tz.gettz('UTC'))
+ return parsed
+
+class SmhcSayStubbed(StudentModuleHistoryCleaner):
+ """StudentModuleHistoryCleaner, but with .say() stubbed for testing."""
+ def __init__(self, **kwargs):
+ super(SmhcSayStubbed, self).__init__(**kwargs)
+ self.said_lines = []
+
+ def say(self, msg):
+ self.said_lines.append(msg)
+
+
+class SmhcDbMocked(SmhcSayStubbed):
+ """StudentModuleHistoryCleaner, but with db access mocked."""
+ def __init__(self, **kwargs):
+ super(SmhcDbMocked, self).__init__(**kwargs)
+ self.get_history_for_student_modules = Mock()
+ self.delete_history = Mock()
+
+ def set_rows(self, rows):
+ """Set the mocked history rows."""
+ rows = [(row_id, parse_date(created)) for row_id, created in rows]
+ self.get_history_for_student_modules.return_value = rows
+
+
+class HistoryCleanerTest(TestCase):
+ """Base class for all history cleaner tests."""
+
+ maxDiff = None
+
+ def setUp(self):
+ super(HistoryCleanerTest, self).setUp()
+ self.addCleanup(self.clean_up_state_file)
+
+ def write_state_file(self, state):
+ """Write the string `state` into the state file read by StudentModuleHistoryCleaner."""
+ with open(StudentModuleHistoryCleaner.STATE_FILE, "w") as state_file:
+ state_file.write(state)
+
+ def read_state_file(self):
+ """Return the string contents of the state file read by StudentModuleHistoryCleaner."""
+ with open(StudentModuleHistoryCleaner.STATE_FILE) as state_file:
+ return state_file.read()
+
+ def clean_up_state_file(self):
+ """Remove any state file lying around."""
+ if os.path.exists(StudentModuleHistoryCleaner.STATE_FILE):
+ os.remove(StudentModuleHistoryCleaner.STATE_FILE)
+
+ def assert_said(self, smhc, *msgs):
+ """Fail if the `smhc` didn't say `msgs`.
+
+ The messages passed here are `fnmatch`-style patterns: "*" means anything.
+
+ """
+ for said, pattern in zip(smhc.said_lines, msgs):
+ if not fnmatch.fnmatch(said, pattern):
+ fmt = textwrap.dedent("""\
+ Messages:
+
+ {msgs}
+
+ don't match patterns:
+
+ {patterns}
+
+ Failed at {said!r} and {pattern!r}
+ """)
+
+ msg = fmt.format(
+ msgs="\n".join(smhc.said_lines),
+ patterns="\n".join(msgs),
+ said=said,
+ pattern=pattern
+ )
+ self.fail(msg)
+
+ def parse_rows(self, rows):
+ """Parse convenient rows into real data."""
+ rows = [
+ (row_id, parse_date(created), student_module_id)
+ for row_id, created, student_module_id in rows
+ ]
+ return rows
+
+ def write_history(self, rows):
+ """Write history rows to the db.
+
+ Each row should be (id, created, student_module_id).
+
+ """
+ cursor = connection.cursor()
+ cursor.executemany("""
+ INSERT INTO courseware_studentmodulehistory
+ (id, created, student_module_id)
+ VALUES (%s, %s, %s)
+ """,
+ self.parse_rows(rows),
+ )
+
+ def read_history(self):
+ """Read the history from the db, and return it as a list of tuples.
+
+ Returns [(id, created, student_module_id), ...]
+
+ """
+ cursor = connection.cursor()
+ cursor.execute("""
+ SELECT id, created, student_module_id FROM courseware_studentmodulehistory
+ """)
+ return cursor.fetchall()
+
+ def assert_history(self, rows):
+ """Assert that the history rows are the same as `rows`."""
+ self.assertEqual(self.parse_rows(rows), self.read_history())
+
+
+class HistoryCleanerNoDbTest(HistoryCleanerTest):
+ """Tests of StudentModuleHistoryCleaner with db access mocked."""
+
+ def test_empty(self):
+ smhc = SmhcDbMocked()
+ smhc.set_rows([])
+
+ smhc.clean_one_student_module(1)
+ self.assert_said(smhc, "No history for student_module_id 1")
+
+ # Nothing to delete, so delete_history wasn't called.
+ self.assertFalse(smhc.delete_history.called)
+
+ def test_one_row(self):
+ smhc = SmhcDbMocked()
+ smhc.set_rows([
+ (1, "2013-07-13 12:11:10.987"),
+ ])
+ smhc.clean_one_student_module(1)
+ self.assert_said(smhc, "Deleting 0 rows of 1 for student_module_id 1")
+ # Nothing to delete, so delete_history wasn't called.
+ self.assertFalse(smhc.delete_history.called)
+
+ def test_one_row_dry_run(self):
+ smhc = SmhcDbMocked(dry_run=True)
+ smhc.set_rows([
+ (1, "2013-07-13 12:11:10.987"),
+ ])
+ smhc.clean_one_student_module(1)
+ self.assert_said(smhc, "Would have deleted 0 rows of 1 for student_module_id 1")
+ # Nothing to delete, so delete_history wasn't called.
+ self.assertFalse(smhc.delete_history.called)
+
+ def test_two_rows_close(self):
+ smhc = SmhcDbMocked()
+ smhc.set_rows([
+ (7, "2013-07-13 12:34:56.789"),
+ (9, "2013-07-13 12:34:56.987"),
+ ])
+ smhc.clean_one_student_module(1)
+ self.assert_said(smhc, "Deleting 1 rows of 2 for student_module_id 1")
+ smhc.delete_history.assert_called_once_with([7])
+
+ def test_two_rows_far(self):
+ smhc = SmhcDbMocked()
+ smhc.set_rows([
+ (7, "2013-07-13 12:34:56.789"),
+ (9, "2013-07-13 12:34:57.890"),
+ ])
+ smhc.clean_one_student_module(1)
+ self.assert_said(smhc, "Deleting 0 rows of 2 for student_module_id 1")
+ self.assertFalse(smhc.delete_history.called)
+
+ def test_a_bunch_of_rows(self):
+ smhc = SmhcDbMocked()
+ smhc.set_rows([
+ ( 4, "2013-07-13 16:30:00.000"), # keep
+ ( 8, "2013-07-13 16:30:01.100"),
+ (15, "2013-07-13 16:30:01.200"),
+ (16, "2013-07-13 16:30:01.300"), # keep
+ (23, "2013-07-13 16:30:02.400"),
+ (42, "2013-07-13 16:30:02.500"),
+ (98, "2013-07-13 16:30:02.600"), # keep
+ (99, "2013-07-13 16:30:59.000"), # keep
+ ])
+ smhc.clean_one_student_module(17)
+ self.assert_said(smhc, "Deleting 4 rows of 8 for student_module_id 17")
+ smhc.delete_history.assert_called_once_with([42, 23, 15, 8])
+
+
+class HistoryCleanerWitDbTest(HistoryCleanerTest):
+ """Tests of StudentModuleHistoryCleaner with a real db."""
+
+ def test_no_history(self):
+ # Cleaning a student_module_id with no history leaves the db unchanged.
+ smhc = SmhcSayStubbed()
+ self.write_history([
+ ( 4, "2013-07-13 16:30:00.000", 11), # keep
+ ( 8, "2013-07-13 16:30:01.100", 11),
+ (15, "2013-07-13 16:30:01.200", 11),
+ (16, "2013-07-13 16:30:01.300", 11), # keep
+ (23, "2013-07-13 16:30:02.400", 11),
+ (42, "2013-07-13 16:30:02.500", 11),
+ (98, "2013-07-13 16:30:02.600", 11), # keep
+ (99, "2013-07-13 16:30:59.000", 11), # keep
+ ])
+
+ smhc.clean_one_student_module(22)
+ self.assert_said(smhc, "No history for student_module_id 22")
+ self.assert_history([
+ ( 4, "2013-07-13 16:30:00.000", 11), # keep
+ ( 8, "2013-07-13 16:30:01.100", 11),
+ (15, "2013-07-13 16:30:01.200", 11),
+ (16, "2013-07-13 16:30:01.300", 11), # keep
+ (23, "2013-07-13 16:30:02.400", 11),
+ (42, "2013-07-13 16:30:02.500", 11),
+ (98, "2013-07-13 16:30:02.600", 11), # keep
+ (99, "2013-07-13 16:30:59.000", 11), # keep
+ ])
+
+ def test_a_bunch_of_rows(self):
+ # Cleaning a student_module_id with 8 records, 4 to delete.
+ smhc = SmhcSayStubbed()
+ self.write_history([
+ ( 4, "2013-07-13 16:30:00.000", 11), # keep
+ ( 8, "2013-07-13 16:30:01.100", 11),
+ (15, "2013-07-13 16:30:01.200", 11),
+ (16, "2013-07-13 16:30:01.300", 11), # keep
+ (17, "2013-07-13 16:30:01.310", 22), # other student_module_id!
+ (23, "2013-07-13 16:30:02.400", 11),
+ (42, "2013-07-13 16:30:02.500", 11),
+ (98, "2013-07-13 16:30:02.600", 11), # keep
+ (99, "2013-07-13 16:30:59.000", 11), # keep
+ ])
+
+ smhc.clean_one_student_module(11)
+ self.assert_said(smhc, "Deleting 4 rows of 8 for student_module_id 11")
+ self.assert_history([
+ ( 4, "2013-07-13 16:30:00.000", 11), # keep
+ (16, "2013-07-13 16:30:01.300", 11), # keep
+ (17, "2013-07-13 16:30:01.310", 22), # other student_module_id!
+ (98, "2013-07-13 16:30:02.600", 11), # keep
+ (99, "2013-07-13 16:30:59.000", 11), # keep
+ ])
+
+ def test_a_bunch_of_rows_dry_run(self):
+ # Cleaning a student_module_id with 8 records, 4 to delete,
+ # but don't really do it.
+ smhc = SmhcSayStubbed(dry_run=True)
+ self.write_history([
+ ( 4, "2013-07-13 16:30:00.000", 11), # keep
+ ( 8, "2013-07-13 16:30:01.100", 11),
+ (15, "2013-07-13 16:30:01.200", 11),
+ (16, "2013-07-13 16:30:01.300", 11), # keep
+ (23, "2013-07-13 16:30:02.400", 11),
+ (42, "2013-07-13 16:30:02.500", 11),
+ (98, "2013-07-13 16:30:02.600", 11), # keep
+ (99, "2013-07-13 16:30:59.000", 11), # keep
+ ])
+
+ smhc.clean_one_student_module(11)
+ self.assert_said(smhc, "Would have deleted 4 rows of 8 for student_module_id 11")
+ self.assert_history([
+ ( 4, "2013-07-13 16:30:00.000", 11), # keep
+ ( 8, "2013-07-13 16:30:01.100", 11),
+ (15, "2013-07-13 16:30:01.200", 11),
+ (16, "2013-07-13 16:30:01.300", 11), # keep
+ (23, "2013-07-13 16:30:02.400", 11),
+ (42, "2013-07-13 16:30:02.500", 11),
+ (98, "2013-07-13 16:30:02.600", 11), # keep
+ (99, "2013-07-13 16:30:59.000", 11), # keep
+ ])
+
+ def test_a_bunch_of_rows_in_jumbled_order(self):
+ # Cleaning a student_module_id with 8 records, 4 to delete.
+ smhc = SmhcSayStubbed()
+ self.write_history([
+ (23, "2013-07-13 16:30:01.100", 11),
+ (24, "2013-07-13 16:30:01.300", 11), # keep
+ (27, "2013-07-13 16:30:02.500", 11),
+ (30, "2013-07-13 16:30:01.350", 22), # other student_module_id!
+ (32, "2013-07-13 16:30:59.000", 11), # keep
+ (50, "2013-07-13 16:30:02.400", 11),
+ (51, "2013-07-13 16:30:02.600", 11), # keep
+ (56, "2013-07-13 16:30:00.000", 11), # keep
+ (57, "2013-07-13 16:30:01.200", 11),
+ ])
+
+ smhc.clean_one_student_module(11)
+ self.assert_said(smhc, "Deleting 4 rows of 8 for student_module_id 11")
+ self.assert_history([
+ (24, "2013-07-13 16:30:01.300", 11), # keep
+ (30, "2013-07-13 16:30:01.350", 22), # other student_module_id!
+ (32, "2013-07-13 16:30:59.000", 11), # keep
+ (51, "2013-07-13 16:30:02.600", 11), # keep
+ (56, "2013-07-13 16:30:00.000", 11), # keep
+ ])
+
+ def test_get_last_student_module(self):
+ # Can we find the last student_module_id properly?
+ smhc = SmhcSayStubbed()
+ self.write_history([
+ (23, "2013-07-13 16:30:01.100", 11),
+ (24, "2013-07-13 16:30:01.300", 44),
+ (27, "2013-07-13 16:30:02.500", 11),
+ (30, "2013-07-13 16:30:01.350", 22),
+ (32, "2013-07-13 16:30:59.000", 11),
+ (51, "2013-07-13 16:30:02.600", 33),
+ (56, "2013-07-13 16:30:00.000", 11),
+ ])
+ last = smhc.get_last_student_module_id()
+ self.assertEqual(last, 44)
+ self.assert_said(smhc, "Last student_module_id is 44")
+
+ def test_load_state_with_no_stored_state(self):
+ smhc = SmhcSayStubbed()
+ self.assertFalse(os.path.exists(smhc.STATE_FILE))
+ smhc.load_state()
+ self.assertEqual(smhc.next_student_module_id, 0)
+ self.assert_said(smhc, "No stored state")
+
+ def test_load_stored_state(self):
+ self.write_state_file('{"next_student_module_id": 23}')
+ smhc = SmhcSayStubbed()
+ smhc.load_state()
+ self.assertEqual(smhc.next_student_module_id, 23)
+ self.assert_said(smhc, 'Loaded stored state: {"next_student_module_id": 23}')
+
+ def test_save_state(self):
+ smhc = SmhcSayStubbed()
+ smhc.next_student_module_id = 47
+ smhc.save_state()
+ state = self.read_state_file()
+ self.assertEqual(state, '{"next_student_module_id": 47}')
+
+
+class SmhcForTestingMain(SmhcSayStubbed):
+ """A StudentModuleHistoryCleaner with a few function stubbed for testing main."""
+
+ def __init__(self, *args, **kwargs):
+ self.exception_smids = kwargs.pop('exception_smids', ())
+ super(SmhcForTestingMain, self).__init__(*args, **kwargs)
+
+ def clean_one_student_module(self, smid):
+ self.say("(not really cleaning {})".format(smid))
+ if smid in self.exception_smids:
+ raise Exception("Something went wrong!")
+
+ def commit(self):
+ self.say("(not really committing)")
+
+
+class HistoryCleanerMainTest(HistoryCleanerTest):
+ """Tests of StudentModuleHistoryCleaner.main(), using SmhcForTestingMain."""
+
+ def test_only_one_record(self):
+ smhc = SmhcForTestingMain()
+ self.write_history([
+ (1, "2013-07-15 11:47:00.000", 1),
+ ])
+ smhc.main()
+ self.assert_said(smhc,
+ 'Last student_module_id is 1',
+ 'No stored state',
+ '(not really cleaning 0)',
+ '(not really cleaning 1)',
+ '(not really committing)',
+ 'Saved state: {"next_student_module_id": 2}',
+ )
+
+ def test_already_processed_some(self):
+ smhc = SmhcForTestingMain()
+ self.write_state_file('{"next_student_module_id": 25}')
+ self.write_history([
+ (1, "2013-07-15 15:04:00.000", 23),
+ (2, "2013-07-15 15:04:11.000", 23),
+ (3, "2013-07-15 15:04:01.000", 24),
+ (4, "2013-07-15 15:04:00.000", 25),
+ (5, "2013-07-15 15:04:00.000", 26),
+ ])
+ smhc.main()
+ self.assert_said(smhc,
+ 'Last student_module_id is 26',
+ 'Loaded stored state: {"next_student_module_id": 25}',
+ '(not really cleaning 25)',
+ '(not really cleaning 26)',
+ '(not really committing)',
+ 'Saved state: {"next_student_module_id": 27}'
+ )
+
+ def test_working_in_batches(self):
+ smhc = SmhcForTestingMain()
+ self.write_state_file('{"next_student_module_id": 25}')
+ self.write_history([
+ (3, "2013-07-15 15:04:01.000", 24),
+ (4, "2013-07-15 15:04:00.000", 25),
+ (5, "2013-07-15 15:04:00.000", 26),
+ (6, "2013-07-15 15:04:00.000", 27),
+ (7, "2013-07-15 15:04:00.000", 28),
+ (8, "2013-07-15 15:04:00.000", 29),
+ ])
+ smhc.main(batch_size=3)
+ self.assert_said(smhc,
+ 'Last student_module_id is 29',
+ 'Loaded stored state: {"next_student_module_id": 25}',
+ '(not really cleaning 25)',
+ '(not really cleaning 26)',
+ '(not really cleaning 27)',
+ '(not really committing)',
+ 'Saved state: {"next_student_module_id": 28}',
+ '(not really cleaning 28)',
+ '(not really cleaning 29)',
+ '(not really committing)',
+ 'Saved state: {"next_student_module_id": 30}',
+ )
+
+ def test_something_failing_while_cleaning(self):
+ smhc = SmhcForTestingMain(exception_smids=[26])
+ self.write_state_file('{"next_student_module_id": 25}')
+ self.write_history([
+ (3, "2013-07-15 15:04:01.000", 24),
+ (4, "2013-07-15 15:04:00.000", 25),
+ (5, "2013-07-15 15:04:00.000", 26),
+ (6, "2013-07-15 15:04:00.000", 27),
+ (7, "2013-07-15 15:04:00.000", 28),
+ (8, "2013-07-15 15:04:00.000", 29),
+ ])
+ smhc.main(batch_size=3)
+ self.assert_said(smhc,
+ 'Last student_module_id is 29',
+ 'Loaded stored state: {"next_student_module_id": 25}',
+ '(not really cleaning 25)',
+ '(not really cleaning 26)',
+ "Couldn't clean student_module_id 26:\nTraceback*Exception: Something went wrong!\n",
+ '(not really cleaning 27)',
+ '(not really committing)',
+ 'Saved state: {"next_student_module_id": 28}',
+ '(not really cleaning 28)',
+ '(not really cleaning 29)',
+ '(not really committing)',
+ 'Saved state: {"next_student_module_id": 30}',
+ )
From 3a49136f03ee436eef36829bc8d0891424c9d9f1 Mon Sep 17 00:00:00 2001
From: Ned Batchelder
Date: Mon, 15 Jul 2013 16:57:28 -0400
Subject: [PATCH 41/73] Quiet some debug output, get transactions right.
---
.../courseware/management/commands/clean_history.py | 13 ++++++++++---
1 file changed, 10 insertions(+), 3 deletions(-)
diff --git a/lms/djangoapps/courseware/management/commands/clean_history.py b/lms/djangoapps/courseware/management/commands/clean_history.py
index 2c243a43be..8b4f3d4d6d 100644
--- a/lms/djangoapps/courseware/management/commands/clean_history.py
+++ b/lms/djangoapps/courseware/management/commands/clean_history.py
@@ -10,7 +10,8 @@ This command that does that.
import datetime
import json
-from optparse import make_option
+import logging
+import optparse
import traceback
from django.core.management.base import NoArgsCommand
@@ -23,7 +24,7 @@ class Command(NoArgsCommand):
help = "Deletes unneeded rows from the StudentModuleHistory table."
option_list = NoArgsCommand.option_list + (
- make_option(
+ optparse.make_option(
'--dry-run',
action='store_true',
default=False,
@@ -32,6 +33,9 @@ class Command(NoArgsCommand):
)
def handle_noargs(self, **options):
+ # We don't want to see the SQL output from the db layer.
+ logging.getLogger("").setLevel(logging.INFO)
+
smhc = StudentModuleHistoryCleaner(
dry_run=options["dry_run"],
)
@@ -55,6 +59,8 @@ class StudentModuleHistoryCleaner(object):
batch_size = batch_size or self.BATCH_SIZE
+ connection.enter_transaction_management()
+
self.last_student_module_id = self.get_last_student_module_id()
self.load_state()
@@ -65,7 +71,8 @@ class StudentModuleHistoryCleaner(object):
except Exception: # pylint: disable=W0703
trace = traceback.format_exc()
self.say("Couldn't clean student_module_id {}:\n{}".format(smid, trace))
- self.commit()
+ if not self.dry_run:
+ self.commit()
self.save_state()
def say(self, message):
From 83cb3d192444172158b1a242a41b5edbebdf0dd9 Mon Sep 17 00:00:00 2001
From: Ned Batchelder
Date: Thu, 18 Jul 2013 09:56:07 -0400
Subject: [PATCH 42/73] Add batch and sleep arguments so we can control the
speed from the command line.
---
.../management/commands/clean_history.py | 19 +++++++++++++++++--
1 file changed, 17 insertions(+), 2 deletions(-)
diff --git a/lms/djangoapps/courseware/management/commands/clean_history.py b/lms/djangoapps/courseware/management/commands/clean_history.py
index 8b4f3d4d6d..30b0df8627 100644
--- a/lms/djangoapps/courseware/management/commands/clean_history.py
+++ b/lms/djangoapps/courseware/management/commands/clean_history.py
@@ -12,6 +12,7 @@ import datetime
import json
import logging
import optparse
+import time
import traceback
from django.core.management.base import NoArgsCommand
@@ -24,12 +25,24 @@ class Command(NoArgsCommand):
help = "Deletes unneeded rows from the StudentModuleHistory table."
option_list = NoArgsCommand.option_list + (
+ optparse.make_option(
+ '--batch',
+ type='int',
+ default=100,
+ help="Batch size, number of module_ids to examine in a transaction.",
+ ),
optparse.make_option(
'--dry-run',
action='store_true',
default=False,
help="Don't change the database, just show what would be done.",
),
+ optparse.make_option(
+ '--sleep',
+ type='float',
+ default=0,
+ help="Seconds to sleep between batches.",
+ ),
)
def handle_noargs(self, **options):
@@ -39,7 +52,7 @@ class Command(NoArgsCommand):
smhc = StudentModuleHistoryCleaner(
dry_run=options["dry_run"],
)
- smhc.main()
+ smhc.main(batch_size=options["batch"], sleep=options["sleep"])
class StudentModuleHistoryCleaner(object):
@@ -54,7 +67,7 @@ class StudentModuleHistoryCleaner(object):
self.next_student_module_id = 0
self.last_student_module_id = 0
- def main(self, batch_size=None):
+ def main(self, batch_size=None, sleep=0):
"""Invoked from the management command to do all the work."""
batch_size = batch_size or self.BATCH_SIZE
@@ -74,6 +87,8 @@ class StudentModuleHistoryCleaner(object):
if not self.dry_run:
self.commit()
self.save_state()
+ if sleep:
+ time.sleep(sleep)
def say(self, message):
"""
From 10f062cf1810da61a2cfafe250bd1d2c903f77eb Mon Sep 17 00:00:00 2001
From: Ned Batchelder
Date: Thu, 18 Jul 2013 10:34:40 -0400
Subject: [PATCH 43/73] Records should be sorted by created,id so that
timestamp ties will be broken by id.
---
.../management/commands/clean_history.py | 2 +-
.../management/tests/test_clean_history.py | 24 +++++++++++++++++++
2 files changed, 25 insertions(+), 1 deletion(-)
diff --git a/lms/djangoapps/courseware/management/commands/clean_history.py b/lms/djangoapps/courseware/management/commands/clean_history.py
index 30b0df8627..c266e2df53 100644
--- a/lms/djangoapps/courseware/management/commands/clean_history.py
+++ b/lms/djangoapps/courseware/management/commands/clean_history.py
@@ -180,7 +180,7 @@ class StudentModuleHistoryCleaner(object):
cursor.execute("""
SELECT id, created FROM courseware_studentmodulehistory
WHERE student_module_id = %s
- ORDER BY created
+ ORDER BY created, id
""",
[student_module_id]
)
diff --git a/lms/djangoapps/courseware/management/tests/test_clean_history.py b/lms/djangoapps/courseware/management/tests/test_clean_history.py
index cf681a7b0a..0636279c26 100644
--- a/lms/djangoapps/courseware/management/tests/test_clean_history.py
+++ b/lms/djangoapps/courseware/management/tests/test_clean_history.py
@@ -313,6 +313,30 @@ class HistoryCleanerWitDbTest(HistoryCleanerTest):
(56, "2013-07-13 16:30:00.000", 11), # keep
])
+ def test_a_bunch_of_rows_with_timestamp_ties(self):
+ # Sometimes rows are written with identical timestamps. The one with
+ # the greater id is the winner in that case.
+ smhc = SmhcSayStubbed()
+ self.write_history([
+ (21, "2013-07-13 16:30:01.100", 11),
+ (24, "2013-07-13 16:30:01.100", 11), # keep
+ (22, "2013-07-13 16:30:01.100", 11),
+ (23, "2013-07-13 16:30:01.100", 11),
+ (27, "2013-07-13 16:30:02.500", 11),
+ (30, "2013-07-13 16:30:01.350", 22), # other student_module_id!
+ (32, "2013-07-13 16:30:59.000", 11), # keep
+ (50, "2013-07-13 16:30:02.500", 11), # keep
+ ])
+
+ smhc.clean_one_student_module(11)
+ self.assert_said(smhc, "Deleting 4 rows of 7 for student_module_id 11")
+ self.assert_history([
+ (24, "2013-07-13 16:30:01.100", 11), # keep
+ (30, "2013-07-13 16:30:01.350", 22), # other student_module_id!
+ (32, "2013-07-13 16:30:59.000", 11), # keep
+ (50, "2013-07-13 16:30:02.500", 11), # keep
+ ])
+
def test_get_last_student_module(self):
# Can we find the last student_module_id properly?
smhc = SmhcSayStubbed()
From 3d67c506a363e3f213e5318421687e0b99bd5d12 Mon Sep 17 00:00:00 2001
From: Ned Batchelder
Date: Thu, 18 Jul 2013 11:16:01 -0400
Subject: [PATCH 44/73] Use TransactionTestCase, to keep other tests from
failing, even though it slows them down.
---
.../courseware/management/tests/test_clean_history.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/lms/djangoapps/courseware/management/tests/test_clean_history.py b/lms/djangoapps/courseware/management/tests/test_clean_history.py
index 0636279c26..2a70556ffe 100644
--- a/lms/djangoapps/courseware/management/tests/test_clean_history.py
+++ b/lms/djangoapps/courseware/management/tests/test_clean_history.py
@@ -7,7 +7,7 @@ import textwrap
import dateutil.parser
-from django.test import TestCase
+from django.test import TransactionTestCase
from django.db import connection
from courseware.management.commands.clean_history import StudentModuleHistoryCleaner
@@ -43,7 +43,7 @@ class SmhcDbMocked(SmhcSayStubbed):
self.get_history_for_student_modules.return_value = rows
-class HistoryCleanerTest(TestCase):
+class HistoryCleanerTest(TransactionTestCase):
"""Base class for all history cleaner tests."""
maxDiff = None
From 900e794c0224c2812fde92a677770e8a65644d61 Mon Sep 17 00:00:00 2001
From: Ned Batchelder
Date: Thu, 18 Jul 2013 11:53:21 -0400
Subject: [PATCH 45/73] Only quiet the particular logger we think is too noisy.
---
lms/djangoapps/courseware/management/commands/clean_history.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lms/djangoapps/courseware/management/commands/clean_history.py b/lms/djangoapps/courseware/management/commands/clean_history.py
index c266e2df53..4286e17658 100644
--- a/lms/djangoapps/courseware/management/commands/clean_history.py
+++ b/lms/djangoapps/courseware/management/commands/clean_history.py
@@ -47,7 +47,7 @@ class Command(NoArgsCommand):
def handle_noargs(self, **options):
# We don't want to see the SQL output from the db layer.
- logging.getLogger("").setLevel(logging.INFO)
+ logging.getLogger("django.db.backends").setLevel(logging.INFO)
smhc = StudentModuleHistoryCleaner(
dry_run=options["dry_run"],
From 46ae2f9c26acefe8387782ded51948d80a655b2f Mon Sep 17 00:00:00 2001
From: RobertMarks
Date: Thu, 27 Jun 2013 17:14:49 -0700
Subject: [PATCH 46/73] Added support for a new problem type:
ChoicetextResponse
---
AUTHORS | 1 +
CHANGELOG.rst | 5 +-
common/lib/capa/capa/inputtypes.py | 208 +++++++++++
common/lib/capa/capa/responsetypes.py | 332 +++++++++++++++++-
.../lib/capa/capa/templates/choicetext.html | 76 ++++
.../capa/capa/tests/response_xml_factory.py | 106 ++++++
.../capa/capa/tests/test_input_templates.py | 167 +++++++++
common/lib/capa/capa/tests/test_inputtypes.py | 91 +++++
.../lib/capa/capa/tests/test_responsetypes.py | 283 +++++++++++++++
common/lib/xmodule/xmodule/capa_module.py | 19 +-
.../lib/xmodule/xmodule/css/capa/display.scss | 28 ++
.../xmodule/js/spec/capa/display_spec.coffee | 52 +++
.../xmodule/js/src/capa/display.coffee | 12 +
common/static/js/capa/choicetextinput.js | 75 ++++
.../courseware/features/problems.feature | 10 +
.../courseware/features/problems_setup.py | 83 ++++-
16 files changed, 1544 insertions(+), 4 deletions(-)
create mode 100644 common/lib/capa/capa/templates/choicetext.html
create mode 100644 common/static/js/capa/choicetextinput.js
diff --git a/AUTHORS b/AUTHORS
index 70af9f318d..89fc2d959b 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -81,3 +81,4 @@ Felix Sun
Adam Palay
Ian Hoover
Mukul Goyal
+Robert Marks
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 04c8a5baae..20642bbb1f 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -5,6 +5,7 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected.
+
Common: Added *experimental* support for jsinput type.
Common: Added setting to specify Celery Broker vhost
@@ -21,6 +22,8 @@ Studio: Added support for uploading and managing PDF textbooks
Common: Student information is now passed to the tracking log via POST instead of GET.
+Blades: Added functionality and tests for new capa input type: choicetextresponse.
+
Common: Add tests for documentation generation to test suite
Blades: User answer now preserved (and changeable) after clicking "show answer" in choice problems
@@ -43,7 +46,7 @@ history of background tasks for a given problem and student.
Blades: Small UX fix on capa multiple-choice problems. Make labels only
as wide as the text to reduce accidental choice selections.
-Studio:
+Studio:
- use xblock field defaults to initialize all new instances' fields and
only use templates as override samples.
- create new instances via in memory create_xmodule and related methods rather
diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py
index 9bb72ad4e1..f26909b633 100644
--- a/common/lib/capa/capa/inputtypes.py
+++ b/common/lib/capa/capa/inputtypes.py
@@ -1368,3 +1368,211 @@ class AnnotationInput(InputTypeBase):
return extra_context
registry.register(AnnotationInput)
+
+
+class ChoiceTextGroup(InputTypeBase):
+ """
+ Groups of radiobutton/checkboxes with text inputs.
+ Allows for a "not enough information" option to be added
+ to problems with numerical answers.
+
+ Examples:
+ RadioButton problem
+
+
+ A person rolls a standard die 100 times and records the results.
+ On the first roll they received a "1". Given this information
+ select the correct choice and fill in numbers to make it accurate.
+
+
+
+ The lowest number rolled was:
+ and the highest number rolled was:
+ .
+ The lowest number rolled was
+ and there is not enough information to determine the highest number rolled.
+
+ There is not enough information to determine the lowest
+ number rolled, and the highest number rolled was:
+ .
+
+
+
+
+
+ CheckboxProblem:
+
+
+ A person randomly selects 100 times, with replacement, from the list of numbers \(\sqrt{2}\) , 2, 3, 4 ,5 ,6
+ and records the results. The first number they pick is \(\sqrt{2}\) Given this information
+ select the correct choices and fill in numbers to make them accurate.
+
+
+
+
+ The lowest number selected was
+
+
+ The highest number selected was .
+
+ There is not enough information given to determine the highest number
+ which was selected.
+
+ There is not enough information given to determine the lowest number
+ selected.
+
+
+
+
+
+ In the preceding examples the is used to generate a textinput html element
+ in the problem's display. Since it is inside of an incorrect choice, no answer given
+ for it will be correct, and thus specifying an answer for it is not needed.
+ """
+ template = "choicetext.html"
+ tags = ['radiotextgroup', 'checkboxtextgroup']
+
+ def setup(self):
+ """
+ Performs setup for the initial rendering of the problem.
+ `self.html_input_type` determines whether this problem is displayed
+ with radiobuttons or checkboxes
+
+ If the initial value of `self.value` is '' change it to {} so that
+ the template has an empty dictionary to work with.
+
+ sets the value of self.choices to be equal to the return value of
+ `self.extract_choices`
+ """
+ self.text_input_values = {}
+ if self.tag == 'radiotextgroup':
+ self.html_input_type = "radio"
+ elif self.tag == 'checkboxtextgroup':
+ self.html_input_type = "checkbox"
+ else:
+ raise Exception("ChoiceGroup: unexpected tag {0}".format(self.tag))
+
+ if self.value == '':
+ # Make `value` an empty dictionary, if it currently has an empty
+ # value. This is necessary because the template expects a
+ # dictionary.
+ self.value = {}
+ self.choices = self.extract_choices(self.xml)
+
+ @classmethod
+ def get_attributes(cls):
+ """
+ Returns a list of `Attribute` for this problem type
+ """
+ return [
+ Attribute("show_correctness", "always"),
+ Attribute("submitted_message", "Answer received.")
+ ]
+
+ def _extra_context(self):
+ """
+ Returns a dictionary of extra content necessary for rendering this InputType.
+
+ `input_type` is either 'radio' or 'checkbox' indicating whether the choices for
+ this problem will have radiobuttons or checkboxes.
+ """
+ return {
+ 'input_type': self.html_input_type,
+ 'choices': self.choices
+ }
+
+ @staticmethod
+ def extract_choices(element):
+ """
+ Extracts choices from the xml for this problem type.
+ If we have xml that is as follows(choice names will have been assigned
+ by now)
+
+
+ The number
+
+ Is the mean of the list.
+
+ " +
+ "tag; got {0} instead".format(choice.tag)
+ )
+
+ components = []
+ choice_text = ''
+ if choice.text is not None:
+ choice_text += choice.text
+ # Initialize our dict for the next content
+ adder = {
+ 'type': 'text',
+ 'contents': choice_text,
+ 'tail_text': '',
+ 'value': ''
+ }
+ components.append(adder)
+
+ for elt in choice:
+ # for elements in the choice e.g.
+ adder = {
+ 'type': 'text',
+ 'contents': '',
+ 'tail_text': '',
+ 'value': ''
+ }
+ tag_type = elt.tag
+ # If the current `elt` is a set the
+ # `adder`type to 'numtolerance_input', and 'contents' to
+ # the `elt`'s name.
+ # Treat decoy_inputs and numtolerance_inputs the same in order
+ # to prevent students from reading the Html and figuring out
+ # which inputs are valid
+ if tag_type in ('numtolerance_input', 'decoy_input'):
+ # We set this to textinput, so that we get a textinput html
+ # element.
+ adder['type'] = 'textinput'
+ adder['contents'] = elt.get('name')
+ else:
+ adder['contents'] = elt.text
+
+ # Add any tail text("is the mean" in the example)
+ adder['tail_text'] = elt.tail if elt.tail else ''
+ components.append(adder)
+
+ # Add the tuple for the current choice to the list of choices
+ choices.append((choice.get("name"), components))
+ return choices
+
+registry.register(ChoiceTextGroup)
diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py
index 3762c21976..51bae0b215 100644
--- a/common/lib/capa/capa/responsetypes.py
+++ b/common/lib/capa/capa/responsetypes.py
@@ -2097,6 +2097,335 @@ class AnnotationResponse(LoncapaResponse):
return option_ids[0]
return None
+
+class ChoiceTextResponse(LoncapaResponse):
+ """
+ Allows for multiple choice responses with text inputs
+ Desired semantics match those of NumericalResponse and
+ ChoiceResponse.
+ """
+
+ response_tag = 'choicetextresponse'
+ max_inputfields = 1
+ allowed_inputfields = ['choicetextgroup',
+ 'checkboxtextgroup',
+ 'radiotextgroup'
+ ]
+
+ def setup_response(self):
+ """
+ Sets up three dictionaries for use later:
+ `correct_choices`: These are the correct binary choices(radio/checkbox)
+ `correct_inputs`: These are the numerical/string answers for required
+ inputs.
+ `answer_values`: This is a dict, keyed by the name of the binary choice
+ which contains the correct answers for the text inputs separated by
+ commas e.g. "1, 0.5"
+
+ `correct_choices` and `correct_inputs` are used for grading the problem
+ and `answer_values` is used for displaying correct answers.
+
+ """
+ context = self.context
+ self.correct_choices = {}
+ self.assign_choice_names()
+ self.correct_inputs = {}
+ self.answer_values = {self.answer_id: []}
+ correct_xml = self.xml.xpath('//*[@id=$id]//choice[@correct="true"]',
+ id=self.xml.get('id'))
+ for node in correct_xml:
+ # For each correct choice, set the `parent_name` to the
+ # current choice's name
+ parent_name = node.get('name')
+ # Add the name of the correct binary choice to the
+ # correct choices list as a key. The value is not important.
+ self.correct_choices[parent_name] = {'answer': ''}
+ # Add the name of the parent to the list of correct answers
+ self.answer_values[self.answer_id].append(parent_name)
+ answer_list = []
+ # Loop over elements inside of the correct choices
+ for child in node:
+ answer = child.get('answer', None)
+ if not answer:
+ # If the question creator does not specify an answer for a
+ # inside of a correct choice, raise an error
+ raise LoncapaProblemError(
+ "Answer not provided for numtolerance_input"
+ )
+ # Contextualize the answer to allow script generated answers.
+ answer = contextualize_text(answer, context)
+ input_name = child.get('name')
+ # Contextualize the tolerance to value.
+ tolerance = contextualize_text(
+ child.get('tolerance', '0'),
+ context
+ )
+ # Add the answer and tolerance information for the current
+ # numtolerance_input to `correct_inputs`
+ self.correct_inputs[input_name] = {
+ 'answer': answer,
+ 'tolerance': tolerance
+ }
+ # Add the correct answer for this input to the list for show
+ answer_list.append(answer)
+ # Turn the list of numtolerance_input answers into a comma separated string.
+ self.answer_values[parent_name] = ', '.join(answer_list)
+ # Turn correct choices into a set. Allows faster grading.
+ self.correct_choices = set(self.correct_choices.keys())
+
+ def assign_choice_names(self):
+ """
+ Initialize name attributes in and tags
+ for this response.
+
+ Example:
+ Assuming for simplicity that `self.answer_id` = '1_2_1'
+
+ Before the function is called `self.xml` =
+
+
+ The number
+
+ Is the mean of the list.
+
+
+ False demonstration choice
+
+
+
+ After this is called the choices and numtolerance_inputs will have a name
+ attribute initialized and self.xml will be:
+
+
+
+ The number
+
+ Is the mean of the list.
+
+
+ # "bc" is appended at the end to indicate that this is a
+ # binary choice as opposed to a numtolerance_input, this convention
+ # is used when grading the problem
+ choice.set(
+ "name",
+ self.answer_id + "_choiceinput_" + str(index) + "bc"
+ )
+ # Set Name attributes for elements
+ # Look for all inside this choice.
+ numtolerance_inputs = choice.findall('numtolerance_input')
+ # Look for all inside this choice
+ decoys = choice.findall('decoy_input')
+ # would only be used in choices which do not contain
+ #
+ inputs = numtolerance_inputs if numtolerance_inputs else decoys
+ # Give each input inside of the choice a name combining
+ # The ordinality of the choice, and the ordinality of the input
+ # within that choice e.g. 1_2_1_choiceinput_0_numtolerance_input_1
+ for ind, child in enumerate(inputs):
+ child.set(
+ "name",
+ self.answer_id + "_choiceinput_" + str(index) +
+ "_numtolerance_input_" + str(ind)
+ )
+
+ def get_score(self, student_answers):
+ """
+ Returns a `CorrectMap` showing whether `student_answers` are correct.
+
+ `student_answers` contains keys for binary inputs(radiobutton,
+ checkbox) and numerical inputs. Keys ending with 'bc' are binary
+ choice inputs otherwise they are text fields.
+
+ This method first separates the two
+ types of answers and then grades them in separate methods.
+
+ The student is only correct if they have both the binary inputs and
+ numerical inputs correct.
+ """
+ answer_dict = student_answers.get(self.answer_id, "")
+ binary_choices, numtolerance_inputs = self._split_answers_dict(answer_dict)
+ # Check the binary choices first.
+ choices_correct = self._check_student_choices(binary_choices)
+ inputs_correct = True
+ inputs_correct = self._check_student_inputs(numtolerance_inputs)
+ # Only return correct if the student got both the binary
+ # and numtolerance_inputs are correct
+ correct = choices_correct and inputs_correct
+
+ return CorrectMap(
+ self.answer_id,
+ 'correct' if correct else 'incorrect'
+ )
+
+ def get_answers(self):
+ """
+ Returns a dictionary containing the names of binary choices as keys
+ and a string of answers to any numtolerance_inputs which they may have
+ e.g {choice_1bc : "answer1, answer2", choice_2bc : ""}
+ """
+ return self.answer_values
+
+ def _split_answers_dict(self, a_dict):
+ """
+ Returns two dicts:
+ `binary_choices` : dictionary {input_name: input_value} for
+ the binary choices which the student selected.
+ and
+ `numtolerance_choices` : a dictionary {input_name: input_value}
+ for the numtolerance_inputs inside of choices which were selected
+
+ Determines if an input is inside of a binary input by looking at
+ the beginning of it's name.
+
+ For example. If a binary_choice was named '1_2_1_choiceinput_0bc'
+ All of the numtolerance_inputs in it would have an idea that begins
+ with '1_2_1_choice_input_0_numtolerance_input'
+
+ Splits the name of the numtolerance_input at the occurence of
+ '_numtolerance_input_' and appends 'bc' to the end to get the name
+ of the choice it is contained in.
+
+ Example:
+ `a_dict` = {
+ '1_2_1_choiceinput_0bc': '1_2_1_choiceinput_0bc',
+ '1_2_1_choiceinput_0_numtolerance_input_0': '1',
+ '1_2_1_choiceinput_0_numtolerance_input_1': '2'
+ '1_2_1_choiceinput_1_numtolerance_input_0': '3'
+ }
+
+ In this case, the binary choice is '1_2_1_choiceinput_0bc', and
+ the numtolerance_inputs associated with it are
+ '1_2_1_choiceinput_0_numtolerance_input_0', and
+ '1_2_1_choiceinput_0_numtolerance_input_1'.
+
+ so the two return dictionaries would be
+ `binary_choices` = {'1_2_1_choiceinput_0bc': '1_2_1_choiceinput_0bc'}
+ and
+ `numtolerance_choices` ={
+ '1_2_1_choiceinput_0_numtolerance_input_0': '1',
+ '1_2_1_choiceinput_0_numtolerance_input_1': '2'
+ }
+
+ The entry '1_2_1_choiceinput_1_numtolerance_input_0': '3' is discarded
+ because it was not inside of a selected binary choice, and no validation
+ should be performed on numtolerance_inputs inside of non-selected choices.
+ """
+
+ # Initialize the two dictionaries that are returned
+ numtolerance_choices = {}
+ binary_choices = {}
+
+ # `selected_choices` is a list of binary choices which were "checked/selected"
+ # when the student submitted the problem.
+ # Keys in a_dict ending with 'bc' refer to binary choices.
+ selected_choices = [key for key in a_dict if key.endswith("bc")]
+ for key in selected_choices:
+ binary_choices[key] = a_dict[key]
+
+ # Convert the name of a numtolerance_input into the name of the binary
+ # choice that it is contained within, and append it to the list if
+ # the numtolerance_input's parent binary_choice is contained in
+ # `selected_choices`.
+ selected_numtolerance_inputs = [
+ key for key in a_dict if key.partition("_numtolerance_input_")[0] + "bc"
+ in selected_choices
+ ]
+
+ for key in selected_numtolerance_inputs:
+ numtolerance_choices[key] = a_dict[key]
+
+ return (binary_choices, numtolerance_choices)
+
+ def _check_student_choices(self, choices):
+ """
+ Compares student submitted checkbox/radiobutton answers against
+ the correct answers. Returns True or False.
+
+ True if all of the correct choices are selected and no incorrect
+ choices are selected.
+ """
+ student_choices = set(choices)
+ required_selected = len(self.correct_choices - student_choices) == 0
+ no_extra_selected = len(student_choices - self.correct_choices) == 0
+ correct = required_selected and no_extra_selected
+ return correct
+
+ def _check_student_inputs(self, numtolerance_inputs):
+ """
+ Compares student submitted numerical answers against the correct
+ answers and tolerances.
+
+ `numtolerance_inputs` is a dictionary {answer_name : answer_value}
+
+ Performs numerical validation by means of calling
+ `compare_with_tolerance()` on all of `numtolerance_inputs`
+
+ Performs a call to `compare_with_tolerance` even on values for
+ decoy_inputs. This is used to validate their numericality and
+ raise an error if the student entered a non numerical expression.
+
+ Returns True if and only if all student inputs are correct.
+ """
+
+ inputs_correct = True
+ for answer_name, answer_value in numtolerance_inputs.iteritems():
+ # If `self.corrrect_inputs` does not contain an entry for
+ # `answer_name`, this means that answer_name is a decoy
+ # input's value, and validation of its numericality is the
+ # only thing of interest from the later call to
+ # `compare_with_tolerance`.
+ params = self.correct_inputs.get(answer_name, {'answer': 0})
+
+ correct_ans = params['answer']
+ # Set the tolerance to '0' if it was not specified in the xml
+ tolerance = params.get('tolerance', '0')
+ # Make sure that the staff answer is a valid number
+ try:
+ correct_ans = complex(correct_ans)
+ except ValueError:
+ log.debug(
+ "Content error--answer" +
+ "'{0}' is not a valid complex number".format(correct_ans)
+ )
+ raise StudentInputError(
+ "The Staff answer could not be interpreted as a number."
+ )
+ # Compare the student answer to the staff answer/ or to 0
+ # if all that is important is verifying numericality
+ try:
+ partial_correct = compare_with_tolerance(
+ evaluator(dict(), dict(), answer_value),
+ correct_ans,
+ tolerance
+ )
+ except:
+ # Use the traceback-preserving version of re-raising with a
+ # different type
+ _, _, trace = sys.exc_info()
+
+ raise StudentInputError(
+ "Could not interpret '{0}' as a number{1}".format(
+ cgi.escape(answer_value),
+ trace
+ )
+ )
+ # Ignore the results of the comparisons which were just for
+ # Numerical Validation.
+ if answer_name in self.correct_inputs and not partial_correct:
+ # If any input is not correct, set the return value to False
+ inputs_correct = False
+ return inputs_correct
+
#-----------------------------------------------------------------------------
# TEMPORARY: List of all response subclasses
@@ -2116,4 +2445,5 @@ __all__ = [CodeResponse,
MultipleChoiceResponse,
TrueFalseResponse,
JavascriptResponse,
- AnnotationResponse]
+ AnnotationResponse,
+ ChoiceTextResponse]
diff --git a/common/lib/capa/capa/templates/choicetext.html b/common/lib/capa/capa/templates/choicetext.html
new file mode 100644
index 0000000000..5f587e214a
--- /dev/null
+++ b/common/lib/capa/capa/templates/choicetext.html
@@ -0,0 +1,76 @@
+<% element_checked = False %>
+% for choice_id, _ in choices:
+ <%choice_id = choice_id %>
+ %if choice_id in value:
+ <% element_checked = True %>
+ %endif
+%endfor
+
+
+
diff --git a/common/lib/capa/capa/tests/response_xml_factory.py b/common/lib/capa/capa/tests/response_xml_factory.py
index 35c12800ae..38c4a00caa 100644
--- a/common/lib/capa/capa/tests/response_xml_factory.py
+++ b/common/lib/capa/capa/tests/response_xml_factory.py
@@ -779,3 +779,109 @@ class SymbolicResponseXMLFactory(ResponseXMLFactory):
def create_input_element(self, **kwargs):
return ResponseXMLFactory.textline_input_xml(**kwargs)
+
+
+class ChoiceTextResponseXMLFactory(ResponseXMLFactory):
+ """ Factory for producing xml """
+
+ def create_response_element(self, **kwargs):
+ """ Create a element """
+ return etree.Element("choicetextresponse")
+
+ def create_input_element(self, **kwargs):
+ """ Create a element.
+ choices can be specified in the following format:
+ [("true", [{"answer": "5", "tolerance": 0}]),
+ ("false", [{"answer": "5", "tolerance": 0}])
+ ]
+
+ This indicates that the first checkbox/radio is correct and it
+ contains a numtolerance_input with an answer of 5 and a tolerance of 0
+
+ It also indicates that the second has a second incorrect radiobutton
+ or checkbox with a numtolerance_input.
+ """
+ choices = kwargs.get('choices', [("true", {})])
+ choice_inputs = []
+ # Ensure that the first element of choices is an ordered
+ # collection. It will start as a list, a tuple, or not a Container.
+ if type(choices[0]) not in [list, tuple]:
+ choices = [choices]
+
+ for choice in choices:
+ correctness, answers = choice
+ numtolerance_inputs = []
+ # If the current `choice` contains any("answer": number)
+ # elements, turn those into numtolerance_inputs
+ if answers:
+ # `answers` will be a list or tuple of answers or a single
+ # answer, representing the answers for numtolerance_inputs
+ # inside of this specific choice.
+
+ # Make sure that `answers` is an ordered collection for
+ # convenience.
+ if type(answers) not in [list, tuple]:
+ answers = [answers]
+
+ numtolerance_inputs = [
+ self._create_numtolerance_input_element(answer)
+ for answer in answers
+ ]
+
+ choice_inputs.append(
+ self._create_choice_element(
+ correctness=correctness,
+ inputs=numtolerance_inputs
+ )
+ )
+ # Default type is 'radiotextgroup'
+ input_type = kwargs.get('type', 'radiotextgroup')
+ input_element = etree.Element(input_type)
+
+ for ind, choice in enumerate(choice_inputs):
+ # Give each choice text equal to it's position(0,1,2...)
+ choice.text = "choice_{0}".format(ind)
+ input_element.append(choice)
+
+ return input_element
+
+ def _create_choice_element(self, **kwargs):
+ """
+ Creates a choice element for a choictextproblem.
+ Defaults to a correct choice with no numtolerance_input
+ """
+ text = kwargs.get('text', '')
+ correct = kwargs.get('correctness', "true")
+ inputs = kwargs.get('inputs', [])
+ choice_element = etree.Element("choice")
+ choice_element.set("correct", correct)
+ choice_element.text = text
+ for inp in inputs:
+ # Add all of the inputs as children of this element
+ choice_element.append(inp)
+
+ return choice_element
+
+ def _create_numtolerance_input_element(self, params):
+ """
+ Creates a element with optionally
+ specified tolerance and answer.
+ """
+ answer = params['answer'] if 'answer' in params else None
+ # If there is not an answer specified, Then create a
+ # otherwise create a and set its tolerance
+ # and answer attributes.
+ if answer:
+ text_input = etree.Element("numtolerance_input")
+ text_input.set('answer', answer)
+ # If tolerance was specified, was specified use it, otherwise
+ # Set the tolerance to "0"
+ text_input.set(
+ 'tolerance',
+ params['tolerance'] if 'tolerance' in params else "0"
+ )
+
+ else:
+ text_input = etree.Element("decoy_input")
+
+ return text_input
diff --git a/common/lib/capa/capa/tests/test_input_templates.py b/common/lib/capa/capa/tests/test_input_templates.py
index 00a9b3f6c2..dcab279614 100644
--- a/common/lib/capa/capa/tests/test_input_templates.py
+++ b/common/lib/capa/capa/tests/test_input_templates.py
@@ -714,3 +714,170 @@ class DragAndDropTemplateTest(TemplateTestCase):
# escaping the HTML. We should be able to traverse the XML tree.
xpath = "//div[@class='drag_and_drop_problem_json']/p/b"
self.assert_has_text(xml, xpath, 'HTML')
+
+
+class ChoiceTextGroupTemplateTest(TemplateTestCase):
+ """Test mako template for `` input"""
+
+ TEMPLATE_NAME = 'choicetext.html'
+ VALUE_DICT = {'1_choiceinput_0bc': '1_choiceinput_0bc', '1_choiceinput_0_textinput_0': '0',
+ '1_choiceinput_1_textinput_0': '0'}
+ EMPTY_DICT = {'1_choiceinput_0_textinput_0': '',
+ '1_choiceinput_1_textinput_0': ''}
+ BOTH_CHOICE_CHECKBOX = {'1_choiceinput_0bc': 'choiceinput_0',
+ '1_choiceinput_1bc': 'choiceinput_1',
+ '1_choiceinput_0_textinput_0': '0',
+ '1_choiceinput_1_textinput_0': '0'}
+ WRONG_CHOICE_CHECKBOX = {'1_choiceinput_1bc': 'choiceinput_1',
+ '1_choiceinput_0_textinput_0': '0',
+ '1_choiceinput_1_textinput_0': '0'}
+
+ def setUp(self):
+ choices = [('1_choiceinput_0bc',
+ [{'tail_text': '', 'type': 'text', 'value': '', 'contents': ''},
+ {'tail_text': '', 'type': 'textinput', 'value': '', 'contents': 'choiceinput_0_textinput_0'}]),
+ ('1_choiceinput_1bc', [{'tail_text': '', 'type': 'text', 'value': '', 'contents': ''},
+ {'tail_text': '', 'type': 'textinput', 'value': '', 'contents': 'choiceinput_1_textinput_0'}])]
+ self.context = {'id': '1',
+ 'choices': choices,
+ 'status': 'correct',
+ 'input_type': 'radio',
+ 'value': self.VALUE_DICT}
+
+ super(ChoiceTextGroupTemplateTest, self).setUp()
+
+ def test_grouping_tag(self):
+ """
+ Tests whether we are using a section or a label to wrap choice elements.
+ Section is used for checkbox, so inputting text does not deselect
+ """
+ input_tags = ('radio', 'checkbox')
+ self.context['status'] = 'correct'
+ xpath = "//section[@id='forinput1_choiceinput_0bc']"
+
+ self.context['value'] = {}
+ for input_type in input_tags:
+ self.context['input_type'] = input_type
+ xml = self.render_to_xml(self.context)
+ self.assert_has_xpath(xml, xpath, self.context)
+
+ def test_problem_marked_correct(self):
+ """Test conditions under which the entire problem
+ (not a particular option) is marked correct"""
+
+ self.context['status'] = 'correct'
+ self.context['input_type'] = 'checkbox'
+ self.context['value'] = self.VALUE_DICT
+
+ # Should mark the entire problem correct
+ xml = self.render_to_xml(self.context)
+ xpath = "//div[@class='indicator_container']/span[@class='correct']"
+ self.assert_has_xpath(xml, xpath, self.context)
+
+ # Should NOT mark individual options
+ self.assert_no_xpath(xml, "//label[@class='choicetextgroup_incorrect']",
+ self.context)
+
+ self.assert_no_xpath(xml, "//label[@class='choicetextgroup_correct']",
+ self.context)
+
+ def test_problem_marked_incorrect(self):
+ """Test all conditions under which the entire problem
+ (not a particular option) is marked incorrect"""
+ grouping_tags = {'radio': 'label', 'checkbox': 'section'}
+ conditions = [
+ {'status': 'incorrect', 'input_type': 'radio', 'value': {}},
+ {'status': 'incorrect', 'input_type': 'checkbox', 'value': self.WRONG_CHOICE_CHECKBOX},
+ {'status': 'incorrect', 'input_type': 'checkbox', 'value': self.BOTH_CHOICE_CHECKBOX},
+ {'status': 'incorrect', 'input_type': 'checkbox', 'value': self.VALUE_DICT},
+ {'status': 'incomplete', 'input_type': 'radio', 'value': {}},
+ {'status': 'incomplete', 'input_type': 'checkbox', 'value': self.WRONG_CHOICE_CHECKBOX},
+ {'status': 'incomplete', 'input_type': 'checkbox', 'value': self.BOTH_CHOICE_CHECKBOX},
+ {'status': 'incomplete', 'input_type': 'checkbox', 'value': self.VALUE_DICT}]
+
+ for test_conditions in conditions:
+ self.context.update(test_conditions)
+ xml = self.render_to_xml(self.context)
+ xpath = "//div[@class='indicator_container']/span[@class='incorrect']"
+ self.assert_has_xpath(xml, xpath, self.context)
+
+ # Should NOT mark individual options
+ grouping_tag = grouping_tags[test_conditions['input_type']]
+ self.assert_no_xpath(xml,
+ "//{0}[@class='choicetextgroup_incorrect']".format(grouping_tag),
+ self.context)
+
+ self.assert_no_xpath(xml,
+ "//{0}[@class='choicetextgroup_correct']".format(grouping_tag),
+ self.context)
+
+ def test_problem_marked_unsubmitted(self):
+ """Test all conditions under which the entire problem
+ (not a particular option) is marked unanswered"""
+ grouping_tags = {'radio': 'label', 'checkbox': 'section'}
+
+ conditions = [
+ {'status': 'unsubmitted', 'input_type': 'radio', 'value': {}},
+ {'status': 'unsubmitted', 'input_type': 'radio', 'value': self.EMPTY_DICT},
+ {'status': 'unsubmitted', 'input_type': 'checkbox', 'value': {}},
+ {'status': 'unsubmitted', 'input_type': 'checkbox', 'value': self.EMPTY_DICT},
+ {'status': 'unsubmitted', 'input_type': 'checkbox', 'value': self.VALUE_DICT},
+ {'status': 'unsubmitted', 'input_type': 'checkbox', 'value': self.BOTH_CHOICE_CHECKBOX}]
+
+ self.context['status'] = 'unanswered'
+
+ for test_conditions in conditions:
+ self.context.update(test_conditions)
+ xml = self.render_to_xml(self.context)
+ xpath = "//div[@class='indicator_container']/span[@class='unanswered']"
+ self.assert_has_xpath(xml, xpath, self.context)
+
+ # Should NOT mark individual options
+ grouping_tag = grouping_tags[test_conditions['input_type']]
+ self.assert_no_xpath(xml,
+ "//{0}[@class='choicetextgroup_incorrect']".format(grouping_tag),
+ self.context)
+
+ self.assert_no_xpath(xml,
+ "//{0}[@class='choicetextgroup_correct']".format(grouping_tag),
+ self.context)
+
+ def test_option_marked_correct(self):
+ """Test conditions under which a particular option
+ (not the entire problem) is marked correct."""
+
+ conditions = [
+ {'input_type': 'radio', 'value': self.VALUE_DICT}]
+
+ self.context['status'] = 'correct'
+
+ for test_conditions in conditions:
+ self.context.update(test_conditions)
+ xml = self.render_to_xml(self.context)
+ xpath = "//section[@id='forinput1_choiceinput_0bc' and\
+ @class='choicetextgroup_correct']"
+ self.assert_has_xpath(xml, xpath, self.context)
+
+ # Should NOT mark the whole problem
+ xpath = "//div[@class='indicator_container']/span"
+ self.assert_no_xpath(xml, xpath, self.context)
+
+ def test_option_marked_incorrect(self):
+ """Test conditions under which a particular option
+ (not the entire problem) is marked incorrect."""
+
+ conditions = [
+ {'input_type': 'radio', 'value': self.VALUE_DICT}]
+
+ self.context['status'] = 'incorrect'
+
+ for test_conditions in conditions:
+ self.context.update(test_conditions)
+ xml = self.render_to_xml(self.context)
+ xpath = "//section[@id='forinput1_choiceinput_0bc' and\
+ @class='choicetextgroup_incorrect']"
+ self.assert_has_xpath(xml, xpath, self.context)
+
+ # Should NOT mark the whole problem
+ xpath = "//div[@class='indicator_container']/span"
+ self.assert_no_xpath(xml, xpath, self.context)
diff --git a/common/lib/capa/capa/tests/test_inputtypes.py b/common/lib/capa/capa/tests/test_inputtypes.py
index 1b52d41890..48e34dea09 100644
--- a/common/lib/capa/capa/tests/test_inputtypes.py
+++ b/common/lib/capa/capa/tests/test_inputtypes.py
@@ -860,3 +860,94 @@ class AnnotationInputTest(unittest.TestCase):
self.maxDiff = None
self.assertDictEqual(context, expected)
+
+
+class TestChoiceText(unittest.TestCase):
+ """
+ Tests for checkboxtextgroup inputs
+ """
+ @staticmethod
+ def build_choice_element(node_type, contents, tail_text, value):
+ """
+ Builds a content node for a choice.
+ """
+ # When xml is being parsed numtolerance_input and decoy_input tags map to textinput type
+ # in order to provide the template with correct rendering information.
+ if node_type in ('numtolerance_input', 'decoy_input'):
+ node_type = 'textinput'
+ choice = {'type': node_type, 'contents': contents, 'tail_text': tail_text, 'value': value}
+ return choice
+
+ def check_group(self, tag, choice_tag, expected_input_type):
+ """
+ Build a radio or checkbox group, parse it and check the resuls against the
+ expected output.
+
+ `tag` should be 'checkboxtextgroup' or 'radiotextgroup'
+ `choice_tag` is either 'choice' for proper xml, or any other value to trigger an error.
+ `expected_input_type` is either 'radio' or 'checkbox'.
+ """
+ xml_str = """
+ <{tag}>
+ <{choice_tag} correct="false" name="choiceinput_0">this isfalse{choice_tag}>
+ Is a number!
+ {tag}>
+ """.format(tag=tag, choice_tag=choice_tag)
+ element = etree.fromstring(xml_str)
+ state = {
+ 'value': '{}',
+ 'id': 'choicetext_input',
+ 'status': 'answered'
+ }
+
+ first_input = self.build_choice_element('numtolerance_input', 'choiceinput_0_textinput_0', 'false', '')
+ second_input = self.build_choice_element('decoy_input', 'choiceinput_1_textinput_0', '', '')
+ first_choice_content = self.build_choice_element('text', 'this is', '', '')
+ second_choice_content = self.build_choice_element('text', 'Is a number', '', '')
+ second_choice_text = self.build_choice_element('text', "!", '', '')
+
+ choices = [
+ ('choiceinput_0', [first_choice_content, first_input]),
+ ('choiceinput_1', [second_choice_content, second_input, second_choice_text])
+ ]
+
+ expected = {
+ 'msg': '',
+ 'input_type': expected_input_type,
+ 'choices': choices,
+ 'show_correctness': 'always',
+ 'submitted_message': 'Answer received.'
+ }
+ expected.update(state)
+ the_input = lookup_tag(tag)(test_system(), element, state)
+ context = the_input._get_render_context()
+ self.assertEqual(context, expected)
+
+ def test_radiotextgroup(self):
+ """
+ Test that a properly formatted radiotextgroup problem generates
+ expected ouputs
+ """
+ self.check_group('radiotextgroup', 'choice', 'radio')
+
+ def test_checkboxtextgroup(self):
+ """
+ Test that a properly formatted checkboxtextgroup problem generates
+ expected ouput
+ """
+ self.check_group('checkboxtextgroup', 'choice', 'checkbox')
+
+ def test_invalid_tag(self):
+ """
+ Test to ensure that an unrecognized inputtype tag causes an error
+ """
+ with self.assertRaises(Exception):
+ self.check_group('invalid', 'choice', 'checkbox')
+
+ def test_invalid_input_tag(self):
+ """
+ Test to ensure having a tag other than inside of
+ a checkbox or radiotextgroup problem raises an error.
+ """
+ with self.assertRaisesRegexp(Exception, "Error in xml"):
+ self.check_group('checkboxtextgroup', 'invalid', 'checkbox')
diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py
index 594e2ca629..4353f5615b 100644
--- a/common/lib/capa/capa/tests/test_responsetypes.py
+++ b/common/lib/capa/capa/tests/test_responsetypes.py
@@ -1429,3 +1429,286 @@ class AnnotationResponseTest(ResponseTest):
msg="%s should be marked %s" % (answer_id, expected_correctness))
self.assertEqual(expected_points, actual_points,
msg="%s should have %d points" % (answer_id, expected_points))
+
+
+class ChoiceTextResponseTest(ResponseTest):
+
+ from response_xml_factory import ChoiceTextResponseXMLFactory
+ xml_factory_class = ChoiceTextResponseXMLFactory
+
+ one_choice_one_input = lambda itype, inst: inst._make_problem(
+ ("true", {"answer": "123", "tolerance": "1"}),
+ itype
+ )
+
+ one_choice_two_inputs = lambda itype, inst: inst._make_problem(
+ [("true", ({"answer": "123", "tolerance": "1"},
+ {"answer": "456", "tolerance": "10"}))
+ ],
+ itype
+ )
+
+ one_input_script = lambda itype, inst: inst._make_problem(
+ ("true", {"answer": "$computed_response", "tolerance": "1"}),
+ itype,
+ "computed_response = math.sqrt(4)"
+ )
+
+ one_choice_no_input = lambda itype, inst: inst._make_problem(
+ ("true", {}),
+ itype
+ )
+
+ two_choices_no_inputs = lambda itype, inst: inst._make_problem(
+ [("false", {}), ("true", {})],
+ itype
+ )
+
+ two_choices_one_input_1 = lambda itype, inst: inst._make_problem(
+ [("false", {}), ("true", {"answer": "123", "tolerance": "0"})],
+ itype
+ )
+
+ two_choices_one_input_2 = lambda itype, inst: inst._make_problem(
+ [("true", {}), ("false", {"answer": "123", "tolerance": "0"})],
+ itype
+ )
+
+ two_choices_two_inputs = lambda itype, inst: inst._make_problem(
+ [("true", {"answer": "123", "tolerance": "0"}),
+ ("false", {"answer": "999", "tolerance": "0"})],
+ itype
+ )
+
+ TEST_INPUTS = {
+ "1_choice_0_input_correct": [(True, [])],
+ "1_choice_0_input_incorrect": [(False, [])],
+ "1_choice_0_input_invalid_choice": [(False, []), (True, [])],
+ "1_choice_1_input_correct": [(True, ["123"])],
+ "1_input_script_correct": [(True, ["2"])],
+ "1_input_script_incorrect": [(True, ["3.25"])],
+ "1_choice_2_inputs_correct": [(True, ["123", "456"])],
+ "1_choice_2_inputs_tolerance": [(True, ["123 + .5", "456 + 9"])],
+ "1_choice_2_inputs_1_wrong": [(True, ["0", "456"])],
+ "1_choice_2_inputs_both_wrong": [(True, ["0", "0"])],
+ "1_choice_2_inputs_inputs_blank": [(True, ["", ""])],
+ "1_choice_2_inputs_empty": [(False, [])],
+ "1_choice_2_inputs_fail_tolerance": [(True, ["123 + 1.5", "456 + 9"])],
+ "1_choice_1_input_within_tolerance": [(True, ["122.5"])],
+ "1_choice_1_input_answer_incorrect": [(True, ["345"])],
+ "1_choice_1_input_choice_incorrect": [(False, ["123"])],
+ "2_choices_0_inputs_correct": [(False, []), (True, [])],
+ "2_choices_0_inputs_incorrect": [(True, []), (False, [])],
+ "2_choices_0_inputs_blank": [(False, []), (False, [])],
+ "2_choices_1_input_1_correct": [(False, []), (True, ["123"])],
+ "2_choices_1_input_1_incorrect": [(True, []), (False, ["123"])],
+ "2_choices_1_input_input_wrong": [(False, []), (True, ["321"])],
+ "2_choices_1_input_1_blank": [(False, []), (False, [])],
+ "2_choices_1_input_2_correct": [(True, []), (False, ["123"])],
+ "2_choices_1_input_2_incorrect": [(False, []), (True, ["123"])],
+ "2_choices_2_inputs_correct": [(True, ["123"]), (False, [])],
+ "2_choices_2_inputs_wrong_choice": [(False, ["123"]), (True, [])],
+ "2_choices_2_inputs_wrong_input": [(True, ["321"]), (False, [])]
+ }
+
+ TEST_SCENARIOS = {
+ "1_choice_0_input_correct": ("1_choice_0_input", "correct"),
+ "1_choice_0_input_incorrect": ("1_choice_0_input", "incorrect"),
+ "1_choice_0_input_invalid_choice": ("1_choice_0_input", "incorrect"),
+ "1_input_script_correct": ("1_input_script", "correct"),
+ "1_input_script_incorrect": ("1_input_script", "incorrect"),
+ "1_choice_2_inputs_correct": ("1_choice_2_inputs", "correct"),
+ "1_choice_2_inputs_tolerance": ("1_choice_2_inputs", "correct"),
+ "1_choice_2_inputs_1_wrong": ("1_choice_2_inputs", "incorrect"),
+ "1_choice_2_inputs_both_wrong": ("1_choice_2_inputs", "incorrect"),
+ "1_choice_2_inputs_inputs_blank": ("1_choice_2_inputs", "incorrect"),
+ "1_choice_2_inputs_empty": ("1_choice_2_inputs", "incorrect"),
+ "1_choice_2_inputs_fail_tolerance": ("1_choice_2_inputs", "incorrect"),
+ "1_choice_1_input_correct": ("1_choice_1_input", "correct"),
+ "1_choice_1_input_within_tolerance": ("1_choice_1_input", "correct"),
+ "1_choice_1_input_answer_incorrect": ("1_choice_1_input", "incorrect"),
+ "1_choice_1_input_choice_incorrect": ("1_choice_1_input", "incorrect"),
+ "2_choices_0_inputs_correct": ("2_choices_0_inputs", "correct"),
+ "2_choices_0_inputs_incorrect": ("2_choices_0_inputs", "incorrect"),
+ "2_choices_0_inputs_blank": ("2_choices_0_inputs", "incorrect"),
+ "2_choices_1_input_1_correct": ("2_choices_1_input_1", "correct"),
+ "2_choices_1_input_1_incorrect": ("2_choices_1_input_1", "incorrect"),
+ "2_choices_1_input_input_wrong": ("2_choices_1_input_1", "incorrect"),
+ "2_choices_1_input_1_blank": ("2_choices_1_input_1", "incorrect"),
+ "2_choices_1_input_2_correct": ("2_choices_1_input_2", "correct"),
+ "2_choices_1_input_2_incorrect": ("2_choices_1_input_2", "incorrect"),
+ "2_choices_2_inputs_correct": ("2_choices_2_inputs", "correct"),
+ "2_choices_2_inputs_wrong_choice": ("2_choices_2_inputs", "incorrect"),
+ "2_choices_2_inputs_wrong_input": ("2_choices_2_inputs", "incorrect")
+ }
+
+ TEST_PROBLEMS = {
+ "1_choice_0_input": one_choice_no_input,
+ "1_choice_1_input": one_choice_one_input,
+ "1_input_script": one_input_script,
+ "1_choice_2_inputs": one_choice_two_inputs,
+ "2_choices_0_inputs": two_choices_no_inputs,
+ "2_choices_1_input_1": two_choices_one_input_1,
+ "2_choices_1_input_2": two_choices_one_input_2,
+ "2_choices_2_inputs": two_choices_two_inputs
+ }
+
+ def _make_problem(self, choices, in_type='radiotextgroup', script=''):
+ """
+ Convenience method to fill in default values for script and
+ type if needed, then call self.build_problem
+ """
+ return self.build_problem(
+ choices=choices,
+ type=in_type,
+ script=script
+ )
+
+ def _make_answer_dict(self, choice_list):
+ """
+ Convenience method to make generation of answers less tedious,
+ pass in an iterable argument with elements of the form: [bool, [ans,]]
+ Will generate an answer dict for those options
+ """
+
+ answer_dict = {}
+ for index, choice_answers_pair in enumerate(choice_list):
+ # Choice is whether this choice is correct
+ # Answers contains a list of answers to textinpts for the choice
+ choice, answers = choice_answers_pair
+
+ if choice:
+ # Radio/Checkbox inputs in choicetext problems follow
+ # a naming convention that gives them names ending with "bc"
+ choice_id = "1_2_1_choiceinput_{index}bc".format(index=index)
+ choice_value = "choiceinput_{index}".format(index=index)
+ answer_dict[choice_id] = choice_value
+ # Build the names for the numtolerance_inputs and add their answers
+ # to `answer_dict`.
+ for ind, answer in enumerate(answers):
+ # In `answer_id` `index` represents the ordinality of the
+ # choice and `ind` represents the ordinality of the
+ # numtolerance_input inside the parent choice.
+ answer_id = "1_2_1_choiceinput_{index}_numtolerance_input_{ind}".format(
+ index=index,
+ ind=ind
+ )
+ answer_dict[answer_id] = answer
+
+ return answer_dict
+
+ def test_invalid_xml(self):
+ with self.assertRaises(Exception):
+ self.build_problem(type="invalidtextgroup")
+
+ def test_valid_xml(self):
+ self.build_problem()
+ self.assertTrue(True)
+
+ def test_interpret_error(self):
+ one_choice_one_input = lambda itype: self._make_problem(
+ ("true", {"answer": "123", "tolerance": "1"}),
+ itype
+ )
+
+ with self.assertRaisesRegexp(StudentInputError, "Could not interpret"):
+ self.assert_grade(
+ one_choice_one_input('radiotextgroup'),
+ self._make_answer_dict([(True, ["Platypus"])]),
+ "correct"
+ )
+
+ def test_staff_answer_error(self):
+ broken_problem = self._make_problem(
+ [("true", {"answer": "Platypus", "tolerance": "0"}),
+ ("true", {"answer": "edX", "tolerance": "0"})
+ ],
+ "checkboxtextgroup"
+ )
+ with self.assertRaisesRegexp(
+ StudentInputError,
+ "The Staff answer could not be interpreted as a number."
+ ):
+ self.assert_grade(
+ broken_problem,
+ self._make_answer_dict(
+ [(True, ["1"]), (True, ["1"])]
+ ),
+ "correct"
+ )
+
+ def test_radio_grades(self):
+
+ for name, inputs in self.TEST_INPUTS.iteritems():
+ submission = self._make_answer_dict(inputs)
+ problem_name, correctness = self.TEST_SCENARIOS[name]
+ problem = self.TEST_PROBLEMS[problem_name]
+
+ self.assert_grade(
+ problem('radiotextgroup', self),
+ submission,
+ correctness,
+ msg="{0} should be {1}".format(
+ name,
+ correctness
+ )
+ )
+
+ def test_checkbox_grades(self):
+ scenarios = {
+ "2_choices_correct": ("checkbox_two_choices", "correct"),
+ "2_choices_incorrect": ("checkbox_two_choices", "incorrect"),
+ "2_choices_2_inputs_correct": (
+ "checkbox_2_choices_2_inputs",
+ "correct"
+ ),
+
+ "2_choices_2_inputs_missing_choice": (
+ "checkbox_2_choices_2_inputs",
+ "incorrect"
+ ),
+
+ "2_choices_2_inputs_wrong_input": (
+ "checkbox_2_choices_2_inputs",
+ "incorrect"
+ )
+ }
+ inputs = {
+ "2_choices_correct": [(True, []), (True, [])],
+ "2_choices_incorrect": [(True, []), (False, [])],
+ "2_choices_2_inputs_correct": [(True, ["123"]), (True, ["456"])],
+ "2_choices_2_inputs_missing_choice": [
+ (True, ["123"]), (False, ["456"])
+ ],
+ "2_choices_2_inputs_wrong_input": [
+ (True, ["123"]), (True, ["654"])
+ ]
+ }
+
+ checkbox_two_choices = self._make_problem(
+ [("true", {}), ("true", {})], "checkboxtextgroup"
+ )
+ checkbox_two_choices_two_inputs = self._make_problem(
+ [("true", {"answer": "123", "tolerance": "0"}),
+ ("true", {"answer": "456", "tolerance": "0"})
+ ],
+ "checkboxtextgroup"
+ )
+
+ problems = {
+ "checkbox_two_choices": checkbox_two_choices,
+ "checkbox_2_choices_2_inputs": checkbox_two_choices_two_inputs
+ }
+ problems.update(self.TEST_PROBLEMS)
+
+ for name, inputs in inputs.iteritems():
+ submission = self._make_answer_dict(inputs)
+ problem_name, correctness = scenarios[name]
+ problem = problems[problem_name]
+
+ self.assert_grade(
+ problem,
+ submission,
+ correctness,
+ msg="{0} should be {1}".format(name, correctness)
+ )
diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py
index 51c1a396c3..d2a20675a5 100644
--- a/common/lib/xmodule/xmodule/capa_module.py
+++ b/common/lib/xmodule/xmodule/capa_module.py
@@ -776,6 +776,13 @@ class CapaModule(CapaFields, XModule):
then the output dict would contain {'1': ['test'] }
(the value is a list).
+ Some other inputs such as ChoiceTextInput expect a dict of values in the returned
+ dict If the key ends with '{}' then we will assume that the value is a json
+ encoded dict and deserialize it.
+ For example, if the `data` dict contains {'input_1{}': '{"1_2_1": 1}'}
+ then the output dict would contain {'1': {"1_2_1": 1} }
+ (the value is a dictionary)
+
Raises an exception if:
-A key in the `data` dictionary does not contain at least one underscore
@@ -802,11 +809,21 @@ class CapaModule(CapaFields, XModule):
# the same form input (e.g. checkbox inputs). The convention is that
# if the name ends with '[]' (which looks like an array), then the
# answer will be an array.
+ # if the name ends with '{}' (Which looks like a dict),
+ # then the answer will be a dict
is_list_key = name.endswith('[]')
- name = name[:-2] if is_list_key else name
+ is_dict_key = name.endswith('{}')
+ name = name[:-2] if is_list_key or is_dict_key else name
if is_list_key:
val = data.getlist(key)
+ elif is_dict_key:
+ try:
+ val = json.loads(data[key])
+ except(KeyError, ValueError):
+ # Send this information along to be reported by
+ # The grading method
+ val = {"error": "error"}
else:
val = data[key]
diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss
index c326c79b76..9e6826242f 100644
--- a/common/lib/xmodule/xmodule/css/capa/display.scss
+++ b/common/lib/xmodule/xmodule/css/capa/display.scss
@@ -929,4 +929,32 @@ section.problem {
}
}
}
+ .choicetextgroup{
+ input[type="text"]{
+ margin-bottom: 0.5em;
+ }
+ @extend .choicegroup;
+
+ label.choicetextgroup_correct, section.choicetextgroup_correct{
+ @extend label.choicegroup_correct;
+
+ input[type="text"] {
+ border-color: green;
+ }
+ }
+
+ label.choicetextgroup_incorrect, section.choicetextgroup_incorrect{
+ @extend label.choicegroup_incorrect;
+ }
+
+ label.choicetextgroup_show_correct, section.choicetextgroup_show_correct{
+ &:after{
+ content: url('../images/correct-icon.png');
+ margin-left:15px;
+ }
+ }
+ span.mock_label{
+ cursor : default;
+ }
+ }
}
diff --git a/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee b/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee
index 1efaa6c852..bca89b0dea 100644
--- a/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee
+++ b/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee
@@ -223,6 +223,58 @@ describe 'Problem', ->
expect($('label[for="input_1_1_3"]')).toHaveAttr 'correct_answer', 'true'
expect($('label[for="input_1_2_1"]')).not.toHaveAttr 'correct_answer', 'true'
+ describe 'radio text question', ->
+ radio_text_xml='''
+
+
+
+
+
+
+'''
+ beforeEach ->
+ # Append a radiotextresponse problem to the problem, so we can check it's javascript functionality
+ @problem.el.prepend(radio_text_xml)
+
+ it 'sets the correct class on the section for the correct choice', ->
+ spyOn($, 'postWithPrefix').andCallFake (url, callback) ->
+ callback answers: "1_2_1": ["1_2_1_choiceinput_0bc"], "1_2_1_choiceinput_0bc": "3"
+ @problem.show()
+
+ expect($('#forinput1_2_1_choiceinput_0bc').attr('class')).toEqual(
+ 'choicetextgroup_show_correct')
+ expect($('#answer_1_2_1_choiceinput_0bc').text()).toEqual('3')
+ expect($('#answer_1_2_1_choiceinput_1bc').text()).toEqual('')
+ expect($('#answer_1_2_1_choiceinput_2bc').text()).toEqual('')
+
+ it 'Should not disable input fields', ->
+ spyOn($, 'postWithPrefix').andCallFake (url, callback) ->
+ callback answers: "1_2_1": ["1_2_1_choiceinput_0bc"], "1_2_1_choiceinput_0bc": "3"
+ @problem.show()
+ expect($('input#1_2_1_choiceinput_0bc').attr('disabled')).not.toEqual('disabled')
+ expect($('input#1_2_1_choiceinput_1bc').attr('disabled')).not.toEqual('disabled')
+ expect($('input#1_2_1_choiceinput_2bc').attr('disabled')).not.toEqual('disabled')
+ expect($('input#1_2_1').attr('disabled')).not.toEqual('disabled')
+
describe 'when the answers are already shown', ->
beforeEach ->
@problem.el.addClass 'showed'
diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee
index b7dbf6864d..601fb749ac 100644
--- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee
+++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee
@@ -403,6 +403,14 @@ class @Problem
answer = JSON.parse(answers[answer_id])
display.showAnswer(answer)
+ choicetextgroup: (element, display, answers) =>
+ element = $(element)
+
+ input_id = element.attr('id').replace(/inputtype_/,'')
+ answer = answers[input_id]
+ for choice in answer
+ element.find("section#forinput#{choice}").addClass 'choicetextgroup_show_correct'
+
inputtypeHideAnswerMethods:
choicegroup: (element, display) =>
element = $(element)
@@ -410,3 +418,7 @@ class @Problem
javascriptinput: (element, display) =>
display.hideAnswer()
+
+ choicetextgroup: (element, display) =>
+ element = $(element)
+ element.find("section[id^='forinput']").removeClass('choicetextgroup_show_correct')
diff --git a/common/static/js/capa/choicetextinput.js b/common/static/js/capa/choicetextinput.js
new file mode 100644
index 0000000000..4d7540f938
--- /dev/null
+++ b/common/static/js/capa/choicetextinput.js
@@ -0,0 +1,75 @@
+(function () {
+ var update = function () {
+ // Whenever a value changes create a new serialized version of this
+ // problem's inputs and set the hidden input fields value to equal it.
+ var parent = $(this).closest('.problems-wrapper');
+ // find the closest parent problems-wrapper and use that as the problem
+ // grab the input id from the input
+ // real_input is the hidden input field
+ var real_input = $('input.choicetextvalue', parent);
+ var all_inputs = $('.choicetextinput .ctinput', parent);
+ var user_inputs = {};
+ $(all_inputs).each(function (index, elt) {
+ var node = $(elt);
+ var name = node.attr('id');
+ var val = node.val();
+ var radio_value = node.attr('value');
+ var type = node.attr('type');
+ var is_checked = node.attr('checked');
+ if (type === "radio" || type === "checkbox") {
+ if (is_checked === "checked" || is_checked === "true") {
+ user_inputs[name] = radio_value;
+ }
+ } else {
+ user_inputs[name] = val;
+ }
+ });
+ var val_string = JSON.stringify(user_inputs);
+ //this is what gets submitted as the answer, we deserialize it later
+ real_input.val(val_string);
+ };
+
+ var check_parent = function (event) {
+ // This looks for the containing choice of a textinput
+ // and sets it to be checked.
+ var elt = $(event.target);
+ var parent_container = elt.closest('section[id^="forinput"]');
+ var choice = parent_container.find("input[type='checkbox'], input[type='radio']");
+ choice.attr("checked", "checked");
+ choice.change();
+ //need to check it then trigger the change event
+ };
+
+ var imitate_label = function (event) {
+ // This causes a section to check and uncheck
+ // a radiobutton/checkbox whenever a user clicks on it
+ // If the button/checkbox is disabled, nothing happens
+ var elt = $(event.target);
+ var parent_container = elt.closest('section[id^="forinput"]');
+ var choice = parent_container.find("input[type='checkbox'], input[type='radio']");
+ if (choice.attr("type") === "radio") {
+ choice.attr("checked", "checked");
+ } else {
+ if (choice.attr('checked')) {
+ choice.prop("checked", false);
+ } else {
+ choice.prop("checked", true);
+ }
+
+ }
+ choice.change();
+ update();
+
+ };
+ var choices = $('.mock_label');
+ var inputs = $('.choicetextinput .ctinput');
+ var text_inputs = $('.choicetextinput .ctinput[type="text"]');
+ // update on load
+ inputs.each(update);
+ // and on every change
+ // This allows text inside of choices to behave as if they were part of
+ // a label for the choice's button/checkbox
+ choices.click(imitate_label);
+ inputs.bind("change", update);
+ text_inputs.click(check_parent);
+}).call(this);
diff --git a/lms/djangoapps/courseware/features/problems.feature b/lms/djangoapps/courseware/features/problems.feature
index 4a5e64e9f4..fe6a695475 100644
--- a/lms/djangoapps/courseware/features/problems.feature
+++ b/lms/djangoapps/courseware/features/problems.feature
@@ -21,6 +21,8 @@ Feature: Answer problems
| formula |
| script |
| code |
+ | radio_text |
+ | checkbox_text |
Scenario: I can answer a problem incorrectly
Given External graders respond "incorrect"
@@ -40,6 +42,8 @@ Feature: Answer problems
| formula |
| script |
| code |
+ | radio_text |
+ | checkbox_text |
Scenario: I can submit a blank answer
Given I am viewing a "" problem
@@ -57,6 +61,8 @@ Feature: Answer problems
| numerical |
| formula |
| script |
+ | radio_text |
+ | checkbox_text |
Scenario: I can reset a problem
@@ -84,6 +90,10 @@ Feature: Answer problems
| formula | incorrect |
| script | correct |
| script | incorrect |
+ | radio_text | correct |
+ | radio_text | incorrect |
+ | checkbox_text | correct |
+ | checkbox_text | incorrect |
Scenario: I can answer a problem with one attempt correctly and not reset
diff --git a/lms/djangoapps/courseware/features/problems_setup.py b/lms/djangoapps/courseware/features/problems_setup.py
index 6086d7fa5e..aacdec90f8 100644
--- a/lms/djangoapps/courseware/features/problems_setup.py
+++ b/lms/djangoapps/courseware/features/problems_setup.py
@@ -18,7 +18,7 @@ from capa.tests.response_xml_factory import OptionResponseXMLFactory, \
ChoiceResponseXMLFactory, MultipleChoiceResponseXMLFactory, \
StringResponseXMLFactory, NumericalResponseXMLFactory, \
FormulaResponseXMLFactory, CustomResponseXMLFactory, \
- CodeResponseXMLFactory
+ CodeResponseXMLFactory, ChoiceTextResponseXMLFactory
from nose.tools import assert_true
@@ -131,6 +131,32 @@ PROBLEM_DICT = {
'grader_payload': '{"grader": "ps1/Spring2013/test_grader.py"}', },
'correct': ['span.correct'],
'incorrect': ['span.incorrect'],
+ 'unanswered': ['span.unanswered']},
+
+ 'radio_text': {
+ 'factory': ChoiceTextResponseXMLFactory(),
+ 'kwargs': {
+ 'question_text': 'The correct answer is Choice 0 and input 8',
+ 'type': 'radiotextgroup',
+ 'choices': [("true", {"answer": "8", "tolerance": "1"}),
+ ("false", {"answer": "8", "tolerance": "1"})
+ ]
+ },
+ 'correct': ['section.choicetextgroup_correct'],
+ 'incorrect': ['span.incorrect', 'section.choicetextgroup_incorrect'],
+ 'unanswered': ['span.unanswered']},
+
+ 'checkbox_text': {
+ 'factory': ChoiceTextResponseXMLFactory(),
+ 'kwargs': {
+ 'question_text': 'The correct answer is Choice 0 and input 8',
+ 'type': 'checkboxtextgroup',
+ 'choices': [("true", {"answer": "8", "tolerance": "1"}),
+ ("false", {"answer": "8", "tolerance": "1"})
+ ]
+ },
+ 'correct': ['span.correct'],
+ 'incorrect': ['span.incorrect'],
'unanswered': ['span.unanswered']}
}
@@ -196,6 +222,19 @@ def answer_problem(problem_type, correctness):
# (configured in the problem XML above)
pass
+ elif problem_type == 'radio_text' or problem_type == 'checkbox_text':
+
+ input_value = "8" if correctness == 'correct' else "5"
+ choice = "choiceinput_0bc" if correctness == 'correct' else "choiceinput_1bc"
+ world.css_check(inputfield(problem_type, choice=choice))
+ world.css_fill(
+ inputfield(
+ problem_type,
+ choice="choiceinput_0_numtolerance_input_0"
+ ),
+ input_value
+ )
+
def problem_has_answer(problem_type, answer_class):
if problem_type == "drop down":
@@ -244,6 +283,17 @@ def problem_has_answer(problem_type, answer_class):
expected = "x^2+2*x+y" if answer_class == 'correct' else 'x^2'
assert_textfield('formula', expected)
+ elif problem_type in ("radio_text", "checkbox_text"):
+ if answer_class == 'blank':
+ expected = ('', '')
+ assert_choicetext_values(problem_type, (), expected)
+ elif answer_class == 'incorrect':
+ expected = ('5', '')
+ assert_choicetext_values(problem_type, ["choiceinput_1bc"], expected)
+ else:
+ expected = ('8', '')
+ assert_choicetext_values(problem_type, ["choiceinput_0bc"], expected)
+
else:
# The other response types use random data,
# which would be difficult to check
@@ -292,6 +342,12 @@ def inputfield(problem_type, choice=None, input_num=1):
sel = ("input#input_i4x-edx-model_course-problem-%s_2_%s" %
(problem_type.replace(" ", "_"), str(input_num)))
+ # this is necessary due to naming requirement for this problem type
+ if problem_type in ("radio_text", "checkbox_text"):
+ sel = "input#i4x-edx-model_course-problem-{0}_2_{1}".format(
+ problem_type.replace(" ", "_"), str(input_num)
+ )
+
if choice is not None:
base = "_choice_" if problem_type == "multiple choice" else "_"
sel = sel + base + str(choice)
@@ -325,3 +381,28 @@ def assert_checked(problem_type, choices):
def assert_textfield(problem_type, expected_text, input_num=1):
element_value = world.css_value(inputfield(problem_type, input_num=input_num))
assert element_value == expected_text
+
+
+def assert_choicetext_values(problem_type, choices, expected_values):
+ """
+ Asserts that only the given choices are checked, and given
+ text fields have a desired value
+ """
+
+ all_choices = ['choiceinput_0bc', 'choiceinput_1bc']
+ all_inputs = [
+ "choiceinput_0_numtolerance_input_0",
+ "choiceinput_1_numtolerance_input_0"
+ ]
+ for this_choice in all_choices:
+ element = world.css_find(inputfield(problem_type, choice=this_choice))
+
+ if this_choice in choices:
+ assert element.checked
+ else:
+ assert not element.checked
+
+ for (name, expected) in zip(all_inputs, expected_values):
+ element = world.css_find(inputfield(problem_type, name))
+ # Remove any trailing spaces that may have been added
+ assert element.value.strip() == expected
From 4d880db1b551641e82d30b5550825190e284ede4 Mon Sep 17 00:00:00 2001
From: RobertMarks
Date: Thu, 4 Jul 2013 21:30:59 -0700
Subject: [PATCH 47/73] Updated behavior for split_answer_dict, get_score, and
check_student_inputs (responsetypes.py)
---
common/lib/capa/capa/inputtypes.py | 19 +-
common/lib/capa/capa/responsetypes.py | 2 -
.../capa/capa/tests/response_xml_factory.py | 6 +-
.../lib/capa/capa/tests/test_responsetypes.py | 193 ++++++++++++------
common/lib/xmodule/xmodule/capa_module.py | 7 +-
.../courseware/features/problems_setup.py | 3 +-
6 files changed, 150 insertions(+), 80 deletions(-)
diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py
index f26909b633..29800a211b 100644
--- a/common/lib/capa/capa/inputtypes.py
+++ b/common/lib/capa/capa/inputtypes.py
@@ -460,10 +460,10 @@ class JSInput(InputTypeBase):
DO NOT USE! HAS NOT BEEN TESTED BEYOND 700X PROBLEMS, AND MAY CHANGE IN
BACKWARDS-INCOMPATIBLE WAYS.
Inputtype for general javascript inputs. Intended to be used with
- customresponse.
+ customresponse.
Loads in a sandboxed iframe to help prevent css and js conflicts between
- frame and top-level window.
-
+ frame and top-level window.
+
iframe sandbox whitelist:
- allow-scripts
- allow-popups
@@ -474,9 +474,9 @@ class JSInput(InputTypeBase):
window elements.
Example:
-
See the documentation in the /doc/public folder for more information.
@@ -500,7 +500,7 @@ class JSInput(InputTypeBase):
Attribute('width', "400"), # iframe width
Attribute('height', "300")] # iframe height
-
+
def _extra_context(self):
context = {
@@ -510,11 +510,12 @@ class JSInput(InputTypeBase):
return context
-
+
registry.register(JSInput)
#-----------------------------------------------------------------------------
+
class TextLine(InputTypeBase):
"""
A text line input. Can do math preview if "math"="1" is specified.
@@ -1373,8 +1374,6 @@ registry.register(AnnotationInput)
class ChoiceTextGroup(InputTypeBase):
"""
Groups of radiobutton/checkboxes with text inputs.
- Allows for a "not enough information" option to be added
- to problems with numerical answers.
Examples:
RadioButton problem
diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py
index 51bae0b215..f99518c8ce 100644
--- a/common/lib/capa/capa/responsetypes.py
+++ b/common/lib/capa/capa/responsetypes.py
@@ -2256,7 +2256,6 @@ class ChoiceTextResponse(LoncapaResponse):
binary_choices, numtolerance_inputs = self._split_answers_dict(answer_dict)
# Check the binary choices first.
choices_correct = self._check_student_choices(binary_choices)
- inputs_correct = True
inputs_correct = self._check_student_inputs(numtolerance_inputs)
# Only return correct if the student got both the binary
# and numtolerance_inputs are correct
@@ -2376,7 +2375,6 @@ class ChoiceTextResponse(LoncapaResponse):
Returns True if and only if all student inputs are correct.
"""
-
inputs_correct = True
for answer_name, answer_value in numtolerance_inputs.iteritems():
# If `self.corrrect_inputs` does not contain an entry for
diff --git a/common/lib/capa/capa/tests/response_xml_factory.py b/common/lib/capa/capa/tests/response_xml_factory.py
index 38c4a00caa..4c015d6699 100644
--- a/common/lib/capa/capa/tests/response_xml_factory.py
+++ b/common/lib/capa/capa/tests/response_xml_factory.py
@@ -857,15 +857,15 @@ class ChoiceTextResponseXMLFactory(ResponseXMLFactory):
choice_element.set("correct", correct)
choice_element.text = text
for inp in inputs:
- # Add all of the inputs as children of this element
+ # Add all of the inputs as children of this choice
choice_element.append(inp)
return choice_element
def _create_numtolerance_input_element(self, params):
"""
- Creates a element with optionally
- specified tolerance and answer.
+ Creates a or element with
+ optionally specified tolerance and answer.
"""
answer = params['answer'] if 'answer' in params else None
# If there is not an answer specified, Then create a
diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py
index 4353f5615b..2a15145579 100644
--- a/common/lib/capa/capa/tests/test_responsetypes.py
+++ b/common/lib/capa/capa/tests/test_responsetypes.py
@@ -1432,54 +1432,15 @@ class AnnotationResponseTest(ResponseTest):
class ChoiceTextResponseTest(ResponseTest):
+ """
+ Class containing setup and tests for ChoiceText responsetype.
+ """
from response_xml_factory import ChoiceTextResponseXMLFactory
xml_factory_class = ChoiceTextResponseXMLFactory
- one_choice_one_input = lambda itype, inst: inst._make_problem(
- ("true", {"answer": "123", "tolerance": "1"}),
- itype
- )
-
- one_choice_two_inputs = lambda itype, inst: inst._make_problem(
- [("true", ({"answer": "123", "tolerance": "1"},
- {"answer": "456", "tolerance": "10"}))
- ],
- itype
- )
-
- one_input_script = lambda itype, inst: inst._make_problem(
- ("true", {"answer": "$computed_response", "tolerance": "1"}),
- itype,
- "computed_response = math.sqrt(4)"
- )
-
- one_choice_no_input = lambda itype, inst: inst._make_problem(
- ("true", {}),
- itype
- )
-
- two_choices_no_inputs = lambda itype, inst: inst._make_problem(
- [("false", {}), ("true", {})],
- itype
- )
-
- two_choices_one_input_1 = lambda itype, inst: inst._make_problem(
- [("false", {}), ("true", {"answer": "123", "tolerance": "0"})],
- itype
- )
-
- two_choices_one_input_2 = lambda itype, inst: inst._make_problem(
- [("true", {}), ("false", {"answer": "123", "tolerance": "0"})],
- itype
- )
-
- two_choices_two_inputs = lambda itype, inst: inst._make_problem(
- [("true", {"answer": "123", "tolerance": "0"}),
- ("false", {"answer": "999", "tolerance": "0"})],
- itype
- )
-
+ # `TEST_INPUTS` is a dictionary mapping from
+ # test_name to a representation of inputs for a test problem.
TEST_INPUTS = {
"1_choice_0_input_correct": [(True, [])],
"1_choice_0_input_incorrect": [(False, [])],
@@ -1511,6 +1472,10 @@ class ChoiceTextResponseTest(ResponseTest):
"2_choices_2_inputs_wrong_input": [(True, ["321"]), (False, [])]
}
+ # `TEST_SCENARIOS` is a dictionary of the form
+ # {Test_Name" : (Test_Problem_name, correctness)}
+ # correctness represents whether the problem should be graded as
+ # correct or incorrect when the test is run.
TEST_SCENARIOS = {
"1_choice_0_input_correct": ("1_choice_0_input", "correct"),
"1_choice_0_input_incorrect": ("1_choice_0_input", "incorrect"),
@@ -1542,15 +1507,53 @@ class ChoiceTextResponseTest(ResponseTest):
"2_choices_2_inputs_wrong_input": ("2_choices_2_inputs", "incorrect")
}
- TEST_PROBLEMS = {
- "1_choice_0_input": one_choice_no_input,
- "1_choice_1_input": one_choice_one_input,
- "1_input_script": one_input_script,
- "1_choice_2_inputs": one_choice_two_inputs,
- "2_choices_0_inputs": two_choices_no_inputs,
- "2_choices_1_input_1": two_choices_one_input_1,
- "2_choices_1_input_2": two_choices_one_input_2,
- "2_choices_2_inputs": two_choices_two_inputs
+ # Dictionary that maps from problem_name to arguments for
+ # _make_problem, that will create the problem.
+ TEST_PROBLEM_ARGS = {
+ "1_choice_0_input": {"choices": ("true", {}), "script": ''},
+ "1_choice_1_input": {
+ "choices": ("true", {"answer": "123", "tolerance": "1"}),
+ "script": ''
+ },
+
+ "1_input_script": {
+ "choices": ("true", {"answer": "$computed_response", "tolerance": "1"}),
+ "script": "computed_response = math.sqrt(4)"
+ },
+
+ "1_choice_2_inputs": {
+ "choices": [
+ (
+ "true", (
+ {"answer": "123", "tolerance": "1"},
+ {"answer": "456", "tolerance": "10"}
+ )
+ )
+ ],
+ "script": ''
+ },
+ "2_choices_0_inputs": {
+ "choices": [("false", {}), ("true", {})],
+ "script": ''
+
+ },
+ "2_choices_1_input_1": {
+ "choices": [
+ ("false", {}), ("true", {"answer": "123", "tolerance": "0"})
+ ],
+ "script": ''
+ },
+ "2_choices_1_input_2": {
+ "choices": [("true", {}), ("false", {"answer": "123", "tolerance": "0"})],
+ "script": ''
+ },
+ "2_choices_2_inputs": {
+ "choices": [
+ ("true", {"answer": "123", "tolerance": "0"}),
+ ("false", {"answer": "999", "tolerance": "0"})
+ ],
+ "script": ''
+ }
}
def _make_problem(self, choices, in_type='radiotextgroup', script=''):
@@ -1598,26 +1601,69 @@ class ChoiceTextResponseTest(ResponseTest):
return answer_dict
def test_invalid_xml(self):
+ """
+ Test that build problem raises errors for invalid options
+ """
with self.assertRaises(Exception):
self.build_problem(type="invalidtextgroup")
def test_valid_xml(self):
+ """
+ Test that `build_problem` builds valid xml
+ """
self.build_problem()
self.assertTrue(True)
+ def test_unchecked_input_not_validated(self):
+ """
+ Test that a student can have a non numeric answer in an unselected
+ choice without causing an error to be raised when the problem is
+ checked.
+ """
+
+ two_choice_two_input = self._make_problem(
+ [
+ ("true", {"answer": "123", "tolerance": "1"}),
+ ("false", {})
+ ],
+ "checkboxtextgroup"
+ )
+
+ self.assert_grade(
+ two_choice_two_input,
+ self._make_answer_dict([(True, ["1"]), (False, ["Platypus"])]),
+ "incorrect"
+ )
+
def test_interpret_error(self):
- one_choice_one_input = lambda itype: self._make_problem(
- ("true", {"answer": "123", "tolerance": "1"}),
- itype
+ """
+ Test that student answers that cannot be interpeted as numbers
+ cause the response type to raise an error.
+ """
+ two_choice_two_input = self._make_problem(
+ [
+ ("true", {"answer": "123", "tolerance": "1"}),
+ ("false", {})
+ ],
+ "checkboxtextgroup"
)
with self.assertRaisesRegexp(StudentInputError, "Could not interpret"):
+ # Test that error is raised for input in selected correct choice.
self.assert_grade(
- one_choice_one_input('radiotextgroup'),
+ two_choice_two_input,
self._make_answer_dict([(True, ["Platypus"])]),
"correct"
)
+ with self.assertRaisesRegexp(StudentInputError, "Could not interpret"):
+ # Test that error is raised for input in selected incorrect choice.
+ self.assert_grade(
+ two_choice_two_input,
+ self._make_answer_dict([(True, ["1"]), (True, ["Platypus"])]),
+ "correct"
+ )
+
def test_staff_answer_error(self):
broken_problem = self._make_problem(
[("true", {"answer": "Platypus", "tolerance": "0"}),
@@ -1638,14 +1684,26 @@ class ChoiceTextResponseTest(ResponseTest):
)
def test_radio_grades(self):
+ """
+ Test that confirms correct operation of grading when the inputtag is
+ radiotextgroup.
+ """
for name, inputs in self.TEST_INPUTS.iteritems():
+ # Turn submission into the form expected when grading this problem.
submission = self._make_answer_dict(inputs)
+ # Lookup the problem_name, and the whether this test problem
+ # and inputs should be graded as correct or incorrect.
problem_name, correctness = self.TEST_SCENARIOS[name]
- problem = self.TEST_PROBLEMS[problem_name]
-
+ # Load the args needed to build the problem for this test.
+ problem_args = self.TEST_PROBLEM_ARGS[problem_name]
+ test_choices = problem_args["choices"]
+ test_script = problem_args["script"]
+ # Build the actual problem for the test.
+ test_problem = self._make_problem(test_choices, 'radiotextgroup', test_script)
+ # Make sure the actual grade matches the expected grade.
self.assert_grade(
- problem('radiotextgroup', self),
+ test_problem,
submission,
correctness,
msg="{0} should be {1}".format(
@@ -1655,9 +1713,16 @@ class ChoiceTextResponseTest(ResponseTest):
)
def test_checkbox_grades(self):
+ """
+ Test that confirms correct operation of grading when the inputtag is
+ checkboxtextgroup.
+ """
+ # Dictionary from name of test_scenario to (problem_name, correctness)
+ # Correctness is used to test whether the problem was graded properly
scenarios = {
"2_choices_correct": ("checkbox_two_choices", "correct"),
"2_choices_incorrect": ("checkbox_two_choices", "incorrect"),
+
"2_choices_2_inputs_correct": (
"checkbox_2_choices_2_inputs",
"correct"
@@ -1673,6 +1738,7 @@ class ChoiceTextResponseTest(ResponseTest):
"incorrect"
)
}
+ # Dictionary scenario_name: test_inputs
inputs = {
"2_choices_correct": [(True, []), (True, [])],
"2_choices_incorrect": [(True, []), (False, [])],
@@ -1685,9 +1751,11 @@ class ChoiceTextResponseTest(ResponseTest):
]
}
+ # Two choice zero input problem with both choices being correct.
checkbox_two_choices = self._make_problem(
[("true", {}), ("true", {})], "checkboxtextgroup"
)
+ # Two choice two input problem with both choices correct.
checkbox_two_choices_two_inputs = self._make_problem(
[("true", {"answer": "123", "tolerance": "0"}),
("true", {"answer": "456", "tolerance": "0"})
@@ -1695,17 +1763,20 @@ class ChoiceTextResponseTest(ResponseTest):
"checkboxtextgroup"
)
+ # Dictionary problem_name: problem
problems = {
"checkbox_two_choices": checkbox_two_choices,
"checkbox_2_choices_2_inputs": checkbox_two_choices_two_inputs
}
- problems.update(self.TEST_PROBLEMS)
for name, inputs in inputs.iteritems():
submission = self._make_answer_dict(inputs)
+ # Load the test problem's name and desired correctness
problem_name, correctness = scenarios[name]
+ # Load the problem
problem = problems[problem_name]
+ # Make sure the actual grade matches the expected grade
self.assert_grade(
problem,
submission,
diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py
index d2a20675a5..d76b62dc06 100644
--- a/common/lib/xmodule/xmodule/capa_module.py
+++ b/common/lib/xmodule/xmodule/capa_module.py
@@ -820,10 +820,11 @@ class CapaModule(CapaFields, XModule):
elif is_dict_key:
try:
val = json.loads(data[key])
+ # If the submission wasn't deserializable, raise an error.
except(KeyError, ValueError):
- # Send this information along to be reported by
- # The grading method
- val = {"error": "error"}
+ raise ValueError(
+ u"Invalid submission: {val} for {key}".format(val=data[key], key=key)
+ )
else:
val = data[key]
diff --git a/lms/djangoapps/courseware/features/problems_setup.py b/lms/djangoapps/courseware/features/problems_setup.py
index aacdec90f8..67dfbf0dc5 100644
--- a/lms/djangoapps/courseware/features/problems_setup.py
+++ b/lms/djangoapps/courseware/features/problems_setup.py
@@ -388,8 +388,9 @@ def assert_choicetext_values(problem_type, choices, expected_values):
Asserts that only the given choices are checked, and given
text fields have a desired value
"""
-
+ # Names of the radio buttons or checkboxes
all_choices = ['choiceinput_0bc', 'choiceinput_1bc']
+ # Names of the numtolerance_inputs
all_inputs = [
"choiceinput_0_numtolerance_input_0",
"choiceinput_1_numtolerance_input_0"
From d17486a9c7c0d1acb0f35be99f5f45b713f06f2f Mon Sep 17 00:00:00 2001
From: Vik Paruchuri
Date: Thu, 18 Jul 2013 14:39:52 -0400
Subject: [PATCH 48/73] Update default handling
---
.../xmodule/combined_open_ended_module.py | 193 ++++++++++++++----
1 file changed, 154 insertions(+), 39 deletions(-)
diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py
index 7d3a35c084..df8c7e716c 100644
--- a/common/lib/xmodule/xmodule/combined_open_ended_module.py
+++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py
@@ -29,36 +29,124 @@ VERSION_TUPLES = {
DEFAULT_VERSION = 1
DEFAULT_DATA = textwrap.dedent("""\
-
+
+
+
Censorship in the Libraries
+
+
'All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us.' --Katherine Paterson, Author
+
+
+
+ Write a persuasive essay to a newspaper reflecting your vies on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading.
+
-
-
-
-
-
-
-
- Enter essay here.
- This is the answer.
- {"grader_settings" : "peer_grading.conf", "problem_id" : "700x/Demo"}
-
-
-
-
+
+
+
+
+
+
+
+
+ Enter essay here.
+ This is the answer.
+ {"grader_settings" : "ml_grading.conf", "problem_id" : "6.002x/Welcome/OETest"}
+
+
+
+
+
+
+
+ Enter essay here.
+ This is the answer.
+ {"grader_settings" : "peer_grading.conf", "problem_id" : "6.002x/Welcome/OETest"}
+
+
+
+
+
""")
@@ -159,17 +247,44 @@ class CombinedOpenEndedFields(object):
markdown = String(
help="Markdown source of this module",
default=textwrap.dedent("""\
- [rubric]
- + Category 1
- - The response does not incorporate what is needed for a one response.
- - The response is correct for category 1.
- [rubric]
- [prompt]
-
'All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us.' --Katherine Paterson, Author
+
+
+
+ Write a persuasive essay to a newspaper reflecting your vies on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading.
+
+ [prompt]
+ [rubric]
+ + Ideas
+ - Difficult for the reader to discern the main idea. Too brief or too repetitive to establish or maintain a focus.
+ - Attempts a main idea. Sometimes loses focus or ineffectively displays focus.
+ - Presents a unifying theme or main idea, but may include minor tangents. Stays somewhat focused on topic and task.
+ - Presents a unifying theme or main idea without going off on tangents. Stays completely focused on topic and task.
+ + Content
+ - Includes little information with few or no details or unrelated details. Unsuccessful in attempts to explore any facets of the topic.
+ - Includes little information and few or no details. Explores only one or two facets of the topic.
+ - Includes sufficient information and supporting details. (Details may not be fully developed; ideas may be listed.) Explores some facets of the topic.
+ - Includes in-depth information and exceptional supporting details that are fully developed. Explores all facets of the topic.
+ + Organization
+ - Ideas organized illogically, transitions weak, and response difficult to follow.
+ - Attempts to logically organize ideas. Attempts to progress in an order that enhances meaning, and demonstrates use of transitions.
+ - Ideas organized logically. Progresses in an order that enhances meaning. Includes smooth transitions.
+ + Style
+ - Contains limited vocabulary, with many words used incorrectly. Demonstrates problems with sentence patterns.
+ - Contains basic vocabulary, with words that are predictable and common. Contains mostly simple sentences (although there may be an attempt at more varied sentence patterns).
+ - Includes vocabulary to make explanations detailed and precise. Includes varied sentence patterns, including complex sentences.
+ + Voice
+ - Demonstrates language and tone that may be inappropriate to task and reader.
+ - Demonstrates an attempt to adjust language and tone to task and reader.
+ - Demonstrates effective adjustment of language and tone to task and reader.
+ [rubric]
+ [tasks]
+ (Self), ({4-12}AI), ({9-12}Peer)
+ [tasks]
+
"""),
scope=Scope.settings
)
From 480e97a3fdfe4250738e24c35ae02a928494ed13 Mon Sep 17 00:00:00 2001
From: Vik Paruchuri
Date: Thu, 18 Jul 2013 14:55:59 -0400
Subject: [PATCH 49/73] Cleanup and change attribute names
---
.../xmodule/combined_open_ended_module.py | 55 ++++++-------------
.../combined_open_ended_modulev1.py | 38 ++-----------
.../xmodule/xmodule/peer_grading_module.py | 39 +++++--------
3 files changed, 34 insertions(+), 98 deletions(-)
diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py
index df8c7e716c..e01ae49149 100644
--- a/common/lib/xmodule/xmodule/combined_open_ended_module.py
+++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py
@@ -13,7 +13,7 @@ import textwrap
log = logging.getLogger("mitx.courseware")
-V1_SETTINGS_ATTRIBUTES = ["display_name", "attempts", "is_graded", "accept_file_upload",
+V1_SETTINGS_ATTRIBUTES = ["display_name", "max_attempts", "graded", "accept_file_upload",
"skip_spelling_checks", "due", "graceperiod", "weight"]
V1_STUDENT_ATTRIBUTES = ["current_task_number", "task_states", "state",
@@ -172,7 +172,7 @@ class CombinedOpenEndedFields(object):
display_name = String(
display_name="Display Name",
help="This name appears in the horizontal navigation at the top of the page.",
- default="Open Ended Grading",
+ default="Open Response Assessment",
scope=Scope.settings
)
current_task_number = Integer(
@@ -189,6 +189,12 @@ class CombinedOpenEndedFields(object):
default="initial",
scope=Scope.user_state
)
+ graded = Boolean(
+ display_name="Graded",
+ help='Defines whether the student gets credit for grading this problem.',
+ default=False,
+ scope=Scope.settings
+ )
student_attempts = Integer(
help="Number of attempts taken by the student on this problem",
default=0,
@@ -199,19 +205,13 @@ class CombinedOpenEndedFields(object):
default=False,
scope=Scope.user_state
)
- attempts = Integer(
+ max_attempts = Integer(
display_name="Maximum Attempts",
help="The number of times the student can try to answer this problem.",
default=1,
scope=Scope.settings,
values={"min" : 1 }
)
- is_graded = Boolean(
- display_name="Graded",
- help="Whether or not the problem is graded.",
- default=False,
- scope=Scope.settings
- )
accept_file_upload = Boolean(
display_name="Allow File Uploads",
help="Whether or not the student can submit files as a response.",
@@ -339,37 +339,9 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
def __init__(self, *args, **kwargs):
"""
- Definition file should have one or many task blocks, a rubric block, and a prompt block:
+ Definition file should have one or many task blocks, a rubric block, and a prompt block.
- Sample file:
-
-
- Blah blah rubric.
-
-
- Some prompt.
-
-
-
-
- What hint about this problem would you give to someone?
-
-
- Save Succcesful. Thanks for participating!
-
-
-
-
-
-
- Enter essay here.
- This is the answer.
- {"grader_settings" : "ml_grading.conf",
- "problem_id" : "6.002x/Welcome/OETest"}
-
-
-
-
+ See DEFAULT_DATA for a sample.
"""
XModule.__init__(self, *args, **kwargs)
@@ -450,6 +422,11 @@ class CombinedOpenEndedDescriptor(CombinedOpenEndedFields, RawDescriptor):
js_module_name = "OpenEndedMarkdownEditingDescriptor"
css = {'scss': [resource_string(__name__, 'css/editor/edit.scss'), resource_string(__name__, 'css/combinedopenended/edit.scss')]}
+ metadata_translations = {
+ 'is_graded': 'graded',
+ 'attempts': 'max_attempts',
+ }
+
def get_context(self):
_context = RawDescriptor.get_context(self)
_context.update({'markdown': self.markdown,
diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
index a9feca24f0..fbc846ecd3 100644
--- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
+++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
@@ -78,37 +78,7 @@ class CombinedOpenEndedV1Module():
instance_state=None, shared_state=None, metadata=None, static_data=None, **kwargs):
"""
- Definition file should have one or many task blocks, a rubric block, and a prompt block:
-
- Sample file:
-
-
- Blah blah rubric.
-
-
- Some prompt.
-
-
-
-
- What hint about this problem would you give to someone?
-
-
- Save Succcesful. Thanks for participating!
-
-
-
-
-
-
- Enter essay here.
- This is the answer.
- {"grader_settings" : "ml_grading.conf",
- "problem_id" : "6.002x/Welcome/OETest"}
-
-
-
-
+ Definition file should have one or many task blocks, a rubric block, and a prompt block. See DEFAULT_DATA in combined_open_ended_module for a sample.
"""
@@ -131,8 +101,8 @@ class CombinedOpenEndedV1Module():
# Allow reset is true if student has failed the criteria to move to the next child task
self.ready_to_reset = instance_state.get('ready_to_reset', False)
- self.attempts = self.instance_state.get('attempts', MAX_ATTEMPTS)
- self.is_scored = self.instance_state.get('is_graded', IS_SCORED) in TRUE_DICT
+ self.max_attempts = self.instance_state.get('max_attempts', MAX_ATTEMPTS)
+ self.is_scored = self.instance_state.get('graded', IS_SCORED) in TRUE_DICT
self.accept_file_upload = self.instance_state.get('accept_file_upload', ACCEPT_FILE_UPLOAD) in TRUE_DICT
self.skip_basic_checks = self.instance_state.get('skip_spelling_checks', SKIP_BASIC_CHECKS) in TRUE_DICT
@@ -153,7 +123,7 @@ class CombinedOpenEndedV1Module():
# Static data is passed to the child modules to render
self.static_data = {
'max_score': self._max_score,
- 'max_attempts': self.attempts,
+ 'max_attempts': self.max_attempts,
'prompt': definition['prompt'],
'rubric': definition['rubric'],
'display_name': self.display_name,
diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py
index 0df47758dc..8f5f93318a 100644
--- a/common/lib/xmodule/xmodule/peer_grading_module.py
+++ b/common/lib/xmodule/xmodule/peer_grading_module.py
@@ -19,32 +19,27 @@ from django.utils.timezone import UTC
log = logging.getLogger(__name__)
-USE_FOR_SINGLE_LOCATION = False
-LINK_TO_LOCATION = ""
-MAX_SCORE = 1
-IS_GRADED = False
EXTERNAL_GRADER_NO_CONTACT_ERROR = "Failed to contact external graders. Please notify course staff."
-
class PeerGradingFields(object):
use_for_single_location = Boolean(
display_name="Show Single Problem",
help='When True, only the single problem specified by "Link to Problem Location" is shown. '
'When False, a panel is displayed with all problems available for peer grading.',
- default=USE_FOR_SINGLE_LOCATION,
+ default=False,
scope=Scope.settings
)
link_to_location = String(
display_name="Link to Problem Location",
help='The location of the problem being graded. Only used when "Show Single Problem" is True.',
- default=LINK_TO_LOCATION,
+ default="",
scope=Scope.settings
)
- is_graded = Boolean(
+ graded = Boolean(
display_name="Graded",
help='Defines whether the student gets credit for grading this problem. Only used when "Show Single Problem" is True.',
- default=IS_GRADED,
+ default=False,
scope=Scope.settings
)
due_date = Date(
@@ -56,12 +51,6 @@ class PeerGradingFields(object):
default=None,
scope=Scope.settings
)
- max_grade = Integer(
- help="The maximum grade that a student can receive for this problem.",
- default=MAX_SCORE,
- scope=Scope.settings,
- values={"min": 0}
- )
student_data_for_location = Dict(
help="Student data for a given peer grading problem.",
scope=Scope.user_state
@@ -136,10 +125,6 @@ class PeerGradingModule(PeerGradingFields, XModule):
if not self.ajax_url.endswith("/"):
self.ajax_url = self.ajax_url + "/"
- # Integer could return None, so keep this check.
- if not isinstance(self.max_grade, int):
- raise TypeError("max_grade needs to be an integer.")
-
def closed(self):
return self._closed(self.timeinfo)
@@ -232,7 +217,7 @@ class PeerGradingModule(PeerGradingFields, XModule):
'score': score,
'total': max_score,
}
- if not self.use_for_single_location or not self.is_graded:
+ if not self.use_for_single_location or not self.graded:
return score_dict
try:
@@ -253,7 +238,7 @@ class PeerGradingModule(PeerGradingFields, XModule):
self.student_data_for_location = response
score = int(count_graded >= count_required and count_graded > 0) * float(weight)
- total = self.max_grade * float(weight)
+ total = float(weight)
score_dict['score'] = score
score_dict['total'] = total
@@ -266,8 +251,8 @@ class PeerGradingModule(PeerGradingFields, XModule):
randomization, and 5/7 on another
'''
max_grade = None
- if self.use_for_single_location and self.is_graded:
- max_grade = self.max_grade
+ if self.use_for_single_location and self.graded:
+ max_grade = self.weight
return max_grade
def get_next_submission(self, data):
@@ -634,9 +619,13 @@ class PeerGradingDescriptor(PeerGradingFields, RawDescriptor):
#Specify whether or not to pass in open ended interface
needs_open_ended_interface = True
+ metadata_translations = {
+ 'is_graded': 'graded',
+ 'attempts': 'max_attempts',
+ }
+
@property
def non_editable_metadata_fields(self):
non_editable_fields = super(PeerGradingDescriptor, self).non_editable_metadata_fields
- non_editable_fields.extend([PeerGradingFields.due_date, PeerGradingFields.grace_period_string,
- PeerGradingFields.max_grade])
+ non_editable_fields.extend([PeerGradingFields.due_date, PeerGradingFields.grace_period_string])
return non_editable_fields
From 84c4b7f1390721704ba0269c5e20af12fa4cf91f Mon Sep 17 00:00:00 2001
From: Vik Paruchuri
Date: Thu, 18 Jul 2013 15:08:52 -0400
Subject: [PATCH 50/73] Test fixes
---
cms/djangoapps/contentstore/tests/test_contentstore.py | 2 +-
.../combined_open_ended_modulev1.py | 4 ++--
common/lib/xmodule/xmodule/tests/test_combined_open_ended.py | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py
index 500db414f4..3e1b046a98 100644
--- a/cms/djangoapps/contentstore/tests/test_contentstore.py
+++ b/cms/djangoapps/contentstore/tests/test_contentstore.py
@@ -134,7 +134,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
self.check_components_on_page(ADVANCED_COMPONENT_TYPES, ['Video Alpha',
'Word cloud',
'Annotation',
- 'Open Ended Grading',
+ 'Open Response Assessment',
'Peer Grading Interface'])
def test_advanced_components_require_two_clicks(self):
diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
index fbc846ecd3..4d406ddd83 100644
--- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
+++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
@@ -614,14 +614,14 @@ class CombinedOpenEndedV1Module():
return self.out_of_sync_error(data)
self.student_attempts +=1
- if self.student_attempts >= self.attempts:
+ if self.student_attempts >= self.max_attempts:
return {
'success': False,
# This is a student_facing_error
'error': (
'You have attempted this question {0} times. '
'You are only allowed to attempt it {1} times.'
- ).format(self.student_attempts, self.attempts)
+ ).format(self.student_attempts, self.max_attempts)
}
self.state = self.INITIAL
self.ready_to_reset = False
diff --git a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
index af1b6aa12b..0a14608edc 100644
--- a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
+++ b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
@@ -335,7 +335,7 @@ class CombinedOpenEndedModuleTest(unittest.TestCase):
's3_interface': test_util_open_ended.S3_INTERFACE,
'open_ended_grading_interface': test_util_open_ended.OPEN_ENDED_GRADING_INTERFACE,
'skip_basic_checks': False,
- 'is_graded': True,
+ 'graded': True,
}
oeparam = etree.XML('''
From 0b991f9bbd2bc6d809527a31f4c7d3bc7548397d Mon Sep 17 00:00:00 2001
From: Vik Paruchuri
Date: Thu, 18 Jul 2013 15:19:13 -0400
Subject: [PATCH 51/73] Fix reset and score tests
---
.../combined_open_ended_modulev1.py | 18 ++++++++++--------
.../xmodule/tests/test_combined_open_ended.py | 2 +-
2 files changed, 11 insertions(+), 9 deletions(-)
diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
index 4d406ddd83..933eb0b5bb 100644
--- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
+++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
@@ -101,14 +101,14 @@ class CombinedOpenEndedV1Module():
# Allow reset is true if student has failed the criteria to move to the next child task
self.ready_to_reset = instance_state.get('ready_to_reset', False)
- self.max_attempts = self.instance_state.get('max_attempts', MAX_ATTEMPTS)
- self.is_scored = self.instance_state.get('graded', IS_SCORED) in TRUE_DICT
- self.accept_file_upload = self.instance_state.get('accept_file_upload', ACCEPT_FILE_UPLOAD) in TRUE_DICT
- self.skip_basic_checks = self.instance_state.get('skip_spelling_checks', SKIP_BASIC_CHECKS) in TRUE_DICT
+ self.max_attempts = instance_state.get('max_attempts', MAX_ATTEMPTS)
+ self.is_scored = instance_state.get('graded', IS_SCORED) in TRUE_DICT
+ self.accept_file_upload = instance_state.get('accept_file_upload', ACCEPT_FILE_UPLOAD) in TRUE_DICT
+ self.skip_basic_checks = instance_state.get('skip_spelling_checks', SKIP_BASIC_CHECKS) in TRUE_DICT
- due_date = self.instance_state.get('due', None)
+ due_date = instance_state.get('due', None)
- grace_period_string = self.instance_state.get('graceperiod', None)
+ grace_period_string = instance_state.get('graceperiod', None)
try:
self.timeinfo = TimeInfo(due_date, grace_period_string)
except Exception:
@@ -613,8 +613,9 @@ class CombinedOpenEndedV1Module():
if not self.ready_to_reset:
return self.out_of_sync_error(data)
- self.student_attempts +=1
- if self.student_attempts >= self.max_attempts:
+ if self.student_attempts >= self.max_attempts-1:
+ if self.student_attempts==self.max_attempts-1:
+ self.student_attempts +=1
return {
'success': False,
# This is a student_facing_error
@@ -623,6 +624,7 @@ class CombinedOpenEndedV1Module():
'You are only allowed to attempt it {1} times.'
).format(self.student_attempts, self.max_attempts)
}
+ self.student_attempts +=1
self.state = self.INITIAL
self.ready_to_reset = False
for i in xrange(0, len(self.task_xml)):
diff --git a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
index 0a14608edc..7d369a8429 100644
--- a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
+++ b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
@@ -453,7 +453,7 @@ class CombinedOpenEndedModuleTest(unittest.TestCase):
self.assertFalse(changed)
def test_get_score_realistic(self):
- instance_state = r"""{"ready_to_reset": false, "skip_spelling_checks": true, "current_task_number": 1, "weight": 5.0, "graceperiod": "1 day 12 hours 59 minutes 59 seconds", "is_graded": "True", "task_states": ["{\"child_created\": false, \"child_attempts\": 4, \"version\": 1, \"child_history\": [{\"answer\": \"The students\\u2019 data are recorded in the table below.\\r\\n\\r\\nStarting Mass (g)\\tEnding Mass (g)\\tDifference in Mass (g)\\r\\nMarble\\t 9.8\\t 9.4\\t\\u20130.4\\r\\nLimestone\\t10.4\\t 9.1\\t\\u20131.3\\r\\nWood\\t11.2\\t11.2\\t 0.0\\r\\nPlastic\\t 7.2\\t 7.1\\t\\u20130.1\\r\\nAfter reading the group\\u2019s procedure, describe what additional information you would need in order to replicate the expe\", \"post_assessment\": \"{\\\"submission_id\\\": 3097, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: More grammar errors than average.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"the students data are recorded in the table below . starting mass g ending mass g difference in mass g marble . . . limestone . . . wood . . . plastic . . . after reading the groups procedure , describe what additional information you would need in order to replicate the expe\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3233, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}, {\"answer\": \"After 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\\r\\nStarting Mass (g)\\tEnding Mass (g)\\tDifference in Mass (g)\\r\\nMarble\\t 9.8\\t 9.4\\t\\u20130.4\\r\\nLimestone\\t10.4\\t 9.1\\t\\u20131.3\\r\\nWood\\t11.2\\t11.2\\t 0.0\\r\\nPlastic\\t 7.2\\t 7.1\\t\\u20130.1\\r\\nAfter reading the\", \"post_assessment\": \"[3]\", \"score\": 3}, {\"answer\": \"To replicate the experiment, the procedure would require more detail. One piece of information that is omitted is the amount of vinegar used in the experiment. It is also important to know what temperature the experiment was kept at during the 24 hours. Finally, the procedure needs to include details about the experiment, for example if the whole sample must be submerged.\", \"post_assessment\": \"[3]\", \"score\": 3}, {\"answer\": \"e the mass of four different samples.\\r\\nPour vinegar in each of four separate, but identical, containers.\\r\\nPlace a sample of one material into one container and label. Repeat with remaining samples, placing a single sample into a single container.\\r\\nAfter 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\", \"post_assessment\": \"[3]\", \"score\": 3}, {\"answer\": \"\", \"post_assessment\": \"[3]\", \"score\": 3}], \"max_score\": 3, \"child_state\": \"done\"}", "{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"The students\\u2019 data are recorded in the table below.\\r\\n\\r\\nStarting Mass (g)\\tEnding Mass (g)\\tDifference in Mass (g)\\r\\nMarble\\t 9.8\\t 9.4\\t\\u20130.4\\r\\nLimestone\\t10.4\\t 9.1\\t\\u20131.3\\r\\nWood\\t11.2\\t11.2\\t 0.0\\r\\nPlastic\\t 7.2\\t 7.1\\t\\u20130.1\\r\\nAfter reading the group\\u2019s procedure, describe what additional information you would need in order to replicate the expe\", \"post_assessment\": \"{\\\"submission_id\\\": 3097, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: More grammar errors than average.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"the students data are recorded in the table below . starting mass g ending mass g difference in mass g marble . . . limestone . . . wood . . . plastic . . . after reading the groups procedure , describe what additional information you would need in order to replicate the expe\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3233, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}, {\"answer\": \"After 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\\r\\nStarting Mass (g)\\tEnding Mass (g)\\tDifference in Mass (g)\\r\\nMarble\\t 9.8\\t 9.4\\t\\u20130.4\\r\\nLimestone\\t10.4\\t 9.1\\t\\u20131.3\\r\\nWood\\t11.2\\t11.2\\t 0.0\\r\\nPlastic\\t 7.2\\t 7.1\\t\\u20130.1\\r\\nAfter reading the\", \"post_assessment\": \"{\\\"submission_id\\\": 3098, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"after hours , remove the samples from the containers and rinse each sample with distilled water . allow the samples to sit and dry for minutes . determine the mass of each sample . the students data are recorded in the table below . starting mass g ending mass g difference in mass g marble . . . limestone . . . wood . . . plastic . . . after reading the\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3235, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}, {\"answer\": \"To replicate the experiment, the procedure would require more detail. One piece of information that is omitted is the amount of vinegar used in the experiment. It is also important to know what temperature the experiment was kept at during the 24 hours. Finally, the procedure needs to include details about the experiment, for example if the whole sample must be submerged.\", \"post_assessment\": \"{\\\"submission_id\\\": 3099, \\\"score\\\": 3, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"to replicate the experiment , the procedure would require more detail . one piece of information that is omitted is the amount of vinegar used in the experiment . it is also important to know what temperature the experiment was kept at during the hours . finally , the procedure needs to include details about the experiment , for example if the whole sample must be submerged .\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3237, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality3\\\"}\", \"score\": 3}, {\"answer\": \"e the mass of four different samples.\\r\\nPour vinegar in each of four separate, but identical, containers.\\r\\nPlace a sample of one material into one container and label. Repeat with remaining samples, placing a single sample into a single container.\\r\\nAfter 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\", \"post_assessment\": \"{\\\"submission_id\\\": 3100, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"e the mass of four different samples . pour vinegar in each of four separate , but identical , containers . place a sample of one material into one container and label . repeat with remaining samples , placing a single sample into a single container . after hours , remove the samples from the containers and rinse each sample with distilled water . allow the samples to sit and dry for minutes . determine the mass of each sample . the students data are recorded in the table below . \\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3239, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}, {\"answer\": \"\", \"post_assessment\": \"{\\\"submission_id\\\": 3101, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"invalid essay .\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3241, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}], \"max_score\": 3, \"child_state\": \"done\"}"], "attempts": "10000", "student_attempts": 0, "due": null, "state": "done", "accept_file_upload": false, "display_name": "Science Question -- Machine Assessed"}"""
+ instance_state = r"""{"ready_to_reset": false, "skip_spelling_checks": true, "current_task_number": 1, "weight": 5.0, "graceperiod": "1 day 12 hours 59 minutes 59 seconds", "graded": "True", "task_states": ["{\"child_created\": false, \"child_attempts\": 4, \"version\": 1, \"child_history\": [{\"answer\": \"The students\\u2019 data are recorded in the table below.\\r\\n\\r\\nStarting Mass (g)\\tEnding Mass (g)\\tDifference in Mass (g)\\r\\nMarble\\t 9.8\\t 9.4\\t\\u20130.4\\r\\nLimestone\\t10.4\\t 9.1\\t\\u20131.3\\r\\nWood\\t11.2\\t11.2\\t 0.0\\r\\nPlastic\\t 7.2\\t 7.1\\t\\u20130.1\\r\\nAfter reading the group\\u2019s procedure, describe what additional information you would need in order to replicate the expe\", \"post_assessment\": \"{\\\"submission_id\\\": 3097, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: More grammar errors than average.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"the students data are recorded in the table below . starting mass g ending mass g difference in mass g marble . . . limestone . . . wood . . . plastic . . . after reading the groups procedure , describe what additional information you would need in order to replicate the expe\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3233, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}, {\"answer\": \"After 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\\r\\nStarting Mass (g)\\tEnding Mass (g)\\tDifference in Mass (g)\\r\\nMarble\\t 9.8\\t 9.4\\t\\u20130.4\\r\\nLimestone\\t10.4\\t 9.1\\t\\u20131.3\\r\\nWood\\t11.2\\t11.2\\t 0.0\\r\\nPlastic\\t 7.2\\t 7.1\\t\\u20130.1\\r\\nAfter reading the\", \"post_assessment\": \"[3]\", \"score\": 3}, {\"answer\": \"To replicate the experiment, the procedure would require more detail. One piece of information that is omitted is the amount of vinegar used in the experiment. It is also important to know what temperature the experiment was kept at during the 24 hours. Finally, the procedure needs to include details about the experiment, for example if the whole sample must be submerged.\", \"post_assessment\": \"[3]\", \"score\": 3}, {\"answer\": \"e the mass of four different samples.\\r\\nPour vinegar in each of four separate, but identical, containers.\\r\\nPlace a sample of one material into one container and label. Repeat with remaining samples, placing a single sample into a single container.\\r\\nAfter 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\", \"post_assessment\": \"[3]\", \"score\": 3}, {\"answer\": \"\", \"post_assessment\": \"[3]\", \"score\": 3}], \"max_score\": 3, \"child_state\": \"done\"}", "{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"The students\\u2019 data are recorded in the table below.\\r\\n\\r\\nStarting Mass (g)\\tEnding Mass (g)\\tDifference in Mass (g)\\r\\nMarble\\t 9.8\\t 9.4\\t\\u20130.4\\r\\nLimestone\\t10.4\\t 9.1\\t\\u20131.3\\r\\nWood\\t11.2\\t11.2\\t 0.0\\r\\nPlastic\\t 7.2\\t 7.1\\t\\u20130.1\\r\\nAfter reading the group\\u2019s procedure, describe what additional information you would need in order to replicate the expe\", \"post_assessment\": \"{\\\"submission_id\\\": 3097, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: More grammar errors than average.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"the students data are recorded in the table below . starting mass g ending mass g difference in mass g marble . . . limestone . . . wood . . . plastic . . . after reading the groups procedure , describe what additional information you would need in order to replicate the expe\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3233, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}, {\"answer\": \"After 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\\r\\nStarting Mass (g)\\tEnding Mass (g)\\tDifference in Mass (g)\\r\\nMarble\\t 9.8\\t 9.4\\t\\u20130.4\\r\\nLimestone\\t10.4\\t 9.1\\t\\u20131.3\\r\\nWood\\t11.2\\t11.2\\t 0.0\\r\\nPlastic\\t 7.2\\t 7.1\\t\\u20130.1\\r\\nAfter reading the\", \"post_assessment\": \"{\\\"submission_id\\\": 3098, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"after hours , remove the samples from the containers and rinse each sample with distilled water . allow the samples to sit and dry for minutes . determine the mass of each sample . the students data are recorded in the table below . starting mass g ending mass g difference in mass g marble . . . limestone . . . wood . . . plastic . . . after reading the\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3235, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}, {\"answer\": \"To replicate the experiment, the procedure would require more detail. One piece of information that is omitted is the amount of vinegar used in the experiment. It is also important to know what temperature the experiment was kept at during the 24 hours. Finally, the procedure needs to include details about the experiment, for example if the whole sample must be submerged.\", \"post_assessment\": \"{\\\"submission_id\\\": 3099, \\\"score\\\": 3, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"to replicate the experiment , the procedure would require more detail . one piece of information that is omitted is the amount of vinegar used in the experiment . it is also important to know what temperature the experiment was kept at during the hours . finally , the procedure needs to include details about the experiment , for example if the whole sample must be submerged .\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3237, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality3\\\"}\", \"score\": 3}, {\"answer\": \"e the mass of four different samples.\\r\\nPour vinegar in each of four separate, but identical, containers.\\r\\nPlace a sample of one material into one container and label. Repeat with remaining samples, placing a single sample into a single container.\\r\\nAfter 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\", \"post_assessment\": \"{\\\"submission_id\\\": 3100, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"e the mass of four different samples . pour vinegar in each of four separate , but identical , containers . place a sample of one material into one container and label . repeat with remaining samples , placing a single sample into a single container . after hours , remove the samples from the containers and rinse each sample with distilled water . allow the samples to sit and dry for minutes . determine the mass of each sample . the students data are recorded in the table below . \\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3239, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}, {\"answer\": \"\", \"post_assessment\": \"{\\\"submission_id\\\": 3101, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"invalid essay .\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3241, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}], \"max_score\": 3, \"child_state\": \"done\"}"], "attempts": "10000", "student_attempts": 0, "due": null, "state": "done", "accept_file_upload": false, "display_name": "Science Question -- Machine Assessed"}"""
instance_state = json.loads(instance_state)
rubric = """
From f0c9aa3916f448bacf32848d15f1d2d421cbe638 Mon Sep 17 00:00:00 2001
From: Sarina Canelake
Date: Mon, 1 Jul 2013 12:27:11 -0400
Subject: [PATCH 52/73] Provide `set_many` methods for Lms and Mongo
KeyValueStores
Refactor new set_many and update XBlock version number.
---
.../xmodule/xmodule/modulestore/mongo/base.py | 8 ++
.../xmodule/modulestore/tests/factories.py | 3 +
lms/djangoapps/courseware/model_data.py | 58 ++++++++++++-
.../courseware/tests/test_model_data.py | 81 +++++++++++++++++--
requirements/edx/github.txt | 2 +-
5 files changed, 143 insertions(+), 9 deletions(-)
diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/base.py b/common/lib/xmodule/xmodule/modulestore/mongo/base.py
index ab63243aaf..7fc903623a 100644
--- a/common/lib/xmodule/xmodule/modulestore/mongo/base.py
+++ b/common/lib/xmodule/xmodule/modulestore/mongo/base.py
@@ -105,6 +105,14 @@ class MongoKeyValueStore(KeyValueStore):
else:
raise InvalidScopeError(key.scope)
+ def set_many(self, update_dict):
+ """set_many method. Implementations should accept an `update_dict` of
+ key-value pairs, and set all the `keys` to the given `value`s."""
+ # It appears that `set` simply updates an in-memory db, rather than calling down
+ # to a real db; need to figure out if this is the case.
+ for key, value in update_dict.iteritems():
+ self.set(key, value)
+
def delete(self, key):
if key.scope == Scope.children:
self._children = []
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py
index be705149f3..e442944805 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py
@@ -53,6 +53,9 @@ class XModuleCourseFactory(Factory):
for k, v in kwargs.iteritems():
setattr(new_course, k, v)
+ # Save the data we've just created before we update mongo datastore
+ new_course.save()
+
# Update the data in the mongo datastore
store.save_xmodule(new_course)
return new_course
diff --git a/lms/djangoapps/courseware/model_data.py b/lms/djangoapps/courseware/model_data.py
index 790f1fd721..dec4805ced 100644
--- a/lms/djangoapps/courseware/model_data.py
+++ b/lms/djangoapps/courseware/model_data.py
@@ -12,9 +12,14 @@ from .models import (
XModuleStudentPrefsField,
XModuleStudentInfoField
)
+import logging
+
+from django.db import DatabaseError
from xblock.runtime import KeyValueStore, InvalidScopeError
-from xblock.core import Scope
+from xblock.core import KeyValueMultiSaveError, Scope
+
+log = logging.getLogger(__name__)
class InvalidWriteError(Exception):
@@ -244,7 +249,7 @@ class ModelDataCache(object):
module_state_key=key.block_scope_id.url(),
defaults={'state': json.dumps({}),
'module_type': key.block_scope_id.category,
- },
+ },
)
elif key.scope == Scope.content:
field_object, _ = XModuleContentField.objects.get_or_create(
@@ -345,6 +350,55 @@ class LmsKeyValueStore(KeyValueStore):
field_object.save()
+ def set_many(self, kv_dict):
+ """
+ Provide a bulk save mechanism.
+
+ `kv_dict`: A dictionary of dirty fields that maps
+ xblock.DbModel._key : value
+
+ """
+ saved_fields = []
+ # field_objects maps a field_object to a list of associated fields
+ field_objects = dict()
+ for field in kv_dict:
+ # check field for validity
+ if field.field_name in self._descriptor_model_data:
+ raise InvalidWriteError("Not allowed to overwrite descriptor model data", field.field_name)
+
+ if field.scope not in self._allowed_scopes:
+ raise InvalidScopeError(field.scope)
+
+ # if the field is valid
+ field_object = self._model_data_cache.find_or_create(field)
+ # if this field_object isn't already in the dictionary
+ # add it
+ if field_object not in field_objects.keys():
+ field_objects[field_object] = []
+ # update the list of associated fields
+ field_objects[field_object].append(field)
+
+ # special case when scope is for the user state
+ if field.scope == Scope.user_state:
+ state = json.loads(field_object.state)
+ state[field.field_name] = kv_dict[field]
+ field_object.state = json.dumps(state)
+ else:
+ # The remaining scopes save fields on different rows, so
+ # we don't have to worry about conflicts
+ field_object.value = json.dumps(kv_dict[field])
+
+ for field_object in field_objects:
+ try:
+ # Save the field object that we made above
+ field_object.save()
+ # If save is successful on this scope, add the saved fields to
+ # the list of successful saves
+ saved_fields.extend([field.field_name for field in field_objects[field_object]])
+ except DatabaseError:
+ log.error('Error saving fields %r', field_objects[field_object])
+ raise KeyValueMultiSaveError(saved_fields)
+
def delete(self, key):
if key.field_name in self._descriptor_model_data:
raise InvalidWriteError("Not allowed to deleted descriptor model data", key.field_name)
diff --git a/lms/djangoapps/courseware/tests/test_model_data.py b/lms/djangoapps/courseware/tests/test_model_data.py
index e961f80939..07aaa66473 100644
--- a/lms/djangoapps/courseware/tests/test_model_data.py
+++ b/lms/djangoapps/courseware/tests/test_model_data.py
@@ -1,5 +1,5 @@
import json
-from mock import Mock
+from mock import Mock, patch
from functools import partial
from courseware.model_data import LmsKeyValueStore, InvalidWriteError
@@ -15,6 +15,8 @@ from courseware.tests.factories import StudentPrefsFactory, StudentInfoFactory
from xblock.core import Scope, BlockScope
from xmodule.modulestore import Location
from django.test import TestCase
+from django.db import DatabaseError
+from xblock.core import KeyValueMultiSaveError
def mock_field(scope, name):
@@ -93,7 +95,7 @@ class TestStudentModuleStorage(TestCase):
def setUp(self):
self.desc_md = {}
- student_module = StudentModuleFactory(state=json.dumps({'a_field': 'a_value'}))
+ student_module = StudentModuleFactory(state=json.dumps({'a_field': 'a_value', 'b_field': 'b_value'}))
self.user = student_module.student
self.mdc = ModelDataCache([mock_descriptor([mock_field(Scope.user_state, 'a_field')])], course_id, self.user)
self.kvs = LmsKeyValueStore(self.desc_md, self.mdc)
@@ -110,13 +112,13 @@ class TestStudentModuleStorage(TestCase):
"Test that setting an existing user_state field changes the value"
self.kvs.set(user_state_key('a_field'), 'new_value')
self.assertEquals(1, StudentModule.objects.all().count())
- self.assertEquals({'a_field': 'new_value'}, json.loads(StudentModule.objects.all()[0].state))
+ self.assertEquals({'b_field': 'b_value', 'a_field': 'new_value'}, json.loads(StudentModule.objects.all()[0].state))
def test_set_missing_field(self):
"Test that setting a new user_state field changes the value"
self.kvs.set(user_state_key('not_a_field'), 'new_value')
self.assertEquals(1, StudentModule.objects.all().count())
- self.assertEquals({'a_field': 'a_value', 'not_a_field': 'new_value'}, json.loads(StudentModule.objects.all()[0].state))
+ self.assertEquals({'b_field': 'b_value', 'a_field': 'a_value', 'not_a_field': 'new_value'}, json.loads(StudentModule.objects.all()[0].state))
def test_delete_existing_field(self):
"Test that deleting an existing field removes it from the StudentModule"
@@ -128,7 +130,7 @@ class TestStudentModuleStorage(TestCase):
"Test that deleting a missing field from an existing StudentModule raises a KeyError"
self.assertRaises(KeyError, self.kvs.delete, user_state_key('not_a_field'))
self.assertEquals(1, StudentModule.objects.all().count())
- self.assertEquals({'a_field': 'a_value'}, json.loads(StudentModule.objects.all()[0].state))
+ self.assertEquals({'b_field': 'b_value', 'a_field': 'a_value'}, json.loads(StudentModule.objects.all()[0].state))
def test_has_existing_field(self):
"Test that `has` returns True for existing fields in StudentModules"
@@ -138,6 +140,35 @@ class TestStudentModuleStorage(TestCase):
"Test that `has` returns False for missing fields in StudentModule"
self.assertFalse(self.kvs.has(user_state_key('not_a_field')))
+ def construct_kv_dict(self):
+ """ construct a kv_dict that can be passed to set_many """
+ key1 = user_state_key('field_a')
+ key2 = user_state_key('field_b')
+ new_value = 'new value'
+ newer_value = 'newer value'
+ return {key1: new_value, key2: newer_value}
+
+ def test_set_many(self):
+ """Test setting many fields that are scoped to Scope.user_state """
+ kv_dict = self.construct_kv_dict()
+ self.kvs.set_many(kv_dict)
+
+ for key in kv_dict:
+ self.assertEquals(self.kvs.get(key), kv_dict[key])
+
+ def test_set_many_failure(self):
+ """Test failures when setting many fields that are scoped to Scope.user_state """
+ kv_dict = self.construct_kv_dict()
+ # because we're patching the underlying save, we need to ensure the
+ # fields are in the cache
+ for key in kv_dict:
+ self.kvs.set(key, 'test_value')
+
+ with patch('django.db.models.Model.save', side_effect=DatabaseError):
+ with self.assertRaises(KeyValueMultiSaveError) as exception_context:
+ self.kvs.set_many(kv_dict)
+ self.assertEquals(len(exception_context.exception.saved_field_names), 0)
+
class TestMissingStudentModule(TestCase):
def setUp(self):
@@ -176,6 +207,10 @@ class TestMissingStudentModule(TestCase):
class StorageTestBase(object):
+ """
+ A base class for that gets subclassed when testing each of the scopes.
+
+ """
factory = None
scope = None
key_factory = None
@@ -188,7 +223,10 @@ class StorageTestBase(object):
else:
self.user = UserFactory.create()
self.desc_md = {}
- self.mdc = ModelDataCache([mock_descriptor([mock_field(self.scope, 'existing_field')])], course_id, self.user)
+ self.mock_descriptor = mock_descriptor([
+ mock_field(self.scope, 'existing_field'),
+ mock_field(self.scope, 'other_existing_field')])
+ self.mdc = ModelDataCache([self.mock_descriptor], course_id, self.user)
self.kvs = LmsKeyValueStore(self.desc_md, self.mdc)
def test_set_and_get_existing_field(self):
@@ -234,6 +272,37 @@ class StorageTestBase(object):
"Test that `has` return False for an existing Storage Field"
self.assertFalse(self.kvs.has(self.key_factory('missing_field')))
+ def construct_kv_dict(self):
+ key1 = self.key_factory('existing_field')
+ key2 = self.key_factory('other_existing_field')
+ new_value = 'new value'
+ newer_value = 'newer value'
+ return {key1: new_value, key2: newer_value}
+
+ def test_set_many(self):
+ """Test that setting many regular fields at the same time works"""
+ kv_dict = self.construct_kv_dict()
+
+ self.kvs.set_many(kv_dict)
+ for key in kv_dict:
+ self.assertEquals(self.kvs.get(key), kv_dict[key])
+
+ def test_set_many_failure(self):
+ """Test that setting many regular fields with a DB error """
+ kv_dict = self.construct_kv_dict()
+ for key in kv_dict:
+ self.kvs.set(key, 'test value')
+
+ with patch('django.db.models.Model.save', side_effect=[None, DatabaseError]):
+ with self.assertRaises(KeyValueMultiSaveError) as exception_context:
+ self.kvs.set_many(kv_dict)
+
+ exception = exception_context.exception
+ self.assertEquals(len(exception.saved_field_names), 1)
+ self.assertEquals(exception.saved_field_names[0], 'existing_field')
+
+
+
class TestSettingsStorage(StorageTestBase, TestCase):
factory = SettingsFactory
diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt
index f64568dc10..62a44f1307 100644
--- a/requirements/edx/github.txt
+++ b/requirements/edx/github.txt
@@ -8,6 +8,6 @@
-e git://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk
# Our libraries:
--e git+https://github.com/edx/XBlock.git@4d8735e883#egg=XBlock
+-e git+https://github.com/edx/XBlock.git@0b71db6ee6f9b216d0dd85cd76b55f2354b93dff#egg=XBlock
-e git+https://github.com/edx/codejail.git@0a1b468#egg=codejail
-e git+https://github.com/edx/diff-cover.git@v0.1.3#egg=diff_cover
From 2a4976cf8a46046fe411da7c35cb2c7ce01532ef Mon Sep 17 00:00:00 2001
From: Vik Paruchuri
Date: Thu, 18 Jul 2013 15:36:42 -0400
Subject: [PATCH 53/73] Cleanup how peer grading handles due date
---
.../xmodule/xmodule/peer_grading_module.py | 19 ++++++++++---------
1 file changed, 10 insertions(+), 9 deletions(-)
diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py
index 8f5f93318a..ff5d41dd8a 100644
--- a/common/lib/xmodule/xmodule/peer_grading_module.py
+++ b/common/lib/xmodule/xmodule/peer_grading_module.py
@@ -42,7 +42,7 @@ class PeerGradingFields(object):
default=False,
scope=Scope.settings
)
- due_date = Date(
+ due = Date(
help="Due date that should be displayed.",
default=None,
scope=Scope.settings)
@@ -100,25 +100,25 @@ class PeerGradingModule(PeerGradingFields, XModule):
if self.use_for_single_location:
try:
self.linked_problem = modulestore().get_instance(self.system.course_id, self.link_to_location)
- except:
+ except Exception:
log.error("Linked location {0} for peer grading module {1} does not exist".format(
self.link_to_location, self.location))
raise
- due_date = self.linked_problem._model_data.get('peer_grading_due', None)
+ due_date = self.linked_problem._model_data.get('due', None)
if due_date:
self._model_data['due'] = due_date
try:
- self.timeinfo = TimeInfo(self.due_date, self.grace_period_string)
- except:
- log.error("Error parsing due date information in location {0}".format(location))
+ self.timeinfo = TimeInfo(self.due, self.grace_period_string)
+ except Exception:
+ log.error("Error parsing due date information in location {0}".format(self.location))
raise
self.display_due_date = self.timeinfo.display_due_date
try:
self.student_data_for_location = json.loads(self.student_data_for_location)
- except:
+ except Exception:
pass
self.ajax_url = self.system.ajax_url
@@ -532,7 +532,7 @@ class PeerGradingModule(PeerGradingFields, XModule):
problem_location = problem['location']
descriptor = _find_corresponding_module_for_location(problem_location)
if descriptor:
- problem['due'] = descriptor._model_data.get('peer_grading_due', None)
+ problem['due'] = descriptor._model_data.get('due', None)
grace_period_string = descriptor._model_data.get('graceperiod', None)
try:
problem_timeinfo = TimeInfo(problem['due'], grace_period_string)
@@ -622,10 +622,11 @@ class PeerGradingDescriptor(PeerGradingFields, RawDescriptor):
metadata_translations = {
'is_graded': 'graded',
'attempts': 'max_attempts',
+ 'due_data' : 'due'
}
@property
def non_editable_metadata_fields(self):
non_editable_fields = super(PeerGradingDescriptor, self).non_editable_metadata_fields
- non_editable_fields.extend([PeerGradingFields.due_date, PeerGradingFields.grace_period_string])
+ non_editable_fields.extend([PeerGradingFields.due, PeerGradingFields.grace_period_string])
return non_editable_fields
From 3f9431e8cff9ba0ad0cf2308e3290334cd8624fa Mon Sep 17 00:00:00 2001
From: Diana Huang
Date: Wed, 3 Jul 2013 14:38:22 -0400
Subject: [PATCH 54/73] Provide implicit saves for XBlocks and XModules.
Update existing tests and provide new ones to test new paradigm.
---
CHANGELOG.rst | 2 +
.../contentstore/tests/test_checklists.py | 2 +
.../contentstore/tests/test_contentstore.py | 13 ++++
.../tests/test_course_settings.py | 65 ++++++++++++++++
.../contentstore/tests/test_textbooks.py | 6 ++
cms/djangoapps/contentstore/views/course.py | 17 ++++-
cms/djangoapps/contentstore/views/item.py | 3 +
cms/djangoapps/contentstore/views/preview.py | 9 ++-
cms/djangoapps/contentstore/views/tabs.py | 3 +
.../models/settings/course_details.py | 4 +
.../models/settings/course_grading.py | 45 +++++++----
.../models/settings/course_metadata.py | 7 ++
common/djangoapps/xmodule_modifiers.py | 15 ++++
.../xmodule/xmodule/modulestore/mongo/base.py | 13 +++-
.../xmodule/modulestore/tests/django_utils.py | 21 +++--
.../xmodule/modulestore/tests/factories.py | 4 -
common/lib/xmodule/xmodule/modulestore/xml.py | 4 +
.../xmodule/tests/test_combined_open_ended.py | 6 +-
.../xmodule/xmodule/tests/test_conditional.py | 7 +-
lms/djangoapps/courseware/model_data.py | 37 +++------
lms/djangoapps/courseware/module_render.py | 76 ++++++++++++-------
.../courseware/tests/test_model_data.py | 30 ++++++--
.../courseware/tests/test_module_render.py | 61 ++++++++++++++-
lms/djangoapps/courseware/views.py | 2 +
requirements/edx/github.txt | 2 +-
25 files changed, 349 insertions(+), 105 deletions(-)
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 04c8a5baae..13015d6ce6 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -9,6 +9,8 @@ Common: Added *experimental* support for jsinput type.
Common: Added setting to specify Celery Broker vhost
+Common: Utilize new XBlock bulk save API in LMS and CMS.
+
Studio: Add table for tracking course creator permissions (not yet used).
Update rake django-admin[syncdb] and rake django-admin[migrate] so they
run for both LMS and CMS.
diff --git a/cms/djangoapps/contentstore/tests/test_checklists.py b/cms/djangoapps/contentstore/tests/test_checklists.py
index 99ffb8678d..02999f6567 100644
--- a/cms/djangoapps/contentstore/tests/test_checklists.py
+++ b/cms/djangoapps/contentstore/tests/test_checklists.py
@@ -46,6 +46,8 @@ class ChecklistTestCase(CourseTestCase):
# Now delete the checklists from the course and verify they get repopulated (for courses
# created before checklists were introduced).
self.course.checklists = None
+ # Save the changed `checklists` to the underlying KeyValueStore before updating the modulestore
+ self.course.save()
modulestore = get_modulestore(self.course.location)
modulestore.update_metadata(self.course.location, own_metadata(self.course))
self.assertEqual(self.get_persisted_checklists(), None)
diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py
index 500db414f4..839175f04d 100644
--- a/cms/djangoapps/contentstore/tests/test_contentstore.py
+++ b/cms/djangoapps/contentstore/tests/test_contentstore.py
@@ -87,6 +87,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
self.user.is_active = True
# Staff has access to view all courses
self.user.is_staff = True
+
+ # Save the data that we've just changed to the db.
self.user.save()
self.client = Client()
@@ -117,6 +119,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
course.advanced_modules = component_types
+ # Save the data that we've just changed to the underlying
+ # MongoKeyValueStore before we update the mongo datastore.
+ course.save()
+
store.update_metadata(course.location, own_metadata(course))
# just pick one vertical
@@ -239,6 +245,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
self.assertNotIn('graceperiod', own_metadata(html_module))
html_module.lms.graceperiod = new_graceperiod
+ # Save the data that we've just changed to the underlying
+ # MongoKeyValueStore before we update the mongo datastore.
+ html_module.save()
self.assertIn('graceperiod', own_metadata(html_module))
self.assertEqual(html_module.lms.graceperiod, new_graceperiod)
@@ -883,6 +892,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
# add a bool piece of unknown metadata so we can verify we don't throw an exception
metadata['new_metadata'] = True
+ # Save the data that we've just changed to the underlying
+ # MongoKeyValueStore before we update the mongo datastore.
+ course.save()
module_store.update_metadata(location, metadata)
print 'Exporting to tempdir = {0}'.format(root_dir)
@@ -1299,6 +1311,7 @@ class ContentStoreTest(ModuleStoreTestCase):
# now let's define an override at the leaf node level
#
new_module.lms.graceperiod = timedelta(1)
+ new_module.save()
module_store.update_metadata(new_module.location, own_metadata(new_module))
# flush the cache and refetch
diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py
index 44eb16436d..0862eb462d 100644
--- a/cms/djangoapps/contentstore/tests/test_course_settings.py
+++ b/cms/djangoapps/contentstore/tests/test_course_settings.py
@@ -290,6 +290,71 @@ class CourseGradingTest(CourseTestCase):
altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1])
self.assertDictEqual(test_grader.graders[1], altered_grader, "drop_count[1] + 2")
+ def test_update_cutoffs_from_json(self):
+ test_grader = CourseGradingModel.fetch(self.course.location)
+ CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs)
+ # Unlike other tests, need to actually perform a db fetch for this test since update_cutoffs_from_json
+ # simply returns the cutoffs you send into it, rather than returning the db contents.
+ altered_grader = CourseGradingModel.fetch(self.course.location)
+ self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "Noop update")
+
+ test_grader.grade_cutoffs['D'] = 0.3
+ CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs)
+ altered_grader = CourseGradingModel.fetch(self.course.location)
+ self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "cutoff add D")
+
+ test_grader.grade_cutoffs['Pass'] = 0.75
+ CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs)
+ altered_grader = CourseGradingModel.fetch(self.course.location)
+ self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "cutoff change 'Pass'")
+
+ def test_delete_grace_period(self):
+ test_grader = CourseGradingModel.fetch(self.course.location)
+ CourseGradingModel.update_grace_period_from_json(test_grader.course_location, test_grader.grace_period)
+ # update_grace_period_from_json doesn't return anything, so query the db for its contents.
+ altered_grader = CourseGradingModel.fetch(self.course.location)
+ self.assertEqual(test_grader.grace_period, altered_grader.grace_period, "Noop update")
+
+ test_grader.grace_period = {'hours': 15, 'minutes': 5, 'seconds': 30}
+ CourseGradingModel.update_grace_period_from_json(test_grader.course_location, test_grader.grace_period)
+ altered_grader = CourseGradingModel.fetch(self.course.location)
+ self.assertDictEqual(test_grader.grace_period, altered_grader.grace_period, "Adding in a grace period")
+
+ test_grader.grace_period = {'hours': 1, 'minutes': 10, 'seconds': 0}
+ # Now delete the grace period
+ CourseGradingModel.delete_grace_period(test_grader.course_location)
+ # update_grace_period_from_json doesn't return anything, so query the db for its contents.
+ altered_grader = CourseGradingModel.fetch(self.course.location)
+ # Once deleted, the grace period should simply be None
+ self.assertEqual(None, altered_grader.grace_period, "Delete grace period")
+
+ def test_update_section_grader_type(self):
+ # Get the descriptor and the section_grader_type and assert they are the default values
+ descriptor = get_modulestore(self.course.location).get_item(self.course.location)
+ section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
+
+ self.assertEqual('Not Graded', section_grader_type['graderType'])
+ self.assertEqual(None, descriptor.lms.format)
+ self.assertEqual(False, descriptor.lms.graded)
+
+ # Change the default grader type to Homework, which should also mark the section as graded
+ CourseGradingModel.update_section_grader_type(self.course.location, {'graderType': 'Homework'})
+ descriptor = get_modulestore(self.course.location).get_item(self.course.location)
+ section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
+
+ self.assertEqual('Homework', section_grader_type['graderType'])
+ self.assertEqual('Homework', descriptor.lms.format)
+ self.assertEqual(True, descriptor.lms.graded)
+
+ # Change the grader type back to Not Graded, which should also unmark the section as graded
+ CourseGradingModel.update_section_grader_type(self.course.location, {'graderType': 'Not Graded'})
+ descriptor = get_modulestore(self.course.location).get_item(self.course.location)
+ section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
+
+ self.assertEqual('Not Graded', section_grader_type['graderType'])
+ self.assertEqual(None, descriptor.lms.format)
+ self.assertEqual(False, descriptor.lms.graded)
+
class CourseMetadataEditingTest(CourseTestCase):
"""
diff --git a/cms/djangoapps/contentstore/tests/test_textbooks.py b/cms/djangoapps/contentstore/tests/test_textbooks.py
index 02c64e9413..a21a1b1023 100644
--- a/cms/djangoapps/contentstore/tests/test_textbooks.py
+++ b/cms/djangoapps/contentstore/tests/test_textbooks.py
@@ -62,6 +62,9 @@ class TextbookIndexTestCase(CourseTestCase):
}
]
self.course.pdf_textbooks = content
+ # Save the data that we've just changed to the underlying
+ # MongoKeyValueStore before we update the mongo datastore.
+ self.course.save()
store = get_modulestore(self.course.location)
store.update_metadata(self.course.location, own_metadata(self.course))
@@ -220,6 +223,9 @@ class TextbookByIdTestCase(CourseTestCase):
'tid': 2,
})
self.course.pdf_textbooks = [self.textbook1, self.textbook2]
+ # Save the data that we've just changed to the underlying
+ # MongoKeyValueStore before we update the mongo datastore.
+ self.course.save()
self.store = get_modulestore(self.course.location)
self.store.update_metadata(self.course.location, own_metadata(self.course))
self.url_nonexist = reverse('textbook_by_id', kwargs={
diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py
index 0e16624c42..3791e6779a 100644
--- a/cms/djangoapps/contentstore/views/course.py
+++ b/cms/djangoapps/contentstore/views/course.py
@@ -1,10 +1,9 @@
"""
Views related to operations on course objects
"""
-#pylint: disable=W0402
import json
import random
-import string
+import string # pylint: disable=W0402
from django.contrib.auth.decorators import login_required
from django_future.csrf import ensure_csrf_cookie
@@ -496,6 +495,9 @@ def textbook_index(request, org, course, name):
if not any(tab['type'] == 'pdf_textbooks' for tab in course_module.tabs):
course_module.tabs.append({"type": "pdf_textbooks"})
course_module.pdf_textbooks = textbooks
+ # Save the data that we've just changed to the underlying
+ # MongoKeyValueStore before we update the mongo datastore.
+ course_module.save()
store.update_metadata(course_module.location, own_metadata(course_module))
return JsonResponse(course_module.pdf_textbooks)
else:
@@ -542,6 +544,9 @@ def create_textbook(request, org, course, name):
tabs = course_module.tabs
tabs.append({"type": "pdf_textbooks"})
course_module.tabs = tabs
+ # Save the data that we've just changed to the underlying
+ # MongoKeyValueStore before we update the mongo datastore.
+ course_module.save()
store.update_metadata(course_module.location, own_metadata(course_module))
resp = JsonResponse(textbook, status=201)
resp["Location"] = reverse("textbook_by_id", kwargs={
@@ -585,10 +590,13 @@ def textbook_by_id(request, org, course, name, tid):
i = course_module.pdf_textbooks.index(textbook)
new_textbooks = course_module.pdf_textbooks[0:i]
new_textbooks.append(new_textbook)
- new_textbooks.extend(course_module.pdf_textbooks[i+1:])
+ new_textbooks.extend(course_module.pdf_textbooks[i + 1:])
course_module.pdf_textbooks = new_textbooks
else:
course_module.pdf_textbooks.append(new_textbook)
+ # Save the data that we've just changed to the underlying
+ # MongoKeyValueStore before we update the mongo datastore.
+ course_module.save()
store.update_metadata(course_module.location, own_metadata(course_module))
return JsonResponse(new_textbook, status=201)
elif request.method == 'DELETE':
@@ -596,7 +604,8 @@ def textbook_by_id(request, org, course, name, tid):
return JsonResponse(status=404)
i = course_module.pdf_textbooks.index(textbook)
new_textbooks = course_module.pdf_textbooks[0:i]
- new_textbooks.extend(course_module.pdf_textbooks[i+1:])
+ new_textbooks.extend(course_module.pdf_textbooks[i + 1:])
course_module.pdf_textbooks = new_textbooks
+ course_module.save()
store.update_metadata(course_module.location, own_metadata(course_module))
return JsonResponse()
diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py
index 90dae10c23..9a5e40e967 100644
--- a/cms/djangoapps/contentstore/views/item.py
+++ b/cms/djangoapps/contentstore/views/item.py
@@ -70,6 +70,9 @@ def save_item(request):
delattr(existing_item, metadata_key)
else:
setattr(existing_item, metadata_key, value)
+ # Save the data that we've just changed to the underlying
+ # MongoKeyValueStore before we update the mongo datastore.
+ existing_item.save()
# commit to datastore
store.update_metadata(item_location, own_metadata(existing_item))
diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py
index ba393e72f4..35af3e9ac3 100644
--- a/cms/djangoapps/contentstore/views/preview.py
+++ b/cms/djangoapps/contentstore/views/preview.py
@@ -7,7 +7,7 @@ from django.core.urlresolvers import reverse
from django.contrib.auth.decorators import login_required
from mitxmako.shortcuts import render_to_response
-from xmodule_modifiers import replace_static_urls, wrap_xmodule
+from xmodule_modifiers import replace_static_urls, wrap_xmodule, save_module # pylint: disable=F0401
from xmodule.error_module import ErrorDescriptor
from xmodule.errortracker import exc_info_to_str
from xmodule.exceptions import NotFoundError, ProcessingError
@@ -47,6 +47,8 @@ def preview_dispatch(request, preview_id, location, dispatch=None):
# Let the module handle the AJAX
try:
ajax_return = instance.handle_ajax(dispatch, request.POST)
+ # Save any module data that has changed to the underlying KeyValueStore
+ instance.save()
except NotFoundError:
log.exception("Module indicating to user that request doesn't exist")
@@ -166,6 +168,11 @@ def load_preview_module(request, preview_id, descriptor):
course_namespace=Location([module.location.tag, module.location.org, module.location.course, None, None])
)
+ module.get_html = save_module(
+ module.get_html,
+ module
+ )
+
return module
diff --git a/cms/djangoapps/contentstore/views/tabs.py b/cms/djangoapps/contentstore/views/tabs.py
index 154f9fb55d..d55932e33d 100644
--- a/cms/djangoapps/contentstore/views/tabs.py
+++ b/cms/djangoapps/contentstore/views/tabs.py
@@ -76,6 +76,9 @@ def reorder_static_tabs(request):
# OK, re-assemble the static tabs in the new order
course.tabs = reordered_tabs
+ # Save the data that we've just changed to the underlying
+ # MongoKeyValueStore before we update the mongo datastore.
+ course.save()
modulestore('direct').update_metadata(course.location, own_metadata(course))
return HttpResponse()
diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py
index 8ce8c2db34..7c3b883283 100644
--- a/cms/djangoapps/models/settings/course_details.py
+++ b/cms/djangoapps/models/settings/course_details.py
@@ -122,6 +122,10 @@ class CourseDetails(object):
descriptor.enrollment_end = converted
if dirty:
+ # Save the data that we've just changed to the underlying
+ # MongoKeyValueStore before we update the mongo datastore.
+ descriptor.save()
+
get_modulestore(course_location).update_metadata(course_location, own_metadata(descriptor))
# NOTE: below auto writes to the db w/o verifying that any of the fields actually changed
diff --git a/cms/djangoapps/models/settings/course_grading.py b/cms/djangoapps/models/settings/course_grading.py
index e529a284c6..0746fc7a90 100644
--- a/cms/djangoapps/models/settings/course_grading.py
+++ b/cms/djangoapps/models/settings/course_grading.py
@@ -7,6 +7,9 @@ class CourseGradingModel(object):
"""
Basically a DAO and Model combo for CRUD operations pertaining to grading policy.
"""
+ # Within this class, allow access to protected members of client classes.
+ # This comes up when accessing kvs data and caches during kvs saves and modulestore writes.
+ # pylint: disable=W0212
def __init__(self, course_descriptor):
self.course_location = course_descriptor.location
self.graders = [CourseGradingModel.jsonize_grader(i, grader) for i, grader in enumerate(course_descriptor.raw_grader)] # weights transformed to ints [0..100]
@@ -83,13 +86,16 @@ class CourseGradingModel(object):
"""
course_location = Location(jsondict['course_location'])
descriptor = get_modulestore(course_location).get_item(course_location)
-
graders_parsed = [CourseGradingModel.parse_grader(jsonele) for jsonele in jsondict['graders']]
descriptor.raw_grader = graders_parsed
descriptor.grade_cutoffs = jsondict['grade_cutoffs']
+ # Save the data that we've just changed to the underlying
+ # MongoKeyValueStore before we update the mongo datastore.
+ descriptor.save()
get_modulestore(course_location).update_item(course_location, descriptor.xblock_kvs._data)
+
CourseGradingModel.update_grace_period_from_json(course_location, jsondict['grace_period'])
return CourseGradingModel.fetch(course_location)
@@ -116,6 +122,9 @@ class CourseGradingModel(object):
else:
descriptor.raw_grader.append(grader)
+ # Save the data that we've just changed to the underlying
+ # MongoKeyValueStore before we update the mongo datastore.
+ descriptor.save()
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
return CourseGradingModel.jsonize_grader(index, descriptor.raw_grader[index])
@@ -131,6 +140,10 @@ class CourseGradingModel(object):
descriptor = get_modulestore(course_location).get_item(course_location)
descriptor.grade_cutoffs = cutoffs
+
+ # Save the data that we've just changed to the underlying
+ # MongoKeyValueStore before we update the mongo datastore.
+ descriptor.save()
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
return cutoffs
@@ -156,6 +169,10 @@ class CourseGradingModel(object):
descriptor = get_modulestore(course_location).get_item(course_location)
descriptor.lms.graceperiod = grace_timedelta
+
+ # Save the data that we've just changed to the underlying
+ # MongoKeyValueStore before we update the mongo datastore.
+ descriptor.save()
get_modulestore(course_location).update_metadata(course_location, descriptor._model_data._kvs._metadata)
@staticmethod
@@ -172,23 +189,12 @@ class CourseGradingModel(object):
del descriptor.raw_grader[index]
# force propagation to definition
descriptor.raw_grader = descriptor.raw_grader
+
+ # Save the data that we've just changed to the underlying
+ # MongoKeyValueStore before we update the mongo datastore.
+ descriptor.save()
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
- # NOTE cannot delete cutoffs. May be useful to reset
- @staticmethod
- def delete_cutoffs(course_location, cutoffs):
- """
- Resets the cutoffs to the defaults
- """
- if not isinstance(course_location, Location):
- course_location = Location(course_location)
-
- descriptor = get_modulestore(course_location).get_item(course_location)
- descriptor.grade_cutoffs = descriptor.defaut_grading_policy['GRADE_CUTOFFS']
- get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
-
- return descriptor.grade_cutoffs
-
@staticmethod
def delete_grace_period(course_location):
"""
@@ -199,6 +205,10 @@ class CourseGradingModel(object):
descriptor = get_modulestore(course_location).get_item(course_location)
del descriptor.lms.graceperiod
+
+ # Save the data that we've just changed to the underlying
+ # MongoKeyValueStore before we update the mongo datastore.
+ descriptor.save()
get_modulestore(course_location).update_metadata(course_location, descriptor._model_data._kvs._metadata)
@staticmethod
@@ -225,6 +235,9 @@ class CourseGradingModel(object):
del descriptor.lms.format
del descriptor.lms.graded
+ # Save the data that we've just changed to the underlying
+ # MongoKeyValueStore before we update the mongo datastore.
+ descriptor.save()
get_modulestore(location).update_metadata(location, descriptor._model_data._kvs._metadata)
@staticmethod
diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py
index 5fb07fe806..8d9a292867 100644
--- a/cms/djangoapps/models/settings/course_metadata.py
+++ b/cms/djangoapps/models/settings/course_metadata.py
@@ -76,6 +76,9 @@ class CourseMetadata(object):
setattr(descriptor.lms, key, value)
if dirty:
+ # Save the data that we've just changed to the underlying
+ # MongoKeyValueStore before we update the mongo datastore.
+ descriptor.save()
get_modulestore(course_location).update_metadata(course_location,
own_metadata(descriptor))
@@ -97,6 +100,10 @@ class CourseMetadata(object):
elif hasattr(descriptor.lms, key):
delattr(descriptor.lms, key)
+ # Save the data that we've just changed to the underlying
+ # MongoKeyValueStore before we update the mongo datastore.
+ descriptor.save()
+
get_modulestore(course_location).update_metadata(course_location,
own_metadata(descriptor))
diff --git a/common/djangoapps/xmodule_modifiers.py b/common/djangoapps/xmodule_modifiers.py
index 3914892bbf..3efc04789e 100644
--- a/common/djangoapps/xmodule_modifiers.py
+++ b/common/djangoapps/xmodule_modifiers.py
@@ -89,6 +89,21 @@ def grade_histogram(module_id):
return grades
+def save_module(get_html, module):
+ """
+ Updates the given get_html function for the given module to save the fields
+ after rendering.
+ """
+ @wraps(get_html)
+ def _get_html():
+ """Cache the rendered output, save, then return the output."""
+ rendered_html = get_html()
+ module.save()
+ return rendered_html
+
+ return _get_html
+
+
def add_histogram(get_html, module, user):
"""
Updates the supplied module with a new get_html function that wraps
diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/base.py b/common/lib/xmodule/xmodule/modulestore/mongo/base.py
index 7fc903623a..ae879ba3e8 100644
--- a/common/lib/xmodule/xmodule/modulestore/mongo/base.py
+++ b/common/lib/xmodule/xmodule/modulestore/mongo/base.py
@@ -108,8 +108,9 @@ class MongoKeyValueStore(KeyValueStore):
def set_many(self, update_dict):
"""set_many method. Implementations should accept an `update_dict` of
key-value pairs, and set all the `keys` to the given `value`s."""
- # It appears that `set` simply updates an in-memory db, rather than calling down
- # to a real db; need to figure out if this is the case.
+ # `set` simply updates an in-memory db, rather than calling down to a real db,
+ # as mongo bulk save is handled elsewhere. A future improvement would be to pull
+ # the mongo-specific bulk save logic into this method.
for key, value in update_dict.iteritems():
self.set(key, value)
@@ -647,6 +648,8 @@ class MongoModuleStore(ModuleStoreBase):
:param xmodule:
"""
+ # Save any changes to the xmodule to the MongoKeyValueStore
+ xmodule.save()
# split mongo's persist_dag is more general and useful.
self.collection.save({
'_id': xmodule.location.dict(),
@@ -691,6 +694,8 @@ class MongoModuleStore(ModuleStoreBase):
'url_slug': new_object.location.name
})
course.tabs = existing_tabs
+ # Save any changes to the course to the MongoKeyValueStore
+ course.save()
self.update_metadata(course.location, course.xblock_kvs._metadata)
def fire_updated_modulestore_signal(self, course_id, location):
@@ -797,6 +802,8 @@ class MongoModuleStore(ModuleStoreBase):
tab['name'] = metadata.get('display_name')
break
course.tabs = existing_tabs
+ # Save the updates to the course to the MongoKeyValueStore
+ course.save()
self.update_metadata(course.location, own_metadata(course))
self._update_single_item(location, {'metadata': metadata})
@@ -819,6 +826,8 @@ class MongoModuleStore(ModuleStoreBase):
course = self.get_course_for_item(item.location)
existing_tabs = course.tabs or []
course.tabs = [tab for tab in existing_tabs if tab.get('url_slug') != location.name]
+ # Save the updates to the course to the MongoKeyValueStore
+ course.save()
self.update_metadata(course.location, own_metadata(course))
# Must include this to avoid the django debug toolbar (which defines the deprecated "safe=False")
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py
index 564aac141d..4f998d57fb 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py
@@ -165,34 +165,31 @@ class ModuleStoreTestCase(TestCase):
# Call superclass implementation
super(ModuleStoreTestCase, self)._post_teardown()
+
def assert2XX(self, status_code, msg=None):
"""
Assert that the given value is a success status (between 200 and 299)
"""
- if not 200 <= status_code < 300:
- msg = self._formatMessage(msg, "%s is not a success status" % safe_repr(status_code))
- raise self.failureExecption(msg)
+ msg = self._formatMessage(msg, "%s is not a success status" % safe_repr(status_code))
+ self.assertTrue(status_code >= 200 and status_code < 300, msg=msg)
def assert3XX(self, status_code, msg=None):
"""
Assert that the given value is a redirection status (between 300 and 399)
"""
- if not 300 <= status_code < 400:
- msg = self._formatMessage(msg, "%s is not a redirection status" % safe_repr(status_code))
- raise self.failureExecption(msg)
+ msg = self._formatMessage(msg, "%s is not a redirection status" % safe_repr(status_code))
+ self.assertTrue(status_code >= 300 and status_code < 400, msg=msg)
def assert4XX(self, status_code, msg=None):
"""
Assert that the given value is a client error status (between 400 and 499)
"""
- if not 400 <= status_code < 500:
- msg = self._formatMessage(msg, "%s is not a client error status" % safe_repr(status_code))
- raise self.failureExecption(msg)
+ msg = self._formatMessage(msg, "%s is not a client error status" % safe_repr(status_code))
+ self.assertTrue(status_code >= 400 and status_code < 500, msg=msg)
def assert5XX(self, status_code, msg=None):
"""
Assert that the given value is a server error status (between 500 and 599)
"""
- if not 500 <= status_code < 600:
- msg = self._formatMessage(msg, "%s is not a server error status" % safe_repr(status_code))
- raise self.failureExecption(msg)
+ msg = self._formatMessage(msg, "%s is not a server error status" % safe_repr(status_code))
+ self.assertTrue(status_code >= 500 and status_code < 600, msg=msg)
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py
index e442944805..8c3b5d59dd 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py
@@ -53,9 +53,6 @@ class XModuleCourseFactory(Factory):
for k, v in kwargs.iteritems():
setattr(new_course, k, v)
- # Save the data we've just created before we update mongo datastore
- new_course.save()
-
# Update the data in the mongo datastore
store.save_xmodule(new_course)
return new_course
@@ -138,7 +135,6 @@ class XModuleItemFactory(Factory):
# replace the display name with an optional parameter passed in from the caller
if display_name is not None:
metadata['display_name'] = display_name
- # note that location comes from above lazy_attribute
store.create_and_save_xmodule(location, metadata=metadata, definition_data=data)
if location.category not in DETACHED_CATEGORIES:
diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py
index 012740ff9a..8bc3142c77 100644
--- a/common/lib/xmodule/xmodule/modulestore/xml.py
+++ b/common/lib/xmodule/xmodule/modulestore/xml.py
@@ -194,6 +194,10 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
if hasattr(descriptor, 'children'):
for child in descriptor.get_children():
parent_tracker.add_parent(child.location, descriptor.location)
+
+ # After setting up the descriptor, save any changes that we have
+ # made to attributes on the descriptor to the underlying KeyValueStore.
+ descriptor.save()
return descriptor
render_template = lambda: ''
diff --git a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
index e1f8d135de..bed9cd86ba 100644
--- a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
+++ b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
@@ -504,11 +504,13 @@ class OpenEndedModuleXmlTest(unittest.TestCase, DummyModulestore):
See if we can load the module and save an answer
@return:
"""
- #Load the module
+ # Load the module
module = self.get_module_from_location(self.problem_location, COURSE)
- #Try saving an answer
+ # Try saving an answer
module.handle_ajax("save_answer", {"student_answer": self.answer})
+ # Save our modifications to the underlying KeyValueStore so they can be persisted
+ module.save()
task_one_json = json.loads(module.task_states[0])
self.assertEqual(task_one_json['child_history'][0]['answer'], self.answer)
diff --git a/common/lib/xmodule/xmodule/tests/test_conditional.py b/common/lib/xmodule/xmodule/tests/test_conditional.py
index abb8b4941a..b28d236369 100644
--- a/common/lib/xmodule/xmodule/tests/test_conditional.py
+++ b/common/lib/xmodule/xmodule/tests/test_conditional.py
@@ -217,8 +217,11 @@ class ConditionalModuleXmlTest(unittest.TestCase):
html = ajax['html']
self.assertFalse(any(['This is a secret' in item for item in html]))
- # now change state of the capa problem to make it completed
- inner_get_module(Location('i4x://HarvardX/ER22x/problem/choiceprob')).attempts = 1
+ # Now change state of the capa problem to make it completed
+ inner_module = inner_get_module(Location('i4x://HarvardX/ER22x/problem/choiceprob'))
+ inner_module.attempts = 1
+ # Save our modifications to the underlying KeyValueStore so they can be persisted
+ inner_module.save()
ajax = json.loads(module.handle_ajax('', ''))
print "post-attempt ajax: ", ajax
diff --git a/lms/djangoapps/courseware/model_data.py b/lms/djangoapps/courseware/model_data.py
index dec4805ced..44be16e441 100644
--- a/lms/djangoapps/courseware/model_data.py
+++ b/lms/djangoapps/courseware/model_data.py
@@ -247,9 +247,10 @@ class ModelDataCache(object):
course_id=self.course_id,
student=self.user,
module_state_key=key.block_scope_id.url(),
- defaults={'state': json.dumps({}),
- 'module_type': key.block_scope_id.category,
- },
+ defaults={
+ 'state': json.dumps({}),
+ 'module_type': key.block_scope_id.category,
+ },
)
elif key.scope == Scope.content:
field_object, _ = XModuleContentField.objects.get_or_create(
@@ -333,22 +334,10 @@ class LmsKeyValueStore(KeyValueStore):
return json.loads(field_object.value)
def set(self, key, value):
- if key.field_name in self._descriptor_model_data:
- raise InvalidWriteError("Not allowed to overwrite descriptor model data", key.field_name)
-
- field_object = self._model_data_cache.find_or_create(key)
-
- if key.scope not in self._allowed_scopes:
- raise InvalidScopeError(key.scope)
-
- if key.scope == Scope.user_state:
- state = json.loads(field_object.state)
- state[key.field_name] = value
- field_object.state = json.dumps(state)
- else:
- field_object.value = json.dumps(value)
-
- field_object.save()
+ """
+ Set a single value in the KeyValueStore
+ """
+ self.set_many({key: value})
def set_many(self, kv_dict):
"""
@@ -362,23 +351,21 @@ class LmsKeyValueStore(KeyValueStore):
# field_objects maps a field_object to a list of associated fields
field_objects = dict()
for field in kv_dict:
- # check field for validity
+ # Check field for validity
if field.field_name in self._descriptor_model_data:
raise InvalidWriteError("Not allowed to overwrite descriptor model data", field.field_name)
if field.scope not in self._allowed_scopes:
raise InvalidScopeError(field.scope)
- # if the field is valid
+ # If the field is valid and isn't already in the dictionary, add it.
field_object = self._model_data_cache.find_or_create(field)
- # if this field_object isn't already in the dictionary
- # add it
if field_object not in field_objects.keys():
field_objects[field_object] = []
- # update the list of associated fields
+ # Update the list of associated fields
field_objects[field_object].append(field)
- # special case when scope is for the user state
+ # Special case when scope is for the user state, because this scope saves fields in a single row
if field.scope == Scope.user_state:
state = json.loads(field_object.state)
state[field.field_name] = kv_dict[field]
diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py
index c343701a94..de709f7652 100644
--- a/lms/djangoapps/courseware/module_render.py
+++ b/lms/djangoapps/courseware/module_render.py
@@ -27,7 +27,7 @@ from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.x_module import ModuleSystem
-from xmodule_modifiers import replace_course_urls, replace_static_urls, add_histogram, wrap_xmodule
+from xmodule_modifiers import replace_course_urls, replace_static_urls, add_histogram, wrap_xmodule, save_module # pylint: disable=F0401
import static_replace
from psychometrics.psychoanalyze import make_psychometrics_data_update_handler
@@ -36,6 +36,8 @@ from student.models import unique_id_for_user
from courseware.access import has_access
from courseware.masquerade import setup_masquerade
from courseware.model_data import LmsKeyValueStore, LmsUsage, ModelDataCache
+from xblock.runtime import KeyValueStore
+from xblock.core import Scope
from courseware.models import StudentModule
from util.sandboxing import can_execute_unsafe_code
from util.json_request import JsonResponse
@@ -226,7 +228,7 @@ def get_module_for_descriptor_internal(user, descriptor, model_data_cache, cours
userid=str(user.id),
mod_id=descriptor.location.url(),
dispatch=dispatch),
- )
+ )
return xqueue_callback_url_prefix + relative_xqueue_callback_url
# Default queuename is course-specific and is derived from the course that
@@ -234,11 +236,12 @@ def get_module_for_descriptor_internal(user, descriptor, model_data_cache, cours
# TODO: Queuename should be derived from 'course_settings.json' of each course
xqueue_default_queuename = descriptor.location.org + '-' + descriptor.location.course
- xqueue = {'interface': xqueue_interface,
- 'construct_callback': make_xqueue_callback,
- 'default_queuename': xqueue_default_queuename.replace(' ', '_'),
- 'waittime': settings.XQUEUE_WAITTIME_BETWEEN_REQUESTS
- }
+ xqueue = {
+ 'interface': xqueue_interface,
+ 'construct_callback': make_xqueue_callback,
+ 'default_queuename': xqueue_default_queuename.replace(' ', '_'),
+ 'waittime': settings.XQUEUE_WAITTIME_BETWEEN_REQUESTS
+ }
# This is a hacky way to pass settings to the combined open ended xmodule
# It needs an S3 interface to upload images to S3
@@ -286,18 +289,24 @@ def get_module_for_descriptor_internal(user, descriptor, model_data_cache, cours
)
def publish(event):
+ """A function that allows XModules to publish events. This only supports grade changes right now."""
if event.get('event_name') != 'grade':
return
- student_module, created = StudentModule.objects.get_or_create(
- course_id=course_id,
- student=user,
- module_type=descriptor.location.category,
- module_state_key=descriptor.location.url(),
- defaults={'state': '{}'},
+ usage = LmsUsage(descriptor.location, descriptor.location)
+ # Construct the key for the module
+ key = KeyValueStore.Key(
+ scope=Scope.user_state,
+ student_id=user.id,
+ block_scope_id=usage.id,
+ field_name='grade'
)
+
+ student_module = model_data_cache.find_or_create(key)
+ # Update the grades
student_module.grade = event.get('value')
student_module.max_grade = event.get('max_value')
+ # Save all changes to the underlying KeyValueStore
student_module.save()
# Bin score into range and increment stats
@@ -388,9 +397,31 @@ def get_module_for_descriptor_internal(user, descriptor, model_data_cache, cours
if has_access(user, module, 'staff', course_id):
module.get_html = add_histogram(module.get_html, module, user)
+ # force the module to save after rendering
+ module.get_html = save_module(module.get_html, module)
return module
+def find_target_student_module(request, user_id, course_id, mod_id):
+ """
+ Retrieve target StudentModule
+ """
+ user = User.objects.get(id=user_id)
+ model_data_cache = ModelDataCache.cache_for_descriptor_descendents(
+ course_id,
+ user,
+ modulestore().get_instance(course_id, mod_id),
+ depth=0,
+ select_for_update=True
+ )
+ instance = get_module(user, request, mod_id, model_data_cache, course_id, grade_bucket_type='xqueue')
+ if instance is None:
+ msg = "No module {0} for user {1}--access denied?".format(mod_id, user)
+ log.debug(msg)
+ raise Http404
+ return instance
+
+
@csrf_exempt
def xqueue_callback(request, course_id, userid, mod_id, dispatch):
'''
@@ -409,20 +440,7 @@ def xqueue_callback(request, course_id, userid, mod_id, dispatch):
if not isinstance(header, dict) or 'lms_key' not in header:
raise Http404
- # Retrieve target StudentModule
- user = User.objects.get(id=userid)
- model_data_cache = ModelDataCache.cache_for_descriptor_descendents(
- course_id,
- user,
- modulestore().get_instance(course_id, mod_id),
- depth=0,
- select_for_update=True
- )
- instance = get_module(user, request, mod_id, model_data_cache, course_id, grade_bucket_type='xqueue')
- if instance is None:
- msg = "No module {0} for user {1}--access denied?".format(mod_id, user)
- log.debug(msg)
- raise Http404
+ instance = find_target_student_module(request, userid, course_id, mod_id)
# Transfer 'queuekey' from xqueue response header to the data.
# This is required to use the interface defined by 'handle_ajax'
@@ -433,6 +451,8 @@ def xqueue_callback(request, course_id, userid, mod_id, dispatch):
try:
# Can ignore the return value--not used for xqueue_callback
instance.handle_ajax(dispatch, data)
+ # Save any state that has changed to the underlying KeyValueStore
+ instance.save()
except:
log.exception("error processing ajax call")
raise
@@ -504,6 +524,8 @@ def modx_dispatch(request, dispatch, location, course_id):
# Let the module handle the AJAX
try:
ajax_return = instance.handle_ajax(dispatch, data)
+ # Save any fields that have changed to the underlying KeyValueStore
+ instance.save()
# If we can't find the module, respond with a 404
except NotFoundError:
diff --git a/lms/djangoapps/courseware/tests/test_model_data.py b/lms/djangoapps/courseware/tests/test_model_data.py
index 07aaa66473..0368bb040b 100644
--- a/lms/djangoapps/courseware/tests/test_model_data.py
+++ b/lms/djangoapps/courseware/tests/test_model_data.py
@@ -1,3 +1,6 @@
+"""
+Test for lms courseware app, module data (runtime data storage for XBlocks)
+"""
import json
from mock import Mock, patch
from functools import partial
@@ -68,12 +71,17 @@ class TestDescriptorFallback(TestCase):
self.assertRaises(InvalidWriteError, self.kvs.set, settings_key('field_b'), 'foo')
self.assertEquals('settings', self.desc_md['field_b'])
+ self.assertRaises(InvalidWriteError, self.kvs.set_many, {content_key('field_a'): 'foo'})
+ self.assertEquals('content', self.desc_md['field_a'])
+
self.assertRaises(InvalidWriteError, self.kvs.delete, content_key('field_a'))
self.assertEquals('content', self.desc_md['field_a'])
self.assertRaises(InvalidWriteError, self.kvs.delete, settings_key('field_b'))
self.assertEquals('settings', self.desc_md['field_b'])
+
+
class TestInvalidScopes(TestCase):
def setUp(self):
self.desc_md = {}
@@ -85,10 +93,13 @@ class TestInvalidScopes(TestCase):
for scope in (Scope(user=True, block=BlockScope.DEFINITION),
Scope(user=False, block=BlockScope.TYPE),
Scope(user=False, block=BlockScope.ALL)):
- self.assertRaises(InvalidScopeError, self.kvs.get, LmsKeyValueStore.Key(scope, None, None, 'field'))
- self.assertRaises(InvalidScopeError, self.kvs.set, LmsKeyValueStore.Key(scope, None, None, 'field'), 'value')
- self.assertRaises(InvalidScopeError, self.kvs.delete, LmsKeyValueStore.Key(scope, None, None, 'field'))
- self.assertRaises(InvalidScopeError, self.kvs.has, LmsKeyValueStore.Key(scope, None, None, 'field'))
+ key = LmsKeyValueStore.Key(scope, None, None, 'field')
+
+ self.assertRaises(InvalidScopeError, self.kvs.get, key)
+ self.assertRaises(InvalidScopeError, self.kvs.set, key, 'value')
+ self.assertRaises(InvalidScopeError, self.kvs.delete, key)
+ self.assertRaises(InvalidScopeError, self.kvs.has, key)
+ self.assertRaises(InvalidScopeError, self.kvs.set_many, {key: 'value'})
class TestStudentModuleStorage(TestCase):
@@ -141,7 +152,7 @@ class TestStudentModuleStorage(TestCase):
self.assertFalse(self.kvs.has(user_state_key('not_a_field')))
def construct_kv_dict(self):
- """ construct a kv_dict that can be passed to set_many """
+ """Construct a kv_dict that can be passed to set_many"""
key1 = user_state_key('field_a')
key2 = user_state_key('field_b')
new_value = 'new value'
@@ -149,7 +160,7 @@ class TestStudentModuleStorage(TestCase):
return {key1: new_value, key2: newer_value}
def test_set_many(self):
- """Test setting many fields that are scoped to Scope.user_state """
+ "Test setting many fields that are scoped to Scope.user_state"
kv_dict = self.construct_kv_dict()
self.kvs.set_many(kv_dict)
@@ -157,7 +168,7 @@ class TestStudentModuleStorage(TestCase):
self.assertEquals(self.kvs.get(key), kv_dict[key])
def test_set_many_failure(self):
- """Test failures when setting many fields that are scoped to Scope.user_state """
+ "Test failures when setting many fields that are scoped to Scope.user_state"
kv_dict = self.construct_kv_dict()
# because we're patching the underlying save, we need to ensure the
# fields are in the cache
@@ -211,6 +222,10 @@ class StorageTestBase(object):
A base class for that gets subclassed when testing each of the scopes.
"""
+ # Disable pylint warnings that arise because of the way the child classes call
+ # this base class -- pylint's static analysis can't keep up with it.
+ # pylint: disable=E1101, E1102
+
factory = None
scope = None
key_factory = None
@@ -273,6 +288,7 @@ class StorageTestBase(object):
self.assertFalse(self.kvs.has(self.key_factory('missing_field')))
def construct_kv_dict(self):
+ """Construct a kv_dict that can be passed to set_many"""
key1 = self.key_factory('existing_field')
key2 = self.key_factory('other_existing_field')
new_value = 'new value'
diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py
index a9060b57e9..732758be2c 100644
--- a/lms/djangoapps/courseware/tests/test_module_render.py
+++ b/lms/djangoapps/courseware/tests/test_module_render.py
@@ -1,4 +1,7 @@
-from mock import MagicMock
+"""
+Test for lms courseware app, module render unit
+"""
+from mock import MagicMock, patch
import json
from django.http import Http404, HttpResponse
@@ -28,6 +31,20 @@ class ModuleRenderTestCase(LoginEnrollmentTestCase):
self.location = ['i4x', 'edX', 'toy', 'chapter', 'Overview']
self.course_id = 'edX/toy/2012_Fall'
self.toy_course = modulestore().get_course(self.course_id)
+ self.mock_user = UserFactory()
+ self.mock_user.id = 1
+ self.request_factory = RequestFactory()
+
+ # Construct a mock module for the modulestore to return
+ self.mock_module = MagicMock()
+ self.mock_module.id = 1
+ self.dispatch = 'score_update'
+
+ # Construct a 'standard' xqueue_callback url
+ self.callback_url = reverse('xqueue_callback', kwargs=dict(course_id=self.course_id,
+ userid=str(self.mock_user.id),
+ mod_id=self.mock_module.id,
+ dispatch=self.dispatch))
def test_get_module(self):
self.assertIsNone(render.get_module('dummyuser', None,
@@ -56,7 +73,7 @@ class ModuleRenderTestCase(LoginEnrollmentTestCase):
mock_request_3 = MagicMock()
mock_request_3.POST.copy.return_value = {'position': 1}
mock_request_3.FILES = False
- mock_request_3.user = UserFactory()
+ mock_request_3.user = self.mock_user
inputfile_2 = Stub()
inputfile_2.size = 1
inputfile_2.name = 'name'
@@ -87,6 +104,46 @@ class ModuleRenderTestCase(LoginEnrollmentTestCase):
self.course_id
)
+ def test_xqueue_callback_success(self):
+ """
+ Test for happy-path xqueue_callback
+ """
+ fake_key = 'fake key'
+ xqueue_header = json.dumps({'lms_key': fake_key})
+ data = {
+ 'xqueue_header': xqueue_header,
+ 'xqueue_body': 'hello world',
+ }
+
+ # Patch getmodule to return our mock module
+ with patch('courseware.module_render.find_target_student_module') as get_fake_module:
+ get_fake_module.return_value = self.mock_module
+ # call xqueue_callback with our mocked information
+ request = self.request_factory.post(self.callback_url, data)
+ render.xqueue_callback(request, self.course_id, self.mock_user.id, self.mock_module.id, self.dispatch)
+
+ # Verify that handle ajax is called with the correct data
+ request.POST['queuekey'] = fake_key
+ self.mock_module.handle_ajax.assert_called_once_with(self.dispatch, request.POST)
+
+ def test_xqueue_callback_missing_header_info(self):
+ data = {
+ 'xqueue_header': '{}',
+ 'xqueue_body': 'hello world',
+ }
+
+ with patch('courseware.module_render.find_target_student_module') as get_fake_module:
+ get_fake_module.return_value = self.mock_module
+ # Test with missing xqueue data
+ with self.assertRaises(Http404):
+ request = self.request_factory.post(self.callback_url, {})
+ render.xqueue_callback(request, self.course_id, self.mock_user.id, self.mock_module.id, self.dispatch)
+
+ # Test with missing xqueue_header
+ with self.assertRaises(Http404):
+ request = self.request_factory.post(self.callback_url, data)
+ render.xqueue_callback(request, self.course_id, self.mock_user.id, self.mock_module.id, self.dispatch)
+
def test_get_score_bucket(self):
self.assertEquals(render.get_score_bucket(0, 10), 'incorrect')
self.assertEquals(render.get_score_bucket(1, 10), 'partial')
diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py
index 9c5a665754..e6e062c494 100644
--- a/lms/djangoapps/courseware/views.py
+++ b/lms/djangoapps/courseware/views.py
@@ -167,6 +167,8 @@ def save_child_position(seq_module, child_name):
# Only save if position changed
if position != seq_module.position:
seq_module.position = position
+ # Save this new position to the underlying KeyValueStore
+ seq_module.save()
def check_for_active_timelimit_module(request, course_id, course):
diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt
index 62a44f1307..5d1a505ace 100644
--- a/requirements/edx/github.txt
+++ b/requirements/edx/github.txt
@@ -8,6 +8,6 @@
-e git://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk
# Our libraries:
--e git+https://github.com/edx/XBlock.git@0b71db6ee6f9b216d0dd85cd76b55f2354b93dff#egg=XBlock
+-e git+https://github.com/edx/XBlock.git@3974e999fe853a37dfa6fadf0611289434349409#egg=XBlock
-e git+https://github.com/edx/codejail.git@0a1b468#egg=codejail
-e git+https://github.com/edx/diff-cover.git@v0.1.3#egg=diff_cover
From 025a95885ad345fb3775e58a7e5e360d6a92f8c0 Mon Sep 17 00:00:00 2001
From: Don Mitchell
Date: Wed, 17 Jul 2013 15:02:53 -0400
Subject: [PATCH 55/73] Generate TypeError if from or to_json fail.
---
common/lib/xmodule/xmodule/fields.py | 5 +++--
common/lib/xmodule/xmodule/tests/test_fields.py | 5 ++++-
2 files changed, 7 insertions(+), 3 deletions(-)
diff --git a/common/lib/xmodule/xmodule/fields.py b/common/lib/xmodule/xmodule/fields.py
index a9b4be4fcd..465993a51f 100644
--- a/common/lib/xmodule/xmodule/fields.py
+++ b/common/lib/xmodule/xmodule/fields.py
@@ -58,8 +58,7 @@ class Date(ModelType):
else:
msg = "Field {0} has bad value '{1}'".format(
self._name, field)
- log.warning(msg)
- return None
+ raise TypeError(msg)
def to_json(self, value):
"""
@@ -76,6 +75,8 @@ class Date(ModelType):
return value.strftime('%Y-%m-%dT%H:%M:%SZ')
else:
return value.isoformat()
+ else:
+ raise TypeError("Cannot convert {} to json".format(value))
TIMEDELTA_REGEX = re.compile(r'^((?P\d+?) day(?:s?))?(\s)?((?P\d+?) hour(?:s?))?(\s)?((?P\d+?) minute(?:s)?)?(\s)?((?P\d+?) second(?:s)?)?$')
diff --git a/common/lib/xmodule/xmodule/tests/test_fields.py b/common/lib/xmodule/xmodule/tests/test_fields.py
index f0eb082dcf..8453adaa20 100644
--- a/common/lib/xmodule/xmodule/tests/test_fields.py
+++ b/common/lib/xmodule/xmodule/tests/test_fields.py
@@ -44,7 +44,8 @@ class DateTest(unittest.TestCase):
def test_return_None(self):
self.assertIsNone(DateTest.date.from_json(""))
self.assertIsNone(DateTest.date.from_json(None))
- self.assertIsNone(DateTest.date.from_json(['unknown value']))
+ with self.assertRaises(TypeError):
+ DateTest.date.from_json(['unknown value'])
def test_old_due_date_format(self):
current = datetime.datetime.today()
@@ -83,6 +84,8 @@ class DateTest(unittest.TestCase):
DateTest.date.to_json(
DateTest.date.from_json("2012-12-31T23:00:01-01:00")),
"2012-12-31T23:00:01-01:00")
+ with self.assertRaises(TypeError):
+ DateTest.date.to_json('2012-12-31T23:00:01-01:00')
class TimedeltaTest(unittest.TestCase):
From badf86f71b7e4f65f07cdbcf43b6b4ebcf0464b2 Mon Sep 17 00:00:00 2001
From: Don Mitchell
Date: Wed, 17 Jul 2013 15:05:19 -0400
Subject: [PATCH 56/73] Have save_item parse json formatted field values by
type
including allowing javascript to serialize date/time natively.
---
cms/djangoapps/contentstore/views/item.py | 37 +++++++++++++++++++++--
cms/static/js/base.js | 8 ++---
2 files changed, 36 insertions(+), 9 deletions(-)
diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py
index 9a5e40e967..efebded9b9 100644
--- a/cms/djangoapps/contentstore/views/item.py
+++ b/cms/djangoapps/contentstore/views/item.py
@@ -59,17 +59,21 @@ def save_item(request):
# 'apply' the submitted metadata, so we don't end up deleting system metadata
existing_item = modulestore().get_item(item_location)
for metadata_key in request.POST.get('nullout', []):
- setattr(existing_item, metadata_key, None)
+ # [dhm] see comment on _get_xblock_field
+ _get_xblock_field(existing_item, metadata_key).write_to(existing_item, None)
# update existing metadata with submitted metadata (which can be partial)
# IMPORTANT NOTE: if the client passed 'null' (None) for a piece of metadata that means 'remove it'. If
# the intent is to make it None, use the nullout field
for metadata_key, value in request.POST.get('metadata', {}).items():
+ # [dhm] see comment on _get_xblock_field
+ field = _get_xblock_field(existing_item, metadata_key)
if value is None:
- delattr(existing_item, metadata_key)
+ field.delete_from(existing_item)
else:
- setattr(existing_item, metadata_key, value)
+ value = field.from_json(value)
+ field.write_to(existing_item, value)
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
existing_item.save()
@@ -79,6 +83,33 @@ def save_item(request):
return HttpResponse()
+# [DHM] A hack until we implement a permanent soln. Proposed perm solution is to make namespace fields also top level
+# fields in xblocks rather than requiring dereference through namespace but we'll need to consider whether there are
+# plausible use cases for distinct fields w/ same name in different namespaces on the same blocks.
+# The idea is that consumers of the xblock, and particularly the web client, shouldn't know about our internal
+# representation (namespaces as means of decorating all modules).
+# Given top-level access, the calls can simply be setattr(existing_item, field, value) ...
+# Really, this method should be elsewhere (e.g., xblock). We also need methods for has_value (v is_default)...
+def _get_xblock_field(xblock, field_name):
+ """
+ A temporary function to get the xblock field either from the xblock or one of its namespaces by name.
+ :param xblock:
+ :param field_name:
+ """
+ def find_field(fields):
+ for field in fields:
+ if field.name == field_name:
+ return field
+
+ found = find_field(xblock.fields)
+ if found:
+ return found
+ for namespace in xblock.namespaces:
+ found = find_field(getattr(xblock, namespace).fields)
+ if found:
+ return found
+
+
@login_required
@expect_json
def create_item(request):
diff --git a/cms/static/js/base.js b/cms/static/js/base.js
index 3d8cd7684e..329624ef46 100644
--- a/cms/static/js/base.js
+++ b/cms/static/js/base.js
@@ -253,17 +253,13 @@ function syncReleaseDate(e) {
}
function getEdxTimeFromDateTimeVals(date_val, time_val) {
- var edxTimeStr = null;
-
if (date_val != '') {
if (time_val == '') time_val = '00:00';
- // Note, we are using date.js utility which has better parsing abilities than the built in JS date parsing
- var date = Date.parse(date_val + " " + time_val);
- edxTimeStr = date.toString('yyyy-MM-ddTHH:mm');
+ return new Date(date_val + " " + time_val + "Z");
}
- return edxTimeStr;
+ else return null;
}
function getEdxTimeFromDateTimeInputs(date_id, time_id) {
From 0aa9c6c1aefe90e8fa11f857cb37cf7ff862764d Mon Sep 17 00:00:00 2001
From: Don Mitchell
Date: Wed, 17 Jul 2013 15:38:44 -0400
Subject: [PATCH 57/73] json field value conversion test
---
.../contentstore/tests/test_item.py | 37 +++++++++++++++++--
1 file changed, 34 insertions(+), 3 deletions(-)
diff --git a/cms/djangoapps/contentstore/tests/test_item.py b/cms/djangoapps/contentstore/tests/test_item.py
index cd203e6af7..578b82b3cf 100644
--- a/cms/djangoapps/contentstore/tests/test_item.py
+++ b/cms/djangoapps/contentstore/tests/test_item.py
@@ -4,6 +4,8 @@ from django.core.urlresolvers import reverse
from xmodule.capa_module import CapaDescriptor
import json
from xmodule.modulestore.django import modulestore
+import datetime
+from pytz import UTC
class DeleteItem(CourseTestCase):
@@ -151,16 +153,16 @@ class TestEditItem(CourseTestCase):
reverse('create_item'),
json.dumps(
{'parent_location': chap_location,
- 'category': 'vertical'
+ 'category': 'sequential'
}),
content_type="application/json"
)
- vert_location = self.response_id(resp)
+ self.seq_location = self.response_id(resp)
# create problem w/ boilerplate
template_id = 'multiplechoice.yaml'
resp = self.client.post(
reverse('create_item'),
- json.dumps({'parent_location': vert_location,
+ json.dumps({'parent_location': self.seq_location,
'category': 'problem',
'boilerplate': template_id
}),
@@ -210,3 +212,32 @@ class TestEditItem(CourseTestCase):
)
problem = modulestore('draft').get_item(self.problems[0])
self.assertIsNone(problem.markdown)
+
+ def test_date_fields(self):
+ """
+ Test setting due & start dates on sequential
+ """
+ sequential = modulestore().get_item(self.seq_location)
+ self.assertIsNone(sequential.lms.due)
+ self.client.post(
+ reverse('save_item'),
+ json.dumps({
+ 'id': self.seq_location,
+ 'metadata': {'due': '2010-11-22T04:00Z'}
+ }),
+ content_type="application/json"
+ )
+ sequential = modulestore().get_item(self.seq_location)
+ self.assertEqual(sequential.lms.due, datetime.datetime(2010, 11, 22, 4, 0, tzinfo=UTC))
+ self.client.post(
+ reverse('save_item'),
+ json.dumps({
+ 'id': self.seq_location,
+ 'metadata': {'start': '2010-09-12T14:00Z'}
+ }),
+ content_type="application/json"
+ )
+ sequential = modulestore().get_item(self.seq_location)
+ self.assertEqual(sequential.lms.due, datetime.datetime(2010, 11, 22, 4, 0, tzinfo=UTC))
+ self.assertEqual(sequential.lms.start, datetime.datetime(2010, 9, 12, 14, 0, tzinfo=UTC))
+
From a478fa9ff752bd5296aa1fd359442dd9c0791937 Mon Sep 17 00:00:00 2001
From: Vik Paruchuri
Date: Thu, 18 Jul 2013 16:56:38 -0400
Subject: [PATCH 58/73] Change error class
---
common/lib/xmodule/xmodule/peer_grading_module.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py
index ff5d41dd8a..09cac9a6b4 100644
--- a/common/lib/xmodule/xmodule/peer_grading_module.py
+++ b/common/lib/xmodule/xmodule/peer_grading_module.py
@@ -9,6 +9,7 @@ from .capa_module import ComplexEncoder
from .x_module import XModule
from xmodule.raw_module import RawDescriptor
from xmodule.modulestore.django import modulestore
+from xmodule.modulestore.exceptions import ItemNotFoundError
from .timeinfo import TimeInfo
from xblock.core import Dict, String, Scope, Boolean, Integer, Float
from xmodule.fields import Date
@@ -100,7 +101,7 @@ class PeerGradingModule(PeerGradingFields, XModule):
if self.use_for_single_location:
try:
self.linked_problem = modulestore().get_instance(self.system.course_id, self.link_to_location)
- except Exception:
+ except ItemNotFoundError:
log.error("Linked location {0} for peer grading module {1} does not exist".format(
self.link_to_location, self.location))
raise
From 89c48f4a3047ca6cf0985b92e004fb584dd22a30 Mon Sep 17 00:00:00 2001
From: Ned Batchelder
Date: Thu, 18 Jul 2013 08:49:05 -0400
Subject: [PATCH 59/73] Use CodeJail with FSIZE support, and default to 50k
---
lms/envs/common.py | 2 ++
requirements/edx/github.txt | 2 +-
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 8b2a1f28cf..0859dbc3f2 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -308,6 +308,8 @@ CODE_JAIL = {
'limits': {
# How many CPU seconds can jailed code use?
'CPU': 1,
+ # How large a file can jailed code write?
+ 'FSIZE': 50000,
},
}
diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt
index 5d1a505ace..64231bc6b7 100644
--- a/requirements/edx/github.txt
+++ b/requirements/edx/github.txt
@@ -9,5 +9,5 @@
# Our libraries:
-e git+https://github.com/edx/XBlock.git@3974e999fe853a37dfa6fadf0611289434349409#egg=XBlock
--e git+https://github.com/edx/codejail.git@0a1b468#egg=codejail
+-e git+https://github.com/edx/codejail.git@c08967fb44d1bcdb259d3ec58812e3ac592539c2#egg=codejail
-e git+https://github.com/edx/diff-cover.git@v0.1.3#egg=diff_cover
From bf8e5e6ba13e543972bdac9c5aa45c634e14fb74 Mon Sep 17 00:00:00 2001
From: Nate Hardison
Date: Fri, 17 May 2013 15:52:09 -0700
Subject: [PATCH 60/73] Simple port of Class2Go's chat feature
Embed a chat widget (much like the calculator widget) into the
courseware. To use, you must point it at an ejabberd box,
configured as `JABBER_DOMAIN` in the settings.
---
common/lib/xmodule/xmodule/course_module.py | 5 +-
lms/djangoapps/courseware/views.py | 10 +-
lms/static/candy_res/audioplayer.swf | Bin 0 -> 2680 bytes
lms/static/candy_res/candy_full.css | 606 +++
.../candy_res/img/action/autoscroll-off.png | Bin 0 -> 442 bytes
.../candy_res/img/action/autoscroll-on.png | Bin 0 -> 223 bytes
lms/static/candy_res/img/action/ban.png | Bin 0 -> 796 bytes
lms/static/candy_res/img/action/emoticons.png | Bin 0 -> 725 bytes
lms/static/candy_res/img/action/ignore.png | Bin 0 -> 715 bytes
lms/static/candy_res/img/action/kick.png | Bin 0 -> 859 bytes
lms/static/candy_res/img/action/menu.png | Bin 0 -> 1229 bytes
lms/static/candy_res/img/action/private.png | Bin 0 -> 557 bytes
lms/static/candy_res/img/action/settings.png | Bin 0 -> 744 bytes
lms/static/candy_res/img/action/sound-off.png | Bin 0 -> 3172 bytes
lms/static/candy_res/img/action/sound-on.png | Bin 0 -> 544 bytes
.../img/action/statusmessage-off.png | Bin 0 -> 764 bytes
.../candy_res/img/action/statusmessage-on.png | Bin 0 -> 659 bytes
lms/static/candy_res/img/action/subject.png | Bin 0 -> 413 bytes
lms/static/candy_res/img/action/unignore.png | Bin 0 -> 781 bytes
lms/static/candy_res/img/action/usercount.png | Bin 0 -> 753 bytes
lms/static/candy_res/img/context-arrows.gif | Bin 0 -> 91 bytes
lms/static/candy_res/img/emoticons/Angel.png | Bin 0 -> 3467 bytes
lms/static/candy_res/img/emoticons/Angry.png | Bin 0 -> 3449 bytes
lms/static/candy_res/img/emoticons/Aww.png | Bin 0 -> 3352 bytes
lms/static/candy_res/img/emoticons/Aww_2.png | Bin 0 -> 3402 bytes
.../candy_res/img/emoticons/Blushing.png | Bin 0 -> 3403 bytes
.../candy_res/img/emoticons/Childish.png | Bin 0 -> 3411 bytes
.../candy_res/img/emoticons/Confused.png | Bin 0 -> 3392 bytes
lms/static/candy_res/img/emoticons/Creepy.png | Bin 0 -> 3417 bytes
lms/static/candy_res/img/emoticons/Crying.png | Bin 0 -> 3434 bytes
.../candy_res/img/emoticons/Cthulhu.png | Bin 0 -> 775 bytes
lms/static/candy_res/img/emoticons/Cute.png | Bin 0 -> 3369 bytes
.../candy_res/img/emoticons/Cute_Winking.png | Bin 0 -> 3380 bytes
lms/static/candy_res/img/emoticons/Devil.png | Bin 0 -> 3478 bytes
lms/static/candy_res/img/emoticons/Gah.png | Bin 0 -> 3415 bytes
lms/static/candy_res/img/emoticons/Gah_2.png | Bin 0 -> 3401 bytes
.../candy_res/img/emoticons/Gasping.png | Bin 0 -> 3384 bytes
lms/static/candy_res/img/emoticons/Greedy.png | Bin 0 -> 3456 bytes
.../candy_res/img/emoticons/Grinning.png | Bin 0 -> 3381 bytes
.../img/emoticons/Grinning_Winking.png | Bin 0 -> 3394 bytes
lms/static/candy_res/img/emoticons/Happy.png | Bin 0 -> 3413 bytes
.../candy_res/img/emoticons/Happy_2.png | Bin 0 -> 3433 bytes
.../candy_res/img/emoticons/Happy_3.png | Bin 0 -> 3408 bytes
lms/static/candy_res/img/emoticons/Heart.png | Bin 0 -> 3209 bytes
lms/static/candy_res/img/emoticons/Huh.png | Bin 0 -> 3417 bytes
lms/static/candy_res/img/emoticons/Huh_2.png | Bin 0 -> 3429 bytes
.../candy_res/img/emoticons/Laughing.png | Bin 0 -> 3450 bytes
.../candy_res/img/emoticons/Lips_Sealed.png | Bin 0 -> 3368 bytes
.../candy_res/img/emoticons/Madness.png | Bin 0 -> 3418 bytes
.../candy_res/img/emoticons/Malicious.png | Bin 0 -> 751 bytes
lms/static/candy_res/img/emoticons/README | 2 +
lms/static/candy_res/img/emoticons/Sick.png | Bin 0 -> 3439 bytes
.../candy_res/img/emoticons/Smiling.png | Bin 0 -> 3390 bytes
.../candy_res/img/emoticons/Speechless.png | Bin 0 -> 3352 bytes
.../candy_res/img/emoticons/Spiteful.png | Bin 0 -> 3417 bytes
lms/static/candy_res/img/emoticons/Stupid.png | Bin 0 -> 3422 bytes
.../candy_res/img/emoticons/Sunglasses.png | Bin 0 -> 3433 bytes
.../candy_res/img/emoticons/Terrified.png | Bin 0 -> 3382 bytes
.../candy_res/img/emoticons/Thumb_Down.png | Bin 0 -> 572 bytes
.../candy_res/img/emoticons/Thumb_Up.png | Bin 0 -> 530 bytes
lms/static/candy_res/img/emoticons/Tired.png | Bin 0 -> 3338 bytes
.../candy_res/img/emoticons/Tongue_Out.png | Bin 0 -> 3403 bytes
.../img/emoticons/Tongue_Out_Laughing.png | Bin 0 -> 3468 bytes
.../img/emoticons/Tongue_Out_Left.png | Bin 0 -> 3387 bytes
.../candy_res/img/emoticons/Tongue_Out_Up.png | Bin 0 -> 3362 bytes
.../img/emoticons/Tongue_Out_Up_Left.png | Bin 0 -> 704 bytes
.../img/emoticons/Tongue_Out_Winking.png | Bin 0 -> 3416 bytes
.../candy_res/img/emoticons/Uncertain.png | Bin 0 -> 3390 bytes
.../candy_res/img/emoticons/Uncertain_2.png | Bin 0 -> 3378 bytes
.../candy_res/img/emoticons/Unhappy.png | Bin 0 -> 3408 bytes
.../candy_res/img/emoticons/Winking.png | Bin 0 -> 3404 bytes
lms/static/candy_res/img/favicon.png | Bin 0 -> 490 bytes
lms/static/candy_res/img/modal-bg.png | Bin 0 -> 109 bytes
lms/static/candy_res/img/modal-spinner.gif | Bin 0 -> 723 bytes
lms/static/candy_res/img/overlay.png | Bin 0 -> 109 bytes
.../img/roster/affiliation-owner.png | Bin 0 -> 670 bytes
lms/static/candy_res/img/roster/ignore.png | Bin 0 -> 715 bytes
.../candy_res/img/roster/role-moderator.png | Bin 0 -> 594 bytes
lms/static/candy_res/img/tab-transitions.png | Bin 0 -> 490 bytes
lms/static/candy_res/img/tooltip-arrows.gif | Bin 0 -> 66 bytes
lms/static/candy_res/notify.mp3 | Bin 0 -> 1095 bytes
lms/static/js/candy.min.js | 1 +
.../js/candy_libs/dateformat/dateFormat.js | 127 +
lms/static/js/candy_libs/libs.bundle.js | 4521 +++++++++++++++++
lms/static/js/candy_libs/libs.min.js | 1 +
lms/static/js/candy_ui.js | 49 +
lms/static/sass/course.scss.mako | 1 +
lms/static/sass/course/layout/_chat.scss | 57 +
lms/templates/courseware/courseware.html | 58 +
89 files changed, 5434 insertions(+), 4 deletions(-)
create mode 100644 lms/static/candy_res/audioplayer.swf
create mode 100644 lms/static/candy_res/candy_full.css
create mode 100644 lms/static/candy_res/img/action/autoscroll-off.png
create mode 100644 lms/static/candy_res/img/action/autoscroll-on.png
create mode 100644 lms/static/candy_res/img/action/ban.png
create mode 100755 lms/static/candy_res/img/action/emoticons.png
create mode 100644 lms/static/candy_res/img/action/ignore.png
create mode 100644 lms/static/candy_res/img/action/kick.png
create mode 100644 lms/static/candy_res/img/action/menu.png
create mode 100644 lms/static/candy_res/img/action/private.png
create mode 100755 lms/static/candy_res/img/action/settings.png
create mode 100644 lms/static/candy_res/img/action/sound-off.png
create mode 100644 lms/static/candy_res/img/action/sound-on.png
create mode 100644 lms/static/candy_res/img/action/statusmessage-off.png
create mode 100644 lms/static/candy_res/img/action/statusmessage-on.png
create mode 100644 lms/static/candy_res/img/action/subject.png
create mode 100644 lms/static/candy_res/img/action/unignore.png
create mode 100755 lms/static/candy_res/img/action/usercount.png
create mode 100644 lms/static/candy_res/img/context-arrows.gif
create mode 100755 lms/static/candy_res/img/emoticons/Angel.png
create mode 100755 lms/static/candy_res/img/emoticons/Angry.png
create mode 100755 lms/static/candy_res/img/emoticons/Aww.png
create mode 100755 lms/static/candy_res/img/emoticons/Aww_2.png
create mode 100755 lms/static/candy_res/img/emoticons/Blushing.png
create mode 100755 lms/static/candy_res/img/emoticons/Childish.png
create mode 100755 lms/static/candy_res/img/emoticons/Confused.png
create mode 100755 lms/static/candy_res/img/emoticons/Creepy.png
create mode 100755 lms/static/candy_res/img/emoticons/Crying.png
create mode 100755 lms/static/candy_res/img/emoticons/Cthulhu.png
create mode 100755 lms/static/candy_res/img/emoticons/Cute.png
create mode 100755 lms/static/candy_res/img/emoticons/Cute_Winking.png
create mode 100755 lms/static/candy_res/img/emoticons/Devil.png
create mode 100755 lms/static/candy_res/img/emoticons/Gah.png
create mode 100755 lms/static/candy_res/img/emoticons/Gah_2.png
create mode 100755 lms/static/candy_res/img/emoticons/Gasping.png
create mode 100755 lms/static/candy_res/img/emoticons/Greedy.png
create mode 100755 lms/static/candy_res/img/emoticons/Grinning.png
create mode 100755 lms/static/candy_res/img/emoticons/Grinning_Winking.png
create mode 100755 lms/static/candy_res/img/emoticons/Happy.png
create mode 100755 lms/static/candy_res/img/emoticons/Happy_2.png
create mode 100755 lms/static/candy_res/img/emoticons/Happy_3.png
create mode 100755 lms/static/candy_res/img/emoticons/Heart.png
create mode 100755 lms/static/candy_res/img/emoticons/Huh.png
create mode 100755 lms/static/candy_res/img/emoticons/Huh_2.png
create mode 100755 lms/static/candy_res/img/emoticons/Laughing.png
create mode 100755 lms/static/candy_res/img/emoticons/Lips_Sealed.png
create mode 100755 lms/static/candy_res/img/emoticons/Madness.png
create mode 100755 lms/static/candy_res/img/emoticons/Malicious.png
create mode 100644 lms/static/candy_res/img/emoticons/README
create mode 100755 lms/static/candy_res/img/emoticons/Sick.png
create mode 100755 lms/static/candy_res/img/emoticons/Smiling.png
create mode 100755 lms/static/candy_res/img/emoticons/Speechless.png
create mode 100755 lms/static/candy_res/img/emoticons/Spiteful.png
create mode 100755 lms/static/candy_res/img/emoticons/Stupid.png
create mode 100755 lms/static/candy_res/img/emoticons/Sunglasses.png
create mode 100755 lms/static/candy_res/img/emoticons/Terrified.png
create mode 100755 lms/static/candy_res/img/emoticons/Thumb_Down.png
create mode 100755 lms/static/candy_res/img/emoticons/Thumb_Up.png
create mode 100755 lms/static/candy_res/img/emoticons/Tired.png
create mode 100755 lms/static/candy_res/img/emoticons/Tongue_Out.png
create mode 100755 lms/static/candy_res/img/emoticons/Tongue_Out_Laughing.png
create mode 100755 lms/static/candy_res/img/emoticons/Tongue_Out_Left.png
create mode 100755 lms/static/candy_res/img/emoticons/Tongue_Out_Up.png
create mode 100755 lms/static/candy_res/img/emoticons/Tongue_Out_Up_Left.png
create mode 100755 lms/static/candy_res/img/emoticons/Tongue_Out_Winking.png
create mode 100755 lms/static/candy_res/img/emoticons/Uncertain.png
create mode 100755 lms/static/candy_res/img/emoticons/Uncertain_2.png
create mode 100755 lms/static/candy_res/img/emoticons/Unhappy.png
create mode 100755 lms/static/candy_res/img/emoticons/Winking.png
create mode 100644 lms/static/candy_res/img/favicon.png
create mode 100644 lms/static/candy_res/img/modal-bg.png
create mode 100644 lms/static/candy_res/img/modal-spinner.gif
create mode 100644 lms/static/candy_res/img/overlay.png
create mode 100644 lms/static/candy_res/img/roster/affiliation-owner.png
create mode 100644 lms/static/candy_res/img/roster/ignore.png
create mode 100644 lms/static/candy_res/img/roster/role-moderator.png
create mode 100644 lms/static/candy_res/img/tab-transitions.png
create mode 100644 lms/static/candy_res/img/tooltip-arrows.gif
create mode 100644 lms/static/candy_res/notify.mp3
create mode 100644 lms/static/js/candy.min.js
create mode 100644 lms/static/js/candy_libs/dateformat/dateFormat.js
create mode 100644 lms/static/js/candy_libs/libs.bundle.js
create mode 100644 lms/static/js/candy_libs/libs.min.js
create mode 100644 lms/static/js/candy_ui.js
create mode 100644 lms/static/sass/course/layout/_chat.scss
diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py
index d4aac5b0ae..d1a60b5683 100644
--- a/common/lib/xmodule/xmodule/course_module.py
+++ b/common/lib/xmodule/xmodule/course_module.py
@@ -192,9 +192,8 @@ class CourseFields(object):
}},
scope=Scope.content)
show_calculator = Boolean(help="Whether to show the calculator in this course", default=False, scope=Scope.settings)
- display_name = String(
- help="Display name for this module", default="Empty",
- display_name="Display Name", scope=Scope.settings)
+ display_name = String(help="Display name for this module", default="Empty", display_name="Display Name", scope=Scope.settings)
+ show_chat = Boolean(help="Whether to show the chat widget in this course", default=False, scope=Scope.settings)
tabs = List(help="List of tabs to enable in this course", scope=Scope.settings)
end_of_course_survey_url = String(help="Url for the end-of-course survey", scope=Scope.settings)
discussion_blackouts = List(help="List of pairs of start/end dates for discussion blackouts", scope=Scope.settings)
diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py
index e6e062c494..360eb143a5 100644
--- a/lms/djangoapps/courseware/views.py
+++ b/lms/djangoapps/courseware/views.py
@@ -40,7 +40,6 @@ log = logging.getLogger("mitx.courseware")
template_imports = {'urllib': urllib}
-
def user_groups(user):
"""
TODO (vshnayder): This is not used. When we have a new plan for groups, adjust appropriately.
@@ -300,6 +299,15 @@ def index(request, course_id, chapter=None, section=None,
'xqa_server': settings.MITX_FEATURES.get('USE_XQA_SERVER', 'http://xqa:server@content-qa.mitx.mit.edu/xqa')
}
+ if course.show_chat:
+ context['chat'] = {
+ 'domain': settings.JABBER_DOMAIN,
+ 'room': "{ID}_class".format(ID=course.id.replace('/', '-')), # Jabber doesn't like /s
+ 'username': "{USER}@{DOMAIN}".format(USER=user.username, DOMAIN=settings.JABBER_DOMAIN),
+ # TODO: clearly this needs to be something other than the username
+ 'password': "{USER}@{DOMAIN}".format(USER=user.username, DOMAIN=settings.JABBER_DOMAIN),
+ }
+
chapter_descriptor = course.get_child_by(lambda m: m.url_name == chapter)
if chapter_descriptor is not None:
save_child_position(course_module, chapter)
diff --git a/lms/static/candy_res/audioplayer.swf b/lms/static/candy_res/audioplayer.swf
new file mode 100644
index 0000000000000000000000000000000000000000..72390d415719d633b1598a0cc253380c85fc2966
GIT binary patch
literal 2680
zcmV-;3WxPWS5pW!8~^}#+MF0$PaErTyt~HB#SjR=+;S4g4RTJ>ko1}a0)fO_f}snE
z`cheoy}+hxuVt@MP*tfHBP;^^3j3>vVFX8BRPa7T>;BQP*NE1<
zT67GAFIGxWs(ZNZRizf4Zigqnw;K302&=EEkyn3RcPpOYr2)!b;2p8eqPkmc_>mu0
zq1XtU)$(IFIE}nU;8*+ziuC@5-rZ&tGSOgxBy^C2+wh=)Q2Ab~hfx@v);uQfo6s%$
z)g!@{L0%Fw1bgx4ei)(h+b-%;C%S;+BZAkwddaJj`n#bJvnF2}8n75cBex!rRNyz*
zK{FXhZe+67th+>2mM_Iazut%#9qiHmmD&@Mfq&K9ChGM952+kh7J>h>o!^B)vqE}`
zx0Ss;l&2onUI*?`16l^yFg-HztpSxD_K}(<(AibyCgwaCwS8&u|3Iq;tdwn7#!kzk
zw*vm#rj^=h^|Nm)!#vGS6OSIJ)eCkeW5gia5euyXva);et*<~!iHdVXx_*rgHyB96
zPAkp6v$(S%xeXRtDB=bQKO$rsQn*cx@(OVs-e8myfMSuHYP%69_l*IgortSW?sph0
zBn;T21^h_mxMbJPSe!t59xD2}FKe18Sw+H3}je%+7l%1
zBqOtOtzodIEW@6*O#8ZJWlW@4T@okr1>fJ8haun)zQv7#q`0ZsF!oAuPBY4eGX*Bb
zzma*iLky-sN>^ak=~U{LBaY;Dft~N5ejIn4oY>}W7kTd~dG9ESKMIPu0@-$hab%Jb
zSK(@7+|}ln+3Ivk82WyJZEon|eTm|c%5&+||En^szm4k!gRhjR3(&IpdBz6Vx$uYt
z50E@=DIRNW`VI2cpLX)?Z2Od7mW|H{6yR&1lNMNheZI}@rV}RfeU#Kw(~^T-yoS9QacxTN4A@H;PXMA869GyKXjPd
zfB8CfK_KQef66L&U1Xf#4CynuZlP#HrAei
zUw)z$26WQdG3K^e)=Gt!ERtMkD>n2hwJnYs=!SY@KdaafN^
z&8Pt@^*UygqF?o+E$>vzJUa*Py;6knkKng8U`Wggt6^D-e79VF<2NF&$|fxQ*b{6M
zqm3BEhE8`}Mhr&bV!uel&CvJfM&wlglB=pO3
zkLI6Y-tdA$ASNeF5c&3&RzuN?uST%H*^qQ`0*cd1#r3OD@uK6f+}19=Z){ZArAdQ_
zuvd>xWe(BO2fx`kh%Z0HjmLp@J0epI!FDgZ1iA21BW{93&lcZ=u{5y2&mcIb%`uJ0a4f0Zy<*(UbW{v%2L8?_N6`$JA~oyfgS7#oCuK}>q9f^1Q1;B^HA0&RCqdpk
z^%SV5>!WA?pD=O6v;zfwQqC%<>~^xO-LMB`0^-6CCF}kM-&M&f
zJ3;zRs+$!@z^4d4-2uN&@Go?DJO3LFBe{7Q$qbRqUPf|@NN%?YAhCByj5|73o$p=3
z-MftWoR}imEbx|@mM$UCF&(L{2tl^=LyJ58!p{rx<;YP-6ur$LjwPZoZiu@fRQs9z
ze1;vc!2%H13=_21GUBl8=vtq2@J9pYUrONjb$oj>`&Q1UJ@%JkKF!h<#tytN*OB7mEEbaO{gu7d;`Yi$0it8S
mp?9&=_)#%sNhT+XtE?sBj>pMi+5z|<00030{{sNu
+ * @author Patrick
+ * @copyright 2011 Amiado Group AG, All rights reserved.
+ */
+html, body {
+ margin: 0;
+ padding: 0;
+ font-family: Arial, Helvetica, sans-serif;
+}
+
+#candy {
+ margin: 0;
+ padding: 0;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: 0;
+ width: 100%;
+ background-color: #bdb7a1;
+ color: #333;
+ overflow: hidden;
+ border: 1px solid #bdb7a1;
+ border-top-right-radius: 10px;
+ box-sizing: border-box;
+}
+#candy.poppedOut {
+ border-top-left-radius: 0 !important;
+}
+
+#chatPopin {
+ position: absolute;
+ bottom: 5px;
+ right: 5px;
+ font-size: 24px;
+ height: 18px !important;
+ width: 22px;
+ color: #666;
+}
+#chatPopin:hover {
+ color: #333;
+ text-decoration: none;
+}
+
+a {
+ color: #333;
+ text-decoration: none;
+}
+ul {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+#chat-tabs {
+ margin: 0 0 0 28px;
+ padding: 0;
+ overflow: auto;
+ border-left: 1px solid #bdb7a1;
+}
+
+#chat-pane.collapsed-message-pane #chat-tabs {
+ height: 100%;
+ width: 29px;
+ border-left: 0;
+ margin: 30px 0 0 0;
+}
+
+#chat-tabs #chat-expand-arrow {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 27px;
+ height: 15px;
+ padding: 5px 0;
+ cursor: pointer;
+}
+#chat-tabs #chat-expand-arrow em {
+ font-size: 18px;
+ padding: 0 10px;
+}
+
+#chat-tabs li {
+ margin: 0;
+ float: left;
+ position: relative;
+ white-space: nowrap;
+}
+
+#chat-tabs li a {
+ background-color: #e9e6df;
+ padding: 6px 20px 4px 1px;
+ display: inline-block;
+ color: #999;
+ height: 19px;
+ font-size: 22px;
+ -webkit-font-smoothing: antialiased;
+}
+
+#chat-tabs li a.label {
+ border-right: 1px solid #bdb7a1;
+ border-bottom: 1px solid #bdb7a1;
+}
+
+#chat-pane.collapsed-message-pane #chat-tabs li a.label {
+ border-bottom: 0;
+ margin-bottom: 1px;
+}
+
+#chat-tabs li.active a {
+ color: #333;
+ font-weight: bold;
+}
+
+#chat-tabs li.active a.label {
+ border-bottom: 1px solid white;
+ background-color: white;
+}
+
+#chat-tabs li a.transition {
+ display: none; /* JRBL */
+}
+
+#chat-tabs li a.close {
+ background-color: transparent;
+ position: absolute;
+ top: 2px;
+ right: 7px;
+ height: auto;
+ padding: 0;
+ margin: 0;
+ color: #999;
+}
+#chat-tabs li a.close:hover, #chat-tabs li.active a.close:hover {
+ color: #333;
+}
+
+#chat-tabs li .unread {
+ color: white;
+ background-color: #8C1515;
+ padding: 1px 2px;
+ font-weight: normal;
+ font-size: 10px;
+ position: absolute;
+ top: 12px;
+ right: 30px;
+ border-radius: 5px;
+}
+
+#chat-tabs li.offline a.label {
+ text-decoration: line-through;
+}
+
+#chat-toolbar {
+ /*
+ position: fixed;
+ bottom: 0;
+ right: 0;
+ font-size: 11px;
+ color: #666;
+ width: 200px;
+ height: 24px;
+ padding-top: 7px;
+ border-top: 1px solid #e9e6df;
+ background-color: #d3cec0;
+ */
+ display: none !important;
+}
+#chat-toolbar li {
+ width: 16px;
+ height: 16px;
+ margin-left: 5px;
+ float: left;
+ display: inline-block;
+ cursor: pointer;
+ background-position: top left;
+ background-repeat: no-repeat;
+}
+#chat-toolbar #emoticons-icon {
+ background-image: url(img/action/emoticons.png);
+}
+#chat-toolbar .context {
+ background-image: url(img/action/settings.png);
+ display: none;
+}
+.role-moderator #chat-toolbar .context, .affiliation-owner #chat-toolbar .context {
+ display: inline-block;
+}
+#chat-sound-control {
+ background-image: url(img/action/sound-off.png);
+}
+#chat-sound-control.checked {
+ background-image: url(img/action/sound-on.png);
+}
+#chat-autoscroll-control {
+ background-image: url(img/action/autoscroll-off.png);
+}
+#chat-autoscroll-control.checked {
+ background-image: url(img/action/autoscroll-on.png);
+}
+#chat-statusmessage-control {
+ background: url(img/action/statusmessage-off.png);
+}
+#chat-statusmessage-control.checked {
+ background: url(img/action/statusmessage-on.png);
+}
+#chat-toolbar .usercount {
+ background-image: url(img/action/usercount.png);
+ cursor: default;
+ padding-left: 20px;
+ width: auto;
+ margin-right: 5px;
+ float: right;
+}
+.usercount span {
+ display: inline-block;
+ padding: 1px 3px;
+ background-color: #ccc;
+ font-weight: bold;
+ border-radius: 3px;
+}
+
+.room-pane {
+ /* display: none; */
+}
+.roster-pane {
+ position: absolute;
+ overflow: auto;
+ top: 30px;
+ right: 0;
+ bottom: 30px;
+ width: 198px;
+ margin: 0;
+}
+.roster-pane .user {
+ cursor: pointer;
+ width: 90%;
+ padding: 6px 5%;
+ font-size: 14px;
+ margin: 0;
+ /* display: none; */
+ color: #666;
+ float: left;
+ clear: both;
+ height: 17px;
+ background-color: #d3cec0;
+ border-bottom: 1px solid #bdb7a1;
+}
+.roster-pane .user.me {
+ font-weight: bold;
+ cursor: default;
+}
+.roster-pane .user:hover,
+.roster-pane .user.me:hover {
+ background-color: #e9e6df;
+}
+.roster-pane .user.status-ignored {
+ cursor: default;
+ color: #999;
+ opacity: .5 !important;
+}
+.roster-pane .label {
+ float: left;
+ width: 125px;
+ overflow: hidden;
+ white-space: nowrap;
+}
+.roster-pane li {
+ width: 16px;
+ height: 16px;
+ float: right;
+ display: block;
+ margin-left: 3px;
+ background-repeat: no-repeat;
+ background-position: center;
+}
+.roster-pane li.role {
+ cursor: default;
+ display: none;
+}
+.roster-pane li.role-moderator {
+ background-image: url(img/roster/role-moderator.png);
+ display: block;
+}
+.roster-pane li.affiliation-owner {
+ background-image: url(img/roster/affiliation-owner.png);
+ display: block;
+}
+.roster-pane li.ignore {
+ background-image: url(img/roster/ignore.png);
+ display: none;
+}
+.roster-pane .status-ignored li.ignore {
+ display: block;
+}
+.roster-pane .me li.context {
+ display: none;
+}
+.roster-pane li.context {
+ background-image: url(img/action/menu.png);
+ cursor: pointer;
+}
+.roster-pane li.context:hover {
+ background-color: #ccc;
+ border-radius: 4px;
+}
+
+.message-pane-wrapper {
+ clear: both;
+ overflow: auto;
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ height: auto;
+ width: auto;
+ margin: 30px 199px 32px 0;
+ background-color: white;
+ font-size: 13px;
+}
+.message-pane {
+ margin: 0;
+ padding: 5px 10px 2px 10px;
+}
+.message-pane dt {
+ width: 55px;
+ float: left;
+ color: #888;
+ font-size: 10px;
+ text-align: right;
+ padding-top: 4px;
+}
+
+.message-pane dd {
+ overflow: auto;
+ padding: 2px 0 1px 100px;
+ margin: 0 0 2px 0;
+ white-space: -o-pre-wrap; /* Opera */
+ word-wrap: break-word; /* Internet Explorer 5.5+ */
+}
+
+.message-pane dd .label {
+ font-weight: bold;
+ white-space: nowrap;
+ display: block;
+ margin-left: -90px;
+ width: 90px;
+ float: left;
+ overflow: hidden;
+}
+
+.message-pane .subject {
+ color: #a00;
+ font-weight: bold;
+}
+
+.message-pane .adminmessage {
+ color: #a00;
+ font-weight: bold;
+}
+
+.message-pane .infomessage {
+ color: #888;
+ font-style: italic;
+ padding-left: 5px;
+}
+
+.message-pane .emoticon {
+ vertical-align: text-bottom;
+ height: 15px;
+ width: 15px;
+}
+
+.message-form-wrapper {
+ position: absolute;
+ bottom: 1px;
+ left: 0;
+ right: 0;
+ width: auto;
+ margin-right: 199px;
+ background-color: #e9e6df;
+ height: 30px;
+}
+
+.message-form {
+ position: absolute;
+ bottom: 1px;
+ left: 0;
+ right: 0;
+ margin: 0 199px 0 0;
+ padding: 0;
+ height: 30px;
+}
+
+.message-form input {
+ border: 0 none;
+ font-size: 14px;
+ width: 100%;
+ height: 100%;
+ display: block;
+ outline-width: 0;
+ padding: 0 50px 0 5px;
+ background-color: transparent;
+}
+.message-form input:focus {
+ background-color: white;
+}
+
+.message-form input.submit {
+ cursor: pointer;
+ background-color: #333;
+ color: #e9e6df;
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ margin: 3px;
+ padding: 5px;
+ width: 40px;
+ font-size: 12px;
+ line-height: 12px;
+ height: 24px;
+ font-weight: bold;
+}
+
+#tooltip {
+ position: absolute;
+ z-index: 10;
+ display: none;
+ margin: 18px -18px 2px -2px;
+ color: white;
+ font-size: 11px;
+ padding: 5px 0;
+ background: url(img/tooltip-arrows.gif) no-repeat left bottom;
+}
+
+#tooltip div {
+ background-color: black;
+ padding: 2px 5px;
+ zoom: 1;
+}
+
+#context-menu {
+ position: absolute;
+ z-index: 10;
+ display: none;
+ padding: 15px 10px;
+ margin: 8px -28px -8px -12px;
+ background: url(img/context-arrows.gif) no-repeat left bottom;
+}
+
+#context-menu ul {
+ background-color: black;
+ color: white;
+ font-size: 12px;
+ padding: 2px;
+ zoom: 1;
+}
+
+#context-menu li {
+ padding: 3px 5px 3px 20px;
+ line-height: 12px;
+ cursor: pointer;
+ margin-bottom: 2px;
+ background: 1px no-repeat;
+ white-space: nowrap;
+}
+
+#context-menu li:hover {
+ background-color: #666;
+}
+
+#context-menu li:last-child {
+ margin-bottom: 0;
+}
+
+#context-menu .private {
+ background-image: url(img/action/private.png);
+}
+
+#context-menu .ignore {
+ background-image: url(img/action/ignore.png);
+}
+
+#context-menu .unignore {
+ background-image: url(img/action/unignore.png);
+}
+
+#context-menu .kick {
+ background-image: url(img/action/kick.png);
+}
+
+#context-menu .ban {
+ background-image: url(img/action/ban.png);
+}
+
+#context-menu .subject {
+ background-image: url(img/action/subject.png);
+}
+
+#context-menu .emoticons {
+ padding-left: 5px;
+ width: 85px;
+ white-space: normal;
+}
+
+#context-menu .emoticons:hover {
+ background-color: transparent;
+}
+
+#context-menu .emoticons img {
+ cursor: pointer;
+ margin: 3px;
+ height: 15px;
+ width: 15px;
+}
+
+#chat-modal {
+ background: url(img/modal-bg.png);
+ width: 300px;
+ padding: 20px 5px;
+ color: white;
+ font-size: 16px;
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ margin-left: -155px;
+ margin-top: -45px;
+ text-align: center;
+ display: none;
+ z-index: 100;
+ border-radius: 5px;
+}
+
+#chat-modal-overlay {
+ position: absolute;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 90;
+ background-image: url(img/overlay.png);
+ border-top-left-radius: 8px;
+}
+
+#chat-modal.modal-login {
+ display: block;
+ margin-top: -100px;
+}
+
+#chat-modal-spinner {
+ display: none;
+ margin-left: 15px;
+}
+
+#chat-modal form {
+ margin: 15px 0;
+}
+
+#chat-modal label, #chat-modal input, #chat-modal select {
+ display: block;
+ float: left;
+ line-height: 26px;
+ font-size: 16px;
+ margin: 5px 0;
+}
+
+#chat-modal input, #chat-modal select {
+ padding: 2px;
+ line-height: 16px;
+ width: 150px;
+}
+
+#chat-modal label {
+ text-align: right;
+ padding-right: 1em;
+ clear: both;
+ width: 100px;
+}
+
+#chat-modal input.button {
+ float: none;
+ display: block;
+ margin: 5px auto;
+ clear: both;
+ position: relative;
+ top: 10px;
+ /* width: 200px; */
+ width: 120px; /* JRBL */
+}
+
+#chat-modal .close {
+ position: absolute;
+ right: 0;
+ display: none;
+ padding: 0 5px;
+ margin: -17px 3px 0 0;
+ color: white;
+ border-radius: 3px;
+}
+
+#chat-modal .close:hover {
+ background-color: #333;
+}
diff --git a/lms/static/candy_res/img/action/autoscroll-off.png b/lms/static/candy_res/img/action/autoscroll-off.png
new file mode 100644
index 0000000000000000000000000000000000000000..a0b8aa69405f27c8f0eea086ab7ddf3c5f874c39
GIT binary patch
literal 442
zcmV;r0Y(0aP)4~
za^wg@WMm{GZUY#ha*rN?48btq_pe_dH3C592L>cpGvRYyYilcO2h;qQFEI!`1-e8~kl~G!3&VRR
zW~^St8V0;KZ!icQJq%{Qv$13Ntfo$UI#B-dnStlr83qPEK8Ej(&J28J<_vs5@rMr|
z5@moU&(_C4cmc#ue*rbWwYGt4!lxNqI$(MH@Zs;rPo7{XymRLcLtK13B(Y#k2N1zON0u+g
k>0bscIhT>r>}92|mcnpvV#YSn9~mIY+n8wrrC>EMaFd{myGX
z)}!i9!MBo*OSpahv%R51B}wz>5eZ%H4^4lv_Lfg-ynXSsmdKI;Vst0Jz>!WdHyG
literal 0
HcmV?d00001
diff --git a/lms/static/candy_res/img/action/ban.png b/lms/static/candy_res/img/action/ban.png
new file mode 100644
index 0000000000000000000000000000000000000000..b335cb11c4d1a397b307883adcfe1e00c4cf8e6a
GIT binary patch
literal 796
zcmV+%1LOROP)h5&w{Y-QlBkdy7eSyz8|k(w=syt3MbOGZFmTy2f|dnI3kj6Sz)H!`
z%1hM3FiovS8#Qs98Rxs5^PSs#9S4da6*};44(Iv3@B5rb3BwQ$Is?0$0ilY#Q^EELL=6SfS*Ly93GR<|lPYvfPXoM^)HN1)!-B@N5Zi(e|Ge`rl{XLurIiz-D}wHL_^0`U`~aor)ql1d5Pg
z6owrVjF7Of7j1n54cD~V+Wq~W=b7nXZIaQU<}$-D^Wh8g9iyuJPn7-MaF0z&l@1k7
zi;z(T6-GoDP~qCfbDCc}Z`LYsk4>`*H%xtJgQ?D-@mf%i7IOIIQNnlKSrOW6T6Aa~
zzzv!ft#0lKwzQ#%D*U(CNVtT$BA5z-ik%o65YF5q{4ms7cV2r-06S=ERhf?MznhZa
zGrpJw`vq|!>WixoQA~L~vGVh0X>qPIVC$n@kA
zgAe9Vv7m|wv0n;2S!@A_bF_Ik?_SL^JGp@R5EaWzcA1Gc%VZkUm>_}pGHl31$w(sf
z+uZ2Ept_z!a-O424U9dTV*JG{cuTUO^>-YP_8i
z`v#vMN1_$fg^2rkMu;VMV$!FWzJD?AEISTe*2Mszrd1b3X#X~G3v?T?bR#BKi;99M
zL3}eC>Q11E<1D@G!$!0px~z-qtUQ0moD|RXC4}Mrzlg<+1Y8PEBfUp0jJpx4B>@E+cy3`^(Gw`Mf+2&yxZm<$to~Vpgvg&QKNR
z_f#1(r6svZt%iF?s+n<8X?B&!h3g9Dbb8_=MX}!;HiQSAh`bp^WMl~Z-44teO7W_Y
zV4thSL{h;rJY7!l3%5J4H1!tIzB`Dv+YxO(haWeausGZYkI8^hWj6mzo=L0{%;yxzh{5!Htr?51
zvG|W62MzC8BZ76hRpCyO2zOn<%e)K>NHge!-~)Ap33OdWw6hsLYbCxGNt0%wk_2z7
zfyYvXheSG)5HRK1VB~%mq7Dmurw#bi@hEcOr3&G1ZiF*$M=&9nB#VNf&Q^r$4G5kp
zTURh&s)E0%5&hyVD}sp<72~zmAY`Y(9aqO6CXF%=zFHGzO-A&I(pE}v70YQxCPJ{Y
z4L+?5-crdLn3ZRPEs!A4ehEY3ZRpL~w9>@aMN+{F4dI@v&>(QDHQum!mG~E^$OS8l
z!7?%Uwib*ROP67Hw`ika)gX-(8Ia`-u_IEhxG7U<13kSsMW+$lbb2dUMm5p6pa}cjgA+U$^mJ^AjD?&bdi)8~y+Q002ovPDHLkV1g8IMc@Dc
literal 0
HcmV?d00001
diff --git a/lms/static/candy_res/img/action/kick.png b/lms/static/candy_res/img/action/kick.png
new file mode 100644
index 0000000000000000000000000000000000000000..bce1c9768634180771b1fafebf331ef3db780208
GIT binary patch
literal 859
zcmV-h1ElpF8FWQhbW?9;ba!ELWdKlNX>N2bPDNB8
zb~7$DE;u(kfL#Cp0@z7JK~y+Tos(-zlVKQu{Q*Tm5%eJ<%97}eboNxNk%yKwYTC4w
zH8Dy}T}#{6YO7q!bfz|U%IT)1n3+~qmf6WlJE+6R&P4}=`U3@5?-uydm!KZFK3>;-
zKMy=%VgGRgu056k$F3hwPpiNGlRo6heJRKcw;tiDEtTF=s*%xv(P+eAFrd@vC@LyK
ztJR{>XvoXUBjDM~kSB*%7mx9Eu!+yl&e2h(;Qq-JF`(D$sjjXL;cz%YIGxTAgYF!@
z^fwAL@xI5-o7*<7ly0KivOx?4r`oISq}Vo$$}X3SAkQ4T`ShfLx80?@x?vHpa=Bz9
zo#q5F5L{VamW1SZRA_2Ybi3Ul`l{4??5z#S!&glLW-eKixolW124of~Sq&<7`qGF#
zyo42oNR|{XBvLbvhvh0h^mrKVF!8)y&+|3|?G_oWg)4psq$gqqGV8Ze^|*xcUJE4;
z%oN`@V!CsH1-Y}idoq=GckF_wW2mKoZ+FvaD^8#(KXN2&jWyb<_NPb&!WpB=XF;Ffo2WwJ2D`UK!n@xYcnz!v+X+0vvn-wm;LG-?5$SadrZ(GaU>n#nBn
zbiy;H(Qc6N;z}`3>oV!{Y@_c?2CXIut{qdwfJ&tzM!TH2eNlM5UTSM=L)6vPaiK6u
zxc^~6+|ENsDvzA$v>aNET{T$@D3wZTYHEnhUpXoUWuMPSW5HqqEqX!TLQkcf2UQBr
z8{?=@P7nhMh2oE?K{1Hm@5i4rpMj?R3<~r&Di}MF#v89X?19G{X#NF=y3!#P{9h!$NGm&{VG9bZSYA)3<7W5mGUzNIr`$|T{w%PP|*ZZA(A
lUtFdb>nm4I_$dUw{Q{y_0qVHs(>?$I002ovPDHLkV1jYJiE#h`
literal 0
HcmV?d00001
diff --git a/lms/static/candy_res/img/action/menu.png b/lms/static/candy_res/img/action/menu.png
new file mode 100644
index 0000000000000000000000000000000000000000..be4540c436f6ac2a09e7f99cc7277c2579eadc78
GIT binary patch
literal 1229
zcmeAS@N?(olHy`uVBq!ia0vp^oIuRV!3-o9^qNHiDaPU;cPEB*=VV?2IV|apzK#qG
z8~eHcB(gFvFf#=Bgt!95?dkO-!8JjERGZgNx4o8zwI5pnrkJT^AW7B+k01lLkoCLBu@bfRhnJSkT}{
z5cwz-C?AEk;`Q7|NyO0V@Jst%?>X<@)8|?6hNplZoH}p}R=_wBd4A);huvr*D{8ta
zv>weaRZy(zA{a{x)NMK$oUyoq;&Q_jA9Yid>V_!Q3{lkDazCTg+2GL8fKO$yVv7pZ
zw#ZdlB3o|B^q?JFdAt+nq80!As0d}1Z|KFR(*lEh^G}{es&~_I}wY;n4d5jAs0d}gj@(+
z%6*K*29I(Myv%_UPWDD&5qS@s3~ZAJGDH
vT*SlN0ce3)r#d%-F%SaNZe6;L@E^Vb!Ji3~dec0&00000NkvXXu0mjflI-*P
literal 0
HcmV?d00001
diff --git a/lms/static/candy_res/img/action/settings.png b/lms/static/candy_res/img/action/settings.png
new file mode 100755
index 0000000000000000000000000000000000000000..327fdf496396160bef8fdc76c4bbd6d506e17f2f
GIT binary patch
literal 744
zcmVP)e
zarFA^`%fmeo(q?A?)lIEJ^uf{lIe8H&hkhIAQp@L{g6l)eUC;XT5sd%DfaS{lauYi
zV6ZzJ4w)#5tWv4)H4q3e=_1->GPyDykMAN`iG&Acv-zgYX1mepbRHor0N@S)uP^2Z
zvsDK6TT4qzp8#TNHk+?|y`K5>^pq>Y**P$78I8sO2s*7+>oLF%kT>E0I8T{
ztyasxzM)Vkm`o<4WM;8g)&R8TbUIlklhIYH)n`!h1vyO)0JJU+UI572@Ap@^G(ljw
zT#f;h+3j}jxl}5#R4Vlg`{w{}E?Rg45Tjf!Hxg^N+hy^1yq3@Bx0Onz=yJLI9*@UC
zpaz3Me{^)Tj=T4fw=T@8{{$C=in&8|pu7M5jc0lO
a2rvNkaD*M4V&1s`0000KLZ*U+IBfRsybQWXdwQbLP>6pAqfylh#{fb6;Z(vMMVS~$e@S=j*ftg6;Uhf59&ghTmgWD0l;*T
zI709Y^p6lP1rIRMx#05C~cW=H_Aw*bJ-5DT&Z2n+x)QHX^p
z00esgV8|mQcmRZ%02D^@S3L16t`O%c004NIvOKvYIYoh62rY33S640`D9%Y2D-rV&nehQ1i
z007~1e$oCcFS8neI|hJl{-P!B1ZZ9hpmq0)X0i`JwE&>$+E?>%_LC6RbVIkUx0b+_+BaR3cnT7Zv!AJxW
zizFb)h!jyGOOZ85F;a?DAXP{m@;!0_IfqH8(HlgRxt7s3}k3K`kFu>>-2Q$QMFfPW!La{h336o>X
zu_CMttHv6zR;&ZNiS=X8v3CR#fknUxHUxJ0uoBa_M6WNWeqIg~6QE69c9o#eyhGvpiOA@W-aonk<7r1(?fC{oI5N*U!4
zfg=2N-7=cNnjjOr{yriy6mMFgG#l
znCF=fnQv8CDz++o6_Lscl}eQ+l^ZHARH>?_s@|##Rr6KLRFA1%Q+=*RRWnoLsR`7U
zt5vFIcfW3@?wFpwUVxrVZ>QdQz32KIeJ}k~{cZZE^+ya?
z2D1z#2HOnI7(B%_ac?{wFUQ;QQA1tBKtrWrm0_3Rgps+?Jfqb{jYbcQX~taRB;#$y
zZN{S}1|}gUOHJxc?wV3fxuz+mJ4`!F$IZ;mqRrNsHJd##*D~ju=bP7?-?v~|cv>vB
zsJ6IeNwVZxrdjT`yl#bBIa#GxRa#xMMy;K#CDyyGyQdMSxlWT#tDe?p!?5wT$+oGt
z8L;Kp2HUQ-ZMJ=3XJQv;x5ci*?vuTfeY$;({XGW_huIFR9a(?@3)XSs8O^N5RyOM=TTmp(3=8^+zpz2r)C
z^>JO{deZfso3oq3?Wo(Y?l$ge?uXo;%ru`Vo>?<<(8I_>;8Eq#KMS9gFl*neeosSB
zfoHYnBQIkwkyowPu(zdms`p{<7e4kra-ZWq<2*OsGTvEV%s0Td$hXT+!*8Bnh2KMe
zBmZRodjHV?r+_5^X9J0WL4jKW`}lf%A-|44I@@LTvf1rHjG(ze6+w@Jt%Bvjts!X0
z?2xS?_ve_-kiKB_KiJlZ$9G`c^=E@oNG)mWWaNo-3TIW8)$Hg0Ub-~8?KhvJ>$
z3*&nim@mj(aCxE5!t{lw7O5^0EIO7zOo&c6l<+|iDySBWCGrz@C5{St!X3hAA}`T4
z(TLbXTq+(;@<=L8dXnssyft|w#WSTW<++3>sgS%(4NTpeI-VAqb|7ssJvzNHgOZVu
zaYCvgO_R1~>SyL=cFU|~g|hy|Zi}}s9+d~lYqOB71z9Z$wnC=pR9Yz4DhIM>Wmjgu
z&56o6maCpC&F##y%G;1PobR9i?GnNg;gYtchD%p19a!eQtZF&3JaKv33gZ<8D~47E
ztUS1iwkmDaPpj=$m#%)jCVEY4fnLGNg2A-`YwHVD3gv};>)hAvT~AmqS>Lr``i7kw
zJ{5_It`yrBmlc25DBO7E8;5VoznR>Ww5hAaxn$2~(q`%A-YuS64wkBy=9dm`4cXeX
z4c}I@?e+FW+b@^RDBHV(wnMq2zdX3SWv9u`%{xC-q*U}&`cyXV(%rRT*Z6MH?i+i&
z_B8C(+grT%{XWUQ+f@NoP1R=AW&26{v-dx)iK^-Nmiuj8txj!m?Z*Ss1N{dh4z}01
z)YTo*JycSU)+_5r4#yw9{+;i4Ee$peRgIj+;v;ZGdF1K$3E%e~4LaI(jC-u%2h$&R
z9cLXcYC@Xwnns&bn)_Q~Te?roKGD|d-g^8;+aC{{G(1^(O7m37Y1-+6)01cN&y1aw
zoqc{T`P^XJqPBbIW6s}d4{z_f5Om?vMgNQEJG?v2T=KYd^0M3I6IZxbny)%vZR&LD
zJpPl@Psh8QyPB@KTx+@RdcC!KX7}kEo;S|j^u2lU7XQ}Oo;f|;z4Ll+_r>@1-xl3|
zawq-H%e&ckC+@AhPrP6BKT#_XdT7&;F71j}Joy
zkC~6lh7E@6o;W@^IpRNZ{ptLtL(gQ-CY~4mqW;US7Zxvm_|@yz&e53Bp_lTPlfP|z
zrTyx_>lv@x#=^!PzR7qqF<$gm`|ZJZ+;<)Cqu&ot2z=0000WV@Og>004R=004l4008;_004mL004C`008P>0026e000+nl3&F}
z0004xNkly12c3s?r4se-3ZmaafD3lT-dz4U#5
z?$XCk^rHwMdrW!-s!|;t9Ya;H7JUCK(;v1t)2Q=J1mHFBd7wbVQ7V<_c6*2jZgmw~
ztI^J8S)aSW)pzf=2MsX;5XX)%4B6ZJ!qo0AGmQqFexI%Rc|t2EZsAxEb9nfRcx?@D
zXNT{-9#zkyEis4=7j!yp3gK5~4-Rm-EVaTU>&7y3bVM#UHe4{#+-G8Q6XzV;=co8t
zEaFA`SZfg(-oo_4o43m&J-#qse}604-nz;AT)B
zQPF$AZ4CKm&tD{GnD#fCkkLfO-vmMVdJvSGe{!n-VE+3z{vH5+N4$!&jm}5_0000<
KMNUMnLSTZ{UFO&T
literal 0
HcmV?d00001
diff --git a/lms/static/candy_res/img/action/sound-on.png b/lms/static/candy_res/img/action/sound-on.png
new file mode 100644
index 0000000000000000000000000000000000000000..b4351604a72146d2d6a6d9664a31f5f8887f145e
GIT binary patch
literal 544
zcmV+*0^j|KP)6Q4q)Hxhv>F5XFOD
zyhwp~5$aO^Le~z_(G3HsjKxC)60s79D6v$KKcGLMi2j4UdGIC)86tTT5&ZqLnWcRg
zm=_N{7-rsk^ZmS;_jcKQK8JON3Dz7d0m)?YAdyIXt;!LXEv8Z_VK$pxO{Y^Cy>)S~
zP$)>_@%Swq4mUU;lgaEDcR?OSPfn-P=WsZnR4Q?CE|s1hd3}1o-8+5toFXHj|sl{TU
z_?-i&&vLn}mrZ1k#{={NbSlAOvDibq-HxE>l`(cpP|Q;~04KW$;f57`5X9wjf$_X}
z@;&D5<4Zxa*(|2h={s6y%l-vPk_248#_$}?n$PE%u@{X-kFkOmRaG1O!}-fXq0k{(
iV^t3O_rLL5fB^u?I7KURR%+k?0000;g_qz*)!Uw0*DfRXB;qLAZ$z&48$H%z2xj}zd7hVyg6N6#;htp{#NP?4R
z417M{qf)7~(c9Z=ZfR*jEEWT!Znat&eI%k$nAXm1_LT%V~|CWUn)~}CPO}w6D0uA_U`QLz}VObX?>l8+o_crj|VEU
zjgF3TW~?-c*o4Dj2JP)
zl!zA)6l}D~3s`Em?4?Yhi=C~d53rSX$*wCZR^kIl^a0euLX9ZwZt#MNuJ=o34*0Xl
zCQULhaORxv`)1C}Io$Dhg#WP*zi;GnIm>RhyEq&U*X#BA4A+23e&EqpTt9|Fq4rxm
z_qKveCKE)`om#EdSu7TiNF;23xm-e{(ST;N`GVnXG#bsn6G*4i!PRQ@#qDf>g!`G#b@Mx%jFrvp^F*=(lr
zSC3E+Q2KUt-yMtyT-IRtxYNu-$IKWHK=kT4Ms}^?IOE
zslefI=rO(|E)&4u%4)U3U@%}{Dx19BZZkn5lP?wvhVghjl=Mmvz`LJIrBWD=$BalK
zlM#?I(Y?uMvl#;unPTb-Hc-F{6=3i*pU*)qm$POO
zn6iC>gs+P|q(C6R(>S}`E}J=tD3wYu8jXyc
z&1PRkBGFAEk;uP$%8^JUh#Bu(E|<>h^|A(%Q8UZsGFA41nQxQHWd4tTfh-&jTX63J
t{Tj2MF%ZYG&xCuANdEh0`WO2tzyND}g0&6oyTSkf002ovPDHLkV1h8tDD(gT
literal 0
HcmV?d00001
diff --git a/lms/static/candy_res/img/action/subject.png b/lms/static/candy_res/img/action/subject.png
new file mode 100644
index 0000000000000000000000000000000000000000..7bc9233ea63c89d52a99494dd0f0735a29a3ec3b
GIT binary patch
literal 413
zcmV;O0b>4%P)i2vikyMR~)n*keF9=!Gc_n*K2@qsNT?}H4v4a974
z1ArVJApZ0B-@pGKzWw|E^3%Wn&p!V9|K$C@{}12&`+x7vzyG&i{r!LE6~yrB1;;^#
zm?0Y=moxPMSn>r>N00000NkvXX
Hu0mjf$^yWL
literal 0
HcmV?d00001
diff --git a/lms/static/candy_res/img/action/unignore.png b/lms/static/candy_res/img/action/unignore.png
new file mode 100644
index 0000000000000000000000000000000000000000..89c8129a490b329f3165f32fa0781701aab417ea
GIT binary patch
literal 781
zcmV+o1M>WdP)4-QibtN)VXQDpczE`xXAkUjh%RI>;okxb7K@0kpyQ1k_Y(|Oe7$m(^
zNYX>mI||sUbmn+c3<&FnE=4u#()KBS^SH8e)Qs5i!#lY=$-1gbH6VluzU=m=EP78&5vQ
z-?+fFP-G2l&l_QzYealK$;1Rl?FkzXR&Jv@fBPNjCr#AYRyJ7UJQ0v#?)7Ott=>3`#-pV!7>9}>Q1jL)H6h&gkP@3nI=+F3nA~M>u#(n*
z8T!#8oEw&-mED4!h4s!N@Jo3S7N&Q6%6l3}nlcd~X@>;uelvPsSkXIgg~e+^T1zSf
z3SNj(5%jK~i8@b;C(G`SI(6vnfMgxg){D+Lwutc1Si0swhN#FwOv#}l83ts6rCW;r!9Q9l
zl<41549yYiq6clJ;(J(YgF_14nmKFB@QK(mo6I~sr{BJxJ$rsp0HSt^ntND0Z;o48
z>O2Ckm9}n?$F`*>$L{;{zT>f+bCm7tpaqw^4q@%k
z&cHHt3=3xZmt6rQ_dtDM#)Xwp66-Thu=<9?(zFvpy0gAr0U4Z3smE5f@pZNr!NoqT
zEjSPuCQzMw(H;?yvf{+e;!7(;4hv)+d%cjKFiBL%egy0aeCof8z<>rLEjMsF|CBRH
z86WcxAYvS6H;Yq)jY1Z-rrjWiu~m;clLmJlDAE7UhMJ*jBxp}s&nQkrZvqDXxsiv3
zSJ78>4W2GFIu$$+Ic&5Pq{1?zhIy(24enCZy35e>z6~XgVx$x%k(+>tPw)9SL~R?4
zs${`1bqjTFC3F)dxIIw>)!QP7$vk+;^#2c5r{lsjtwKYnfnn+j{~{GK;|I8rvPFU
z5NbS#W7m)ofjNER&&ggR6fXi0xd4%4143#8JZlhXW+2TN#8b=5@L&-EUlY^cTT=>w
zb_~+jfcRCYfdj}H0J49#sP#gtxE~%YBJiQ3AjMgoQJKuMITA}Iz|zizG7pw|7R*XF
j=$D`QjOCK>V3B}dL4UFUkhgq600000NkvXXu0mjf1x-zB
literal 0
HcmV?d00001
diff --git a/lms/static/candy_res/img/context-arrows.gif b/lms/static/candy_res/img/context-arrows.gif
new file mode 100644
index 0000000000000000000000000000000000000000..d02df8c43f555bcc568701dbbd52ebdfc5053a27
GIT binary patch
literal 91
zcmZ?wbhEHbRA-Q5XkcUjg8%>jEB<5wG8q|kKzxu41Cvot|H{*E`4`XGa;tmuy*KLZ*U+IBfRsybQWXdwQbLP>6pAqfylh#{fb6;Z(vMMVS~$e@S=j*ftg6;Uhf59&ghTmgWD0l;*T
zI709Y^p6lP1rIRMx#05C~cW=H_Aw*bJ-5DT&Z2n+x)QHX^p
z00esgV8|mQcmRZ%02D^@S3L16t`O%c004NIvOKvYIYoh62rY33S640`D9%Y2D-rV&nehQ1i
z007~1e$oCcFS8neI|hJl{-P!B1ZZ9hpmq0)X0i`JwE&>$+E?>%_LC6RbVIkUx0b+_+BaR3cnT7Zv!AJxW
zizFb)h!jyGOOZ85F;a?DAXP{m@;!0_IfqH8(HlgRxt7s3}k3K`kFu>>-2Q$QMFfPW!La{h336o>X
zu_CMttHv6zR;&ZNiS=X8v3CR#fknUxHUxJ0uoBa_M6WNWeqIg~6QE69c9o#eyhGvpiOA@W-aonk<7r1(?fC{oI5N*U!4
zfg=2N-7=cNnjjOr{yriy6mMFgG#l
znCF=fnQv8CDz++o6_Lscl}eQ+l^ZHARH>?_s@|##Rr6KLRFA1%Q+=*RRWnoLsR`7U
zt5vFIcfW3@?wFpwUVxrVZ>QdQz32KIeJ}k~{cZZE^+ya?
z2D1z#2HOnI7(B%_ac?{wFUQ;QQA1tBKtrWrm0_3Rgps+?Jfqb{jYbcQX~taRB;#$y
zZN{S}1|}gUOHJxc?wV3fxuz+mJ4`!F$IZ;mqRrNsHJd##*D~ju=bP7?-?v~|cv>vB
zsJ6IeNwVZxrdjT`yl#bBIa#GxRa#xMMy;K#CDyyGyQdMSxlWT#tDe?p!?5wT$+oGt
z8L;Kp2HUQ-ZMJ=3XJQv;x5ci*?vuTfeY$;({XGW_huIFR9a(?@3)XSs8O^N5RyOM=TTmp(3=8^+zpz2r)C
z^>JO{deZfso3oq3?Wo(Y?l$ge?uXo;%ru`Vo>?<<(8I_>;8Eq#KMS9gFl*neeosSB
zfoHYnBQIkwkyowPu(zdms`p{<7e4kra-ZWq<2*OsGTvEV%s0Td$hXT+!*8Bnh2KMe
zBmZRodjHV?r+_5^X9J0WL4jKW`}lf%A-|44I@@LTvf1rHjG(ze6+w@Jt%Bvjts!X0
z?2xS?_ve_-kiKB_KiJlZ$9G`c^=E@oNG)mWWaNo-3TIW8)$Hg0Ub-~8?KhvJ>$
z3*&nim@mj(aCxE5!t{lw7O5^0EIO7zOo&c6l<+|iDySBWCGrz@C5{St!X3hAA}`T4
z(TLbXTq+(;@<=L8dXnssyft|w#WSTW<++3>sgS%(4NTpeI-VAqb|7ssJvzNHgOZVu
zaYCvgO_R1~>SyL=cFU|~g|hy|Zi}}s9+d~lYqOB71z9Z$wnC=pR9Yz4DhIM>Wmjgu
z&56o6maCpC&F##y%G;1PobR9i?GnNg;gYtchD%p19a!eQtZF&3JaKv33gZ<8D~47E
ztUS1iwkmDaPpj=$m#%)jCVEY4fnLGNg2A-`YwHVD3gv};>)hAvT~AmqS>Lr``i7kw
zJ{5_It`yrBmlc25DBO7E8;5VoznR>Ww5hAaxn$2~(q`%A-YuS64wkBy=9dm`4cXeX
z4c}I@?e+FW+b@^RDBHV(wnMq2zdX3SWv9u`%{xC-q*U}&`cyXV(%rRT*Z6MH?i+i&
z_B8C(+grT%{XWUQ+f@NoP1R=AW&26{v-dx)iK^-Nmiuj8txj!m?Z*Ss1N{dh4z}01
z)YTo*JycSU)+_5r4#yw9{+;i4Ee$peRgIj+;v;ZGdF1K$3E%e~4LaI(jC-u%2h$&R
z9cLXcYC@Xwnns&bn)_Q~Te?roKGD|d-g^8;+aC{{G(1^(O7m37Y1-+6)01cN&y1aw
zoqc{T`P^XJqPBbIW6s}d4{z_f5Om?vMgNQEJG?v2T=KYd^0M3I6IZxbny)%vZR&LD
zJpPl@Psh8QyPB@KTx+@RdcC!KX7}kEo;S|j^u2lU7XQ}Oo;f|;z4Ll+_r>@1-xl3|
zawq-H%e&ckC+@AhPrP6BKT#_XdT7&;F71j}Joy
zkC~6lh7E@6o;W@^IpRNZ{ptLtL(gQ-CY~4mqW;US7Zxvm_|@yz&e53Bp_lTPlfP|z
zrTyx_>lv@x#=^!PzR7qqF<$gm`|ZJZ+;<)Cqu&ot2z=0000WV@Og>004R=004l4008;_004mL004C`008P>0026e000+nl3&F}
z0008ENkljQKU$W8JcvFOp~duGBX)7_na=~#pVx(%ekEY
z2bX({ssa!Mz6Xo~XMt|uD3Ao+1Is`Gm;!ze!(0`>#>DS{8ZZV-0~df}VVE;ON8C@v
z;_rgMzXafkxNhJM@Oc>KevG21Ef)DcilTeKY2YyMIt+8Sq9~dKZbVV^r}6#lU0@NI
z1b*u5>>Tar=p>a&(R#R*e~Lwl#UgK)-cnd9OvNeA0ACp2&vpY3fc@!o=3pk1K~Yde
zRZ(c1syv*TA)n9x3#I=ANmLaTa95tK*!*ImdQ=o0a8ds?{paIo8+L0p=|*xv;Q+s8HFeFg-m@{!yNCrHrUh
zE|+=o_$g8J2OnxR6k+krBEYZ4_p=@_m;Nl>+t=4iZL7wM7cVKUtzkV6S9he2r06@*
zOCphAer}%GXU~7Te*MO%QB{J#Z@1Rw2L=aP`v>|l2EYKI+IXbxdYxAKLZ*U+IBfRsybQWXdwQbLP>6pAqfylh#{fb6;Z(vMMVS~$e@S=j*ftg6;Uhf59&ghTmgWD0l;*T
zI709Y^p6lP1rIRMx#05C~cW=H_Aw*bJ-5DT&Z2n+x)QHX^p
z00esgV8|mQcmRZ%02D^@S3L16t`O%c004NIvOKvYIYoh62rY33S640`D9%Y2D-rV&nehQ1i
z007~1e$oCcFS8neI|hJl{-P!B1ZZ9hpmq0)X0i`JwE&>$+E?>%_LC6RbVIkUx0b+_+BaR3cnT7Zv!AJxW
zizFb)h!jyGOOZ85F;a?DAXP{m@;!0_IfqH8(HlgRxt7s3}k3K`kFu>>-2Q$QMFfPW!La{h336o>X
zu_CMttHv6zR;&ZNiS=X8v3CR#fknUxHUxJ0uoBa_M6WNWeqIg~6QE69c9o#eyhGvpiOA@W-aonk<7r1(?fC{oI5N*U!4
zfg=2N-7=cNnjjOr{yriy6mMFgG#l
znCF=fnQv8CDz++o6_Lscl}eQ+l^ZHARH>?_s@|##Rr6KLRFA1%Q+=*RRWnoLsR`7U
zt5vFIcfW3@?wFpwUVxrVZ>QdQz32KIeJ}k~{cZZE^+ya?
z2D1z#2HOnI7(B%_ac?{wFUQ;QQA1tBKtrWrm0_3Rgps+?Jfqb{jYbcQX~taRB;#$y
zZN{S}1|}gUOHJxc?wV3fxuz+mJ4`!F$IZ;mqRrNsHJd##*D~ju=bP7?-?v~|cv>vB
zsJ6IeNwVZxrdjT`yl#bBIa#GxRa#xMMy;K#CDyyGyQdMSxlWT#tDe?p!?5wT$+oGt
z8L;Kp2HUQ-ZMJ=3XJQv;x5ci*?vuTfeY$;({XGW_huIFR9a(?@3)XSs8O^N5RyOM=TTmp(3=8^+zpz2r)C
z^>JO{deZfso3oq3?Wo(Y?l$ge?uXo;%ru`Vo>?<<(8I_>;8Eq#KMS9gFl*neeosSB
zfoHYnBQIkwkyowPu(zdms`p{<7e4kra-ZWq<2*OsGTvEV%s0Td$hXT+!*8Bnh2KMe
zBmZRodjHV?r+_5^X9J0WL4jKW`}lf%A-|44I@@LTvf1rHjG(ze6+w@Jt%Bvjts!X0
z?2xS?_ve_-kiKB_KiJlZ$9G`c^=E@oNG)mWWaNo-3TIW8)$Hg0Ub-~8?KhvJ>$
z3*&nim@mj(aCxE5!t{lw7O5^0EIO7zOo&c6l<+|iDySBWCGrz@C5{St!X3hAA}`T4
z(TLbXTq+(;@<=L8dXnssyft|w#WSTW<++3>sgS%(4NTpeI-VAqb|7ssJvzNHgOZVu
zaYCvgO_R1~>SyL=cFU|~g|hy|Zi}}s9+d~lYqOB71z9Z$wnC=pR9Yz4DhIM>Wmjgu
z&56o6maCpC&F##y%G;1PobR9i?GnNg;gYtchD%p19a!eQtZF&3JaKv33gZ<8D~47E
ztUS1iwkmDaPpj=$m#%)jCVEY4fnLGNg2A-`YwHVD3gv};>)hAvT~AmqS>Lr``i7kw
zJ{5_It`yrBmlc25DBO7E8;5VoznR>Ww5hAaxn$2~(q`%A-YuS64wkBy=9dm`4cXeX
z4c}I@?e+FW+b@^RDBHV(wnMq2zdX3SWv9u`%{xC-q*U}&`cyXV(%rRT*Z6MH?i+i&
z_B8C(+grT%{XWUQ+f@NoP1R=AW&26{v-dx)iK^-Nmiuj8txj!m?Z*Ss1N{dh4z}01
z)YTo*JycSU)+_5r4#yw9{+;i4Ee$peRgIj+;v;ZGdF1K$3E%e~4LaI(jC-u%2h$&R
z9cLXcYC@Xwnns&bn)_Q~Te?roKGD|d-g^8;+aC{{G(1^(O7m37Y1-+6)01cN&y1aw
zoqc{T`P^XJqPBbIW6s}d4{z_f5Om?vMgNQEJG?v2T=KYd^0M3I6IZxbny)%vZR&LD
zJpPl@Psh8QyPB@KTx+@RdcC!KX7}kEo;S|j^u2lU7XQ}Oo;f|;z4Ll+_r>@1-xl3|
zawq-H%e&ckC+@AhPrP6BKT#_XdT7&;F71j}Joy
zkC~6lh7E@6o;W@^IpRNZ{ptLtL(gQ-CY~4mqW;US7Zxvm_|@yz&e53Bp_lTPlfP|z
zrTyx_>lv@x#=^!PzR7qqF<$gm`|ZJZ+;<)Cqu&ot2z=0000WV@Og>004R=004l4008;_004mL004C`008P>0026e000+nl3&F}
z0007{NklS*SDgg2tS291P0qxai*hX^{=H=)mE?kKe~RaLy?a
z5sD)k9q0jifP+8_Fb~WD6Q(&hQS2@G2S=j=2Jj#h3Z1F14-p6i@cDhLW-??l89sjc
z$mdU=p8=Olb1+wcqtStVKs*xZ@O5-_AP9&cA_)AgBD{{ph{xkupv5!?S2d*^4S3nr
z-CcjEwUtyV#f}|25u^Z#2&r%12seb$bgeR#O4S`ceB`kfiL_s6YHGUDd8`v_)8fha
zI2#)q>@^H@T_>N4pH
zolZ0RW|l-E!P3$a-YPF$-JMiaR3IW~nocsA^yolqO(1|Mg&>TKi~w-!_N~Im!}o`o
zo}T8~^?sBHhEWZ0PzQ{1PdQ2y#cNYaZNsXdIZ7d-aJgLoyLBLES(f4Pcn}e8+`NH^
zpp;YS+}D2$+aKzo7wJIoOVkvz@=~-*_s@S@^KX{JZySZnyhnTU*;k;kJi{?(Vg1`$4cS
zcsdlWr`j-ZyL7Bgi?!7?zI^%0_vQ2puea)QUtfO~kdps=)j8lI&ma*HBzgWscxP00000NkvXXu0mjfMT>G-
literal 0
HcmV?d00001
diff --git a/lms/static/candy_res/img/emoticons/Aww.png b/lms/static/candy_res/img/emoticons/Aww.png
new file mode 100755
index 0000000000000000000000000000000000000000..35128639878e612a94e417b5b9e5cb533345c068
GIT binary patch
literal 3352
zcmV+z4d?QSP)KLZ*U+IBfRsybQWXdwQbLP>6pAqfylh#{fb6;Z(vMMVS~$e@S=j*ftg6;Uhf59&ghTmgWD0l;*T
zI709Y^p6lP1rIRMx#05C~cW=H_Aw*bJ-5DT&Z2n+x)QHX^p
z00esgV8|mQcmRZ%02D^@S3L16t`O%c004NIvOKvYIYoh62rY33S640`D9%Y2D-rV&nehQ1i
z007~1e$oCcFS8neI|hJl{-P!B1ZZ9hpmq0)X0i`JwE&>$+E?>%_LC6RbVIkUx0b+_+BaR3cnT7Zv!AJxW
zizFb)h!jyGOOZ85F;a?DAXP{m@;!0_IfqH8(HlgRxt7s3}k3K`kFu>>-2Q$QMFfPW!La{h336o>X
zu_CMttHv6zR;&ZNiS=X8v3CR#fknUxHUxJ0uoBa_M6WNWeqIg~6QE69c9o#eyhGvpiOA@W-aonk<7r1(?fC{oI5N*U!4
zfg=2N-7=cNnjjOr{yriy6mMFgG#l
znCF=fnQv8CDz++o6_Lscl}eQ+l^ZHARH>?_s@|##Rr6KLRFA1%Q+=*RRWnoLsR`7U
zt5vFIcfW3@?wFpwUVxrVZ>QdQz32KIeJ}k~{cZZE^+ya?
z2D1z#2HOnI7(B%_ac?{wFUQ;QQA1tBKtrWrm0_3Rgps+?Jfqb{jYbcQX~taRB;#$y
zZN{S}1|}gUOHJxc?wV3fxuz+mJ4`!F$IZ;mqRrNsHJd##*D~ju=bP7?-?v~|cv>vB
zsJ6IeNwVZxrdjT`yl#bBIa#GxRa#xMMy;K#CDyyGyQdMSxlWT#tDe?p!?5wT$+oGt
z8L;Kp2HUQ-ZMJ=3XJQv;x5ci*?vuTfeY$;({XGW_huIFR9a(?@3)XSs8O^N5RyOM=TTmp(3=8^+zpz2r)C
z^>JO{deZfso3oq3?Wo(Y?l$ge?uXo;%ru`Vo>?<<(8I_>;8Eq#KMS9gFl*neeosSB
zfoHYnBQIkwkyowPu(zdms`p{<7e4kra-ZWq<2*OsGTvEV%s0Td$hXT+!*8Bnh2KMe
zBmZRodjHV?r+_5^X9J0WL4jKW`}lf%A-|44I@@LTvf1rHjG(ze6+w@Jt%Bvjts!X0
z?2xS?_ve_-kiKB_KiJlZ$9G`c^=E@oNG)mWWaNo-3TIW8)$Hg0Ub-~8?KhvJ>$
z3*&nim@mj(aCxE5!t{lw7O5^0EIO7zOo&c6l<+|iDySBWCGrz@C5{St!X3hAA}`T4
z(TLbXTq+(;@<=L8dXnssyft|w#WSTW<++3>sgS%(4NTpeI-VAqb|7ssJvzNHgOZVu
zaYCvgO_R1~>SyL=cFU|~g|hy|Zi}}s9+d~lYqOB71z9Z$wnC=pR9Yz4DhIM>Wmjgu
z&56o6maCpC&F##y%G;1PobR9i?GnNg;gYtchD%p19a!eQtZF&3JaKv33gZ<8D~47E
ztUS1iwkmDaPpj=$m#%)jCVEY4fnLGNg2A-`YwHVD3gv};>)hAvT~AmqS>Lr``i7kw
zJ{5_It`yrBmlc25DBO7E8;5VoznR>Ww5hAaxn$2~(q`%A-YuS64wkBy=9dm`4cXeX
z4c}I@?e+FW+b@^RDBHV(wnMq2zdX3SWv9u`%{xC-q*U}&`cyXV(%rRT*Z6MH?i+i&
z_B8C(+grT%{XWUQ+f@NoP1R=AW&26{v-dx)iK^-Nmiuj8txj!m?Z*Ss1N{dh4z}01
z)YTo*JycSU)+_5r4#yw9{+;i4Ee$peRgIj+;v;ZGdF1K$3E%e~4LaI(jC-u%2h$&R
z9cLXcYC@Xwnns&bn)_Q~Te?roKGD|d-g^8;+aC{{G(1^(O7m37Y1-+6)01cN&y1aw
zoqc{T`P^XJqPBbIW6s}d4{z_f5Om?vMgNQEJG?v2T=KYd^0M3I6IZxbny)%vZR&LD
zJpPl@Psh8QyPB@KTx+@RdcC!KX7}kEo;S|j^u2lU7XQ}Oo;f|;z4Ll+_r>@1-xl3|
zawq-H%e&ckC+@AhPrP6BKT#_XdT7&;F71j}Joy
zkC~6lh7E@6o;W@^IpRNZ{ptLtL(gQ-CY~4mqW;US7Zxvm_|@yz&e53Bp_lTPlfP|z
zrTyx_>lv@x#=^!PzR7qqF<$gm`|ZJZ+;<)Cqu&ot2z=0000WV@Og>004R=004l4008;_004mL004C`008P>0026e000+nl3&F}
z0006*NklFg4ROogLi4lTipk&@JemkhC3s
zGt#?J5=GES2$aM_x0LmVm49v%wL=S;W6){gpZ(tF>F`@?Suec2@bdllywCGms!I8a
z#iGN&FwgZ}hKZEbDs=)TmA909w|KG81q6T`
ikOW@ZcKmfY_SXQ((t8+z2~yDj0000KLZ*U+IBfRsybQWXdwQbLP>6pAqfylh#{fb6;Z(vMMVS~$e@S=j*ftg6;Uhf59&ghTmgWD0l;*T
zI709Y^p6lP1rIRMx#05C~cW=H_Aw*bJ-5DT&Z2n+x)QHX^p
z00esgV8|mQcmRZ%02D^@S3L16t`O%c004NIvOKvYIYoh62rY33S640`D9%Y2D-rV&nehQ1i
z007~1e$oCcFS8neI|hJl{-P!B1ZZ9hpmq0)X0i`JwE&>$+E?>%_LC6RbVIkUx0b+_+BaR3cnT7Zv!AJxW
zizFb)h!jyGOOZ85F;a?DAXP{m@;!0_IfqH8(HlgRxt7s3}k3K`kFu>>-2Q$QMFfPW!La{h336o>X
zu_CMttHv6zR;&ZNiS=X8v3CR#fknUxHUxJ0uoBa_M6WNWeqIg~6QE69c9o#eyhGvpiOA@W-aonk<7r1(?fC{oI5N*U!4
zfg=2N-7=cNnjjOr{yriy6mMFgG#l
znCF=fnQv8CDz++o6_Lscl}eQ+l^ZHARH>?_s@|##Rr6KLRFA1%Q+=*RRWnoLsR`7U
zt5vFIcfW3@?wFpwUVxrVZ>QdQz32KIeJ}k~{cZZE^+ya?
z2D1z#2HOnI7(B%_ac?{wFUQ;QQA1tBKtrWrm0_3Rgps+?Jfqb{jYbcQX~taRB;#$y
zZN{S}1|}gUOHJxc?wV3fxuz+mJ4`!F$IZ;mqRrNsHJd##*D~ju=bP7?-?v~|cv>vB
zsJ6IeNwVZxrdjT`yl#bBIa#GxRa#xMMy;K#CDyyGyQdMSxlWT#tDe?p!?5wT$+oGt
z8L;Kp2HUQ-ZMJ=3XJQv;x5ci*?vuTfeY$;({XGW_huIFR9a(?@3)XSs8O^N5RyOM=TTmp(3=8^+zpz2r)C
z^>JO{deZfso3oq3?Wo(Y?l$ge?uXo;%ru`Vo>?<<(8I_>;8Eq#KMS9gFl*neeosSB
zfoHYnBQIkwkyowPu(zdms`p{<7e4kra-ZWq<2*OsGTvEV%s0Td$hXT+!*8Bnh2KMe
zBmZRodjHV?r+_5^X9J0WL4jKW`}lf%A-|44I@@LTvf1rHjG(ze6+w@Jt%Bvjts!X0
z?2xS?_ve_-kiKB_KiJlZ$9G`c^=E@oNG)mWWaNo-3TIW8)$Hg0Ub-~8?KhvJ>$
z3*&nim@mj(aCxE5!t{lw7O5^0EIO7zOo&c6l<+|iDySBWCGrz@C5{St!X3hAA}`T4
z(TLbXTq+(;@<=L8dXnssyft|w#WSTW<++3>sgS%(4NTpeI-VAqb|7ssJvzNHgOZVu
zaYCvgO_R1~>SyL=cFU|~g|hy|Zi}}s9+d~lYqOB71z9Z$wnC=pR9Yz4DhIM>Wmjgu
z&56o6maCpC&F##y%G;1PobR9i?GnNg;gYtchD%p19a!eQtZF&3JaKv33gZ<8D~47E
ztUS1iwkmDaPpj=$m#%)jCVEY4fnLGNg2A-`YwHVD3gv};>)hAvT~AmqS>Lr``i7kw
zJ{5_It`yrBmlc25DBO7E8;5VoznR>Ww5hAaxn$2~(q`%A-YuS64wkBy=9dm`4cXeX
z4c}I@?e+FW+b@^RDBHV(wnMq2zdX3SWv9u`%{xC-q*U}&`cyXV(%rRT*Z6MH?i+i&
z_B8C(+grT%{XWUQ+f@NoP1R=AW&26{v-dx)iK^-Nmiuj8txj!m?Z*Ss1N{dh4z}01
z)YTo*JycSU)+_5r4#yw9{+;i4Ee$peRgIj+;v;ZGdF1K$3E%e~4LaI(jC-u%2h$&R
z9cLXcYC@Xwnns&bn)_Q~Te?roKGD|d-g^8;+aC{{G(1^(O7m37Y1-+6)01cN&y1aw
zoqc{T`P^XJqPBbIW6s}d4{z_f5Om?vMgNQEJG?v2T=KYd^0M3I6IZxbny)%vZR&LD
zJpPl@Psh8QyPB@KTx+@RdcC!KX7}kEo;S|j^u2lU7XQ}Oo;f|;z4Ll+_r>@1-xl3|
zawq-H%e&ckC+@AhPrP6BKT#_XdT7&;F71j}Joy
zkC~6lh7E@6o;W@^IpRNZ{ptLtL(gQ-CY~4mqW;US7Zxvm_|@yz&e53Bp_lTPlfP|z
zrTyx_>lv@x#=^!PzR7qqF<$gm`|ZJZ+;<)Cqu&ot2z=0000WV@Og>004R=004l4008;_004mL004C`008P>0026e000+nl3&F}
z0007YNklGt%bysNx>pP4YW{}Pysn8b^(s
zzy1lDC@~rnPezO!D2X=(+-g}aq-F7772AfO9;&kRJL929>N1zfyu3GgnY^*7DwP$B
zMTdc5pa%#53&1Rpu&j8Zl56`dj>V#GVASXHo%4Eqcsw5J4%U&+<;dl7EUzrHy0ZEd
zxN2GP^*wPc7HtM*LZNV7I2=Y%P(@WyD0fw+Uc6vtW~KlHEGwS3ix@lb?Cj8x_f&uX
zE)f*n6RDP$H6CsR8~d}_Y*QpM@W>tt1uu4Xc3wYo`V5LPJw45nu`ya(T5!2seEs&7
z(L19UV>r}MPeVfk8yg$`vB!@yRlue8jt)eHwNIZ&zI{vJSb&Md1fs%ZVv??|E>fu!
zpFihNRs4QGz~w5SuioQ9MOa*1WN>hhP$-1c;beJf8HdwBFcc&b3A2=0LV?;^H$aaA
za97tj49*!LLOk&0X#~l
z)5AxO9zj*JW`yZ71L+T`TP6WqByimTei(9l^#45C6NodI}gi$-tQfmARY40LxN
z`@^=VqEenk1!GFgzMEryZsDnA#m`q+R@?v=rk+m~UcG!>wl4-Ff`}ku*ePwZwY^F5
zO_KS!g#vKxzu9hTZaUY}>cd@I%RZ-r;`btje1X-K_hdhO{5RWweboqX1?U6Zz&elw
g9#~dx(LLRsaA107*qoM6N<$f|yfDdjJ3c
literal 0
HcmV?d00001
diff --git a/lms/static/candy_res/img/emoticons/Blushing.png b/lms/static/candy_res/img/emoticons/Blushing.png
new file mode 100755
index 0000000000000000000000000000000000000000..ab03ee8c5dbc3e33c04758e04833b9ac26000618
GIT binary patch
literal 3403
zcmV-R4Ycx!P)KLZ*U+IBfRsybQWXdwQbLP>6pAqfylh#{fb6;Z(vMMVS~$e@S=j*ftg6;Uhf59&ghTmgWD0l;*T
zI709Y^p6lP1rIRMx#05C~cW=H_Aw*bJ-5DT&Z2n+x)QHX^p
z00esgV8|mQcmRZ%02D^@S3L16t`O%c004NIvOKvYIYoh62rY33S640`D9%Y2D-rV&nehQ1i
z007~1e$oCcFS8neI|hJl{-P!B1ZZ9hpmq0)X0i`JwE&>$+E?>%_LC6RbVIkUx0b+_+BaR3cnT7Zv!AJxW
zizFb)h!jyGOOZ85F;a?DAXP{m@;!0_IfqH8(HlgRxt7s3}k3K`kFu>>-2Q$QMFfPW!La{h336o>X
zu_CMttHv6zR;&ZNiS=X8v3CR#fknUxHUxJ0uoBa_M6WNWeqIg~6QE69c9o#eyhGvpiOA@W-aonk<7r1(?fC{oI5N*U!4
zfg=2N-7=cNnjjOr{yriy6mMFgG#l
znCF=fnQv8CDz++o6_Lscl}eQ+l^ZHARH>?_s@|##Rr6KLRFA1%Q+=*RRWnoLsR`7U
zt5vFIcfW3@?wFpwUVxrVZ>QdQz32KIeJ}k~{cZZE^+ya?
z2D1z#2HOnI7(B%_ac?{wFUQ;QQA1tBKtrWrm0_3Rgps+?Jfqb{jYbcQX~taRB;#$y
zZN{S}1|}gUOHJxc?wV3fxuz+mJ4`!F$IZ;mqRrNsHJd##*D~ju=bP7?-?v~|cv>vB
zsJ6IeNwVZxrdjT`yl#bBIa#GxRa#xMMy;K#CDyyGyQdMSxlWT#tDe?p!?5wT$+oGt
z8L;Kp2HUQ-ZMJ=3XJQv;x5ci*?vuTfeY$;({XGW_huIFR9a(?@3)XSs8O^N5RyOM=TTmp(3=8^+zpz2r)C
z^>JO{deZfso3oq3?Wo(Y?l$ge?uXo;%ru`Vo>?<<(8I_>;8Eq#KMS9gFl*neeosSB
zfoHYnBQIkwkyowPu(zdms`p{<7e4kra-ZWq<2*OsGTvEV%s0Td$hXT+!*8Bnh2KMe
zBmZRodjHV?r+_5^X9J0WL4jKW`}lf%A-|44I@@LTvf1rHjG(ze6+w@Jt%Bvjts!X0
z?2xS?_ve_-kiKB_KiJlZ$9G`c^=E@oNG)mWWaNo-3TIW8)$Hg0Ub-~8?KhvJ>$
z3*&nim@mj(aCxE5!t{lw7O5^0EIO7zOo&c6l<+|iDySBWCGrz@C5{St!X3hAA}`T4
z(TLbXTq+(;@<=L8dXnssyft|w#WSTW<++3>sgS%(4NTpeI-VAqb|7ssJvzNHgOZVu
zaYCvgO_R1~>SyL=cFU|~g|hy|Zi}}s9+d~lYqOB71z9Z$wnC=pR9Yz4DhIM>Wmjgu
z&56o6maCpC&F##y%G;1PobR9i?GnNg;gYtchD%p19a!eQtZF&3JaKv33gZ<8D~47E
ztUS1iwkmDaPpj=$m#%)jCVEY4fnLGNg2A-`YwHVD3gv};>)hAvT~AmqS>Lr``i7kw
zJ{5_It`yrBmlc25DBO7E8;5VoznR>Ww5hAaxn$2~(q`%A-YuS64wkBy=9dm`4cXeX
z4c}I@?e+FW+b@^RDBHV(wnMq2zdX3SWv9u`%{xC-q*U}&`cyXV(%rRT*Z6MH?i+i&
z_B8C(+grT%{XWUQ+f@NoP1R=AW&26{v-dx)iK^-Nmiuj8txj!m?Z*Ss1N{dh4z}01
z)YTo*JycSU)+_5r4#yw9{+;i4Ee$peRgIj+;v;ZGdF1K$3E%e~4LaI(jC-u%2h$&R
z9cLXcYC@Xwnns&bn)_Q~Te?roKGD|d-g^8;+aC{{G(1^(O7m37Y1-+6)01cN&y1aw
zoqc{T`P^XJqPBbIW6s}d4{z_f5Om?vMgNQEJG?v2T=KYd^0M3I6IZxbny)%vZR&LD
zJpPl@Psh8QyPB@KTx+@RdcC!KX7}kEo;S|j^u2lU7XQ}Oo;f|;z4Ll+_r>@1-xl3|
zawq-H%e&ckC+@AhPrP6BKT#_XdT7&;F71j}Joy
zkC~6lh7E@6o;W@^IpRNZ{ptLtL(gQ-CY~4mqW;US7Zxvm_|@yz&e53Bp_lTPlfP|z
zrTyx_>lv@x#=^!PzR7qqF<$gm`|ZJZ+;<)Cqu&ot2z=0000WV@Og>004R=004l4008;_004mL004C`008P>0026e000+nl3&F}
z0007ZNkl^tnIrR_?TL(Lof}|iRrcmTO6x2x&f*|M?bjkhz
zclgdo|AcNubg%+X9y-Ztl2oP@(R5ZI&CLec%=EkOkI&O#wMGv-Jn-;(KfmBHM1)35
zr6w-{mw=PNgPK0NHcrM%qV4Mi2x<5D^4wQG{oi4B2dU8|bs`bSb2D5CWc_zi^@R
z?3puvKoF!Z5~&sIIMx9MEt{L0$CJs?Sp{6~?d|O!Iz5E`53Y4?ETS169->?>kEK$R
zmrdZx$(|mxXhdtK(rIo_O`%2CTx+K9Ofxe*gVq|Yh3@WdfU71jcr=k9@O?br`*%sh
zKt#}5*K;8=gdoJ@hXMK(5Vyh>2hIV3A24z27EO^zZA>lrzQ@GGO)v~x&qGUq6}A8l
zDWF{WQHguL$Nv64(WWTU)=D^R5d;C%Y8B_e!Pfy{D~uIZIF1AG-2`40whDE~aU5K)
zitD;~o=3G>Wp{6vN~OZD{XIO-r&KBdEStb=KA$HNiO?KtCLFO?da=Z#M~~5>>-!r9
zAc7X3wcHxO0~2_d`C2pL
zr)L!q0OO0#7qf2FZ66vw&8dM?3=9q+T4S09A!Q*Ja`fM0G-Ka19s);y@XA
h3EZ>o^pi&R_W&LbUZ&x0T}uD}002ovPDHLkV1g$;O_l%v
literal 0
HcmV?d00001
diff --git a/lms/static/candy_res/img/emoticons/Childish.png b/lms/static/candy_res/img/emoticons/Childish.png
new file mode 100755
index 0000000000000000000000000000000000000000..1a31c5068eecc6ec4e9eef75f7eea1edf039545c
GIT binary patch
literal 3411
zcmV-Z4XpBsP)KLZ*U+IBfRsybQWXdwQbLP>6pAqfylh#{fb6;Z(vMMVS~$e@S=j*ftg6;Uhf59&ghTmgWD0l;*T
zI709Y^p6lP1rIRMx#05C~cW=H_Aw*bJ-5DT&Z2n+x)QHX^p
z00esgV8|mQcmRZ%02D^@S3L16t`O%c004NIvOKvYIYoh62rY33S640`D9%Y2D-rV&nehQ1i
z007~1e$oCcFS8neI|hJl{-P!B1ZZ9hpmq0)X0i`JwE&>$+E?>%_LC6RbVIkUx0b+_+BaR3cnT7Zv!AJxW
zizFb)h!jyGOOZ85F;a?DAXP{m@;!0_Ifq