From cb54081d2eb4795b334d812edc76433d36d11935 Mon Sep 17 00:00:00 2001
From: Vik Paruchuri
Date: Tue, 6 Aug 2013 09:55:49 -0400
Subject: [PATCH 001/125] Fix notification problem
---
.../open_ended_grading/open_ended_notifications.py | 14 +-------------
1 file changed, 1 insertion(+), 13 deletions(-)
diff --git a/lms/djangoapps/open_ended_grading/open_ended_notifications.py b/lms/djangoapps/open_ended_grading/open_ended_notifications.py
index 1d6fa22929..44ff41be22 100644
--- a/lms/djangoapps/open_ended_grading/open_ended_notifications.py
+++ b/lms/djangoapps/open_ended_grading/open_ended_notifications.py
@@ -146,19 +146,7 @@ def combined_notifications(course, user):
#Get the time of the last login of the user
last_login = user.last_login
-
- #Find the modules they have seen since they logged in
- last_module_seen = StudentModule.objects.filter(student=user, course_id=course_id,
- modified__gt=last_login).values('modified').order_by(
- '-modified')
- last_module_seen_count = last_module_seen.count()
-
- if last_module_seen_count > 0:
- #The last time they viewed an updated notification (last module seen minus how long notifications are cached)
- last_time_viewed = last_module_seen[0]['modified'] - datetime.timedelta(seconds=(NOTIFICATION_CACHE_TIME + 60))
- else:
- #If they have not seen any modules since they logged in, then don't refresh
- return {'pending_grading': False, 'img_path': img_path, 'response': notifications}
+ last_time_viewed = last_login - datetime.timedelta(seconds=(NOTIFICATION_CACHE_TIME + 60))
try:
#Get the notifications from the grading controller
From 1be6ce3ee387ead051b001112c0ef68a7a172b03 Mon Sep 17 00:00:00 2001
From: Vik Paruchuri
Date: Thu, 8 Aug 2013 08:34:08 -0400
Subject: [PATCH 002/125] Add in and route control options
---
.../xmodule/combined_open_ended_module.py | 33 +++++++++++++++++--
.../combined_open_ended_modulev1.py | 11 +++++++
.../open_ended_module.py | 1 +
.../openendedchild.py | 1 +
4 files changed, 44 insertions(+), 2 deletions(-)
diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py
index e01ae49149..f8ae7a3f13 100644
--- a/common/lib/xmodule/xmodule/combined_open_ended_module.py
+++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py
@@ -14,7 +14,8 @@ import textwrap
log = logging.getLogger("mitx.courseware")
V1_SETTINGS_ATTRIBUTES = ["display_name", "max_attempts", "graded", "accept_file_upload",
- "skip_spelling_checks", "due", "graceperiod", "weight"]
+ "skip_spelling_checks", "due", "graceperiod", "weight", "min_to_calibrate",
+ "max_to_calibrate", "peer_grader_count", "required_peer_grading"]
V1_STUDENT_ATTRIBUTES = ["current_task_number", "task_states", "state",
"student_attempts", "ready_to_reset"]
@@ -37,7 +38,7 @@ DEFAULT_DATA = textwrap.dedent("""\
- 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.
+ Write a persuasive essay to a newspaper reflecting your views 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.
@@ -244,6 +245,34 @@ class CombinedOpenEndedFields(object):
values={"min" : 0 , "step": ".1"},
default=1
)
+ min_to_calibrate = Integer(
+ display_name="Minimum Peer Grading Calibrations",
+ help="The minimum number of calibration essays each student will need to complete for peer grading.",
+ default=1,
+ scope=Scope.settings,
+ values={"min" : 1, "step" : "1"}
+ )
+ max_to_calibrate = Integer(
+ display_name="Maximum Peer Grading Calibrations",
+ help="The maximum number of calibration essays each student will need to complete for peer grading.",
+ default=1,
+ scope=Scope.settings,
+ values={"max" : 20, "step" : "1"}
+ )
+ peer_grader_count = Integer(
+ display_name="Peer Graders per Response",
+ help="The number of peers who will grade each submission.",
+ default=1,
+ scope=Scope.settings,
+ values={"min" : 1, "step" : "1", "max" : 5}
+ )
+ required_peer_grading = Integer(
+ display_name="Required Peer Grading",
+ help="The number of other students each student making a submission will have to grade.",
+ default=1,
+ scope=Scope.settings,
+ values={"min" : 1, "step" : "1", "max" : 5}
+ )
markdown = String(
help="Markdown source of this module",
default=textwrap.dedent("""\
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 933eb0b5bb..c65d30968d 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
@@ -106,6 +106,11 @@ class CombinedOpenEndedV1Module():
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
+ self.required_peer_grading = instance_state.get('required_peer_grading', 3)
+ self.peer_grader_count = instance_state.get('peer_grader_count', 3)
+ self.min_to_calibrate = instance_state.get('min_to_calibrate', 3)
+ self.max_to_calibrate = instance_state.get('max_to_calibrate', 6)
+
due_date = instance_state.get('due', None)
grace_period_string = instance_state.get('graceperiod', None)
@@ -131,6 +136,12 @@ class CombinedOpenEndedV1Module():
'close_date': self.timeinfo.close_date,
's3_interface': self.system.s3_interface,
'skip_basic_checks': self.skip_basic_checks,
+ 'control': {
+ 'required_peer_grading': self.required_peer_grading,
+ 'peer_grader_count': self.peer_grader_count,
+ 'min_to_calibrate': self.min_to_calibrate,
+ 'max_to_calibrate': self.max_to_calibrate,
+ }
}
self.task_xml = definition['task_xml']
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 2e7a3eaf89..924ca2c23d 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
@@ -118,6 +118,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
'answer': self.answer,
'problem_id': self.display_name,
'skip_basic_checks': self.skip_basic_checks,
+ 'control': json.dumps(self.control),
})
updated_grader_payload = json.dumps(parsed_grader_payload)
diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py
index 10f939b270..7138dcc723 100644
--- a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py
+++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py
@@ -92,6 +92,7 @@ class OpenEndedChild(object):
self.s3_interface = static_data['s3_interface']
self.skip_basic_checks = static_data['skip_basic_checks']
self._max_score = static_data['max_score']
+ self.control = static_data['control']
# Used for progress / grading. Currently get credit just for
# completion (doesn't matter if you self-assessed correct/incorrect).
From 4b5aba29ca3fc0b24ea4195bf5cf5f50065ff7db Mon Sep 17 00:00:00 2001
From: Vik Paruchuri
Date: Thu, 8 Aug 2013 09:52:13 -0400
Subject: [PATCH 003/125] Fix defaults
---
common/lib/xmodule/xmodule/combined_open_ended_module.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py
index f8ae7a3f13..faf22d1926 100644
--- a/common/lib/xmodule/xmodule/combined_open_ended_module.py
+++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py
@@ -248,28 +248,28 @@ class CombinedOpenEndedFields(object):
min_to_calibrate = Integer(
display_name="Minimum Peer Grading Calibrations",
help="The minimum number of calibration essays each student will need to complete for peer grading.",
- default=1,
+ default=3,
scope=Scope.settings,
values={"min" : 1, "step" : "1"}
)
max_to_calibrate = Integer(
display_name="Maximum Peer Grading Calibrations",
help="The maximum number of calibration essays each student will need to complete for peer grading.",
- default=1,
+ default=6,
scope=Scope.settings,
values={"max" : 20, "step" : "1"}
)
peer_grader_count = Integer(
display_name="Peer Graders per Response",
help="The number of peers who will grade each submission.",
- default=1,
+ default=3,
scope=Scope.settings,
values={"min" : 1, "step" : "1", "max" : 5}
)
required_peer_grading = Integer(
display_name="Required Peer Grading",
help="The number of other students each student making a submission will have to grade.",
- default=1,
+ default=3,
scope=Scope.settings,
values={"min" : 1, "step" : "1", "max" : 5}
)
From 6e41c082b11a89ec4f54fe5b8e362dd460dff570 Mon Sep 17 00:00:00 2001
From: Vik Paruchuri
Date: Fri, 9 Aug 2013 12:30:05 -0400
Subject: [PATCH 004/125] Fix tests
---
.../xmodule/tests/test_combined_open_ended.py | 12 ++++++++++++
1 file changed, 12 insertions(+)
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 4fd0ddccf7..268f8f0b69 100644
--- a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
+++ b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
@@ -64,6 +64,12 @@ class OpenEndedChildTest(unittest.TestCase):
's3_interface': "",
'open_ended_grading_interface': {},
'skip_basic_checks': False,
+ 'control': {
+ 'required_peer_grading': 1,
+ 'peer_grader_count': 1,
+ 'min_to_calibrate': 3,
+ 'max_to_calibrate': 6,
+ }
}
definition = Mock()
descriptor = Mock()
@@ -180,6 +186,12 @@ class OpenEndedModuleTest(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,
+ 'control': {
+ 'required_peer_grading': 1,
+ 'peer_grader_count': 1,
+ 'min_to_calibrate': 3,
+ 'max_to_calibrate': 6,
+ }
}
oeparam = etree.XML('''
From 8cf82f297371df61f93b346bf736b53daec9f381 Mon Sep 17 00:00:00 2001
From: Vik Paruchuri
Date: Fri, 9 Aug 2013 14:38:30 -0400
Subject: [PATCH 005/125] Fix self assessment test
---
common/lib/xmodule/xmodule/tests/test_self_assessment.py | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/common/lib/xmodule/xmodule/tests/test_self_assessment.py b/common/lib/xmodule/xmodule/tests/test_self_assessment.py
index 0ccc6864cd..c9140d643a 100644
--- a/common/lib/xmodule/xmodule/tests/test_self_assessment.py
+++ b/common/lib/xmodule/xmodule/tests/test_self_assessment.py
@@ -49,6 +49,12 @@ class SelfAssessmentTest(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,
+ 'control': {
+ 'required_peer_grading': 1,
+ 'peer_grader_count': 1,
+ 'min_to_calibrate': 3,
+ 'max_to_calibrate': 6,
+ }
}
self.module = SelfAssessmentModule(get_test_system(), self.location,
From 835edbf32f5554f1e5cb7829f0ce988f4ec8fa8d Mon Sep 17 00:00:00 2001
From: cahrens
Date: Fri, 9 Aug 2013 14:46:18 -0400
Subject: [PATCH 006/125] Change locators to a restful interface.
Don't use ; @ and # as separators.
---
.../xmodule/xmodule/modulestore/locator.py | 39 +++++-----
.../xmodule/xmodule/modulestore/parsers.py | 34 +++++----
.../modulestore/tests/test_locators.py | 74 ++++++++++++-------
3 files changed, 86 insertions(+), 61 deletions(-)
diff --git a/common/lib/xmodule/xmodule/modulestore/locator.py b/common/lib/xmodule/xmodule/modulestore/locator.py
index 591ef3115f..3e20f3e1b4 100644
--- a/common/lib/xmodule/xmodule/modulestore/locator.py
+++ b/common/lib/xmodule/xmodule/modulestore/locator.py
@@ -1,8 +1,7 @@
"""
-Created on Mar 13, 2013
+Identifier for course resources.
+"""
-@author: dmitchell
-"""
from __future__ import absolute_import
import logging
import inspect
@@ -15,6 +14,7 @@ from bson.errors import InvalidId
from xmodule.modulestore.exceptions import InsufficientSpecificationError, OverSpecificationError
from .parsers import parse_url, parse_course_id, parse_block_ref
+from .parsers import BRANCH_PREFIX, BLOCK_PREFIX, URL_VERSION_PREFIX
log = logging.getLogger(__name__)
@@ -37,9 +37,6 @@ class Locator(object):
"""
raise InsufficientSpecificationError()
- def quoted_url(self):
- return quote(self.url(), '@;#')
-
def __eq__(self, other):
return self.__dict__ == other.__dict__
@@ -90,11 +87,11 @@ class CourseLocator(Locator):
Examples of valid CourseLocator specifications:
CourseLocator(version_guid=ObjectId('519665f6223ebd6980884f2b'))
CourseLocator(course_id='mit.eecs.6002x')
- CourseLocator(course_id='mit.eecs.6002x;published')
+ CourseLocator(course_id='mit.eecs.6002x/branch/published')
CourseLocator(course_id='mit.eecs.6002x', branch='published')
- CourseLocator(url='edx://@519665f6223ebd6980884f2b')
+ CourseLocator(url='edx://version/519665f6223ebd6980884f2b')
CourseLocator(url='edx://mit.eecs.6002x')
- CourseLocator(url='edx://mit.eecs.6002x;published')
+ CourseLocator(url='edx://mit.eecs.6002x/branch/published')
Should have at lease a specific course_id (id for the course as if it were a project w/
versions) with optional 'branch',
@@ -115,10 +112,10 @@ class CourseLocator(Locator):
if self.course_id:
result = self.course_id
if self.branch:
- result += ';' + self.branch
+ result += BRANCH_PREFIX + self.branch
return result
elif self.version_guid:
- return '@' + str(self.version_guid)
+ return URL_VERSION_PREFIX + str(self.version_guid)
else:
# raise InsufficientSpecificationError("missing course_id or version_guid")
return ''
@@ -224,7 +221,7 @@ class CourseLocator(Locator):
"""
url must be a string beginning with 'edx://' and containing
either a valid version_guid or course_id (with optional branch)
- If a block ('#HW3') is present, it is ignored.
+ If a block ('/block/HW3') is present, it is ignored.
"""
if isinstance(url, Locator):
url = url.url()
@@ -253,14 +250,14 @@ class CourseLocator(Locator):
def init_from_course_id(self, course_id, explicit_branch=None):
"""
- Course_id is a string like 'mit.eecs.6002x' or 'mit.eecs.6002x;published'.
+ Course_id is a string like 'mit.eecs.6002x' or 'mit.eecs.6002x/branch/published'.
Revision (optional) is a string like 'published'.
It may be provided explicitly (explicit_branch) or embedded into course_id.
- If branch is part of course_id ("...;published"), parse it out separately.
+ If branch is part of course_id (".../branch/published"), parse it out separately.
If branch is provided both ways, that's ok as long as they are the same value.
- If a block ('#HW3') is a part of course_id, it is ignored.
+ If a block ('/block/HW3') is a part of course_id, it is ignored.
"""
@@ -411,9 +408,9 @@ class BlockUsageLocator(CourseLocator):
rep = CourseLocator.__unicode__(self)
if self.usage_id is None:
# usage_id has not been initialized
- return rep + '#NONE'
+ return rep + BLOCK_PREFIX + 'NONE'
else:
- return rep + '#' + self.usage_id
+ return rep + BLOCK_PREFIX + self.usage_id
class DescriptionLocator(Locator):
@@ -427,14 +424,14 @@ class DescriptionLocator(Locator):
def __unicode__(self):
'''
Return a string representing this location.
- unicode(self) returns something like this: "@519665f6223ebd6980884f2b"
+ unicode(self) returns something like this: "version/519665f6223ebd6980884f2b"
'''
- return '@' + str(self.definition_guid)
+ return URL_VERSION_PREFIX + str(self.definition_id)
def url(self):
"""
Return a string containing the URL for this location.
- url(self) returns something like this: 'edx://@519665f6223ebd6980884f2b'
+ url(self) returns something like this: 'edx://version/519665f6223ebd6980884f2b'
"""
return 'edx://' + unicode(self)
@@ -442,7 +439,7 @@ class DescriptionLocator(Locator):
"""
Returns the ObjectId referencing this specific location.
"""
- return self.definition_guid
+ return self.definition_id
class VersionTree(object):
diff --git a/common/lib/xmodule/xmodule/modulestore/parsers.py b/common/lib/xmodule/xmodule/modulestore/parsers.py
index 8e5b685cec..efdf1c9e18 100644
--- a/common/lib/xmodule/xmodule/modulestore/parsers.py
+++ b/common/lib/xmodule/xmodule/modulestore/parsers.py
@@ -1,5 +1,12 @@
import re
+# Prefix for the branch portion of a locator URL
+BRANCH_PREFIX = "/branch/"
+# Prefix for the block portion of a locator URL
+BLOCK_PREFIX = "/block/"
+# Prefix for when a course URL begins with a version ID
+URL_VERSION_PREFIX = 'version/'
+
URL_RE = re.compile(r'^edx://(.+)$', re.IGNORECASE)
@@ -9,10 +16,10 @@ def parse_url(string):
followed by either a version_guid or a course_id.
Examples:
- 'edx://@0123FFFF'
+ 'edx://version/0123FFFF'
'edx://edu.mit.eecs.6002x'
- 'edx://edu.mit.eecs.6002x;published'
- 'edx://edu.mit.eecs.6002x;published#HW3'
+ 'edx://edu.mit.eecs.6002x/branch/published'
+ 'edx://edu.mit.eecs.6002x/branch/published/block/HW3'
This returns None if string cannot be parsed.
@@ -27,8 +34,8 @@ def parse_url(string):
if not match:
return None
path = match.group(1)
- if path[0] == '@':
- return parse_guid(path[1:])
+ if path.startswith(URL_VERSION_PREFIX):
+ return parse_guid(path[len(URL_VERSION_PREFIX):])
return parse_course_id(path)
@@ -52,8 +59,7 @@ def parse_block_ref(string):
return None
-GUID_RE = re.compile(r'^(?P[A-F0-9]+)(#(?P\w+))?$', re.IGNORECASE)
-
+GUID_RE = re.compile(r'^(?P[A-F0-9]+)(' + BLOCK_PREFIX + '(?P\w+))?$', re.IGNORECASE)
def parse_guid(string):
"""
@@ -69,27 +75,27 @@ def parse_guid(string):
return None
-COURSE_ID_RE = re.compile(r'^(?P(\w+)(\.\w+\w*)*)(;(?P\w+))?(#(?P\w+))?$', re.IGNORECASE)
+COURSE_ID_RE = re.compile(r'^(?P(\w+)(\.\w+\w*)*)('+ BRANCH_PREFIX + '(?P\w+))?(' + BLOCK_PREFIX + '(?P\w+))?$', re.IGNORECASE)
def parse_course_id(string):
r"""
A course_id has a main id component.
- There may also be an optional branch (;published or ;draft).
- There may also be an optional block (#HW3 or #Quiz2).
+ There may also be an optional branch (/branch/published or /branch/draft).
+ There may also be an optional block (/block/HW3 or /block/Quiz2).
Examples of valid course_ids:
'edu.mit.eecs.6002x'
- 'edu.mit.eecs.6002x;published'
- 'edu.mit.eecs.6002x#HW3'
- 'edu.mit.eecs.6002x;published#HW3'
+ 'edu.mit.eecs.6002x/branch/published'
+ 'edu.mit.eecs.6002x/block/HW3'
+ 'edu.mit.eecs.6002x/branch/published/block/HW3'
Syntax:
- course_id = main_id [; branch] [# block]
+ course_id = main_id [/branch/ branch] [/block/ block]
main_id = name [. name]*
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py b/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py
index bb41131234..0f39a4c66f 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py
@@ -1,12 +1,11 @@
-'''
-Created on Mar 14, 2013
-
-@author: dmitchell
-'''
+"""
+Tests for xmodule.modulestore.locator.
+"""
from unittest import TestCase
from bson.objectid import ObjectId
-from xmodule.modulestore.locator import Locator, CourseLocator, BlockUsageLocator
+from xmodule.modulestore.locator import Locator, CourseLocator, BlockUsageLocator, DescriptionLocator
+from xmodule.modulestore.parsers import BRANCH_PREFIX, BLOCK_PREFIX, URL_VERSION_PREFIX
from xmodule.modulestore.exceptions import InsufficientSpecificationError, OverSpecificationError
@@ -32,12 +31,12 @@ class LocatorTest(TestCase):
self.assertRaises(
OverSpecificationError,
CourseLocator,
- url='edx://mit.eecs.6002x;published',
+ url='edx://mit.eecs.6002x' + BRANCH_PREFIX + 'published',
branch='draft')
self.assertRaises(
OverSpecificationError,
CourseLocator,
- course_id='mit.eecs.6002x;published',
+ course_id='mit.eecs.6002x' + BRANCH_PREFIX + 'published',
branch='draft')
def test_course_constructor_underspecified(self):
@@ -55,8 +54,8 @@ class LocatorTest(TestCase):
testobj_1 = CourseLocator(version_guid=test_id_1)
self.check_course_locn_fields(testobj_1, 'version_guid', version_guid=test_id_1)
self.assertEqual(str(testobj_1.version_guid), test_id_1_loc)
- self.assertEqual(str(testobj_1), '@' + test_id_1_loc)
- self.assertEqual(testobj_1.url(), 'edx://@' + test_id_1_loc)
+ self.assertEqual(str(testobj_1), URL_VERSION_PREFIX + test_id_1_loc)
+ self.assertEqual(testobj_1.url(), 'edx://' + URL_VERSION_PREFIX + test_id_1_loc)
# Test using a given string
test_id_2_loc = '519665f6223ebd6980884f2b'
@@ -64,8 +63,8 @@ class LocatorTest(TestCase):
testobj_2 = CourseLocator(version_guid=test_id_2)
self.check_course_locn_fields(testobj_2, 'version_guid', version_guid=test_id_2)
self.assertEqual(str(testobj_2.version_guid), test_id_2_loc)
- self.assertEqual(str(testobj_2), '@' + test_id_2_loc)
- self.assertEqual(testobj_2.url(), 'edx://@' + test_id_2_loc)
+ self.assertEqual(str(testobj_2), URL_VERSION_PREFIX + test_id_2_loc)
+ self.assertEqual(testobj_2.url(), 'edx://'+ URL_VERSION_PREFIX + test_id_2_loc)
def test_course_constructor_bad_course_id(self):
"""
@@ -74,20 +73,20 @@ class LocatorTest(TestCase):
for bad_id in ('mit.',
' mit.eecs',
'mit.eecs ',
- '@mit.eecs',
- '#mit.eecs',
+ URL_VERSION_PREFIX + 'mit.eecs',
+ BLOCK_PREFIX + 'block/mit.eecs',
'mit.ee cs',
'mit.ee,cs',
'mit.ee/cs',
'mit.ee$cs',
'mit.ee&cs',
'mit.ee()cs',
- ';this',
- 'mit.eecs;',
- 'mit.eecs;this;that',
- 'mit.eecs;this;',
- 'mit.eecs;this ',
- 'mit.eecs;th%is ',
+ BRANCH_PREFIX + 'this',
+ 'mit.eecs' + BRANCH_PREFIX,
+ 'mit.eecs' + BRANCH_PREFIX + 'this' + BRANCH_PREFIX +'that',
+ 'mit.eecs' + BRANCH_PREFIX + 'this' + BRANCH_PREFIX ,
+ 'mit.eecs' + BRANCH_PREFIX + 'this ',
+ 'mit.eecs' + BRANCH_PREFIX + 'th%is ',
):
self.assertRaises(AssertionError, CourseLocator, course_id=bad_id)
self.assertRaises(AssertionError, CourseLocator, url='edx://' + bad_id)
@@ -106,7 +105,7 @@ class LocatorTest(TestCase):
self.check_course_locn_fields(testobj, 'course_id', course_id=testurn)
def test_course_constructor_redundant_002(self):
- testurn = 'mit.eecs.6002x;published'
+ testurn = 'mit.eecs.6002x' + BRANCH_PREFIX + 'published'
expected_urn = 'mit.eecs.6002x'
expected_rev = 'published'
testobj = CourseLocator(course_id=testurn, url='edx://' + testurn)
@@ -114,6 +113,17 @@ class LocatorTest(TestCase):
course_id=expected_urn,
branch=expected_rev)
+ def test_course_constructor_url(self):
+ # Test parsing a url when it starts with a version ID and there is also a block ID.
+ # This hits the parsers parse_guid method.
+ test_id_loc = '519665f6223ebd6980884f2b'
+ testobj = CourseLocator(url="edx://" + URL_VERSION_PREFIX + test_id_loc + BLOCK_PREFIX + "hw3")
+ self.check_course_locn_fields(
+ testobj,
+ 'test_block constructor',
+ version_guid=ObjectId(test_id_loc)
+ )
+
def test_course_constructor_course_id_no_branch(self):
testurn = 'mit.eecs.6002x'
testobj = CourseLocator(course_id=testurn)
@@ -123,7 +133,7 @@ class LocatorTest(TestCase):
self.assertEqual(testobj.url(), 'edx://' + testurn)
def test_course_constructor_course_id_with_branch(self):
- testurn = 'mit.eecs.6002x;published'
+ testurn = 'mit.eecs.6002x' + BRANCH_PREFIX + 'published'
expected_id = 'mit.eecs.6002x'
expected_branch = 'published'
testobj = CourseLocator(course_id=testurn)
@@ -139,7 +149,7 @@ class LocatorTest(TestCase):
def test_course_constructor_course_id_separate_branch(self):
test_id = 'mit.eecs.6002x'
test_branch = 'published'
- expected_urn = 'mit.eecs.6002x;published'
+ expected_urn = 'mit.eecs.6002x' + BRANCH_PREFIX + 'published'
testobj = CourseLocator(course_id=test_id, branch=test_branch)
self.check_course_locn_fields(testobj, 'course_id with separate branch',
course_id=test_id,
@@ -154,10 +164,10 @@ class LocatorTest(TestCase):
"""
The same branch appears in the course_id and the branch field.
"""
- test_id = 'mit.eecs.6002x;published'
+ test_id = 'mit.eecs.6002x' + BRANCH_PREFIX + 'published'
test_branch = 'published'
expected_id = 'mit.eecs.6002x'
- expected_urn = 'mit.eecs.6002x;published'
+ expected_urn = test_id
testobj = CourseLocator(course_id=test_id, branch=test_branch)
self.check_course_locn_fields(testobj, 'course_id with repeated branch',
course_id=expected_id,
@@ -169,7 +179,7 @@ class LocatorTest(TestCase):
self.assertEqual(testobj.url(), 'edx://' + expected_urn)
def test_block_constructor(self):
- testurn = 'mit.eecs.6002x;published#HW3'
+ testurn = 'mit.eecs.6002x' + BRANCH_PREFIX + 'published' + BLOCK_PREFIX + 'HW3'
expected_id = 'mit.eecs.6002x'
expected_branch = 'published'
expected_block_ref = 'HW3'
@@ -181,6 +191,18 @@ class LocatorTest(TestCase):
self.assertEqual(str(testobj), testurn)
self.assertEqual(testobj.url(), 'edx://' + testurn)
+ def test_repr(self):
+ testurn = 'mit.eecs.6002x' + BRANCH_PREFIX + 'published' + BLOCK_PREFIX + 'HW3'
+ testobj = BlockUsageLocator(course_id=testurn)
+ self.assertEqual('BlockUsageLocator("mit.eecs.6002x/branch/published/block/HW3")', repr(testobj))
+
+ def test_description_locator_url(self):
+ definition_locator=DescriptionLocator("chapter12345_2")
+ self.assertEqual('edx://' + URL_VERSION_PREFIX + 'chapter12345_2', definition_locator.url())
+
+ def test_description_locator_version(self):
+ definition_locator=DescriptionLocator("chapter12345_2")
+ self.assertEqual("chapter12345_2", definition_locator.version())
# ------------------------------------------------------------------
# Utilities
From 5c9538a9a112129d9d44551e23bdcd2cad35949c Mon Sep 17 00:00:00 2001
From: Vik Paruchuri
Date: Fri, 9 Aug 2013 16:46:25 -0400
Subject: [PATCH 007/125] Address review comments
---
.../xmodule/xmodule/combined_open_ended_module.py | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py
index faf22d1926..2856c98127 100644
--- a/common/lib/xmodule/xmodule/combined_open_ended_module.py
+++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py
@@ -13,9 +13,11 @@ import textwrap
log = logging.getLogger("mitx.courseware")
-V1_SETTINGS_ATTRIBUTES = ["display_name", "max_attempts", "graded", "accept_file_upload",
- "skip_spelling_checks", "due", "graceperiod", "weight", "min_to_calibrate",
- "max_to_calibrate", "peer_grader_count", "required_peer_grading"]
+V1_SETTINGS_ATTRIBUTES = [
+ "display_name", "max_attempts", "graded", "accept_file_upload",
+ "skip_spelling_checks", "due", "graceperiod", "weight", "min_to_calibrate",
+ "max_to_calibrate", "peer_grader_count", "required_peer_grading",
+]
V1_STUDENT_ATTRIBUTES = ["current_task_number", "task_states", "state",
"student_attempts", "ready_to_reset"]
@@ -250,14 +252,14 @@ class CombinedOpenEndedFields(object):
help="The minimum number of calibration essays each student will need to complete for peer grading.",
default=3,
scope=Scope.settings,
- values={"min" : 1, "step" : "1"}
+ values={"min" : 1, "max" : 20, "step" : "1"}
)
max_to_calibrate = Integer(
display_name="Maximum Peer Grading Calibrations",
help="The maximum number of calibration essays each student will need to complete for peer grading.",
default=6,
scope=Scope.settings,
- values={"max" : 20, "step" : "1"}
+ values={"min" : 1, "max" : 20, "step" : "1"}
)
peer_grader_count = Integer(
display_name="Peer Graders per Response",
From 8fa4b4dbd1e0ce95845f3464ca5684e3ed53e88f Mon Sep 17 00:00:00 2001
From: Peter Fogg
Date: Tue, 6 Aug 2013 16:59:15 -0400
Subject: [PATCH 008/125] Change course create form to synchronous validation.
---
.../contentstore/features/courses.feature | 2 +-
cms/static/js/base.js | 157 +++++++++++-------
cms/static/sass/elements/_forms.scss | 1 -
cms/templates/index.html | 2 +-
4 files changed, 102 insertions(+), 60 deletions(-)
diff --git a/cms/djangoapps/contentstore/features/courses.feature b/cms/djangoapps/contentstore/features/courses.feature
index 455313b0e2..a0ba8099ac 100644
--- a/cms/djangoapps/contentstore/features/courses.feature
+++ b/cms/djangoapps/contentstore/features/courses.feature
@@ -8,6 +8,6 @@ Feature: Create Course
And I am logged into Studio
When I click the New Course button
And I fill in the new course information
- And I press the "Save" button
+ And I press the "Create" button
Then the Courseware page has loaded in Studio
And I see a link for adding a new section
diff --git a/cms/static/js/base.js b/cms/static/js/base.js
index 80b24776da..72ef5991a3 100644
--- a/cms/static/js/base.js
+++ b/cms/static/js/base.js
@@ -605,80 +605,118 @@ function cancelNewSection(e) {
function addNewCourse(e) {
e.preventDefault();
$('.new-course-button').addClass('is-disabled');
+ $('.new-course-save').addClass('is-disabled');
var $newCourse = $('.wrapper-create-course').addClass('is-shown');
var $cancelButton = $newCourse.find('.new-course-cancel');
- $newCourse.find('.new-course-name').focus().select();
- $newCourse.find('form').bind('submit', saveNewCourse);
+ var $courseName = $('.new-course-name');
+ $courseName.focus().select();
+ $('.new-course-save').on('click', saveNewCourse);
$cancelButton.bind('click', cancelNewCourse);
$body.bind('keyup', {
$cancelButton: $cancelButton
}, checkForCancel);
+ // Handle validation asynchronously
+ _.each(
+ ['.new-course-org', '.new-course-number', '.new-course-run'],
+ function(ele) {
+ var $ele = $(ele);
+ $ele.on('keyup', function(event) {
+ // Don't bother showing "required field" error when
+ // the user tabs into a new field; this is distracting
+ // and unnecessary
+ if(event.keyCode === 9) {
+ return;
+ }
+ var error = validateCourseItemEncoding($ele.val());
+ setNewCourseFieldInErr($ele.parent('li'), error);
+ validateTotalCourseItemsLength();
+ });
+ }
+ );
+ var $name = $('.new-course-name');
+ $name.on('keyup', function() {
+ var error = validateCourseName($name.val());
+ setNewCourseFieldInErr($name.parent('li'), error);
+ validateTotalCourseItemsLength();
+ });
+}
+
+function setNewCourseFieldInErr(el, msg) {
+ el.children('.tip-error').remove();
+ if(msg) {
+ el.addClass('error');
+ el.append('' + msg + '');
+ $('.new-course-save').addClass('is-disabled');
+ }
+ else {
+ el.removeClass('error');
+ // One "error" div is always present, but hidden or shown
+ if($('.error').length === 1) {
+ $('.new-course-save').removeClass('is-disabled');
+ }
+ }
+};
+
+function validateCourseName(name) {
+ if(name.length === 0) {
+ return gettext('Required field.');
+ }
+ return '';
+}
+
+// Check that a course (org, number, run) doesn't use any special characters
+function validateCourseItemEncoding(item) {
+ if(item === '') {
+ return gettext('Required field.');
+ }
+ if(item !== encodeURIComponent(item)) {
+ return gettext('Please do not use any spaces or special characters in this field.');
+ }
+ return '';
+}
+
+// Ensure that all items are less than 80 characters.
+function validateTotalCourseItemsLength() {
+ var totalLength = _.reduce(
+ ['.new-course-name', '.new-course-org', '.new-course-number', '.new-course-run'],
+ function(sum, ele) {
+ return sum + $(ele).val().length;
+ }, 0
+ );
+ if(totalLength > 80) {
+ $('.wrap-error').addClass('is-shown');
+ $('#course_creation_error').html('
' + gettext('Course fields must have a combined length of no more than 80 characters.') + '
');
+ $('.new-course-save').addClass('is-disabled');
+ }
+ else {
+ $('.wrap-error').removeClass('is-shown');
+ }
}
function saveNewCourse(e) {
e.preventDefault();
+ // One final check for empty values
+ var errors = _.reduce(
+ ['.new-course-name', '.new-course-org', '.new-course-number', '.new-course-run'],
+ function(acc, ele) {
+ var $ele = $(ele);
+ var error = $ele.val().length === 0 ? gettext('Required field.') : '';
+ setNewCourseFieldInErr($ele.parent('li'), error);
+ return error ? true : acc;
+ },
+ false
+ );
+
var $newCourseForm = $(this).closest('#create-course-form');
var display_name = $newCourseForm.find('.new-course-name').val();
var org = $newCourseForm.find('.new-course-org').val();
var number = $newCourseForm.find('.new-course-number').val();
var run = $newCourseForm.find('.new-course-run').val();
- var required_field_text = gettext('Required field');
-
- var display_name_errMsg = (display_name === '') ? required_field_text : null;
- var org_errMsg = (org === '') ? required_field_text : null;
- var number_errMsg = (number === '') ? required_field_text : null;
- var run_errMsg = (run === '') ? required_field_text : null;
-
- var bInErr = (display_name_errMsg || org_errMsg || number_errMsg || run_errMsg);
-
- // check for suitable encoding
- if (!bInErr) {
- var encoding_errMsg = gettext('Please do not use any spaces or special characters in this field.');
-
- if (encodeURIComponent(org) != org)
- org_errMsg = encoding_errMsg;
- if (encodeURIComponent(number) != number)
- number_errMsg = encoding_errMsg;
- if (encodeURIComponent(run) != run)
- run_errMsg = encoding_errMsg;
-
- bInErr = (org_errMsg || number_errMsg || run_errMsg);
- }
-
- var header_err_msg = (bInErr) ? gettext('Please correct the fields below.') : null;
-
- var setNewCourseErrMsgs = function(header_err_msg, display_name_errMsg, org_errMsg, number_errMsg, run_errMsg) {
- if (header_err_msg) {
- $('.wrapper-create-course').addClass('has-errors');
- $('.wrap-error').addClass('is-shown');
- $('#course_creation_error').html('
From 05e1ffb190df99b84e03926f18df928e12d1525b Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Mon, 12 Aug 2013 12:49:15 -0400
Subject: [PATCH 009/125] remove unused code from xml_import.py
---
.../xmodule/modulestore/xml_importer.py | 54 -------------------
1 file changed, 54 deletions(-)
diff --git a/common/lib/xmodule/xmodule/modulestore/xml_importer.py b/common/lib/xmodule/xmodule/modulestore/xml_importer.py
index 0b30a884be..fa9228bed3 100644
--- a/common/lib/xmodule/xmodule/modulestore/xml_importer.py
+++ b/common/lib/xmodule/xmodule/modulestore/xml_importer.py
@@ -490,57 +490,3 @@ def perform_xlint(data_dir, course_dirs,
print "This course can be imported successfully."
return err_cnt
-
-
-#
-# UNSURE IF THIS IS UNUSED CODE - IF SO NEEDS TO BE PRUNED. TO BE INVESTIGATED.
-#
-def import_module_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace=None, verbose=False):
- # remap module to the new namespace
- if target_location_namespace is not None:
- # This looks a bit wonky as we need to also change the 'name' of the imported course to be what
- # the caller passed in
- if module.location.category != 'course':
- module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
- course=target_location_namespace.course)
- else:
- module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
- course=target_location_namespace.course, name=target_location_namespace.name)
-
- # then remap children pointers since they too will be re-namespaced
- if module.has_children:
- children_locs = module.children
- new_locs = []
- for child in children_locs:
- child_loc = Location(child)
- new_child_loc = child_loc._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
- course=target_location_namespace.course)
-
- new_locs.append(new_child_loc.url())
-
- module.children = new_locs
-
- if hasattr(module, 'data'):
- modulestore.update_item(module.location, module.data)
-
- if module.has_children:
- modulestore.update_children(module.location, module.children)
-
- modulestore.update_metadata(module.location, own_metadata(module))
-
-
-def import_course_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace=None, verbose=False):
- # CDODGE: Is this unused code (along with import_module_from_xml)? I can't find any references to it. If so, then
- # we need to delete this apparently duplicate code.
-
- # cdodge: more hacks (what else). Seems like we have a problem when importing a course (like 6.002) which
- # does not have any tabs defined in the policy file. The import goes fine and then displays fine in LMS,
- # but if someone tries to add a new tab in the CMS, then the LMS barfs because it expects that -
- # if there is *any* tabs - then there at least needs to be some predefined ones
- if module.tabs is None or len(module.tabs) == 0:
- module.tabs = [{"type": "courseware"},
- {"type": "course_info", "name": "Course Info"},
- {"type": "discussion", "name": "Discussion"},
- {"type": "wiki", "name": "Wiki"}] # note, add 'progress' when we can support it on Edge
-
- import_module_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace, verbose=verbose)
From 431eb8f4e7896d0aac32fc63bb1aba519def304c Mon Sep 17 00:00:00 2001
From: Adam Palay
Date: Mon, 12 Aug 2013 14:03:27 -0400
Subject: [PATCH 010/125] remove 'preview' from urls in enrollment emails sent
while previewing a course
---
lms/djangoapps/instructor/views/legacy.py | 16 ++++++++++++----
1 file changed, 12 insertions(+), 4 deletions(-)
diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py
index 6a02b5be7d..42f399c143 100644
--- a/lms/djangoapps/instructor/views/legacy.py
+++ b/lms/djangoapps/instructor/views/legacy.py
@@ -1119,13 +1119,14 @@ def _do_enroll_students(course, course_id, students, overload=False, auto_enroll
ceaset.delete()
if email_students:
- registration_url = 'https://' + settings.SITE_NAME + reverse('student.views.register_user')
+ stripped_site_name = _remove_preview(settings.SITE_NAME)
+ registration_url = 'https://' + stripped_site_name + reverse('student.views.register_user')
#Composition of email
- d = {'site_name': settings.SITE_NAME,
+ d = {'site_name': stripped_site_name,
'registration_url': registration_url,
'course_id': course_id,
'auto_enroll': auto_enroll,
- 'course_url': 'https://' + settings.SITE_NAME + '/courses/' + course_id,
+ 'course_url': 'https://' + stripped_site_name + '/courses/' + course_id,
}
for student in new_students:
@@ -1209,9 +1210,10 @@ def _do_unenroll_students(course_id, students, email_students=False):
old_students, _ = get_and_clean_student_list(students)
status = dict([x, 'unprocessed'] for x in old_students)
+ stripped_site_name = _remove_preview(settings.SITE_NAME)
if email_students:
#Composition of email
- d = {'site_name': settings.SITE_NAME,
+ d = {'site_name': stripped_site_name,
'course_id': course_id}
for student in old_students:
@@ -1301,6 +1303,12 @@ def send_mail_to_student(student, param_dict):
return False
+def _remove_preview(site_name):
+ if site_name[:8] == "preview.":
+ return site_name[8:]
+ return site_name
+
+
def get_and_clean_student_list(students):
"""
Separate out individual student email from the comma, or space separated string.
From 98a47857b3f95749b55092b0da9336320613e3ff Mon Sep 17 00:00:00 2001
From: cahrens
Date: Mon, 12 Aug 2013 13:38:31 -0400
Subject: [PATCH 011/125] Allow version ID to appear after course ID.
cleanup
---
.../xmodule/xmodule/modulestore/locator.py | 38 +++++++------
.../xmodule/xmodule/modulestore/parsers.py | 21 +++++--
.../modulestore/tests/test_locators.py | 55 +++++++++++++++++--
3 files changed, 86 insertions(+), 28 deletions(-)
diff --git a/common/lib/xmodule/xmodule/modulestore/locator.py b/common/lib/xmodule/xmodule/modulestore/locator.py
index 3e20f3e1b4..bdc9e61fce 100644
--- a/common/lib/xmodule/xmodule/modulestore/locator.py
+++ b/common/lib/xmodule/xmodule/modulestore/locator.py
@@ -92,6 +92,7 @@ class CourseLocator(Locator):
CourseLocator(url='edx://version/519665f6223ebd6980884f2b')
CourseLocator(url='edx://mit.eecs.6002x')
CourseLocator(url='edx://mit.eecs.6002x/branch/published')
+ CourseLocator(url='edx://mit.eecs.6002x/branch/published/version/519665f6223ebd6980884f2b')
Should have at lease a specific course_id (id for the course as if it were a project w/
versions) with optional 'branch',
@@ -220,21 +221,18 @@ class CourseLocator(Locator):
def init_from_url(self, url):
"""
url must be a string beginning with 'edx://' and containing
- either a valid version_guid or course_id (with optional branch)
- If a block ('/block/HW3') is present, it is ignored.
+ either a valid version_guid or course_id (with optional branch), or both.
"""
if isinstance(url, Locator):
url = url.url()
- assert isinstance(url, basestring), \
- '%s is not an instance of basestring' % url
+ assert isinstance(url, basestring), '%s is not an instance of basestring' % url
parse = parse_url(url)
assert parse, 'Could not parse "%s" as a url' % url
- if 'version_guid' in parse:
- new_guid = parse['version_guid']
- self.set_version_guid(self.as_object_id(new_guid))
- else:
- self.set_course_id(parse['id'])
- self.set_branch(parse['branch'])
+ self._set_value(
+ parse, 'version_guid', lambda (new_guid): self.set_version_guid(self.as_object_id(new_guid))
+ )
+ self._set_value(parse, 'id', lambda (new_id): self.set_course_id(new_id))
+ self._set_value(parse, 'branch', lambda (new_branch): self.set_branch(new_branch))
def init_from_version_guid(self, version_guid):
"""
@@ -292,6 +290,16 @@ class CourseLocator(Locator):
"""
return self.course_id
+ def _set_value(self, parse, key, setter):
+ """
+ Helper method that gets a value out of the dict returned by parse,
+ and then sets the corresponding bit of information in this locator
+ (via the supplied lambda 'setter'), unless the value is None.
+ """
+ value = parse.get(key, None)
+ if value:
+ setter(value)
+
class BlockUsageLocator(CourseLocator):
"""
@@ -387,9 +395,7 @@ class BlockUsageLocator(CourseLocator):
url = url.url()
parse = parse_url(url)
assert parse, 'Could not parse "%s" as a url' % url
- block = parse.get('block', None)
- if block:
- self.set_usage_id(block)
+ self._set_value(parse, 'block', lambda(new_block): self.set_usage_id(new_block))
def init_block_ref_from_course_id(self, course_id):
if isinstance(course_id, CourseLocator):
@@ -397,9 +403,7 @@ class BlockUsageLocator(CourseLocator):
assert course_id, "%s does not have a valid course_id"
parse = parse_course_id(course_id)
assert parse, 'Could not parse "%s" as a course_id' % course_id
- block = parse.get('block', None)
- if block:
- self.set_usage_id(block)
+ self._set_value(parse, 'block', lambda(new_block): self.set_usage_id(new_block))
def __unicode__(self):
"""
@@ -415,7 +419,7 @@ class BlockUsageLocator(CourseLocator):
class DescriptionLocator(Locator):
"""
- Container for how to locate a description
+ Container for how to locate a description (the course-independent content).
"""
def __init__(self, definition_id):
diff --git a/common/lib/xmodule/xmodule/modulestore/parsers.py b/common/lib/xmodule/xmodule/modulestore/parsers.py
index efdf1c9e18..9308894b86 100644
--- a/common/lib/xmodule/xmodule/modulestore/parsers.py
+++ b/common/lib/xmodule/xmodule/modulestore/parsers.py
@@ -4,7 +4,9 @@ import re
BRANCH_PREFIX = "/branch/"
# Prefix for the block portion of a locator URL
BLOCK_PREFIX = "/block/"
-# Prefix for when a course URL begins with a version ID
+# Prefix for the version portion of a locator URL, when it is preceded by a course ID
+VERSION_PREFIX = "/version/"
+# Prefix for version when it begins the URL (no course ID).
URL_VERSION_PREFIX = 'version/'
URL_RE = re.compile(r'^edx://(.+)$', re.IGNORECASE)
@@ -19,15 +21,16 @@ def parse_url(string):
'edx://version/0123FFFF'
'edx://edu.mit.eecs.6002x'
'edx://edu.mit.eecs.6002x/branch/published'
+ 'edx://edu.mit.eecs.6002x/branch/published/version/519665f6223ebd6980884f2b/block/HW3'
'edx://edu.mit.eecs.6002x/branch/published/block/HW3'
This returns None if string cannot be parsed.
- If it can be parsed as a version_guid, returns a dict
+ If it can be parsed as a version_guid with no preceding course_id, returns a dict
with key 'version_guid' and the value,
If it can be parsed as a course_id, returns a dict
- with keys 'id' and 'branch' (value of 'branch' may be None),
+ with key 'id' and optional keys 'branch' and 'version_guid'.
"""
match = URL_RE.match(string)
@@ -61,6 +64,7 @@ def parse_block_ref(string):
GUID_RE = re.compile(r'^(?P[A-F0-9]+)(' + BLOCK_PREFIX + '(?P\w+))?$', re.IGNORECASE)
+
def parse_guid(string):
"""
A version_guid is a string of hex digits (0-F).
@@ -75,7 +79,12 @@ def parse_guid(string):
return None
-COURSE_ID_RE = re.compile(r'^(?P(\w+)(\.\w+\w*)*)('+ BRANCH_PREFIX + '(?P\w+))?(' + BLOCK_PREFIX + '(?P\w+))?$', re.IGNORECASE)
+COURSE_ID_RE = re.compile(
+ r'^(?P(\w+)(\.\w+\w*)*)(' +
+ BRANCH_PREFIX + '(?P\w+))?(' +
+ VERSION_PREFIX + '(?P[A-F0-9]+))?(' +
+ BLOCK_PREFIX + '(?P\w+))?$', re.IGNORECASE
+)
def parse_course_id(string):
@@ -83,6 +92,7 @@ def parse_course_id(string):
A course_id has a main id component.
There may also be an optional branch (/branch/published or /branch/draft).
+ There may also be an optional version (/version/519665f6223ebd6980884f2b).
There may also be an optional block (/block/HW3 or /block/Quiz2).
Examples of valid course_ids:
@@ -91,11 +101,12 @@ def parse_course_id(string):
'edu.mit.eecs.6002x/branch/published'
'edu.mit.eecs.6002x/block/HW3'
'edu.mit.eecs.6002x/branch/published/block/HW3'
+ 'edu.mit.eecs.6002x/branch/published/version/519665f6223ebd6980884f2b/block/HW3'
Syntax:
- course_id = main_id [/branch/ branch] [/block/ block]
+ course_id = main_id [/branch/ branch] [/version/ version ] [/block/ block]
main_id = name [. name]*
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py b/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py
index 0f39a4c66f..654de26db4 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py
@@ -5,11 +5,14 @@ from unittest import TestCase
from bson.objectid import ObjectId
from xmodule.modulestore.locator import Locator, CourseLocator, BlockUsageLocator, DescriptionLocator
-from xmodule.modulestore.parsers import BRANCH_PREFIX, BLOCK_PREFIX, URL_VERSION_PREFIX
+from xmodule.modulestore.parsers import BRANCH_PREFIX, BLOCK_PREFIX, VERSION_PREFIX, URL_VERSION_PREFIX
from xmodule.modulestore.exceptions import InsufficientSpecificationError, OverSpecificationError
class LocatorTest(TestCase):
+ """
+ Tests for subclasses of Locator.
+ """
def test_cant_instantiate_abstract_class(self):
self.assertRaises(TypeError, Locator)
@@ -64,7 +67,7 @@ class LocatorTest(TestCase):
self.check_course_locn_fields(testobj_2, 'version_guid', version_guid=test_id_2)
self.assertEqual(str(testobj_2.version_guid), test_id_2_loc)
self.assertEqual(str(testobj_2), URL_VERSION_PREFIX + test_id_2_loc)
- self.assertEqual(testobj_2.url(), 'edx://'+ URL_VERSION_PREFIX + test_id_2_loc)
+ self.assertEqual(testobj_2.url(), 'edx://' + URL_VERSION_PREFIX + test_id_2_loc)
def test_course_constructor_bad_course_id(self):
"""
@@ -83,8 +86,8 @@ class LocatorTest(TestCase):
'mit.ee()cs',
BRANCH_PREFIX + 'this',
'mit.eecs' + BRANCH_PREFIX,
- 'mit.eecs' + BRANCH_PREFIX + 'this' + BRANCH_PREFIX +'that',
- 'mit.eecs' + BRANCH_PREFIX + 'this' + BRANCH_PREFIX ,
+ 'mit.eecs' + BRANCH_PREFIX + 'this' + BRANCH_PREFIX + 'that',
+ 'mit.eecs' + BRANCH_PREFIX + 'this' + BRANCH_PREFIX,
'mit.eecs' + BRANCH_PREFIX + 'this ',
'mit.eecs' + BRANCH_PREFIX + 'th%is ',
):
@@ -124,6 +127,21 @@ class LocatorTest(TestCase):
version_guid=ObjectId(test_id_loc)
)
+ def test_course_constructor_url_course_id_and_version_guid(self):
+ test_id_loc = '519665f6223ebd6980884f2b'
+ testobj = CourseLocator(url='edx://mit.eecs.6002x' + VERSION_PREFIX + test_id_loc)
+ self.check_course_locn_fields(testobj, 'error parsing url with both course ID and version GUID',
+ course_id='mit.eecs.6002x',
+ version_guid=ObjectId(test_id_loc))
+
+ def test_course_constructor_url_course_id_branch_and_version_guid(self):
+ test_id_loc = '519665f6223ebd6980884f2b'
+ testobj = CourseLocator(url='edx://mit.eecs.6002x' + BRANCH_PREFIX + 'draft' + VERSION_PREFIX + test_id_loc)
+ self.check_course_locn_fields(testobj, 'error parsing url with both course ID branch, and version GUID',
+ course_id='mit.eecs.6002x',
+ branch='draft',
+ version_guid=ObjectId(test_id_loc))
+
def test_course_constructor_course_id_no_branch(self):
testurn = 'mit.eecs.6002x'
testobj = CourseLocator(course_id=testurn)
@@ -191,17 +209,42 @@ class LocatorTest(TestCase):
self.assertEqual(str(testobj), testurn)
self.assertEqual(testobj.url(), 'edx://' + testurn)
+ def test_block_constructor_url_version_prefix(self):
+ test_id_loc = '519665f6223ebd6980884f2b'
+ testobj = BlockUsageLocator(
+ url='edx://mit.eecs.6002x' + VERSION_PREFIX + test_id_loc + BLOCK_PREFIX + 'lab2'
+ )
+ self.check_block_locn_fields(
+ testobj, 'error parsing URL with version and block',
+ course_id='mit.eecs.6002x',
+ block='lab2',
+ version_guid=ObjectId(test_id_loc)
+ )
+
+ def test_block_constructor_url_kitchen_sink(self):
+ test_id_loc = '519665f6223ebd6980884f2b'
+ testobj = BlockUsageLocator(
+ url='edx://mit.eecs.6002x' + BRANCH_PREFIX + 'draft' + VERSION_PREFIX + test_id_loc + BLOCK_PREFIX + 'lab2'
+ )
+ self.check_block_locn_fields(
+ testobj, 'error parsing URL with branch, version, and block',
+ course_id='mit.eecs.6002x',
+ branch='draft',
+ block='lab2',
+ version_guid=ObjectId(test_id_loc)
+ )
+
def test_repr(self):
testurn = 'mit.eecs.6002x' + BRANCH_PREFIX + 'published' + BLOCK_PREFIX + 'HW3'
testobj = BlockUsageLocator(course_id=testurn)
self.assertEqual('BlockUsageLocator("mit.eecs.6002x/branch/published/block/HW3")', repr(testobj))
def test_description_locator_url(self):
- definition_locator=DescriptionLocator("chapter12345_2")
+ definition_locator = DescriptionLocator("chapter12345_2")
self.assertEqual('edx://' + URL_VERSION_PREFIX + 'chapter12345_2', definition_locator.url())
def test_description_locator_version(self):
- definition_locator=DescriptionLocator("chapter12345_2")
+ definition_locator = DescriptionLocator("chapter12345_2")
self.assertEqual("chapter12345_2", definition_locator.version())
# ------------------------------------------------------------------
From 25abce865f2ad4223508bc149f01770d04c27432 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Mon, 12 Aug 2013 14:39:59 -0400
Subject: [PATCH 012/125] add changelog information on two features being
deployed
---
CHANGELOG.rst | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 897ea3ae3a..8d04c44d88 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -222,6 +222,10 @@ XModules: Added "randomize" XModule to list of XModule types.
XModules: Show errors with full descriptors.
+Studio: Add feedback to end user if there is a problem exporting a course
+
+Studio: Improve link re-writing on imports into a different course-id
+
XQueue: Fixed (hopefully) worker crash when the connection to RabbitMQ is
dropped suddenly.
From 57d9ea3f654e072ed35787f5baf517ed2fd0a351 Mon Sep 17 00:00:00 2001
From: Miles Steele
Date: Mon, 12 Aug 2013 15:03:38 -0400
Subject: [PATCH 013/125] add beta dash to changelog
---
CHANGELOG.rst | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 8d04c44d88..b900eeb71e 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -5,6 +5,10 @@ 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.
+LMS: Enable beta instructor dashboard. The beta dashboard is a rearchitecture
+of the existing instructor dashboard and is available by clicking a link at
+the top right of the existing dashboard.
+
Studio: Email will be sent to admin address when a user requests course creator
privileges for Studio (edge only).
From 57e141827437a6122d9b70d0edc4c6f0ade7aed0 Mon Sep 17 00:00:00 2001
From: Peter Baratta
Date: Mon, 12 Aug 2013 15:37:18 -0400
Subject: [PATCH 014/125] Add an entry to the CHANGELOG for #512
---
CHANGELOG.rst | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index b900eeb71e..29f608ef9c 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -15,6 +15,10 @@ privileges for Studio (edge only).
Studio: Studio course authors (both instructors and staff) will be auto-enrolled
for their courses so that "View Live" works.
+Common: Add a new input type ```` for Formula/Numerical
+Responses. It periodically makes AJAX calls to preview and validate the
+student's input.
+
Common: Added ratelimiting to our authentication backend.
Common: Add additional logging to cover login attempts and logouts.
From 13d54c0dd7b43718d6dfad7cd53c86e1fceeced7 Mon Sep 17 00:00:00 2001
From: Adam Palay
Date: Mon, 12 Aug 2013 10:08:02 -0400
Subject: [PATCH 015/125] fix password reset templates
---
.../registration/password_reset_complete.html | 9 ++--
.../registration/password_reset_confirm.html | 41 +++++++++----------
2 files changed, 25 insertions(+), 25 deletions(-)
diff --git a/lms/templates/registration/password_reset_complete.html b/lms/templates/registration/password_reset_complete.html
index 3f301102b5..6ccb75ed26 100644
--- a/lms/templates/registration/password_reset_complete.html
+++ b/lms/templates/registration/password_reset_complete.html
@@ -1,4 +1,3 @@
-<%! from django.utils.translation import ugettext as _ %>
{% load i18n %}
{% load compressed %}
{% load staticfiles %}
@@ -6,7 +5,7 @@
- ${_("Your Password Reset is Complete")}
+ {% trans "Your Password Reset is Complete" %}
{% compressed_css 'application' %}
@@ -54,13 +53,15 @@
-
${_("Your Password Reset is Complete")}
+
{% trans "Your Password Reset is Complete" %}
{% block content %}
-
${_('Your password has been set. You may go ahead and {link_start}log in{link_end} now.').format(link_start='', link_end='')}
+ {% blocktrans with link_start='' link_end='' %}
+ Your password has been set. You may go ahead and {{ link_start }}log in{{ link_end }} now.
+ {% endblocktrans %}
{% endblock %}
diff --git a/lms/templates/registration/password_reset_confirm.html b/lms/templates/registration/password_reset_confirm.html
index 6a568545d1..afbaa22683 100644
--- a/lms/templates/registration/password_reset_confirm.html
+++ b/lms/templates/registration/password_reset_confirm.html
@@ -1,11 +1,11 @@
-<%! from django.utils.translation import ugettext as _ %>
+{% load i18n %}
{% load compressed %}
{% load staticfiles %}
- ${_("Reset Your {platform_name} Password").format(platform_name=settings.PLATFORM_NAME)}
+{% trans "Reset Your edX Password" %}
{% compressed_css 'application' %}
@@ -53,78 +53,77 @@
-
${_("Reset Your {platform_name} Password").format(platform_name=settings.PLATFORM_NAME)}
+
{% trans "Reset Your edX Password" %}
{% if validlink %}
-
${_("Password Reset Form")}
+
{% trans "Password Reset Form" %}
{% else %}
-
${_("Your Password Reset Was Unsuccessful")}
+
{% trans "Your Password Reset Was Unsuccessful" %}
-
${_('The password reset link was invalid, possibly because the link has already been used. Please return to the login page and start the password reset process again.')}
+
{% trans 'The password reset link was invalid, possibly because the link has already been used. Please return to the login page and start the password reset process again.' %}
{% endif %}
From c611470e97edab910ae57eee24acde0070ff460d Mon Sep 17 00:00:00 2001
From: Peter Fogg
Date: Mon, 12 Aug 2013 16:28:04 -0400
Subject: [PATCH 016/125] Correct non-unique course validation; code cleanup;
better error style.
---
cms/static/js/base.js | 98 +++++++++++++---------------
cms/static/sass/elements/_forms.scss | 8 +++
cms/templates/index.html | 4 ++
3 files changed, 58 insertions(+), 52 deletions(-)
diff --git a/cms/static/js/base.js b/cms/static/js/base.js
index 72ef5991a3..b68b82dfa6 100644
--- a/cms/static/js/base.js
+++ b/cms/static/js/base.js
@@ -615,6 +615,37 @@ function addNewCourse(e) {
$body.bind('keyup', {
$cancelButton: $cancelButton
}, checkForCancel);
+
+ // Check that a course (org, number, run) doesn't use any special characters
+ var validateCourseItemEncoding = function(item) {
+ var required = validateRequiredField(item);
+ if(required) {
+ return required;
+ }
+ if(item !== encodeURIComponent(item)) {
+ return gettext('Please do not use any spaces or special characters in this field.');
+ }
+ return '';
+ }
+
+ // Ensure that all items are less than 80 characters.
+ var validateTotalCourseItemsLength = function() {
+ var totalLength = _.reduce(
+ ['.new-course-name', '.new-course-org', '.new-course-number', '.new-course-run'],
+ function(sum, ele) {
+ return sum + $(ele).val().length;
+ }, 0
+ );
+ if(totalLength > 80) {
+ $('.wrap-error').addClass('is-shown');
+ $('#course_creation_error').html('
' + gettext('Course fields must have a combined length of no more than 80 characters.') + '
');
+ $('.new-course-save').addClass('is-disabled');
+ }
+ else {
+ $('.wrap-error').removeClass('is-shown');
+ }
+ }
+
// Handle validation asynchronously
_.each(
['.new-course-org', '.new-course-number', '.new-course-run'],
@@ -635,21 +666,25 @@ function addNewCourse(e) {
);
var $name = $('.new-course-name');
$name.on('keyup', function() {
- var error = validateCourseName($name.val());
+ var error = validateRequiredField($name.val());
setNewCourseFieldInErr($name.parent('li'), error);
validateTotalCourseItemsLength();
});
}
+function validateRequiredField(msg) {
+ return msg.length === 0 ? gettext('Required field.') : '';
+}
+
function setNewCourseFieldInErr(el, msg) {
- el.children('.tip-error').remove();
if(msg) {
el.addClass('error');
- el.append('' + msg + '');
+ el.children('span.tip-error').addClass('is-showing').removeClass('is-hiding').text(msg);
$('.new-course-save').addClass('is-disabled');
}
else {
el.removeClass('error');
+ el.children('span.tip-error').addClass('is-hiding').removeClass('is-showing');
// One "error" div is always present, but hidden or shown
if($('.error').length === 1) {
$('.new-course-save').removeClass('is-disabled');
@@ -657,42 +692,6 @@ function setNewCourseFieldInErr(el, msg) {
}
};
-function validateCourseName(name) {
- if(name.length === 0) {
- return gettext('Required field.');
- }
- return '';
-}
-
-// Check that a course (org, number, run) doesn't use any special characters
-function validateCourseItemEncoding(item) {
- if(item === '') {
- return gettext('Required field.');
- }
- if(item !== encodeURIComponent(item)) {
- return gettext('Please do not use any spaces or special characters in this field.');
- }
- return '';
-}
-
-// Ensure that all items are less than 80 characters.
-function validateTotalCourseItemsLength() {
- var totalLength = _.reduce(
- ['.new-course-name', '.new-course-org', '.new-course-number', '.new-course-run'],
- function(sum, ele) {
- return sum + $(ele).val().length;
- }, 0
- );
- if(totalLength > 80) {
- $('.wrap-error').addClass('is-shown');
- $('#course_creation_error').html('
' + gettext('Course fields must have a combined length of no more than 80 characters.') + '
');
- $('.new-course-save').addClass('is-disabled');
- }
- else {
- $('.wrap-error').removeClass('is-shown');
- }
-}
-
function saveNewCourse(e) {
e.preventDefault();
@@ -701,23 +700,23 @@ function saveNewCourse(e) {
['.new-course-name', '.new-course-org', '.new-course-number', '.new-course-run'],
function(acc, ele) {
var $ele = $(ele);
- var error = $ele.val().length === 0 ? gettext('Required field.') : '';
+ var error = validateRequiredField($ele.val());
setNewCourseFieldInErr($ele.parent('li'), error);
return error ? true : acc;
},
false
);
+ if(errors) {
+ return;
+ }
+
var $newCourseForm = $(this).closest('#create-course-form');
var display_name = $newCourseForm.find('.new-course-name').val();
var org = $newCourseForm.find('.new-course-org').val();
var number = $newCourseForm.find('.new-course-number').val();
var run = $newCourseForm.find('.new-course-run').val();
- if(errors) {
- return;
- }
-
analytics.track('Created a Course', {
'org': org,
'number': number,
@@ -735,14 +734,9 @@ function saveNewCourse(e) {
if (data.id !== undefined) {
window.location = '/' + data.id.replace(/.*:\/\//, '');
} else if (data.ErrMsg !== undefined) {
- var orgErrMsg = (data.OrgErrMsg !== undefined) ? data.OrgErrMsg : null;
- if(orgErrMsg) {
- setNewCourseFieldInErr($('.new-course-org').parent('li'), orgErrMsg);
- }
- var courseErrMsg = (data.CourseErrMsg !== undefined) ? data.CourseErrMsg : null;
- if(courseErrMsg) {
- setNewCourseFieldInErr($('.new-course-number').parent('li'), orgErrMsg);
- }
+ $('.wrap-error').addClass('is-shown');
+ $('#course_creation_error').html('
${_("The name of the organization sponsoring the course")} - ${_("Note: No spaces or special characters are allowed. This cannot be changed.")}
+
${_("The unique number that identifies your course within your organization")} - ${_("Note: No spaces or special characters are allowed. This cannot be changed.")}
+
${_("The term in which your course will run")} - ${_("Note: No spaces or special characters are allowed. This cannot be changed.")}
+
From 18a979bb4d02fd4b5d20f620775fa1aaea69a2ee Mon Sep 17 00:00:00 2001
From: Adam Palay
Date: Mon, 12 Aug 2013 15:50:34 -0400
Subject: [PATCH 017/125] put wiki templates back into Django templating
---
lms/templates/wiki/includes/cheatsheet.html | 53 +++++++++++--------
.../wiki/includes/editor_widget.html | 7 ++-
lms/templates/wiki/preview_inline.html | 5 +-
3 files changed, 37 insertions(+), 28 deletions(-)
diff --git a/lms/templates/wiki/includes/cheatsheet.html b/lms/templates/wiki/includes/cheatsheet.html
index f8507d2011..7ff7ae009c 100644
--- a/lms/templates/wiki/includes/cheatsheet.html
+++ b/lms/templates/wiki/includes/cheatsheet.html
@@ -1,21 +1,24 @@
-<%! from django.utils.translation import ugettext as _ %>
+{% load i18n %}
+{% comment %}
+ Translators: Do not translate "edX" or "Wikipedia"
+{% endcomment %}
+{% trans "http://wikipedia.org" %}
+{% trans "[Wikipedia](http://wikipedia.org)" %}
+{% trans "[edX Wiki](wiki:/edx/)" %}
+
-${_("Huge Header")}
+{% trans "Huge Header" %}
===========
-${_("Smaller Header")}
+{% trans "Smaller Header" %}
--------------
-${_("*emphasis* or _emphasis_")}
+{% trans "*emphasis* or _emphasis_" %}
-${_("**strong** or __strong__")}
+{% trans "**strong** or __strong__" %}
-${_("- Unordered List\
- - Sub Item 1\
- - Sub Item 2")}
+- {% trans "Unordered List" %}
+ - {% trans "Sub Item 1" %}
+ - {% trans "Sub Item 2" %}
-${_("1. Ordered\
-2. List")}
+1. {% trans "Ordered" %}
+2. {% trans "List" %}
-${_("> Quotes")}
+> {% trans "Quotes" %}
diff --git a/lms/templates/wiki/includes/editor_widget.html b/lms/templates/wiki/includes/editor_widget.html
index 264a8afbea..4d9b1a7472 100644
--- a/lms/templates/wiki/includes/editor_widget.html
+++ b/lms/templates/wiki/includes/editor_widget.html
@@ -1,5 +1,8 @@
-<%! from django.utils.translation import ugettext as _ %>
+{% load i18n %}
- ${_('Markdown syntax is allowed. See the _{("cheatsheet")} for help.')}
+ {% comment %}
+ Translators: Do not translate 'cheatsheetLink'
+ {% endcomment %}
+ {% trans "Markdown syntax is allowed. See the cheatsheet for help." %}
diff --git a/lms/templates/wiki/preview_inline.html b/lms/templates/wiki/preview_inline.html
index faa230df86..4f8aafeeff 100644
--- a/lms/templates/wiki/preview_inline.html
+++ b/lms/templates/wiki/preview_inline.html
@@ -1,4 +1,3 @@
-<%! from django.utils.translation import ugettext as _ %>
{% load wiki_tags i18n %}{% load compressed %}
@@ -29,8 +28,8 @@
{% if revision and revision.deleted %}
- ${_("This revision has been deleted.")}
-
${_("Restoring to this revision will mark the article as deleted.")}
+ {% trans "This revision has been deleted." %}
+
{% trans "Restoring to this revision will mark the article as deleted." %}
{% else %}
{% wiki_render article content %}
From f9ab433e26e6419cc0630514632b3dcf630fce7c Mon Sep 17 00:00:00 2001
From: Adam Palay
Date: Mon, 12 Aug 2013 17:27:18 -0400
Subject: [PATCH 018/125] add settings.PLATFORM_NAME to password_reset_confirm
context
---
common/djangoapps/student/views.py | 13 +++++++++++--
.../registration/password_reset_confirm.html | 18 +++++++++++++++---
2 files changed, 26 insertions(+), 5 deletions(-)
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index 0b061f5a94..9b96b90dee 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -1037,7 +1037,11 @@ def password_reset(request):
'error': _('Invalid e-mail or user')}))
-def password_reset_confirm_wrapper(request, uidb36=None, token=None):
+def password_reset_confirm_wrapper(
+ request,
+ uidb36=None,
+ token=None,
+):
''' A wrapper around django.contrib.auth.views.password_reset_confirm.
Needed because we want to set the user as active at this step.
'''
@@ -1049,7 +1053,12 @@ def password_reset_confirm_wrapper(request, uidb36=None, token=None):
user.save()
except (ValueError, User.DoesNotExist):
pass
- return password_reset_confirm(request, uidb36=uidb36, token=token)
+ # we also want to pass settings.PLATFORM_NAME in as extra_context
+
+ extra_context = {"platform_name": settings.PLATFORM_NAME}
+ return password_reset_confirm(
+ request, uidb36=uidb36, token=token, extra_context=extra_context
+ )
def reactivation_email_for_user(user):
diff --git a/lms/templates/registration/password_reset_confirm.html b/lms/templates/registration/password_reset_confirm.html
index afbaa22683..607ff1a077 100644
--- a/lms/templates/registration/password_reset_confirm.html
+++ b/lms/templates/registration/password_reset_confirm.html
@@ -5,7 +5,11 @@
-{% trans "Reset Your edX Password" %}
+
+ {% blocktrans with platform_name=platform_name %}
+ Reset Your {{ platform_name }} Password
+ {% endblocktrans %}
+
{% compressed_css 'application' %}
@@ -53,7 +57,11 @@
-
{% trans "Reset Your edX Password" %}
+
+ {% blocktrans with platform_name=platform_name %}
+ Reset Your {{ platform_name }} Password
+ {% endblocktrans %}
+
{% trans "To create a new wiki article, create a link to it. Clicking the link gives you the creation page." %}
{% trans "[Article Name](wiki:ArticleName)" %}
diff --git a/lms/templates/wiki/includes/editor_widget.html b/lms/templates/wiki/includes/editor_widget.html
index 4d9b1a7472..3745e6ee6d 100644
--- a/lms/templates/wiki/includes/editor_widget.html
+++ b/lms/templates/wiki/includes/editor_widget.html
@@ -4,5 +4,7 @@
{% comment %}
Translators: Do not translate 'cheatsheetLink'
{% endcomment %}
- {% trans "Markdown syntax is allowed. See the cheatsheet for help." %}
+ {% blocktrans with start_link="" end_link="" %}
+ Markdown syntax is allowed. See the {{ start_link }}cheatsheet{{ end_link }} for help.
+ {% endblocktrans %}
From 39b829761a0fdc9c6c7f3e07740f54af30e4843d Mon Sep 17 00:00:00 2001
From: Adam Palay
Date: Mon, 12 Aug 2013 10:08:02 -0400
Subject: [PATCH 029/125] fix password reset templates
---
.../registration/password_reset_complete.html | 9 ++--
.../registration/password_reset_confirm.html | 41 +++++++++----------
2 files changed, 25 insertions(+), 25 deletions(-)
diff --git a/lms/templates/registration/password_reset_complete.html b/lms/templates/registration/password_reset_complete.html
index 3f301102b5..6ccb75ed26 100644
--- a/lms/templates/registration/password_reset_complete.html
+++ b/lms/templates/registration/password_reset_complete.html
@@ -1,4 +1,3 @@
-<%! from django.utils.translation import ugettext as _ %>
{% load i18n %}
{% load compressed %}
{% load staticfiles %}
@@ -6,7 +5,7 @@
- ${_("Your Password Reset is Complete")}
+ {% trans "Your Password Reset is Complete" %}
{% compressed_css 'application' %}
@@ -54,13 +53,15 @@
-
${_("Your Password Reset is Complete")}
+
{% trans "Your Password Reset is Complete" %}
{% block content %}
-
${_('Your password has been set. You may go ahead and {link_start}log in{link_end} now.').format(link_start='', link_end='')}
+ {% blocktrans with link_start='' link_end='' %}
+ Your password has been set. You may go ahead and {{ link_start }}log in{{ link_end }} now.
+ {% endblocktrans %}
{% endblock %}
diff --git a/lms/templates/registration/password_reset_confirm.html b/lms/templates/registration/password_reset_confirm.html
index 6a568545d1..afbaa22683 100644
--- a/lms/templates/registration/password_reset_confirm.html
+++ b/lms/templates/registration/password_reset_confirm.html
@@ -1,11 +1,11 @@
-<%! from django.utils.translation import ugettext as _ %>
+{% load i18n %}
{% load compressed %}
{% load staticfiles %}
- ${_("Reset Your {platform_name} Password").format(platform_name=settings.PLATFORM_NAME)}
+{% trans "Reset Your edX Password" %}
{% compressed_css 'application' %}
@@ -53,78 +53,77 @@
-
${_("Reset Your {platform_name} Password").format(platform_name=settings.PLATFORM_NAME)}
+
{% trans "Reset Your edX Password" %}
{% if validlink %}
-
${_("Password Reset Form")}
+
{% trans "Password Reset Form" %}
{% csrf_token %}
-
${_("We're sorry, {platform_name} enrollment is not available in your region").format(platform_name=settings.PLATFORM_NAME)}
+
{% trans "We're sorry, edX enrollment is not available in your region" %}
-
${_("The following errors occured while processing your registration: ")}
+
{% trans "The following errors occured while processing your registration: " %}
-
${_("You must complete all fields.")}
-
${_("The two password fields didn't match.")}
+
{% trans "You must complete all fields." %}
+
{% trans "The two password fields didn't match." %}
-
${_("We're sorry, our systems seem to be having trouble processing your password reset")}
-
${_('Someone has been made aware of this issue. Please try again shortly. Please contact us about any concerns you have.')}
+
{% trans "We're sorry, our systems seem to be having trouble processing your password reset" %}
+
{% trans 'Someone has been made aware of this issue. Please try again shortly. Please contact us about any concerns you have.' %}
- ${_('Please enter your new password twice so we can verify you typed it in correctly. '
- 'Required fields are noted by bold text and an asterisk (*).')}
+ {% trans 'Please enter your new password twice so we can verify you typed it in correctly. Required fields are noted by bold text and an asterisk (*).' %}
-
+
{% else %}
-
${_("Your Password Reset Was Unsuccessful")}
+
{% trans "Your Password Reset Was Unsuccessful" %}
-
${_('The password reset link was invalid, possibly because the link has already been used. Please return to the login page and start the password reset process again.')}
+
{% trans 'The password reset link was invalid, possibly because the link has already been used. Please return to the login page and start the password reset process again.' %}
{% endif %}
From 5ed8d557c2be24dbab6db2c7b540b8c784db8af9 Mon Sep 17 00:00:00 2001
From: Adam Palay
Date: Mon, 12 Aug 2013 15:50:34 -0400
Subject: [PATCH 030/125] put wiki templates back into Django templating
---
lms/templates/wiki/includes/cheatsheet.html | 53 +++++++++++--------
.../wiki/includes/editor_widget.html | 7 ++-
lms/templates/wiki/preview_inline.html | 5 +-
3 files changed, 37 insertions(+), 28 deletions(-)
diff --git a/lms/templates/wiki/includes/cheatsheet.html b/lms/templates/wiki/includes/cheatsheet.html
index f8507d2011..7ff7ae009c 100644
--- a/lms/templates/wiki/includes/cheatsheet.html
+++ b/lms/templates/wiki/includes/cheatsheet.html
@@ -1,21 +1,24 @@
-<%! from django.utils.translation import ugettext as _ %>
+{% load i18n %}
+{% comment %}
+ Translators: Do not translate "edX" or "Wikipedia"
+{% endcomment %}
+{% trans "http://wikipedia.org" %}
+{% trans "[Wikipedia](http://wikipedia.org)" %}
+{% trans "[edX Wiki](wiki:/edx/)" %}
+
-${_("Huge Header")}
+{% trans "Huge Header" %}
===========
-${_("Smaller Header")}
+{% trans "Smaller Header" %}
--------------
-${_("*emphasis* or _emphasis_")}
+{% trans "*emphasis* or _emphasis_" %}
-${_("**strong** or __strong__")}
+{% trans "**strong** or __strong__" %}
-${_("- Unordered List\
- - Sub Item 1\
- - Sub Item 2")}
+- {% trans "Unordered List" %}
+ - {% trans "Sub Item 1" %}
+ - {% trans "Sub Item 2" %}
-${_("1. Ordered\
-2. List")}
+1. {% trans "Ordered" %}
+2. {% trans "List" %}
-${_("> Quotes")}
+> {% trans "Quotes" %}
diff --git a/lms/templates/wiki/includes/editor_widget.html b/lms/templates/wiki/includes/editor_widget.html
index 264a8afbea..4d9b1a7472 100644
--- a/lms/templates/wiki/includes/editor_widget.html
+++ b/lms/templates/wiki/includes/editor_widget.html
@@ -1,5 +1,8 @@
-<%! from django.utils.translation import ugettext as _ %>
+{% load i18n %}
- ${_('Markdown syntax is allowed. See the _{("cheatsheet")} for help.')}
+ {% comment %}
+ Translators: Do not translate 'cheatsheetLink'
+ {% endcomment %}
+ {% trans "Markdown syntax is allowed. See the cheatsheet for help." %}
diff --git a/lms/templates/wiki/preview_inline.html b/lms/templates/wiki/preview_inline.html
index faa230df86..4f8aafeeff 100644
--- a/lms/templates/wiki/preview_inline.html
+++ b/lms/templates/wiki/preview_inline.html
@@ -1,4 +1,3 @@
-<%! from django.utils.translation import ugettext as _ %>
{% load wiki_tags i18n %}{% load compressed %}
@@ -29,8 +28,8 @@
{% if revision and revision.deleted %}
- ${_("This revision has been deleted.")}
-
${_("Restoring to this revision will mark the article as deleted.")}
+ {% trans "This revision has been deleted." %}
+
{% trans "Restoring to this revision will mark the article as deleted." %}
{% else %}
{% wiki_render article content %}
From 5d4edc61a75dc09e310dba182b9ed4fba8cb7c99 Mon Sep 17 00:00:00 2001
From: Adam Palay
Date: Mon, 12 Aug 2013 17:27:18 -0400
Subject: [PATCH 031/125] add settings.PLATFORM_NAME to password_reset_confirm
context
---
common/djangoapps/student/views.py | 13 +++++++++++--
.../registration/password_reset_confirm.html | 18 +++++++++++++++---
2 files changed, 26 insertions(+), 5 deletions(-)
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index 0b061f5a94..9b96b90dee 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -1037,7 +1037,11 @@ def password_reset(request):
'error': _('Invalid e-mail or user')}))
-def password_reset_confirm_wrapper(request, uidb36=None, token=None):
+def password_reset_confirm_wrapper(
+ request,
+ uidb36=None,
+ token=None,
+):
''' A wrapper around django.contrib.auth.views.password_reset_confirm.
Needed because we want to set the user as active at this step.
'''
@@ -1049,7 +1053,12 @@ def password_reset_confirm_wrapper(request, uidb36=None, token=None):
user.save()
except (ValueError, User.DoesNotExist):
pass
- return password_reset_confirm(request, uidb36=uidb36, token=token)
+ # we also want to pass settings.PLATFORM_NAME in as extra_context
+
+ extra_context = {"platform_name": settings.PLATFORM_NAME}
+ return password_reset_confirm(
+ request, uidb36=uidb36, token=token, extra_context=extra_context
+ )
def reactivation_email_for_user(user):
diff --git a/lms/templates/registration/password_reset_confirm.html b/lms/templates/registration/password_reset_confirm.html
index afbaa22683..607ff1a077 100644
--- a/lms/templates/registration/password_reset_confirm.html
+++ b/lms/templates/registration/password_reset_confirm.html
@@ -5,7 +5,11 @@
-{% trans "Reset Your edX Password" %}
+
+ {% blocktrans with platform_name=platform_name %}
+ Reset Your {{ platform_name }} Password
+ {% endblocktrans %}
+
{% compressed_css 'application' %}
@@ -53,7 +57,11 @@
-
{% trans "Reset Your edX Password" %}
+
+ {% blocktrans with platform_name=platform_name %}
+ Reset Your {{ platform_name }} Password
+ {% endblocktrans %}
+
@@ -66,7 +74,11 @@
{% csrf_token %}
-
{% trans "We're sorry, edX enrollment is not available in your region" %}
+
+ {% blocktrans with platform_name=platform_name %}
+ We're sorry, {{ platform_name }} enrollment is not available in your region
+ {% endblocktrans %}
+
{% trans "We're sorry, our systems seem to be having trouble processing your password reset" %}
-
{% trans 'Someone has been made aware of this issue. Please try again shortly. Please contact us about any concerns you have.' %}
+
+ {% blocktrans with start_link='' end_link='' %}
+ Someone has been made aware of this issue. Please try again shortly. Please {{ start_link }}contact us{{ end_link }} about any concerns you have.
+ {% endblocktrans %}
+
@@ -123,7 +127,11 @@
{% trans "Your Password Reset Was Unsuccessful" %}
-
{% trans 'The password reset link was invalid, possibly because the link has already been used. Please return to the login page and start the password reset process again.' %}
+
+ {% blocktrans with start_link='' end_link='' %}
+ The password reset link was invalid, possibly because the link has already been used. Please return to the {{ start_link }}login page{{ end_link }} and start the password reset process again.
+ {% endblocktrans %}
+
{% trans "To create a new wiki article, create a link to it. Clicking the link gives you the creation page." %}
{% trans "[Article Name](wiki:ArticleName)" %}
diff --git a/lms/templates/wiki/includes/editor_widget.html b/lms/templates/wiki/includes/editor_widget.html
index 4d9b1a7472..3745e6ee6d 100644
--- a/lms/templates/wiki/includes/editor_widget.html
+++ b/lms/templates/wiki/includes/editor_widget.html
@@ -4,5 +4,7 @@
{% comment %}
Translators: Do not translate 'cheatsheetLink'
{% endcomment %}
- {% trans "Markdown syntax is allowed. See the cheatsheet for help." %}
+ {% blocktrans with start_link="" end_link="" %}
+ Markdown syntax is allowed. See the {{ start_link }}cheatsheet{{ end_link }} for help.
+ {% endblocktrans %}
From d0a64f2c2aa5ee76b66eb84505dbca497b629305 Mon Sep 17 00:00:00 2001
From: John Jarvis
Date: Tue, 13 Aug 2013 13:29:48 -0400
Subject: [PATCH 034/125] adding django-admin commands to dump grades to a csv
file
---
.../student/management/commands/get_grades.py | 96 +++++++++++++++++++
1 file changed, 96 insertions(+)
create mode 100644 common/djangoapps/student/management/commands/get_grades.py
diff --git a/common/djangoapps/student/management/commands/get_grades.py b/common/djangoapps/student/management/commands/get_grades.py
new file mode 100644
index 0000000000..9aa279458e
--- /dev/null
+++ b/common/djangoapps/student/management/commands/get_grades.py
@@ -0,0 +1,96 @@
+from courseware import grades, courses
+from django.test.client import RequestFactory
+from django.core.management.base import BaseCommand, CommandError
+import os
+from django.contrib.auth.models import User
+from optparse import make_option
+import datetime
+from django.core.handlers.base import BaseHandler
+import csv
+
+
+class RequestMock(RequestFactory):
+ def request(self, **request):
+ "Construct a generic request object."
+ request = RequestFactory.request(self, **request)
+ handler = BaseHandler()
+ handler.load_middleware()
+ for middleware_method in handler._request_middleware:
+ if middleware_method(request):
+ raise Exception("Couldn't create request mock object - "
+ "request middleware returned a response")
+ return request
+
+
+class Command(BaseCommand):
+
+ help = """
+ Generate a list of grades for all students
+ that are enrolled in a course.
+
+ Outputs grades to a csv file.
+
+ Example:
+ sudo -u www-data SERVICE_VARIANT=lms /opt/edx/bin/django-admin.py get_grades \
+ -c MITx/Chi6.00intro/A_Taste_of_Python_Programming -o /tmp/20130813-6.00x.csv \
+ --settings=lms.envs.aws --pythonpath=/opt/wwc/edx-platform
+ """
+
+ option_list = BaseCommand.option_list + (
+ make_option('-c', '--course',
+ metavar='COURSE_ID',
+ dest='course',
+ default=False,
+ help='Grade and generate certificates for a specific '
+ 'course'),
+ make_option('-o', '--output',
+ metavar='FILE',
+ dest='output',
+ default=False,
+ help='Filename for grade output'))
+
+ def handle(self, *args, **options):
+ if os.path.exists(options['output']):
+ raise CommandError("File {0} already exists".format(
+ options['output']))
+
+ STATUS_INTERVAL = 100
+ course_id = options['course']
+ print "Fetching enrolled students for {0}".format(course_id)
+ enrolled_students = User.objects.filter(
+ courseenrollment__course_id=course_id).prefetch_related(
+ "groups").order_by('username')
+ factory = RequestMock()
+ request = factory.get('/')
+
+ total = enrolled_students.count()
+ print "Total enrolled: {0}".format(total)
+ course = courses.get_course_by_id(course_id)
+ total = enrolled_students.count()
+ start = datetime.datetime.now()
+ rows = []
+ header = None
+ for count, student in enumerate(enrolled_students):
+ count += 1
+ if count % STATUS_INTERVAL == 0:
+ # Print a status update with an approximation of
+ # how much time is left based on how long the last
+ # interval took
+ diff = datetime.datetime.now() - start
+ timeleft = diff * (total - count) / STATUS_INTERVAL
+ hours, remainder = divmod(timeleft.seconds, 3600)
+ minutes, seconds = divmod(remainder, 60)
+ print "{0}/{1} completed ~{2:02}:{3:02}m remaining".format(
+ count, total, hours, minutes)
+ start = datetime.datetime.now()
+ request.user = student
+ grade = grades.grade(student, request, course)
+ if not header:
+ header = [section['label'] for section in grade[u'section_breakdown']]
+ rows.append(["email", "username"] + header)
+ percents = {section['label']: section['percent'] for section in grade[u'section_breakdown']}
+ row_percents = [percents[label] for label in header]
+ rows.append([student.email, student.username] + row_percents)
+ with open(options['output'], 'wb') as f:
+ writer = csv.writer(f)
+ writer.writerows(rows)
From e312344dfaf70604dbcc632efe93eb5d0a651c41 Mon Sep 17 00:00:00 2001
From: Peter Baratta
Date: Tue, 13 Aug 2013 13:28:00 -0400
Subject: [PATCH 035/125] Fix initial loading bug.
Change the `MathJax.Hub.Queue(initializeRequest)` to a simpler function call
(`initializeRequest.call(this)`). This was failing to give a proper context
to initializeRequest, and `this.value` was turning up as `undefined`.
Also add a fallback if we need to display some code before MathJax finishes
its original typesetting.
I was stubbing out `Queue` in my specs, so the tests had to be changed around
a little.
---
.../spec/formula_equation_preview_spec.js | 119 +++++++++++-------
.../js/capa/src/formula_equation_preview.js | 33 ++---
2 files changed, 96 insertions(+), 56 deletions(-)
diff --git a/common/static/js/capa/spec/formula_equation_preview_spec.js b/common/static/js/capa/spec/formula_equation_preview_spec.js
index 8ec705b14e..39151f0f63 100644
--- a/common/static/js/capa/spec/formula_equation_preview_spec.js
+++ b/common/static/js/capa/spec/formula_equation_preview_spec.js
@@ -77,37 +77,33 @@ describe("Formula Equation Preview", function () {
});
describe('Ajax requests', function () {
- it('has an initial request with the correct parameters', function () {
+ beforeEach(function () {
+ // This is common to all tests on ajax requests.
formulaEquationPreview.enable();
- expect(MathJax.Hub.Queue).toHaveBeenCalled();
- // Do what Queue would've done--call the function.
- var args = MathJax.Hub.Queue.mostRecentCall.args;
- args[1].call(args[0]);
-
// This part may be asynchronous, so wait.
waitsFor(function () {
return Problem.inputAjax.wasCalled;
}, "AJAX never called initially", 1000);
+ });
- runs(function () {
- expect(Problem.inputAjax.callCount).toEqual(1);
+ it('has an initial request with the correct parameters', function () {
+ expect(Problem.inputAjax.callCount).toEqual(1);
- // Use `.toEqual` rather than `.toHaveBeenCalledWith`
- // since it supports `jasmine.any`.
- expect(Problem.inputAjax.mostRecentCall.args).toEqual([
- "THE_URL",
- "THE_ID",
- "preview_formcalc",
- {formula: "prefilled_value",
- request_start: jasmine.any(Number)},
- jasmine.any(Function)
- ]);
- });
+ // Use `.toEqual` rather than `.toHaveBeenCalledWith`
+ // since it supports `jasmine.any`.
+ expect(Problem.inputAjax.mostRecentCall.args).toEqual([
+ "THE_URL",
+ "THE_ID",
+ "preview_formcalc",
+ {formula: "prefilled_value",
+ request_start: jasmine.any(Number)},
+ jasmine.any(Function)
+ ]);
});
it('makes a request on user input', function () {
- formulaEquationPreview.enable();
+ Problem.inputAjax.reset();
$('#input_THE_ID').val('user_input').trigger('input');
// This part is probably asynchronous
@@ -122,8 +118,7 @@ describe("Formula Equation Preview", function () {
});
it("shouldn't be requested for empty input", function () {
- formulaEquationPreview.enable();
- MathJax.Hub.Queue.reset();
+ Problem.inputAjax.reset();
// When we make an input of '',
$('#input_THE_ID').val('').trigger('input');
@@ -142,9 +137,7 @@ describe("Formula Equation Preview", function () {
});
it('should limit the number of requests per second', function () {
- formulaEquationPreview.enable();
-
- var minDelay = formulaEquationPreview.minDelay;
+ var minDelay = formulaEquationPreview.minDelay;
var end = Date.now() + minDelay * 1.1;
var step = 10; // ms
@@ -179,23 +172,35 @@ describe("Formula Equation Preview", function () {
describe("Visible results (icon and mathjax)", function () {
it('should display a loading icon when requests are open', function () {
- formulaEquationPreview.enable();
var $img = $("img.loading");
expect($img.css('visibility')).toEqual('hidden');
-
- $("#input_THE_ID").val("different").trigger('input');
+ formulaEquationPreview.enable();
expect($img.css('visibility')).toEqual('visible');
+ // This part could be asynchronous
+ waitsFor(function () {
+ return Problem.inputAjax.wasCalled;
+ }, "AJAX never called initially", 1000);
+
+ runs(function () {
+ expect($img.css('visibility')).toEqual('visible');
+
+ // Reset and send another request.
+ $img.css('visibility', 'hidden');
+ $("#input_THE_ID").val("different").trigger('input');
+
+ expect($img.css('visibility')).toEqual('visible');
+ });
+
// Don't let it fail later.
waitsFor(function () {
- return Problem.inputAjax.wasCalled;
+ var args = Problem.inputAjax.mostRecentCall.args;
+ return args[3].formula == "different";
});
});
it('should update MathJax and loading icon on callback', function () {
formulaEquationPreview.enable();
- $('#input_THE_ID').val('user_input').trigger('input');
-
waitsFor(function () {
return Problem.inputAjax.wasCalled;
}, "AJAX never called initially", 1000);
@@ -223,12 +228,44 @@ describe("Formula Equation Preview", function () {
});
});
+ it('finds alternatives if MathJax hasn\'t finished loading', function () {
+ formulaEquationPreview.enable();
+ $('#input_THE_ID').val('user_input').trigger('input');
+
+ waitsFor(function () {
+ return Problem.inputAjax.wasCalled;
+ }, "AJAX never called initially", 1000);
+
+ runs(function () {
+ var args = Problem.inputAjax.mostRecentCall.args;
+ var callback = args[4];
+
+ // Cannot find MathJax.
+ MathJax.Hub.getAllJax.andReturn([]);
+ spyOn(console, 'error');
+
+ callback({
+ preview: 'THE_FORMULA',
+ request_start: args[3].request_start
+ });
+
+ // Tests.
+ expect(console.error).toHaveBeenCalled();
+
+ // We should look in the preview div for the MathJax.
+ var previewElement = $("div")[0];
+ expect(previewElement.firstChild.data).toEqual("\\[THE_FORMULA\\]");
+
+ // Refresh the MathJax.
+ expect(MathJax.Hub.Queue).toHaveBeenCalledWith(
+ ['Typeset', jasmine.any(Object), jasmine.any(Element)]
+ );
+ });
+ });
+
it('should display errors from the server well', function () {
var $img = $("img.loading");
formulaEquationPreview.enable();
- MathJax.Hub.Queue.reset();
- $("#input_THE_ID").val("different").trigger('input');
-
waitsFor(function () {
return Problem.inputAjax.wasCalled;
}, "AJAX never called initially", 1000);
@@ -263,16 +300,14 @@ describe("Formula Equation Preview", function () {
describe('Multiple callbacks', function () {
beforeEach(function () {
formulaEquationPreview.enable();
- MathJax.Hub.Queue.reset();
- $('#input_THE_ID').val('different').trigger('input');
- waitsFor(function () {
- return Problem.inputAjax.wasCalled;
- });
+ waitsFor(function () {
+ return Problem.inputAjax.wasCalled;
+ });
- runs(function () {
- $("#input_THE_ID").val("different2").trigger('input');
- });
+ runs(function () {
+ $('#input_THE_ID').val('different').trigger('input');
+ });
waitsFor(function () {
return Problem.inputAjax.callCount > 1;
diff --git a/common/static/js/capa/src/formula_equation_preview.js b/common/static/js/capa/src/formula_equation_preview.js
index b92b0bff99..c883e1445c 100644
--- a/common/static/js/capa/src/formula_equation_preview.js
+++ b/common/static/js/capa/src/formula_equation_preview.js
@@ -32,8 +32,7 @@ formulaEquationPreview.enable = function () {
// Store the DOM/MathJax elements in which visible output occurs.
$preview: $preview,
- // Note: sometimes MathJax hasn't finished loading yet.
- jax: MathJax.Hub.getAllJax($preview[0])[0],
+ jax: null, // Fill this in later.
$img: $preview.find("img.loading"),
requestCallback: null // Fill it in in a bit.
@@ -59,7 +58,7 @@ formulaEquationPreview.enable = function () {
$this.on("input", initializeRequest);
// send an initial
- MathJax.Hub.Queue(this, initializeRequest);
+ initializeRequest.call(this);
}
/**
@@ -127,20 +126,26 @@ formulaEquationPreview.enable = function () {
function display(latex) {
// Load jax if it failed before.
+ var previewElement = inputData.$preview[0];
if (!inputData.jax) {
- results = MathJax.Hub.getAllJax(inputData.$preview[0]);
- if (!results.length) {
- console.log("Unable to find MathJax to display");
- return;
- }
- inputData.jax = results[0];
+ inputData.jax = MathJax.Hub.getAllJax(previewElement)[0];
}
- // Set the text as the latex code, and then update the MathJax.
- MathJax.Hub.Queue(
- ['Text', inputData.jax, latex],
- ['Reprocess', inputData.jax]
- );
+ // MathJax might not be loaded yet (still).
+ if (inputData.jax) {
+ // Set the text as the latex code, and then update the MathJax.
+ MathJax.Hub.Queue(
+ ['Text', inputData.jax, latex],
+ ['Reprocess', inputData.jax]
+ );
+ }
+ else if (latex) {
+ console.error("Oops no mathjax for ", latex);
+ // Fall back to modifying the actual element.
+ var textNode = previewElement.childNodes[0];
+ textNode.data = "\\[" + latex + "\\]";
+ MathJax.Hub.Queue(["Typeset", MathJax.Hub, previewElement]);
+ }
}
if (response.error) {
From e4994b15cd40ec8b70f0dee0b8311cd238a18e66 Mon Sep 17 00:00:00 2001
From: Nate Hardison
Date: Fri, 19 Jul 2013 10:28:43 -0700
Subject: [PATCH 036/125] Add mgmt cmd to generate anonymized ID mapping
So that instructors have easy access to the mapping from anonymized
IDs (a simple MD5 hash of the user ID that's used when integrating
with third-party services like Qualtrics) to user IDs, we have a
simple Django management command to generate a CSV mapping. To run,
use the following:
rake django-admin[anonymized_id_mapping,,,]
And, of course, substitute the appropriate values for , ,
and . (E.g., lms, dev, and MITx/6.002x/Circuits)
---
.../commands/anonymized_id_mapping.py | 45 +++++++++++++++++++
1 file changed, 45 insertions(+)
create mode 100644 common/djangoapps/student/management/commands/anonymized_id_mapping.py
diff --git a/common/djangoapps/student/management/commands/anonymized_id_mapping.py b/common/djangoapps/student/management/commands/anonymized_id_mapping.py
new file mode 100644
index 0000000000..12153471e6
--- /dev/null
+++ b/common/djangoapps/student/management/commands/anonymized_id_mapping.py
@@ -0,0 +1,45 @@
+import csv
+import sys
+
+from django.contrib.auth.models import User
+from django.core.management.base import BaseCommand, CommandError
+
+from student.models import unique_id_for_user
+
+class Command(BaseCommand):
+ # It appears that with the way Rake invokes these commands, we can't
+ # have more than one arg passed through...annoying.
+ args = ("course_id", )
+
+ help = """
+ Exports a CSV document mapping from a username to the anonymized,
+ unique user ID for every user in the specified course.
+ """
+
+ def handle(self, *args, **options):
+ if len(args) != 1:
+ raise CommandError("Usage: unique_id_mapping %s" %
+ " ".join(("<%s>" % arg for arg in Command.args)))
+
+ course_id = args[0]
+
+ # Generate the output filename from the course ID.
+ # Change slashes to dashes first, and then append .csv extension.
+ output_filename = course_id.replace('/', '-') + ".csv"
+
+ # Figure out which students are enrolled in the course
+ students = User.objects.filter(courseenrollment__course_id=course_id)
+ if len(students) == 0:
+ self.stdout.write("No students enrolled in %s" % course_id)
+ return
+
+ # Write mapping to output file in CSV format with a simple header
+ try:
+ with open(output_filename, 'wb') as output_file:
+ csv_writer = csv.writer(output_file)
+ csv_writer.writerow(("User ID", "Anonymized user ID"))
+ for student in students:
+ csv_writer.writerow((student.id, unique_id_for_user(student)))
+ except IOError:
+ raise CommandError("Error writing to file: %s" % output_filename)
+
From d6530fa633a6df96d7643b7b3c606ef115a408fd Mon Sep 17 00:00:00 2001
From: Joe Blaylock
Date: Tue, 13 Aug 2013 11:35:23 -0700
Subject: [PATCH 037/125] Anonymized ID mapping fixups
Fixups to Nate's anonymized id mapper, repairing pep8 and pylint errors,
and rebasing on recent master.
---
.../commands/anonymized_id_mapping.py | 23 +++++++++++++++----
1 file changed, 19 insertions(+), 4 deletions(-)
diff --git a/common/djangoapps/student/management/commands/anonymized_id_mapping.py b/common/djangoapps/student/management/commands/anonymized_id_mapping.py
index 12153471e6..6156d4bf1d 100644
--- a/common/djangoapps/student/management/commands/anonymized_id_mapping.py
+++ b/common/djangoapps/student/management/commands/anonymized_id_mapping.py
@@ -1,19 +1,34 @@
+# -*- coding: utf8 -*-
+"""Dump username,unique_id_for_user pairs as CSV.
+
+Give instructors easy access to the mapping from anonymized IDs to user IDs
+with a simple Django management command to generate a CSV mapping. To run, use
+the following:
+
+rake django-admin[anonymized_id_mapping,x,y,z]
+
+[Naturally, substitute the appropriate values for x, y, and z. (I.e.,
+ lms, dev, and MITx/6.002x/Circuits)]"""
+
import csv
-import sys
from django.contrib.auth.models import User
from django.core.management.base import BaseCommand, CommandError
from student.models import unique_id_for_user
+
class Command(BaseCommand):
+ """Add our handler to the space where django-admin looks up commands."""
+
# It appears that with the way Rake invokes these commands, we can't
# have more than one arg passed through...annoying.
args = ("course_id", )
- help = """
- Exports a CSV document mapping from a username to the anonymized,
- unique user ID for every user in the specified course.
+ help = """Export a CSV mapping usernames to anonymized ids
+
+ Exports a CSV document mapping each username in the specified course to
+ the anonymized, unique user ID.
"""
def handle(self, *args, **options):
From e0aa46ab2df069078d87e00e901fba7c0227a827 Mon Sep 17 00:00:00 2001
From: cahrens
Date: Tue, 13 Aug 2013 15:06:08 -0400
Subject: [PATCH 038/125] Make split mongo read-only API consistent with other
modulestores.
---
.../contentstore/tests/test_utils.py | 14 +++--
cms/djangoapps/contentstore/utils.py | 21 +++----
cms/djangoapps/contentstore/views/user.py | 3 +-
common/djangoapps/student/views.py | 2 +-
.../xmodule/xmodule/modulestore/__init__.py | 15 -----
.../xmodule/xmodule/modulestore/mongo/base.py | 7 ---
.../xmodule/modulestore/split_mongo/split.py | 47 ++++++---------
.../tests/test_split_modulestore.py | 59 +++++++++++--------
8 files changed, 76 insertions(+), 92 deletions(-)
diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py
index 26c49843b5..30e9e29451 100644
--- a/cms/djangoapps/contentstore/tests/test_utils.py
+++ b/cms/djangoapps/contentstore/tests/test_utils.py
@@ -56,21 +56,27 @@ class LMSLinksTestCase(TestCase):
def get_about_page_link(self):
""" create mock course and return the about page link """
location = 'i4x', 'mitX', '101', 'course', 'test'
- utils.get_course_id = mock.Mock(return_value="mitX/101/test")
return utils.get_lms_link_for_about_page(location)
def lms_link_test(self):
""" Tests get_lms_link_for_item. """
location = 'i4x', 'mitX', '101', 'vertical', 'contacting_us'
- utils.get_course_id = mock.Mock(return_value="mitX/101/test")
- link = utils.get_lms_link_for_item(location, False)
+ link = utils.get_lms_link_for_item(location, False, "mitX/101/test")
self.assertEquals(link, "//localhost:8000/courses/mitX/101/test/jump_to/i4x://mitX/101/vertical/contacting_us")
- link = utils.get_lms_link_for_item(location, True)
+ link = utils.get_lms_link_for_item(location, True, "mitX/101/test")
self.assertEquals(
link,
"//preview/courses/mitX/101/test/jump_to/i4x://mitX/101/vertical/contacting_us"
)
+ # If no course_id is passed in, it is obtained from the location. This is the case for
+ # Studio dashboard.
+ location = 'i4x', 'mitX', '101', 'course', 'test'
+ link = utils.get_lms_link_for_item(location)
+ self.assertEquals(
+ link,
+ "//localhost:8000/courses/mitX/101/test/jump_to/i4x://mitX/101/course/test"
+ )
class ExtraPanelTabTestCase(TestCase):
""" Tests adding and removing extra course tabs. """
diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py
index a2e927ef46..d956a903b6 100644
--- a/cms/djangoapps/contentstore/utils.py
+++ b/cms/djangoapps/contentstore/utils.py
@@ -89,8 +89,17 @@ def get_course_for_item(location):
def get_lms_link_for_item(location, preview=False, course_id=None):
+ """
+ Returns an LMS link to the course with a jump_to to the provided location.
+
+ :param location: the location to jump to
+ :param preview: True if the preview version of LMS should be returned. Default value is false.
+ :param course_id: the course_id within which the location lives. If not specified, the course_id is obtained
+ by calling Location(location).course_id; note that this only works for locations representing courses
+ instead of elements within courses.
+ """
if course_id is None:
- course_id = get_course_id(location)
+ course_id = Location(location).course_id
if settings.LMS_BASE is not None:
if preview:
@@ -136,7 +145,7 @@ def get_lms_link_for_about_page(location):
if about_base is not None:
lms_link = "//{about_base_url}/courses/{course_id}/about".format(
about_base_url=about_base,
- course_id=get_course_id(location)
+ course_id=Location(location).course_id
)
else:
lms_link = None
@@ -144,14 +153,6 @@ def get_lms_link_for_about_page(location):
return lms_link
-def get_course_id(location):
- """
- Returns the course_id from a given the location tuple.
- """
- # TODO: These will need to be changed to point to the particular instance of this problem in the particular course
- return modulestore().get_containing_courses(Location(location))[0].id
-
-
class UnitState(object):
draft = 'draft'
private = 'private'
diff --git a/cms/djangoapps/contentstore/views/user.py b/cms/djangoapps/contentstore/views/user.py
index 8b92107e88..db2c1eb058 100644
--- a/cms/djangoapps/contentstore/views/user.py
+++ b/cms/djangoapps/contentstore/views/user.py
@@ -54,8 +54,7 @@ def index(request):
'name': course.location.name,
}),
get_lms_link_for_item(
- course.location,
- course_id=course.location.course_id,
+ course.location
),
course.display_org_with_default,
course.display_number_with_default,
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index 0b061f5a94..2fe165077a 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -274,7 +274,7 @@ def dashboard(request):
# Global staff can see what courses errored on their dashboard
staff_access = False
errored_courses = {}
- if has_access(user, 'global', 'staff'):
+ if has_access(user, 'global', 'staff') and callable(getattr(modulestore(), 'get_errored_courses')):
# Show any courses that errored on load
staff_access = True
errored_courses = modulestore().get_errored_courses()
diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py
index eb721dfc99..a2297a7d26 100644
--- a/common/lib/xmodule/xmodule/modulestore/__init__.py
+++ b/common/lib/xmodule/xmodule/modulestore/__init__.py
@@ -370,21 +370,6 @@ class ModuleStore(object):
'''
raise NotImplementedError
- def get_containing_courses(self, location):
- '''
- Returns the list of courses that contains the specified location
-
- TODO (cpennington): This should really take a module instance id,
- rather than a location
- '''
- courses = [
- course
- for course in self.get_courses()
- if course.location.org == location.org and course.location.course == location.course
- ]
-
- return courses
-
class ModuleStoreBase(ModuleStore):
'''
diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/base.py b/common/lib/xmodule/xmodule/modulestore/mongo/base.py
index 066197f4b2..4fdbc4aef0 100644
--- a/common/lib/xmodule/xmodule/modulestore/mongo/base.py
+++ b/common/lib/xmodule/xmodule/modulestore/mongo/base.py
@@ -841,13 +841,6 @@ class MongoModuleStore(ModuleStoreBase):
{'_id': True})
return [i['_id'] for i in items]
- def get_errored_courses(self):
- """
- This function doesn't make sense for the mongo modulestore, as courses
- 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
diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py
index c8ed57d027..497bd7f792 100644
--- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py
+++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py
@@ -226,7 +226,7 @@ class SplitMongoModuleStore(ModuleStoreBase):
entry['branch'] = course_locator.branch
return entry
- def get_courses(self, branch, qualifiers=None):
+ def get_courses(self, branch='published', qualifiers=None):
'''
Returns a list of course descriptors matching any given qualifiers.
@@ -235,6 +235,9 @@ class SplitMongoModuleStore(ModuleStoreBase):
Note, this is to find the current head of the named branch type
(e.g., 'draft'). To get specific versions via guid use get_course.
+
+ :param branch: the branch for which to return courses. Default value is 'published'.
+ :param qualifiers: a optional dict restricting which elements should match
'''
if qualifiers is None:
qualifiers = {}
@@ -272,13 +275,6 @@ class SplitMongoModuleStore(ModuleStoreBase):
result = self._load_items(course_entry, [root], 0, lazy=True)
return result[0]
- def get_course_for_item(self, location):
- '''
- Provided for backward compatibility. Is equivalent to calling get_course
- :param location:
- '''
- return self.get_course(location)
-
def has_item(self, block_location):
"""
Returns True if location exists in its course. Returns false if
@@ -313,9 +309,8 @@ class SplitMongoModuleStore(ModuleStoreBase):
raise ItemNotFoundError(location)
return items[0]
- # TODO refactor this and get_courses to use a constructed query
- def get_items(self, locator, qualifiers):
- '''
+ def get_items(self, locator, course_id=None, depth=0, qualifiers=None):
+ """
Get all of the modules in the given course matching the qualifiers. The
qualifiers should only be fields in the structures collection (sorry).
There will be a separate search method for searching through
@@ -331,9 +326,14 @@ class SplitMongoModuleStore(ModuleStoreBase):
try arbitrary queries.
:param locator: CourseLocator or BlockUsageLocator restricting search scope
+ :param course_id: ignored. Only included for API compatibility.
+ :param depth: ignored. Only included for API compatibility.
:param qualifiers: a dict restricting which elements should match
- '''
+
+ """
# TODO extend to only search a subdag of the course?
+ if qualifiers is None:
+ qualifiers = {}
course = self._lookup_course(locator)
items = []
for usage_id, value in course['blocks'].iteritems():
@@ -345,23 +345,22 @@ class SplitMongoModuleStore(ModuleStoreBase):
else:
return []
- # What's the use case for usage_id being separate?
def get_parent_locations(self, locator, usage_id=None):
'''
Return the locations (Locators w/ usage_ids) for the parents of this location in this
course. Could use get_items(location, {'children': usage_id}) but this is slightly faster.
- NOTE: does not actually ensure usage_id exists
- If usage_id is None, then the locator must specify the usage_id
+ NOTE: the locator must contain the usage_id, and this code does not actually ensure usage_id exists
+
+ :param locator: BlockUsageLocator restricting search scope
+ :param usage_id: ignored. Only included for API compatibility. Specify the usage_id within the locator.
'''
- if usage_id is None:
- usage_id = locator.usage_id
+
course = self._lookup_course(locator)
items = []
for parent_id, value in course['blocks'].iteritems():
for child_id in value['children']:
- if usage_id == child_id:
- locator = locator.as_course_locator()
- items.append(BlockUsageLocator(url=locator, usage_id=parent_id))
+ if locator.usage_id == child_id:
+ items.append(BlockUsageLocator(url=locator.as_course_locator(), usage_id=parent_id))
return items
def get_course_index_info(self, course_locator):
@@ -1050,14 +1049,6 @@ class SplitMongoModuleStore(ModuleStoreBase):
# this is the only real delete in the system. should it do something else?
self.course_index.remove(index['_id'])
- # TODO remove all callers and then this
- def get_errored_courses(self):
- """
- This function doesn't make sense for the mongo modulestore, as structures
- are loaded on demand, rather than up front
- """
- return {}
-
def inherit_metadata(self, block_map, block, inheriting_metadata=None):
"""
Updates block with any value
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
index d71223f59b..f86a8dcaef 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
@@ -107,7 +107,7 @@ class SplitModuleCourseTests(SplitModuleTest):
'''
def test_get_courses(self):
- courses = modulestore().get_courses('draft')
+ courses = modulestore().get_courses(branch='draft')
# should have gotten 3 draft courses
self.assertEqual(len(courses), 3, "Wrong number of courses")
# check metadata -- NOTE no promised order
@@ -138,35 +138,40 @@ class SplitModuleCourseTests(SplitModuleTest):
def test_branch_requests(self):
# query w/ branch qualifier (both draft and published)
- courses_published = modulestore().get_courses('published')
- self.assertEqual(len(courses_published), 1, len(courses_published))
- course = self.findByIdInResult(courses_published, "head23456")
- self.assertIsNotNone(course, "published courses")
- self.assertEqual(course.location.course_id, "wonderful")
- self.assertEqual(str(course.location.version_guid), self.GUID_P,
- course.location.version_guid)
- self.assertEqual(course.category, 'course', 'wrong category')
- self.assertEqual(len(course.tabs), 4, "wrong number of tabs")
- self.assertEqual(course.display_name, "The most wonderful course",
- course.display_name)
- self.assertIsNone(course.advertised_start)
- self.assertEqual(len(course.children), 0,
- "children")
+ def _verify_published_course(courses_published):
+ """ Helper function for verifying published course. """
+ self.assertEqual(len(courses_published), 1, len(courses_published))
+ course = self.findByIdInResult(courses_published, "head23456")
+ self.assertIsNotNone(course, "published courses")
+ self.assertEqual(course.location.course_id, "wonderful")
+ self.assertEqual(str(course.location.version_guid), self.GUID_P,
+ course.location.version_guid)
+ self.assertEqual(course.category, 'course', 'wrong category')
+ self.assertEqual(len(course.tabs), 4, "wrong number of tabs")
+ self.assertEqual(course.display_name, "The most wonderful course",
+ course.display_name)
+ self.assertIsNone(course.advertised_start)
+ self.assertEqual(len(course.children), 0,
+ "children")
+
+ _verify_published_course(modulestore().get_courses(branch='published'))
+ # default for branch is 'published'.
+ _verify_published_course(modulestore().get_courses())
def test_search_qualifiers(self):
# query w/ search criteria
- courses = modulestore().get_courses('draft', qualifiers={'org': 'testx'})
+ courses = modulestore().get_courses(branch='draft', qualifiers={'org': 'testx'})
self.assertEqual(len(courses), 2)
self.assertIsNotNone(self.findByIdInResult(courses, "head12345"))
self.assertIsNotNone(self.findByIdInResult(courses, "head23456"))
courses = modulestore().get_courses(
- 'draft',
+ branch='draft',
qualifiers={'edited_on': {"$lt": datetime.datetime(2013, 3, 28, 15)}})
self.assertEqual(len(courses), 2)
courses = modulestore().get_courses(
- 'draft',
+ branch='draft',
qualifiers={'org': 'testx', "prettyid": "test_course"})
self.assertEqual(len(courses), 1)
self.assertIsNotNone(self.findByIdInResult(courses, "head12345"))
@@ -415,14 +420,17 @@ class SplitModuleItemTests(SplitModuleTest):
'''
locator = CourseLocator(version_guid=self.GUID_D0)
# get all modules
- matches = modulestore().get_items(locator, {})
+ matches = modulestore().get_items(locator)
self.assertEqual(len(matches), 6)
- matches = modulestore().get_items(locator, {'category': 'chapter'})
+ matches = modulestore().get_items(locator, qualifiers={})
+ self.assertEqual(len(matches), 6)
+ matches = modulestore().get_items(locator, qualifiers={'category': 'chapter'})
self.assertEqual(len(matches), 3)
- matches = modulestore().get_items(locator, {'category': 'garbage'})
+ matches = modulestore().get_items(locator, qualifiers={'category': 'garbage'})
self.assertEqual(len(matches), 0)
matches = modulestore().get_items(
locator,
+ qualifiers=
{
'category': 'chapter',
'metadata': {'display_name': {'$regex': 'Hera'}}
@@ -430,7 +438,7 @@ class SplitModuleItemTests(SplitModuleTest):
)
self.assertEqual(len(matches), 2)
- matches = modulestore().get_items(locator, {'children': 'chapter2'})
+ matches = modulestore().get_items(locator, qualifiers={'children': 'chapter2'})
self.assertEqual(len(matches), 1)
self.assertEqual(matches[0].location.usage_id, 'head12345')
@@ -438,8 +446,8 @@ class SplitModuleItemTests(SplitModuleTest):
'''
get_parent_locations(locator, [usage_id], [branch]): [BlockUsageLocator]
'''
- locator = CourseLocator(course_id="GreekHero", branch='draft')
- parents = modulestore().get_parent_locations(locator, usage_id='chapter1')
+ locator = BlockUsageLocator(course_id="GreekHero", branch='draft', usage_id='chapter1')
+ parents = modulestore().get_parent_locations(locator)
self.assertEqual(len(parents), 1)
self.assertEqual(parents[0].usage_id, 'head12345')
self.assertEqual(parents[0].course_id, "GreekHero")
@@ -447,7 +455,8 @@ class SplitModuleItemTests(SplitModuleTest):
parents = modulestore().get_parent_locations(locator)
self.assertEqual(len(parents), 1)
self.assertEqual(parents[0].usage_id, 'head12345')
- parents = modulestore().get_parent_locations(locator, usage_id='nosuchblock')
+ locator.usage_id='nosuchblock'
+ parents = modulestore().get_parent_locations(locator)
self.assertEqual(len(parents), 0)
def test_get_children(self):
From f8ef72cd5b5f602d066f12fcae84a0e95b15fe4e Mon Sep 17 00:00:00 2001
From: Lyla Fischer
Date: Tue, 13 Aug 2013 15:38:11 -0400
Subject: [PATCH 039/125] added video changes to the changelog
---
CHANGELOG.rst | 2 ++
1 file changed, 2 insertions(+)
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 2e51f0b834..94c5da86ab 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -5,6 +5,8 @@ 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.
+Blades: Took videoalpha out of alpha, replacing the old video player
+
LMS: Enable beta instructor dashboard. The beta dashboard is a rearchitecture
of the existing instructor dashboard and is available by clicking a link at
the top right of the existing dashboard.
From 4fc8aaab02c4fde4d3a4cc9ea55fe555e854668b Mon Sep 17 00:00:00 2001
From: Adam Palay
Date: Thu, 1 Aug 2013 14:54:50 -0400
Subject: [PATCH 040/125] if mktg, redirects faq to mktg. else, to '/help'
---
lms/templates/help_modal.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lms/templates/help_modal.html b/lms/templates/help_modal.html
index 41a751aae4..5aae550fd9 100644
--- a/lms/templates/help_modal.html
+++ b/lms/templates/help_modal.html
@@ -106,7 +106,7 @@ discussion_link = get_discussion_link(course) if course else None
).format(
open_time=open_time,
close_time=close_time,
- link_start='',
+ link_start=''.format(marketing_link('FAQ')),
link_end=''
)}
From 307d27db9a8dac92c083a6b1392cbad6e0c02c61 Mon Sep 17 00:00:00 2001
From: Adam Palay
Date: Tue, 13 Aug 2013 12:53:39 -0400
Subject: [PATCH 041/125] fix syntax error in help_modal
---
lms/templates/help_modal.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lms/templates/help_modal.html b/lms/templates/help_modal.html
index 5aae550fd9..84cb690a03 100644
--- a/lms/templates/help_modal.html
+++ b/lms/templates/help_modal.html
@@ -193,7 +193,7 @@ discussion_link = get_discussion_link(course) if course else None
% if settings.FEEDBACK_SUBMISSION_EMAIL:
htmlStr += " " + _.template(
gettext("Please {link_start}send us e-mail{link_end}."),
- {link_start: '', link_end=''},
+ {link_start: '', link_end: ''},
{interpolate: /\{(.+?)\}/g})
% else:
// If no email is configured, we can't do much other than
From 172d415929cd04ced917a5bfaebde182f608e846 Mon Sep 17 00:00:00 2001
From: Adam Palay
Date: Tue, 13 Aug 2013 16:11:51 -0400
Subject: [PATCH 042/125] add dummy FEEDBACK_SUBMISSION_EMAIL for debugging
---
lms/envs/dev.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/lms/envs/dev.py b/lms/envs/dev.py
index 44c2edf21a..10501448af 100644
--- a/lms/envs/dev.py
+++ b/lms/envs/dev.py
@@ -31,6 +31,8 @@ MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True
MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True
+FEEDBACK_SUBMISSION_EMAIL = "dummy@dummy.org"
+
WIKI_ENABLED = True
LOGGING = get_logger_config(ENV_ROOT / "log",
From 05539b1bbef92d73df2ab966f391dfd9ec678da2 Mon Sep 17 00:00:00 2001
From: Valera Rozuvan
Date: Tue, 13 Aug 2013 12:29:15 +0300
Subject: [PATCH 043/125] Removed unnecessary tabindex -1 from Video template.
---
lms/templates/video.html | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/lms/templates/video.html b/lms/templates/video.html
index ab3fd08d0c..90b3e5b25f 100644
--- a/lms/templates/video.html
+++ b/lms/templates/video.html
@@ -45,15 +45,15 @@
${_('Fill browser')}
From 0a5d261fc422dc237f7e59f25aa742948eb960d5 Mon Sep 17 00:00:00 2001
From: Valera Rozuvan
Date: Tue, 13 Aug 2013 12:40:01 +0300
Subject: [PATCH 044/125] For YouTube videos tabbing from Speeds to Volume
closes Speeds dialog.
An old TODO item was done. It turns out a simple case of calling
the method to bind handlers after the Spees dialog was re-rendered.
---
.../xmodule/js/src/video/08_video_speed_control.js | 8 ++------
1 file changed, 2 insertions(+), 6 deletions(-)
diff --git a/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js b/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js
index 740a1aa63d..e8cb1e15ed 100644
--- a/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js
+++ b/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js
@@ -151,7 +151,7 @@ function () {
$.each(this.videoSpeedControl.speeds, function(index, speed) {
var link, listItem;
- link = '' + speed + 'x';
+ link = '' + speed + 'x';
listItem = $('
' + link + '
');
@@ -162,11 +162,7 @@ function () {
_this.videoSpeedControl.videoSpeedsEl.prepend(listItem);
});
- this.videoSpeedControl.videoSpeedsEl.find('a')
- .on('click', this.videoSpeedControl.changeVideoSpeed);
-
- // TODO: After the control was re-rendered, we should attach 'focus'
- // and 'blur' events once more.
+ _bindHandlers(this);
}
});
From da3e21ceee67bde92452c99515785ff85729ec47 Mon Sep 17 00:00:00 2001
From: Valera Rozuvan
Date: Tue, 13 Aug 2013 14:51:47 +0300
Subject: [PATCH 045/125] Fixed tabbing backwards.
Now you can tab through all of the controls in Video forwards, and then tab backwards.
---
.../js/src/video/07_video_volume_control.js | 15 ++++++++----
.../js/src/video/08_video_speed_control.js | 24 ++++++++++++++++++-
2 files changed, 33 insertions(+), 6 deletions(-)
diff --git a/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js b/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js
index 6b12783e9e..cb05887144 100644
--- a/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js
+++ b/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js
@@ -77,20 +77,25 @@ function () {
$(this).addClass('open');
});
- state.videoVolumeControl.buttonEl.on('focus', function() {
- $(this).parent().addClass('open');
- });
-
state.videoVolumeControl.el.on('mouseleave', function() {
$(this).removeClass('open');
});
state.videoVolumeControl.buttonEl.on('blur', function() {
- state.videoVolumeControl.volumeSliderEl.find('a').focus();
+ if (state.volumeBlur !== true) {
+ state.videoVolumeControl.el.addClass('open');
+ state.videoVolumeControl.volumeSliderEl.find('a').focus();
+ } else {
+ state.volumeBlur = false;
+ }
});
state.videoVolumeControl.volumeSliderEl.find('a').on('blur', function () {
state.videoVolumeControl.el.removeClass('open');
+
+ state.videoVolumeControl.buttonEl.focus();
+
+ state.volumeBlur = true;
});
}
diff --git a/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js b/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js
index e8cb1e15ed..29424ae4b2 100644
--- a/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js
+++ b/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js
@@ -58,6 +58,8 @@ function () {
);
});
+ state.videoSpeedControl.videoSpeedsEl.find('a:first').addClass('first_speed_el');
+
state.videoSpeedControl.setSpeed(state.speed);
}
@@ -89,7 +91,13 @@ function () {
state.videoSpeedControl.el.children('a')
.on('focus', function () {
- $(this).parent().addClass('open');
+ if (state.firstSpeedBlur === true) {
+ $(this).parent().removeClass('open');
+
+ state.firstSpeedBlur = false;
+ } else {
+ $(this).parent().addClass('open');
+ }
})
.on('blur', function () {
state.videoSpeedControl.videoSpeedsEl
@@ -101,6 +109,16 @@ function () {
.on('blur', function () {
state.videoSpeedControl.el.removeClass('open');
});
+
+ state.videoSpeedControl.videoSpeedsEl.find('a.speed_link:first')
+ .on('blur', function () {
+ state.firstSpeedBlur = true;
+ });
+
+ state.videoSpeedControl.videoSpeedsEl.find('a.speed_link')
+ .on('focus', function () {
+ state.firstSpeedBlur = false;
+ });
}
}
@@ -162,6 +180,10 @@ function () {
_this.videoSpeedControl.videoSpeedsEl.prepend(listItem);
});
+ this.videoSpeedControl.videoSpeedsEl
+ .find('a:first')
+ .addClass('first_speed_el');
+
_bindHandlers(this);
}
From efac70e6b392812bf2c61b4389909202faad21d5 Mon Sep 17 00:00:00 2001
From: Valera Rozuvan
Date: Tue, 13 Aug 2013 16:06:05 +0300
Subject: [PATCH 046/125] Adding documentation to the event handlers for volume
and speed control.
---
.../js/src/video/07_video_volume_control.js | 60 ++++++++++---
.../js/src/video/08_video_speed_control.js | 86 +++++++++++++++----
2 files changed, 113 insertions(+), 33 deletions(-)
diff --git a/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js b/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js
index cb05887144..a21a19f23c 100644
--- a/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js
+++ b/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js
@@ -67,36 +67,68 @@ function () {
state.videoVolumeControl.el.toggleClass('muted', state.videoVolumeControl.currentVolume === 0);
}
- // function _bindHandlers(state)
- //
- // Bind any necessary function callbacks to DOM events (click, mousemove, etc.).
+ /**
+ * @desc Bind any necessary function callbacks to DOM events (click,
+ * mousemove, etc.).
+ *
+ * @type {function}
+ * @access private
+ *
+ * @param {object} state The object containg the state of the video player.
+ * All other modules, their parameters, public variables, etc. are
+ * available via this object.
+ *
+ * @this {object} The global window object.
+ *
+ * @returns {undefined}
+ */
function _bindHandlers(state) {
- state.videoVolumeControl.buttonEl.on('click', state.videoVolumeControl.toggleMute);
+ state.videoVolumeControl.buttonEl
+ .on('click', state.videoVolumeControl.toggleMute);
state.videoVolumeControl.el.on('mouseenter', function() {
- $(this).addClass('open');
+ state.videoVolumeControl.el.addClass('open');
});
state.videoVolumeControl.el.on('mouseleave', function() {
- $(this).removeClass('open');
+ state.videoVolumeControl.el.removeClass('open');
});
+ // Attach a focus event to the volume button.
state.videoVolumeControl.buttonEl.on('blur', function() {
- if (state.volumeBlur !== true) {
+ // If the focus is being trasnfered from the volume slider, then we
+ // don't do anything except for unsetting the special flag.
+ if (state.volumeBlur === true) {
+ state.volumeBlur = false;
+ }
+
+ //If the focus is comming from elsewhere, then we must show the
+ // volume slider and set focus to it.
+ else {
state.videoVolumeControl.el.addClass('open');
state.videoVolumeControl.volumeSliderEl.find('a').focus();
- } else {
- state.volumeBlur = false;
}
});
- state.videoVolumeControl.volumeSliderEl.find('a').on('blur', function () {
- state.videoVolumeControl.el.removeClass('open');
+ // Attach a blur event handler (loss of focus) to the volume slider
+ // element. More specifically, we are attaching to the handle on
+ // the slider with which you can change the volume.
+ state.videoVolumeControl.volumeSliderEl.find('a')
+ .on('blur', function () {
+ // Hide the volume slider. This is done so that we can
+ // contrinue to the next (or previous) element by tabbing.
+ // Otherwise, after next tab we would come back to the volume
+ // slider because it is the next element sisible element that
+ // we can tab to after the volume button.
+ state.videoVolumeControl.el.removeClass('open');
- state.videoVolumeControl.buttonEl.focus();
+ // Set focus to the volume button.
+ state.videoVolumeControl.buttonEl.focus();
- state.volumeBlur = true;
- });
+ // We store the fact that previous element that lost focus was
+ // the volume clontrol.
+ state.volumeBlur = true;
+ });
}
// ***************************************************************
diff --git a/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js b/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js
index 29424ae4b2..988ac6b2f0 100644
--- a/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js
+++ b/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js
@@ -58,65 +58,115 @@ function () {
);
});
- state.videoSpeedControl.videoSpeedsEl.find('a:first').addClass('first_speed_el');
-
state.videoSpeedControl.setSpeed(state.speed);
}
- // function _bindHandlers(state)
- //
- // Bind any necessary function callbacks to DOM events (click,
- // mousemove, etc.).
+ /**
+ * @desc Bind any necessary function callbacks to DOM events (click,
+ * mousemove, etc.).
+ *
+ * @type {function}
+ * @access private
+ *
+ * @param {object} state The object containg the state of the video player.
+ * All other modules, their parameters, public variables, etc. are
+ * available via this object.
+ *
+ * @this {object} The global window object.
+ *
+ * @returns {undefined}
+ */
function _bindHandlers(state) {
state.videoSpeedControl.videoSpeedsEl.find('a')
.on('click', state.videoSpeedControl.changeVideoSpeed);
if (onTouchBasedDevice()) {
state.videoSpeedControl.el.on('click', function(event) {
+ // So that you can't highlight this control via a drag
+ // operation, we disable the default browser actions on a
+ // click event.
event.preventDefault();
- $(this).toggleClass('open');
+
+ state.videoSpeedControl.el.toggleClass('open');
});
} else {
state.videoSpeedControl.el
.on('mouseenter', function () {
- $(this).addClass('open');
+ state.videoSpeedControl.el.addClass('open');
})
.on('mouseleave', function () {
- $(this).removeClass('open');
+ state.videoSpeedControl.el.removeClass('open');
})
.on('click', function (event) {
+ // So that you can't highlight this control via a drag
+ // operation, we disable the default browser actions on a
+ // click event.
event.preventDefault();
- $(this).removeClass('open');
+
+ state.videoSpeedControl.el.removeClass('open');
});
+ // ******************************
+ // Attach 'focus', and 'blur' events to the speed button which
+ // either brings up the speed dialog with individual speed entries,
+ // or closes it.
state.videoSpeedControl.el.children('a')
.on('focus', function () {
+ // If the focus is comming from the first speed entry, this
+ // means we are tabbing backwards. In this case we have to
+ // hide the speed entries which will allow us to change the
+ // focus further backwards.
if (state.firstSpeedBlur === true) {
- $(this).parent().removeClass('open');
+ state.videoSpeedControl.el.removeClass('open');
state.firstSpeedBlur = false;
- } else {
- $(this).parent().addClass('open');
+ }
+
+ // If the focus is comming from some other element, show
+ // the drop down with the speed entries.
+ else {
+ state.videoSpeedControl.el.addClass('open');
}
})
.on('blur', function () {
+ // When the focus leaves this element, if the speed entries
+ // dialog is shown (tabbing forwards), then we will set
+ // focus to the first speed entry.
+ //
+ // If the selector does not select anything, then this
+ // means that the speed entries dialog is closed, and we
+ // are tabbing backwads. The browser will select the
+ // previous element to tab to by itself.
state.videoSpeedControl.videoSpeedsEl
.find('a.speed_link:first')
.focus();
});
+
+ // ******************************
+ // Attach 'focus', and 'blur' events to elements which represent
+ // individual speed entries.
state.videoSpeedControl.videoSpeedsEl.find('a.speed_link:last')
.on('blur', function () {
+ // If we have reached the last speed enrty, and the focus
+ // changes to the next element, we need to hide the speeds
+ // control drop-down.
state.videoSpeedControl.el.removeClass('open');
});
-
state.videoSpeedControl.videoSpeedsEl.find('a.speed_link:first')
.on('blur', function () {
+ // This flag will indicate that the focus to the next
+ // element that will receive it is comming from the first
+ // speed entry.
+ //
+ // This flag will be used to correctly handle scenario of
+ // tabbing backwards.
state.firstSpeedBlur = true;
});
-
state.videoSpeedControl.videoSpeedsEl.find('a.speed_link')
.on('focus', function () {
+ // Clear the flag which is only set when we are un-focusing
+ // (the blur event) from the first speed entry.
state.firstSpeedBlur = false;
});
}
@@ -180,10 +230,8 @@ function () {
_this.videoSpeedControl.videoSpeedsEl.prepend(listItem);
});
- this.videoSpeedControl.videoSpeedsEl
- .find('a:first')
- .addClass('first_speed_el');
-
+ // Re-attach all events with their appropriate callbacks to the
+ // newly generated elements.
_bindHandlers(this);
}
From 3b326c307204226be35c552e315273476c53b3e0 Mon Sep 17 00:00:00 2001
From: Valera Rozuvan
Date: Wed, 14 Aug 2013 11:48:59 +0300
Subject: [PATCH 047/125] Fixing spelling typos in comments.
---
.../xmodule/xmodule/js/src/video/07_video_volume_control.js | 4 ++--
.../xmodule/xmodule/js/src/video/08_video_speed_control.js | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js b/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js
index a21a19f23c..785401aa65 100644
--- a/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js
+++ b/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js
@@ -116,9 +116,9 @@ function () {
state.videoVolumeControl.volumeSliderEl.find('a')
.on('blur', function () {
// Hide the volume slider. This is done so that we can
- // contrinue to the next (or previous) element by tabbing.
+ // continue to the next (or previous) element by tabbing.
// Otherwise, after next tab we would come back to the volume
- // slider because it is the next element sisible element that
+ // slider because it is the next element visible element that
// we can tab to after the volume button.
state.videoVolumeControl.el.removeClass('open');
diff --git a/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js b/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js
index 988ac6b2f0..87d0b31b10 100644
--- a/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js
+++ b/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js
@@ -148,7 +148,7 @@ function () {
// individual speed entries.
state.videoSpeedControl.videoSpeedsEl.find('a.speed_link:last')
.on('blur', function () {
- // If we have reached the last speed enrty, and the focus
+ // If we have reached the last speed entry, and the focus
// changes to the next element, we need to hide the speeds
// control drop-down.
state.videoSpeedControl.el.removeClass('open');
From 99ae63777e317183fa7fb5ac9fa0f35603dab337 Mon Sep 17 00:00:00 2001
From: Valera Rozuvan
Date: Wed, 14 Aug 2013 11:51:15 +0300
Subject: [PATCH 048/125] Removed unnecessary tabindex = 0 from - they
get it by default.
---
.../xmodule/js/src/video/07_video_volume_control.js | 3 ---
lms/templates/video.html | 10 +++++-----
2 files changed, 5 insertions(+), 8 deletions(-)
diff --git a/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js b/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js
index 785401aa65..90154d2079 100644
--- a/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js
+++ b/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js
@@ -61,9 +61,6 @@ function () {
slide: state.videoVolumeControl.onChange
});
- // Make sure that we can focus the actual volume slider while Tabing.
- state.videoVolumeControl.volumeSliderEl.find('a').attr('tabindex', '0');
-
state.videoVolumeControl.el.toggleClass('muted', state.videoVolumeControl.currentVolume === 0);
}
diff --git a/lms/templates/video.html b/lms/templates/video.html
index 90b3e5b25f..43f36915a0 100644
--- a/lms/templates/video.html
+++ b/lms/templates/video.html
@@ -39,25 +39,25 @@
From b583a4e7932d19f6a39f34bc3b2295abbd69ae8f Mon Sep 17 00:00:00 2001
From: Valera Rozuvan
Date: Wed, 14 Aug 2013 11:56:13 +0300
Subject: [PATCH 049/125] Optimizing code. Caching an element selector.
---
.../js/src/video/08_video_speed_control.js | 48 ++++++++++---------
1 file changed, 25 insertions(+), 23 deletions(-)
diff --git a/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js b/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js
index 87d0b31b10..27e4888d0d 100644
--- a/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js
+++ b/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js
@@ -77,6 +77,8 @@ function () {
* @returns {undefined}
*/
function _bindHandlers(state) {
+ var speedLinks;
+
state.videoSpeedControl.videoSpeedsEl.find('a')
.on('click', state.videoSpeedControl.changeVideoSpeed);
@@ -146,29 +148,29 @@ function () {
// ******************************
// Attach 'focus', and 'blur' events to elements which represent
// individual speed entries.
- state.videoSpeedControl.videoSpeedsEl.find('a.speed_link:last')
- .on('blur', function () {
- // If we have reached the last speed entry, and the focus
- // changes to the next element, we need to hide the speeds
- // control drop-down.
- state.videoSpeedControl.el.removeClass('open');
- });
- state.videoSpeedControl.videoSpeedsEl.find('a.speed_link:first')
- .on('blur', function () {
- // This flag will indicate that the focus to the next
- // element that will receive it is comming from the first
- // speed entry.
- //
- // This flag will be used to correctly handle scenario of
- // tabbing backwards.
- state.firstSpeedBlur = true;
- });
- state.videoSpeedControl.videoSpeedsEl.find('a.speed_link')
- .on('focus', function () {
- // Clear the flag which is only set when we are un-focusing
- // (the blur event) from the first speed entry.
- state.firstSpeedBlur = false;
- });
+ speedLinks = state.videoSpeedControl.videoSpeedsEl
+ .find('a.speed_link');
+
+ speedLinks.last().on('blur', function () {
+ // If we have reached the last speed entry, and the focus
+ // changes to the next element, we need to hide the speeds
+ // control drop-down.
+ state.videoSpeedControl.el.removeClass('open');
+ });
+ speedLinks.first().on('blur', function () {
+ // This flag will indicate that the focus to the next
+ // element that will receive it is comming from the first
+ // speed entry.
+ //
+ // This flag will be used to correctly handle scenario of
+ // tabbing backwards.
+ state.firstSpeedBlur = true;
+ });
+ speedLinks.on('focus', function () {
+ // Clear the flag which is only set when we are un-focusing
+ // (the blur event) from the first speed entry.
+ state.firstSpeedBlur = false;
+ });
}
}
From 70d5ec6ea4fb8e434b19586a23110a0f743498fb Mon Sep 17 00:00:00 2001
From: cahrens
Date: Wed, 14 Aug 2013 09:04:58 -0400
Subject: [PATCH 050/125] Make it clear that get_course_for_item is only a
helper method in mongo modulestore.
---
common/lib/xmodule/xmodule/modulestore/mongo/base.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/base.py b/common/lib/xmodule/xmodule/modulestore/mongo/base.py
index 4fdbc4aef0..21daff1875 100644
--- a/common/lib/xmodule/xmodule/modulestore/mongo/base.py
+++ b/common/lib/xmodule/xmodule/modulestore/mongo/base.py
@@ -681,7 +681,7 @@ class MongoModuleStore(ModuleStoreBase):
# 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)
+ course = self._get_course_for_item(location)
existing_tabs = course.tabs or []
existing_tabs.append({
'type': 'static_tab',
@@ -701,7 +701,7 @@ class MongoModuleStore(ModuleStoreBase):
self.modulestore_update_signal.send(self, modulestore=self, course_id=course_id,
location=location)
- def get_course_for_item(self, location, depth=0):
+ def _get_course_for_item(self, location, depth=0):
'''
VS[compat]
cdodge: for a given Xmodule, return the course that it belongs to
@@ -790,7 +790,7 @@ class MongoModuleStore(ModuleStoreBase):
# we should remove this once we can break this reference from the course to static tabs
loc = Location(location)
if loc.category == 'static_tab':
- course = self.get_course_for_item(loc)
+ course = self._get_course_for_item(loc)
existing_tabs = course.tabs or []
for tab in existing_tabs:
if tab.get('url_slug') == loc.name:
@@ -818,7 +818,7 @@ class MongoModuleStore(ModuleStoreBase):
# we should remove this once we can break this reference from the course to static tabs
if location.category == 'static_tab':
item = self.get_item(location)
- course = self.get_course_for_item(item.location)
+ 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
From aed29cb01fb851ecbf7788a917f4f9deb02218ee Mon Sep 17 00:00:00 2001
From: cahrens
Date: Wed, 14 Aug 2013 09:50:00 -0400
Subject: [PATCH 051/125] Add get_instance method.
---
.../xmodule/modulestore/split_mongo/split.py | 13 +++++++
.../tests/test_split_modulestore.py | 34 ++++++++++++-------
2 files changed, 34 insertions(+), 13 deletions(-)
diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py
index 497bd7f792..74c7e7241a 100644
--- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py
+++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py
@@ -345,6 +345,19 @@ class SplitMongoModuleStore(ModuleStoreBase):
else:
return []
+ def get_instance(self, course_id, location, depth=0):
+ """
+ Get an instance of this location.
+
+ For now, just delegate to get_item and ignore course policy.
+
+ depth (int): An argument that some module stores may use to prefetch
+ descendants of the queried modules for more efficient results later
+ in the request. The depth is counted in the number of
+ calls to get_children() to cache. None indicates to cache all descendants.
+ """
+ return self.get_item(location, depth=depth)
+
def get_parent_locations(self, locator, usage_id=None):
'''
Return the locations (Locators w/ usage_ids) for the parents of this location in this
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
index f86a8dcaef..086d85b72c 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
@@ -327,21 +327,29 @@ class SplitModuleItemTests(SplitModuleTest):
locator = BlockUsageLocator(version_guid=self.GUID_D1, usage_id='head12345')
block = modulestore().get_item(locator)
self.assertIsInstance(block, CourseDescriptor)
+ # get_instance just redirects to get_item, ignores course_id
+ self.assertIsInstance(modulestore().get_instance("course_id", locator), CourseDescriptor)
+
+ def verify_greek_hero(block):
+ self.assertEqual(block.location.course_id, "GreekHero")
+
+
+ # look at this one in detail
+ self.assertEqual(len(block.tabs), 6, "wrong number of tabs")
+ self.assertEqual(block.display_name, "The Ancient Greek Hero")
+ self.assertEqual(block.advertised_start, "Fall 2013")
+ self.assertEqual(len(block.children), 3)
+ self.assertEqual(block.definition_locator.definition_id, "head12345_12")
+ # check dates and graders--forces loading of descriptor
+ self.assertEqual(block.edited_by, "testassist@edx.org")
+ self.assertDictEqual(
+ block.grade_cutoffs, {"Pass": 0.45},
+ )
locator = BlockUsageLocator(course_id='GreekHero', usage_id='head12345', branch='draft')
- block = modulestore().get_item(locator)
- self.assertEqual(block.location.course_id, "GreekHero")
- # look at this one in detail
- self.assertEqual(len(block.tabs), 6, "wrong number of tabs")
- self.assertEqual(block.display_name, "The Ancient Greek Hero")
- self.assertEqual(block.advertised_start, "Fall 2013")
- self.assertEqual(len(block.children), 3)
- self.assertEqual(block.definition_locator.definition_id, "head12345_12")
- # check dates and graders--forces loading of descriptor
- self.assertEqual(block.edited_by, "testassist@edx.org")
- self.assertDictEqual(
- block.grade_cutoffs, {"Pass": 0.45},
- )
+ verify_greek_hero(modulestore().get_item(locator))
+ # get_instance just redirects to get_item, ignores course_id
+ verify_greek_hero(modulestore().get_instance("course_id", locator))
# try to look up other branches
self.assertRaises(ItemNotFoundError,
From 13ef870b1bf09f709509fa8469d9bcb90d5655bb Mon Sep 17 00:00:00 2001
From: cahrens
Date: Wed, 14 Aug 2013 09:54:30 -0400
Subject: [PATCH 052/125] pylint/pep8 cleanup.
cleanup.
---
cms/djangoapps/contentstore/tests/test_utils.py | 4 +---
.../xmodule/modulestore/tests/test_split_modulestore.py | 5 +----
2 files changed, 2 insertions(+), 7 deletions(-)
diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py
index 30e9e29451..c3335aaaa0 100644
--- a/cms/djangoapps/contentstore/tests/test_utils.py
+++ b/cms/djangoapps/contentstore/tests/test_utils.py
@@ -5,8 +5,6 @@ import collections
import copy
from django.test import TestCase
from django.test.utils import override_settings
-from xmodule.modulestore.tests.factories import CourseFactory
-from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
class LMSLinksTestCase(TestCase):
@@ -78,6 +76,7 @@ class LMSLinksTestCase(TestCase):
"//localhost:8000/courses/mitX/101/test/jump_to/i4x://mitX/101/course/test"
)
+
class ExtraPanelTabTestCase(TestCase):
""" Tests adding and removing extra course tabs. """
@@ -151,4 +150,3 @@ class ExtraPanelTabTestCase(TestCase):
changed, actual_tabs = utils.remove_extra_panel_tab(tab_type, course)
self.assertFalse(changed)
self.assertEqual(actual_tabs, expected_tabs)
-
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
index 086d85b72c..9976a33a00 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
@@ -332,9 +332,6 @@ class SplitModuleItemTests(SplitModuleTest):
def verify_greek_hero(block):
self.assertEqual(block.location.course_id, "GreekHero")
-
-
- # look at this one in detail
self.assertEqual(len(block.tabs), 6, "wrong number of tabs")
self.assertEqual(block.display_name, "The Ancient Greek Hero")
self.assertEqual(block.advertised_start, "Fall 2013")
@@ -463,7 +460,7 @@ class SplitModuleItemTests(SplitModuleTest):
parents = modulestore().get_parent_locations(locator)
self.assertEqual(len(parents), 1)
self.assertEqual(parents[0].usage_id, 'head12345')
- locator.usage_id='nosuchblock'
+ locator.usage_id = 'nosuchblock'
parents = modulestore().get_parent_locations(locator)
self.assertEqual(len(parents), 0)
From 6594401d2f2fee62a8de9818306bc420ea10380e Mon Sep 17 00:00:00 2001
From: Adam Palay
Date: Thu, 1 Aug 2013 14:54:50 -0400
Subject: [PATCH 053/125] if mktg, redirects faq to mktg. else, to '/help'
---
lms/templates/help_modal.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lms/templates/help_modal.html b/lms/templates/help_modal.html
index 41a751aae4..5aae550fd9 100644
--- a/lms/templates/help_modal.html
+++ b/lms/templates/help_modal.html
@@ -106,7 +106,7 @@ discussion_link = get_discussion_link(course) if course else None
).format(
open_time=open_time,
close_time=close_time,
- link_start='',
+ link_start=''.format(marketing_link('FAQ')),
link_end=''
)}
From 970f5f60d3589070862debd06235f1182b40f0a3 Mon Sep 17 00:00:00 2001
From: Adam Palay
Date: Tue, 13 Aug 2013 12:53:39 -0400
Subject: [PATCH 054/125] fix syntax error in help_modal
---
lms/templates/help_modal.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lms/templates/help_modal.html b/lms/templates/help_modal.html
index 5aae550fd9..84cb690a03 100644
--- a/lms/templates/help_modal.html
+++ b/lms/templates/help_modal.html
@@ -193,7 +193,7 @@ discussion_link = get_discussion_link(course) if course else None
% if settings.FEEDBACK_SUBMISSION_EMAIL:
htmlStr += " " + _.template(
gettext("Please {link_start}send us e-mail{link_end}."),
- {link_start: '', link_end=''},
+ {link_start: '', link_end: ''},
{interpolate: /\{(.+?)\}/g})
% else:
// If no email is configured, we can't do much other than
From 5fd09ca1ee31f9503bcce13dbb6cb3f68cee3796 Mon Sep 17 00:00:00 2001
From: Adam Palay
Date: Tue, 13 Aug 2013 16:11:51 -0400
Subject: [PATCH 055/125] add dummy FEEDBACK_SUBMISSION_EMAIL for debugging
---
lms/envs/dev.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/lms/envs/dev.py b/lms/envs/dev.py
index 622ff6acf7..d423e1f347 100644
--- a/lms/envs/dev.py
+++ b/lms/envs/dev.py
@@ -31,6 +31,8 @@ MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True
MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = False
+FEEDBACK_SUBMISSION_EMAIL = "dummy@dummy.org"
+
WIKI_ENABLED = True
LOGGING = get_logger_config(ENV_ROOT / "log",
From 72fdc414ca43707364e7c5deb846dba689e0f48d Mon Sep 17 00:00:00 2001
From: Adam Palay
Date: Wed, 14 Aug 2013 10:57:03 -0400
Subject: [PATCH 056/125] change FEEDBACK_SUBMISSION_EMAIL to @example.com in
dev settings
---
lms/envs/dev.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lms/envs/dev.py b/lms/envs/dev.py
index d423e1f347..1ed0aa5810 100644
--- a/lms/envs/dev.py
+++ b/lms/envs/dev.py
@@ -31,7 +31,7 @@ MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True
MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = False
-FEEDBACK_SUBMISSION_EMAIL = "dummy@dummy.org"
+FEEDBACK_SUBMISSION_EMAIL = "dummy@example.com"
WIKI_ENABLED = True
From b4df2c4d73e25186afbf667e34d2bc0e50e3267c Mon Sep 17 00:00:00 2001
From: Anton Stupak
Date: Wed, 14 Aug 2013 18:10:33 +0300
Subject: [PATCH 057/125] Add filter for playback rates.
---
.../xmodule/js/src/video/03_video_player.js | 16 +++++++++++++++-
1 file changed, 15 insertions(+), 1 deletion(-)
diff --git a/common/lib/xmodule/xmodule/js/src/video/03_video_player.js b/common/lib/xmodule/xmodule/js/src/video/03_video_player.js
index 5bf1b8f66d..25617283f5 100644
--- a/common/lib/xmodule/xmodule/js/src/video/03_video_player.js
+++ b/common/lib/xmodule/xmodule/js/src/video/03_video_player.js
@@ -315,7 +315,21 @@ function (HTML5Video) {
this.videoPlayer.log('load_video');
- availablePlaybackRates = this.videoPlayer.player.getAvailablePlaybackRates();
+ availablePlaybackRates = this.videoPlayer.player
+ .getAvailablePlaybackRates();
+
+ // Because of problems with muting sound outside of range 0.25 and
+ // 5.0, we should filter our available playback rates.
+ // Issues:
+ // https://code.google.com/p/chromium/issues/detail?id=264341
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=840745
+ // https://developer.mozilla.org/en-US/docs/DOM/HTMLMediaElement
+
+ availablePlaybackRates = _.filter(availablePlaybackRates, function(item){
+ var speed = Number(item);
+ return speed > 0.25 && speed <= 5;
+ });
+
if ((this.currentPlayerMode === 'html5') && (this.videoType === 'youtube')) {
if (availablePlaybackRates.length === 1) {
// This condition is needed in cases when Firefox version is less than 20. In those versions
From 3ce87583ab6be7fde4ccfa5aa7be360ff451f505 Mon Sep 17 00:00:00 2001
From: David Ormsbee
Date: Mon, 12 Aug 2013 09:20:13 -0400
Subject: [PATCH 058/125] Shift enroll/unenroll logic to CourseEnrollment
model, add is_active and mode.
Features coming down the pipe will want to be able to:
* Refer to enrollments before they are actually activated (approval step).
* See what courses a user used to be enrolled in for when they re-enroll in
the same course, or a different run of that course.
* Have different "modes" of enrolling in a course, representing things like
honor certificate enrollment, auditing (no certs), etc.
This change adds an is_active flag and mode (with default being "honor").
The commit is only as large as it is because many parts of the codebase were
manipulating enrollments by adding and removing CourseEnrollment objects
directly. It was necessary to create classmethods on CourseEnrollment to
encapsulate this functionality and then port everything over to using them.
The migration to add columns has been tested on a prod replica, and seems to be
fine for running on a live system with single digit millions of rows of
enrollments.
---
CHANGELOG.rst | 9 +
.../contentstore/tests/test_contentstore.py | 8 +-
.../contentstore/tests/test_users.py | 6 +-
cms/djangoapps/contentstore/views/course.py | 4 +-
cms/djangoapps/contentstore/views/user.py | 6 +-
.../django_comment_common/models.py | 14 ++
.../djangoapps/django_comment_common/tests.py | 58 +++++
.../external_auth/tests/test_shib.py | 10 +-
.../commands/create_random_users.py | 2 +-
..._flag_and_mode_to_courseware_enrollment.py | 183 +++++++++++++++
common/djangoapps/student/models.py | 213 +++++++++++++++++-
common/djangoapps/student/tests/tests.py | 132 ++++++++++-
common/djangoapps/student/views.py | 50 ++--
common/djangoapps/terrain/course_helpers.py | 2 +-
.../internal_data_formats/sql_schema.rst | 9 +-
lms/djangoapps/analytics/basic.py | 6 +-
lms/djangoapps/analytics/tests/test_basic.py | 3 +-
.../analytics/tests/test_distributions.py | 9 +-
lms/djangoapps/courseware/features/common.py | 2 +-
.../courseware/features/navigation.py | 2 +-
lms/djangoapps/courseware/tests/test_views.py | 6 +-
lms/djangoapps/courseware/views.py | 4 +-
lms/djangoapps/dashboard/views.py | 32 +--
.../commands/assign_roles_for_course.py | 2 +-
.../commands/create_roles_for_existing.py | 2 +-
lms/djangoapps/django_comment_client/tests.py | 4 +-
lms/djangoapps/instructor/enrollment.py | 11 +-
.../instructor/offline_gradecalc.py | 5 +-
lms/djangoapps/instructor/tests/test_api.py | 17 +-
.../instructor/tests/test_enrollment.py | 5 +-
.../tests/test_legacy_enrollment.py | 31 +--
lms/djangoapps/instructor/views/legacy.py | 29 ++-
32 files changed, 733 insertions(+), 143 deletions(-)
create mode 100644 common/djangoapps/django_comment_common/tests.py
create mode 100644 common/djangoapps/student/migrations/0027_add_active_flag_and_mode_to_courseware_enrollment.py
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 2e51f0b834..44b6b38447 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -9,6 +9,15 @@ LMS: Enable beta instructor dashboard. The beta dashboard is a rearchitecture
of the existing instructor dashboard and is available by clicking a link at
the top right of the existing dashboard.
+Common: CourseEnrollment has new fields `is_active` and `mode`. The mode will be
+used to differentiate different kinds of enrollments (currently, all enrollments
+are honor certificate enrollments). The `is_active` flag will be used to
+deactivate enrollments without deleting them, so that we know what course you
+*were* enrolled in. Because of the latter change, enrollment and unenrollment
+logic has been consolidated into the model -- you should use new class methods
+to `enroll()`, `unenroll()`, and to check `is_enrolled()`, instead of creating
+CourseEnrollment objects or querying them directly.
+
Studio: Email will be sent to admin address when a user requests course creator
privileges for Studio (edge only).
diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py
index 86369f73d9..e70df4164a 100644
--- a/cms/djangoapps/contentstore/tests/test_contentstore.py
+++ b/cms/djangoapps/contentstore/tests/test_contentstore.py
@@ -49,7 +49,7 @@ import datetime
from pytz import UTC
from uuid import uuid4
from pymongo import MongoClient
-from student.views import is_enrolled_in_course
+from student.models import CourseEnrollment
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
TEST_DATA_CONTENTSTORE['OPTIONS']['db'] = 'test_xcontent_%s' % uuid4().hex
@@ -1168,7 +1168,7 @@ class ContentStoreTest(ModuleStoreTestCase):
self.assertNotIn('ErrMsg', data)
self.assertEqual(data['id'], 'i4x://MITx/{0}/course/2013_Spring'.format(test_course_data['number']))
# Verify that the creator is now registered in the course.
- self.assertTrue(is_enrolled_in_course(self.user, self._get_course_id(test_course_data)))
+ self.assertTrue(CourseEnrollment.is_enrolled(self.user, self._get_course_id(test_course_data)))
return test_course_data
def test_create_course_check_forum_seeding(self):
@@ -1190,14 +1190,14 @@ class ContentStoreTest(ModuleStoreTestCase):
Checks that the course did not get created
"""
course_id = self._get_course_id(self.course_data)
- initially_enrolled = is_enrolled_in_course(self.user, course_id)
+ initially_enrolled = CourseEnrollment.is_enrolled(self.user, course_id)
resp = self.client.post(reverse('create_new_course'), self.course_data)
self.assertEqual(resp.status_code, 200)
data = parse_json(resp)
self.assertEqual(data['ErrMsg'], error_message)
# One test case involves trying to create the same course twice. Hence for that course,
# the user will be enrolled. In the other cases, initially_enrolled will be False.
- self.assertEqual(initially_enrolled, is_enrolled_in_course(self.user, course_id))
+ self.assertEqual(initially_enrolled, CourseEnrollment.is_enrolled(self.user, course_id))
def test_create_course_duplicate_number(self):
"""Test new course creation - error path"""
diff --git a/cms/djangoapps/contentstore/tests/test_users.py b/cms/djangoapps/contentstore/tests/test_users.py
index a9216da612..cbb8aa8b01 100644
--- a/cms/djangoapps/contentstore/tests/test_users.py
+++ b/cms/djangoapps/contentstore/tests/test_users.py
@@ -6,7 +6,7 @@ from .utils import CourseTestCase
from django.contrib.auth.models import User, Group
from django.core.urlresolvers import reverse
from auth.authz import get_course_groupname_for_role
-from student.views import is_enrolled_in_course
+from student.models import CourseEnrollment
class UsersTestCase(CourseTestCase):
@@ -372,13 +372,13 @@ class UsersTestCase(CourseTestCase):
def assert_not_enrolled(self):
""" Asserts that self.ext_user is not enrolled in self.course. """
self.assertFalse(
- is_enrolled_in_course(self.ext_user, self.course.location.course_id),
+ CourseEnrollment.is_enrolled(self.ext_user, self.course.location.course_id),
'Did not expect ext_user to be enrolled in course'
)
def assert_enrolled(self):
""" Asserts that self.ext_user is enrolled in self.course. """
self.assertTrue(
- is_enrolled_in_course(self.ext_user, self.course.location.course_id),
+ CourseEnrollment.is_enrolled(self.ext_user, self.course.location.course_id),
'User ext_user should have been enrolled in the course'
)
diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py
index 8ac1d223cb..a6b1b29aab 100644
--- a/cms/djangoapps/contentstore/views/course.py
+++ b/cms/djangoapps/contentstore/views/course.py
@@ -44,7 +44,7 @@ from .component import (
from django_comment_common.utils import seed_permissions_roles
-from student.views import enroll_in_course
+from student.models import CourseEnrollment
from xmodule.html_module import AboutDescriptor
__all__ = ['course_index', 'create_new_course', 'course_info',
@@ -165,7 +165,7 @@ def create_new_course(request):
seed_permissions_roles(new_course.location.course_id)
# auto-enroll the course creator in the course so that "View Live" will work.
- enroll_in_course(request.user, new_course.location.course_id)
+ CourseEnrollment.enroll(request.user, new_course.location.course_id)
return JsonResponse({'id': new_course.location.url()})
diff --git a/cms/djangoapps/contentstore/views/user.py b/cms/djangoapps/contentstore/views/user.py
index 8b92107e88..d98931f65e 100644
--- a/cms/djangoapps/contentstore/views/user.py
+++ b/cms/djangoapps/contentstore/views/user.py
@@ -24,7 +24,7 @@ from course_creators.views import (
from .access import has_access
-from student.views import enroll_in_course
+from student.models import CourseEnrollment
@login_required
@@ -208,7 +208,7 @@ def course_team_user(request, org, course, name, email):
user.groups.add(groups["instructor"])
user.save()
# auto-enroll the course creator in the course so that "View Live" will work.
- enroll_in_course(user, location.course_id)
+ CourseEnrollment.enroll(user, location.course_id)
elif role == "staff":
# if we're trying to downgrade a user from "instructor" to "staff",
# make sure we have at least one other instructor in the course team.
@@ -223,7 +223,7 @@ def course_team_user(request, org, course, name, email):
user.groups.add(groups["staff"])
user.save()
# auto-enroll the course creator in the course so that "View Live" will work.
- enroll_in_course(user, location.course_id)
+ CourseEnrollment.enroll(user, location.course_id)
return JsonResponse()
diff --git a/common/djangoapps/django_comment_common/models.py b/common/djangoapps/django_comment_common/models.py
index ec722b718a..7878f1b453 100644
--- a/common/djangoapps/django_comment_common/models.py
+++ b/common/djangoapps/django_comment_common/models.py
@@ -19,6 +19,20 @@ FORUM_ROLE_STUDENT = 'Student'
@receiver(post_save, sender=CourseEnrollment)
def assign_default_role(sender, instance, **kwargs):
+ # The code below would remove all forum Roles from a user when they unenroll
+ # from a course. Concerns were raised that it should apply only to students,
+ # or that even the history of student roles is important for research
+ # purposes. Since this was new functionality being added in this release,
+ # I'm just going to comment it out for now and let the forums team deal with
+ # implementing the right behavior.
+ #
+ # # We've unenrolled the student, so remove all roles for this course
+ # if not instance.is_active:
+ # course_roles = list(Role.objects.filter(course_id=instance.course_id))
+ # instance.user.roles.remove(*course_roles)
+ # return
+
+ # We've enrolled the student, so make sure they have a default role
if instance.user.is_staff:
role = Role.objects.get_or_create(course_id=instance.course_id, name="Moderator")[0]
else:
diff --git a/common/djangoapps/django_comment_common/tests.py b/common/djangoapps/django_comment_common/tests.py
new file mode 100644
index 0000000000..47790f1e1e
--- /dev/null
+++ b/common/djangoapps/django_comment_common/tests.py
@@ -0,0 +1,58 @@
+from django.test import TestCase
+
+from django_comment_common.models import Role
+from student.models import CourseEnrollment, User
+
+class RoleAssignmentTest(TestCase):
+ """
+ Basic checks to make sure our Roles get assigned and unassigned as students
+ are enrolled and unenrolled from a course.
+ """
+
+ def setUp(self):
+ self.staff_user = User.objects.create_user(
+ "patty",
+ "patty@fake.edx.org",
+ )
+ self.staff_user.is_staff = True
+
+ self.student_user = User.objects.create_user(
+ "hacky",
+ "hacky@fake.edx.org"
+ )
+ self.course_id = "edX/Fake101/2012"
+ CourseEnrollment.enroll(self.staff_user, self.course_id)
+ CourseEnrollment.enroll(self.student_user, self.course_id)
+
+ def test_enrollment_auto_role_creation(self):
+ moderator_role = Role.objects.get(
+ course_id=self.course_id,
+ name="Moderator"
+ )
+ student_role = Role.objects.get(
+ course_id=self.course_id,
+ name="Student"
+ )
+ self.assertIn(moderator_role, self.staff_user.roles.all())
+
+ self.assertIn(student_role, self.student_user.roles.all())
+ self.assertNotIn(moderator_role, self.student_user.roles.all())
+
+ # The following was written on the assumption that unenrolling from a course
+ # should remove all forum Roles for that student for that course. This is
+ # not necessarily the case -- please see comments at the top of
+ # django_comment_client.models.assign_default_role(). Leaving it for the
+ # forums team to sort out.
+ #
+ # def test_unenrollment_auto_role_removal(self):
+ # another_student = User.objects.create_user("sol", "sol@fake.edx.org")
+ # CourseEnrollment.enroll(another_student, self.course_id)
+ #
+ # CourseEnrollment.unenroll(self.student_user, self.course_id)
+ # # Make sure we didn't delete the actual Role
+ # student_role = Role.objects.get(
+ # course_id=self.course_id,
+ # name="Student"
+ # )
+ # self.assertNotIn(student_role, self.student_user.roles.all())
+ # self.assertIn(student_role, another_student.roles.all())
diff --git a/common/djangoapps/external_auth/tests/test_shib.py b/common/djangoapps/external_auth/tests/test_shib.py
index 428119b886..6bb9c38e6f 100644
--- a/common/djangoapps/external_auth/tests/test_shib.py
+++ b/common/djangoapps/external_auth/tests/test_shib.py
@@ -431,12 +431,12 @@ class ShibSPTest(ModuleStoreTestCase):
# If course is not limited or student has correct shib extauth then enrollment should be allowed
if course is open_enroll_course or student is shib_student:
self.assertEqual(response.status_code, 200)
- self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 1)
+ self.assertTrue(CourseEnrollment.is_enrolled(student, course.id))
# Clean up
- CourseEnrollment.objects.filter(user=student, course_id=course.id).delete()
+ CourseEnrollment.unenroll(student, course.id)
else:
self.assertEqual(response.status_code, 400)
- self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 0)
+ self.assertFalse(CourseEnrollment.is_enrolled(student, course.id))
@unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True)
def test_shib_login_enrollment(self):
@@ -462,7 +462,7 @@ class ShibSPTest(ModuleStoreTestCase):
# use django test client for sessions and url processing
# no enrollment before trying
- self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 0)
+ self.assertFalse(CourseEnrollment.is_enrolled(student, course.id))
self.client.logout()
request_kwargs = {'path': '/shib-login/',
'data': {'enrollment_action': 'enroll', 'course_id': course.id},
@@ -474,4 +474,4 @@ class ShibSPTest(ModuleStoreTestCase):
self.assertEqual(response.status_code, 302)
self.assertEqual(response['location'], 'http://testserver/')
# now there is enrollment
- self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 1)
+ self.assertTrue(CourseEnrollment.is_enrolled(student, course.id))
diff --git a/common/djangoapps/student/management/commands/create_random_users.py b/common/djangoapps/student/management/commands/create_random_users.py
index 3000c86601..db4bb796cc 100644
--- a/common/djangoapps/student/management/commands/create_random_users.py
+++ b/common/djangoapps/student/management/commands/create_random_users.py
@@ -12,7 +12,7 @@ def create(n, course_id):
for i in range(n):
(user, user_profile, _) = _do_create_account(get_random_post_override())
if course_id is not None:
- CourseEnrollment.objects.create(user=user, course_id=course_id)
+ CourseEnrollment.enroll(user, course_id)
class Command(BaseCommand):
diff --git a/common/djangoapps/student/migrations/0027_add_active_flag_and_mode_to_courseware_enrollment.py b/common/djangoapps/student/migrations/0027_add_active_flag_and_mode_to_courseware_enrollment.py
new file mode 100644
index 0000000000..bba8cc6e34
--- /dev/null
+++ b/common/djangoapps/student/migrations/0027_add_active_flag_and_mode_to_courseware_enrollment.py
@@ -0,0 +1,183 @@
+# -*- coding: utf-8 -*-
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+
+class Migration(SchemaMigration):
+
+ def forwards(self, orm):
+ # Adding field 'CourseEnrollment.is_active'
+ db.add_column('student_courseenrollment', 'is_active',
+ self.gf('django.db.models.fields.BooleanField')(default=True),
+ keep_default=False)
+
+ # Adding field 'CourseEnrollment.mode'
+ db.add_column('student_courseenrollment', 'mode',
+ self.gf('django.db.models.fields.CharField')(default='honor', max_length=100),
+ keep_default=False)
+
+
+ def backwards(self, orm):
+ # Deleting field 'CourseEnrollment.is_active'
+ db.delete_column('student_courseenrollment', 'is_active')
+
+ # Deleting field 'CourseEnrollment.mode'
+ db.delete_column('student_courseenrollment', 'mode')
+
+
+ models = {
+ 'auth.group': {
+ 'Meta': {'object_name': 'Group'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+ 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+ },
+ 'auth.permission': {
+ 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+ 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+ },
+ 'auth.user': {
+ 'Meta': {'object_name': 'User'},
+ 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+ 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+ },
+ 'contenttypes.contenttype': {
+ 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+ 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+ },
+ 'student.courseenrollment': {
+ 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'},
+ 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}),
+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
+ },
+ 'student.courseenrollmentallowed': {
+ 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'},
+ 'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
+ },
+ 'student.pendingemailchange': {
+ 'Meta': {'object_name': 'PendingEmailChange'},
+ 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
+ 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
+ },
+ 'student.pendingnamechange': {
+ 'Meta': {'object_name': 'PendingNameChange'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+ 'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
+ 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
+ },
+ 'student.registration': {
+ 'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"},
+ 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'})
+ },
+ 'student.testcenterregistration': {
+ 'Meta': {'object_name': 'TestCenterRegistration'},
+ 'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
+ 'accommodation_request': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
+ 'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
+ 'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}),
+ 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
+ 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}),
+ 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'eligibility_appointment_date_first': ('django.db.models.fields.DateField', [], {'db_index': 'True'}),
+ 'eligibility_appointment_date_last': ('django.db.models.fields.DateField', [], {'db_index': 'True'}),
+ 'exam_series_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
+ 'testcenter_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['student.TestCenterUser']"}),
+ 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
+ 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
+ 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
+ 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
+ },
+ 'student.testcenteruser': {
+ 'Meta': {'object_name': 'TestCenterUser'},
+ 'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
+ 'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
+ 'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
+ 'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
+ 'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
+ 'client_candidate_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}),
+ 'company_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '50', 'blank': 'True'}),
+ 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
+ 'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
+ 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}),
+ 'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}),
+ 'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}),
+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
+ 'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}),
+ 'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
+ 'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}),
+ 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
+ 'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}),
+ 'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
+ 'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+ 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
+ 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
+ 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}),
+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}),
+ 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
+ },
+ 'student.userprofile': {
+ 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"},
+ 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}),
+ 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
+ 'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
+ 'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
+ 'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
+ 'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+ 'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
+ 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}),
+ 'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'})
+ },
+ 'student.usertestgroup': {
+ 'Meta': {'object_name': 'UserTestGroup'},
+ 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
+ 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'})
+ }
+ }
+
+ complete_apps = ['student']
\ No newline at end of file
diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py
index 34278c5581..6b5897e97d 100644
--- a/common/djangoapps/student/models.py
+++ b/common/djangoapps/student/models.py
@@ -11,11 +11,11 @@ file and check it in at the same time as your model changes. To do that,
3. Add the migration file created in edx-platform/common/djangoapps/student/migrations/
"""
from datetime import datetime
+from random import randint
import hashlib
import json
import logging
import uuid
-from random import randint
from django.conf import settings
@@ -645,16 +645,223 @@ class PendingEmailChange(models.Model):
class CourseEnrollment(models.Model):
+ """
+ Represents a Student's Enrollment record for a single Course. You should
+ generally not manipulate CourseEnrollment objects directly, but use the
+ classmethods provided to enroll, unenroll, or check on the enrollment status
+ of a given student.
+
+ We're starting to consolidate course enrollment logic in this class, but
+ more should be brought in (such as checking against CourseEnrollmentAllowed,
+ checking course dates, user permissions, etc.) This logic is currently
+ scattered across our views.
+ """
user = models.ForeignKey(User)
course_id = models.CharField(max_length=255, db_index=True)
-
created = models.DateTimeField(auto_now_add=True, null=True, db_index=True)
+ # If is_active is False, then the student is not considered to be enrolled
+ # in the course (is_enrolled() will return False)
+ is_active = models.BooleanField(default=True)
+
+ # Represents the modes that are possible. We'll update this later with a
+ # list of possible values.
+ mode = models.CharField(default="honor", max_length=100)
+
+
class Meta:
unique_together = (('user', 'course_id'),)
+ ordering = ('user', 'course_id')
def __unicode__(self):
- return "[CourseEnrollment] %s: %s (%s)" % (self.user, self.course_id, self.created)
+ return (
+ "[CourseEnrollment] {}: {} ({}); active: ({})"
+ ).format(self.user, self.course_id, self.created, self.is_active)
+
+ @classmethod
+ def create_enrollment(cls, user, course_id, mode="honor", is_active=False):
+ """
+ Create an enrollment for a user in a class. By default *this enrollment
+ is not active*. This is useful for when an enrollment needs to go
+ through some sort of approval process before being activated. If you
+ don't need this functionality, just call `enroll()` instead.
+
+ Returns a CoursewareEnrollment object.
+
+ `user` is a Django User object. If it hasn't been saved yet (no `.id`
+ attribute), this method will automatically save it before
+ adding an enrollment for it.
+
+ `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)
+
+ `mode` is a string specifying what kind of enrollment this is. The
+ default is "honor", meaning honor certificate. Future options
+ may include "audit", "verified_id", etc. Please don't use it
+ until we have these mapped out.
+
+ `is_active` is a boolean. If the CourseEnrollment object has
+ `is_active=False`, then calling
+ `CourseEnrollment.is_enrolled()` for that user/course_id
+ will return False.
+
+ It is expected that this method is called from a method which has already
+ verified the user authentication and access.
+ """
+ # If we're passing in a newly constructed (i.e. not yet persisted) User,
+ # save it to the database so that it can have an ID that we can throw
+ # into our CourseEnrollment object. Otherwise, we'll get an
+ # IntegrityError for having a null user_id.
+ if user.id is None:
+ user.save()
+
+ enrollment, _ = CourseEnrollment.objects.get_or_create(
+ user=user,
+ course_id=course_id,
+ )
+ # In case we're reactivating a deactivated enrollment, or changing the
+ # enrollment mode.
+ if enrollment.mode != mode or enrollment.is_active != is_active:
+ enrollment.mode = mode
+ enrollment.is_active = is_active
+ enrollment.save()
+
+ return enrollment
+
+ @classmethod
+ def enroll(cls, user, course_id, mode="honor"):
+ """
+ Enroll a user in a course. This saves immediately.
+
+ Returns a CoursewareEnrollment object.
+
+ `user` is a Django User object. If it hasn't been saved yet (no `.id`
+ attribute), this method will automatically save it before
+ adding an enrollment for it.
+
+ `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)
+
+ `mode` is a string specifying what kind of enrollment this is. The
+ default is "honor", meaning honor certificate. Future options
+ may include "audit", "verified_id", etc. Please don't use it
+ until we have these mapped out.
+
+ It is expected that this method is called from a method which has already
+ verified the user authentication and access.
+ """
+ return cls.create_enrollment(user, course_id, mode, is_active=True)
+
+ @classmethod
+ def enroll_by_email(cls, email, course_id, mode="honor", ignore_errors=True):
+ """
+ Enroll a user in a course given their email. This saves immediately.
+
+ Note that enrolling by email is generally done in big batches and the
+ error rate is high. For that reason, we supress User lookup errors by
+ default.
+
+ Returns a CoursewareEnrollment object. If the User does not exist and
+ `ignore_errors` is set to `True`, it will return None.
+
+ `email` Email address of the User to add to enroll in the course.
+
+ `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)
+
+ `mode` is a string specifying what kind of enrollment this is. The
+ default is "honor", meaning honor certificate. Future options
+ may include "audit", "verified_id", etc. Please don't use it
+ until we have these mapped out.
+
+ `ignore_errors` is a boolean indicating whether we should suppress
+ `User.DoesNotExist` errors (returning None) or let it
+ bubble up.
+
+ It is expected that this method is called from a method which has already
+ verified the user authentication and access.
+ """
+ try:
+ user = User.objects.get(email=email)
+ return cls.enroll(user, course_id, mode)
+ except User.DoesNotExist:
+ err_msg = u"Tried to enroll email {} into course {}, but user not found"
+ log.error(err_msg.format(email, course_id))
+ if ignore_errors:
+ return None
+ raise
+
+ @classmethod
+ def unenroll(cls, user, course_id):
+ """
+ Remove the user from a given course. If the relevant `CourseEnrollment`
+ object doesn't exist, we log an error but don't throw an exception.
+
+ `user` is a Django User object. If it hasn't been saved yet (no `.id`
+ attribute), this method will automatically save it before
+ adding an enrollment for it.
+
+ `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)
+ """
+ try:
+ record = CourseEnrollment.objects.get(user=user, course_id=course_id)
+ record.is_active = False
+ record.save()
+ except cls.DoesNotExist:
+ log.error("Tried to unenroll student {} from {} but they were not enrolled")
+
+ @classmethod
+ def unenroll_by_email(cls, email, course_id):
+ """
+ Unenroll a user from a course given their email. This saves immediately.
+ User lookup errors are logged but will not throw an exception.
+
+ `email` Email address of the User to unenroll from the course.
+
+ `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)
+ """
+ try:
+ user = User.objects.get(email=email)
+ return cls.unenroll(user, course_id)
+ except User.DoesNotExist:
+ err_msg = u"Tried to unenroll email {} from course {}, but user not found"
+ log.error(err_msg.format(email, course_id))
+
+ @classmethod
+ def is_enrolled(cls, user, course_id):
+ """
+ Remove the user from a given course. If the relevant `CourseEnrollment`
+ object doesn't exist, we log an error but don't throw an exception.
+
+ Returns True if the user is enrolled in the course (the entry must exist
+ and it must have `is_active=True`). Otherwise, returns False.
+
+ `user` is a Django User object. If it hasn't been saved yet (no `.id`
+ attribute), this method will automatically save it before
+ adding an enrollment for it.
+
+ `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)
+ """
+ try:
+ record = CourseEnrollment.objects.get(user=user, course_id=course_id)
+ return record.is_active
+ except cls.DoesNotExist:
+ return False
+
+ @classmethod
+ def enrollments_for_user(cls, user):
+ return CourseEnrollment.objects.filter(user=user, is_active=1)
+
+ def activate(self):
+ """Makes this `CourseEnrollment` record active. Saves immediately."""
+ if not self.is_active:
+ self.is_active = True
+ self.save()
+
+ def deactivate(self):
+ """Makes this `CourseEnrollment` record inactive. Saves immediately. An
+ inactive record means that the student is not enrolled in this course.
+ """
+ if self.is_active:
+ self.is_active = False
+ self.save()
class CourseEnrollmentAllowed(models.Model):
diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py
index 513216ba17..397816ec00 100644
--- a/common/djangoapps/student/tests/tests.py
+++ b/common/djangoapps/student/tests/tests.py
@@ -21,9 +21,8 @@ from django.utils.http import int_to_base36
from mock import Mock, patch
from textwrap import dedent
-from student.models import unique_id_for_user
+from student.models import unique_id_for_user, CourseEnrollment
from student.views import process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper
-from student.views import enroll_in_course, is_enrolled_in_course
from student.tests.factories import UserFactory
from student.tests.test_email import mock_render_to_string
COURSE_1 = 'edX/toy/2012_Fall'
@@ -209,12 +208,127 @@ class CourseEndingTest(TestCase):
class EnrollInCourseTest(TestCase):
- """ Tests the helper method for enrolling a user in a class """
+ """Tests enrolling and unenrolling in courses."""
- def test_enroll_in_course(self):
+ def test_enrollment(self):
user = User.objects.create_user("joe", "joe@joe.com", "password")
- user.save()
- course_id = "course_id"
- self.assertFalse(is_enrolled_in_course(user, course_id))
- enroll_in_course(user, course_id)
- self.assertTrue(is_enrolled_in_course(user, course_id))
+ course_id = "edX/Test101/2013"
+
+ # Test basic enrollment
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course_id))
+ CourseEnrollment.enroll(user, course_id)
+ self.assertTrue(CourseEnrollment.is_enrolled(user, course_id))
+
+ # Enrolling them again should be harmless
+ CourseEnrollment.enroll(user, course_id)
+ self.assertTrue(CourseEnrollment.is_enrolled(user, course_id))
+
+ # Now unenroll the user
+ CourseEnrollment.unenroll(user, course_id)
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course_id))
+
+ # Unenrolling them again should also be harmless
+ CourseEnrollment.unenroll(user, course_id)
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course_id))
+
+ # The enrollment record should still exist, just be inactive
+ enrollment_record = CourseEnrollment.objects.get(
+ user=user,
+ course_id=course_id
+ )
+ self.assertFalse(enrollment_record.is_active)
+
+ def test_enrollment_non_existent_user(self):
+ # Testing enrollment of newly unsaved user (i.e. no database entry)
+ user = User(username="rusty", email="rusty@fake.edx.org")
+ course_id = "edX/Test101/2013"
+
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course_id))
+
+ # Unenroll does nothing
+ CourseEnrollment.unenroll(user, course_id)
+
+ # Implicit save() happens on new User object when enrolling, so this
+ # should still work
+ CourseEnrollment.enroll(user, course_id)
+ self.assertTrue(CourseEnrollment.is_enrolled(user, course_id))
+
+ def test_enrollment_by_email(self):
+ user = User.objects.create(username="jack", email="jack@fake.edx.org")
+ course_id = "edX/Test101/2013"
+
+ CourseEnrollment.enroll_by_email("jack@fake.edx.org", course_id)
+ self.assertTrue(CourseEnrollment.is_enrolled(user, course_id))
+
+ # This won't throw an exception, even though the user is not found
+ self.assertIsNone(
+ CourseEnrollment.enroll_by_email("not_jack@fake.edx.org", course_id)
+ )
+
+ self.assertRaises(
+ User.DoesNotExist,
+ CourseEnrollment.enroll_by_email,
+ "not_jack@fake.edx.org",
+ course_id,
+ ignore_errors=False
+ )
+
+ # Now unenroll them by email
+ CourseEnrollment.unenroll_by_email("jack@fake.edx.org", course_id)
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course_id))
+
+ # Harmless second unenroll
+ CourseEnrollment.unenroll_by_email("jack@fake.edx.org", course_id)
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course_id))
+
+ # Unenroll on non-existent user shouldn't throw an error
+ CourseEnrollment.unenroll_by_email("not_jack@fake.edx.org", course_id)
+
+ def test_enrollment_multiple_classes(self):
+ user = User(username="rusty", email="rusty@fake.edx.org")
+ course_id1 = "edX/Test101/2013"
+ course_id2 = "MITx/6.003z/2012"
+
+ CourseEnrollment.enroll(user, course_id1)
+ CourseEnrollment.enroll(user, course_id2)
+ self.assertTrue(CourseEnrollment.is_enrolled(user, course_id1))
+ self.assertTrue(CourseEnrollment.is_enrolled(user, course_id2))
+
+ CourseEnrollment.unenroll(user, course_id1)
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course_id1))
+ self.assertTrue(CourseEnrollment.is_enrolled(user, course_id2))
+
+ CourseEnrollment.unenroll(user, course_id2)
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course_id1))
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course_id2))
+
+ def test_activation(self):
+ user = User.objects.create(username="jack", email="jack@fake.edx.org")
+ course_id = "edX/Test101/2013"
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course_id))
+
+ # Creating an enrollment doesn't actually enroll a student
+ # (calling CourseEnrollment.enroll() would have)
+ enrollment = CourseEnrollment.create_enrollment(user, course_id)
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course_id))
+
+ # Until you explicitly activate it
+ enrollment.activate()
+ self.assertTrue(CourseEnrollment.is_enrolled(user, course_id))
+
+ # Activating something that's already active does nothing
+ enrollment.activate()
+ self.assertTrue(CourseEnrollment.is_enrolled(user, course_id))
+
+ # Now deactive
+ enrollment.deactivate()
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course_id))
+
+ # Deactivating something that's already inactive does nothing
+ enrollment.deactivate()
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course_id))
+
+ # A deactivated enrollment should be activated if enroll() is called
+ # for that user/course_id combination
+ CourseEnrollment.enroll(user, course_id)
+ self.assertTrue(CourseEnrollment.is_enrolled(user, course_id))
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index 9b96b90dee..7795a13c47 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -254,13 +254,12 @@ def register_user(request, extra_context=None):
@ensure_csrf_cookie
def dashboard(request):
user = request.user
- enrollments = CourseEnrollment.objects.filter(user=user)
# Build our courses list for the user, but ignore any courses that no longer
# exist (because the course IDs have changed). Still, we don't delete those
# enrollments, because it could have been a data push snafu.
courses = []
- for enrollment in enrollments:
+ for enrollment in CourseEnrollment.enrollments_for_user(user):
try:
courses.append(course_from_id(enrollment.course_id))
except ItemNotFoundError:
@@ -377,18 +376,13 @@ def change_enrollment(request):
"course:{0}".format(course_num),
"run:{0}".format(run)])
- try:
- enroll_in_course(user, course.id)
- except IntegrityError:
- # If we've already created this enrollment in a separate transaction,
- # then just continue
- pass
+ CourseEnrollment.enroll(user, course.id)
+
return HttpResponse()
elif action == "unenroll":
try:
- enrollment = CourseEnrollment.objects.get(user=user, course_id=course_id)
- enrollment.delete()
+ CourseEnrollment.unenroll(user, course_id)
org, course_num, run = course_id.split("/")
statsd.increment("common.student.unenrollment",
@@ -402,30 +396,10 @@ def change_enrollment(request):
else:
return HttpResponseBadRequest(_("Enrollment action is invalid"))
-
-def enroll_in_course(user, course_id):
- """
- Helper method to enroll a user in a particular class.
-
- It is expected that this method is called from a method which has already
- verified the user authentication and access.
- """
- CourseEnrollment.objects.get_or_create(user=user, course_id=course_id)
-
-
-def is_enrolled_in_course(user, course_id):
- """
- Helper method that returns whether or not the user is enrolled in a particular course.
- """
- return CourseEnrollment.objects.filter(user=user, course_id=course_id).count() > 0
-
-
@ensure_csrf_cookie
def accounts_login(request, error=""):
-
return render_to_response('login.html', {'error': error})
-
# Need different levels of logging
@ensure_csrf_cookie
def login_user(request, error=""):
@@ -1008,13 +982,21 @@ def activate_account(request, key):
ceas = CourseEnrollmentAllowed.objects.filter(email=student[0].email)
for cea in ceas:
if cea.auto_enroll:
- course_id = cea.course_id
- _enrollment, _created = CourseEnrollment.objects.get_or_create(user_id=student[0].id, course_id=course_id)
+ CourseEnrollment.enroll(student[0], cea.course_id)
- resp = render_to_response("registration/activation_complete.html", {'user_logged_in': user_logged_in, 'already_active': already_active})
+ resp = render_to_response(
+ "registration/activation_complete.html",
+ {
+ 'user_logged_in': user_logged_in,
+ 'already_active': already_active
+ }
+ )
return resp
if len(r) == 0:
- return render_to_response("registration/activation_invalid.html", {'csrf': csrf(request)['csrf_token']})
+ return render_to_response(
+ "registration/activation_invalid.html",
+ {'csrf': csrf(request)['csrf_token']}
+ )
return HttpResponse(_("Unknown error. Please e-mail us to let us know how it happened."))
diff --git a/common/djangoapps/terrain/course_helpers.py b/common/djangoapps/terrain/course_helpers.py
index afef8bf2e1..eca3290080 100644
--- a/common/djangoapps/terrain/course_helpers.py
+++ b/common/djangoapps/terrain/course_helpers.py
@@ -54,7 +54,7 @@ def register_by_course_id(course_id, is_staff=False):
if is_staff:
u.is_staff = True
u.save()
- CourseEnrollment.objects.get_or_create(user=u, course_id=course_id)
+ CourseEnrollment.enroll(u, course_id)
@world.absorb
diff --git a/docs/data/source/internal_data_formats/sql_schema.rst b/docs/data/source/internal_data_formats/sql_schema.rst
index 92c5c4fa0e..6c5a7eab8c 100644
--- a/docs/data/source/internal_data_formats/sql_schema.rst
+++ b/docs/data/source/internal_data_formats/sql_schema.rst
@@ -347,7 +347,7 @@ There is an important split in demographic data gathered for the students who si
`student_courseenrollment`
==========================
-A row in this table represents a student's enrollment for a particular course run. If they decide to unenroll in the course, we delete their entry in this table, but we still leave all their state in `courseware_studentmodule` untouched.
+A row in this table represents a student's enrollment for a particular course run. If they decide to unenroll in the course, we set `is_active` to `False`. We still leave all their state in `courseware_studentmodule` untouched, so they will not lose courseware state if they unenroll and reenroll.
`id`
----
@@ -365,6 +365,13 @@ A row in this table represents a student's enrollment for a particular course ru
---------
Datetime of enrollment, UTC.
+`is_active`
+-----------
+ Boolean indicating whether this enrollment is active. If an enrollment is not active, a student is not enrolled in that course. This lets us unenroll students without losing a record of what courses they were enrolled in previously. This was introduced in the 2013-08-20 release. Before this release, unenrolling a student simply deleted the row in `student_courseenrollment`.
+
+`mode`
+------
+ String indicating what kind of enrollment this was. The default is "honor" (honor certificate) and all enrollments prior to 2013-08-20 will be of that type. Other types being considered are "audit" and "verified_id".
*******************
diff --git a/lms/djangoapps/analytics/basic.py b/lms/djangoapps/analytics/basic.py
index 0a8c6fec09..3600d0e393 100644
--- a/lms/djangoapps/analytics/basic.py
+++ b/lms/djangoapps/analytics/basic.py
@@ -25,8 +25,10 @@ def enrolled_students_features(course_id, features):
{'username': 'username3', 'first_name': 'firstname3'}
]
"""
- students = User.objects.filter(courseenrollment__course_id=course_id)\
- .order_by('username').select_related('profile')
+ students = User.objects.filter(
+ courseenrollment__course_id=course_id,
+ courseenrollment__is_active=1,
+ ).order_by('username').select_related('profile')
def extract_student(student, features):
""" convert student to dictionary """
diff --git a/lms/djangoapps/analytics/tests/test_basic.py b/lms/djangoapps/analytics/tests/test_basic.py
index e0251c4567..91d6ed45e9 100644
--- a/lms/djangoapps/analytics/tests/test_basic.py
+++ b/lms/djangoapps/analytics/tests/test_basic.py
@@ -15,7 +15,8 @@ class TestAnalyticsBasic(TestCase):
def setUp(self):
self.course_id = 'some/robot/course/id'
self.users = tuple(UserFactory() for _ in xrange(30))
- self.ces = tuple(CourseEnrollment.objects.create(course_id=self.course_id, user=user) for user in self.users)
+ self.ces = tuple(CourseEnrollment.enroll(user, self.course_id)
+ for user in self.users)
def test_enrolled_students_features_username(self):
self.assertIn('username', AVAILABLE_FEATURES)
diff --git a/lms/djangoapps/analytics/tests/test_distributions.py b/lms/djangoapps/analytics/tests/test_distributions.py
index 61f948c26d..6d314e4a49 100644
--- a/lms/djangoapps/analytics/tests/test_distributions.py
+++ b/lms/djangoapps/analytics/tests/test_distributions.py
@@ -19,10 +19,8 @@ class TestAnalyticsDistributions(TestCase):
profile__year_of_birth=i + 1930
) for i in xrange(30)]
- self.ces = [CourseEnrollment.objects.create(
- course_id=self.course_id,
- user=user
- ) for user in self.users]
+ self.ces = [CourseEnrollment.enroll(user, self.course_id)
+ for user in self.users]
@raises(ValueError)
def test_profile_distribution_bad_feature(self):
@@ -68,7 +66,8 @@ class TestAnalyticsDistributionsNoData(TestCase):
self.users += self.nodata_users
- self.ces = tuple(CourseEnrollment.objects.create(course_id=self.course_id, user=user) for user in self.users)
+ self.ces = tuple(CourseEnrollment.enroll(user, self.course_id)
+ for user in self.users)
def test_profile_distribution_easy_choice_nodata(self):
feature = 'gender'
diff --git a/lms/djangoapps/courseware/features/common.py b/lms/djangoapps/courseware/features/common.py
index 8b934e435d..bf58cbc6fe 100644
--- a/lms/djangoapps/courseware/features/common.py
+++ b/lms/djangoapps/courseware/features/common.py
@@ -53,7 +53,7 @@ def i_am_registered_for_the_course(step, course):
# If the user is not already enrolled, enroll the user.
# TODO: change to factory
- CourseEnrollment.objects.get_or_create(user=u, course_id=course_id(course))
+ CourseEnrollment.enroll(u, course_id(course))
world.log_in(username='robot', password='test')
diff --git a/lms/djangoapps/courseware/features/navigation.py b/lms/djangoapps/courseware/features/navigation.py
index d4ac7060c4..fcbf21b095 100644
--- a/lms/djangoapps/courseware/features/navigation.py
+++ b/lms/djangoapps/courseware/features/navigation.py
@@ -149,7 +149,7 @@ def create_user_and_visit_course():
world.create_user('robot', 'test')
u = User.objects.get(username='robot')
- CourseEnrollment.objects.get_or_create(user=u, course_id=course_id(world.scenario_dict['COURSE'].number))
+ CourseEnrollment.enroll(u, course_id(world.scenario_dict['COURSE'].number))
world.log_in(username='robot', password='test')
chapter_name = (TEST_SECTION_NAME + "1").replace(" ", "_")
diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py
index a5121042d1..6f665f7345 100644
--- a/lms/djangoapps/courseware/tests/test_views.py
+++ b/lms/djangoapps/courseware/tests/test_views.py
@@ -67,9 +67,9 @@ class ViewsTestCase(TestCase):
email='test@mit.edu')
self.date = datetime.datetime(2013, 1, 22, tzinfo=UTC)
self.course_id = 'edX/toy/2012_Fall'
- self.enrollment = CourseEnrollment.objects.get_or_create(user=self.user,
- course_id=self.course_id,
- created=self.date)[0]
+ self.enrollment = CourseEnrollment.enroll(self.user, self.course_id)
+ self.enrollment.created = self.date
+ self.enrollment.save()
self.location = ['tag', 'org', 'course', 'category', 'name']
self._MODULESTORES = {}
# This is a CourseDescriptor object
diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py
index 7815188227..fefa09288a 100644
--- a/lms/djangoapps/courseware/views.py
+++ b/lms/djangoapps/courseware/views.py
@@ -568,12 +568,12 @@ def syllabus(request, course_id):
def registered_for_course(course, user):
"""
- Return CourseEnrollment if user is registered for course, else False
+ Return True if user is registered for course, else False
"""
if user is None:
return False
if user.is_authenticated():
- return CourseEnrollment.objects.filter(user=user, course_id=course.id).exists()
+ return CourseEnrollment.is_enrolled(user, course.id)
else:
return False
diff --git a/lms/djangoapps/dashboard/views.py b/lms/djangoapps/dashboard/views.py
index e04588fff4..630222d7bf 100644
--- a/lms/djangoapps/dashboard/views.py
+++ b/lms/djangoapps/dashboard/views.py
@@ -47,7 +47,7 @@ def dashboard(request):
results["scalars"]["Activated Usernames"]=User.objects.filter(is_active=1).count()
# count how many enrollments we have
- results["scalars"]["Total Enrollments Across All Courses"]=CourseEnrollment.objects.count()
+ results["scalars"]["Total Enrollments Across All Courses"] = CourseEnrollment.objects.filter(is_active=1).count()
# establish a direct connection to the database (for executing raw SQL)
cursor = connection.cursor()
@@ -56,20 +56,22 @@ def dashboard(request):
# table queries need not take the form of raw SQL, but do in this case since
# the MySQL backend for django isn't very friendly with group by or distinct
table_queries = {}
- table_queries["course enrollments"]= \
- "select "+ \
- "course_id as Course, "+ \
- "count(user_id) as Students " + \
- "from student_courseenrollment "+ \
- "group by course_id "+ \
- "order by students desc;"
- table_queries["number of students in each number of classes"]= \
- "select registrations as 'Registered for __ Classes' , "+ \
- "count(registrations) as Users "+ \
- "from (select count(user_id) as registrations "+ \
- "from student_courseenrollment "+ \
- "group by user_id) as registrations_per_user "+ \
- "group by registrations;"
+ table_queries["course enrollments"] = """
+ select
+ course_id as Course,
+ count(user_id) as Students
+ from student_courseenrollment
+ where is_active=1
+ group by course_id
+ order by students desc;"""
+ table_queries["number of students in each number of classes"] = """
+ select registrations as 'Registered for __ Classes' ,
+ count(registrations) as Users
+ from (select count(user_id) as registrations
+ from student_courseenrollment
+ where is_active=1
+ group by user_id) as registrations_per_user
+ group by registrations;"""
# add the result for each of the table_queries to the results object
for query in table_queries.keys():
diff --git a/lms/djangoapps/django_comment_client/management/commands/assign_roles_for_course.py b/lms/djangoapps/django_comment_client/management/commands/assign_roles_for_course.py
index 9ef4f3d0b1..2a58e370af 100644
--- a/lms/djangoapps/django_comment_client/management/commands/assign_roles_for_course.py
+++ b/lms/djangoapps/django_comment_client/management/commands/assign_roles_for_course.py
@@ -22,7 +22,7 @@ class Command(BaseCommand):
course_id = args[0]
print "Updated roles for ",
- for i, enrollment in enumerate(CourseEnrollment.objects.filter(course_id=course_id), start=1):
+ for i, enrollment in enumerate(CourseEnrollment.objects.filter(course_id=course_id, is_active=1), start=1):
assign_default_role(None, enrollment)
if i % 1000 == 0:
print "{0}...".format(i),
diff --git a/lms/djangoapps/django_comment_client/management/commands/create_roles_for_existing.py b/lms/djangoapps/django_comment_client/management/commands/create_roles_for_existing.py
index 037bb292ec..0516c61c7c 100644
--- a/lms/djangoapps/django_comment_client/management/commands/create_roles_for_existing.py
+++ b/lms/djangoapps/django_comment_client/management/commands/create_roles_for_existing.py
@@ -19,7 +19,7 @@ class Command(BaseCommand):
raise CommandError("This Command takes no arguments")
print "Updated roles for ",
- for i, enrollment in enumerate(CourseEnrollment.objects.all(), start=1):
+ for i, enrollment in enumerate(CourseEnrollment.objects.filter(is_active=1), start=1):
assign_default_role(None, enrollment)
if i % 1000 == 0:
print "{0}...".format(i),
diff --git a/lms/djangoapps/django_comment_client/tests.py b/lms/djangoapps/django_comment_client/tests.py
index 8c6a48d8c1..d9f601ad0b 100644
--- a/lms/djangoapps/django_comment_client/tests.py
+++ b/lms/djangoapps/django_comment_client/tests.py
@@ -26,8 +26,8 @@ class PermissionsTestCase(TestCase):
password="123456", email="staff@edx.org")
self.moderator.is_staff = True
self.moderator.save()
- self.student_enrollment = CourseEnrollment.objects.create(user=self.student, course_id=self.course_id)
- self.moderator_enrollment = CourseEnrollment.objects.create(user=self.moderator, course_id=self.course_id)
+ self.student_enrollment = CourseEnrollment.enroll(self.student, self.course_id)
+ self.moderator_enrollment = CourseEnrollment.enroll(self.moderator, self.course_id)
def tearDown(self):
self.student_enrollment.delete()
diff --git a/lms/djangoapps/instructor/enrollment.py b/lms/djangoapps/instructor/enrollment.py
index 0b462957ec..7205cffe06 100644
--- a/lms/djangoapps/instructor/enrollment.py
+++ b/lms/djangoapps/instructor/enrollment.py
@@ -14,7 +14,11 @@ class EmailEnrollmentState(object):
""" Store the complete enrollment state of an email in a class """
def __init__(self, course_id, email):
exists_user = User.objects.filter(email=email).exists()
- exists_ce = CourseEnrollment.objects.filter(course_id=course_id, user__email=email).exists()
+ if exists_user:
+ user = User.objects.get(email=email)
+ exists_ce = CourseEnrollment.is_enrolled(user, course_id)
+ else:
+ exists_ce = False
ceas = CourseEnrollmentAllowed.objects.filter(course_id=course_id, email=email).all()
exists_allowed = len(ceas) > 0
state_auto_enroll = exists_allowed and ceas[0].auto_enroll
@@ -66,8 +70,7 @@ def enroll_email(course_id, student_email, auto_enroll=False):
previous_state = EmailEnrollmentState(course_id, student_email)
if previous_state.user:
- user = User.objects.get(email=student_email)
- CourseEnrollment.objects.get_or_create(course_id=course_id, user=user)
+ CourseEnrollment.enroll_by_email(student_email, course_id)
else:
cea, _ = CourseEnrollmentAllowed.objects.get_or_create(course_id=course_id, email=student_email)
cea.auto_enroll = auto_enroll
@@ -91,7 +94,7 @@ def unenroll_email(course_id, student_email):
previous_state = EmailEnrollmentState(course_id, student_email)
if previous_state.enrollment:
- CourseEnrollment.objects.get(course_id=course_id, user__email=student_email).delete()
+ CourseEnrollment.unenroll_by_email(student_email, course_id)
if previous_state.allowed:
CourseEnrollmentAllowed.objects.get(course_id=course_id, email=student_email).delete()
diff --git a/lms/djangoapps/instructor/offline_gradecalc.py b/lms/djangoapps/instructor/offline_gradecalc.py
index 97f252e3a9..da5fb3f7d4 100644
--- a/lms/djangoapps/instructor/offline_gradecalc.py
+++ b/lms/djangoapps/instructor/offline_gradecalc.py
@@ -31,7 +31,10 @@ def offline_grade_calculation(course_id):
'''
tstart = time.time()
- enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).prefetch_related("groups").order_by('username')
+ enrolled_students = User.objects.filter(
+ courseenrollment__course_id=course_id,
+ courseenrollment__is_active=1
+ ).prefetch_related("groups").order_by('username')
enc = MyEncoder()
diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py
index 51cf682aa5..155a8a2c9f 100644
--- a/lms/djangoapps/instructor/tests/test_api.py
+++ b/lms/djangoapps/instructor/tests/test_api.py
@@ -98,7 +98,7 @@ class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase):
def setUp(self):
self.user = UserFactory.create()
self.course = CourseFactory.create()
- CourseEnrollment.objects.create(user=self.user, course_id=self.course.id)
+ CourseEnrollment.enroll(self.user, self.course.id)
self.client.login(username=self.user.username, password='test')
def test_deny_students_update_enrollment(self):
@@ -161,9 +161,9 @@ class TestInstructorAPIEnrollment(ModuleStoreTestCase, LoginEnrollmentTestCase):
self.client.login(username=self.instructor.username, password='test')
self.enrolled_student = UserFactory()
- CourseEnrollment.objects.create(
- user=self.enrolled_student,
- course_id=self.course.id
+ CourseEnrollment.enroll(
+ self.enrolled_student,
+ self.course.id
)
self.notenrolled_student = UserFactory()
@@ -237,7 +237,8 @@ class TestInstructorAPIEnrollment(ModuleStoreTestCase, LoginEnrollmentTestCase):
self.assertEqual(
self.enrolled_student.courseenrollment_set.filter(
- course_id=self.course.id
+ course_id=self.course.id,
+ is_active=1,
).count(),
0
)
@@ -425,7 +426,7 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa
self.students = [UserFactory() for _ in xrange(6)]
for student in self.students:
- CourseEnrollment.objects.create(user=student, course_id=self.course.id)
+ CourseEnrollment.enroll(student, self.course.id)
def test_get_students_features(self):
"""
@@ -535,7 +536,7 @@ class TestInstructorAPIRegradeTask(ModuleStoreTestCase, LoginEnrollmentTestCase)
self.client.login(username=self.instructor.username, password='test')
self.student = UserFactory()
- CourseEnrollment.objects.create(course_id=self.course.id, user=self.student)
+ CourseEnrollment.enroll(self.student, self.course.id)
self.problem_urlname = 'robot-some-problem-urlname'
self.module_to_reset = StudentModule.objects.create(
@@ -678,7 +679,7 @@ class TestInstructorAPITaskLists(ModuleStoreTestCase, LoginEnrollmentTestCase):
self.client.login(username=self.instructor.username, password='test')
self.student = UserFactory()
- CourseEnrollment.objects.create(course_id=self.course.id, user=self.student)
+ CourseEnrollment.enroll(self.student, self.course.id)
self.problem_urlname = 'robot-some-problem-urlname'
self.module = StudentModule.objects.create(
diff --git a/lms/djangoapps/instructor/tests/test_enrollment.py b/lms/djangoapps/instructor/tests/test_enrollment.py
index 46f677fe88..3af71bb466 100644
--- a/lms/djangoapps/instructor/tests/test_enrollment.py
+++ b/lms/djangoapps/instructor/tests/test_enrollment.py
@@ -353,10 +353,7 @@ class SettableEnrollmentState(EmailEnrollmentState):
user = UserFactory()
email = user.email
if self.enrollment:
- cenr = CourseEnrollment.objects.create(
- user=user,
- course_id=course_id
- )
+ cenr = CourseEnrollment.enroll(user, course_id)
return EnrollmentObjects(email, user, cenr, None)
else:
return EnrollmentObjects(email, user, None, None)
diff --git a/lms/djangoapps/instructor/tests/test_legacy_enrollment.py b/lms/djangoapps/instructor/tests/test_legacy_enrollment.py
index c6ee82bfb1..1f5ea8ad56 100644
--- a/lms/djangoapps/instructor/tests/test_legacy_enrollment.py
+++ b/lms/djangoapps/instructor/tests/test_legacy_enrollment.py
@@ -51,7 +51,13 @@ class TestInstructorEnrollsStudent(ModuleStoreTestCase, LoginEnrollmentTestCase)
# Run the Un-enroll students command
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
- response = self.client.post(url, {'action': 'Unenroll multiple students', 'multiple_students': 'student0@test.com student1@test.com'})
+ response = self.client.post(
+ url,
+ {
+ 'action': 'Unenroll multiple students',
+ 'multiple_students': 'student0@test.com student1@test.com'
+ }
+ )
# Check the page output
self.assertContains(response, '
student0@test.com
')
@@ -60,12 +66,10 @@ class TestInstructorEnrollsStudent(ModuleStoreTestCase, LoginEnrollmentTestCase)
# Check the enrollment table
user = User.objects.get(email='student0@test.com')
- ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
- self.assertEqual(0, len(ce))
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course.id))
user = User.objects.get(email='student1@test.com')
- ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
- self.assertEqual(0, len(ce))
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course.id))
# Check the outbox
self.assertEqual(len(mail.outbox), 0)
@@ -96,7 +100,7 @@ class TestInstructorEnrollsStudent(ModuleStoreTestCase, LoginEnrollmentTestCase)
self.assertEqual(1, cea[0].auto_enroll)
# Check there is no enrollment db entry other than for the other students
- ce = CourseEnrollment.objects.filter(course_id=course.id)
+ ce = CourseEnrollment.objects.filter(course_id=course.id, is_active=1)
self.assertEqual(4, len(ce))
# Create and activate student accounts with same email
@@ -111,12 +115,10 @@ class TestInstructorEnrollsStudent(ModuleStoreTestCase, LoginEnrollmentTestCase)
# Check students are enrolled
user = User.objects.get(email='student1_1@test.com')
- ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
- self.assertEqual(1, len(ce))
+ self.assertTrue(CourseEnrollment.is_enrolled(user, course.id))
user = User.objects.get(email='student1_2@test.com')
- ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
- self.assertEqual(1, len(ce))
+ self.assertTrue(CourseEnrollment.is_enrolled(user, course.id))
def test_repeat_enroll(self):
"""
@@ -156,7 +158,7 @@ class TestInstructorEnrollsStudent(ModuleStoreTestCase, LoginEnrollmentTestCase)
self.assertEqual(0, cea[0].auto_enroll)
# Check there is no enrollment db entry other than for the setup instructor and students
- ce = CourseEnrollment.objects.filter(course_id=course.id)
+ ce = CourseEnrollment.objects.filter(course_id=course.id, is_active=1)
self.assertEqual(4, len(ce))
# Create and activate student accounts with same email
@@ -171,11 +173,10 @@ class TestInstructorEnrollsStudent(ModuleStoreTestCase, LoginEnrollmentTestCase)
# Check students are not enrolled
user = User.objects.get(email='student2_1@test.com')
- ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
- self.assertEqual(0, len(ce))
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course.id))
+
user = User.objects.get(email='student2_2@test.com')
- ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
- self.assertEqual(0, len(ce))
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course.id))
def test_get_and_clean_student_list(self):
"""
diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py
index 42f399c143..dd4c776446 100644
--- a/lms/djangoapps/instructor/views/legacy.py
+++ b/lms/djangoapps/instructor/views/legacy.py
@@ -93,7 +93,7 @@ def instructor_dashboard(request, course_id):
datatable = {'header': ['Statistic', 'Value'],
'title': 'Course Statistics At A Glance',
}
- data = [['# Enrolled', CourseEnrollment.objects.filter(course_id=course_id).count()]]
+ data = [['# Enrolled', CourseEnrollment.objects.filter(course_id=course_id, is_active=1).count()]]
data += [['Date', timezone.now().isoformat()]]
data += compute_course_stats(course).items()
if request.user.is_staff:
@@ -530,7 +530,10 @@ def instructor_dashboard(request, course_id):
# DataDump
elif 'Download CSV of all student profile data' in action:
- enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).order_by('username').select_related("profile")
+ enrolled_students = User.objects.filter(
+ courseenrollment__course_id=course_id,
+ courseenrollment__is_active=1,
+ ).order_by('username').select_related("profile")
profkeys = ['name', 'language', 'location', 'year_of_birth', 'gender', 'level_of_education',
'mailing_address', 'goals']
datatable = {'header': ['username', 'email'] + profkeys}
@@ -1002,7 +1005,10 @@ def get_student_grade_summary_data(request, course, course_id, get_grades=True,
If get_raw_scores=True, then instead of grade summaries, the raw grades for all graded modules are returned.
'''
- enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).prefetch_related("groups").order_by('username')
+ enrolled_students = User.objects.filter(
+ courseenrollment__course_id=course_id,
+ courseenrollment__is_active=1,
+ ).prefetch_related("groups").order_by('username')
header = ['ID', 'Username', 'Full Name', 'edX email', 'External email']
assignments = []
@@ -1053,7 +1059,10 @@ def gradebook(request, course_id):
"""
course = get_course_with_access(request.user, course_id, 'staff', depth=None)
- enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).order_by('username').select_related("profile")
+ enrolled_students = User.objects.filter(
+ courseenrollment__course_id=course_id,
+ courseenrollment__is_active=1
+ ).order_by('username').select_related("profile")
# TODO (vshnayder): implement pagination.
enrolled_students = enrolled_students[:1000] # HACK!
@@ -1110,7 +1119,7 @@ def _do_enroll_students(course, course_id, students, overload=False, auto_enroll
for ce in todelete:
if not has_access(ce.user, course, 'staff') and ce.user.email.lower() not in new_students_lc:
status[ce.user.email] = 'deleted'
- ce.delete()
+ ce.deactivate()
else:
status[ce.user.email] = 'is staff'
ceaset = CourseEnrollmentAllowed.objects.filter(course_id=course_id)
@@ -1162,14 +1171,13 @@ def _do_enroll_students(course, course_id, students, overload=False, auto_enroll
continue
#Student has already registered
- if CourseEnrollment.objects.filter(user=user, course_id=course_id):
+ if CourseEnrollment.is_enrolled(user, course_id):
status[student] = 'already enrolled'
continue
try:
#Not enrolled yet
- ce = CourseEnrollment(user=user, course_id=course_id)
- ce.save()
+ ce = CourseEnrollment.enroll(user, course_id)
status[student] = 'added'
if email_students:
@@ -1239,11 +1247,10 @@ def _do_unenroll_students(course_id, students, email_students=False):
continue
- ce = CourseEnrollment.objects.filter(user=user, course_id=course_id)
#Will be 0 or 1 records as there is a unique key on user + course_id
- if ce:
+ if CourseEnrollment.is_enrolled(user, course_id):
try:
- ce[0].delete()
+ CourseEnrollment.unenroll(user, course_id)
status[student] = "un-enrolled"
if email_students:
#User was enrolled
From 6c6ba54e84c248fca71a4b89b6c54b8881ccef8a Mon Sep 17 00:00:00 2001
From: Diana Huang
Date: Wed, 14 Aug 2013 11:57:26 -0400
Subject: [PATCH 059/125] Create Course Modes django app
---
lms/djangoapps/course_modes/__init__.py | 0
lms/djangoapps/course_modes/models.py | 46 +++++++++++++++++++
lms/djangoapps/course_modes/tests.py | 61 +++++++++++++++++++++++++
lms/djangoapps/course_modes/views.py | 1 +
lms/envs/common.py | 3 ++
5 files changed, 111 insertions(+)
create mode 100644 lms/djangoapps/course_modes/__init__.py
create mode 100644 lms/djangoapps/course_modes/models.py
create mode 100644 lms/djangoapps/course_modes/tests.py
create mode 100644 lms/djangoapps/course_modes/views.py
diff --git a/lms/djangoapps/course_modes/__init__.py b/lms/djangoapps/course_modes/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/lms/djangoapps/course_modes/models.py b/lms/djangoapps/course_modes/models.py
new file mode 100644
index 0000000000..44e096fa13
--- /dev/null
+++ b/lms/djangoapps/course_modes/models.py
@@ -0,0 +1,46 @@
+"""
+Add and create new modes for running courses on this particular LMS
+"""
+from django.db import models
+from collections import namedtuple
+from django.utils.translation import ugettext as _
+
+Mode = namedtuple('Mode', ['slug', 'name', 'min_price', 'suggested_prices'])
+
+
+class CourseMode(models.Model):
+ """
+ We would like to offer a course in a variety of modes.
+
+ """
+ # the course that this mode is attached to
+ course_id = models.CharField(max_length=255, db_index=True)
+
+ # the reference to this mode that can be used by Enrollments to generate
+ # similar behavior for the same slug across courses
+ mode_slug = models.CharField(max_length=100)
+
+ # The 'pretty' name that can be translated and displayed
+ mode_display_name = models.CharField(max_length=255)
+
+ # minimum price in USD that we would like to charge for this mode of the course
+ min_price = models.IntegerField(default=0)
+
+ # the suggested prices for this mode
+ suggested_prices = models.CommaSeparatedIntegerField(max_length=255, blank=True, default='')
+
+ DEFAULT_MODE = Mode('honor', _('Honor Code Certificate'), 0, '')
+
+ @classmethod
+ def modes_for_course(cls, course_id):
+ """
+ Returns a list of the modes for a given course id
+
+ If no modes have been set in the table, returns the default mode
+ """
+ found_course_modes = cls.objects.filter(course_id=course_id)
+ modes = ([Mode(mode.mode_slug, mode.mode_display_name, mode.min_price, mode.suggested_prices)
+ for mode in found_course_modes])
+ if not modes:
+ modes = [cls.DEFAULT_MODE]
+ return modes
diff --git a/lms/djangoapps/course_modes/tests.py b/lms/djangoapps/course_modes/tests.py
new file mode 100644
index 0000000000..9f19d5e5bc
--- /dev/null
+++ b/lms/djangoapps/course_modes/tests.py
@@ -0,0 +1,61 @@
+"""
+This file demonstrates writing tests using the unittest module. These will pass
+when you run "manage.py test".
+
+Replace this with more appropriate tests for your application.
+"""
+
+from django.test import TestCase
+from course_modes.models import CourseMode, Mode
+
+
+class CourseModeModelTest(TestCase):
+ """
+ Tests for the CourseMode model
+ """
+
+ def setUp(self):
+ self.course_id = 'TestCourse'
+ CourseMode.objects.all().delete()
+
+ def create_mode(self, mode_slug, mode_name, min_price=0, suggested_prices=''):
+ """
+ Create a new course mode
+ """
+ CourseMode.objects.get_or_create(
+ course_id=self.course_id,
+ mode_display_name=mode_name,
+ mode_slug=mode_slug,
+ min_price=min_price,
+ suggested_prices=suggested_prices
+ )
+
+ def test_modes_for_course_empty(self):
+ """
+ If we can't find any modes, we should get back the default mode
+ """
+ # shouldn't be able to find a corresponding course
+ modes = CourseMode.modes_for_course(self.course_id)
+ self.assertEqual([CourseMode.DEFAULT_MODE], modes)
+
+ def test_nodes_for_course_single(self):
+ """
+ Find the modes for a course with only one mode
+ """
+
+ self.create_mode('verified', 'Verified Certificate')
+ modes = CourseMode.modes_for_course(self.course_id)
+ self.assertEqual([Mode(u'verified', u'Verified Certificate', 0, '')], modes)
+
+ def test_modes_for_course_multiple(self):
+ """
+ Finding the modes when there's multiple modes
+ """
+ mode1 = Mode(u'honor', u'Honor Code Certificate', 0, '')
+ mode2 = Mode(u'verified', u'Verified Certificate', 0, '')
+ set_modes = [mode1, mode2]
+ for mode in set_modes:
+ self.create_mode(mode.slug, mode.name, mode.min_price, mode.suggested_prices)
+
+ modes = CourseMode.modes_for_course(self.course_id)
+ self.assertEqual(modes, set_modes)
diff --git a/lms/djangoapps/course_modes/views.py b/lms/djangoapps/course_modes/views.py
new file mode 100644
index 0000000000..60f00ef0ef
--- /dev/null
+++ b/lms/djangoapps/course_modes/views.py
@@ -0,0 +1 @@
+# Create your views here.
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 0579fc94d6..0a96efd45d 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -771,6 +771,9 @@ INSTALLED_APPS = (
# Notification preferences setting
'notification_prefs',
+
+ # Different Course Modes
+ 'course_modes'
)
######################### MARKETING SITE ###############################
From 22b1ef34723bff7689b174b7a87b008ca90c1b81 Mon Sep 17 00:00:00 2001
From: Diana Huang
Date: Wed, 14 Aug 2013 13:26:32 -0400
Subject: [PATCH 060/125] Initial migrations for course modes
---
.../course_modes/migrations/0001_initial.py | 40 +++++++++++++++++++
.../course_modes/migrations/__init__.py | 0
2 files changed, 40 insertions(+)
create mode 100644 lms/djangoapps/course_modes/migrations/0001_initial.py
create mode 100644 lms/djangoapps/course_modes/migrations/__init__.py
diff --git a/lms/djangoapps/course_modes/migrations/0001_initial.py b/lms/djangoapps/course_modes/migrations/0001_initial.py
new file mode 100644
index 0000000000..83e53769a2
--- /dev/null
+++ b/lms/djangoapps/course_modes/migrations/0001_initial.py
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+
+class Migration(SchemaMigration):
+
+ def forwards(self, orm):
+ # Adding model 'CourseMode'
+ db.create_table('course_modes_coursemode', (
+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
+ ('mode_slug', self.gf('django.db.models.fields.CharField')(max_length=100)),
+ ('mode_display_name', self.gf('django.db.models.fields.CharField')(max_length=255)),
+ ('min_price', self.gf('django.db.models.fields.IntegerField')(default=0)),
+ ('suggested_prices', self.gf('django.db.models.fields.CommaSeparatedIntegerField')(default='', max_length=255, blank=True)),
+ ))
+ db.send_create_signal('course_modes', ['CourseMode'])
+
+
+ def backwards(self, orm):
+ # Deleting model 'CourseMode'
+ db.delete_table('course_modes_coursemode')
+
+
+ models = {
+ 'course_modes.coursemode': {
+ 'Meta': {'object_name': 'CourseMode'},
+ 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'min_price': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+ 'mode_display_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+ 'mode_slug': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'suggested_prices': ('django.db.models.fields.CommaSeparatedIntegerField', [], {'default': "''", 'max_length': '255', 'blank': 'True'})
+ }
+ }
+
+ complete_apps = ['course_modes']
\ No newline at end of file
diff --git a/lms/djangoapps/course_modes/migrations/__init__.py b/lms/djangoapps/course_modes/migrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
From 7dc7406d3c352a4c09e7d87fec53c2a5ce704e26 Mon Sep 17 00:00:00 2001
From: John Jarvis
Date: Wed, 14 Aug 2013 14:42:24 -0400
Subject: [PATCH 061/125] updating help for --course
---
common/djangoapps/student/management/commands/get_grades.py | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/common/djangoapps/student/management/commands/get_grades.py b/common/djangoapps/student/management/commands/get_grades.py
index 9aa279458e..f0d5b5ec5d 100644
--- a/common/djangoapps/student/management/commands/get_grades.py
+++ b/common/djangoapps/student/management/commands/get_grades.py
@@ -41,8 +41,7 @@ class Command(BaseCommand):
metavar='COURSE_ID',
dest='course',
default=False,
- help='Grade and generate certificates for a specific '
- 'course'),
+ help='Course ID for grade distribution'),
make_option('-o', '--output',
metavar='FILE',
dest='output',
From 7d44379c9929ef2b3e329285f2f00218a2852a23 Mon Sep 17 00:00:00 2001
From: Diana Huang
Date: Wed, 14 Aug 2013 15:13:11 -0400
Subject: [PATCH 062/125] Add admin site for CourseMode
---
lms/djangoapps/course_modes/admin.py | 4 ++++
1 file changed, 4 insertions(+)
create mode 100644 lms/djangoapps/course_modes/admin.py
diff --git a/lms/djangoapps/course_modes/admin.py b/lms/djangoapps/course_modes/admin.py
new file mode 100644
index 0000000000..58c458236a
--- /dev/null
+++ b/lms/djangoapps/course_modes/admin.py
@@ -0,0 +1,4 @@
+from ratelimitbackend import admin
+from course_modes.models import CourseMode
+
+admin.site.register(CourseMode)
From f438922a88397d931d20c69d33c7593a78f84da4 Mon Sep 17 00:00:00 2001
From: Sarina Canelake
Date: Wed, 14 Aug 2013 16:28:46 -0400
Subject: [PATCH 063/125] Fix error seen in combinedopenended modules
---
common/lib/xmodule/xmodule/combined_open_ended_module.py | 4 ++--
common/lib/xmodule/xmodule/fields.py | 3 +++
2 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py
index e01ae49149..74082df1ce 100644
--- a/common/lib/xmodule/xmodule/combined_open_ended_module.py
+++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py
@@ -8,7 +8,7 @@ from .x_module import XModule
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
+from .fields import Date, Timedelta
import textwrap
log = logging.getLogger("mitx.courseware")
@@ -229,7 +229,7 @@ class CombinedOpenEndedFields(object):
default=None,
scope=Scope.settings
)
- graceperiod = String(
+ graceperiod = Timedelta(
help="Amount of time after the due date that submissions will be accepted",
default=None,
scope=Scope.settings
diff --git a/common/lib/xmodule/xmodule/fields.py b/common/lib/xmodule/xmodule/fields.py
index dc2f000286..b7094203c4 100644
--- a/common/lib/xmodule/xmodule/fields.py
+++ b/common/lib/xmodule/xmodule/fields.py
@@ -82,6 +82,9 @@ TIMEDELTA_REGEX = re.compile(r'^((?P\d+?) day(?:s?))?(\s)?((?P\d+?)
class Timedelta(ModelType):
+ # Timedeltas are immutable, see http://docs.python.org/2/library/datetime.html#available-types
+ MUTABLE = False
+
def from_json(self, time_str):
"""
time_str: A string with the following components:
From 4855fd5af446b5a6a633091612d145899153bde3 Mon Sep 17 00:00:00 2001
From: Adam Palay
Date: Wed, 14 Aug 2013 17:03:21 -0400
Subject: [PATCH 064/125] turn off i18n
---
cms/envs/common.py | 2 +-
lms/envs/common.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/cms/envs/common.py b/cms/envs/common.py
index cd92f17b1d..9d246edece 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -201,7 +201,7 @@ STATICFILES_DIRS = [
TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html
-USE_I18N = True
+USE_I18N = False
USE_L10N = True
# Localization strings (e.g. django.po) are under this directory
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 0a96efd45d..3e65c70805 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -378,7 +378,7 @@ FAVICON_PATH = 'images/favicon.ico'
# Locale/Internationalization
TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html
-USE_I18N = True
+USE_I18N = False
USE_L10N = True
# Localization strings (e.g. django.po) are under this directory
From c5d52b8b7479b20ac2d443f22e0ce4a99866a9b8 Mon Sep 17 00:00:00 2001
From: Adam Palay
Date: Wed, 14 Aug 2013 17:03:21 -0400
Subject: [PATCH 065/125] turn off i18n
---
cms/envs/common.py | 2 +-
lms/envs/common.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/cms/envs/common.py b/cms/envs/common.py
index cd92f17b1d..9d246edece 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -201,7 +201,7 @@ STATICFILES_DIRS = [
TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html
-USE_I18N = True
+USE_I18N = False
USE_L10N = True
# Localization strings (e.g. django.po) are under this directory
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 0579fc94d6..f4ecb318f6 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -378,7 +378,7 @@ FAVICON_PATH = 'images/favicon.ico'
# Locale/Internationalization
TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html
-USE_I18N = True
+USE_I18N = False
USE_L10N = True
# Localization strings (e.g. django.po) are under this directory
From 3ed4198204e7a062f47a25d9be915c40ecaf33fd Mon Sep 17 00:00:00 2001
From: Julian Arni
Date: Wed, 14 Aug 2013 19:10:00 -0400
Subject: [PATCH 066/125] Contentstore views pylint fixes
---
cms/djangoapps/contentstore/views/access.py | 10 +-
cms/djangoapps/contentstore/views/assets.py | 71 +++---
.../contentstore/views/checklist.py | 15 +-
.../contentstore/views/component.py | 165 +++++++++-----
cms/djangoapps/contentstore/views/course.py | 204 ++++++++++++------
5 files changed, 305 insertions(+), 160 deletions(-)
diff --git a/cms/djangoapps/contentstore/views/access.py b/cms/djangoapps/contentstore/views/access.py
index 49ce0c8733..5cb6b8c6f4 100644
--- a/cms/djangoapps/contentstore/views/access.py
+++ b/cms/djangoapps/contentstore/views/access.py
@@ -26,12 +26,16 @@ def has_access(user, location, role=STAFF_ROLE_NAME):
There is a super-admin permissions if user.is_staff is set
Also, since we're unifying the user database between LMS and CAS,
I'm presuming that the course instructor (formally known as admin)
- will not be in both INSTRUCTOR and STAFF groups, so we have to cascade our queries here as INSTRUCTOR
- has all the rights that STAFF do
+ will not be in both INSTRUCTOR and STAFF groups, so we have to cascade our
+ queries here as INSTRUCTOR has all the rights that STAFF do
'''
course_location = get_course_location_for_item(location)
_has_access = is_user_in_course_group_role(user, course_location, role)
# if we're not in STAFF, perhaps we're in INSTRUCTOR groups
if not _has_access and role == STAFF_ROLE_NAME:
- _has_access = is_user_in_course_group_role(user, course_location, INSTRUCTOR_ROLE_NAME)
+ _has_access = is_user_in_course_group_role(
+ user,
+ course_location,
+ INSTRUCTOR_ROLE_NAME
+ )
return _has_access
diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py
index 94bfa55b58..ede6939398 100644
--- a/cms/djangoapps/contentstore/views/assets.py
+++ b/cms/djangoapps/contentstore/views/assets.py
@@ -4,6 +4,7 @@ import os
import tarfile
import shutil
import cgi
+from functools import partial
from tempfile import mkdtemp
from path import path
@@ -34,7 +35,8 @@ from .access import get_location_and_verify_access
from util.json_request import JsonResponse
-__all__ = ['asset_index', 'upload_asset', 'import_course', 'generate_export_course', 'export_course']
+__all__ = ['asset_index', 'upload_asset', 'import_course',
+ 'generate_export_course', 'export_course']
def assets_to_json_dict(assets):
@@ -58,13 +60,14 @@ def assets_to_json_dict(assets):
obj["thumbnail"] = thumbnail
id_info = asset.get("_id")
if id_info:
- obj["id"] = "/{tag}/{org}/{course}/{revision}/{category}/{name}".format(
- org=id_info.get("org", ""),
- course=id_info.get("course", ""),
- revision=id_info.get("revision", ""),
- tag=id_info.get("tag", ""),
- category=id_info.get("category", ""),
- name=id_info.get("name", ""),
+ obj["id"] = "/{tag}/{org}/{course}/{revision}/{category}/{name}" \
+ .format(
+ org=id_info.get("org", ""),
+ course=id_info.get("course", ""),
+ revision=id_info.get("revision", ""),
+ tag=id_info.get("tag", ""),
+ category=id_info.get("category", ""),
+ name=id_info.get("name", ""),
)
ret.append(obj)
return ret
@@ -132,14 +135,14 @@ def asset_index(request, org, course, name):
@login_required
def upload_asset(request, org, course, coursename):
'''
- This method allows for POST uploading of files into the course asset library, which will
- be supported by GridFS in MongoDB.
+ This method allows for POST uploading of files into the course asset
+ library, which will be supported by GridFS in MongoDB.
'''
# construct a location from the passed in path
location = get_location_and_verify_access(request, org, course, coursename)
- # Does the course actually exist?!? Get anything from it to prove its existance
-
+ # Does the course actually exist?!? Get anything from it to prove its
+ # existence
try:
modulestore().get_item(location)
except:
@@ -150,9 +153,10 @@ def upload_asset(request, org, course, coursename):
if 'file' not in request.FILES:
return HttpResponseBadRequest()
- # compute a 'filename' which is similar to the location formatting, we're using the 'filename'
- # nomenclature since we're using a FileSystem paradigm here. We're just imposing
- # the Location string formatting expectations to keep things a bit more consistent
+ # compute a 'filename' which is similar to the location formatting, we're
+ # using the 'filename' nomenclature since we're using a FileSystem paradigm
+ # here. We're just imposing the Location string formatting expectations to
+ # keep things a bit more consistent
upload_file = request.FILES['file']
filename = upload_file.name
mime_type = upload_file.content_type
@@ -160,20 +164,25 @@ def upload_asset(request, org, course, coursename):
content_loc = StaticContent.compute_location(org, course, filename)
chunked = upload_file.multiple_chunks()
+ sc_partial = partial(StaticContent, content_loc, filename, mime_type)
if chunked:
- content = StaticContent(content_loc, filename, mime_type, upload_file.chunks())
+ content = sc_partial(upload_file.chunks())
+ temp_filepath = upload_file.temporary_file_path()
else:
- content = StaticContent(content_loc, filename, mime_type, upload_file.read())
+ content = sc_partial(upload_file.read())
+ tempfile_path = None
thumbnail_content = None
thumbnail_location = None
# first let's see if a thumbnail can be created
- (thumbnail_content, thumbnail_location) = contentstore().generate_thumbnail(content,
- tempfile_path=None if not chunked else
- upload_file.temporary_file_path())
+ (thumbnail_content, thumbnail_location) = contentstore().generate_thumbnail(
+ content,
+ tempfile_path=tempfile_path
+ )
- # delete cached thumbnail even if one couldn't be created this time (else the old thumbnail will continue to show)
+ # delete cached thumbnail even if one couldn't be created this time (else
+ # the old thumbnail will continue to show)
del_cached_content(thumbnail_location)
# now store thumbnail location only if we could create it
if thumbnail_content is not None:
@@ -186,13 +195,15 @@ def upload_asset(request, org, course, coursename):
# readback the saved content - we need the database timestamp
readback = contentstore().find(content.location)
- response_payload = {'displayname': content.name,
- 'uploadDate': get_default_time_display(readback.last_modified_at),
- 'url': StaticContent.get_url_path_from_location(content.location),
- 'portable_url': StaticContent.get_static_path_from_location(content.location),
- 'thumb_url': StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_content is not None else None,
- 'msg': 'Upload completed'
- }
+ response_payload = {
+ 'displayname': content.name,
+ 'uploadDate': get_default_time_display(readback.last_modified_at),
+ 'url': StaticContent.get_url_path_from_location(content.location),
+ 'portable_url': StaticContent.get_static_path_from_location(content.location),
+ 'thumb_url': StaticContent.get_url_path_from_location(thumbnail_location)
+ if thumbnail_content is not None else None,
+ 'msg': 'Upload completed'
+ }
response = JsonResponse(response_payload)
return response
@@ -202,8 +213,8 @@ def upload_asset(request, org, course, coursename):
@login_required
def remove_asset(request, org, course, name):
'''
- This method will perform a 'soft-delete' of an asset, which is basically to copy the asset from
- the main GridFS collection and into a Trashcan
+ This method will perform a 'soft-delete' of an asset, which is basically to
+ copy the asset from the main GridFS collection and into a Trashcan
'''
get_location_and_verify_access(request, org, course, name)
diff --git a/cms/djangoapps/contentstore/views/checklist.py b/cms/djangoapps/contentstore/views/checklist.py
index 74f0a33769..030aa70693 100644
--- a/cms/djangoapps/contentstore/views/checklist.py
+++ b/cms/djangoapps/contentstore/views/checklist.py
@@ -30,7 +30,8 @@ def get_checklists(request, org, course, name):
modulestore = get_modulestore(location)
course_module = modulestore.get_item(location)
- # If course was created before checklists were introduced, copy them over from the 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 = CourseDescriptor.checklists.default
@@ -68,7 +69,8 @@ def update_checklist(request, org, course, name, checklist_index=None):
if checklist_index is not None and 0 <= int(checklist_index) < len(course_module.checklists):
index = int(checklist_index)
course_module.checklists[index] = json.loads(request.body)
- # seeming noop which triggers kvs to record that the metadata is not default
+ # seeming noop which triggers kvs to record that the metadata is
+ # not default
course_module.checklists = course_module.checklists
checklists, _ = expand_checklist_action_urls(course_module)
course_module.save()
@@ -76,10 +78,13 @@ def update_checklist(request, org, course, name, checklist_index=None):
return JsonResponse(checklists[index])
else:
return HttpResponseBadRequest(
- "Could not save checklist state because the checklist index was out of range or unspecified.",
- content_type="text/plain")
+ ( "Could not save checklist state because the checklist index "
+ "was out of range or unspecified."),
+ content_type="text/plain"
+ )
elif request.method == 'GET':
- # In the JavaScript view initialize method, we do a fetch to get all the checklists.
+ # In the JavaScript view initialize method, we do a fetch to get all
+ # the checklists.
checklists, modified = expand_checklist_action_urls(course_module)
if modified:
course_module.save()
diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py
index d7b41acb24..292bc841ff 100644
--- a/cms/djangoapps/contentstore/views/component.py
+++ b/cms/djangoapps/contentstore/views/component.py
@@ -2,7 +2,8 @@ import json
import logging
from collections import defaultdict
-from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
+from django.http import HttpResponse, HttpResponseBadRequest, \
+ HttpResponseForbidden
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_http_methods
from django.core.exceptions import PermissionDenied
@@ -72,10 +73,15 @@ def edit_subsection(request, location):
except ItemNotFoundError:
return HttpResponseBadRequest()
- lms_link = get_lms_link_for_item(location, course_id=course.location.course_id)
- preview_link = get_lms_link_for_item(location, course_id=course.location.course_id, preview=True)
+ lms_link = get_lms_link_for_item(
+ location, course_id=course.location.course_id
+ )
+ preview_link = get_lms_link_for_item(
+ location, course_id=course.location.course_id, preview=True
+ )
- # make sure that location references a 'sequential', otherwise return BadRequest
+ # make sure that location references a 'sequential', otherwise return
+ # BadRequest
if item.location.category != 'sequential':
return HttpResponseBadRequest()
@@ -83,18 +89,22 @@ def edit_subsection(request, location):
# we're for now assuming a single parent
if len(parent_locs) != 1:
- logging.error('Multiple (or none) parents have been found for {0}'.format(location))
+ logging.error(
+ 'Multiple (or none) parents have been found for' + location
+ )
# this should blow up if we don't find any parents, which would be erroneous
parent = modulestore().get_item(parent_locs[0])
- # remove all metadata from the generic dictionary that is presented in a more normalized UI
+ # remove all metadata from the generic dictionary that is presented in a
+ # more normalized UI
policy_metadata = dict(
(field.name, field.read_from(item))
for field
in item.fields
- if field.name not in ['display_name', 'start', 'due', 'format'] and field.scope == Scope.settings
+ if field.name not in ['display_name', 'start', 'due', 'format']
+ and field.scope == Scope.settings
)
can_view_live = False
@@ -105,19 +115,22 @@ def edit_subsection(request, location):
can_view_live = True
break
- return render_to_response('edit_subsection.html',
- {'subsection': item,
- 'context_course': course,
- 'new_unit_category': 'vertical',
- 'lms_link': lms_link,
- 'preview_link': preview_link,
- 'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
- 'parent_location': course.location,
- 'parent_item': parent,
- 'policy_metadata': policy_metadata,
- 'subsection_units': subsection_units,
- 'can_view_live': can_view_live
- })
+ return render_to_response(
+ 'edit_subsection.html',
+ {
+ 'subsection': item,
+ 'context_course': course,
+ 'new_unit_category': 'vertical',
+ 'lms_link': lms_link,
+ 'preview_link': preview_link,
+ 'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
+ 'parent_location': course.location,
+ 'parent_item': parent,
+ 'policy_metadata': policy_metadata,
+ 'subsection_units': subsection_units,
+ 'can_view_live': can_view_live
+ }
+ )
@login_required
@@ -125,7 +138,7 @@ def edit_unit(request, location):
"""
Display an editing page for the specified module.
- Expects a GET request with the parameter 'id'.
+ Expects a GET request with the parameter `id`.
id: A Location URL
"""
@@ -141,7 +154,10 @@ 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)
+ lms_link = get_lms_link_for_item(
+ item.location,
+ course_id=course.location.course_id
+ )
component_templates = defaultdict(list)
for category in COMPONENT_TYPES:
@@ -162,17 +178,19 @@ def edit_unit(request, location):
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
- # in ADVANCED_COMPONENT_TYPES that should be enabled for the course.
+ # 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 in ADVANCED_COMPONENT_TYPES that should be
+ # enabled for the course.
course_advanced_keys = course.advanced_modules
# Set component types according to course policy file
if isinstance(course_advanced_keys, list):
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?
+ # 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)
@@ -183,13 +201,16 @@ def edit_unit(request, location):
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
+ # 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))
+ log.error(
+ "Improper format for course advanced keys!" + course_advanced_keys
+ )
components = [
component.location.url()
@@ -201,16 +222,20 @@ def edit_unit(request, location):
# this will need to change to check permissions correctly so as
# to pick the correct parent subsection
- containing_subsection_locs = modulestore().get_parent_locations(location, None)
+ containing_subsection_locs = modulestore().get_parent_locations(
+ location, None
+ )
containing_subsection = modulestore().get_item(containing_subsection_locs[0])
-
- containing_section_locs = modulestore().get_parent_locations(containing_subsection.location, None)
+ containing_section_locs = modulestore().get_parent_locations(
+ containing_subsection.location, None
+ )
containing_section = modulestore().get_item(containing_section_locs[0])
# cdodge hack. We're having trouble previewing drafts via jump_to redirect
# so let's generate the link url here
- # need to figure out where this item is in the list of children as the preview will need this
+ # need to figure out where this item is in the list of children as the
+ # preview will need this
index = 1
for child in containing_subsection.get_children():
if child.location == item.location:
@@ -219,15 +244,19 @@ def edit_unit(request, location):
preview_lms_base = settings.MITX_FEATURES.get('PREVIEW_LMS_BASE')
- preview_lms_link = '//{preview_lms_base}/courses/{org}/{course}/{course_name}/courseware/{section}/{subsection}/{index}'.format(
- preview_lms_base=preview_lms_base,
- lms_base=settings.LMS_BASE,
- org=course.location.org,
- course=course.location.course,
- course_name=course.location.name,
- section=containing_section.location.name,
- subsection=containing_subsection.location.name,
- index=index)
+ preview_lms_link = (
+ '//{preview_lms_base}/courses/{org}/{course}/'
+ '{course_name}/courseware/{section}/{subsection}/{index}'
+ ).format(
+ preview_lms_base=preview_lms_base,
+ lms_base=settings.LMS_BASE,
+ org=course.location.org,
+ course=course.location.course,
+ course_name=course.location.name,
+ section=containing_section.location.name,
+ subsection=containing_subsection.location.name,
+ index=index
+ )
unit_state = compute_unit_state(item)
@@ -240,11 +269,13 @@ def edit_unit(request, location):
'draft_preview_link': preview_lms_link,
'published_preview_link': lms_link,
'subsection': containing_subsection,
- 'release_date': get_default_time_display(containing_subsection.lms.start) if containing_subsection.lms.start is not None else None,
+ 'release_date': get_default_time_display(containing_subsection.lms.start)
+ if containing_subsection.lms.start is not None else None,
'section': containing_section,
'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
+ 'published_date': get_default_time_display(item.cms.published_date)
+ if item.cms.published_date is not None else None
})
@@ -253,9 +284,10 @@ def edit_unit(request, location):
@require_http_methods(("GET", "POST", "PUT"))
@ensure_csrf_cookie
def assignment_type_update(request, org, course, category, name):
- '''
- CRUD operations on assignment types for sections and subsections and anything else gradable.
- '''
+ """
+ CRUD operations on assignment types for sections and subsections and
+ anything else gradable.
+ """
location = Location(['i4x', org, course, category, name])
if not has_access(request.user, location):
return HttpResponseForbidden()
@@ -263,7 +295,9 @@ def assignment_type_update(request, org, course, category, name):
if request.method == 'GET':
return JsonResponse(CourseGradingModel.get_section_grader_type(location))
elif request.method in ('POST', 'PUT'): # post or put, doesn't matter.
- return JsonResponse(CourseGradingModel.update_section_grader_type(location, request.POST))
+ return JsonResponse(CourseGradingModel.update_section_grader_type(
+ location, request.POST
+ ))
@login_required
@@ -276,8 +310,8 @@ def create_draft(request):
if not has_access(request.user, location):
raise PermissionDenied()
- # This clones the existing item location to a draft location (the draft is implicit,
- # because modulestore is a Draft modulestore)
+ # This clones the existing item location to a draft location (the draft is
+ # implicit, because modulestore is a Draft modulestore)
modulestore().convert_to_draft(location)
return HttpResponse()
@@ -286,7 +320,9 @@ def create_draft(request):
@login_required
@expect_json
def publish_draft(request):
- "Publish a draft"
+ """
+ Publish a draft
+ """
location = request.POST['id']
# check permissions for this user within this course
@@ -294,7 +330,10 @@ def publish_draft(request):
raise PermissionDenied()
item = modulestore().get_item(location)
- _xmodule_recurse(item, lambda i: modulestore().publish(i.location, request.user.id))
+ _xmodule_recurse(
+ item,
+ lambda i: modulestore().publish(i.location, request.user.id)
+ )
return HttpResponse()
@@ -328,13 +367,23 @@ def module_info(request, module_location):
raise PermissionDenied()
rewrite_static_links = request.GET.get('rewrite_url_links', 'True') in ['True', 'true']
- logging.debug('rewrite_static_links = {0} {1}'.format(request.GET.get('rewrite_url_links', 'False'), rewrite_static_links))
+ logging.debug('rewrite_static_links = {0} {1}'.format(
+ request.GET.get('rewrite_url_links', 'False'),
+ rewrite_static_links)
+ )
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
if request.method == 'GET':
- return JsonResponse(get_module_info(get_modulestore(location), location, rewrite_static_links=rewrite_static_links))
+ return JsonResponse(get_module_info(
+ get_modulestore(location),
+ location,
+ rewrite_static_links=rewrite_static_links
+ ))
elif request.method in ("POST", "PUT"):
- return JsonResponse(set_module_info(get_modulestore(location), location, request.POST))
+ return JsonResponse(set_module_info(
+ get_modulestore(location),
+ location, request.POST
+ ))
diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py
index a6b1b29aab..b47f8e9ffb 100644
--- a/cms/djangoapps/contentstore/views/course.py
+++ b/cms/djangoapps/contentstore/views/course.py
@@ -82,7 +82,9 @@ def course_index(request, org, course, name):
'context_course': course,
'lms_link': lms_link,
'sections': sections,
- 'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
+ 'course_graders': json.dumps(
+ CourseGradingModel.fetch(course.location).graders
+ ),
'parent_location': course.location,
'new_section_category': 'chapter',
'new_subsection_category': 'sequential',
@@ -120,24 +122,31 @@ def create_new_course(request):
except ItemNotFoundError:
pass
if existing_course is not None:
- return JsonResponse(
- {
- 'ErrMsg': _('There is already a course defined with the same organization, course number, and course run. Please change either organization or course number to be unique.'),
- 'OrgErrMsg': _('Please change either the organization or course number so that it is unique.'),
- 'CourseErrMsg': _('Please change either the organization or course number so that it is unique.'),
- }
- )
+ return JsonResponse({
+ 'ErrMsg': _(('There is already a course defined with the same '
+ 'organization, course number, and course run. Please '
+ 'change either organization or course number to be '
+ 'unique.')),
+ 'OrgErrMsg': _(('Please change either the organization or '
+ 'course number so that it is unique.')),
+ 'CourseErrMsg': _(('Please change either the organization or '
+ 'course number so that it is unique.')),
+ })
- course_search_location = ['i4x', dest_location.org, dest_location.course, 'course', None]
+ 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. Please change at least one field to be unique.'),
- 'OrgErrMsg': _('Please change either the organization or course number so that it is unique.'),
- 'CourseErrMsg': _('Please change either the organization or course number so that it is unique.'),
- }
- )
+ return JsonResponse({
+ 'ErrMsg': _(('There is already a course defined with the same '
+ 'organization and course number. Please '
+ 'change at least one field to be unique.')),
+ 'OrgErrMsg': _(('Please change either the organization or '
+ 'course number so that it is unique.')),
+ 'CourseErrMsg': _(('Please change either the organization or '
+ 'course number so that it is unique.')),
+ })
# instantiate the CourseDescriptor and then persist it
# note: no system to pass
@@ -145,11 +154,17 @@ def create_new_course(request):
metadata = {}
else:
metadata = {'display_name': display_name}
- modulestore('direct').create_and_save_xmodule(dest_location, metadata=metadata)
+ modulestore('direct').create_and_save_xmodule(
+ dest_location,
+ metadata=metadata
+ )
new_course = modulestore('direct').get_item(dest_location)
# clone a default 'about' overview module as well
- dest_about_location = dest_location.replace(category='about', name='overview')
+ 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,
@@ -164,7 +179,8 @@ def create_new_course(request):
# seed the forums
seed_permissions_roles(new_course.location.course_id)
- # auto-enroll the course creator in the course so that "View Live" will work.
+ # auto-enroll the course creator in the course so that "View Live" will
+ # work.
CourseEnrollment.enroll(request.user, new_course.location.course_id)
return JsonResponse({'id': new_course.location.url()})
@@ -174,7 +190,8 @@ def create_new_course(request):
@ensure_csrf_cookie
def course_info(request, org, course, name, provided_id=None):
"""
- Send models and views as well as html for editing the course info to the client.
+ Send models and views as well as html for editing the course info to the
+ client.
org, course, name: Attributes of the Location for the item to edit
"""
@@ -189,7 +206,8 @@ def course_info(request, org, course, name, provided_id=None):
'context_course': course_module,
'url_base': "/" + org + "/" + course + "/",
'course_updates': json.dumps(get_course_updates(location)),
- 'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url()
+ 'handouts_location': Location(['i4x', org, course, 'course_info',
+ 'handouts']).url()
})
@@ -202,14 +220,16 @@ def course_info_updates(request, org, course, provided_id=None):
restful CRUD operations on course_info updates.
org, course: Attributes of the Location for the item to edit
- provided_id should be none if it's new (create) and a composite of the update db id + index otherwise.
+ provided_id should be none if it's new (create) and a composite of the
+ update db id + index otherwise.
"""
# ??? No way to check for access permission afaik
# get current updates
location = ['i4x', org, course, 'course_info', "updates"]
- # Hmmm, provided_id is coming as empty string on create whereas I believe it used to be None :-(
- # Possibly due to my removing the seemingly redundant pattern in urls.py
+ # Hmmm, provided_id is coming as empty string on create whereas I believe
+ # it used to be None :-( Possibly due to my removing the seemingly
+ # redundant pattern in urls.py
if provided_id == '':
provided_id = None
@@ -221,13 +241,19 @@ def course_info_updates(request, org, course, provided_id=None):
return JsonResponse(get_course_updates(location))
elif request.method == 'DELETE':
try:
- return JsonResponse(delete_course_update(location, request.POST, provided_id))
+ return JsonResponse(delete_course_update(location, request.POST,
+ provided_id
+ ))
except:
return HttpResponseBadRequest("Failed to delete",
content_type="text/plain")
- elif request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other
+ elif request.method in ('POST', 'PUT'): # can be either and sometimes
+ # django is rewriting one to the
+ # other
try:
- return JsonResponse(update_course_updates(location, request.POST, provided_id))
+ return JsonResponse(update_course_updates(location, request.POST,
+ provided_id
+ ))
except:
return HttpResponseBadRequest("Failed to save",
content_type="text/plain")
@@ -237,7 +263,8 @@ def course_info_updates(request, org, course, provided_id=None):
@ensure_csrf_cookie
def get_course_settings(request, org, course, name):
"""
- Send models and views as well as html for editing the course settings to the client.
+ Send models and views as well as html for editing the course settings to
+ the client.
org, course, name: Attributes of the Location for the item to edit
"""
@@ -253,7 +280,9 @@ def get_course_settings(request, org, course, name):
"course": course,
"name": name,
"section": "details"}),
- 'about_page_editable': not settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False)
+ 'about_page_editable': not settings.MITX_FEATURES.get(
+ 'ENABLE_MKTG_SITE', False
+ )
})
@@ -261,7 +290,8 @@ def get_course_settings(request, org, course, name):
@ensure_csrf_cookie
def course_config_graders_page(request, org, course, name):
"""
- Send models and views as well as html for editing the course settings to the client.
+ Send models and views as well as html for editing the course settings to
+ the client.
org, course, name: Attributes of the Location for the item to edit
"""
@@ -281,7 +311,8 @@ def course_config_graders_page(request, org, course, name):
@ensure_csrf_cookie
def course_config_advanced_page(request, org, course, name):
"""
- Send models and views as well as html for editing the advanced course settings to the client.
+ Send models and views as well as html for editing the advanced course
+ settings to the client.
org, course, name: Attributes of the Location for the item to edit
"""
@@ -301,8 +332,9 @@ def course_config_advanced_page(request, org, course, name):
@ensure_csrf_cookie
def course_settings_updates(request, org, course, name, section):
"""
- restful CRUD operations on course settings. This differs from get_course_settings by communicating purely
- through json (not rendering any html) and handles section level operations rather than whole page.
+ Restful CRUD operations on course settings. This differs from
+ get_course_settings by communicating purely through json (not rendering any
+ html) and handles section level operations rather than whole page.
org, course: Attributes of the Location for the item to edit
section: one of details, faculty, grading, problems, discussions
@@ -318,9 +350,15 @@ def course_settings_updates(request, org, course, name, section):
if request.method == 'GET':
# Cannot just do a get w/o knowing the course name :-(
- return JsonResponse(manager.fetch(Location(['i4x', org, course, 'course', name])), encoder=CourseSettingsEncoder)
+ return JsonResponse(
+ manager.fetch(Location(['i4x', org, course, 'course', name])),
+ encoder=CourseSettingsEncoder
+ )
elif request.method in ('POST', 'PUT'): # post or put, doesn't matter.
- return JsonResponse(manager.update_from_json(request.POST), encoder=CourseSettingsEncoder)
+ return JsonResponse(
+ manager.update_from_json(request.POST),
+ encoder=CourseSettingsEncoder
+ )
@expect_json
@@ -329,8 +367,9 @@ def course_settings_updates(request, org, course, name, section):
@ensure_csrf_cookie
def course_grader_updates(request, org, course, name, grader_index=None):
"""
- restful CRUD operations on course_info updates. This differs from get_course_settings by communicating purely
- through json (not rendering any html) and handles section level operations rather than whole page.
+ Restful CRUD operations on course_info updates. This differs from
+ get_course_settings by communicating purely through json (not rendering any
+ html) and handles section level operations rather than whole page.
org, course: Attributes of the Location for the item to edit
"""
@@ -339,13 +378,18 @@ def course_grader_updates(request, org, course, name, grader_index=None):
if request.method == 'GET':
# Cannot just do a get w/o knowing the course name :-(
- return JsonResponse(CourseGradingModel.fetch_grader(Location(location), grader_index))
+ return JsonResponse(CourseGradingModel.fetch_grader(
+ Location(location), grader_index
+ ))
elif request.method == "DELETE":
# ??? Should this return anything? Perhaps success fail?
CourseGradingModel.delete_grader(Location(location), grader_index)
return JsonResponse()
else: # post or put, doesn't matter.
- return JsonResponse(CourseGradingModel.update_grader_from_json(Location(location), request.POST))
+ return JsonResponse(CourseGradingModel.update_grader_from_json(
+ Location(location),
+ request.POST
+ ))
# # NB: expect_json failed on ["key", "key2"] and json payload
@@ -354,8 +398,9 @@ def course_grader_updates(request, org, course, name, grader_index=None):
@ensure_csrf_cookie
def course_advanced_updates(request, org, course, name):
"""
- restful CRUD operations on metadata. The payload is a json rep of the metadata dicts. For delete, otoh,
- the payload is either a key or a list of keys to delete.
+ Restful CRUD operations on metadata. The payload is a json rep of the
+ metadata dicts. For delete, otoh, the payload is either a key or a list of
+ keys to delete.
org, course: Attributes of the Location for the item to edit
"""
@@ -364,20 +409,26 @@ def course_advanced_updates(request, org, course, name):
if request.method == 'GET':
return JsonResponse(CourseMetadata.fetch(location))
elif request.method == 'DELETE':
- return JsonResponse(CourseMetadata.delete_key(location, json.loads(request.body)))
+ return JsonResponse(CourseMetadata.delete_key(
+ location,
+ json.loads(request.body)
+ ))
else:
# NOTE: request.POST is messed up because expect_json
- # cloned_request.POST.copy() is creating a defective entry w/ the whole payload as the key
+ # cloned_request.POST.copy() is creating a defective entry w/ the whole
+ # payload as the key
request_body = json.loads(request.body)
# Whether or not to filter the tabs key out of the settings metadata
filter_tabs = True
- # Check to see if the user instantiated any advanced components. This is a hack
- # that does the following :
- # 1) adds/removes the open ended panel tab to a course automatically if the user
- # has indicated that they want to edit the combinedopendended or peergrading module
- # 2) adds/removes the notes panel tab to a course automatically if the user has
- # indicated that they want the notes module enabled in their course
+ # Check to see if the user instantiated any advanced components. This
+ # is a hack that does the following :
+ # 1) adds/removes the open ended panel tab to a course automatically
+ # if the user has indicated that they want to edit the
+ # combinedopendended or peergrading module
+ # 2) adds/removes the notes panel tab to a course automatically if
+ # the user has indicated that they want the notes module enabled in
+ # their course
# TODO refactor the above into distinct advanced policy settings
if ADVANCED_COMPONENT_POLICY_KEY in request_body:
# Get the course so that we can scrape current tabs
@@ -389,19 +440,25 @@ def course_advanced_updates(request, org, course, name):
'notes': NOTE_COMPONENT_TYPES,
}
- # Check to see if the user instantiated any notes or open ended components
+ # Check to see if the user instantiated any notes or open ended
+ # components
for tab_type in tab_component_map.keys():
component_types = tab_component_map.get(tab_type)
found_ac_type = False
for ac_type in component_types:
if ac_type in request_body[ADVANCED_COMPONENT_POLICY_KEY]:
# Add tab to the course if needed
- changed, new_tabs = add_extra_panel_tab(tab_type, course_module)
- # If a tab has been added to the course, then send the metadata along to CourseMetadata.update_from_json
+ changed, new_tabs = add_extra_panel_tab(
+ tab_type,
+ course_module
+ )
+ # If a tab has been added to the course, then send the
+ # metadata along to CourseMetadata.update_from_json
if changed:
course_module.tabs = new_tabs
request_body.update({'tabs': new_tabs})
- # Indicate that tabs should not be filtered out of the metadata
+ # Indicate that tabs should not be filtered out of
+ # the metadata
filter_tabs = False
# Set this flag to avoid the tab removal code below.
found_ac_type = True
@@ -410,18 +467,26 @@ def course_advanced_updates(request, org, course, name):
# we may need to remove the tab from the course.
if not found_ac_type:
# Remove tab from the course if needed
- changed, new_tabs = remove_extra_panel_tab(tab_type, course_module)
+ changed, new_tabs = remove_extra_panel_tab(
+ tab_type, course_module
+ )
if changed:
course_module.tabs = new_tabs
request_body.update({'tabs': new_tabs})
- # Indicate that tabs should *not* be filtered out of the metadata
+ # Indicate that tabs should *not* be filtered out of
+ # the metadata
filter_tabs = False
try:
- return JsonResponse(CourseMetadata.update_from_json(location,
- request_body,
- filter_tabs=filter_tabs))
+ return JsonResponse(CourseMetadata.update_from_json(
+ location,
+ request_body,
+ filter_tabs=filter_tabs
+ ))
except (TypeError, ValueError) as err:
- return HttpResponseBadRequest("Incorrect setting format. " + str(err), content_type="text/plain")
+ return HttpResponseBadRequest(
+ "Incorrect setting format. " + str(err),
+ content_type="text/plain"
+ )
class TextbookValidationError(Exception):
@@ -498,7 +563,8 @@ def textbook_index(request, org, course, name):
if request.is_ajax():
if request.method == 'GET':
return JsonResponse(course_module.pdf_textbooks)
- elif request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other
+ elif request.method in ('POST', 'PUT'): # can be either and sometimes
+ # django is rewriting one to the other
try:
textbooks = validate_textbooks_json(request.body)
except TextbookValidationError as err:
@@ -517,7 +583,10 @@ def textbook_index(request, org, course, name):
# 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))
+ store.update_metadata(
+ course_module.location,
+ own_metadata(course_module)
+ )
return JsonResponse(course_module.pdf_textbooks)
else:
upload_asset_url = reverse('upload_asset', kwargs={
@@ -599,7 +668,8 @@ def textbook_by_id(request, org, course, name, tid):
if not textbook:
return JsonResponse(status=404)
return JsonResponse(textbook)
- elif request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other
+ elif request.method in ('POST', 'PUT'): # can be either and sometimes
+ # django is rewriting one to the other
try:
new_textbook = validate_textbook_json(request.body)
except TextbookValidationError as err:
@@ -616,7 +686,10 @@ def textbook_by_id(request, org, course, name, tid):
# 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))
+ store.update_metadata(
+ course_module.location,
+ own_metadata(course_module)
+ )
return JsonResponse(new_textbook, status=201)
elif request.method == 'DELETE':
if not textbook:
@@ -626,5 +699,8 @@ def textbook_by_id(request, org, course, name, tid):
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))
+ store.update_metadata(
+ course_module.location,
+ own_metadata(course_module)
+ )
return JsonResponse()
From 501c089f419a2b879cf75ac7d64692f5d1388357 Mon Sep 17 00:00:00 2001
From: ichuang
Date: Wed, 14 Aug 2013 21:51:40 -0400
Subject: [PATCH 067/125] capa optioninput template was missing msg output
(needed for hints)
---
common/lib/capa/capa/templates/optioninput.html | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/common/lib/capa/capa/templates/optioninput.html b/common/lib/capa/capa/templates/optioninput.html
index 9205fe7f3f..81ba43b71c 100644
--- a/common/lib/capa/capa/templates/optioninput.html
+++ b/common/lib/capa/capa/templates/optioninput.html
@@ -29,4 +29,9 @@
Status: incomplete
% endif
+
+ % if msg:
+ ${msg|n}
+ % endif
+
From 5f3dd37f98634c6046f6331cb59e758aa0957861 Mon Sep 17 00:00:00 2001
From: Peter Baratta
Date: Wed, 14 Aug 2013 16:17:40 -0400
Subject: [PATCH 068/125] Move the silencing of numpy's warnings into
test_calc.py
..because that is where it is the most annoying/visible. Otherwise it really
has no effect on the LMS or anything else.
---
common/lib/calc/calc.py | 5 -----
common/lib/calc/tests/test_calc.py | 21 +++++++++++++--------
2 files changed, 13 insertions(+), 13 deletions(-)
diff --git a/common/lib/calc/calc.py b/common/lib/calc/calc.py
index f2a68988ae..ab300f121b 100644
--- a/common/lib/calc/calc.py
+++ b/common/lib/calc/calc.py
@@ -11,11 +11,6 @@ import numpy
import scipy.constants
import calcfunctions
-# Have numpy ignore errors on functions outside its domain.
-# See http://docs.scipy.org/doc/numpy/reference/generated/numpy.seterr.html
-# TODO worry about thread safety/changing a global setting
-numpy.seterr(all='ignore') # Also: 'ignore', 'warn' (default), 'raise'
-
from pyparsing import (
Word, Literal, CaselessLiteral, ZeroOrMore, MatchFirst, Optional, Forward,
Group, ParseResults, stringEnd, Suppress, Combine, alphas, nums, alphanums
diff --git a/common/lib/calc/tests/test_calc.py b/common/lib/calc/tests/test_calc.py
index 48ac7b88c1..3b8981f5c3 100644
--- a/common/lib/calc/tests/test_calc.py
+++ b/common/lib/calc/tests/test_calc.py
@@ -7,6 +7,12 @@ import numpy
import calc
from pyparsing import ParseException
+# numpy's default behavior when it evaluates a function outside its domain
+# is to raise a warning (not an exception) which is then printed to STDOUT.
+# To prevent this from polluting the output of the tests, configure numpy to
+# ignore it instead.
+# See http://docs.scipy.org/doc/numpy/reference/generated/numpy.seterr.html
+numpy.seterr(all='ignore') # Also: 'ignore', 'warn' (default), 'raise'
class EvaluatorTest(unittest.TestCase):
"""
@@ -186,17 +192,16 @@ class EvaluatorTest(unittest.TestCase):
arcsin_inputs = ['-0.707', '0', '0.5', '0.588', '1.298 + 0.635*j']
arcsin_angles = [-0.785, 0, 0.524, 0.629, 1 + 1j]
self.assert_function_values('arcsin', arcsin_inputs, arcsin_angles)
- # Rather than throwing an exception, numpy.arcsin gives nan
- # self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arcsin(-1.1)')))
- # self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arcsin(1.1)')))
- # Disabled for now because they are giving a runtime warning... :-/
+ # Rather than a complex number, numpy.arcsin gives nan
+ self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arcsin(-1.1)')))
+ self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arcsin(1.1)')))
# Include those where the real part is between 0 and pi
arccos_inputs = ['1', '0.866', '0.809', '0.834-0.989*j']
arccos_angles = [0, 0.524, 0.628, 1 + 1j]
self.assert_function_values('arccos', arccos_inputs, arccos_angles)
- # self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arccos(-1.1)')))
- # self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arccos(1.1)')))
+ self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arccos(-1.1)')))
+ self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arccos(1.1)')))
# Has the same range as arcsin
arctan_inputs = ['-1', '0', '0.577', '0.727', '0.272 + 1.084*j']
@@ -535,10 +540,10 @@ class EvaluatorTest(unittest.TestCase):
# With case sensitive turned on, it should pick the right function
functions = {'f': lambda x: x, 'F': lambda x: x + 1}
self.assertEqual(
- calc.evaluator({}, functions, 'f(6)', case_sensitive=True), 6
+ 6, calc.evaluator({}, functions, 'f(6)', case_sensitive=True)
)
self.assertEqual(
- calc.evaluator({}, functions, 'F(6)', case_sensitive=True), 7
+ 7, calc.evaluator({}, functions, 'F(6)', case_sensitive=True)
)
def test_undefined_vars(self):
From 80619da4290ec933b10cc1a8eb7d0068169d8950 Mon Sep 17 00:00:00 2001
From: Julian Arni
Date: Thu, 15 Aug 2013 10:21:32 -0400
Subject: [PATCH 069/125] Review fixes
---
.../contentstore/views/component.py | 35 ++++++-----
cms/djangoapps/contentstore/views/course.py | 60 +++++++++----------
2 files changed, 47 insertions(+), 48 deletions(-)
diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py
index 292bc841ff..a5fec7c033 100644
--- a/cms/djangoapps/contentstore/views/component.py
+++ b/cms/djangoapps/contentstore/views/component.py
@@ -2,14 +2,15 @@ import json
import logging
from collections import defaultdict
-from django.http import HttpResponse, HttpResponseBadRequest, \
- HttpResponseForbidden
+from django.http import ( HttpResponse, HttpResponseBadRequest,
+ HttpResponseForbidden )
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_http_methods
from django.core.exceptions import PermissionDenied
from django_future.csrf import ensure_csrf_cookie
from django.conf import settings
-from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
+from xmodule.modulestore.exceptions import ( ItemNotFoundError,
+ InvalidLocationError )
from mitxmako.shortcuts import render_to_response
from xmodule.modulestore import Location
@@ -20,8 +21,8 @@ from xblock.core import Scope
from util.json_request import expect_json, JsonResponse
from contentstore.module_info_model import get_module_info, set_module_info
-from contentstore.utils import get_modulestore, get_lms_link_for_item, \
- compute_unit_state, UnitState, get_course_for_item
+from contentstore.utils import ( get_modulestore, get_lms_link_for_item,
+ compute_unit_state, UnitState, get_course_for_item )
from models.settings.course_grading import CourseGradingModel
@@ -90,7 +91,8 @@ def edit_subsection(request, location):
# we're for now assuming a single parent
if len(parent_locs) != 1:
logging.error(
- 'Multiple (or none) parents have been found for' + location
+ 'Multiple (or none) parents have been found for %',
+ location
)
# this should blow up if we don't find any parents, which would be erroneous
@@ -209,7 +211,8 @@ def edit_unit(request, location):
pass
else:
log.error(
- "Improper format for course advanced keys!" + course_advanced_keys
+ "Improper format for course advanced keys! %",
+ course_advanced_keys
)
components = [
@@ -293,11 +296,12 @@ def assignment_type_update(request, org, course, category, name):
return HttpResponseForbidden()
if request.method == 'GET':
- return JsonResponse(CourseGradingModel.get_section_grader_type(location))
+ rsp = CourseGradingModel.get_section_grader_type(location)
elif request.method in ('POST', 'PUT'): # post or put, doesn't matter.
- return JsonResponse(CourseGradingModel.update_section_grader_type(
+ rsp = CourseGradingModel.update_section_grader_type(
location, request.POST
- ))
+ )
+ return JsonResponse(rsp)
@login_required
@@ -368,7 +372,7 @@ def module_info(request, module_location):
rewrite_static_links = request.GET.get('rewrite_url_links', 'True') in ['True', 'true']
logging.debug('rewrite_static_links = {0} {1}'.format(
- request.GET.get('rewrite_url_links', 'False'),
+ request.GET.get('rewrite_url_links', False),
rewrite_static_links)
)
@@ -377,13 +381,14 @@ def module_info(request, module_location):
raise PermissionDenied()
if request.method == 'GET':
- return JsonResponse(get_module_info(
+ rsp = get_module_info(
get_modulestore(location),
location,
rewrite_static_links=rewrite_static_links
- ))
+ )
elif request.method in ("POST", "PUT"):
- return JsonResponse(set_module_info(
+ rsp = set_module_info(
get_modulestore(location),
location, request.POST
- ))
+ )
+ return JsonResponse(rsp)
diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py
index b47f8e9ffb..753df66fe0 100644
--- a/cms/djangoapps/contentstore/views/course.py
+++ b/cms/djangoapps/contentstore/views/course.py
@@ -123,14 +123,14 @@ def create_new_course(request):
pass
if existing_course is not None:
return JsonResponse({
- 'ErrMsg': _(('There is already a course defined with the same '
+ 'ErrMsg': _('There is already a course defined with the same '
'organization, course number, and course run. Please '
'change either organization or course number to be '
- 'unique.')),
- 'OrgErrMsg': _(('Please change either the organization or '
- 'course number so that it is unique.')),
- 'CourseErrMsg': _(('Please change either the organization or '
- 'course number so that it is unique.')),
+ 'unique.'),
+ 'OrgErrMsg': _('Please change either the organization or '
+ 'course number so that it is unique.'),
+ 'CourseErrMsg': _('Please change either the organization or '
+ 'course number so that it is unique.'),
})
course_search_location = ['i4x', dest_location.org, dest_location.course,
@@ -139,13 +139,13 @@ def create_new_course(request):
courses = modulestore().get_items(course_search_location)
if len(courses) > 0:
return JsonResponse({
- 'ErrMsg': _(('There is already a course defined with the same '
+ 'ErrMsg': _('There is already a course defined with the same '
'organization and course number. Please '
- 'change at least one field to be unique.')),
- 'OrgErrMsg': _(('Please change either the organization or '
- 'course number so that it is unique.')),
- 'CourseErrMsg': _(('Please change either the organization or '
- 'course number so that it is unique.')),
+ 'change at least one field to be unique.'),
+ 'OrgErrMsg': _('Please change either the organization or '
+ 'course number so that it is unique.'),
+ 'CourseErrMsg': _('Please change either the organization or '
+ 'course number so that it is unique.'),
})
# instantiate the CourseDescriptor and then persist it
@@ -206,9 +206,7 @@ def course_info(request, org, course, name, provided_id=None):
'context_course': course_module,
'url_base': "/" + org + "/" + course + "/",
'course_updates': json.dumps(get_course_updates(location)),
- 'handouts_location': Location(['i4x', org, course, 'course_info',
- 'handouts']).url()
- })
+ 'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url() })
@expect_json
@@ -227,9 +225,6 @@ def course_info_updates(request, org, course, provided_id=None):
# get current updates
location = ['i4x', org, course, 'course_info', "updates"]
- # Hmmm, provided_id is coming as empty string on create whereas I believe
- # it used to be None :-( Possibly due to my removing the seemingly
- # redundant pattern in urls.py
if provided_id == '':
provided_id = None
@@ -241,22 +236,21 @@ def course_info_updates(request, org, course, provided_id=None):
return JsonResponse(get_course_updates(location))
elif request.method == 'DELETE':
try:
- return JsonResponse(delete_course_update(location, request.POST,
- provided_id
- ))
+ return JsonResponse(delete_course_update(location, request.POST, provided_id))
except:
- return HttpResponseBadRequest("Failed to delete",
- content_type="text/plain")
- elif request.method in ('POST', 'PUT'): # can be either and sometimes
- # django is rewriting one to the
- # other
+ return HttpResponseBadRequest(
+ "Failed to delete",
+ content_type="text/plain"
+ )
+ # can be either and sometimes django is rewriting one to the other:
+ elif request.method in ('POST', 'PUT'):
try:
- return JsonResponse(update_course_updates(location, request.POST,
- provided_id
- ))
+ return JsonResponse(update_course_updates(location, request.POST, provided_id))
except:
- return HttpResponseBadRequest("Failed to save",
- content_type="text/plain")
+ return HttpResponseBadRequest(
+ "Failed to save",
+ content_type="text/plain"
+ )
@login_required
@@ -563,8 +557,8 @@ def textbook_index(request, org, course, name):
if request.is_ajax():
if request.method == 'GET':
return JsonResponse(course_module.pdf_textbooks)
- elif request.method in ('POST', 'PUT'): # can be either and sometimes
- # django is rewriting one to the other
+ # can be either and sometimes django is rewriting one to the other:
+ elif request.method in ('POST', 'PUT'):
try:
textbooks = validate_textbooks_json(request.body)
except TextbookValidationError as err:
From 9199f1d08bd94145f8b1c1a79dee24fc78ffa924 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carlos=20Andr=C3=A9s=20Rocha?=
Date: Wed, 14 Aug 2013 17:37:10 -0400
Subject: [PATCH 070/125] Add test for invalid YouTube IDs strings
---
.../lib/xmodule/xmodule/tests/test_video.py | 26 +++++++++++++++++++
1 file changed, 26 insertions(+)
diff --git a/common/lib/xmodule/xmodule/tests/test_video.py b/common/lib/xmodule/xmodule/tests/test_video.py
index 4a13d565cc..1ea4b4e187 100644
--- a/common/lib/xmodule/xmodule/tests/test_video.py
+++ b/common/lib/xmodule/xmodule/tests/test_video.py
@@ -64,6 +64,32 @@ class VideoModuleTest(LogicTest):
'1.25': '',
'1.50': ''})
+ def test_parse_youtube_invalid(self):
+ """Ensure that ids that are invalid return an empty dict"""
+
+ # invalid id
+ youtube_str = 'thisisaninvalidid'
+ output = VideoDescriptor._parse_youtube(youtube_str)
+ self.assertEqual(output, {'0.75': '',
+ '1.00': '',
+ '1.25': '',
+ '1.50': ''})
+ # another invalid id
+ youtube_str = ',::,:,,'
+ output = VideoDescriptor._parse_youtube(youtube_str)
+ self.assertEqual(output, {'0.75': '',
+ '1.00': '',
+ '1.25': '',
+ '1.50': ''})
+
+ # and another one, partially invalid
+ youtube_str = '0.75_BAD!!!,1.0:AXdE34_U,1.25:KLHF9K_Y,1.5:VO3SxfeD,'
+ output = VideoDescriptor._parse_youtube(youtube_str)
+ self.assertEqual(output, {'0.75': '',
+ '1.00': 'AXdE34_U',
+ '1.25': 'KLHF9K_Y',
+ '1.50': 'VO3SxfeD'})
+
def test_parse_youtube_key_format(self):
"""
Make sure that inconsistent speed keys are parsed correctly.
From 9f229a46059f239f98cc0b568ec15f8c089dd815 Mon Sep 17 00:00:00 2001
From: cahrens
Date: Thu, 15 Aug 2013 12:42:13 -0400
Subject: [PATCH 071/125] Make get_errored_courses part of the modulestore API
(with default implementation).
Also clarifies the contraction of location.course_id by throwing an exception for lcoations that are not of category course.
Add test for course_id method.
---
cms/djangoapps/auth/tests/test_authz.py | 4 +--
common/djangoapps/student/views.py | 2 +-
.../xmodule/xmodule/modulestore/__init__.py | 27 +++++++++++++++++--
.../modulestore/tests/test_location.py | 9 +++++++
4 files changed, 37 insertions(+), 5 deletions(-)
diff --git a/cms/djangoapps/auth/tests/test_authz.py b/cms/djangoapps/auth/tests/test_authz.py
index e04c108250..69050539cf 100644
--- a/cms/djangoapps/auth/tests/test_authz.py
+++ b/cms/djangoapps/auth/tests/test_authz.py
@@ -181,7 +181,7 @@ class CourseGroupTest(TestCase):
create_all_course_groups(self.creator, self.location)
add_user_to_course_group(self.creator, self.staff, self.location, STAFF_ROLE_NAME)
- location2 = 'i4x', 'mitX', '103', 'course2', 'test2'
+ location2 = 'i4x', 'mitX', '103', 'course', 'test2'
staff2 = User.objects.create_user('teststaff2', 'teststaff2+courses@edx.org', 'foo')
create_all_course_groups(self.creator, location2)
add_user_to_course_group(self.creator, staff2, location2, STAFF_ROLE_NAME)
@@ -193,7 +193,7 @@ class CourseGroupTest(TestCase):
create_all_course_groups(self.creator, self.location)
add_user_to_course_group(self.creator, self.staff, self.location, STAFF_ROLE_NAME)
- location2 = 'i4x', 'mitX', '103', 'course2', 'test2'
+ location2 = 'i4x', 'mitX', '103', 'course', 'test2'
creator2 = User.objects.create_user('testcreator2', 'testcreator2+courses@edx.org', 'foo')
staff2 = User.objects.create_user('teststaff2', 'teststaff2+courses@edx.org', 'foo')
create_all_course_groups(creator2, location2)
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index 2fe165077a..0b061f5a94 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -274,7 +274,7 @@ def dashboard(request):
# Global staff can see what courses errored on their dashboard
staff_access = False
errored_courses = {}
- if has_access(user, 'global', 'staff') and callable(getattr(modulestore(), 'get_errored_courses')):
+ if has_access(user, 'global', 'staff'):
# Show any courses that errored on load
staff_access = True
errored_courses = modulestore().get_errored_courses()
diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py
index a2297a7d26..d616f21efa 100644
--- a/common/lib/xmodule/xmodule/modulestore/__init__.py
+++ b/common/lib/xmodule/xmodule/modulestore/__init__.py
@@ -235,8 +235,15 @@ class Location(_LocationBase):
@property
def course_id(self):
- """Return the ID of the Course that this item belongs to by looking
- at the location URL hierachy"""
+ """
+ Return the ID of the Course that this item belongs to by looking
+ at the location URL hierachy.
+
+ Throws an InvalidLocationError is this location does not represent a course.
+ """
+ if self.category != 'course':
+ raise InvalidLocationError('Cannot call course_id for {0} because it is not of category course'.format(self))
+
return "/".join([self.org, self.course, self.name])
def replace(self, **kwargs):
@@ -370,6 +377,13 @@ class ModuleStore(object):
'''
raise NotImplementedError
+ def get_errored_courses(self):
+ """
+ Return a dictionary of course_dir -> [(msg, exception_str)], for each
+ course_dir where course loading failed.
+ """
+ raise NotImplementedError
+
class ModuleStoreBase(ModuleStore):
'''
@@ -409,6 +423,15 @@ class ModuleStoreBase(ModuleStore):
errorlog = self._get_errorlog(location)
return errorlog.errors
+ def get_errored_courses(self):
+ """
+ Returns an empty dict.
+
+ It is up to subclasses to extend this method if the concept
+ of errored courses makes sense for their implementation.
+ """
+ return {}
+
def get_course(self, course_id):
"""Default impl--linear search through course list"""
for c in self.get_courses():
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_location.py b/common/lib/xmodule/xmodule/modulestore/tests/test_location.py
index f0f0e8bf48..7e8ba1731b 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/test_location.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/test_location.py
@@ -159,3 +159,12 @@ def test_clean_for_html():
def test_html_id():
loc = Location("tag://org/course/cat/name:more_name@rev")
assert_equals(loc.html_id(), "tag-org-course-cat-name_more_name-rev")
+
+
+def test_course_id():
+ loc = Location('i4x', 'mitX', '103', 'course', 'test2')
+ assert_equals('mitX/103/test2', loc.course_id)
+
+ loc = Location('i4x', 'mitX', '103', '_not_a_course', 'test2')
+ with assert_raises(InvalidLocationError):
+ loc.course_id
From 958597ac71c2f509573073cb7fbd5c4128795cfd Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Wed, 14 Aug 2013 23:09:06 -0400
Subject: [PATCH 072/125] was getting unicode errors when exporting CB22x
---
cms/djangoapps/contentstore/views/assets.py | 3 +++
common/lib/xmodule/xmodule/video_module.py | 2 +-
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py
index 94bfa55b58..b35f00f0e2 100644
--- a/cms/djangoapps/contentstore/views/assets.py
+++ b/cms/djangoapps/contentstore/views/assets.py
@@ -348,6 +348,8 @@ def generate_export_course(request, org, course, name):
try:
export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name, modulestore())
except SerializationError, e:
+ logging.exception('There was an error exporting course {0}. {1}'.format(course_module.location, unicode(e)))
+
unit = None
failed_item = None
parent = None
@@ -380,6 +382,7 @@ def generate_export_course(request, org, course, name):
})
})
except Exception, e:
+ logging.exception('There was an error exporting course {0}. {1}'.format(course_module.location, unicode(e)))
return render_to_response('export.html', {
'context_course': course_module,
'successful_import_redirect_url': '',
diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py
index 407547d9bf..afa811b718 100644
--- a/common/lib/xmodule/xmodule/video_module.py
+++ b/common/lib/xmodule/xmodule/video_module.py
@@ -266,7 +266,7 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
if key in fields and fields[key].default == getattr(self, key):
continue
if value:
- xml.set(key, str(value))
+ xml.set(key, unicode(value))
for source in self.html5_sources:
ele = etree.Element('source')
From 26651a1dd7e4d9267764586cba620282eae08c3d Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Wed, 14 Aug 2013 23:35:40 -0400
Subject: [PATCH 073/125] export_to_xml() really should be definition_to_xml()
since we'll rely on the base method that is in xml_module.py to preserve the
correct export filesystem hierarchy.
---
common/lib/xmodule/xmodule/video_module.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py
index afa811b718..c8c0bf4225 100644
--- a/common/lib/xmodule/xmodule/video_module.py
+++ b/common/lib/xmodule/xmodule/video_module.py
@@ -240,7 +240,7 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
video = cls(system, model_data)
return video
- def export_to_xml(self, resource_fs):
+ def definition_to_xml(self, resource_fs):
"""
Returns an xml string representing this module.
"""
@@ -277,7 +277,8 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
ele = etree.Element('track')
ele.set('src', self.track)
xml.append(ele)
- return etree.tostring(xml, pretty_print=True)
+ return xml
+ #return etree.tostring(xml, pretty_print=True)
@staticmethod
def _parse_youtube(data):
From 43f2c6a8e4c20bce937307de2a629534f9478de8 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Thu, 15 Aug 2013 00:28:16 -0400
Subject: [PATCH 074/125] remove commented out line
---
common/lib/xmodule/xmodule/video_module.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py
index c8c0bf4225..8f5b4b4d09 100644
--- a/common/lib/xmodule/xmodule/video_module.py
+++ b/common/lib/xmodule/xmodule/video_module.py
@@ -278,7 +278,6 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
ele.set('src', self.track)
xml.append(ele)
return xml
- #return etree.tostring(xml, pretty_print=True)
@staticmethod
def _parse_youtube(data):
From f27ed07305994a9cd5e0c591de77e22fbf2ac547 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Thu, 15 Aug 2013 01:04:55 -0400
Subject: [PATCH 075/125] update tests to call definition_to_xml rather than
export_to_xml, which presumes writing to a filesystem, plus it adds a
url_name to the attribute set. Also, on __init__ reset the 'category'
attribute, on some code paths this can get lost. Not sure why, but this gets
all the tests to pass.
---
common/lib/xmodule/xmodule/tests/test_video.py | 9 +++++----
common/lib/xmodule/xmodule/video_module.py | 4 ++++
2 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/common/lib/xmodule/xmodule/tests/test_video.py b/common/lib/xmodule/xmodule/tests/test_video.py
index 4a13d565cc..ac1ab1d247 100644
--- a/common/lib/xmodule/xmodule/tests/test_video.py
+++ b/common/lib/xmodule/xmodule/tests/test_video.py
@@ -15,6 +15,7 @@ the course, section, subsection, unit, etc.
import unittest
from . import LogicTest
+from lxml import etree
from .import get_test_system
from xmodule.modulestore import Location
from xmodule.video_module import VideoDescriptor, _create_youtube_string
@@ -344,7 +345,7 @@ class VideoExportTestCase(unittest.TestCase):
desc.track = 'http://www.example.com/track'
desc.html5_sources = ['http://www.example.com/source.mp4', 'http://www.example.com/source.ogg']
- xml = desc.export_to_xml(None) # We don't use the `resource_fs` parameter
+ xml = desc.definition_to_xml(None) # We don't use the `resource_fs` parameter
expected = dedent('''\
''')
- self.assertEquals(expected, xml)
+ self.assertEquals(expected, etree.tostring(xml, pretty_print=True))
def test_export_to_xml_empty_parameters(self):
"""Test XML export with defaults."""
@@ -361,7 +362,7 @@ class VideoExportTestCase(unittest.TestCase):
location = Location(["i4x", "edX", "video", "default", "SampleProblem1"])
desc = VideoDescriptor(module_system, {'location': location})
- xml = desc.export_to_xml(None)
+ xml = desc.definition_to_xml(None)
expected = '\n'
- self.assertEquals(expected, xml)
+ self.assertEquals(expected, etree.tostring(xml, pretty_print=True))
diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py
index 8f5b4b4d09..e0fae8cdbe 100644
--- a/common/lib/xmodule/xmodule/video_module.py
+++ b/common/lib/xmodule/xmodule/video_module.py
@@ -215,6 +215,10 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
self._model_data.update(model_data)
del self.data
+ # I'm not sure how, but in some lifecycles we can loose the .category attribute
+ # this needs to be investigated
+ self.category = 'video'
+
@classmethod
def from_xml(cls, xml_data, system, org=None, course=None):
"""
From 96b4914fef3b530f2f85cdd8aee8adfaec8ced25 Mon Sep 17 00:00:00 2001
From: cahrens
Date: Thu, 15 Aug 2013 11:03:31 -0400
Subject: [PATCH 076/125] Make our deserializing consistent with serializing.
Also fixes problems with double-quoted strings (historical artifact).
STUD-640
Conflicts:
common/lib/xmodule/xmodule/video_module.py
---
.../lib/xmodule/xmodule/tests/test_video.py | 56 +++++++++++++++++++
common/lib/xmodule/xmodule/video_module.py | 32 +++++++----
common/lib/xmodule/xmodule/xml_module.py | 5 ++
3 files changed, 82 insertions(+), 11 deletions(-)
diff --git a/common/lib/xmodule/xmodule/tests/test_video.py b/common/lib/xmodule/xmodule/tests/test_video.py
index ac1ab1d247..5076f39c79 100644
--- a/common/lib/xmodule/xmodule/tests/test_video.py
+++ b/common/lib/xmodule/xmodule/tests/test_video.py
@@ -264,6 +264,62 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'data': ''
})
+ def test_from_xml_double_quotes(self):
+ """
+ Make sure we can handle the double-quoted string format (which was used for exporting for
+ a few weeks).
+ """
+ module_system = DummySystem(load_error_modules=True)
+ xml_data ='''
+
+ '''
+ output = VideoDescriptor.from_xml(xml_data, module_system)
+ self.assert_attributes_equal(output, {
+ 'youtube_id_0_75': 'OEoXaMPEzf65',
+ 'youtube_id_1_0': 'OEoXaMPEzf10',
+ 'youtube_id_1_25': 'OEoXaMPEzf125',
+ 'youtube_id_1_5': 'OEoXaMPEzf15',
+ 'show_captions': False,
+ 'start_time': 0.0,
+ 'end_time': 0.0,
+ 'track': 'http://download_track',
+ 'source': 'http://download_video',
+ 'html5_sources': ["source_1", "source_2"],
+ 'data': ''
+ })
+
+ def test_from_xml_double_quote_concatenated_youtube(self):
+ module_system = DummySystem(load_error_modules=True)
+ xml_data = '''
+
+ '''
+ output = VideoDescriptor.from_xml(xml_data, module_system)
+ self.assert_attributes_equal(output, {
+ 'youtube_id_0_75': '',
+ 'youtube_id_1_0': 'p2Q6BrNhdh8',
+ 'youtube_id_1_25': '1EeWXzPdhSA',
+ 'youtube_id_1_5': '',
+ 'show_captions': True,
+ 'start_time': 0.0,
+ 'end_time': 0.0,
+ 'track': '',
+ 'source': '',
+ 'html5_sources': [],
+ 'data': ''
+ })
+
def test_old_video_format(self):
"""
Test backwards compatibility with VideoModule's XML format.
diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py
index e0fae8cdbe..805ca48930 100644
--- a/common/lib/xmodule/xmodule/video_module.py
+++ b/common/lib/xmodule/xmodule/video_module.py
@@ -291,19 +291,19 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
XML-based courses.
"""
ret = {'0.75': '', '1.00': '', '1.25': '', '1.50': ''}
- if data == '':
- return ret
+
videos = data.split(',')
for video in videos:
pieces = video.split(':')
- # HACK
- # To elaborate somewhat: in many LMS tests, the keys for
- # Youtube IDs are inconsistent. Sometimes a particular
- # speed isn't present, and formatting is also inconsistent
- # ('1.0' versus '1.00'). So it's necessary to either do
- # something like this or update all the tests to work
- # properly.
- ret['%.2f' % float(pieces[0])] = pieces[1]
+ try:
+ speed = '%.2f' % float(pieces[0]) # normalize speed
+ # Handle the fact that youtube IDs got double-quoted for a period of time.
+ # Note: we pass in "VideoFields.youtube_id_1_0" so we deserialize as a String--
+ # it doesn't matter what the actual speed is for the purposes of deserializing.
+ youtube_id = VideoDescriptor._deserialize(VideoFields.youtube_id_1_0.name, pieces[1])
+ ret[speed] = youtube_id
+ except (ValueError, IndexError):
+ log.warning('Invalid YouTube ID: %s' % video)
return ret
@staticmethod
@@ -316,7 +316,6 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
model_data = {}
conversions = {
- 'show_captions': json.loads,
'start_time': VideoDescriptor._parse_time,
'end_time': VideoDescriptor._parse_time
}
@@ -355,10 +354,21 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
# Convert XML attrs into Python values.
if attr in conversions:
value = conversions[attr](value)
+ else:
+ # We export values with json.dumps (well, except for Strings, but
+ # for about a month we did it for Strings also).
+ value = VideoDescriptor._deserialize(attr, value)
model_data[attr] = value
return model_data
+ @classmethod
+ def _deserialize(cls, attr, value):
+ """
+ Handles deserializing values that may have been encoded with json.dumps.
+ """
+ return cls.get_map_for_field(attr).from_xml(value)
+
@staticmethod
def _parse_time(str_time):
"""Converts s in '12:34:45' format to seconds. If s is
diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py
index 5b8d2c8aee..b0b7f300ed 100644
--- a/common/lib/xmodule/xmodule/xml_module.py
+++ b/common/lib/xmodule/xmodule/xml_module.py
@@ -167,6 +167,11 @@ class XmlDescriptor(XModuleDescriptor):
@classmethod
def get_map_for_field(cls, attr):
+ """
+ Returns a serialize/deserialize AttrMap for the given field of a class.
+
+ Searches through fields defined by cls to find one named attr.
+ """
for field in set(cls.fields + cls.lms.fields):
if field.name == attr:
from_xml = lambda val: deserialize_field(field, val)
From 0acc201a2197e270389ea97fd6c3158f0955cee9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carlos=20Andr=C3=A9s=20Rocha?=
Date: Wed, 14 Aug 2013 17:37:10 -0400
Subject: [PATCH 077/125] Add test for invalid YouTube IDs strings
---
.../lib/xmodule/xmodule/tests/test_video.py | 26 +++++++++++++++++++
1 file changed, 26 insertions(+)
diff --git a/common/lib/xmodule/xmodule/tests/test_video.py b/common/lib/xmodule/xmodule/tests/test_video.py
index 5076f39c79..a6a7d86510 100644
--- a/common/lib/xmodule/xmodule/tests/test_video.py
+++ b/common/lib/xmodule/xmodule/tests/test_video.py
@@ -65,6 +65,32 @@ class VideoModuleTest(LogicTest):
'1.25': '',
'1.50': ''})
+ def test_parse_youtube_invalid(self):
+ """Ensure that ids that are invalid return an empty dict"""
+
+ # invalid id
+ youtube_str = 'thisisaninvalidid'
+ output = VideoDescriptor._parse_youtube(youtube_str)
+ self.assertEqual(output, {'0.75': '',
+ '1.00': '',
+ '1.25': '',
+ '1.50': ''})
+ # another invalid id
+ youtube_str = ',::,:,,'
+ output = VideoDescriptor._parse_youtube(youtube_str)
+ self.assertEqual(output, {'0.75': '',
+ '1.00': '',
+ '1.25': '',
+ '1.50': ''})
+
+ # and another one, partially invalid
+ youtube_str = '0.75_BAD!!!,1.0:AXdE34_U,1.25:KLHF9K_Y,1.5:VO3SxfeD,'
+ output = VideoDescriptor._parse_youtube(youtube_str)
+ self.assertEqual(output, {'0.75': '',
+ '1.00': 'AXdE34_U',
+ '1.25': 'KLHF9K_Y',
+ '1.50': 'VO3SxfeD'})
+
def test_parse_youtube_key_format(self):
"""
Make sure that inconsistent speed keys are parsed correctly.
From 03608a8931b3abdc0bc752c34b205a4911e36c05 Mon Sep 17 00:00:00 2001
From: Don Mitchell
Date: Thu, 15 Aug 2013 11:04:48 -0400
Subject: [PATCH 078/125] have XModuleDescriptor properly ensure category is
set
---
common/lib/xmodule/xmodule/video_module.py | 4 ----
common/lib/xmodule/xmodule/x_module.py | 4 ++--
2 files changed, 2 insertions(+), 6 deletions(-)
diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py
index 805ca48930..7830ff77b4 100644
--- a/common/lib/xmodule/xmodule/video_module.py
+++ b/common/lib/xmodule/xmodule/video_module.py
@@ -215,10 +215,6 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
self._model_data.update(model_data)
del self.data
- # I'm not sure how, but in some lifecycles we can loose the .category attribute
- # this needs to be investigated
- self.category = 'video'
-
@classmethod
def from_xml(cls, xml_data, system, org=None, course=None):
"""
diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py
index 3556f3f0f3..4b22ae9fe2 100644
--- a/common/lib/xmodule/xmodule/x_module.py
+++ b/common/lib/xmodule/xmodule/x_module.py
@@ -467,11 +467,11 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock):
self.system = self.runtime
if isinstance(self.location, Location):
self.url_name = self.location.name
- if not hasattr(self, 'category'):
+ if getattr(self, 'category', None) is None:
self.category = self.location.category
elif isinstance(self.location, BlockUsageLocator):
self.url_name = self.location.usage_id
- if not hasattr(self, 'category'):
+ if getattr(self, 'category', None) is None:
raise InsufficientSpecificationError()
else:
raise InsufficientSpecificationError()
From 71de34fca3a8a7887fdbcd63df3471f1e8f8bbc6 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Thu, 15 Aug 2013 16:24:54 -0400
Subject: [PATCH 079/125] use same getattr pattern in the XModule constructor
that exists in the XModuleDescriptor
---
common/lib/xmodule/xmodule/x_module.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py
index 4b22ae9fe2..87d9413334 100644
--- a/common/lib/xmodule/xmodule/x_module.py
+++ b/common/lib/xmodule/xmodule/x_module.py
@@ -173,11 +173,11 @@ class XModule(XModuleFields, HTMLSnippet, XBlock):
# 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'):
+ if getattr(self, 'category', None) is None:
self.category = self.location.category
elif isinstance(self.location, BlockUsageLocator):
self.url_name = self.location.usage_id
- if not hasattr(self, 'category'):
+ if getattr(self, 'category', None) is None:
raise InsufficientSpecificationError()
else:
raise InsufficientSpecificationError()
From a93d7229ac3957395fdcca39dc63a7411ae6be04 Mon Sep 17 00:00:00 2001
From: Peter Fogg
Date: Thu, 15 Aug 2013 16:20:25 -0400
Subject: [PATCH 080/125] Fix grade range names not being editable.
---
.../contentstore/features/grading.feature | 9 +++++++++
cms/djangoapps/contentstore/features/grading.py | 13 +++++++++++++
.../js/views/settings/settings_grading_view.js | 2 +-
3 files changed, 23 insertions(+), 1 deletion(-)
diff --git a/cms/djangoapps/contentstore/features/grading.feature b/cms/djangoapps/contentstore/features/grading.feature
index b01d762d73..4b5cacc159 100644
--- a/cms/djangoapps/contentstore/features/grading.feature
+++ b/cms/djangoapps/contentstore/features/grading.feature
@@ -84,3 +84,12 @@ Feature: Course Grading
And I am viewing the grading settings
When I change assignment type "Homework" to ""
Then the save button is disabled
+
+ Scenario: User can edit grading range names
+ Given I have opened a new course in Studio
+ And I have populated the course
+ And I am viewing the grading settings
+ When I change the highest grade range to "Good"
+ And I press the "Save" notification button
+ And I reload the page
+ Then I see the highest grade range is "Good"
diff --git a/cms/djangoapps/contentstore/features/grading.py b/cms/djangoapps/contentstore/features/grading.py
index 0b60510bf5..40cba61edc 100644
--- a/cms/djangoapps/contentstore/features/grading.py
+++ b/cms/djangoapps/contentstore/features/grading.py
@@ -117,6 +117,19 @@ def i_see_the_assignment_type(_step, name):
assert name in types
+@step(u'I change the highest grade range to "(.*)"$')
+def change_grade_range(_step, range_name):
+ range_css = 'span.letter-grade'
+ grade = world.css_find(range_css).first
+ grade.value = range_name
+
+
+@step(u'I see the highest grade range is "(.*)"$')
+def i_see_highest_grade_range(_step, range_name):
+ range_css = 'span.letter-grade'
+ grade = world.css_find(range_css).first
+ assert grade.value == range_name
+
def get_type_index(name):
name_id = '#course-grading-assignment-name'
all_types = world.css_find(name_id)
diff --git a/cms/static/js/views/settings/settings_grading_view.js b/cms/static/js/views/settings/settings_grading_view.js
index f05261d67f..8c2af25f8c 100644
--- a/cms/static/js/views/settings/settings_grading_view.js
+++ b/cms/static/js/views/settings/settings_grading_view.js
@@ -8,7 +8,7 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
// Leaving change in as fallback for older browsers
"change input" : "updateModel",
"change textarea" : "updateModel",
- "change span[contenteditable=true]" : "updateDesignation",
+ "input span[contenteditable]" : "updateDesignation",
"click .settings-extra header" : "showSettingsExtras",
"click .new-grade-button" : "addNewGrade",
"click .remove-button" : "removeGrade",
From a067fb58d60af3e3890af3f208cb2b55066d674b Mon Sep 17 00:00:00 2001
From: JonahStanley
Date: Thu, 15 Aug 2013 16:47:23 -0400
Subject: [PATCH 081/125] Workaround for issue
Better wording of commit
Workaround for issue
Better wording of commit
Fixed spacing
---
cms/envs/acceptance.py | 6 ++++++
lms/envs/acceptance.py | 5 +++++
2 files changed, 11 insertions(+)
diff --git a/cms/envs/acceptance.py b/cms/envs/acceptance.py
index a1f5edb153..7debfe18d1 100644
--- a/cms/envs/acceptance.py
+++ b/cms/envs/acceptance.py
@@ -75,6 +75,12 @@ DATABASES = {
# Use the auto_auth workflow for creating users and logging them in
MITX_FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True
+
+# HACK
+# Setting this flag to false causes imports to not load correctly in the lettuce python files
+# We do not yet understand why this occurs. Setting this to true is a stopgap measure
+USE_I18N = True
+
# Include the lettuce app for acceptance testing, including the 'harvest' django-admin command
INSTALLED_APPS += ('lettuce.django',)
LETTUCE_APPS = ('contentstore',)
diff --git a/lms/envs/acceptance.py b/lms/envs/acceptance.py
index e9ac9762c2..f1132557cf 100644
--- a/lms/envs/acceptance.py
+++ b/lms/envs/acceptance.py
@@ -82,6 +82,11 @@ MITX_FEATURES['ENABLE_DISCUSSION_SERVICE'] = True
# Use the auto_auth workflow for creating users and logging them in
MITX_FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True
+# HACK
+# Setting this flag to false causes imports to not load correctly in the lettuce python files
+# We do not yet understand why this occurs. Setting this to true is a stopgap measure
+USE_I18N = True
+
# Include the lettuce app for acceptance testing, including the 'harvest' django-admin command
INSTALLED_APPS += ('lettuce.django',)
LETTUCE_APPS = ('courseware',)
From bbfe3662e2b5b364a81d507dfe19e4dc4cd40d67 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carlos=20Andr=C3=A9s=20Rocha?=
Date: Thu, 15 Aug 2013 19:31:30 -0400
Subject: [PATCH 082/125] Enable the django_nose app by default
The django_nose it is very useful, even outside the test
environment. For example, it lets you to easily run test from
manage.py without additional changes to the test packages.
---
cms/envs/common.py | 5 ++++-
cms/envs/test.py | 1 -
lms/envs/common.py | 2 +-
lms/envs/test.py | 2 --
4 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/cms/envs/common.py b/cms/envs/common.py
index 9d246edece..d91e2aeeba 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -332,6 +332,9 @@ INSTALLED_APPS = (
# Monitor the status of services
'service_status',
+ # Testing
+ 'django_nose',
+
# For CMS
'contentstore',
'auth',
@@ -339,7 +342,7 @@ INSTALLED_APPS = (
'student', # misleading name due to sharing with lms
'course_groups', # not used in cms (yet), but tests run
- # tracking
+ # Tracking
'track',
# For asset pipelining
diff --git a/cms/envs/test.py b/cms/envs/test.py
index 4f3b0caee0..ffbf9f5376 100644
--- a/cms/envs/test.py
+++ b/cms/envs/test.py
@@ -18,7 +18,6 @@ from path import path
from warnings import filterwarnings
# Nose Test Runner
-INSTALLED_APPS += ('django_nose',)
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
TEST_ROOT = path('test_root')
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 3e65c70805..250552a40c 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -758,6 +758,7 @@ INSTALLED_APPS = (
# For testing
'django.contrib.admin', # only used in DEBUG mode
+ 'django_nose',
'debug',
# Discussion forums
@@ -816,4 +817,3 @@ def enable_theme(theme_name):
# avoid collisions with default edX static files
STATICFILES_DIRS.append((u'themes/%s' % theme_name,
theme_root / 'static'))
-
diff --git a/lms/envs/test.py b/lms/envs/test.py
index 704c78b177..bf2df444f4 100644
--- a/lms/envs/test.py
+++ b/lms/envs/test.py
@@ -39,8 +39,6 @@ WIKI_ENABLED = True
SOUTH_TESTS_MIGRATE = False # To disable migrations and use syncdb instead
# Nose Test Runner
-INSTALLED_APPS += ('django_nose',)
-
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
# Local Directories
From 6f11b98b4fb823874fbafd42ba96debfacd55c9b Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Mon, 22 Jul 2013 10:26:12 -0400
Subject: [PATCH 083/125] initial commit for a mixed module store which can
interoperate with both XML and Mongo module stores
---
.../xmodule/xmodule/modulestore/__init__.py | 2 +-
.../lib/xmodule/xmodule/modulestore/django.py | 41 ++++---
.../lib/xmodule/xmodule/modulestore/mixed.py | 106 ++++++++++++++++++
.../xmodule/xmodule/modulestore/mongo/base.py | 2 +-
.../lib/xmodule/xmodule/modulestore/search.py | 2 +-
.../xmodule/modulestore/split_mongo/split.py | 9 +-
.../xmodule/modulestore/store_utilities.py | 6 +-
.../tests/test_split_modulestore.py | 44 ++++----
common/lib/xmodule/xmodule/modulestore/xml.py | 4 +-
lms/envs/cms/mixed_dev.py | 37 ++++++
10 files changed, 206 insertions(+), 47 deletions(-)
create mode 100644 common/lib/xmodule/xmodule/modulestore/mixed.py
create mode 100644 lms/envs/cms/mixed_dev.py
diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py
index d616f21efa..17741225e5 100644
--- a/common/lib/xmodule/xmodule/modulestore/__init__.py
+++ b/common/lib/xmodule/xmodule/modulestore/__init__.py
@@ -258,7 +258,7 @@ class ModuleStore(object):
An abstract interface for a database backend that stores XModuleDescriptor
instances
"""
- def has_item(self, location):
+ def has_item(self, course_id, location):
"""
Returns True if location exists in this ModuleStore.
"""
diff --git a/common/lib/xmodule/xmodule/modulestore/django.py b/common/lib/xmodule/xmodule/modulestore/django.py
index c98e6cadef..7b1ce37d07 100644
--- a/common/lib/xmodule/xmodule/modulestore/django.py
+++ b/common/lib/xmodule/xmodule/modulestore/django.py
@@ -25,24 +25,31 @@ def load_function(path):
return getattr(import_module(module_path), name)
+def create_modulestore_instance(engine, options):
+ """
+ This will return a new instance of a modulestore given an engine and options
+ """
+ class_ = load_function(engine)
+
+ _options = {}
+ _options.update(options)
+
+ for key in FUNCTION_KEYS:
+ if key in _options:
+ _options[key] = load_function(_options[key])
+
+ return class_(
+ **_options
+ )
+
+
def modulestore(name='default'):
+ """
+ This returns an instance of a modulestore of given name. This will wither return an existing
+ modulestore or create a new one
+ """
if name not in _MODULESTORES:
- class_ = load_function(settings.MODULESTORE[name]['ENGINE'])
-
- options = {}
-
- options.update(settings.MODULESTORE[name]['OPTIONS'])
- for key in FUNCTION_KEYS:
- if key in options:
- options[key] = load_function(options[key])
-
- _MODULESTORES[name] = class_(
- **options
- )
+ _MODULESTORES[name] = create_modulestore_instance(settings.MODULESTORE[name]['ENGINE'],
+ settings.MODULESTORE[name]['OPTIONS'])
return _MODULESTORES[name]
-
-# if 'DJANGO_SETTINGS_MODULE' in environ:
-# # Initialize the modulestores immediately
-# for store_name in settings.MODULESTORE:
-# modulestore(store_name)
diff --git a/common/lib/xmodule/xmodule/modulestore/mixed.py b/common/lib/xmodule/xmodule/modulestore/mixed.py
new file mode 100644
index 0000000000..1ecb12f858
--- /dev/null
+++ b/common/lib/xmodule/xmodule/modulestore/mixed.py
@@ -0,0 +1,106 @@
+"""
+MixedModuleStore allows for aggregation between multiple modulestores.
+
+In this way, courses can be served up both - say - XMLModuleStore or MongoModuleStore
+
+IMPORTANT: This modulestore is experimental AND INCOMPLETE. Therefore this should only be used cautiously
+"""
+
+from . import ModuleStoreBase
+from django import create_modulestore_instance
+
+
+class MixedModuleStore(ModuleStoreBase):
+ """
+ ModuleStore that can be backed by either XML or Mongo
+ """
+ def __init__(self, mappings, stores):
+ """
+ Initialize a MixedModuleStore. Here we look into our passed in kwargs which should be a
+ collection of other modulestore configuration informations
+ """
+ super(MixedModuleStore, self).__init__()
+
+ self.modulestores = {}
+ self.mappings = mappings
+ for key in stores:
+ self.modulestores[key] = create_modulestore_instance(stores[key]['ENGINE'],
+ stores[key]['OPTIONS'])
+
+ def _get_modulestore_for_courseid(self, course_id):
+ """
+ For a given course_id, look in the mapping table and see if it has been pinned
+ to a particular modulestore
+ """
+ return self.mappings.get(course_id, self.mappings['default'])
+
+ def has_item(self, course_id, location):
+ return self._get_modulestore_for_courseid(course_id).has_item(course_id, location)
+
+ def get_item(self, location, depth=0):
+ """
+ This method is explicitly not implemented as we need a course_id to disambiguate
+ We should be able to fix this when the data-model rearchitecting is done
+ """
+ raise NotImplementedError
+
+ def get_instance(self, course_id, location, depth=0):
+ return self._get_modulestore_for_courseid(course_id).get_instance(course_id, location, depth)
+
+ def get_items(self, location, course_id=None, depth=0):
+ """
+ Returns a list of XModuleDescriptor instances for the items
+ that match location. Any element of location that is None is treated
+ as a wildcard that matches any value
+
+ location: Something that can be passed to Location
+
+ depth: An argument that some module stores may use to prefetch
+ descendents of the queried modules for more efficient results later
+ in the request. The depth is counted in the number of calls to
+ get_children() to cache. None indicates to cache all descendents
+ """
+ if not course_id:
+ raise Exception("Must pass in a course_id when calling get_items() with MixedModuleStore")
+
+ return self._get_modulestore_for_courseid(course_id).get_items(location, course_id, depth)
+
+ def update_item(self, location, data, allow_not_found=False):
+ """
+ MixedModuleStore is for read-only (aka LMS)
+ """
+ raise NotImplementedError
+
+ def update_children(self, location, children):
+ """
+ MixedModuleStore is for read-only (aka LMS)
+ """
+ raise NotImplementedError
+
+ def update_metadata(self, location, metadata):
+ """
+ MixedModuleStore is for read-only (aka LMS)
+ """
+ raise NotImplementedError
+
+ def delete_item(self, location):
+ """
+ MixedModuleStore is for read-only (aka LMS)
+ """
+ raise NotImplementedError
+
+ def get_courses(self):
+ '''
+ Returns a list containing the top level XModuleDescriptors of the courses
+ in this modulestore.
+ '''
+ courses = []
+ for key in self.modulestores:
+ courses.append(self.modulestores[key].get_courses)
+ return courses
+
+ def get_course(self, course_id):
+ return self._get_modulestore_for_courseid(course_id).get_course(course_id)
+
+ def get_parent_locations(self, location, course_id):
+ return self._get_modulestore_for_courseid(course_id).get_parent_locations(location, course_id)
diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/base.py b/common/lib/xmodule/xmodule/modulestore/mongo/base.py
index 21daff1875..8b4ce23ba7 100644
--- a/common/lib/xmodule/xmodule/modulestore/mongo/base.py
+++ b/common/lib/xmodule/xmodule/modulestore/mongo/base.py
@@ -547,7 +547,7 @@ class MongoModuleStore(ModuleStoreBase):
raise ItemNotFoundError(location)
return item
- def has_item(self, location):
+ def has_item(self, course_id, location):
"""
Returns True if location exists in this ModuleStore.
"""
diff --git a/common/lib/xmodule/xmodule/modulestore/search.py b/common/lib/xmodule/xmodule/modulestore/search.py
index 25ebc7e89c..804cdb0194 100644
--- a/common/lib/xmodule/xmodule/modulestore/search.py
+++ b/common/lib/xmodule/xmodule/modulestore/search.py
@@ -81,7 +81,7 @@ def path_to_location(modulestore, course_id, location):
# If we're here, there is no path
return None
- if not modulestore.has_item(location):
+ if not modulestore.has_item(course_id, location):
raise ItemNotFoundError
path = find_path_to_course()
diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py
index 74c7e7241a..52f5539bae 100644
--- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py
+++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py
@@ -275,7 +275,14 @@ class SplitMongoModuleStore(ModuleStoreBase):
result = self._load_items(course_entry, [root], 0, lazy=True)
return result[0]
- def has_item(self, block_location):
+ def get_course_for_item(self, location):
+ '''
+ Provided for backward compatibility. Is equivalent to calling get_course
+ :param location:
+ '''
+ return self.get_course(location)
+
+ def has_item(self, course_id, block_location):
"""
Returns True if location exists in its course. Returns false if
the course or the block w/in the course do not exist for the given version.
diff --git a/common/lib/xmodule/xmodule/modulestore/store_utilities.py b/common/lib/xmodule/xmodule/modulestore/store_utilities.py
index e0f3db6810..19d1cac988 100644
--- a/common/lib/xmodule/xmodule/modulestore/store_utilities.py
+++ b/common/lib/xmodule/xmodule/modulestore/store_utilities.py
@@ -152,7 +152,7 @@ def clone_course(modulestore, contentstore, source_location, dest_location, dele
# check to see if the dest_location exists as an empty course
# we need an empty course because the app layers manage the permissions and users
- if not modulestore.has_item(dest_location):
+ if not modulestore.has_item(dest_location.course_id, dest_location):
raise Exception("An empty course at {0} must have already been created. Aborting...".format(dest_location))
# verify that the dest_location really is an empty course, which means only one with an optional 'overview'
@@ -171,7 +171,7 @@ def clone_course(modulestore, contentstore, source_location, dest_location, dele
raise Exception("Course at destination {0} is not an empty course. You can only clone into an empty course. Aborting...".format(dest_location))
# check to see if the source course is actually there
- if not modulestore.has_item(source_location):
+ if not modulestore.has_item(source_location.course_id, source_location):
raise Exception("Cannot find a course at {0}. Aborting".format(source_location))
# Get all modules under this namespace which is (tag, org, course) tuple
@@ -250,7 +250,7 @@ def delete_course(modulestore, contentstore, source_location, commit=False):
"""
# check to see if the source course is actually there
- if not modulestore.has_item(source_location):
+ if not modulestore.has_item(source_location.course_id, source_location):
raise Exception("Cannot find a course at {0}. Aborting".format(source_location))
# first delete all of the thumbnails
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
index 9976a33a00..ca5eb72a26 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
@@ -257,18 +257,19 @@ class SplitModuleItemTests(SplitModuleTest):
'''
has_item(BlockUsageLocator)
'''
+ course_id = 'GreekHero'
# positive tests of various forms
locator = BlockUsageLocator(version_guid=self.GUID_D1, usage_id='head12345')
- self.assertTrue(modulestore().has_item(locator),
+ self.assertTrue(modulestore().has_item(course_id, locator),
"couldn't find in %s" % self.GUID_D1)
locator = BlockUsageLocator(course_id='GreekHero', usage_id='head12345', branch='draft')
self.assertTrue(
- modulestore().has_item(locator),
+ modulestore().has_item(course_id, locator),
"couldn't find in 12345"
)
self.assertTrue(
- modulestore().has_item(BlockUsageLocator(
+ modulestore().has_item(course_id, BlockUsageLocator(
course_id=locator.course_id,
branch='draft',
usage_id=locator.usage_id
@@ -276,7 +277,7 @@ class SplitModuleItemTests(SplitModuleTest):
"couldn't find in draft 12345"
)
self.assertFalse(
- modulestore().has_item(BlockUsageLocator(
+ modulestore().has_item(course_id, BlockUsageLocator(
course_id=locator.course_id,
branch='published',
usage_id=locator.usage_id)),
@@ -284,40 +285,41 @@ class SplitModuleItemTests(SplitModuleTest):
)
locator.branch = 'draft'
self.assertTrue(
- modulestore().has_item(locator),
+ modulestore().has_item(course_id, locator),
"not found in draft 12345"
)
# not a course obj
locator = BlockUsageLocator(course_id='GreekHero', usage_id='chapter1', branch='draft')
self.assertTrue(
- modulestore().has_item(locator),
+ modulestore().has_item(course_id, locator),
"couldn't find chapter1"
)
# in published course
- locator = BlockUsageLocator(course_id="wonderful", usage_id="head23456", branch='draft')
- self.assertTrue(modulestore().has_item(BlockUsageLocator(course_id=locator.course_id,
- usage_id=locator.usage_id,
- branch='published')),
+ locator = BlockUsageLocator(course_id="wonderful", usage_id="head23456", revision='draft')
+ self.assertTrue(modulestore().has_item(course_id, BlockUsageLocator(course_id=locator.course_id,
+ usage_id=locator.usage_id,
+ revision='published')),
"couldn't find in 23456")
locator.branch = 'published'
- self.assertTrue(modulestore().has_item(locator), "couldn't find in 23456")
+ self.assertTrue(modulestore().has_item(course_id, locator), "couldn't find in 23456")
def test_negative_has_item(self):
# negative tests--not found
# no such course or block
+ course_id = 'GreekHero'
locator = BlockUsageLocator(course_id="doesnotexist", usage_id="head23456", branch='draft')
- self.assertFalse(modulestore().has_item(locator))
+ self.assertFalse(modulestore().has_item(course_id, locator))
locator = BlockUsageLocator(course_id="wonderful", usage_id="doesnotexist", branch='draft')
- self.assertFalse(modulestore().has_item(locator))
+ self.assertFalse(modulestore().has_item(course_id, locator))
# negative tests--insufficient specification
self.assertRaises(InsufficientSpecificationError, BlockUsageLocator)
self.assertRaises(InsufficientSpecificationError,
- modulestore().has_item, BlockUsageLocator(version_guid=self.GUID_D1))
+ modulestore().has_item, None, BlockUsageLocator(version_guid=self.GUID_D1))
self.assertRaises(InsufficientSpecificationError,
- modulestore().has_item, BlockUsageLocator(course_id='GreekHero'))
+ modulestore().has_item, None, BlockUsageLocator(course_id='GreekHero'))
def test_get_item(self):
'''
@@ -737,13 +739,13 @@ class TestItemCrud(SplitModuleTest):
deleted = BlockUsageLocator(course_id=reusable_location.course_id,
branch=reusable_location.branch,
usage_id=locn_to_del.usage_id)
- self.assertFalse(modulestore().has_item(deleted))
- self.assertRaises(VersionConflictError, modulestore().has_item, locn_to_del)
+ self.assertFalse(modulestore().has_item(reusable_location.course_id, deleted))
+ self.assertRaises(VersionConflictError, modulestore().has_item, reusable_location.course_id, locn_to_del)
locator = BlockUsageLocator(
version_guid=locn_to_del.version_guid,
usage_id=locn_to_del.usage_id
)
- self.assertTrue(modulestore().has_item(locator))
+ self.assertTrue(modulestore().has_item(reusable_location.course_id, locator))
self.assertNotEqual(new_course_loc.version_guid, course.location.version_guid)
# delete a subtree
@@ -754,7 +756,7 @@ class TestItemCrud(SplitModuleTest):
def check_subtree(node):
if node:
node_loc = node.location
- self.assertFalse(modulestore().has_item(
+ self.assertFalse(modulestore().has_item(reusable_location.course_id,
BlockUsageLocator(
course_id=node_loc.course_id,
branch=node_loc.branch,
@@ -762,7 +764,7 @@ class TestItemCrud(SplitModuleTest):
locator = BlockUsageLocator(
version_guid=node.location.version_guid,
usage_id=node.location.usage_id)
- self.assertTrue(modulestore().has_item(locator))
+ self.assertTrue(modulestore().has_item(reusable_location.course_id, locator))
if node.has_children:
for sub in node.get_children():
check_subtree(sub)
@@ -873,7 +875,7 @@ class TestCourseCreation(SplitModuleTest):
original_course = modulestore().get_course(original_locator)
self.assertEqual(original_course.location.version_guid, original_index['versions']['draft'])
self.assertFalse(
- modulestore().has_item(BlockUsageLocator(
+ modulestore().has_item(new_draft_locator.course_id, BlockUsageLocator(
original_locator,
usage_id=new_item.location.usage_id
))
diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py
index 8bc3142c77..ae726041ab 100644
--- a/common/lib/xmodule/xmodule/modulestore/xml.py
+++ b/common/lib/xmodule/xmodule/modulestore/xml.py
@@ -505,12 +505,12 @@ class XMLModuleStore(ModuleStoreBase):
except KeyError:
raise ItemNotFoundError(location)
- def has_item(self, location):
+ def has_item(self, course_id, location):
"""
Returns True if location exists in this ModuleStore.
"""
location = Location(location)
- return any(location in course_modules for course_modules in self.modules.values())
+ return location in self.modules[course_id]
def get_item(self, location, depth=0):
"""
diff --git a/lms/envs/cms/mixed_dev.py b/lms/envs/cms/mixed_dev.py
new file mode 100644
index 0000000000..22fd5eeb0e
--- /dev/null
+++ b/lms/envs/cms/mixed_dev.py
@@ -0,0 +1,37 @@
+"""
+This configuration is to run the MixedModuleStore on a localdev environment
+"""
+
+from .dev import *
+
+MODULESTORE = {
+ 'default': {
+ 'ENGINE': 'xmodule.modulestore.mixed.MixedModuleStore',
+ 'OPTIONS': {
+ 'mappings': {
+ '6.002/a/a': 'xml',
+ '6.002/b/b': 'xml'
+ },
+ 'stores': {
+ 'xml': {
+ 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore',
+ 'OPTIONS': {
+ 'data_dir': DATA_DIR,
+ 'default_class': 'xmodule.hidden_module.HiddenDescriptor',
+ }
+ },
+ 'default': {
+ 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
+ 'OPTIONS': {
+ 'default_class': 'xmodule.raw_module.RawDescriptor',
+ 'host': 'localhost',
+ 'db': 'xmodule',
+ 'collection': 'modulestore',
+ 'fs_root': DATA_DIR,
+ 'render_template': 'mitxmako.shortcuts.render_to_string',
+ }
+ }
+ },
+ }
+ }
+}
From b5253b52b693f231d534600ad6eb3681e89f5614 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Tue, 23 Jul 2013 21:25:18 -0400
Subject: [PATCH 084/125] add a set_modulestore_configuration to the
modulestore interface. This can be used to pass in settings run the Django
tier
---
cms/one_time_startup.py | 7 +++++--
.../lib/xmodule/xmodule/modulestore/__init__.py | 16 ++++++++++++++++
common/lib/xmodule/xmodule/modulestore/mixed.py | 14 ++++++++++++++
lms/one_time_startup.py | 6 ++++--
4 files changed, 39 insertions(+), 4 deletions(-)
diff --git a/cms/one_time_startup.py b/cms/one_time_startup.py
index cbd8775d97..4198cf2637 100644
--- a/cms/one_time_startup.py
+++ b/cms/one_time_startup.py
@@ -9,8 +9,11 @@ from django.core.cache import get_cache
CACHE = get_cache('mongo_metadata_inheritance')
for store_name in settings.MODULESTORE:
store = modulestore(store_name)
- store.metadata_inheritance_cache_subsystem = CACHE
- store.request_cache = RequestCache.get_request_cache()
+
+ store.set_modulestore_configuration({
+ 'metadata_inheritance_cache_subsystem': CACHE,
+ 'request_cache': RequestCache.get_request_cache()
+ })
modulestore_update_signal = Signal(providing_args=['modulestore', 'course_id', 'location'])
store.modulestore_update_signal = modulestore_update_signal
diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py
index 17741225e5..a9848d6c05 100644
--- a/common/lib/xmodule/xmodule/modulestore/__init__.py
+++ b/common/lib/xmodule/xmodule/modulestore/__init__.py
@@ -384,6 +384,13 @@ class ModuleStore(object):
"""
raise NotImplementedError
+ def set_modulestore_configuration(self, config_dict):
+ '''
+ Allows for runtime configuration of the modulestore. In particular this is how the
+ application (LMS/CMS) can pass down Django related configuration information, e.g. caches, etc.
+ '''
+ raise NotImplementedError
+
class ModuleStoreBase(ModuleStore):
'''
@@ -395,6 +402,7 @@ class ModuleStoreBase(ModuleStore):
'''
self._location_errors = {} # location -> ErrorLog
self.metadata_inheritance_cache = None
+ self.request_cache = None
self.modulestore_update_signal = None # can be set by runtime to route notifications of datastore changes
def _get_errorlog(self, location):
@@ -439,6 +447,14 @@ class ModuleStoreBase(ModuleStore):
return c
return None
+ def set_modulestore_configuration(self, config_dict):
+ """
+ This is the base implementation of the interface, all we need to do is store
+ two possible configurations as attributes on the class
+ """
+ self.metadata_inheritance_cache = config_dict.get('metadata_inheritance_cache_subsystem', None)
+ self.request_cache = config_dict.get('request_cache', None)
+
def namedtuple_to_son(namedtuple, prefix=''):
"""
diff --git a/common/lib/xmodule/xmodule/modulestore/mixed.py b/common/lib/xmodule/xmodule/modulestore/mixed.py
index 1ecb12f858..fe4d4d63be 100644
--- a/common/lib/xmodule/xmodule/modulestore/mixed.py
+++ b/common/lib/xmodule/xmodule/modulestore/mixed.py
@@ -100,7 +100,21 @@ class MixedModuleStore(ModuleStoreBase):
return courses
def get_course(self, course_id):
+ """
+ returns the course module associated with the course_id
+ """
return self._get_modulestore_for_courseid(course_id).get_course(course_id)
def get_parent_locations(self, location, course_id):
+ """
+ returns the parent locations for a given lcoation and course_id
+ """
return self._get_modulestore_for_courseid(course_id).get_parent_locations(location, course_id)
+
+ def set_modulestore_configuration(self, config_dict):
+ """
+ This implementation of the interface method will pass along the configuration to all ModuleStore
+ instances
+ """
+ for store in self.modulestores.values():
+ store.set_modulestore_configuration(config_dict)
diff --git a/lms/one_time_startup.py b/lms/one_time_startup.py
index e10ec06685..2cd2077c4e 100644
--- a/lms/one_time_startup.py
+++ b/lms/one_time_startup.py
@@ -8,8 +8,10 @@ from django.core.cache import get_cache
cache = get_cache('mongo_metadata_inheritance')
for store_name in settings.MODULESTORE:
store = modulestore(store_name)
- store.metadata_inheritance_cache_subsystem = cache
- store.request_cache = RequestCache.get_request_cache()
+ store.set_modulestore_configuration({
+ 'metadata_inheritance_cache_subsystem': cache,
+ 'request_cache': RequestCache.get_request_cache()
+ })
if hasattr(settings, 'DATADOG_API'):
dog_http_api.api_key = settings.DATADOG_API
From 82988972ade7452ca59dbeab63ce974e80afa972 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Wed, 24 Jul 2013 08:40:04 -0400
Subject: [PATCH 085/125] WIP: added a get_modulestore_type. Added some unit
tests. changed over the isinstance() with respect to modulestores to use this
get_modulestore_type()
---
common/djangoapps/static_replace/__init__.py | 10 +++++-----
.../lib/xmodule/xmodule/modulestore/__init__.py | 9 +++++++++
common/lib/xmodule/xmodule/modulestore/mixed.py | 7 +++++++
.../lib/xmodule/xmodule/modulestore/mongo/base.py | 15 +++++++++++----
.../xmodule/modulestore/tests/test_mongo.py | 8 ++++++--
.../xmodule/xmodule/modulestore/tests/test_xml.py | 11 ++++++++---
common/lib/xmodule/xmodule/modulestore/xml.py | 9 ++++++++-
common/lib/xmodule/xmodule/video_module.py | 7 +------
lms/djangoapps/courseware/courses.py | 4 ++--
9 files changed, 57 insertions(+), 23 deletions(-)
diff --git a/common/djangoapps/static_replace/__init__.py b/common/djangoapps/static_replace/__init__.py
index 9e50d73b26..c08db34349 100644
--- a/common/djangoapps/static_replace/__init__.py
+++ b/common/djangoapps/static_replace/__init__.py
@@ -6,7 +6,7 @@ from staticfiles import finders
from django.conf import settings
from xmodule.modulestore.django import modulestore
-from xmodule.modulestore.xml import XMLModuleStore
+from xmodule.modulestore import XML_MODULESTORE_TYPE
from xmodule.contentstore.content import StaticContent
log = logging.getLogger(__name__)
@@ -90,7 +90,7 @@ def replace_course_urls(text, course_id):
return re.sub(_url_replace_regex('/course/'), replace_course_url, text)
-def replace_static_urls(text, data_directory, course_namespace=None):
+def replace_static_urls(text, data_directory, course_id=None):
"""
Replace /static/$stuff urls either with their correct url as generated by collectstatic,
(/static/$md5_hashed_stuff) or by the course-specific content static url
@@ -99,7 +99,7 @@ def replace_static_urls(text, data_directory, course_namespace=None):
text: The source text to do the substitution in
data_directory: The directory in which course data is stored
- course_namespace: The course identifier used to distinguish static content for this course in studio
+ course_id: The course identifier used to distinguish static content for this course in studio
"""
def replace_static_url(match):
@@ -116,7 +116,7 @@ def replace_static_urls(text, data_directory, course_namespace=None):
if settings.DEBUG and finders.find(rest, True):
return original
# if we're running with a MongoBacked store course_namespace is not None, then use studio style urls
- elif course_namespace is not None and not isinstance(modulestore(), XMLModuleStore):
+ elif course_id and modulestore().get_modulestore_type(course_id) != XML_MODULESTORE_TYPE:
# first look in the static file pipeline and see if we are trying to reference
# a piece of static content which is in the mitx repo (e.g. JS associated with an xmodule)
if staticfiles_storage.exists(rest):
@@ -124,7 +124,7 @@ def replace_static_urls(text, data_directory, course_namespace=None):
else:
# if not, then assume it's courseware specific content and then look in the
# Mongo-backed database
- url = StaticContent.convert_legacy_static_url(rest, course_namespace)
+ url = StaticContent.convert_legacy_static_url(rest, course_id)
# Otherwise, look the file up in staticfiles_storage, and append the data directory if needed
else:
course_path = "/".join((data_directory, rest))
diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py
index a9848d6c05..ee61998931 100644
--- a/common/lib/xmodule/xmodule/modulestore/__init__.py
+++ b/common/lib/xmodule/xmodule/modulestore/__init__.py
@@ -14,6 +14,8 @@ from bson.son import SON
log = logging.getLogger('mitx.' + 'modulestore')
+MONGO_MODULESTORE_TYPE = 'mongo'
+XML_MODULESTORE_TYPE = 'xml'
URL_RE = re.compile("""
(?P[^:]+)://?
@@ -391,6 +393,13 @@ class ModuleStore(object):
'''
raise NotImplementedError
+ def get_modulestore_type(self, course_id):
+ """
+ Returns a type which identifies which modulestore is servicing the given
+ course_id. The return can be either "xml" (for XML based courses) or "mongo" for MongoDB backed courses
+ """
+ raise NotImplementedError
+
class ModuleStoreBase(ModuleStore):
'''
diff --git a/common/lib/xmodule/xmodule/modulestore/mixed.py b/common/lib/xmodule/xmodule/modulestore/mixed.py
index fe4d4d63be..253852d1db 100644
--- a/common/lib/xmodule/xmodule/modulestore/mixed.py
+++ b/common/lib/xmodule/xmodule/modulestore/mixed.py
@@ -118,3 +118,10 @@ class MixedModuleStore(ModuleStoreBase):
"""
for store in self.modulestores.values():
store.set_modulestore_configuration(config_dict)
+
+ def get_modulestore_type(self, course_id):
+ """
+ Returns a type which identifies which modulestore is servicing the given
+ course_id. The return can be either "xml" (for XML based courses) or "mongo" for MongoDB backed courses
+ """
+ return self._get_modulestore_for_courseid(course_id).get_modulestore_type(course_id)
diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/base.py b/common/lib/xmodule/xmodule/modulestore/mongo/base.py
index 8b4ce23ba7..de946f76e1 100644
--- a/common/lib/xmodule/xmodule/modulestore/mongo/base.py
+++ b/common/lib/xmodule/xmodule/modulestore/mongo/base.py
@@ -32,7 +32,7 @@ from xmodule.error_module import ErrorDescriptor
from xblock.runtime import DbModel, KeyValueStore, InvalidScopeError
from xblock.core import Scope
-from xmodule.modulestore import ModuleStoreBase, Location, namedtuple_to_son
+from xmodule.modulestore import ModuleStoreBase, Location, namedtuple_to_son, MONGO_MODULESTORE_TYPE
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.inheritance import own_metadata, INHERITABLE_METADATA, inherit_metadata
@@ -841,6 +841,13 @@ class MongoModuleStore(ModuleStoreBase):
{'_id': True})
return [i['_id'] for i in items]
+ def get_modulestore_type(self, course_id):
+ """
+ Returns a type which identifies which modulestore is servicing the given
+ course_id. The return can be either "xml" (for XML based courses) or "mongo" for MongoDB backed courses
+ """
+ return MONGO_MODULESTORE_TYPE
+
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
@@ -854,9 +861,9 @@ class MongoModuleStore(ModuleStoreBase):
)
class_ = XModuleDescriptor.load_class(
- category,
- self.default_class
- )
+ category,
+ self.default_class
+ )
model_data = DbModel(kvs, class_, None, MongoUsage(None, location))
model_data['category'] = category
model_data['location'] = location
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py
index 69ba9ad94b..58c48f673e 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py
@@ -45,8 +45,7 @@ class TestMongoModuleStore(object):
@staticmethod
def initdb():
# connect to the db
- store = MongoModuleStore(HOST, DB, COLLECTION, FS_ROOT, RENDER_TEMPLATE,
- default_class=DEFAULT_CLASS)
+ store = MongoModuleStore(HOST, DB, COLLECTION, FS_ROOT, RENDER_TEMPLATE, default_class=DEFAULT_CLASS)
# Explicitly list the courses to load (don't want the big one)
courses = ['toy', 'simple']
import_from_xml(store, DATA_DIR, courses)
@@ -71,6 +70,10 @@ class TestMongoModuleStore(object):
pprint([Location(i['_id']).url() for i in ids])
+ def test_mongo_modulestore_type(self):
+ store = MongoModuleStore(HOST, DB, COLLECTION, FS_ROOT, RENDER_TEMPLATE, default_class=DEFAULT_CLASS)
+ assert_equals(store.get_modulestore_type('foo/bar/baz'), 'mongo')
+
def test_get_courses(self):
'''Make sure the course objects loaded properly'''
courses = self.store.get_courses()
@@ -117,6 +120,7 @@ class TestMongoModuleStore(object):
'{0} is a template course'.format(course)
)
+
class TestMongoKeyValueStore(object):
def setUp(self):
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_xml.py b/common/lib/xmodule/xmodule/modulestore/tests/test_xml.py
index 1819850614..676d928bb7 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/test_xml.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/test_xml.py
@@ -1,12 +1,13 @@
import os.path
-from nose.tools import assert_raises
+from nose.tools import assert_raises, assert_equals
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore.xml import XMLModuleStore
+from xmodule.modulestore import XML_MODULESTORE_TYPE
-from xmodule.tests import DATA_DIR
-from xmodule.modulestore.tests.test_modulestore import check_path_to_location
+from .test_modulestore import check_path_to_location
+from . import DATA_DIR
class TestXMLModuleStore(object):
@@ -19,6 +20,10 @@ class TestXMLModuleStore(object):
check_path_to_location(modulestore)
+ def test_xml_modulestore_type(self):
+ store = XMLModuleStore(DATA_DIR, course_dirs=['toy', 'simple'])
+ assert_equals(store.get_modulestore_type('foo/bar/baz'), XML_MODULESTORE_TYPE)
+
def test_unicode_chars_in_xml_content(self):
# edX/full/6.002_Spring_2012 has non-ASCII chars, and during
# uniquification of names, would raise a UnicodeError. It no longer does.
diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py
index ae726041ab..fd04982f81 100644
--- a/common/lib/xmodule/xmodule/modulestore/xml.py
+++ b/common/lib/xmodule/xmodule/modulestore/xml.py
@@ -21,7 +21,7 @@ from xmodule.x_module import XModuleDescriptor, XMLParsingSystem
from xmodule.html_module import HtmlDescriptor
-from . import ModuleStoreBase, Location
+from . import ModuleStoreBase, Location, XML_MODULESTORE_TYPE
from .exceptions import ItemNotFoundError
from .inheritance import compute_inherited_metadata
@@ -601,3 +601,10 @@ class XMLModuleStore(ModuleStoreBase):
raise ItemNotFoundError("{0} not in {1}".format(location, course_id))
return self.parent_trackers[course_id].parents(location)
+
+ def get_modulestore_type(self, course_id):
+ """
+ Returns a type which identifies which modulestore is servicing the given
+ course_id. The return can be either "xml" (for XML based courses) or "mongo" for MongoDB backed courses
+ """
+ return XML_MODULESTORE_TYPE
diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py
index dd408a6f74..e847638af1 100644
--- a/common/lib/xmodule/xmodule/video_module.py
+++ b/common/lib/xmodule/xmodule/video_module.py
@@ -161,12 +161,7 @@ class VideoModule(VideoFields, XModule):
return json.dumps({'position': self.position})
def get_html(self):
- if isinstance(modulestore(), MongoModuleStore):
- caption_asset_path = StaticContent.get_base_url_path_for_course_assets(self.location) + '/subs_'
- else:
- # VS[compat]
- # cdodge: filesystem static content support.
- caption_asset_path = "/static/subs/"
+ caption_asset_path = "/static/subs/"
get_ext = lambda filename: filename.rpartition('.')[-1]
sources = {get_ext(src): src for src in self.html5_sources}
diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py
index 086f92a123..34de2eb958 100644
--- a/lms/djangoapps/courseware/courses.py
+++ b/lms/djangoapps/courseware/courses.py
@@ -8,7 +8,7 @@ from django.http import Http404
from .module_render import get_module
from xmodule.course_module import CourseDescriptor
-from xmodule.modulestore import Location
+from xmodule.modulestore import Location, MONGO_MODULESTORE_TYPE
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.content import StaticContent
from xmodule.modulestore.xml import XMLModuleStore
@@ -82,7 +82,7 @@ def get_opt_course_with_access(user, course_id, action):
def course_image_url(course):
"""Try to look up the image url for the course. If it's not found,
log an error and return the dead link"""
- if isinstance(modulestore(), XMLModuleStore):
+ if modulestore().get_modulestore_type(course.course_id) == MONGO_MODULESTORE_TYPE:
return '/static/' + course.data_dir + "/images/course_image.jpg"
else:
loc = course.location._replace(tag='c4x', category='asset', name='images_course_image.jpg')
From 2616d8f7a2819e68f87932db4de357227b939faf Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Wed, 24 Jul 2013 08:52:11 -0400
Subject: [PATCH 086/125] remove unneeded type check on the modulestore
---
common/lib/xmodule/xmodule/modulestore/store_utilities.py | 4 ----
1 file changed, 4 deletions(-)
diff --git a/common/lib/xmodule/xmodule/modulestore/store_utilities.py b/common/lib/xmodule/xmodule/modulestore/store_utilities.py
index 19d1cac988..725d4aebb7 100644
--- a/common/lib/xmodule/xmodule/modulestore/store_utilities.py
+++ b/common/lib/xmodule/xmodule/modulestore/store_utilities.py
@@ -146,10 +146,6 @@ def _clone_modules(modulestore, modules, source_location, dest_location):
def clone_course(modulestore, contentstore, source_location, dest_location, delete_original=False):
- # first check to see if the modulestore is Mongo backed
- if not isinstance(modulestore, MongoModuleStore):
- raise Exception("Expected a MongoModuleStore in the runtime. Aborting....")
-
# check to see if the dest_location exists as an empty course
# we need an empty course because the app layers manage the permissions and users
if not modulestore.has_item(dest_location.course_id, dest_location):
From cff93d324bc932cd5f6ee3448f6dcfef29d11251 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Thu, 25 Jul 2013 00:50:42 -0400
Subject: [PATCH 087/125] WIP
---
cms/djangoapps/contentstore/views/preview.py | 4 ++--
common/djangoapps/contentserver/middleware.py | 5 +++++
common/djangoapps/static_replace/__init__.py | 2 +-
common/djangoapps/student/views.py | 1 +
common/djangoapps/xmodule_modifiers.py | 4 ++--
.../xmodule/xmodule/contentstore/content.py | 6 ++++++
.../lib/xmodule/xmodule/modulestore/mixed.py | 21 +++++++++++++++++--
lms/djangoapps/branding/__init__.py | 5 +++--
lms/djangoapps/courseware/courses.py | 11 +++++-----
lms/djangoapps/courseware/module_render.py | 8 ++++++-
lms/djangoapps/staticbook/views.py | 2 +-
lms/envs/cms/mixed_dev.py | 3 +--
12 files changed, 53 insertions(+), 19 deletions(-)
diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py
index 0591ff0dc4..bbbf3d5f0a 100644
--- a/cms/djangoapps/contentstore/views/preview.py
+++ b/cms/djangoapps/contentstore/views/preview.py
@@ -116,7 +116,7 @@ def preview_module_system(request, preview_id, descriptor):
get_module=partial(load_preview_module, request, preview_id),
render_template=render_from_lms,
debug=True,
- replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_namespace=descriptor.location),
+ replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_id=course_id),
user=request.user,
xblock_model_data=preview_model_data,
can_execute_unsafe_code=(lambda: can_execute_unsafe_code(course_id)),
@@ -158,7 +158,7 @@ def load_preview_module(request, preview_id, descriptor):
module.get_html = replace_static_urls(
module.get_html,
getattr(module, 'data_dir', module.location.course),
- course_namespace=Location([module.location.tag, module.location.org, module.location.course, None, None])
+ course_id=module.location.org+'/'+module.location.course+'/REPLACE_WITH_RUN'
)
module.get_html = save_module(
diff --git a/common/djangoapps/contentserver/middleware.py b/common/djangoapps/contentserver/middleware.py
index d89e3fdd23..733e1c30e3 100644
--- a/common/djangoapps/contentserver/middleware.py
+++ b/common/djangoapps/contentserver/middleware.py
@@ -6,11 +6,14 @@ from xmodule.modulestore import InvalidLocationError
from cache_toolbox.core import get_cached_content, set_cached_content
from xmodule.exceptions import NotFoundError
+import logging
+
class StaticContentServer(object):
def process_request(self, request):
# look to see if the request is prefixed with 'c4x' tag
if request.path.startswith('/' + XASSET_LOCATION_TAG + '/'):
+ logging.debug('**** path = {0}'.format(request.path))
try:
loc = StaticContent.get_location_from_path(request.path)
except InvalidLocationError:
@@ -24,8 +27,10 @@ class StaticContentServer(object):
if content is None:
# nope, not in cache, let's fetch from DB
try:
+ logging.debug('!!!! loc = {0}'.format(loc))
content = contentstore().find(loc, as_stream=True)
except NotFoundError:
+ logging.debug('**** NOT FOUND')
response = HttpResponse()
response.status_code = 404
return response
diff --git a/common/djangoapps/static_replace/__init__.py b/common/djangoapps/static_replace/__init__.py
index c08db34349..63c576cdd2 100644
--- a/common/djangoapps/static_replace/__init__.py
+++ b/common/djangoapps/static_replace/__init__.py
@@ -124,7 +124,7 @@ def replace_static_urls(text, data_directory, course_id=None):
else:
# if not, then assume it's courseware specific content and then look in the
# Mongo-backed database
- url = StaticContent.convert_legacy_static_url(rest, course_id)
+ url = StaticContent.convert_legacy_static_url_with_course_id(rest, course_id)
# Otherwise, look the file up in staticfiles_storage, and append the data directory if needed
else:
course_path = "/".join((data_directory, rest))
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index 7795a13c47..4d59b5cc66 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -95,6 +95,7 @@ def index(request, extra_context={}, user=None):
courses = sort_by_announcement(courses)
context = {'courses': courses}
+
context.update(extra_context)
return render_to_response('index.html', context)
diff --git a/common/djangoapps/xmodule_modifiers.py b/common/djangoapps/xmodule_modifiers.py
index dd40b5139d..6b73395599 100644
--- a/common/djangoapps/xmodule_modifiers.py
+++ b/common/djangoapps/xmodule_modifiers.py
@@ -76,7 +76,7 @@ def replace_course_urls(get_html, course_id):
return _get_html
-def replace_static_urls(get_html, data_dir, course_namespace=None):
+def replace_static_urls(get_html, data_dir, course_id=None):
"""
Updates the supplied module with a new get_html function that wraps
the old get_html function and substitutes urls of the form /static/...
@@ -85,7 +85,7 @@ def replace_static_urls(get_html, data_dir, course_namespace=None):
@wraps(get_html)
def _get_html():
- return static_replace.replace_static_urls(get_html(), data_dir, course_namespace)
+ return static_replace.replace_static_urls(get_html(), data_dir, course_id)
return _get_html
diff --git a/common/lib/xmodule/xmodule/contentstore/content.py b/common/lib/xmodule/xmodule/contentstore/content.py
index eaf3f48dea..cdecfd1af5 100644
--- a/common/lib/xmodule/xmodule/contentstore/content.py
+++ b/common/lib/xmodule/xmodule/contentstore/content.py
@@ -100,6 +100,12 @@ class StaticContent(object):
loc = StaticContent.compute_location(course_namespace.org, course_namespace.course, path)
return StaticContent.get_url_path_from_location(loc)
+ @staticmethod
+ def convert_legacy_static_url_with_course_id(path, course_id):
+ org, course_num, run = course_id.split("/")
+ loc = StaticContent.compute_location(org, course_num, path)
+ return StaticContent.get_url_path_from_location(loc)
+
def stream_data(self):
yield self._data
diff --git a/common/lib/xmodule/xmodule/modulestore/mixed.py b/common/lib/xmodule/xmodule/modulestore/mixed.py
index 253852d1db..85ba95af48 100644
--- a/common/lib/xmodule/xmodule/modulestore/mixed.py
+++ b/common/lib/xmodule/xmodule/modulestore/mixed.py
@@ -8,6 +8,9 @@ IMPORTANT: This modulestore is experimental AND INCOMPLETE. Therefore this shoul
from . import ModuleStoreBase
from django import create_modulestore_instance
+import logging
+
+log = logging.getLogger(__name__)
class MixedModuleStore(ModuleStoreBase):
@@ -23,6 +26,9 @@ class MixedModuleStore(ModuleStoreBase):
self.modulestores = {}
self.mappings = mappings
+ if 'default' not in stores:
+ raise Exception('Missing a default modulestore in the MixedModuleStore __init__ method.')
+
for key in stores:
self.modulestores[key] = create_modulestore_instance(stores[key]['ENGINE'],
stores[key]['OPTIONS'])
@@ -32,7 +38,8 @@ class MixedModuleStore(ModuleStoreBase):
For a given course_id, look in the mapping table and see if it has been pinned
to a particular modulestore
"""
- return self.mappings.get(course_id, self.mappings['default'])
+ mapping = self.mappings.get(course_id, 'default')
+ return self.modulestores[mapping]
def has_item(self, course_id, location):
return self._get_modulestore_for_courseid(course_id).has_item(course_id, location)
@@ -96,7 +103,8 @@ class MixedModuleStore(ModuleStoreBase):
'''
courses = []
for key in self.modulestores:
- courses.append(self.modulestores[key].get_courses)
+ courses = courses + (self.modulestores[key].get_courses())
+
return courses
def get_course(self, course_id):
@@ -125,3 +133,12 @@ class MixedModuleStore(ModuleStoreBase):
course_id. The return can be either "xml" (for XML based courses) or "mongo" for MongoDB backed courses
"""
return self._get_modulestore_for_courseid(course_id).get_modulestore_type(course_id)
+
+ def get_errored_courses(self):
+ """
+ Return a dictionary of course_dir -> [(msg, exception_str)], for each
+ course_dir where course loading failed.
+ """
+ errs = {}
+ for store in self.modulestores.values():
+ errs.update(store.get_errored_courses())
diff --git a/lms/djangoapps/branding/__init__.py b/lms/djangoapps/branding/__init__.py
index b2ac874020..0bdc69bd0d 100644
--- a/lms/djangoapps/branding/__init__.py
+++ b/lms/djangoapps/branding/__init__.py
@@ -1,4 +1,3 @@
-
from xmodule.modulestore.django import modulestore
from xmodule.course_module import CourseDescriptor
from django.conf import settings
@@ -15,7 +14,9 @@ def get_visible_courses(domain=None):
"""
Return the set of CourseDescriptors that should be visible in this branded instance
"""
- courses = [c for c in modulestore().get_courses()
+ _courses = modulestore().get_courses()
+
+ courses = [c for c in _courses
if isinstance(c, CourseDescriptor)]
courses = sorted(courses, key=lambda course: course.number)
diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py
index 34de2eb958..5f31658cf2 100644
--- a/lms/djangoapps/courseware/courses.py
+++ b/lms/djangoapps/courseware/courses.py
@@ -8,10 +8,9 @@ from django.http import Http404
from .module_render import get_module
from xmodule.course_module import CourseDescriptor
-from xmodule.modulestore import Location, MONGO_MODULESTORE_TYPE
+from xmodule.modulestore import Location, XML_MODULESTORE_TYPE
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.content import StaticContent
-from xmodule.modulestore.xml import XMLModuleStore
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
from courseware.model_data import ModelDataCache
from static_replace import replace_static_urls
@@ -82,12 +81,12 @@ def get_opt_course_with_access(user, course_id, action):
def course_image_url(course):
"""Try to look up the image url for the course. If it's not found,
log an error and return the dead link"""
- if modulestore().get_modulestore_type(course.course_id) == MONGO_MODULESTORE_TYPE:
+ if modulestore().get_modulestore_type(course.location.course_id) == XML_MODULESTORE_TYPE:
return '/static/' + course.data_dir + "/images/course_image.jpg"
else:
loc = course.location._replace(tag='c4x', category='asset', name='images_course_image.jpg')
- path = StaticContent.get_url_path_from_location(loc)
- return path
+ _path = StaticContent.get_url_path_from_location(loc)
+ return _path
def find_file(fs, dirs, filename):
@@ -243,7 +242,7 @@ def get_course_syllabus_section(course, section_key):
return replace_static_urls(
htmlFile.read().decode('utf-8'),
getattr(course, 'data_dir', None),
- course_namespace=course.location
+ course_id=course.location.course_id
)
except ResourceNotFoundError:
log.exception("Missing syllabus section {key} in course {url}".format(
diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py
index 0a48c56f87..a851a6d8f4 100644
--- a/lms/djangoapps/courseware/module_render.py
+++ b/lms/djangoapps/courseware/module_render.py
@@ -332,6 +332,7 @@ def get_module_for_descriptor_internal(user, descriptor, model_data_cache, cours
# TODO (cpennington): When modules are shared between courses, the static
# prefix is going to have to be specific to the module, not the directory
# that the xml was loaded from
+
system = ModuleSystem(
track_function=track_function,
render_template=render_to_string,
@@ -347,7 +348,7 @@ def get_module_for_descriptor_internal(user, descriptor, model_data_cache, cours
replace_urls=partial(
static_replace.replace_static_urls,
data_directory=getattr(descriptor, 'data_dir', None),
- course_namespace=descriptor.location._replace(category=None, name=None),
+ course_id=course_id,
),
replace_course_urls=partial(
static_replace.replace_course_urls,
@@ -368,6 +369,7 @@ def get_module_for_descriptor_internal(user, descriptor, model_data_cache, cours
cache=cache,
can_execute_unsafe_code=(lambda: can_execute_unsafe_code(course_id)),
)
+
# pass position specified in URL to module through ModuleSystem
system.set('position', position)
system.set('DEBUG', settings.DEBUG)
@@ -405,8 +407,12 @@ def get_module_for_descriptor_internal(user, descriptor, model_data_cache, cours
module.get_html = replace_static_urls(
_get_html,
getattr(descriptor, 'data_dir', None),
+<<<<<<< HEAD
course_namespace=module.location._replace(category=None, name=None)
)
+=======
+ course_id=course_id)
+>>>>>>> WIP
# Allow URLs of the form '/course/' refer to the root of multicourse directory
# hierarchy of this course
diff --git a/lms/djangoapps/staticbook/views.py b/lms/djangoapps/staticbook/views.py
index 9ed14bfb6c..25475eb36a 100644
--- a/lms/djangoapps/staticbook/views.py
+++ b/lms/djangoapps/staticbook/views.py
@@ -50,7 +50,7 @@ def remap_static_url(original_url, course):
output_url = replace_static_urls(
input_url,
getattr(course, 'data_dir', None),
- course_namespace=course.location,
+ coures_id=course.location.course_id,
)
# strip off the quotes again...
return output_url[1:-1]
diff --git a/lms/envs/cms/mixed_dev.py b/lms/envs/cms/mixed_dev.py
index 22fd5eeb0e..9a1a3a4cb8 100644
--- a/lms/envs/cms/mixed_dev.py
+++ b/lms/envs/cms/mixed_dev.py
@@ -9,8 +9,7 @@ MODULESTORE = {
'ENGINE': 'xmodule.modulestore.mixed.MixedModuleStore',
'OPTIONS': {
'mappings': {
- '6.002/a/a': 'xml',
- '6.002/b/b': 'xml'
+ 'MITx/2.01x/2013_Spring': 'xml'
},
'stores': {
'xml': {
From 5298f54f4c7edb45a7148e1e655badb991fe35b5 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Thu, 25 Jul 2013 10:56:57 -0400
Subject: [PATCH 088/125] fix tests
---
cms/djangoapps/contentstore/module_info_model.py | 9 +--------
.../static_replace/test/test_static_replace.py | 9 ++++-----
lms/djangoapps/courseware/tests/test_video_mongo.py | 9 ++++++++-
lms/djangoapps/staticbook/views.py | 2 +-
4 files changed, 14 insertions(+), 15 deletions(-)
diff --git a/cms/djangoapps/contentstore/module_info_model.py b/cms/djangoapps/contentstore/module_info_model.py
index bce4b0326c..049f95c221 100644
--- a/cms/djangoapps/contentstore/module_info_model.py
+++ b/cms/djangoapps/contentstore/module_info_model.py
@@ -1,6 +1,5 @@
from static_replace import replace_static_urls
from xmodule.modulestore.exceptions import ItemNotFoundError
-from xmodule.modulestore import Location
def get_module_info(store, location, rewrite_static_links=False):
@@ -16,13 +15,7 @@ def get_module_info(store, location, rewrite_static_links=False):
data = replace_static_urls(
module.data,
None,
- course_namespace=Location([
- module.location.tag,
- module.location.org,
- module.location.course,
- None,
- None
- ])
+ course_id=module.location.org + '/' + module.location.course + '/REPLACE_WITH_RUN_WHEN_IMPLEMENTED'
)
return {
diff --git a/common/djangoapps/static_replace/test/test_static_replace.py b/common/djangoapps/static_replace/test/test_static_replace.py
index f23610e1bd..b1bc05b895 100644
--- a/common/djangoapps/static_replace/test/test_static_replace.py
+++ b/common/djangoapps/static_replace/test/test_static_replace.py
@@ -10,7 +10,6 @@ from xmodule.modulestore.xml import XMLModuleStore
DATA_DIRECTORY = 'data_dir'
COURSE_ID = 'org/course/run'
-NAMESPACE = Location('org', 'course', 'run', None, None)
STATIC_SOURCE = '"/static/file.png"'
@@ -52,18 +51,18 @@ def test_storage_url_not_exists(mock_storage):
def test_mongo_filestore(mock_modulestore, mock_static_content):
mock_modulestore.return_value = Mock(MongoModuleStore)
- mock_static_content.convert_legacy_static_url.return_value = "c4x://mock_url"
+ mock_static_content.convert_legacy_static_url_with_course_id.return_value = "c4x://mock_url"
# No namespace => no change to path
assert_equals('"/static/data_dir/file.png"', replace_static_urls(STATIC_SOURCE, DATA_DIRECTORY))
# Namespace => content url
assert_equals(
- '"' + mock_static_content.convert_legacy_static_url.return_value + '"',
- replace_static_urls(STATIC_SOURCE, DATA_DIRECTORY, NAMESPACE)
+ '"' + mock_static_content.convert_legacy_static_url_with_course_id.return_value + '"',
+ replace_static_urls(STATIC_SOURCE, DATA_DIRECTORY, course_id=COURSE_ID)
)
- mock_static_content.convert_legacy_static_url.assert_called_once_with('file.png', NAMESPACE)
+ mock_static_content.convert_legacy_static_url_with_course_id.assert_called_once_with('file.png', COURSE_ID)
@patch('static_replace.settings')
diff --git a/lms/djangoapps/courseware/tests/test_video_mongo.py b/lms/djangoapps/courseware/tests/test_video_mongo.py
index 65da586812..af71e15be7 100644
--- a/lms/djangoapps/courseware/tests/test_video_mongo.py
+++ b/lms/djangoapps/courseware/tests/test_video_mongo.py
@@ -54,7 +54,7 @@ class TestVideo(BaseTestXmodule):
expected_context = {
'data_dir': getattr(self, 'data_dir', None),
- 'caption_asset_path': '/c4x/MITx/999/asset/subs_',
+ 'caption_asset_path': '/static/subs/',
'show_captions': 'true',
'display_name': 'A Name',
'end': 3610.0,
@@ -104,10 +104,17 @@ class TestVideoNonYouTube(TestVideo):
expected_context = {
'data_dir': getattr(self, 'data_dir', None),
+<<<<<<< HEAD
'caption_asset_path': '/c4x/MITx/999/asset/subs_',
'show_captions': 'true',
'display_name': 'A Name',
'end': 3610.0,
+=======
+ 'caption_asset_path': '/static/subs/',
+ 'show_captions': self.item_module.show_captions,
+ 'display_name': self.item_module.display_name_with_default,
+ 'end': self.item_module.end_time,
+>>>>>>> fix tests
'id': self.item_module.location.html_id(),
'sources': sources,
'start': 3603.0,
diff --git a/lms/djangoapps/staticbook/views.py b/lms/djangoapps/staticbook/views.py
index 25475eb36a..f73cfb6e5b 100644
--- a/lms/djangoapps/staticbook/views.py
+++ b/lms/djangoapps/staticbook/views.py
@@ -50,7 +50,7 @@ def remap_static_url(original_url, course):
output_url = replace_static_urls(
input_url,
getattr(course, 'data_dir', None),
- coures_id=course.location.course_id,
+ course_id=course.location.course_id,
)
# strip off the quotes again...
return output_url[1:-1]
From fa61bdf69f7647ea401e03625a755d3297b08435 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Thu, 25 Jul 2013 11:25:38 -0400
Subject: [PATCH 089/125] remove debugging logging
---
common/djangoapps/contentserver/middleware.py | 5 -----
1 file changed, 5 deletions(-)
diff --git a/common/djangoapps/contentserver/middleware.py b/common/djangoapps/contentserver/middleware.py
index 733e1c30e3..d89e3fdd23 100644
--- a/common/djangoapps/contentserver/middleware.py
+++ b/common/djangoapps/contentserver/middleware.py
@@ -6,14 +6,11 @@ from xmodule.modulestore import InvalidLocationError
from cache_toolbox.core import get_cached_content, set_cached_content
from xmodule.exceptions import NotFoundError
-import logging
-
class StaticContentServer(object):
def process_request(self, request):
# look to see if the request is prefixed with 'c4x' tag
if request.path.startswith('/' + XASSET_LOCATION_TAG + '/'):
- logging.debug('**** path = {0}'.format(request.path))
try:
loc = StaticContent.get_location_from_path(request.path)
except InvalidLocationError:
@@ -27,10 +24,8 @@ class StaticContentServer(object):
if content is None:
# nope, not in cache, let's fetch from DB
try:
- logging.debug('!!!! loc = {0}'.format(loc))
content = contentstore().find(loc, as_stream=True)
except NotFoundError:
- logging.debug('**** NOT FOUND')
response = HttpResponse()
response.status_code = 404
return response
From 54bd3170bc5eb7cc2c81d45bc18a6ae18efd0e43 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Thu, 25 Jul 2013 11:27:44 -0400
Subject: [PATCH 090/125] remove another debug logging message
---
common/djangoapps/student/views.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index 4d59b5cc66..7b24f80917 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -96,6 +96,8 @@ def index(request, extra_context={}, user=None):
context = {'courses': courses}
+ context = {'courses': courses, 'news': top_news}
+
context.update(extra_context)
return render_to_response('index.html', context)
From 9f14f1ee62e30912239f59cbc746602a8d5459ca Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Thu, 25 Jul 2013 11:28:40 -0400
Subject: [PATCH 091/125] update file comment on MixedModuleStore
---
common/lib/xmodule/xmodule/modulestore/mixed.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/common/lib/xmodule/xmodule/modulestore/mixed.py b/common/lib/xmodule/xmodule/modulestore/mixed.py
index 85ba95af48..34effafc14 100644
--- a/common/lib/xmodule/xmodule/modulestore/mixed.py
+++ b/common/lib/xmodule/xmodule/modulestore/mixed.py
@@ -3,7 +3,7 @@ MixedModuleStore allows for aggregation between multiple modulestores.
In this way, courses can be served up both - say - XMLModuleStore or MongoModuleStore
-IMPORTANT: This modulestore is experimental AND INCOMPLETE. Therefore this should only be used cautiously
+IMPORTANT: This modulestore only supports READONLY applications, e.g. LMS
"""
from . import ModuleStoreBase
From aa8b0545907d8844d813538d9deb88936a9b45d5 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Thu, 25 Jul 2013 13:38:18 -0400
Subject: [PATCH 092/125] fix missed conflict resolution
---
lms/djangoapps/courseware/module_render.py | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py
index a851a6d8f4..4f05d660be 100644
--- a/lms/djangoapps/courseware/module_render.py
+++ b/lms/djangoapps/courseware/module_render.py
@@ -407,12 +407,8 @@ def get_module_for_descriptor_internal(user, descriptor, model_data_cache, cours
module.get_html = replace_static_urls(
_get_html,
getattr(descriptor, 'data_dir', None),
-<<<<<<< HEAD
- course_namespace=module.location._replace(category=None, name=None)
+ course_id=course_id
)
-=======
- course_id=course_id)
->>>>>>> WIP
# Allow URLs of the form '/course/' refer to the root of multicourse directory
# hierarchy of this course
From e4eea6cc4f9a689881890da869ea2c74bf2defc9 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Mon, 29 Jul 2013 21:15:39 -0400
Subject: [PATCH 093/125] some additional comments to clarify the partially
bogus course_id.
---
cms/djangoapps/contentstore/module_info_model.py | 4 +++-
cms/djangoapps/contentstore/views/preview.py | 4 +++-
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/cms/djangoapps/contentstore/module_info_model.py b/cms/djangoapps/contentstore/module_info_model.py
index 049f95c221..c0e1ff7207 100644
--- a/cms/djangoapps/contentstore/module_info_model.py
+++ b/cms/djangoapps/contentstore/module_info_model.py
@@ -12,10 +12,12 @@ def get_module_info(store, location, rewrite_static_links=False):
data = module.data
if rewrite_static_links:
+ # we pass a partially bogus course_id as we don't have the RUN information passed yet
+ # through the CMS. Also the contentstore is also not RUN-aware at this point in time.
data = replace_static_urls(
module.data,
None,
- course_id=module.location.org + '/' + module.location.course + '/REPLACE_WITH_RUN_WHEN_IMPLEMENTED'
+ course_id=module.location.org + '/' + module.location.course + '/BOGUS_RUN_REPLACE_WHEN_AVAILABLE'
)
return {
diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py
index bbbf3d5f0a..75ee59dd2d 100644
--- a/cms/djangoapps/contentstore/views/preview.py
+++ b/cms/djangoapps/contentstore/views/preview.py
@@ -155,10 +155,12 @@ def load_preview_module(request, preview_id, descriptor):
"xmodule_display.html",
)
+ # we pass a partially bogus course_id as we don't have the RUN information passed yet
+ # through the CMS. Also the contentstore is also not RUN-aware at this point in time.
module.get_html = replace_static_urls(
module.get_html,
getattr(module, 'data_dir', module.location.course),
- course_id=module.location.org+'/'+module.location.course+'/REPLACE_WITH_RUN'
+ course_id=module.location.org+'/'+module.location.course+'/BOGUS_RUN_REPLACE_WHEN_AVAILABLE'
)
module.get_html = save_module(
From 52928d165a6f69decb5f682abd08e212fa2392b1 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Mon, 29 Jul 2013 21:30:07 -0400
Subject: [PATCH 094/125] update SplitModuleStore tests to pull the course_id
from the locator
---
.../modulestore/tests/test_split_modulestore.py | 13 +++++++------
1 file changed, 7 insertions(+), 6 deletions(-)
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
index ca5eb72a26..1f297add36 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
@@ -265,11 +265,11 @@ class SplitModuleItemTests(SplitModuleTest):
locator = BlockUsageLocator(course_id='GreekHero', usage_id='head12345', branch='draft')
self.assertTrue(
- modulestore().has_item(course_id, locator),
+ modulestore().has_item(locator.course_id, locator),
"couldn't find in 12345"
)
self.assertTrue(
- modulestore().has_item(course_id, BlockUsageLocator(
+ modulestore().has_item(locator.course_id, BlockUsageLocator(
course_id=locator.course_id,
branch='draft',
usage_id=locator.usage_id
@@ -277,7 +277,7 @@ class SplitModuleItemTests(SplitModuleTest):
"couldn't find in draft 12345"
)
self.assertFalse(
- modulestore().has_item(course_id, BlockUsageLocator(
+ modulestore().has_item(locator.course_id, BlockUsageLocator(
course_id=locator.course_id,
branch='published',
usage_id=locator.usage_id)),
@@ -285,26 +285,27 @@ class SplitModuleItemTests(SplitModuleTest):
)
locator.branch = 'draft'
self.assertTrue(
- modulestore().has_item(course_id, locator),
+ modulestore().has_item(locator.course_id, locator),
"not found in draft 12345"
)
# not a course obj
locator = BlockUsageLocator(course_id='GreekHero', usage_id='chapter1', branch='draft')
self.assertTrue(
- modulestore().has_item(course_id, locator),
+ modulestore().has_item(locator.course_id, locator),
"couldn't find chapter1"
)
# in published course
locator = BlockUsageLocator(course_id="wonderful", usage_id="head23456", revision='draft')
- self.assertTrue(modulestore().has_item(course_id, BlockUsageLocator(course_id=locator.course_id,
+ self.assertTrue(modulestore().has_item(locator.course_id, BlockUsageLocator(course_id=locator.course_id,
usage_id=locator.usage_id,
revision='published')),
"couldn't find in 23456")
locator.branch = 'published'
self.assertTrue(modulestore().has_item(course_id, locator), "couldn't find in 23456")
+
def test_negative_has_item(self):
# negative tests--not found
# no such course or block
From 7bdc4c51340e83dbeaad23e2fcb9d80934e91247 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Mon, 5 Aug 2013 13:40:26 -0400
Subject: [PATCH 095/125] fix errant conflict resolution
---
common/djangoapps/student/views.py | 2 --
1 file changed, 2 deletions(-)
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index 7b24f80917..4d59b5cc66 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -96,8 +96,6 @@ def index(request, extra_context={}, user=None):
context = {'courses': courses}
- context = {'courses': courses, 'news': top_news}
-
context.update(extra_context)
return render_to_response('index.html', context)
From 7a80a9c02aefc47b159a541e69d5c51683d77afa Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Thu, 8 Aug 2013 15:40:28 -0400
Subject: [PATCH 096/125] fix broken test after rebase
---
.../xmodule/xmodule/modulestore/tests/test_split_modulestore.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
index 1f297add36..530a173d23 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
@@ -297,7 +297,7 @@ class SplitModuleItemTests(SplitModuleTest):
)
# in published course
- locator = BlockUsageLocator(course_id="wonderful", usage_id="head23456", revision='draft')
+ locator = BlockUsageLocator(course_id="wonderful", usage_id="head23456", branch='draft')
self.assertTrue(modulestore().has_item(locator.course_id, BlockUsageLocator(course_id=locator.course_id,
usage_id=locator.usage_id,
revision='published')),
From 9cc796df833f7abfbb68f63f58c729cf07848b58 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Thu, 8 Aug 2013 15:52:11 -0400
Subject: [PATCH 097/125] fix one more 'revision' -> 'branch' argument name
change
---
.../xmodule/xmodule/modulestore/tests/test_split_modulestore.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
index 530a173d23..c217646725 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
@@ -300,7 +300,7 @@ class SplitModuleItemTests(SplitModuleTest):
locator = BlockUsageLocator(course_id="wonderful", usage_id="head23456", branch='draft')
self.assertTrue(modulestore().has_item(locator.course_id, BlockUsageLocator(course_id=locator.course_id,
usage_id=locator.usage_id,
- revision='published')),
+ branch='published')),
"couldn't find in 23456")
locator.branch = 'published'
self.assertTrue(modulestore().has_item(course_id, locator), "couldn't find in 23456")
From 5ee5beafbef101d6c6a705dd9051a6e722547dc7 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Tue, 13 Aug 2013 12:42:24 -0400
Subject: [PATCH 098/125] fix bad merge conflict resolution
---
lms/djangoapps/courseware/tests/test_video_mongo.py | 9 +--------
1 file changed, 1 insertion(+), 8 deletions(-)
diff --git a/lms/djangoapps/courseware/tests/test_video_mongo.py b/lms/djangoapps/courseware/tests/test_video_mongo.py
index af71e15be7..3436938cc0 100644
--- a/lms/djangoapps/courseware/tests/test_video_mongo.py
+++ b/lms/djangoapps/courseware/tests/test_video_mongo.py
@@ -104,17 +104,10 @@ class TestVideoNonYouTube(TestVideo):
expected_context = {
'data_dir': getattr(self, 'data_dir', None),
-<<<<<<< HEAD
- 'caption_asset_path': '/c4x/MITx/999/asset/subs_',
+ 'caption_asset_path': '/static/subs/',
'show_captions': 'true',
'display_name': 'A Name',
'end': 3610.0,
-=======
- 'caption_asset_path': '/static/subs/',
- 'show_captions': self.item_module.show_captions,
- 'display_name': self.item_module.display_name_with_default,
- 'end': self.item_module.end_time,
->>>>>>> fix tests
'id': self.item_module.location.html_id(),
'sources': sources,
'start': 3603.0,
From 0b8866ef29fe82449138675586c87b49c611fe03 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Tue, 13 Aug 2013 17:01:09 -0400
Subject: [PATCH 099/125] forgot to return dictionary on get_errored_courses
---
common/lib/xmodule/xmodule/modulestore/mixed.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/common/lib/xmodule/xmodule/modulestore/mixed.py b/common/lib/xmodule/xmodule/modulestore/mixed.py
index 34effafc14..ec2b563cac 100644
--- a/common/lib/xmodule/xmodule/modulestore/mixed.py
+++ b/common/lib/xmodule/xmodule/modulestore/mixed.py
@@ -129,7 +129,7 @@ class MixedModuleStore(ModuleStoreBase):
def get_modulestore_type(self, course_id):
"""
- Returns a type which identifies which modulestore is servicing the given
+ Returns a type which identifies which modulestore is servicing the given
course_id. The return can be either "xml" (for XML based courses) or "mongo" for MongoDB backed courses
"""
return self._get_modulestore_for_courseid(course_id).get_modulestore_type(course_id)
@@ -142,3 +142,4 @@ class MixedModuleStore(ModuleStoreBase):
errs = {}
for store in self.modulestores.values():
errs.update(store.get_errored_courses())
+ return errs
From 61219169d43f9c2af634b81f0ab26af68b9e6479 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Tue, 13 Aug 2013 19:56:00 -0400
Subject: [PATCH 100/125] change over the modulestore configuration to be a
dict and use a property accessor. Also update some django-admin configs to
use this means to set the runtime configuration
---
.../management/commands/check_course.py | 8 +++++++-
.../management/commands/clone_course.py | 11 +++++------
.../management/commands/delete_course.py | 7 +++++--
common/lib/xmodule/xmodule/modulestore/__init__.py | 14 ++++++++++----
.../lib/xmodule/xmodule/modulestore/mongo/base.py | 5 +----
lms/djangoapps/courseware/tests/__init__.py | 3 +--
.../instructor/tests/test_legacy_gradebook.py | 3 ++-
7 files changed, 31 insertions(+), 20 deletions(-)
diff --git a/cms/djangoapps/contentstore/management/commands/check_course.py b/cms/djangoapps/contentstore/management/commands/check_course.py
index 215bb8add8..2f0b0b2a2c 100644
--- a/cms/djangoapps/contentstore/management/commands/check_course.py
+++ b/cms/djangoapps/contentstore/management/commands/check_course.py
@@ -5,6 +5,9 @@ from xmodule.course_module import CourseDescriptor
from request_cache.middleware import RequestCache
+from django.core.cache import get_cache
+
+CACHE = get_cache('mongo_metadata_inheritance')
class Command(BaseCommand):
help = '''Enumerates through the course and find common errors'''
@@ -19,7 +22,10 @@ class Command(BaseCommand):
store = modulestore()
# setup a request cache so we don't throttle the DB with all the metadata inheritance requests
- store.request_cache = RequestCache.get_request_cache()
+ store.set_modulestore_configuration({
+ 'metadata_inheritance_cache_subsystem': CACHE,
+ 'request_cache': RequestCache.get_request_cache()
+ })
course = store.get_item(loc, depth=3)
diff --git a/cms/djangoapps/contentstore/management/commands/clone_course.py b/cms/djangoapps/contentstore/management/commands/clone_course.py
index 5fffe29543..aa0e076f08 100644
--- a/cms/djangoapps/contentstore/management/commands/clone_course.py
+++ b/cms/djangoapps/contentstore/management/commands/clone_course.py
@@ -15,10 +15,6 @@ from auth.authz import _copy_course_group
from request_cache.middleware import RequestCache
from django.core.cache import get_cache
-#
-# To run from command line: rake cms:delete_course LOC=MITx/111/Foo1
-#
-
CACHE = get_cache('mongo_metadata_inheritance')
class Command(BaseCommand):
@@ -36,8 +32,11 @@ class Command(BaseCommand):
mstore = modulestore('direct')
cstore = contentstore()
- mstore.metadata_inheritance_cache_subsystem = CACHE
- mstore.request_cache = RequestCache.get_request_cache()
+ mstore.set_modulestore_configuration({
+ 'metadata_inheritance_cache_subsystem': CACHE,
+ 'request_cache': RequestCache.get_request_cache()
+ })
+
org, course_num, run = dest_course_id.split("/")
mstore.ignore_write_events_on_courses.append('{0}/{1}'.format(org, course_num))
diff --git a/cms/djangoapps/contentstore/management/commands/delete_course.py b/cms/djangoapps/contentstore/management/commands/delete_course.py
index 3e0ecfb8d9..b0901ccfc9 100644
--- a/cms/djangoapps/contentstore/management/commands/delete_course.py
+++ b/cms/djangoapps/contentstore/management/commands/delete_course.py
@@ -36,8 +36,11 @@ class Command(BaseCommand):
ms = modulestore('direct')
cs = contentstore()
- ms.metadata_inheritance_cache_subsystem = CACHE
- ms.request_cache = RequestCache.get_request_cache()
+ ms.set_modulestore_configuration({
+ 'metadata_inheritance_cache_subsystem': CACHE,
+ 'request_cache': RequestCache.get_request_cache()
+ })
+
org, course_num, run = course_id.split("/")
ms.ignore_write_events_on_courses.append('{0}/{1}'.format(org, course_num))
diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py
index ee61998931..a7e29ed105 100644
--- a/common/lib/xmodule/xmodule/modulestore/__init__.py
+++ b/common/lib/xmodule/xmodule/modulestore/__init__.py
@@ -410,8 +410,7 @@ class ModuleStoreBase(ModuleStore):
Set up the error-tracking logic.
'''
self._location_errors = {} # location -> ErrorLog
- self.metadata_inheritance_cache = None
- self.request_cache = None
+ self.modulestore_configuration = {}
self.modulestore_update_signal = None # can be set by runtime to route notifications of datastore changes
def _get_errorlog(self, location):
@@ -456,13 +455,20 @@ class ModuleStoreBase(ModuleStore):
return c
return None
+ @property
+ def metadata_inheritance_cache_subsystem(self):
+ return self.modulestore_configuration.get('metadata_inheritance_cache_subsystem', None)
+
+ @property
+ def request_cache(self):
+ return self.modulestore_configuration.get('request_cache', None)
+
def set_modulestore_configuration(self, config_dict):
"""
This is the base implementation of the interface, all we need to do is store
two possible configurations as attributes on the class
"""
- self.metadata_inheritance_cache = config_dict.get('metadata_inheritance_cache_subsystem', None)
- self.request_cache = config_dict.get('request_cache', None)
+ self.modulestore_configuration = config_dict
def namedtuple_to_son(namedtuple, prefix=''):
diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/base.py b/common/lib/xmodule/xmodule/modulestore/mongo/base.py
index de946f76e1..ad2732409d 100644
--- a/common/lib/xmodule/xmodule/modulestore/mongo/base.py
+++ b/common/lib/xmodule/xmodule/modulestore/mongo/base.py
@@ -270,8 +270,7 @@ class MongoModuleStore(ModuleStoreBase):
def __init__(self, host, db, collection, fs_root, render_template,
port=27017, default_class=None,
error_tracker=null_error_tracker,
- user=None, password=None, request_cache=None,
- metadata_inheritance_cache_subsystem=None, **kwargs):
+ user=None, password=None, **kwargs):
super(MongoModuleStore, self).__init__()
@@ -303,8 +302,6 @@ class MongoModuleStore(ModuleStoreBase):
self.error_tracker = error_tracker
self.render_template = render_template
self.ignore_write_events_on_courses = []
- self.request_cache = request_cache
- self.metadata_inheritance_cache_subsystem = metadata_inheritance_cache_subsystem
def compute_metadata_inheritance_tree(self, location):
'''
diff --git a/lms/djangoapps/courseware/tests/__init__.py b/lms/djangoapps/courseware/tests/__init__.py
index 40f1df0fbc..9d1b549b9f 100644
--- a/lms/djangoapps/courseware/tests/__init__.py
+++ b/lms/djangoapps/courseware/tests/__init__.py
@@ -50,8 +50,7 @@ class BaseTestXmodule(ModuleStoreTestCase):
self.course = CourseFactory.create(data=self.COURSE_DATA)
# Turn off cache.
- modulestore().request_cache = None
- modulestore().metadata_inheritance_cache_subsystem = None
+ modulestore().set_modulestore_configuration({})
chapter = ItemFactory.create(
parent_location=self.course.location,
diff --git a/lms/djangoapps/instructor/tests/test_legacy_gradebook.py b/lms/djangoapps/instructor/tests/test_legacy_gradebook.py
index 8be648a930..aaf03deb8c 100644
--- a/lms/djangoapps/instructor/tests/test_legacy_gradebook.py
+++ b/lms/djangoapps/instructor/tests/test_legacy_gradebook.py
@@ -25,7 +25,8 @@ class TestGradebook(ModuleStoreTestCase):
instructor = AdminFactory.create()
self.client.login(username=instructor.username, password='test')
- modulestore().request_cache = modulestore().metadata_inheritance_cache_subsystem = None
+ # remove the caches
+ modulestore().set_modulestore_configuration({})
kwargs = {}
if self.grading_policy is not None:
From c6cde6a72204a9e688ea0d6dfe9550f2cb39a0fc Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Tue, 13 Aug 2013 20:12:37 -0400
Subject: [PATCH 101/125] resolve incorrect merge conflict resolution
---
common/lib/xmodule/xmodule/modulestore/tests/test_xml.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_xml.py b/common/lib/xmodule/xmodule/modulestore/tests/test_xml.py
index 676d928bb7..ffbce40874 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/test_xml.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/test_xml.py
@@ -7,7 +7,7 @@ from xmodule.modulestore.xml import XMLModuleStore
from xmodule.modulestore import XML_MODULESTORE_TYPE
from .test_modulestore import check_path_to_location
-from . import DATA_DIR
+from xmodule.tests import DATA_DIR
class TestXMLModuleStore(object):
From ed584a9abb11644ccf67857045343cec338a6701 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Thu, 15 Aug 2013 12:41:30 -0400
Subject: [PATCH 102/125] fix pep8 violations
---
cms/djangoapps/contentstore/views/preview.py | 2 +-
common/lib/xmodule/xmodule/modulestore/__init__.py | 2 +-
.../modulestore/tests/test_split_modulestore.py | 11 ++++++-----
common/lib/xmodule/xmodule/modulestore/xml.py | 2 +-
4 files changed, 9 insertions(+), 8 deletions(-)
diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py
index 75ee59dd2d..7a3a224d86 100644
--- a/cms/djangoapps/contentstore/views/preview.py
+++ b/cms/djangoapps/contentstore/views/preview.py
@@ -160,7 +160,7 @@ def load_preview_module(request, preview_id, descriptor):
module.get_html = replace_static_urls(
module.get_html,
getattr(module, 'data_dir', module.location.course),
- course_id=module.location.org+'/'+module.location.course+'/BOGUS_RUN_REPLACE_WHEN_AVAILABLE'
+ course_id=module.location.org + '/' + module.location.course + '/BOGUS_RUN_REPLACE_WHEN_AVAILABLE'
)
module.get_html = save_module(
diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py
index a7e29ed105..eb9c44a441 100644
--- a/common/lib/xmodule/xmodule/modulestore/__init__.py
+++ b/common/lib/xmodule/xmodule/modulestore/__init__.py
@@ -395,7 +395,7 @@ class ModuleStore(object):
def get_modulestore_type(self, course_id):
"""
- Returns a type which identifies which modulestore is servicing the given
+ Returns a type which identifies which modulestore is servicing the given
course_id. The return can be either "xml" (for XML based courses) or "mongo" for MongoDB backed courses
"""
raise NotImplementedError
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
index c217646725..d548dcc838 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
@@ -298,14 +298,15 @@ class SplitModuleItemTests(SplitModuleTest):
# in published course
locator = BlockUsageLocator(course_id="wonderful", usage_id="head23456", branch='draft')
- self.assertTrue(modulestore().has_item(locator.course_id, BlockUsageLocator(course_id=locator.course_id,
- usage_id=locator.usage_id,
- branch='published')),
- "couldn't find in 23456")
+ self.assertTrue(
+ modulestore().has_item(
+ locator.course_id,
+ BlockUsageLocator(course_id=locator.course_id, usage_id=locator.usage_id, branch='published')
+ ), "couldn't find in 23456"
+ )
locator.branch = 'published'
self.assertTrue(modulestore().has_item(course_id, locator), "couldn't find in 23456")
-
def test_negative_has_item(self):
# negative tests--not found
# no such course or block
diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py
index fd04982f81..89c3299394 100644
--- a/common/lib/xmodule/xmodule/modulestore/xml.py
+++ b/common/lib/xmodule/xmodule/modulestore/xml.py
@@ -604,7 +604,7 @@ class XMLModuleStore(ModuleStoreBase):
def get_modulestore_type(self, course_id):
"""
- Returns a type which identifies which modulestore is servicing the given
+ Returns a type which identifies which modulestore is servicing the given
course_id. The return can be either "xml" (for XML based courses) or "mongo" for MongoDB backed courses
"""
return XML_MODULESTORE_TYPE
From cf715cb7278405bd8294c541b41951919d1d1d46 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Thu, 15 Aug 2013 12:48:01 -0400
Subject: [PATCH 103/125] fix pylint violations
---
common/lib/xmodule/xmodule/contentstore/content.py | 6 +++++-
common/lib/xmodule/xmodule/modulestore/__init__.py | 6 ++++++
lms/envs/cms/mixed_dev.py | 6 +++++-
3 files changed, 16 insertions(+), 2 deletions(-)
diff --git a/common/lib/xmodule/xmodule/contentstore/content.py b/common/lib/xmodule/xmodule/contentstore/content.py
index cdecfd1af5..9d767482d5 100644
--- a/common/lib/xmodule/xmodule/contentstore/content.py
+++ b/common/lib/xmodule/xmodule/contentstore/content.py
@@ -102,7 +102,11 @@ class StaticContent(object):
@staticmethod
def convert_legacy_static_url_with_course_id(path, course_id):
- org, course_num, run = course_id.split("/")
+ """
+ Returns a path to a piece of static content when we are provided with a filepath and
+ a course_id
+ """
+ org, course_num, __ = course_id.split("/")
loc = StaticContent.compute_location(org, course_num, path)
return StaticContent.get_url_path_from_location(loc)
diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py
index eb9c44a441..707390d759 100644
--- a/common/lib/xmodule/xmodule/modulestore/__init__.py
+++ b/common/lib/xmodule/xmodule/modulestore/__init__.py
@@ -457,10 +457,16 @@ class ModuleStoreBase(ModuleStore):
@property
def metadata_inheritance_cache_subsystem(self):
+ """
+ Exposes an accessor to the runtime configuration for the metadata inheritance cache
+ """
return self.modulestore_configuration.get('metadata_inheritance_cache_subsystem', None)
@property
def request_cache(self):
+ """
+ Exposes an accessor to the runtime configuration for the request cache
+ """
return self.modulestore_configuration.get('request_cache', None)
def set_modulestore_configuration(self, config_dict):
diff --git a/lms/envs/cms/mixed_dev.py b/lms/envs/cms/mixed_dev.py
index 9a1a3a4cb8..88e1f3d1f8 100644
--- a/lms/envs/cms/mixed_dev.py
+++ b/lms/envs/cms/mixed_dev.py
@@ -2,7 +2,11 @@
This configuration is to run the MixedModuleStore on a localdev environment
"""
-from .dev import *
+# We intentionally define lots of variables that aren't used, and
+# want to import all variables from base settings files
+# pylint: disable=W0401, W0614
+
+from .dev import *, DATA_DIR
MODULESTORE = {
'default': {
From bca2018ab5eb8defdd8ecca769d3fa8a3d852067 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Fri, 16 Aug 2013 00:53:15 -0400
Subject: [PATCH 104/125] add unit tests for all methods in MixedModuleStore
---
.../lib/xmodule/xmodule/modulestore/django.py | 2 +-
.../tests/test_mixed_modulestore.py | 242 ++++++++++++++++++
2 files changed, 243 insertions(+), 1 deletion(-)
create mode 100644 common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py
diff --git a/common/lib/xmodule/xmodule/modulestore/django.py b/common/lib/xmodule/xmodule/modulestore/django.py
index 7b1ce37d07..2f0cd126f9 100644
--- a/common/lib/xmodule/xmodule/modulestore/django.py
+++ b/common/lib/xmodule/xmodule/modulestore/django.py
@@ -35,7 +35,7 @@ def create_modulestore_instance(engine, options):
_options.update(options)
for key in FUNCTION_KEYS:
- if key in _options:
+ if key in _options and isinstance(_options[key], basestring):
_options[key] = load_function(_options[key])
return class_(
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py
new file mode 100644
index 0000000000..c065b14de9
--- /dev/null
+++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py
@@ -0,0 +1,242 @@
+from nose.tools import assert_equals, assert_raises, assert_false, assert_true, assert_not_equals
+import pymongo
+from uuid import uuid4
+
+from xmodule.tests import DATA_DIR
+from xmodule.modulestore import Location, MONGO_MODULESTORE_TYPE, XML_MODULESTORE_TYPE
+from xmodule.modulestore.exceptions import ItemNotFoundError
+from xmodule.modulestore.mixed import MixedModuleStore
+from xmodule.modulestore.xml_importer import import_from_xml
+
+
+HOST = 'localhost'
+PORT = 27017
+DB = 'test_mongo_%s' % uuid4().hex
+COLLECTION = 'modulestore'
+FS_ROOT = DATA_DIR # TODO (vshnayder): will need a real fs_root for testing load_item
+DEFAULT_CLASS = 'xmodule.raw_module.RawDescriptor'
+RENDER_TEMPLATE = lambda t_n, d, ctx = None, nsp = 'main': ''
+
+IMPORT_COURSEID = 'MITx/999/2013_Spring'
+XML_COURSEID1 = 'edX/toy/2012_Fall'
+XML_COURSEID2 = 'edX/simple/2012_Fall'
+
+OPTIONS = {
+ 'mappings': {
+ XML_COURSEID1: 'xml',
+ XML_COURSEID2: 'xml',
+ IMPORT_COURSEID: 'default'
+ },
+ 'stores': {
+ 'xml': {
+ 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore',
+ 'OPTIONS': {
+ 'data_dir': DATA_DIR,
+ 'default_class': 'xmodule.hidden_module.HiddenDescriptor',
+ }
+ },
+ 'default': {
+ 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
+ 'OPTIONS': {
+ 'default_class': DEFAULT_CLASS,
+ 'host': HOST,
+ 'db': DB,
+ 'collection': COLLECTION,
+ 'fs_root': DATA_DIR,
+ 'render_template': RENDER_TEMPLATE,
+ }
+ }
+ }
+}
+
+
+class TestMixedModuleStore(object):
+ '''Tests!'''
+ @classmethod
+ def setupClass(cls):
+ cls.connection = pymongo.connection.Connection(HOST, PORT)
+ cls.connection.drop_database(DB)
+ cls.fake_location = Location(['i4x', 'foo', 'bar', 'vertical', 'baz'])
+ cls.import_org, cls.import_course, cls.import_run = IMPORT_COURSEID.split('/')
+ # NOTE: Creating a single db for all the tests to save time. This
+ # is ok only as long as none of the tests modify the db.
+ # If (when!) that changes, need to either reload the db, or load
+ # once and copy over to a tmp db for each test.
+ cls.store = cls.initdb()
+
+ @classmethod
+ def teardownClass(cls):
+ cls.connection = pymongo.connection.Connection(HOST, PORT)
+ cls.connection.drop_database(DB)
+
+ @staticmethod
+ def initdb():
+ # connect to the db
+ _options = {}
+ _options.update(OPTIONS)
+ store = MixedModuleStore(**_options)
+
+ import_from_xml(
+ store._get_modulestore_for_courseid(IMPORT_COURSEID),
+ DATA_DIR,
+ ['toy'],
+ target_location_namespace=Location(
+ 'i4x',
+ TestMixedModuleStore.import_org,
+ TestMixedModuleStore.import_course,
+ 'course',
+ TestMixedModuleStore.import_run
+ )
+ )
+
+ return store
+
+ @staticmethod
+ def destroy_db(connection):
+ # Destroy the test db.
+ connection.drop_database(DB)
+
+ def setUp(self):
+ # make a copy for convenience
+ self.connection = TestMixedModuleStore.connection
+
+ def tearDown(self):
+ pass
+
+ def test_get_modulestore_type(self):
+ """
+ Make sure we get back the store type we expect for given mappings
+ """
+ assert_equals(self.store.get_modulestore_type(XML_COURSEID1), XML_MODULESTORE_TYPE)
+ assert_equals(self.store.get_modulestore_type(XML_COURSEID2), XML_MODULESTORE_TYPE)
+ assert_equals(self.store.get_modulestore_type(IMPORT_COURSEID), MONGO_MODULESTORE_TYPE)
+ # try an unknown mapping, it should be the 'default' store
+ assert_equals(self.store.get_modulestore_type('foo/bar/2012_Fall'), MONGO_MODULESTORE_TYPE)
+
+ def test_has_item(self):
+ assert_true(self.store.has_item(
+ IMPORT_COURSEID, Location(['i4x', self.import_org, self.import_course, 'course', self.import_run])
+ ))
+ assert_true(self.store.has_item(
+ XML_COURSEID1, Location(['i4x', 'edX', 'toy', 'course', '2012_Fall'])
+ ))
+
+ # try negative cases
+ assert_false(self.store.has_item(
+ XML_COURSEID1, Location(['i4x', self.import_org, self.import_course, 'course', self.import_run])
+ ))
+ assert_false(self.store.has_item(
+ IMPORT_COURSEID, Location(['i4x', 'edX', 'toy', 'course', '2012_Fall'])
+ ))
+
+ def test_get_item(self):
+ with assert_raises(NotImplementedError):
+ self.store.get_item(self.fake_location)
+
+ def test_get_instance(self):
+ module = self.store.get_instance(
+ IMPORT_COURSEID, Location(['i4x', self.import_org, self.import_course, 'course', self.import_run])
+ )
+ assert_not_equals(module, None)
+
+ module = self.store.get_instance(
+ XML_COURSEID1, Location(['i4x', 'edX', 'toy', 'course', '2012_Fall'])
+ )
+ assert_not_equals(module, None)
+
+ # try negative cases
+ with assert_raises(ItemNotFoundError):
+ self.store.get_instance(
+ XML_COURSEID1, Location(['i4x', self.import_org, self.import_course, 'course', self.import_run])
+ )
+
+ with assert_raises(ItemNotFoundError):
+ self.store.get_instance(
+ IMPORT_COURSEID, Location(['i4x', 'edX', 'toy', 'course', '2012_Fall'])
+ )
+
+ def test_get_items(self):
+ modules = self.store.get_items(['i4x', None, None, 'course', None], IMPORT_COURSEID)
+ assert_equals(len(modules), 1)
+ assert_equals(modules[0].location.course, self.import_course)
+
+ modules = self.store.get_items(['i4x', None, None, 'course', None], XML_COURSEID1)
+ assert_equals(len(modules), 1)
+ assert_equals(modules[0].location.course, 'toy')
+
+ modules = self.store.get_items(['i4x', None, None, 'course', None], XML_COURSEID2)
+ assert_equals(len(modules), 1)
+ assert_equals(modules[0].location.course, 'simple')
+
+ def test_update_item(self):
+ with assert_raises(NotImplementedError):
+ self.store.update_item(self.fake_location, None)
+
+ def test_update_children(self):
+ with assert_raises(NotImplementedError):
+ self.store.update_children(self.fake_location, None)
+
+ def test_update_metadata(self):
+ with assert_raises(NotImplementedError):
+ self.store.update_metadata(self.fake_location, None)
+
+ def test_delete_item(self):
+ with assert_raises(NotImplementedError):
+ self.store.delete_item(self.fake_location)
+
+ def test_get_courses(self):
+ # we should have 3 total courses aggregated
+ courses = self.store.get_courses()
+ course_ids = []
+ for course in courses:
+ course_ids.append(course.location.course_id)
+ assert_true(IMPORT_COURSEID in course_ids)
+ assert_true(XML_COURSEID1 in course_ids)
+ assert_true(XML_COURSEID2 in course_ids)
+
+ def test_get_course(self):
+ module = self.store.get_course(IMPORT_COURSEID)
+ assert_equals(module.location.course, self.import_course)
+
+ module = self.store.get_course(XML_COURSEID1)
+ assert_equals(module.location.course, 'toy')
+
+ module = self.store.get_course(XML_COURSEID2)
+ assert_equals(module.location.course, 'simple')
+
+ def test_get_parent_locations(self):
+ parents = self.store.get_parent_locations(
+ Location(['i4x', self.import_org, self.import_course, 'chapter', 'Overview']),
+ IMPORT_COURSEID
+ )
+ assert_equals(len(parents), 1)
+ assert_equals(Location(parents[0]).org, self.import_org)
+ assert_equals(Location(parents[0]).course, self.import_course)
+ assert_equals(Location(parents[0]).name, self.import_run)
+
+ parents = self.store.get_parent_locations(
+ Location(['i4x', 'edX', 'toy', 'chapter', 'Overview']),
+ XML_COURSEID1
+ )
+ assert_equals(len(parents), 1)
+ assert_equals(Location(parents[0]).org, 'edX')
+ assert_equals(Location(parents[0]).course, 'toy')
+ assert_equals(Location(parents[0]).name, '2012_Fall')
+
+ def test_set_modulestore_configuration(self):
+ config = {'foo': 'bar'}
+ self.store.set_modulestore_configuration(config)
+ assert_equals(
+ config,
+ self.store._get_modulestore_for_courseid(IMPORT_COURSEID).modulestore_configuration
+ )
+
+ assert_equals(
+ config,
+ self.store._get_modulestore_for_courseid(XML_COURSEID1).modulestore_configuration
+ )
+
+ assert_equals(
+ config,
+ self.store._get_modulestore_for_courseid(XML_COURSEID2).modulestore_configuration
+ )
From 9b76b4929244500e53d9b0b19057c1591b8f725b Mon Sep 17 00:00:00 2001
From: Anton Stupak
Date: Thu, 15 Aug 2013 14:46:16 +0300
Subject: [PATCH 105/125] Disable speed controls for unsupported browsers.
---
.../js/src/video/08_video_speed_control.js | 35 +++++++++++++++++++
1 file changed, 35 insertions(+)
diff --git a/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js b/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js
index 27e4888d0d..c315e4afbc 100644
--- a/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js
+++ b/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js
@@ -10,6 +10,12 @@ function () {
return function (state) {
state.videoSpeedControl = {};
+ if (state.videoType === 'html5' && !(_checkPlaybackRates())) {
+ _hideSpeedControl(state);
+
+ return;
+ }
+
_makeFunctionsPublic(state);
_renderElements(state);
_bindHandlers(state);
@@ -61,6 +67,35 @@ function () {
state.videoSpeedControl.setSpeed(state.speed);
}
+ /**
+ * @desc Check if playbackRate supports by browser.
+ *
+ * @type {function}
+ * @access private
+ *
+ * @param {object} state The object containg the state of the video player.
+ * All other modules, their parameters, public variables, etc. are
+ * available via this object.
+ *
+ * @this {object} The global window object.
+ *
+ * @returns {Boolean}
+ * true: Browser support playbackRate functionality.
+ * false: Browser doesn't support playbackRate functionality.
+ */
+ function _checkPlaybackRates() {
+ var video = document.createElement('video');
+
+ // If browser supports, 1.0 should be returned by playbackRate property.
+ // In this case, function return True. Otherwise, False will be returned.
+ return Boolean(video.playbackRate);
+ }
+
+ // Hide speed control.
+ function _hideSpeedControl(state) {
+ state.el.find('div.speeds').hide();
+ }
+
/**
* @desc Bind any necessary function callbacks to DOM events (click,
* mousemove, etc.).
From a66743128aa5fccf19b33a3d3da62e4a60cd1a76 Mon Sep 17 00:00:00 2001
From: Peter Fogg
Date: Thu, 15 Aug 2013 17:24:03 -0400
Subject: [PATCH 106/125] Correctly seek in captions with non-1.0 speeds.
---
common/lib/xmodule/xmodule/js/src/video/09_video_caption.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js
index e8a30f6e9c..5ec111219a 100644
--- a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js
+++ b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js
@@ -312,7 +312,7 @@ function () {
var newIndex;
if (this.videoCaption.loaded) {
- time = Math.round(Time.convert(time, this.speed, '1.0') * 1000 + 250);
+ time = Math.round(parseInt(time, 10) * 1000);
newIndex = this.videoCaption.search(time);
if (newIndex !== void 0 && this.videoCaption.currentIndex !== newIndex) {
@@ -333,7 +333,7 @@ function () {
var time;
event.preventDefault();
- time = Math.round(Time.convert($(event.target).data('start'), '1.0', this.speed) / 1000);
+ time = parseInt($(event.target).data('start'), 10)/1000;
this.trigger('videoPlayer.onCaptionSeek', {'type': 'onCaptionSeek', 'time': time});
}
From 4b5f77aa55ab4e1718a1e0c346799453cae97d15 Mon Sep 17 00:00:00 2001
From: Valera Rozuvan
Date: Fri, 16 Aug 2013 14:06:42 +0300
Subject: [PATCH 107/125] Added back old method of getting caption position for
Flash video playback.
Because with speed changing the time also changes for Flash playback,
a different way to calculate the current time is needed than for HTML5
playback. I have added conditions for Flash and HTML5 video, and put
old method of calculating time for Flash.
I have tested it on the YouTube video ZwkTiUPN0mg. Both HTML5 mode and
Flash mode have proper video-captions syncing with this fix. NOTE: to view
YouTube video in Flash mode you either have to use an old browser (ex. Firefox
version 18) or hard code in source that state.currentPlayerMode = 'flash' (in
function _setPlayerMode(), file 01_initialize.js).
---
.../xmodule/js/src/video/09_video_caption.js | 51 ++++++++++++++++---
1 file changed, 45 insertions(+), 6 deletions(-)
diff --git a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js
index 5ec111219a..a34f33ba4c 100644
--- a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js
+++ b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js
@@ -312,15 +312,34 @@ function () {
var newIndex;
if (this.videoCaption.loaded) {
- time = Math.round(parseInt(time, 10) * 1000);
+ // Current mode === 'flash' can only be for YouTube videos. So, we
+ // don't have to also check for videoType === 'youtube'.
+ if (this.currentPlayerMode === 'flash') {
+ // Total play time changes with speed change. Also there is
+ // a 250 ms delay we have to take into account.
+ time = Math.round(
+ Time.convert(time, this.speed, '1.0') * 1000 + 250
+ );
+ } else {
+ // Total play time remains constant when speed changes.
+ time = Math.round(parseInt(time, 10) * 1000);
+ }
+
newIndex = this.videoCaption.search(time);
- if (newIndex !== void 0 && this.videoCaption.currentIndex !== newIndex) {
+ if (
+ newIndex !== void 0 &&
+ this.videoCaption.currentIndex !== newIndex
+ ) {
if (this.videoCaption.currentIndex) {
- this.videoCaption.subtitlesEl.find('li.current').removeClass('current');
+ this.videoCaption.subtitlesEl
+ .find('li.current')
+ .removeClass('current');
}
- this.videoCaption.subtitlesEl.find("li[data-index='" + newIndex + "']").addClass('current');
+ this.videoCaption.subtitlesEl
+ .find("li[data-index='" + newIndex + "']")
+ .addClass('current');
this.videoCaption.currentIndex = newIndex;
@@ -333,9 +352,29 @@ function () {
var time;
event.preventDefault();
- time = parseInt($(event.target).data('start'), 10)/1000;
- this.trigger('videoPlayer.onCaptionSeek', {'type': 'onCaptionSeek', 'time': time});
+ // Current mode === 'flash' can only be for YouTube videos. So, we
+ // don't have to also check for videoType === 'youtube'.
+ if (this.currentPlayerMode === 'flash') {
+ // Total play time changes with speed change. Also there is
+ // a 250 ms delay we have to take into account.
+ time = Math.round(
+ Time.convert(
+ $(event.target).data('start'), '1.0', this.speed
+ ) / 1000
+ );
+ } else {
+ // Total play time remains constant when speed changes.
+ time = parseInt($(event.target).data('start'), 10)/1000;
+ }
+
+ this.trigger(
+ 'videoPlayer.onCaptionSeek',
+ {
+ 'type': 'onCaptionSeek',
+ 'time': time
+ }
+ );
}
function calculateOffset(element) {
From ae6f97a3683b61d5d4e21948a66c6238b985df4e Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Fri, 16 Aug 2013 09:51:57 -0400
Subject: [PATCH 108/125] add test for test_static_url_generation_from_courseid
---
common/lib/xmodule/xmodule/tests/test_content.py | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/common/lib/xmodule/xmodule/tests/test_content.py b/common/lib/xmodule/xmodule/tests/test_content.py
index e73c33197c..6f5cf8ab8c 100644
--- a/common/lib/xmodule/xmodule/tests/test_content.py
+++ b/common/lib/xmodule/xmodule/tests/test_content.py
@@ -19,12 +19,18 @@ class ContentTest(unittest.TestCase):
content = StaticContent('loc', 'name', 'content_type', 'data')
self.assertIsNone(content.thumbnail_location)
+
+ def test_static_url_generation_from_courseid(self):
+ url = StaticContent.convert_legacy_static_url_with_course_id('images_course_image.jpg', 'foo/bar/bz')
+ self.assertEqual(url, '/c4x/foo/bar/asset/images_course_image.jpg')
+
def test_generate_thumbnail_image(self):
contentStore = ContentStore()
content = Content(Location(u'c4x', u'mitX', u'800', u'asset', u'monsters__.jpg'), None)
(thumbnail_content, thumbnail_file_location) = contentStore.generate_thumbnail(content)
self.assertIsNone(thumbnail_content)
self.assertEqual(Location(u'c4x', u'mitX', u'800', u'thumbnail', u'monsters__.jpg'), thumbnail_file_location)
+
def test_compute_location(self):
# We had a bug that __ got converted into a single _. Make sure that substitution of INVALID_CHARS (like space)
# still happen.
From 8aee2251361639b71291ba0ae28dcf4e843c9028 Mon Sep 17 00:00:00 2001
From: Adam Palay
Date: Thu, 15 Aug 2013 10:35:29 -0400
Subject: [PATCH 109/125] in grading, if problem cannot be created, return
score as none
---
lms/djangoapps/courseware/grades.py | 2 ++
lms/envs/dev.py | 2 --
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py
index e3c40079c3..8874a5686c 100644
--- a/lms/djangoapps/courseware/grades.py
+++ b/lms/djangoapps/courseware/grades.py
@@ -358,6 +358,8 @@ def get_score(course_id, user, problem_descriptor, module_creator, model_data_ca
# with the LMS, so they need to always be scored. (E.g. foldit.)
if problem_descriptor.always_recalculate_grades:
problem = module_creator(problem_descriptor)
+ if problem is None:
+ return (None, None)
score = problem.get_score()
if score is not None:
return (score['score'], score['total'])
diff --git a/lms/envs/dev.py b/lms/envs/dev.py
index b9768554b1..d47c7bf82d 100644
--- a/lms/envs/dev.py
+++ b/lms/envs/dev.py
@@ -31,8 +31,6 @@ MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True
MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True
-FEEDBACK_SUBMISSION_EMAIL = "dummy@dummy.org"
-
FEEDBACK_SUBMISSION_EMAIL = "dummy@example.com"
WIKI_ENABLED = True
From 13ff461461f13beb2482ef07a973d73349545e08 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Fri, 16 Aug 2013 10:30:47 -0400
Subject: [PATCH 110/125] add a filter to get_courses to not surface any
courses that haven't been mapped, unless the store provider has been labeled
as 'default'
---
common/lib/xmodule/xmodule/modulestore/mixed.py | 15 ++++++++++++++-
.../modulestore/tests/test_mixed_modulestore.py | 1 +
2 files changed, 15 insertions(+), 1 deletion(-)
diff --git a/common/lib/xmodule/xmodule/modulestore/mixed.py b/common/lib/xmodule/xmodule/modulestore/mixed.py
index ec2b563cac..9548323115 100644
--- a/common/lib/xmodule/xmodule/modulestore/mixed.py
+++ b/common/lib/xmodule/xmodule/modulestore/mixed.py
@@ -103,7 +103,20 @@ class MixedModuleStore(ModuleStoreBase):
'''
courses = []
for key in self.modulestores:
- courses = courses + (self.modulestores[key].get_courses())
+ store_courses = self.modulestores[key].get_courses()
+ # If the store has not been labeled as 'default' then we should
+ # only surface courses that have a mapping entry, for example the XMLModuleStore will
+ # slurp up anything that is on disk, however, we don't want to surface those to
+ # consumers *unless* there is an explicit mapping in the configuration
+ if key != 'default':
+ for course in store_courses:
+ # make sure that the courseId is mapped to the store in question
+ if key == self.mappings.get(course.location.course_id, 'default'):
+ courses = courses + ([course])
+ else:
+ # if we're the 'default' store provider, then we surface all courses hosted in
+ # that store provider
+ courses = courses + (store_courses)
return courses
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py
index c065b14de9..70e4d1a5d3 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py
@@ -187,6 +187,7 @@ class TestMixedModuleStore(object):
def test_get_courses(self):
# we should have 3 total courses aggregated
courses = self.store.get_courses()
+ assert_equals(len(courses), 3)
course_ids = []
for course in courses:
course_ids.append(course.location.course_id)
From e4a69373d0d2c706f1cd41d6f6509fafd3e38a9d Mon Sep 17 00:00:00 2001
From: Don Mitchell
Date: Thu, 8 Aug 2013 17:44:48 -0400
Subject: [PATCH 111/125] xblock fields persist w/o breaking by scope
Letting xblocks handle scope rather than separating fields into
different attrs. Although, split still shunts content fields to a
different collection than setting and children fields.
The big difference is that content fields will always be a dict and not
sometimes just a string and there's no special casing of 'data' attr.
The other mind change is no more 'metadata' dict.
---
.../contentstore/tests/test_crud.py | 30 +-
.../split_mongo/caching_descriptor_system.py | 29 +-
.../xmodule/modulestore/split_mongo/split.py | 425 +++++++++++-------
.../split_mongo/split_mongo_kvs.py | 228 +++++-----
.../modulestore/tests/persistent_factories.py | 39 +-
.../tests/test_split_modulestore.py | 53 ++-
common/lib/xmodule/xmodule/x_module.py | 50 ++-
.../data/splitmongo_json/definitions.json | 132 +++---
.../test/data/splitmongo_json/structures.json | 246 +++++-----
9 files changed, 688 insertions(+), 544 deletions(-)
diff --git a/cms/djangoapps/contentstore/tests/test_crud.py b/cms/djangoapps/contentstore/tests/test_crud.py
index e12711a6ff..5beef20d6c 100644
--- a/cms/djangoapps/contentstore/tests/test_crud.py
+++ b/cms/djangoapps/contentstore/tests/test_crud.py
@@ -75,7 +75,7 @@ class TemplateTests(unittest.TestCase):
display_name='fun test course', user_id='testbot')
test_chapter = XModuleDescriptor.load_from_json({'category': 'chapter',
- 'metadata': {'display_name': 'chapter n'}},
+ 'fields': {'display_name': 'chapter n'}},
test_course.system, parent_xblock=test_course)
self.assertIsInstance(test_chapter, SequenceDescriptor)
self.assertEqual(test_chapter.display_name, 'chapter n')
@@ -84,7 +84,7 @@ class TemplateTests(unittest.TestCase):
# test w/ a definition (e.g., a problem)
test_def_content = 'boo'
test_problem = XModuleDescriptor.load_from_json({'category': 'problem',
- 'definition': {'data': test_def_content}},
+ 'fields': {'data': test_def_content}},
test_course.system, parent_xblock=test_chapter)
self.assertIsInstance(test_problem, CapaDescriptor)
self.assertEqual(test_problem.data, test_def_content)
@@ -99,11 +99,12 @@ class TemplateTests(unittest.TestCase):
test_course = persistent_factories.PersistentCourseFactory.create(org='testx', prettyid='tempcourse',
display_name='fun test course', user_id='testbot')
test_chapter = XModuleDescriptor.load_from_json({'category': 'chapter',
- 'metadata': {'display_name': 'chapter n'}},
+ 'fields': {'display_name': 'chapter n'}},
test_course.system, parent_xblock=test_course)
test_def_content = 'boo'
- test_problem = XModuleDescriptor.load_from_json({'category': 'problem',
- 'definition': {'data': test_def_content}},
+ # create child
+ _ = XModuleDescriptor.load_from_json({'category': 'problem',
+ 'fields': {'data': test_def_content}},
test_course.system, parent_xblock=test_chapter)
# better to pass in persisted parent over the subdag so
# subdag gets the parent pointer (otherwise 2 ops, persist dag, update parent children,
@@ -152,15 +153,24 @@ class TemplateTests(unittest.TestCase):
parent_location=test_course.location, user_id='testbot')
sub = persistent_factories.ItemFactory.create(display_name='subsection 1',
parent_location=chapter.location, user_id='testbot', category='vertical')
- first_problem = persistent_factories.ItemFactory.create(display_name='problem 1',
- parent_location=sub.location, user_id='testbot', category='problem', data="")
+ first_problem = persistent_factories.ItemFactory.create(
+ display_name='problem 1', parent_location=sub.location, user_id='testbot', category='problem',
+ fields={'data':""}
+ )
first_problem.max_attempts = 3
+ first_problem.save() # decache the above into the kvs
updated_problem = modulestore('split').update_item(first_problem, 'testbot')
- updated_loc = modulestore('split').delete_item(updated_problem.location, 'testbot')
+ self.assertIsNotNone(updated_problem.previous_version)
+ self.assertEqual(updated_problem.previous_version, first_problem.update_version)
+ self.assertNotEqual(updated_problem.update_version, first_problem.update_version)
+ updated_loc = modulestore('split').delete_item(updated_problem.location, 'testbot', delete_children=True)
- second_problem = persistent_factories.ItemFactory.create(display_name='problem 2',
+ second_problem = persistent_factories.ItemFactory.create(
+ display_name='problem 2',
parent_location=BlockUsageLocator(updated_loc, usage_id=sub.location.usage_id),
- user_id='testbot', category='problem', data="")
+ user_id='testbot', category='problem',
+ fields={'data':""}
+ )
# course root only updated 2x
version_history = modulestore('split').get_block_generations(test_course.location)
diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py
index 8a9b35e4f1..73dcabfa69 100644
--- a/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py
+++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py
@@ -11,18 +11,17 @@ from .split_mongo_kvs import SplitMongoKVS, SplitMongoKVSid
log = logging.getLogger(__name__)
-# TODO should this be here or w/ x_module or ???
class CachingDescriptorSystem(MakoDescriptorSystem):
"""
A system that has a cache of a course version's json that it will use to load modules
from, with a backup of calling to the underlying modulestore for more data.
- Computes the metadata inheritance upon creation.
+ Computes the settings (nee 'metadata') inheritance upon creation.
"""
def __init__(self, modulestore, course_entry, module_data, lazy,
default_class, error_tracker, render_template):
"""
- Computes the metadata inheritance and sets up the cache.
+ Computes the settings inheritance and sets up the cache.
modulestore: the module store that can be used to retrieve additional
modules
@@ -50,9 +49,10 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
self.default_class = default_class
# TODO see if self.course_id is needed: is already in course_entry but could be > 1 value
# Compute inheritance
- modulestore.inherit_metadata(course_entry.get('blocks', {}),
- course_entry.get('blocks', {})
- .get(course_entry.get('root')))
+ modulestore.inherit_settings(
+ course_entry.get('blocks', {}),
+ course_entry.get('blocks', {}).get(course_entry.get('root'))
+ )
def _load_item(self, usage_id, course_entry_override=None):
# TODO ensure all callers of system.load_item pass just the id
@@ -73,9 +73,8 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
def xblock_from_json(self, class_, usage_id, json_data, course_entry_override=None):
if course_entry_override is None:
course_entry_override = self.course_entry
- # most likely a lazy loader but not the id directly
+ # most likely a lazy loader or the id directly
definition = json_data.get('definition', {})
- metadata = json_data.get('metadata', {})
block_locator = BlockUsageLocator(
version_guid=course_entry_override['_id'],
@@ -86,9 +85,8 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
kvs = SplitMongoKVS(
definition,
- json_data.get('children', []),
- metadata,
- json_data.get('_inherited_metadata'),
+ json_data.get('fields', {}),
+ json_data.get('_inherited_settings'),
block_locator,
json_data.get('category'))
model_data = DbModel(kvs, class_, None,
@@ -111,10 +109,11 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
error_msg=exc_info_to_str(sys.exc_info())
)
- module.edited_by = json_data.get('edited_by')
- module.edited_on = json_data.get('edited_on')
- module.previous_version = json_data.get('previous_version')
- module.update_version = json_data.get('update_version')
+ edit_info = json_data.get('edit_info', {})
+ module.edited_by = edit_info.get('edited_by')
+ module.edited_on = edit_info.get('edited_on')
+ module.previous_version = edit_info.get('previous_version')
+ module.update_version = edit_info.get('update_version')
module.definition_locator = self.modulestore.definition_locator(definition)
# decache any pending field settings
module.save()
diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py
index 74c7e7241a..a1429d9c90 100644
--- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py
+++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py
@@ -16,6 +16,9 @@ from .. import ModuleStoreBase
from ..exceptions import ItemNotFoundError
from .definition_lazy_loader import DefinitionLazyLoader
from .caching_descriptor_system import CachingDescriptorSystem
+from xblock.core import Scope
+from pytz import UTC
+import collections
log = logging.getLogger(__name__)
#==============================================================================
@@ -102,10 +105,12 @@ class SplitMongoModuleStore(ModuleStoreBase):
'''
new_module_data = {}
for usage_id in base_usage_ids:
- new_module_data = self.descendants(system.course_entry['blocks'],
- usage_id,
- depth,
- new_module_data)
+ new_module_data = self.descendants(
+ system.course_entry['blocks'],
+ usage_id,
+ depth,
+ new_module_data
+ )
# remove any which were already in module_data (not sure if there's a better way)
for newkey in new_module_data.iterkeys():
@@ -114,8 +119,7 @@ class SplitMongoModuleStore(ModuleStoreBase):
if lazy:
for block in new_module_data.itervalues():
- block['definition'] = DefinitionLazyLoader(self,
- block['definition'])
+ block['definition'] = DefinitionLazyLoader(self, block['definition'])
else:
# Load all descendants by id
descendent_definitions = self.definitions.find({
@@ -127,7 +131,7 @@ class SplitMongoModuleStore(ModuleStoreBase):
for block in new_module_data.itervalues():
if block['definition'] in definitions:
- block['definition'] = definitions[block['definition']]
+ block['fields'].update(definitions[block['definition']].get('fields'))
system.module_data.update(new_module_data)
return system.module_data
@@ -317,7 +321,7 @@ class SplitMongoModuleStore(ModuleStoreBase):
definitions.
Common qualifiers are category, definition (provide definition id),
- metadata: {display_name ..}, children (return
+ display_name, anyfieldname, children (return
block if its children includes the one given value). If you want
substring matching use {$regex: /acme.*corp/i} type syntax.
@@ -371,7 +375,7 @@ class SplitMongoModuleStore(ModuleStoreBase):
course = self._lookup_course(locator)
items = []
for parent_id, value in course['blocks'].iteritems():
- for child_id in value['children']:
+ for child_id in value['fields'].get('children', []):
if locator.usage_id == child_id:
items.append(BlockUsageLocator(url=locator.as_course_locator(), usage_id=parent_id))
return items
@@ -427,11 +431,7 @@ class SplitMongoModuleStore(ModuleStoreBase):
definition = self.definitions.find_one({'_id': definition_locator.definition_id})
if definition is None:
return None
- return {'original_version': definition['original_version'],
- 'previous_version': definition['previous_version'],
- 'edited_by': definition['edited_by'],
- 'edited_on': definition['edited_on']
- }
+ return definition['edit_info']
def get_course_successors(self, course_locator, version_history_depth=1):
'''
@@ -471,29 +471,29 @@ class SplitMongoModuleStore(ModuleStoreBase):
Find the history of this block. Return as a VersionTree of each place the block changed (except
deletion).
- The block's history tracks its explicit changes; so, changes in descendants won't be reflected
- as new iterations.
+ The block's history tracks its explicit changes but not the changes in its children.
+
'''
block_locator = block_locator.version_agnostic()
course_struct = self._lookup_course(block_locator)
usage_id = block_locator.usage_id
- update_version_field = 'blocks.{}.update_version'.format(usage_id)
+ update_version_field = 'blocks.{}.edit_info.update_version'.format(usage_id)
all_versions_with_block = self.structures.find({'original_version': course_struct['original_version'],
update_version_field: {'$exists': True}})
# find (all) root versions and build map previous: [successors]
possible_roots = []
result = {}
for version in all_versions_with_block:
- if version['_id'] == version['blocks'][usage_id]['update_version']:
- if version['blocks'][usage_id].get('previous_version') is None:
- possible_roots.append(version['blocks'][usage_id]['update_version'])
+ if version['_id'] == version['blocks'][usage_id]['edit_info']['update_version']:
+ if version['blocks'][usage_id]['edit_info'].get('previous_version') is None:
+ possible_roots.append(version['blocks'][usage_id]['edit_info']['update_version'])
else:
- result.setdefault(version['blocks'][usage_id]['previous_version'], set()).add(
- version['blocks'][usage_id]['update_version'])
+ result.setdefault(version['blocks'][usage_id]['edit_info']['previous_version'], set()).add(
+ version['blocks'][usage_id]['edit_info']['update_version'])
# more than one possible_root means usage was added and deleted > 1x.
if len(possible_roots) > 1:
# find the history segment including block_locator's version
- element_to_find = course_struct['blocks'][usage_id]['update_version']
+ element_to_find = course_struct['blocks'][usage_id]['edit_info']['update_version']
if element_to_find in possible_roots:
possible_roots = [element_to_find]
for possibility in possible_roots:
@@ -513,7 +513,7 @@ class SplitMongoModuleStore(ModuleStoreBase):
Find the version_history_depth next versions of this definition. Return as a VersionTree
'''
# TODO implement
- pass
+ raise NotImplementedError()
def create_definition_from_data(self, new_def_data, category, user_id):
"""
@@ -522,16 +522,21 @@ class SplitMongoModuleStore(ModuleStoreBase):
:param user_id: request.user object
"""
- document = {"category" : category,
- "data": new_def_data,
- "edited_by": user_id,
- "edited_on": datetime.datetime.utcnow(),
- "previous_version": None,
- "original_version": None}
+ new_def_data = self._filter_special_fields(new_def_data)
+ document = {
+ "category" : category,
+ "fields": new_def_data,
+ "edit_info": {
+ "edited_by": user_id,
+ "edited_on": datetime.datetime.now(UTC),
+ "previous_version": None,
+ "original_version": None
+ }
+ }
new_id = self.definitions.insert(document)
definition_locator = DescriptionLocator(new_id)
- document['original_version'] = new_id
- self.definitions.update({'_id': new_id}, {'$set': {"original_version": new_id}})
+ document['edit_info']['original_version'] = new_id
+ self.definitions.update({'_id': new_id}, {'$set': {"edit_info.original_version": new_id}})
return definition_locator
def update_definition_from_data(self, definition_locator, new_def_data, user_id):
@@ -541,16 +546,14 @@ class SplitMongoModuleStore(ModuleStoreBase):
:param user_id: request.user
"""
+ new_def_data = self._filter_special_fields(new_def_data)
def needs_saved():
- if isinstance(new_def_data, dict):
- for key, value in new_def_data.iteritems():
- if key not in old_definition['data'] or value != old_definition['data'][key]:
- return True
- for key, value in old_definition['data'].iteritems():
- if key not in new_def_data:
- return True
- else:
- return new_def_data != old_definition['data']
+ for key, value in new_def_data.iteritems():
+ if key not in old_definition['fields'] or value != old_definition['fields'][key]:
+ return True
+ for key, value in old_definition.get('fields', {}).iteritems():
+ if key not in new_def_data:
+ return True
# if this looks in cache rather than fresh fetches, then it will probably not detect
# actual change b/c the descriptor and cache probably point to the same objects
@@ -560,10 +563,10 @@ class SplitMongoModuleStore(ModuleStoreBase):
del old_definition['_id']
if needs_saved():
- old_definition['data'] = new_def_data
- old_definition['edited_by'] = user_id
- old_definition['edited_on'] = datetime.datetime.utcnow()
- old_definition['previous_version'] = definition_locator.definition_id
+ old_definition['fields'] = new_def_data
+ old_definition['edit_info']['edited_by'] = user_id
+ old_definition['edit_info']['edited_on'] = datetime.datetime.now(UTC)
+ old_definition['edit_info']['previous_version'] = definition_locator.definition_id
new_id = self.definitions.insert(old_definition)
return DescriptionLocator(new_id), True
else:
@@ -605,11 +608,11 @@ class SplitMongoModuleStore(ModuleStoreBase):
else:
return id_root
- # TODO I would love to write this to take a real descriptor and persist it BUT descriptors, kvs, and dbmodel
- # all assume locators are set and unique! Having this take the model contents piecemeal breaks the separation
- # of model from persistence layer
- def create_item(self, course_or_parent_locator, category, user_id, definition_locator=None, new_def_data=None,
- metadata=None, force=False):
+ # TODO Should I rewrite this to take a new xblock instance rather than to construct it? That is, require the
+ # caller to use XModuleDescriptor.load_from_json thus reducing similar code and making the object creation and
+ # validation behavior a responsibility of the model layer rather than the persistence layer.
+ def create_item(self, course_or_parent_locator, category, user_id, definition_locator=None, fields=None,
+ force=False):
"""
Add a descriptor to persistence as the last child of the optional parent_location or just as an element
of the course (if no parent provided). Return the resulting post saved version with populated locators.
@@ -624,9 +627,10 @@ class SplitMongoModuleStore(ModuleStoreBase):
The incoming definition_locator should either be None to indicate this is a brand new definition or
a pointer to the existing definition to which this block should point or from which this was derived.
- If new_def_data is None, then definition_locator must have a value meaning that this block points
- to the existing definition. If new_def_data is not None and definition_location is not None, then
- new_def_data is assumed to be a new payload for definition_location.
+ If fields does not contain any Scope.content, then definition_locator must have a value meaning that this
+ block points
+ to the existing definition. If fields contains Scope.content and definition_locator is not None, then
+ the Scope.content fields are assumed to be a new payload for definition_locator.
Creates a new version of the course structure, creates and inserts the new block, makes the block point
to the definition which may be new or a new version of an existing or an existing.
@@ -645,6 +649,8 @@ class SplitMongoModuleStore(ModuleStoreBase):
index_entry = self._get_index_if_valid(course_or_parent_locator, force)
structure = self._lookup_course(course_or_parent_locator)
+ partitioned_fields = self._partition_fields_by_scope(category, fields)
+ new_def_data = partitioned_fields.get(Scope.content, {})
# persist the definition if persisted != passed
if (definition_locator is None or definition_locator.definition_id is None):
definition_locator = self.create_definition_from_data(new_def_data, category, user_id)
@@ -655,23 +661,27 @@ class SplitMongoModuleStore(ModuleStoreBase):
new_structure = self._version_structure(structure, user_id)
# generate an id
new_usage_id = self._generate_usage_id(new_structure['blocks'], category)
- update_version_keys = ['blocks.{}.update_version'.format(new_usage_id)]
+ update_version_keys = ['blocks.{}.edit_info.update_version'.format(new_usage_id)]
if isinstance(course_or_parent_locator, BlockUsageLocator) and course_or_parent_locator.usage_id is not None:
parent = new_structure['blocks'][course_or_parent_locator.usage_id]
- parent['children'].append(new_usage_id)
- parent['edited_on'] = datetime.datetime.utcnow()
- parent['edited_by'] = user_id
- parent['previous_version'] = parent['update_version']
- update_version_keys.append('blocks.{}.update_version'.format(course_or_parent_locator.usage_id))
+ parent['fields'].setdefault('children', []).append(new_usage_id)
+ parent['edit_info']['edited_on'] = datetime.datetime.now(UTC)
+ parent['edit_info']['edited_by'] = user_id
+ parent['edit_info']['previous_version'] = parent['edit_info']['update_version']
+ update_version_keys.append('blocks.{}.edit_info.update_version'.format(course_or_parent_locator.usage_id))
+ block_fields = partitioned_fields.get(Scope.settings, {})
+ if Scope.children in partitioned_fields:
+ block_fields.update(partitioned_fields[Scope.children])
new_structure['blocks'][new_usage_id] = {
- "children": [],
"category": category,
"definition": definition_locator.definition_id,
- "metadata": metadata if metadata else {},
- 'edited_on': datetime.datetime.utcnow(),
- 'edited_by': user_id,
- 'previous_version': None
+ "fields": block_fields,
+ 'edit_info': {
+ 'edited_on': datetime.datetime.now(UTC),
+ 'edited_by': user_id,
+ 'previous_version': None
}
+ }
new_id = self.structures.insert(new_structure)
update_version_payload = {key: new_id for key in update_version_keys}
self.structures.update({'_id': new_id},
@@ -689,8 +699,9 @@ class SplitMongoModuleStore(ModuleStoreBase):
usage_id=new_usage_id,
version_guid=new_id))
- def create_course(self, org, prettyid, user_id, id_root=None, metadata=None, course_data=None,
- master_version='draft', versions_dict=None, root_category='course'):
+ def create_course(
+ self, org, prettyid, user_id, id_root=None, fields=None,
+ master_branch='draft', versions_dict=None, root_category='course'):
"""
Create a new entry in the active courses index which points to an existing or new structure. Returns
the course root of the resulting entry (the location has the course id)
@@ -698,93 +709,106 @@ class SplitMongoModuleStore(ModuleStoreBase):
id_root: allows the caller to specify the course_id. It's a root in that, if it's already taken,
this method will append things to the root to make it unique. (defaults to org)
- metadata: if provided, will set the metadata of the root course object in the new draft course. If both
- metadata and a starting version are provided, it will generate a successor version to the given version,
- and update the metadata with any provided values (via update not setting).
+ fields: if scope.settings fields provided, will set the fields of the root course object in the
+ new course. If both
+ settings fields and a starting version are provided (via versions_dict), it will generate a successor version
+ to the given version,
+ and update the settings fields with any provided values (via update not setting).
- course_data: if provided, will update the data of the new course xblock definition to this. Like metadata,
+ fields (content): if scope.content fields provided, will update the fields of the new course
+ xblock definition to this. Like settings fields,
if provided, this will cause a new version of any given version as well as a new version of the
definition (which will point to the existing one if given a version). If not provided and given
- a draft_version, it will reuse the same definition as the draft course (obvious since it's reusing the draft
- course). If not provided and no draft is given, it will be empty and get the field defaults (hopefully) when
+ a version_dict, it will reuse the same definition as that version's course
+ (obvious since it's reusing the
+ course). If not provided and no version_dict is given, it will be empty and get the field defaults
+ when
loaded.
- master_version: the tag (key) for the version name in the dict which is the 'draft' version. Not the actual
+ master_branch: the tag (key) for the version name in the dict which is the 'draft' version. Not the actual
version guid, but what to call it.
versions_dict: the starting version ids where the keys are the tags such as 'draft' and 'published'
and the values are structure guids. If provided, the new course will reuse this version (unless you also
- provide any overrides such as metadata, see above). if not provided, will create a mostly empty course
+ provide any fields overrides, see above). if not provided, will create a mostly empty course
structure with just a category course root xblock.
"""
- if metadata is None:
- metadata = {}
+ partitioned_fields = self._partition_fields_by_scope('course', fields)
+ block_fields = partitioned_fields.setdefault(Scope.settings, {})
+ if Scope.children in partitioned_fields:
+ block_fields.update(partitioned_fields[Scope.children])
+ definition_fields = self._filter_special_fields(partitioned_fields.get(Scope.content, {}))
+
# build from inside out: definition, structure, index entry
# if building a wholly new structure
- if versions_dict is None or master_version not in versions_dict:
+ if versions_dict is None or master_branch not in versions_dict:
# create new definition and structure
- if course_data is None:
- course_data = {}
definition_entry = {
'category': root_category,
- 'data': course_data,
- 'edited_by': user_id,
- 'edited_on': datetime.datetime.utcnow(),
- 'previous_version': None,
+ 'fields': definition_fields,
+ 'edit_info': {
+ 'edited_by': user_id,
+ 'edited_on': datetime.datetime.now(UTC),
+ 'previous_version': None,
}
+ }
definition_id = self.definitions.insert(definition_entry)
- definition_entry['original_version'] = definition_id
- self.definitions.update({'_id': definition_id}, {'$set': {"original_version": definition_id}})
+ definition_entry['edit_info']['original_version'] = definition_id
+ self.definitions.update({'_id': definition_id}, {'$set': {"edit_info.original_version": definition_id}})
draft_structure = {
'root': 'course',
'previous_version': None,
'edited_by': user_id,
- 'edited_on': datetime.datetime.utcnow(),
+ 'edited_on': datetime.datetime.now(UTC),
'blocks': {
'course': {
- 'children':[],
'category': 'course',
'definition': definition_id,
- 'metadata': metadata,
- 'edited_on': datetime.datetime.utcnow(),
- 'edited_by': user_id,
- 'previous_version': None}}}
+ 'fields': block_fields,
+ 'edit_info': {
+ 'edited_on': datetime.datetime.now(UTC),
+ 'edited_by': user_id,
+ 'previous_version': None
+ }
+ }
+ }
+ }
new_id = self.structures.insert(draft_structure)
draft_structure['original_version'] = new_id
self.structures.update({'_id': new_id},
{'$set': {"original_version": new_id,
- 'blocks.course.update_version': new_id}})
+ 'blocks.course.edit_info.update_version': new_id}})
if versions_dict is None:
- versions_dict = {master_version: new_id}
+ versions_dict = {master_branch: new_id}
else:
- versions_dict[master_version] = new_id
+ versions_dict[master_branch] = new_id
else:
# just get the draft_version structure
- draft_version = CourseLocator(version_guid=versions_dict[master_version])
+ draft_version = CourseLocator(version_guid=versions_dict[master_branch])
draft_structure = self._lookup_course(draft_version)
- if course_data is not None or metadata:
+ if definition_fields or block_fields:
draft_structure = self._version_structure(draft_structure, user_id)
root_block = draft_structure['blocks'][draft_structure['root']]
- if metadata is not None:
- root_block['metadata'].update(metadata)
- if course_data is not None:
+ if block_fields is not None:
+ root_block['fields'].update(block_fields)
+ if definition_fields is not None:
definition = self.definitions.find_one({'_id': root_block['definition']})
- definition['data'].update(course_data)
- definition['previous_version'] = definition['_id']
- definition['edited_by'] = user_id
- definition['edited_on'] = datetime.datetime.utcnow()
+ definition['fields'].update(definition_fields)
+ definition['edit_info']['previous_version'] = definition['_id']
+ definition['edit_info']['edited_by'] = user_id
+ definition['edit_info']['edited_on'] = datetime.datetime.now(UTC)
del definition['_id']
root_block['definition'] = self.definitions.insert(definition)
- root_block['edited_on'] = datetime.datetime.utcnow()
- root_block['edited_by'] = user_id
- root_block['previous_version'] = root_block.get('update_version')
+ root_block['edit_info']['edited_on'] = datetime.datetime.now(UTC)
+ root_block['edit_info']['edited_by'] = user_id
+ root_block['edit_info']['previous_version'] = root_block['edit_info'].get('update_version')
# insert updates the '_id' in draft_structure
new_id = self.structures.insert(draft_structure)
- versions_dict[master_version] = new_id
+ versions_dict[master_branch] = new_id
self.structures.update({'_id': new_id},
- {'$set': {'blocks.{}.update_version'.format(draft_structure['root']): new_id}})
+ {'$set': {'blocks.{}.edit_info.update_version'.format(draft_structure['root']): new_id}})
# create the index entry
if id_root is None:
id_root = org
@@ -795,14 +819,14 @@ class SplitMongoModuleStore(ModuleStoreBase):
'org': org,
'prettyid': prettyid,
'edited_by': user_id,
- 'edited_on': datetime.datetime.utcnow(),
+ 'edited_on': datetime.datetime.now(UTC),
'versions': versions_dict}
new_id = self.course_index.insert(index_entry)
- return self.get_course(CourseLocator(course_id=new_id, branch=master_version))
+ return self.get_course(CourseLocator(course_id=new_id, branch=master_branch))
def update_item(self, descriptor, user_id, force=False):
"""
- Save the descriptor's definition, metadata, & children references (i.e., it doesn't descend the tree).
+ Save the descriptor's fields. it doesn't descend the course dag to save the children.
Return the new descriptor (updated location).
raises ItemNotFoundError if the location does not exist.
@@ -819,31 +843,38 @@ class SplitMongoModuleStore(ModuleStoreBase):
index_entry = self._get_index_if_valid(descriptor.location, force)
descriptor.definition_locator, is_updated = self.update_definition_from_data(
- descriptor.definition_locator, descriptor.xblock_kvs.get_data(), user_id)
+ descriptor.definition_locator, descriptor.get_explicitly_set_fields_by_scope(Scope.content), user_id)
# check children
original_entry = original_structure['blocks'][descriptor.location.usage_id]
if (not is_updated and descriptor.has_children
- and not self._xblock_lists_equal(original_entry['children'], descriptor.children)):
+ and not self._xblock_lists_equal(original_entry['fields']['children'], descriptor.children)):
is_updated = True
# check metadata
if not is_updated:
- is_updated = self._compare_metadata(descriptor.xblock_kvs.get_own_metadata(), original_entry['metadata'])
+ is_updated = self._compare_settings(
+ descriptor.get_explicitly_set_fields_by_scope(Scope.settings),
+ original_entry['fields']
+ )
# if updated, rev the structure
if is_updated:
new_structure = self._version_structure(original_structure, user_id)
block_data = new_structure['blocks'][descriptor.location.usage_id]
- if descriptor.has_children:
- block_data["children"] = [self._usage_id(child) for child in descriptor.children]
block_data["definition"] = descriptor.definition_locator.definition_id
- block_data["metadata"] = descriptor.xblock_kvs.get_own_metadata()
- block_data['edited_on'] = datetime.datetime.utcnow()
- block_data['edited_by'] = user_id
- block_data['previous_version'] = block_data['update_version']
+ block_data["fields"] = descriptor.get_explicitly_set_fields_by_scope(Scope.settings)
+ if descriptor.has_children:
+ block_data['fields']["children"] = [self._usage_id(child) for child in descriptor.children]
+
+ block_data['edit_info'] = {
+ 'edited_on': datetime.datetime.now(UTC),
+ 'edited_by': user_id,
+ 'previous_version': block_data['edit_info']['update_version'],
+ }
new_id = self.structures.insert(new_structure)
- self.structures.update({'_id': new_id},
- {'$set': {'blocks.{}.update_version'.format(descriptor.location.usage_id): new_id}})
+ self.structures.update(
+ {'_id': new_id},
+ {'$set': {'blocks.{}.edit_info.update_version'.format(descriptor.location.usage_id): new_id}})
# update the index entry if appropriate
if index_entry is not None:
@@ -869,8 +900,8 @@ class SplitMongoModuleStore(ModuleStoreBase):
returns the post-persisted version of the incoming xblock. Note that its children will be ids not
objects.
- :param xblock:
- :param user_id:
+ :param xblock: the head of the dag
+ :param user_id: who's doing the change
"""
# find course_index entry if applicable and structures entry
index_entry = self._get_index_if_valid(xblock.location, force)
@@ -883,7 +914,7 @@ class SplitMongoModuleStore(ModuleStoreBase):
new_id = self.structures.insert(new_structure)
update_command = {}
for usage_id in changed_blocks:
- update_command['blocks.{}.update_version'.format(usage_id)] = new_id
+ update_command['blocks.{}.edit_info.update_version'.format(usage_id)] = new_id
self.structures.update({'_id': new_id}, {'$set': update_command})
# update the index entry if appropriate
@@ -897,14 +928,14 @@ class SplitMongoModuleStore(ModuleStoreBase):
def _persist_subdag(self, xblock, user_id, structure_blocks):
# persist the definition if persisted != passed
- new_def_data = xblock.xblock_kvs.get_data()
+ new_def_data = self._filter_special_fields(xblock.get_explicitly_set_fields_by_scope(Scope.content))
if (xblock.definition_locator is None or xblock.definition_locator.definition_id is None):
- xblock.definition_locator = self.create_definition_from_data(new_def_data,
- xblock.category, user_id)
+ xblock.definition_locator = self.create_definition_from_data(
+ new_def_data, xblock.category, user_id)
is_updated = True
- elif new_def_data is not None:
- xblock.definition_locator, is_updated = self.update_definition_from_data(xblock.definition_locator,
- new_def_data, user_id)
+ elif new_def_data:
+ xblock.definition_locator, is_updated = self.update_definition_from_data(
+ xblock.definition_locator, new_def_data, user_id)
if xblock.location.usage_id is None:
# generate an id
@@ -916,7 +947,7 @@ class SplitMongoModuleStore(ModuleStoreBase):
is_new = False
usage_id = xblock.location.usage_id
if (not is_updated and xblock.has_children
- and not self._xblock_lists_equal(structure_blocks[usage_id]['children'], xblock.children)):
+ and not self._xblock_lists_equal(structure_blocks[usage_id]['fields']['children'], xblock.children)):
is_updated = True
children = []
@@ -930,41 +961,52 @@ class SplitMongoModuleStore(ModuleStoreBase):
children.append(child)
is_updated = is_updated or updated_blocks
- metadata = xblock.xblock_kvs.get_own_metadata()
+ block_fields = xblock.get_explicitly_set_fields_by_scope(Scope.settings)
if not is_new and not is_updated:
- is_updated = self._compare_metadata(metadata, structure_blocks[usage_id]['metadata'])
+ is_updated = self._compare_settings(block_fields, structure_blocks[usage_id]['fields'])
+ if children:
+ block_fields['children'] = children
if is_updated:
+ previous_version = None if is_new else structure_blocks[usage_id]['edit_info'].get('update_version')
structure_blocks[usage_id] = {
- "children": children,
"category": xblock.category,
"definition": xblock.definition_locator.definition_id,
- "metadata": metadata if metadata else {},
- 'previous_version': structure_blocks.get(usage_id, {}).get('update_version'),
- 'edited_by': user_id,
- 'edited_on': datetime.datetime.utcnow()
+ "fields": block_fields,
+ 'edit_info': {
+ 'previous_version': previous_version,
+ 'edited_by': user_id,
+ 'edited_on': datetime.datetime.now(UTC)
+ }
}
updated_blocks.append(usage_id)
return updated_blocks
- def _compare_metadata(self, metadata, original_metadata):
- original_keys = original_metadata.keys()
- if len(metadata) != len(original_keys):
+ def _compare_settings(self, settings, original_fields):
+ """
+ Return True if the settings are not == to the original fields
+ :param settings:
+ :param original_fields:
+ """
+ original_keys = original_fields.keys()
+ if 'children' in original_keys:
+ original_keys.remove('children')
+ if len(settings) != len(original_keys):
return True
else:
- new_keys = metadata.keys()
+ new_keys = settings.keys()
for key in original_keys:
- if key not in new_keys or original_metadata[key] != metadata[key]:
+ if key not in new_keys or original_fields[key] != settings[key]:
return True
- # TODO change all callers to update_item
- def update_children(self, course_id, location, children):
- raise NotImplementedError()
+ def update_children(self, location, children):
+ '''Deprecated, use update_item.'''
+ raise NotImplementedError('use update_item')
- # TODO change all callers to update_item
- def update_metadata(self, course_id, location, metadata):
- raise NotImplementedError()
+ def update_metadata(self, location, metadata):
+ '''Deprecated, use update_item.'''
+ raise NotImplementedError('use update_item')
def update_course_index(self, course_locator, new_values_dict, update_versions=False):
"""
@@ -992,9 +1034,9 @@ class SplitMongoModuleStore(ModuleStoreBase):
self.course_index.update({'_id': course_locator.course_id},
{'$set': new_values_dict})
- def delete_item(self, usage_locator, user_id, force=False):
+ def delete_item(self, usage_locator, user_id, delete_children=False, force=False):
"""
- Delete the tree rooted at block and any references w/in the course to the block
+ Delete the block or tree rooted at block (if delete_children) and any references w/in the course to the block
from a new version of the course structure.
returns CourseLocator for new version
@@ -1018,17 +1060,18 @@ class SplitMongoModuleStore(ModuleStoreBase):
update_version_keys = []
for parent in parents:
parent_block = new_blocks[parent.usage_id]
- parent_block['children'].remove(usage_locator.usage_id)
- parent_block['edited_on'] = datetime.datetime.utcnow()
- parent_block['edited_by'] = user_id
- parent_block['previous_version'] = parent_block['update_version']
- update_version_keys.append('blocks.{}.update_version'.format(parent.usage_id))
+ parent_block['fields']['children'].remove(usage_locator.usage_id)
+ parent_block['edit_info']['edited_on'] = datetime.datetime.now(UTC)
+ parent_block['edit_info']['edited_by'] = user_id
+ parent_block['edit_info']['previous_version'] = parent_block['edit_info']['update_version']
+ update_version_keys.append('blocks.{}.edit_info.update_version'.format(parent.usage_id))
# remove subtree
def remove_subtree(usage_id):
- for child in new_blocks[usage_id]['children']:
+ for child in new_blocks[usage_id]['fields'].get('children', []):
remove_subtree(child)
del new_blocks[usage_id]
- remove_subtree(usage_locator.usage_id)
+ if delete_children:
+ remove_subtree(usage_locator.usage_id)
# update index if appropriate and structures
new_id = self.structures.insert(new_structure)
@@ -1062,32 +1105,38 @@ class SplitMongoModuleStore(ModuleStoreBase):
# this is the only real delete in the system. should it do something else?
self.course_index.remove(index['_id'])
- def inherit_metadata(self, block_map, block, inheriting_metadata=None):
+ def get_errored_courses(self):
"""
- Updates block with any value
- that exist in inheriting_metadata and don't appear in block['metadata'],
- and then inherits block['metadata'] to all of the children in
- block['children']. Filters by inheritance.INHERITABLE_METADATA
+ This function doesn't make sense for the mongo modulestore, as structures
+ are loaded on demand, rather than up front
+ """
+ return {}
+
+ def inherit_settings(self, block_map, block, inheriting_settings=None):
+ """
+ Updates block with any inheritable setting set by an ancestor and recurses to children.
"""
if block is None:
return
- if inheriting_metadata is None:
- inheriting_metadata = {}
+ if inheriting_settings is None:
+ inheriting_settings = {}
# the currently passed down values take precedence over any previously cached ones
# NOTE: this should show the values which all fields would have if inherited: i.e.,
# not set to the locally defined value but to value set by nearest ancestor who sets it
- block.setdefault('_inherited_metadata', {}).update(inheriting_metadata)
+ # ALSO NOTE: no xblock should ever define a _inherited_settings field as it will collide w/ this logic.
+ block.setdefault('_inherited_settings', {}).update(inheriting_settings)
# update the inheriting w/ what should pass to children
- inheriting_metadata = block['_inherited_metadata'].copy()
+ inheriting_settings = block['_inherited_settings'].copy()
+ block_fields = block['fields']
for field in inheritance.INHERITABLE_METADATA:
- if field in block['metadata']:
- inheriting_metadata[field] = block['metadata'][field]
+ if field in block_fields:
+ inheriting_settings[field] = block_fields[field]
- for child in block.get('children', []):
- self.inherit_metadata(block_map, block_map[child], inheriting_metadata)
+ for child in block_fields.get('children', []):
+ self.inherit_settings(block_map, block_map[child], inheriting_settings)
def descendants(self, block_map, usage_id, depth, descendent_map):
"""
@@ -1104,7 +1153,7 @@ class SplitMongoModuleStore(ModuleStoreBase):
if depth is None or depth > 0:
depth = depth - 1 if depth is not None else None
- for child in block_map[usage_id].get('children', []):
+ for child in block_map[usage_id]['fields'].get('children', []):
descendent_map = self.descendants(block_map, child, depth,
descendent_map)
@@ -1217,7 +1266,7 @@ class SplitMongoModuleStore(ModuleStoreBase):
del new_structure['_id']
new_structure['previous_version'] = structure['_id']
new_structure['edited_by'] = user_id
- new_structure['edited_on'] = datetime.datetime.utcnow()
+ new_structure['edited_on'] = datetime.datetime.now(UTC)
return new_structure
def _find_local_root(self, element_to_find, possibility, tree):
@@ -1242,3 +1291,31 @@ class SplitMongoModuleStore(ModuleStoreBase):
self.course_index.update(
{"_id": index_entry["_id"]},
{"$set": {"versions.{}".format(branch): new_id}})
+
+ def _partition_fields_by_scope(self, category, fields):
+ """
+ Return dictionary of {scope: {field1: val, ..}..} for the fields of this potential xblock
+
+ :param category: the xblock category
+ :param fields: the dictionary of {fieldname: value}
+ """
+ if fields is None:
+ return {}
+ cls = XModuleDescriptor.load_class(category)
+ result = collections.defaultdict(dict)
+ for field_name, value in fields.iteritems():
+ field = getattr(cls, field_name)
+ result[field.scope][field_name] = value
+ return result
+
+ def _filter_special_fields(self, fields):
+ """
+ Remove any fields which split or its kvs computes or adds but does not want persisted.
+
+ :param fields: a dict of fields
+ """
+ if 'location' in fields:
+ del fields['location']
+ if 'category' in fields:
+ del fields['category']
+ return fields
diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_mongo_kvs.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_mongo_kvs.py
index 843c1ce364..0ffc111586 100644
--- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_mongo_kvs.py
+++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_mongo_kvs.py
@@ -8,45 +8,49 @@ from .definition_lazy_loader import DefinitionLazyLoader
SplitMongoKVSid = namedtuple('SplitMongoKVSid', 'id, def_id')
-# TODO should this be here or w/ x_module or ???
+PROVENANCE_LOCAL = 'local'
+PROVENANCE_DEFAULT = 'default'
+PROVENANCE_INHERITED = 'inherited'
+
class SplitMongoKVS(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, definition, children, metadata, _inherited_metadata, location, category):
+
+ def __init__(self, definition, fields, _inherited_settings, location, category):
"""
- :param definition:
- :param children:
- :param metadata: the locally defined value for each metadata field
- :param _inherited_metadata: the value of each inheritable field from above this.
- Note, metadata may override and disagree w/ this b/c this says what the value
- should be if metadata is undefined for this field.
+ :param definition: either a lazyloader or definition id for the definition
+ :param fields: a dictionary of the locally set fields
+ :param _inherited_settings: the value of each inheritable field from above this.
+ Note, local fields may override and disagree w/ this b/c this says what the value
+ should be if the field is undefined.
"""
# ensure kvs's don't share objects w/ others so that changes can't appear in separate ones
# the particular use case was that changes to kvs's were polluting caches. My thinking was
# that kvs's should be independent thus responsible for the isolation.
- if isinstance(definition, DefinitionLazyLoader):
- self._definition = definition
- else:
- self._definition = copy.copy(definition)
- self._children = copy.copy(children)
- self._metadata = copy.copy(metadata)
- self._inherited_metadata = _inherited_metadata
+ self._definition = definition # either a DefinitionLazyLoader or the db id of the definition.
+ # if the db id, then the definition is presumed to be loaded into _fields
+ self._fields = copy.copy(fields)
+ self._inherited_settings = _inherited_settings
self._location = location
self._category = category
def get(self, key):
- if key.scope == Scope.children:
- return self._children
- elif key.scope == Scope.parent:
+ # simplest case, field is directly set
+ if key.field_name in self._fields:
+ return self._fields[key.field_name]
+
+ # parent undefined in editing runtime (I think)
+ if key.scope == Scope.parent:
return None
+ if key.scope == Scope.children:
+ raise KeyError()
elif key.scope == Scope.settings:
- if key.field_name in self._metadata:
- return self._metadata[key.field_name]
- elif key.field_name in self._inherited_metadata:
- return self._inherited_metadata[key.field_name]
+ # get from inheritance since not locally set
+ if key.field_name in self._inherited_settings:
+ return self._inherited_settings[key.field_name]
else:
raise KeyError()
elif key.scope == Scope.content:
@@ -54,110 +58,118 @@ class SplitMongoKVS(KeyValueStore):
return self._location
elif key.field_name == 'category':
return self._category
- else:
- if isinstance(self._definition, DefinitionLazyLoader):
- self._definition = self._definition.fetch()
- if (key.field_name == 'data' and
- not isinstance(self._definition.get('data'), dict)):
- return self._definition.get('data')
- elif 'data' not in self._definition or key.field_name not in self._definition['data']:
- raise KeyError()
- else:
- return self._definition['data'][key.field_name]
+ elif isinstance(self._definition, DefinitionLazyLoader):
+ self._load_definition()
+ if key.field_name in self._fields:
+ return self._fields[key.field_name]
+
+ raise KeyError()
else:
raise InvalidScopeError(key.scope)
def set(self, key, value):
- # TODO cache db update implications & add method to invoke
- if key.scope == Scope.children:
- self._children = value
- # TODO remove inheritance from any orphaned exchildren
- # TODO add inheritance to any new children
- elif key.scope == Scope.settings:
- # TODO if inheritable, push down to children who don't override
- self._metadata[key.field_name] = value
- elif key.scope == Scope.content:
- if key.field_name == 'location':
- self._location = value
- elif key.field_name == 'category':
- self._category = value
- else:
- if isinstance(self._definition, DefinitionLazyLoader):
- self._definition = self._definition.fetch()
- if (key.field_name == 'data' and
- not isinstance(self._definition.get('data'), dict)):
- self._definition.get('data')
- else:
- self._definition.setdefault('data', {})[key.field_name] = value
- else:
+ # handle any special cases
+ if key.scope not in [Scope.children, Scope.settings, Scope.content]:
raise InvalidScopeError(key.scope)
+ if key.scope == Scope.content:
+ if key.field_name == 'location':
+ self._location = value # is changing this legal?
+ return
+ elif key.field_name == 'category':
+ # TODO should this raise an exception? that is, should xblock types be mungable?
+ return
+ else:
+ self._load_definition()
+
+ # set the field
+ self._fields[key.field_name] = value
+
+ # handle any side effects
+ # if key.scope == Scope.children:
+ # TODO remove inheritance from any exchildren
+ # TODO add inheritance to any new children
+ # if key.scope == Scope.settings:
+ # TODO if inheritable, push down to children
def delete(self, key):
- # TODO cache db update implications & add method to invoke
- if key.scope == Scope.children:
- self._children = []
- elif key.scope == Scope.settings:
- # TODO if inheritable, ensure _inherited_metadata has value from above and
- # revert children to that value
- if key.field_name in self._metadata:
- del self._metadata[key.field_name]
- elif key.scope == Scope.content:
- # don't allow deletion of location nor category
- if key.field_name == 'location':
- pass
- elif key.field_name == 'category':
- pass
- else:
- if isinstance(self._definition, DefinitionLazyLoader):
- self._definition = self._definition.fetch()
- if (key.field_name == 'data' and
- not isinstance(self._definition.get('data'), dict)):
- self._definition.setdefault('data', None)
- else:
- try:
- del self._definition['data'][key.field_name]
- except KeyError:
- pass
- else:
+ # handle any special cases
+ if key.scope not in [Scope.children, Scope.settings, Scope.content]:
raise InvalidScopeError(key.scope)
+ if key.scope == Scope.content:
+ if key.field_name == 'location':
+ return # noop
+ elif key.field_name == 'category':
+ # TODO should this raise an exception? that is, should xblock types be mungable?
+ return # noop
+ else:
+ self._load_definition()
+
+ # delete the field value
+ if key.field_name in self._fields:
+ del self._fields[key.field_name]
+
+ # handle any side effects
+ # if key.scope == Scope.children:
+ # TODO remove inheritance from any exchildren
+ # if key.scope == Scope.settings:
+ # TODO if inheritable, push down _inherited_settings value to children
def has(self, key):
- if key.scope in (Scope.children, Scope.parent):
- return True
- elif key.scope == Scope.settings:
- return key.field_name in self._metadata or key.field_name in self._inherited_metadata
- elif key.scope == Scope.content:
+ # handle any special cases
+ if key.scope == Scope.content:
if key.field_name == 'location':
return True
elif key.field_name == 'category':
return self._category is not None
else:
- if isinstance(self._definition, DefinitionLazyLoader):
- self._definition = self._definition.fetch()
- if (key.field_name == 'data' and
- not isinstance(self._definition.get('data'), dict)):
- return self._definition.get('data') is not None
+ self._load_definition()
+ elif key.scope == Scope.parent:
+ return True
+
+ # it's not clear whether inherited values should return True. Right now they don't
+ # if someone changes it so that they do, then change any tests of field.name in xx._model_data
+ return key.field_name in self._fields
+
+ # would like to just take a key, but there's a bunch of magic in DbModel for constructing the key via
+ # a private method
+ def field_value_provenance(self, key_scope, key_name):
+ """
+ Where the field value comes from: one of [PROVENANCE_LOCAL, PROVENANCE_DEFAULT, PROVENANCE_INHERITED].
+ """
+ # handle any special cases
+ if key_scope == Scope.content:
+ if key_name == 'location':
+ return PROVENANCE_LOCAL
+ elif key_name == 'category':
+ return PROVENANCE_LOCAL
+ else:
+ self._load_definition()
+ if key_name in self._fields:
+ return PROVENANCE_LOCAL
else:
- return key.field_name in self._definition.get('data', {})
+ return PROVENANCE_DEFAULT
+ elif key_scope == Scope.parent:
+ return PROVENANCE_DEFAULT
+ elif key_name in self._fields:
+ return PROVENANCE_LOCAL
+ elif key_scope == Scope.settings and key_name in self._inherited_settings:
+ return PROVENANCE_INHERITED
else:
- return False
+ return PROVENANCE_DEFAULT
- def get_data(self):
- """
- Intended only for use by persistence layer to get the native definition['data'] rep
- """
- if isinstance(self._definition, DefinitionLazyLoader):
- self._definition = self._definition.fetch()
- return self._definition.get('data')
-
- def get_own_metadata(self):
- """
- Get the metadata explicitly set on this element.
- """
- return self._metadata
-
- def get_inherited_metadata(self):
+ def get_inherited_settings(self):
"""
Get the metadata set by the ancestors (which own metadata may override or not)
"""
- return self._inherited_metadata
+ return self._inherited_settings
+
+ def _load_definition(self):
+ """
+ Update fields w/ the lazily loaded definitions
+ """
+ if isinstance(self._definition, DefinitionLazyLoader):
+ persisted_definition = self._definition.fetch()
+ if persisted_definition is not None:
+ self._fields.update(persisted_definition.get('fields'))
+ # do we want to cache any of the edit_info?
+ self._definition = None # already loaded
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/persistent_factories.py b/common/lib/xmodule/xmodule/modulestore/tests/persistent_factories.py
index 5e46f5a318..4675c6a50c 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/persistent_factories.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/persistent_factories.py
@@ -16,8 +16,7 @@ class PersistentCourseFactory(factory.Factory):
* prettyid: defaults to 999
* display_name
* user_id
- * data (optional) the data payload to save in the course item
- * metadata (optional) the metadata payload. If display_name is in the metadata, that takes
+ * fields (optional) the settings and content payloads. If display_name is in the metadata, that takes
precedence over any display_name provided directly.
"""
FACTORY_FOR = CourseDescriptor
@@ -28,7 +27,7 @@ class PersistentCourseFactory(factory.Factory):
user_id = "test_user"
data = None
metadata = None
- master_version = 'draft'
+ master_branch = 'draft'
# pylint: disable=W0613
@classmethod
@@ -38,17 +37,14 @@ class PersistentCourseFactory(factory.Factory):
prettyid = kwargs.get('prettyid')
display_name = kwargs.get('display_name')
user_id = kwargs.get('user_id')
- data = kwargs.get('data')
- metadata = kwargs.get('metadata', {})
- if metadata is None:
- metadata = {}
- if 'display_name' not in metadata:
- metadata['display_name'] = display_name
+ fields = kwargs.get('fields', {})
+ if display_name and 'display_name' not in fields:
+ fields['display_name'] = display_name
# Write the data to the mongo datastore
new_course = modulestore('split').create_course(
- org, prettyid, user_id, metadata=metadata, course_data=data, id_root=prettyid,
- master_version=kwargs.get('master_version'))
+ org, prettyid, user_id, fields=fields, id_root=prettyid,
+ master_branch=kwargs.get('master_branch'))
return new_course
@@ -70,26 +66,23 @@ class ItemFactory(factory.Factory):
"""
Uses *kwargs*:
- *parent_location* (required): the location of the course & possibly parent
+ :param parent_location: (required) the location of the course & possibly parent
- *category* (defaults to 'chapter')
+ :param category: (defaults to 'chapter')
- *data* (optional): the data for the item
+ :param fields: (optional) the data for the item
- definition_locator (optional): the DescriptorLocator for the definition this uses or branches
+ :param definition_locator (optional): the DescriptorLocator for the definition this uses or branches
- *display_name* (optional): the display name of the item
-
- *metadata* (optional): dictionary of metadata attributes (display_name here takes
- precedence over the above attr)
+ :param display_name (optional): the display name of the item
"""
- metadata = kwargs.get('metadata', {})
- if 'display_name' not in metadata and 'display_name' in kwargs:
- metadata['display_name'] = kwargs['display_name']
+ fields = kwargs.get('fields', {})
+ if 'display_name' not in fields and 'display_name' in kwargs:
+ fields['display_name'] = kwargs['display_name']
return modulestore('split').create_item(kwargs['parent_location'], kwargs['category'],
kwargs['user_id'], definition_locator=kwargs.get('definition_locator'),
- new_def_data=kwargs.get('data'), metadata=metadata)
+ fields=fields)
@classmethod
def _build(cls, target_class, *args, **kwargs):
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
index 9976a33a00..36130be8d4 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
@@ -187,6 +187,7 @@ class SplitModuleCourseTests(SplitModuleTest):
self.assertEqual(course.category, 'course')
self.assertEqual(len(course.tabs), 6)
self.assertEqual(course.display_name, "The Ancient Greek Hero")
+ self.assertEqual(course.lms.graceperiod, datetime.timedelta(hours=2))
self.assertIsNone(course.advertised_start)
self.assertEqual(len(course.children), 0)
self.assertEqual(course.definition_locator.definition_id, "head12345_11")
@@ -438,12 +439,12 @@ class SplitModuleItemTests(SplitModuleTest):
qualifiers=
{
'category': 'chapter',
- 'metadata': {'display_name': {'$regex': 'Hera'}}
+ 'fields': {'display_name': {'$regex': 'Hera'}}
}
)
self.assertEqual(len(matches), 2)
- matches = modulestore().get_items(locator, qualifiers={'children': 'chapter2'})
+ matches = modulestore().get_items(locator, qualifiers={'fields': {'children': 'chapter2'}})
self.assertEqual(len(matches), 1)
self.assertEqual(matches[0].location.usage_id, 'head12345')
@@ -507,8 +508,7 @@ class TestItemCrud(SplitModuleTest):
def test_create_minimal_item(self):
"""
- create_item(course_or_parent_locator, category, user, definition_locator=None, new_def_data=None,
- metadata=None): new_desciptor
+ create_item(course_or_parent_locator, category, user, definition_locator=None, fields): new_desciptor
"""
# grab link to course to ensure new versioning works
locator = CourseLocator(course_id="GreekHero", branch='draft')
@@ -518,7 +518,7 @@ class TestItemCrud(SplitModuleTest):
category = 'sequential'
new_module = modulestore().create_item(
locator, category, 'user123',
- metadata={'display_name': 'new sequential'}
+ fields={'display_name': 'new sequential'}
)
# check that course version changed and course's previous is the other one
self.assertEqual(new_module.location.course_id, "GreekHero")
@@ -553,7 +553,7 @@ class TestItemCrud(SplitModuleTest):
category = 'chapter'
new_module = modulestore().create_item(
locator, category, 'user123',
- metadata={'display_name': 'new chapter'},
+ fields={'display_name': 'new chapter'},
definition_locator=DescriptionLocator("chapter12345_2")
)
# check that course version changed and course's previous is the other one
@@ -574,15 +574,13 @@ class TestItemCrud(SplitModuleTest):
new_payload = "empty"
new_module = modulestore().create_item(
locator, category, 'anotheruser',
- metadata={'display_name': 'problem 1'},
- new_def_data=new_payload
+ fields={'display_name': 'problem 1', 'data': new_payload},
)
another_payload = "not empty"
another_module = modulestore().create_item(
locator, category, 'anotheruser',
- metadata={'display_name': 'problem 2'},
+ fields={'display_name': 'problem 2', 'data': another_payload},
definition_locator=DescriptionLocator("problem12345_3_1"),
- new_def_data=another_payload
)
# check that course version changed and course's previous is the other one
parent = modulestore().get_item(locator)
@@ -616,6 +614,7 @@ class TestItemCrud(SplitModuleTest):
self.assertNotEqual(problem.max_attempts, 4, "Invalidates rest of test")
problem.max_attempts = 4
+ problem.save() # decache above setting into the kvs
updated_problem = modulestore().update_item(problem, 'changeMaven')
# check that course version changed and course's previous is the other one
self.assertEqual(updated_problem.definition_locator.definition_id, pre_def_id)
@@ -651,6 +650,7 @@ class TestItemCrud(SplitModuleTest):
# reorder children
self.assertGreater(len(block.children), 0, "meaningless test")
moved_child = block.children.pop()
+ block.save() # decache model changes
updated_problem = modulestore().update_item(block, 'childchanger')
# check that course version changed and course's previous is the other one
self.assertEqual(updated_problem.definition_locator.definition_id, pre_def_id)
@@ -660,6 +660,7 @@ class TestItemCrud(SplitModuleTest):
locator.usage_id = "chapter1"
other_block = modulestore().get_item(locator)
other_block.children.append(moved_child)
+ other_block.save() # decache model changes
other_updated = modulestore().update_item(other_block, 'childchanger')
self.assertIn(moved_child, other_updated.children)
@@ -673,6 +674,7 @@ class TestItemCrud(SplitModuleTest):
pre_version_guid = block.location.version_guid
block.grading_policy['GRADER'][0]['min_count'] = 13
+ block.save() # decache model changes
updated_block = modulestore().update_item(block, 'definition_changer')
self.assertNotEqual(updated_block.definition_locator.definition_id, pre_def_id)
@@ -689,15 +691,13 @@ class TestItemCrud(SplitModuleTest):
new_payload = "empty"
modulestore().create_item(
locator, category, 'test_update_manifold',
- metadata={'display_name': 'problem 1'},
- new_def_data=new_payload
+ fields={'display_name': 'problem 1', 'data': new_payload},
)
another_payload = "not empty"
modulestore().create_item(
locator, category, 'test_update_manifold',
- metadata={'display_name': 'problem 2'},
+ fields={'display_name': 'problem 2', 'data': another_payload},
definition_locator=DescriptionLocator("problem12345_3_1"),
- new_def_data=another_payload
)
# pylint: disable=W0212
modulestore()._clear_cache()
@@ -712,6 +712,7 @@ class TestItemCrud(SplitModuleTest):
block.children = block.children[1:] + [block.children[0]]
block.advertised_start = "Soon"
+ block.save() # decache model changes
updated_block = modulestore().update_item(block, "test_update_manifold")
self.assertNotEqual(updated_block.definition_locator.definition_id, pre_def_id)
self.assertNotEqual(updated_block.location.version_guid, pre_version_guid)
@@ -733,7 +734,7 @@ class TestItemCrud(SplitModuleTest):
# delete a leaf
problems = modulestore().get_items(reusable_location, {'category': 'problem'})
locn_to_del = problems[0].location
- new_course_loc = modulestore().delete_item(locn_to_del, 'deleting_user')
+ new_course_loc = modulestore().delete_item(locn_to_del, 'deleting_user', delete_children=True)
deleted = BlockUsageLocator(course_id=reusable_location.course_id,
branch=reusable_location.branch,
usage_id=locn_to_del.usage_id)
@@ -748,7 +749,7 @@ class TestItemCrud(SplitModuleTest):
# delete a subtree
nodes = modulestore().get_items(reusable_location, {'category': 'chapter'})
- new_course_loc = modulestore().delete_item(nodes[0].location, 'deleting_user')
+ new_course_loc = modulestore().delete_item(nodes[0].location, 'deleting_user', delete_children=True)
# check subtree
def check_subtree(node):
@@ -855,7 +856,7 @@ class TestCourseCreation(SplitModuleTest):
# using new_draft.location will insert the chapter under the course root
new_item = modulestore().create_item(
new_draft.location, 'chapter', 'leech_master',
- metadata={'display_name': 'new chapter'}
+ fields={'display_name': 'new chapter'}
)
new_draft_locator.version_guid = None
new_index = modulestore().get_course_index_info(new_draft_locator)
@@ -887,20 +888,18 @@ class TestCourseCreation(SplitModuleTest):
original_locator = CourseLocator(course_id="contender", branch='draft')
original = modulestore().get_course(original_locator)
original_index = modulestore().get_course_index_info(original_locator)
- data_payload = {}
- metadata_payload = {}
+ fields = {}
for field in original.fields:
if field.scope == Scope.content and field.name != 'location':
- data_payload[field.name] = getattr(original, field.name)
+ fields[field.name] = getattr(original, field.name)
elif field.scope == Scope.settings:
- metadata_payload[field.name] = getattr(original, field.name)
- data_payload['grading_policy']['GRADE_CUTOFFS'] = {'A': .9, 'B': .8, 'C': .65}
- metadata_payload['display_name'] = 'Derivative'
+ fields[field.name] = getattr(original, field.name)
+ fields['grading_policy']['GRADE_CUTOFFS'] = {'A': .9, 'B': .8, 'C': .65}
+ fields['display_name'] = 'Derivative'
new_draft = modulestore().create_course(
'leech', 'derivative', 'leech_master', id_root='counter',
versions_dict={'draft': original_index['versions']['draft']},
- course_data=data_payload,
- metadata=metadata_payload
+ fields=fields
)
new_draft_locator = new_draft.location
self.assertRegexpMatches(new_draft_locator.course_id, r'counter.*')
@@ -913,10 +912,10 @@ class TestCourseCreation(SplitModuleTest):
self.assertGreaterEqual(new_index["edited_on"], pre_time)
self.assertLessEqual(new_index["edited_on"], datetime.datetime.now(UTC))
self.assertEqual(new_index['edited_by'], 'leech_master')
- self.assertEqual(new_draft.display_name, metadata_payload['display_name'])
+ self.assertEqual(new_draft.display_name, fields['display_name'])
self.assertDictEqual(
new_draft.grading_policy['GRADE_CUTOFFS'],
- data_payload['grading_policy']['GRADE_CUTOFFS']
+ fields['grading_policy']['GRADE_CUTOFFS']
)
def test_update_course_index(self):
diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py
index 637eb9f17c..c4deaa8925 100644
--- a/common/lib/xmodule/xmodule/x_module.py
+++ b/common/lib/xmodule/xmodule/x_module.py
@@ -587,33 +587,20 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock):
Creates an instance of this descriptor from the supplied json_data.
This may be overridden by subclasses
- json_data: A json object with the keys 'definition' and 'metadata',
- definition: A json object with the keys 'data' and 'children'
- data: A json value
- children: A list of edX Location urls
- metadata: A json object with any keys
-
- This json_data is transformed to model_data using the following rules:
- 1) The model data contains all of the fields from metadata
- 2) The model data contains the 'children' array
- 3) If 'definition.data' is a json object, model data contains all of its fields
- Otherwise, it contains the single field 'data'
- 4) Any value later in this list overrides a value earlier in this list
-
json_data:
- 'category': the xmodule category (required)
- - 'metadata': a dict of locally set metadata (not inherited)
- - 'children': a list of children's usage_ids w/in this course
- - 'definition':
+ - 'fields': a dict of locally set fields (not inherited)
+ - 'definition': (optional) the db id for the definition record (not the definition content) or a
+ definitionLazyLoader
- '_id' (optional): the usage_id of this. Will generate one if not given one.
"""
usage_id = json_data.get('_id', None)
- if not '_inherited_metadata' in json_data and parent_xblock is not None:
- json_data['_inherited_metadata'] = parent_xblock.xblock_kvs.get_inherited_metadata().copy()
- json_metadata = json_data.get('metadata', {})
+ if not '_inherited_settings' in json_data and parent_xblock is not None:
+ json_data['_inherited_settings'] = parent_xblock.xblock_kvs.get_inherited_settings().copy()
+ json_fields = json_data.get('fields', {})
for field in inheritance.INHERITABLE_METADATA:
- if field in json_metadata:
- json_data['_inherited_metadata'][field] = json_metadata[field]
+ if field in json_fields:
+ json_data['_inherited_settings'][field] = json_fields[field]
new_block = system.xblock_from_json(cls, usage_id, json_data)
if parent_xblock is not None:
@@ -736,6 +723,27 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock):
# We are not allowing editing of xblock tag and name fields at this time (for any component).
return [XBlock.tags, XBlock.name]
+ def get_set_fields_by_scope(self, scope=Scope.content):
+ """
+ Get a dictionary of the fields for the given scope which are set explicitly on this xblock. (Including
+ any set to None.)
+ """
+ if scope == Scope.settings and hasattr(self, '_inherited_metadata'):
+ inherited_metadata = getattr(self, '_inherited_metadata')
+ result = {}
+ for field in self.fields:
+ if (field.scope == scope and
+ field.name in self._model_data and
+ field.name not in inherited_metadata):
+ result[field.name] = getattr(self, field.name)
+ return result
+ else:
+ result = {}
+ for field in self.fields:
+ if (field.scope == scope and field.name in self._model_data):
+ result[field.name] = getattr(self, field.name)
+ return result
+
@property
def editable_metadata_fields(self):
"""
diff --git a/common/test/data/splitmongo_json/definitions.json b/common/test/data/splitmongo_json/definitions.json
index 0ed42112aa..433afbe8ac 100644
--- a/common/test/data/splitmongo_json/definitions.json
+++ b/common/test/data/splitmongo_json/definitions.json
@@ -2,7 +2,7 @@
{
"_id":"head12345_12",
"category":"course",
- "data":{
+ "fields":{
"textbooks":[
],
@@ -43,15 +43,17 @@
},
"wiki_slug":null
},
- "edited_by":"testassist@edx.org",
- "edited_on":{"$date" : 1364481713238},
- "previous_version":"head12345_11",
- "original_version":"head12345_10"
+ "edit_info": {
+ "edited_by":"testassist@edx.org",
+ "edited_on":{"$date" : 1364481713238},
+ "previous_version":"head12345_11",
+ "original_version":"head12345_10"
+ }
},
{
"_id":"head12345_11",
"category":"course",
- "data":{
+ "fields":{
"textbooks":[
],
@@ -92,15 +94,17 @@
},
"wiki_slug":null
},
- "edited_by":"testassist@edx.org",
- "edited_on":{"$date" : 1364481713238},
- "previous_version":"head12345_10",
- "original_version":"head12345_10"
+ "edit_info": {
+ "edited_by":"testassist@edx.org",
+ "edited_on":{"$date" : 1364481713238},
+ "previous_version":"head12345_10",
+ "original_version":"head12345_10"
+ }
},
{
"_id":"head12345_10",
"category":"course",
- "data":{
+ "fields":{
"textbooks":[
],
@@ -141,15 +145,17 @@
},
"wiki_slug":null
},
- "edited_by":"test@edx.org",
- "edited_on":{"$date": 1364473713238},
- "previous_version":null,
- "original_version":"head12345_10"
+ "edit_info": {
+ "edited_by":"test@edx.org",
+ "edited_on":{"$date": 1364473713238},
+ "previous_version":null,
+ "original_version":"head12345_10"
+ }
},
{
"_id":"head23456_1",
"category":"course",
- "data":{
+ "fields":{
"textbooks":[
],
@@ -190,15 +196,17 @@
},
"wiki_slug":null
},
- "edited_by":"test@edx.org",
- "edited_on":{"$date": 1364481313238},
- "previous_version":"head23456_0",
- "original_version":"head23456_0"
+ "edit_info": {
+ "edited_by":"test@edx.org",
+ "edited_on":{"$date": 1364481313238},
+ "previous_version":"head23456_0",
+ "original_version":"head23456_0"
+ }
},
{
"_id":"head23456_0",
"category":"course",
- "data":{
+ "fields":{
"textbooks":[
],
@@ -239,15 +247,17 @@
},
"wiki_slug":null
},
- "edited_by":"test@edx.org",
- "edited_on":{"$date" : 1364481313238},
- "previous_version":null,
- "original_version":"head23456_0"
+ "edit_info": {
+ "edited_by":"test@edx.org",
+ "edited_on":{"$date" : 1364481313238},
+ "previous_version":null,
+ "original_version":"head23456_0"
+ }
},
{
"_id":"head345679_1",
"category":"course",
- "data":{
+ "fields":{
"textbooks":[
],
@@ -281,54 +291,66 @@
},
"wiki_slug":null
},
- "edited_by":"test@edx.org",
- "edited_on":{"$date" : 1364481313238},
- "previous_version":null,
- "original_version":"head23456_0"
+ "edit_info": {
+ "edited_by":"test@edx.org",
+ "edited_on":{"$date" : 1364481313238},
+ "previous_version":null,
+ "original_version":"head23456_0"
+ }
},
{
"_id":"chapter12345_1",
"category":"chapter",
- "data":null,
- "edited_by":"testassist@edx.org",
- "edited_on":{"$date" : 1364483713238},
- "previous_version":null,
- "original_version":"chapter12345_1"
+ "fields":{},
+ "edit_info": {
+ "edited_by":"testassist@edx.org",
+ "edited_on":{"$date" : 1364483713238},
+ "previous_version":null,
+ "original_version":"chapter12345_1"
+ }
},
{
"_id":"chapter12345_2",
"category":"chapter",
- "data":null,
- "edited_by":"testassist@edx.org",
- "edited_on":{"$date" : 1364483713238},
- "previous_version":null,
- "original_version":"chapter12345_2"
+ "fields":{},
+ "edit_info": {
+ "edited_by":"testassist@edx.org",
+ "edited_on":{"$date" : 1364483713238},
+ "previous_version":null,
+ "original_version":"chapter12345_2"
+ }
},
{
"_id":"chapter12345_3",
"category":"chapter",
- "data":null,
- "edited_by":"testassist@edx.org",
- "edited_on":{"$date" : 1364483713238},
- "previous_version":null,
- "original_version":"chapter12345_3"
+ "fields":{},
+ "edit_info": {
+ "edited_by":"testassist@edx.org",
+ "edited_on":{"$date" : 1364483713238},
+ "previous_version":null,
+ "original_version":"chapter12345_3"
+ }
},
{
"_id":"problem12345_3_1",
"category":"problem",
- "data":"",
- "edited_by":"testassist@edx.org",
- "edited_on":{"$date" : 1364483713238},
- "previous_version":null,
- "original_version":"problem12345_3_1"
+ "fields": {"data": ""},
+ "edit_info": {
+ "edited_by":"testassist@edx.org",
+ "edited_on":{"$date" : 1364483713238},
+ "previous_version":null,
+ "original_version":"problem12345_3_1"
+ }
},
{
"_id":"problem12345_3_2",
"category":"problem",
- "data":"",
- "edited_by":"testassist@edx.org",
- "edited_on":{"$date" : 1364483713238},
- "previous_version":null,
- "original_version":"problem12345_3_2"
+ "fields": {"data": ""},
+ "edit_info": {
+ "edited_by":"testassist@edx.org",
+ "edited_on":{"$date" : 1364483713238},
+ "previous_version":null,
+ "original_version":"problem12345_3_2"
+ }
}
]
\ No newline at end of file
diff --git a/common/test/data/splitmongo_json/structures.json b/common/test/data/splitmongo_json/structures.json
index 0021225213..b72c0fd7a5 100644
--- a/common/test/data/splitmongo_json/structures.json
+++ b/common/test/data/splitmongo_json/structures.json
@@ -10,14 +10,14 @@
},
"blocks":{
"head12345":{
- "children":[
- "chapter1",
- "chapter2",
- "chapter3"
- ],
"category":"course",
"definition":"head12345_12",
- "metadata":{
+ "fields":{
+ "children":[
+ "chapter1",
+ "chapter2",
+ "chapter3"
+ ],
"end":"2013-06-13T04:30",
"tabs":[
{
@@ -54,93 +54,105 @@
"advertised_start":"Fall 2013",
"display_name":"The Ancient Greek Hero"
},
- "update_version":{ "$oid" : "1d00000000000000dddd0000" },
- "previous_version":{ "$oid" : "1d00000000000000dddd1111" },
- "edited_by":"testassist@edx.org",
- "edited_on":{
- "$date":1364483713238
+ "edit_info": {
+ "update_version":{ "$oid" : "1d00000000000000dddd0000" },
+ "previous_version":{ "$oid" : "1d00000000000000dddd1111" },
+ "edited_by":"testassist@edx.org",
+ "edited_on":{
+ "$date":1364483713238
+ }
}
},
"chapter1":{
- "children":[
-
- ],
"category":"chapter",
"definition":"chapter12345_1",
- "metadata":{
+ "fields":{
+ "children":[
+
+ ],
"display_name":"Hercules"
},
- "update_version":{ "$oid" : "1d00000000000000dddd0000" },
- "previous_version":null,
- "edited_by":"testassist@edx.org",
- "edited_on":{
- "$date":1364483713238
+ "edit_info": {
+ "update_version":{ "$oid" : "1d00000000000000dddd0000" },
+ "previous_version":null,
+ "edited_by":"testassist@edx.org",
+ "edited_on":{
+ "$date":1364483713238
+ }
}
},
"chapter2":{
- "children":[
-
- ],
"category":"chapter",
"definition":"chapter12345_2",
- "metadata":{
+ "fields":{
+ "children":[
+
+ ],
"display_name":"Hera heckles Hercules"
},
- "update_version":{ "$oid" : "1d00000000000000dddd0000" },
- "previous_version":null,
- "edited_by":"testassist@edx.org",
- "edited_on":{
- "$date":1364483713238
+ "edit_info": {
+ "update_version":{ "$oid" : "1d00000000000000dddd0000" },
+ "previous_version":null,
+ "edited_by":"testassist@edx.org",
+ "edited_on":{
+ "$date":1364483713238
+ }
}
},
"chapter3":{
- "children":[
- "problem1",
- "problem3_2"
- ],
"category":"chapter",
"definition":"chapter12345_3",
- "metadata":{
+ "fields":{
+ "children":[
+ "problem1",
+ "problem3_2"
+ ],
"display_name":"Hera cuckolds Zeus"
},
- "update_version":{ "$oid" : "1d00000000000000dddd0000" },
- "previous_version":null,
- "edited_by":"testassist@edx.org",
- "edited_on":{
- "$date":1364483713238
+ "edit_info": {
+ "update_version":{ "$oid" : "1d00000000000000dddd0000" },
+ "previous_version":null,
+ "edited_by":"testassist@edx.org",
+ "edited_on":{
+ "$date":1364483713238
+ }
}
},
"problem1":{
- "children":[
-
- ],
"category":"problem",
"definition":"problem12345_3_1",
- "metadata":{
+ "fields":{
+ "children":[
+
+ ],
"display_name":"Problem 3.1",
"graceperiod":"4 hours 0 minutes 0 seconds"
},
- "update_version":{ "$oid" : "1d00000000000000dddd0000" },
- "previous_version":null,
- "edited_by":"testassist@edx.org",
- "edited_on":{
- "$date":1364483713238
+ "edit_info": {
+ "update_version":{ "$oid" : "1d00000000000000dddd0000" },
+ "previous_version":null,
+ "edited_by":"testassist@edx.org",
+ "edited_on":{
+ "$date":1364483713238
+ }
}
},
"problem3_2":{
- "children":[
-
- ],
"category":"problem",
"definition":"problem12345_3_2",
- "metadata":{
+ "fields":{
+ "children":[
+
+ ],
"display_name":"Problem 3.2"
},
- "update_version":{ "$oid" : "1d00000000000000dddd0000" },
- "previous_version":null,
- "edited_by":"testassist@edx.org",
- "edited_on":{
- "$date":1364483713238
+ "edit_info": {
+ "update_version":{ "$oid" : "1d00000000000000dddd0000" },
+ "previous_version":null,
+ "edited_by":"testassist@edx.org",
+ "edited_on":{
+ "$date":1364483713238
+ }
}
}
}
@@ -156,12 +168,12 @@
},
"blocks":{
"head12345":{
- "children":[
-
- ],
"category":"course",
"definition":"head12345_11",
- "metadata":{
+ "fields":{
+ "children":[
+
+ ],
"end":"2013-04-13T04:30",
"tabs":[
{
@@ -198,11 +210,13 @@
"advertised_start":null,
"display_name":"The Ancient Greek Hero"
},
- "update_version":{ "$oid" : "1d00000000000000dddd1111" },
- "previous_version":{ "$oid" : "1d00000000000000dddd3333" },
- "edited_by":"testassist@edx.org",
- "edited_on":{
- "$date":1364481713238
+ "edit_info": {
+ "update_version":{ "$oid" : "1d00000000000000dddd1111" },
+ "previous_version":{ "$oid" : "1d00000000000000dddd3333" },
+ "edited_by":"testassist@edx.org",
+ "edited_on":{
+ "$date":1364481713238
+ }
}
}
}
@@ -218,12 +232,12 @@
},
"blocks":{
"head12345":{
- "children":[
-
- ],
"category":"course",
"definition":"head12345_10",
- "metadata":{
+ "fields":{
+ "children":[
+
+ ],
"end":null,
"tabs":[
{
@@ -250,11 +264,13 @@
"advertised_start":null,
"display_name":"The Ancient Greek Hero"
},
- "update_version":{ "$oid" : "1d00000000000000dddd3333" },
- "previous_version":null,
- "edited_by":"test@edx.org",
- "edited_on":{
- "$date":1364473713238
+ "edit_info": {
+ "update_version":{ "$oid" : "1d00000000000000dddd3333" },
+ "previous_version":null,
+ "edited_by":"test@edx.org",
+ "edited_on":{
+ "$date":1364473713238
+ }
}
}
}
@@ -270,12 +286,12 @@
},
"blocks":{
"head23456":{
- "children":[
-
- ],
"category":"course",
"definition":"head23456_1",
- "metadata":{
+ "fields":{
+ "children":[
+
+ ],
"end":null,
"tabs":[
{
@@ -302,11 +318,13 @@
"advertised_start":null,
"display_name":"The most wonderful course"
},
- "update_version":{ "$oid" : "1d00000000000000dddd2222" },
- "previous_version":{ "$oid" : "1d00000000000000dddd4444" },
- "edited_by":"test@edx.org",
- "edited_on":{
- "$date":1364481313238
+ "edit_info": {
+ "update_version":{ "$oid" : "1d00000000000000dddd2222" },
+ "previous_version":{ "$oid" : "1d00000000000000dddd4444" },
+ "edited_by":"test@edx.org",
+ "edited_on":{
+ "$date":1364481313238
+ }
}
}
@@ -323,12 +341,12 @@
},
"blocks":{
"head23456":{
- "children":[
-
- ],
"category":"course",
"definition":"head23456_0",
- "metadata":{
+ "fields":{
+ "children":[
+
+ ],
"end":null,
"tabs":[
{
@@ -355,11 +373,13 @@
"advertised_start":null,
"display_name":"A wonderful course"
},
- "update_version":{ "$oid" : "1d00000000000000dddd4444" },
- "previous_version":null,
- "edited_by":"test@edx.org",
- "edited_on":{
- "$date":1364480313238
+ "edit_info": {
+ "update_version":{ "$oid" : "1d00000000000000dddd4444" },
+ "previous_version":null,
+ "edited_by":"test@edx.org",
+ "edited_on":{
+ "$date":1364480313238
+ }
}
}
}
@@ -375,12 +395,12 @@
},
"blocks":{
"head23456":{
- "children":[
-
- ],
"category":"course",
"definition":"head23456_1",
- "metadata":{
+ "fields":{
+ "children":[
+
+ ],
"end":null,
"tabs":[
{
@@ -407,11 +427,13 @@
"advertised_start":null,
"display_name":"The most wonderful course"
},
- "update_version":{ "$oid" : "1d00000000000000eeee0000" },
- "previous_version":null,
- "edited_by":"test@edx.org",
- "edited_on":{
- "$date":1364481333238
+ "edit_info": {
+ "update_version":{ "$oid" : "1d00000000000000eeee0000" },
+ "previous_version":null,
+ "edited_by":"test@edx.org",
+ "edited_on":{
+ "$date":1364481333238
+ }
}
}
}
@@ -427,12 +449,12 @@
},
"blocks":{
"head345679":{
- "children":[
-
- ],
"category":"course",
"definition":"head345679_1",
- "metadata":{
+ "fields":{
+ "children":[
+
+ ],
"end":null,
"tabs":[
{
@@ -459,11 +481,13 @@
"advertised_start":null,
"display_name":"Yet another contender"
},
- "update_version":{ "$oid" : "1d00000000000000dddd5555" },
- "previous_version":null,
- "edited_by":"test@guestx.edu",
- "edited_on":{
- "$date":1364491313238
+ "edit_info": {
+ "update_version":{ "$oid" : "1d00000000000000dddd5555" },
+ "previous_version":null,
+ "edited_by":"test@guestx.edu",
+ "edited_on":{
+ "$date":1364491313238
+ }
}
}
}
From a507ebc1b649ae5e23f3be1a7bd29542baa90d0a Mon Sep 17 00:00:00 2001
From: Don Mitchell
Date: Fri, 9 Aug 2013 16:39:31 -0400
Subject: [PATCH 112/125] Clarify function name
and fix some comments ref'g old names
---
common/lib/xmodule/xmodule/x_module.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py
index c4deaa8925..924f7a075c 100644
--- a/common/lib/xmodule/xmodule/x_module.py
+++ b/common/lib/xmodule/xmodule/x_module.py
@@ -723,7 +723,7 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock):
# We are not allowing editing of xblock tag and name fields at this time (for any component).
return [XBlock.tags, XBlock.name]
- def get_set_fields_by_scope(self, scope=Scope.content):
+ def get_explicitly_set_fields_by_scope(self, scope=Scope.content):
"""
Get a dictionary of the fields for the given scope which are set explicitly on this xblock. (Including
any set to None.)
From 857431f793498fc6a515d6c3eba0366d99171e89 Mon Sep 17 00:00:00 2001
From: Don Mitchell
Date: Mon, 12 Aug 2013 14:22:35 -0400
Subject: [PATCH 113/125] Clarify comments re behavior
---
.../xmodule/modulestore/split_mongo/split_mongo_kvs.py | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_mongo_kvs.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_mongo_kvs.py
index 0ffc111586..6f1b7c25c0 100644
--- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_mongo_kvs.py
+++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_mongo_kvs.py
@@ -46,12 +46,14 @@ class SplitMongoKVS(KeyValueStore):
if key.scope == Scope.parent:
return None
if key.scope == Scope.children:
+ # didn't find children in _fields; so, see if there's a default
raise KeyError()
elif key.scope == Scope.settings:
- # get from inheritance since not locally set
+ # didn't find in _fields; so, get from inheritance since not locally set
if key.field_name in self._inherited_settings:
return self._inherited_settings[key.field_name]
else:
+ # or get default
raise KeyError()
elif key.scope == Scope.content:
if key.field_name == 'location':
@@ -76,7 +78,7 @@ class SplitMongoKVS(KeyValueStore):
self._location = value # is changing this legal?
return
elif key.field_name == 'category':
- # TODO should this raise an exception? that is, should xblock types be mungable?
+ # TODO should this raise an exception? category is not changeable.
return
else:
self._load_definition()
@@ -99,7 +101,7 @@ class SplitMongoKVS(KeyValueStore):
if key.field_name == 'location':
return # noop
elif key.field_name == 'category':
- # TODO should this raise an exception? that is, should xblock types be mungable?
+ # TODO should this raise an exception? category is not deleteable.
return # noop
else:
self._load_definition()
@@ -159,7 +161,7 @@ class SplitMongoKVS(KeyValueStore):
def get_inherited_settings(self):
"""
- Get the metadata set by the ancestors (which own metadata may override or not)
+ Get the settings set by the ancestors (which locally set fields may override or not)
"""
return self._inherited_settings
From 929597ce84ed7812b362cecf44a894fc7ef502b9 Mon Sep 17 00:00:00 2001
From: Don Mitchell
Date: Mon, 12 Aug 2013 17:22:05 -0400
Subject: [PATCH 114/125] Move load_from_json to the test file only
It's a reasonable demo of in memory xblock creation, but doesn't fit
the xblock pattern. Moving temporarily to keep the dag persistence test.
---
.../contentstore/tests/test_crud.py | 61 ++++++++++++++++---
common/lib/xmodule/xmodule/x_module.py | 58 +-----------------
2 files changed, 52 insertions(+), 67 deletions(-)
diff --git a/cms/djangoapps/contentstore/tests/test_crud.py b/cms/djangoapps/contentstore/tests/test_crud.py
index 5beef20d6c..9bfd20b1c0 100644
--- a/cms/djangoapps/contentstore/tests/test_crud.py
+++ b/cms/djangoapps/contentstore/tests/test_crud.py
@@ -1,19 +1,15 @@
-'''
-Created on May 7, 2013
-
-@author: dmitchell
-'''
import unittest
from xmodule import templates
from xmodule.modulestore.tests import persistent_factories
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore.django import modulestore
from xmodule.seq_module import SequenceDescriptor
-from xmodule.x_module import XModuleDescriptor
from xmodule.capa_module import CapaDescriptor
from xmodule.modulestore.locator import CourseLocator, BlockUsageLocator
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.html_module import HtmlDescriptor
+from xmodule.modulestore import inheritance
+from xmodule.x_module import XModuleDescriptor
class TemplateTests(unittest.TestCase):
@@ -74,7 +70,7 @@ class TemplateTests(unittest.TestCase):
test_course = persistent_factories.PersistentCourseFactory.create(org='testx', prettyid='tempcourse',
display_name='fun test course', user_id='testbot')
- test_chapter = XModuleDescriptor.load_from_json({'category': 'chapter',
+ test_chapter = self.load_from_json({'category': 'chapter',
'fields': {'display_name': 'chapter n'}},
test_course.system, parent_xblock=test_course)
self.assertIsInstance(test_chapter, SequenceDescriptor)
@@ -83,7 +79,7 @@ class TemplateTests(unittest.TestCase):
# test w/ a definition (e.g., a problem)
test_def_content = 'boo'
- test_problem = XModuleDescriptor.load_from_json({'category': 'problem',
+ test_problem = self.load_from_json({'category': 'problem',
'fields': {'data': test_def_content}},
test_course.system, parent_xblock=test_chapter)
self.assertIsInstance(test_problem, CapaDescriptor)
@@ -98,12 +94,12 @@ class TemplateTests(unittest.TestCase):
"""
test_course = persistent_factories.PersistentCourseFactory.create(org='testx', prettyid='tempcourse',
display_name='fun test course', user_id='testbot')
- test_chapter = XModuleDescriptor.load_from_json({'category': 'chapter',
+ test_chapter = self.load_from_json({'category': 'chapter',
'fields': {'display_name': 'chapter n'}},
test_course.system, parent_xblock=test_course)
test_def_content = 'boo'
# create child
- _ = XModuleDescriptor.load_from_json({'category': 'problem',
+ _ = self.load_from_json({'category': 'problem',
'fields': {'data': test_def_content}},
test_course.system, parent_xblock=test_chapter)
# better to pass in persisted parent over the subdag so
@@ -194,3 +190,48 @@ class TemplateTests(unittest.TestCase):
version_history = modulestore('split').get_block_generations(second_problem.location)
self.assertNotEqual(version_history.locator.version_guid, first_problem.location.version_guid)
+
+ # ================================= JSON PARSING ===========================
+ # These are example methods for creating xmodules in memory w/o persisting them.
+ # They were in x_module but since xblock is not planning to support them but will
+ # allow apps to use this type of thing, I put it here.
+ @staticmethod
+ def load_from_json(json_data, system, default_class=None, parent_xblock=None):
+ """
+ This method instantiates the correct subclass of XModuleDescriptor based
+ on the contents of json_data. It does not persist it and can create one which
+ has no usage id.
+
+ parent_xblock is used to compute inherited metadata as well as to append the new xblock.
+
+ json_data:
+ - 'location' : must have this field
+ - 'category': the xmodule category (required or location must be a Location)
+ - 'metadata': a dict of locally set metadata (not inherited)
+ - 'children': a list of children's usage_ids w/in this course
+ - 'definition':
+ - '_id' (optional): the usage_id of this. Will generate one if not given one.
+ """
+ class_ = XModuleDescriptor.load_class(
+ json_data.get('category', json_data.get('location', {}).get('category')),
+ default_class
+ )
+ usage_id = json_data.get('_id', None)
+ if not '_inherited_settings' in json_data and parent_xblock is not None:
+ json_data['_inherited_settings'] = parent_xblock.xblock_kvs.get_inherited_settings().copy()
+ json_fields = json_data.get('fields', {})
+ for field in inheritance.INHERITABLE_METADATA:
+ if field in json_fields:
+ json_data['_inherited_settings'][field] = json_fields[field]
+
+ new_block = system.xblock_from_json(class_, usage_id, json_data)
+ if parent_xblock is not None:
+ children = parent_xblock.children
+ children.append(new_block)
+ # trigger setter method by using top level field access
+ parent_xblock.children = children
+ # decache pending children field settings (Note, truly persisting at this point would break b/c
+ # persistence assumes children is a list of ids not actual xblocks)
+ parent_xblock.save()
+ return new_block
+
diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py
index 924f7a075c..ba1626c1b2 100644
--- a/common/lib/xmodule/xmodule/x_module.py
+++ b/common/lib/xmodule/xmodule/x_module.py
@@ -7,7 +7,7 @@ from lxml import etree
from collections import namedtuple
from pkg_resources import resource_listdir, resource_string, resource_isdir
-from xmodule.modulestore import inheritance, Location
+from xmodule.modulestore import Location
from xmodule.modulestore.exceptions import ItemNotFoundError, InsufficientSpecificationError, InvalidLocationError
from xblock.core import XBlock, Scope, String, Integer, Float, List, ModelType
@@ -557,62 +557,6 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock):
"""
return False
- # ================================= JSON PARSING ===========================
- @staticmethod
- def load_from_json(json_data, system, default_class=None, parent_xblock=None):
- """
- This method instantiates the correct subclass of XModuleDescriptor based
- on the contents of json_data. It does not persist it and can create one which
- has no usage id.
-
- parent_xblock is used to compute inherited metadata as well as to append the new xblock.
-
- json_data:
- - 'location' : must have this field
- - 'category': the xmodule category (required or location must be a Location)
- - 'metadata': a dict of locally set metadata (not inherited)
- - 'children': a list of children's usage_ids w/in this course
- - 'definition':
- - '_id' (optional): the usage_id of this. Will generate one if not given one.
- """
- class_ = XModuleDescriptor.load_class(
- json_data.get('category', json_data.get('location', {}).get('category')),
- default_class
- )
- return class_.from_json(json_data, system, parent_xblock)
-
- @classmethod
- def from_json(cls, json_data, system, parent_xblock=None):
- """
- Creates an instance of this descriptor from the supplied json_data.
- This may be overridden by subclasses
-
- json_data:
- - 'category': the xmodule category (required)
- - 'fields': a dict of locally set fields (not inherited)
- - 'definition': (optional) the db id for the definition record (not the definition content) or a
- definitionLazyLoader
- - '_id' (optional): the usage_id of this. Will generate one if not given one.
- """
- usage_id = json_data.get('_id', None)
- if not '_inherited_settings' in json_data and parent_xblock is not None:
- json_data['_inherited_settings'] = parent_xblock.xblock_kvs.get_inherited_settings().copy()
- json_fields = json_data.get('fields', {})
- for field in inheritance.INHERITABLE_METADATA:
- if field in json_fields:
- json_data['_inherited_settings'][field] = json_fields[field]
-
- new_block = system.xblock_from_json(cls, usage_id, json_data)
- if parent_xblock is not None:
- children = parent_xblock.children
- children.append(new_block)
- # trigger setter method by using top level field access
- parent_xblock.children = children
- # decache pending children field settings (Note, truly persisting at this point would break b/c
- # persistence assumes children is a list of ids not actual xblocks)
- parent_xblock.save()
- return new_block
-
@classmethod
def _translate(cls, key):
'VS[compat]'
From 438bbffb938f9cb7a2d659aef4d0ccaddcf366bb Mon Sep 17 00:00:00 2001
From: Don Mitchell
Date: Mon, 12 Aug 2013 17:25:35 -0400
Subject: [PATCH 115/125] Add field iterator which includes namespaced fields
And fix get_explicitly_set to use it and to get the json not native
field values.
---
cms/djangoapps/contentstore/views/item.py | 25 +++--------------------
common/lib/xmodule/xmodule/x_module.py | 19 +++++++++++++----
2 files changed, 18 insertions(+), 26 deletions(-)
diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py
index ff347a2878..bbd95dba84 100644
--- a/cms/djangoapps/contentstore/views/item.py
+++ b/cms/djangoapps/contentstore/views/item.py
@@ -58,14 +58,12 @@ 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', []):
- # [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:
@@ -82,32 +80,15 @@ def save_item(request):
return JsonResponse()
-# [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
-
+ for field in xblock.iterfields():
+ if field.name == field_name:
+ return field
@login_required
@expect_json
diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py
index ba1626c1b2..cc8c39dca3 100644
--- a/common/lib/xmodule/xmodule/x_module.py
+++ b/common/lib/xmodule/xmodule/x_module.py
@@ -657,6 +657,17 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock):
)
)
+ def iterfields(self):
+ """
+ A generator for iterate over the fields of this xblock (including the ones in namespaces).
+ Example usage: [field.name for field in module.iterfields()]
+ """
+ for field in self.fields:
+ yield field
+ for namespace in self.namespaces:
+ for field in getattr(self, namespace).fields:
+ yield field
+
@property
def non_editable_metadata_fields(self):
"""
@@ -675,17 +686,17 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock):
if scope == Scope.settings and hasattr(self, '_inherited_metadata'):
inherited_metadata = getattr(self, '_inherited_metadata')
result = {}
- for field in self.fields:
+ for field in self.iterfields():
if (field.scope == scope and
field.name in self._model_data and
field.name not in inherited_metadata):
- result[field.name] = getattr(self, field.name)
+ result[field.name] = self._model_data[field.name]
return result
else:
result = {}
- for field in self.fields:
+ for field in self.iterfields():
if (field.scope == scope and field.name in self._model_data):
- result[field.name] = getattr(self, field.name)
+ result[field.name] = self._model_data[field.name]
return result
@property
From 5029659327753d2a88df74da8eb6dab88542cbae Mon Sep 17 00:00:00 2001
From: Don Mitchell
Date: Mon, 12 Aug 2013 17:26:34 -0400
Subject: [PATCH 116/125] Better explain behavior and todo's
---
.../modulestore/split_mongo/split_mongo_kvs.py | 17 +++++++++++------
1 file changed, 11 insertions(+), 6 deletions(-)
diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_mongo_kvs.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_mongo_kvs.py
index 6f1b7c25c0..e4a4b92027 100644
--- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_mongo_kvs.py
+++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_mongo_kvs.py
@@ -44,6 +44,7 @@ class SplitMongoKVS(KeyValueStore):
# parent undefined in editing runtime (I think)
if key.scope == Scope.parent:
+ # see STUD-624. Right now copies MongoKeyValueStore.get's behavior of returning None
return None
if key.scope == Scope.children:
# didn't find children in _fields; so, see if there's a default
@@ -86,12 +87,12 @@ class SplitMongoKVS(KeyValueStore):
# set the field
self._fields[key.field_name] = value
- # handle any side effects
+ # handle any side effects -- story STUD-624
# if key.scope == Scope.children:
- # TODO remove inheritance from any exchildren
- # TODO add inheritance to any new children
+ # STUD-624 remove inheritance from any exchildren
+ # STUD-624 add inheritance to any new children
# if key.scope == Scope.settings:
- # TODO if inheritable, push down to children
+ # STUD-624 if inheritable, push down to children
def delete(self, key):
# handle any special cases
@@ -112,11 +113,14 @@ class SplitMongoKVS(KeyValueStore):
# handle any side effects
# if key.scope == Scope.children:
- # TODO remove inheritance from any exchildren
+ # STUD-624 remove inheritance from any exchildren
# if key.scope == Scope.settings:
- # TODO if inheritable, push down _inherited_settings value to children
+ # STUD-624 if inheritable, push down _inherited_settings value to children
def has(self, key):
+ """
+ Is the given field explicitly set in this kvs (not inherited nor default)
+ """
# handle any special cases
if key.scope == Scope.content:
if key.field_name == 'location':
@@ -152,6 +156,7 @@ class SplitMongoKVS(KeyValueStore):
return PROVENANCE_DEFAULT
elif key_scope == Scope.parent:
return PROVENANCE_DEFAULT
+ # catch the locally set state
elif key_name in self._fields:
return PROVENANCE_LOCAL
elif key_scope == Scope.settings and key_name in self._inherited_settings:
From 69a068332297ed41541e994187afc5e939bddd2a Mon Sep 17 00:00:00 2001
From: Don Mitchell
Date: Fri, 16 Aug 2013 09:55:58 -0400
Subject: [PATCH 117/125] Remove obsolete attrs.
---
.../xmodule/xmodule/modulestore/tests/persistent_factories.py | 2 --
1 file changed, 2 deletions(-)
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/persistent_factories.py b/common/lib/xmodule/xmodule/modulestore/tests/persistent_factories.py
index 4675c6a50c..51e3a7f04d 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/persistent_factories.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/persistent_factories.py
@@ -25,8 +25,6 @@ class PersistentCourseFactory(factory.Factory):
prettyid = '999'
display_name = 'Robot Super Course'
user_id = "test_user"
- data = None
- metadata = None
master_branch = 'draft'
# pylint: disable=W0613
From 0a59cb65989ada506a0c11337a36fe8f8aa6791f Mon Sep 17 00:00:00 2001
From: Adam Palay
Date: Thu, 15 Aug 2013 10:35:29 -0400
Subject: [PATCH 118/125] in grading, if problem cannot be created, return
score as none
Conflicts:
lms/envs/dev.py
---
lms/djangoapps/courseware/grades.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py
index e3c40079c3..8874a5686c 100644
--- a/lms/djangoapps/courseware/grades.py
+++ b/lms/djangoapps/courseware/grades.py
@@ -358,6 +358,8 @@ def get_score(course_id, user, problem_descriptor, module_creator, model_data_ca
# with the LMS, so they need to always be scored. (E.g. foldit.)
if problem_descriptor.always_recalculate_grades:
problem = module_creator(problem_descriptor)
+ if problem is None:
+ return (None, None)
score = problem.get_score()
if score is not None:
return (score['score'], score['total'])
From 97083d0f69b4f3b954249b23d6761e914040e739 Mon Sep 17 00:00:00 2001
From: James Tauber
Date: Fri, 16 Aug 2013 11:49:20 -0400
Subject: [PATCH 119/125] fixed typo in faculty member settings
This was confusing translators as didn't make sense in English
---
cms/templates/settings_discussions_faculty.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/cms/templates/settings_discussions_faculty.html b/cms/templates/settings_discussions_faculty.html
index 1ce96c1ae5..b8f9a24e73 100644
--- a/cms/templates/settings_discussions_faculty.html
+++ b/cms/templates/settings_discussions_faculty.html
@@ -37,7 +37,7 @@ from contentstore import utils
${_("Faculty Members")}
- ${_("Individuals instructing and help with this course")}
+ ${_("Individuals instructing and helping with this course")}
From d1ce55f65c2a9019691c8184cb14c0a2a7353107 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Fri, 16 Aug 2013 12:02:58 -0400
Subject: [PATCH 120/125] have the test teardown explicitly call destroy_db
---
.../xmodule/modulestore/tests/test_mixed_modulestore.py | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py
index 70e4d1a5d3..e04ef9c363 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py
@@ -66,8 +66,7 @@ class TestMixedModuleStore(object):
@classmethod
def teardownClass(cls):
- cls.connection = pymongo.connection.Connection(HOST, PORT)
- cls.connection.drop_database(DB)
+ cls.destroy_db(cls.connection)
@staticmethod
def initdb():
From b98572225500ecaaf74b3c2be37bf639859e090e Mon Sep 17 00:00:00 2001
From: James Tauber
Date: Fri, 16 Aug 2013 13:52:07 -0400
Subject: [PATCH 121/125] fixed encoding
xgettext seems to only like `utf-8` not `utf8` so string extraction
was failing without this
---
.../student/management/commands/anonymized_id_mapping.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/common/djangoapps/student/management/commands/anonymized_id_mapping.py b/common/djangoapps/student/management/commands/anonymized_id_mapping.py
index 6156d4bf1d..f1ed5bdef9 100644
--- a/common/djangoapps/student/management/commands/anonymized_id_mapping.py
+++ b/common/djangoapps/student/management/commands/anonymized_id_mapping.py
@@ -1,4 +1,4 @@
-# -*- coding: utf8 -*-
+# -*- coding: utf-8 -*-
"""Dump username,unique_id_for_user pairs as CSV.
Give instructors easy access to the mapping from anonymized IDs to user IDs
From 4c286840146f1e250f7f4c62d519e8ac7429208e Mon Sep 17 00:00:00 2001
From: Don Mitchell
Date: Fri, 16 Aug 2013 14:01:16 -0400
Subject: [PATCH 122/125] pass xblock fields as top-level keywords on factories
---
.../contentstore/tests/test_crud.py | 4 +-
.../modulestore/tests/persistent_factories.py | 51 +++++--------------
2 files changed, 16 insertions(+), 39 deletions(-)
diff --git a/cms/djangoapps/contentstore/tests/test_crud.py b/cms/djangoapps/contentstore/tests/test_crud.py
index 9bfd20b1c0..e543b7b517 100644
--- a/cms/djangoapps/contentstore/tests/test_crud.py
+++ b/cms/djangoapps/contentstore/tests/test_crud.py
@@ -151,7 +151,7 @@ class TemplateTests(unittest.TestCase):
parent_location=chapter.location, user_id='testbot', category='vertical')
first_problem = persistent_factories.ItemFactory.create(
display_name='problem 1', parent_location=sub.location, user_id='testbot', category='problem',
- fields={'data':""}
+ data=""
)
first_problem.max_attempts = 3
first_problem.save() # decache the above into the kvs
@@ -165,7 +165,7 @@ class TemplateTests(unittest.TestCase):
display_name='problem 2',
parent_location=BlockUsageLocator(updated_loc, usage_id=sub.location.usage_id),
user_id='testbot', category='problem',
- fields={'data':""}
+ data=""
)
# course root only updated 2x
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/persistent_factories.py b/common/lib/xmodule/xmodule/modulestore/tests/persistent_factories.py
index 51e3a7f04d..3031990974 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/persistent_factories.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/persistent_factories.py
@@ -11,38 +11,24 @@ class PersistentCourseFactory(factory.Factory):
"""
Create a new course (not a new version of a course, but a whole new index entry).
- keywords:
+ keywords: any xblock field plus (note, the below are filtered out; so, if they
+ become legitimate xblock fields, they won't be settable via this factory)
* org: defaults to textX
* prettyid: defaults to 999
- * display_name
- * user_id
- * fields (optional) the settings and content payloads. If display_name is in the metadata, that takes
- precedence over any display_name provided directly.
+ * master_branch: (optional) defaults to 'draft'
+ * user_id: (optional) defaults to 'test_user'
+ * display_name (xblock field): will default to 'Robot Super Course' unless provided
"""
FACTORY_FOR = CourseDescriptor
- org = 'testX'
- prettyid = '999'
- display_name = 'Robot Super Course'
- user_id = "test_user"
- master_branch = 'draft'
-
# pylint: disable=W0613
@classmethod
- def _create(cls, target_class, *args, **kwargs):
-
- org = kwargs.get('org')
- prettyid = kwargs.get('prettyid')
- display_name = kwargs.get('display_name')
- user_id = kwargs.get('user_id')
- fields = kwargs.get('fields', {})
- if display_name and 'display_name' not in fields:
- fields['display_name'] = display_name
+ def _create(cls, target_class, org='testX', prettyid='999', user_id='test_user', master_branch='draft', **kwargs):
# Write the data to the mongo datastore
new_course = modulestore('split').create_course(
- org, prettyid, user_id, fields=fields, id_root=prettyid,
- master_branch=kwargs.get('master_branch'))
+ org, prettyid, user_id, fields=kwargs, id_root=prettyid,
+ master_branch=master_branch)
return new_course
@@ -54,33 +40,24 @@ class PersistentCourseFactory(factory.Factory):
class ItemFactory(factory.Factory):
FACTORY_FOR = XModuleDescriptor
- category = 'chapter'
- user_id = 'test_user'
display_name = factory.LazyAttributeSequence(lambda o, n: "{} {}".format(o.category, n))
# pylint: disable=W0613
@classmethod
- def _create(cls, target_class, *args, **kwargs):
+ def _create(cls, target_class, parent_location, category='chapter',
+ user_id='test_user', definition_locator=None, **kwargs):
"""
- Uses *kwargs*:
+ passes *kwargs* as the new item's field values:
:param parent_location: (required) the location of the course & possibly parent
:param category: (defaults to 'chapter')
- :param fields: (optional) the data for the item
-
:param definition_locator (optional): the DescriptorLocator for the definition this uses or branches
-
- :param display_name (optional): the display name of the item
"""
- fields = kwargs.get('fields', {})
- if 'display_name' not in fields and 'display_name' in kwargs:
- fields['display_name'] = kwargs['display_name']
-
- return modulestore('split').create_item(kwargs['parent_location'], kwargs['category'],
- kwargs['user_id'], definition_locator=kwargs.get('definition_locator'),
- fields=fields)
+ return modulestore('split').create_item(
+ parent_location, category, user_id, definition_locator, fields=kwargs
+ )
@classmethod
def _build(cls, target_class, *args, **kwargs):
From a5a6d2d54afdc81319d88e19fb8f4291229da392 Mon Sep 17 00:00:00 2001
From: Valera Rozuvan
Date: Fri, 16 Aug 2013 16:59:40 +0300
Subject: [PATCH 123/125] Added test for Flash video player.
Fix for video and captions sync. Test for this fix.
---
.../xmodule/js/spec/video/video_caption_spec.js | 17 +++++++++++++++--
1 file changed, 15 insertions(+), 2 deletions(-)
diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js
index 49e5df0e9a..f7a7e131dc 100644
--- a/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js
+++ b/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js
@@ -443,7 +443,7 @@
});
it('trigger seek event with the correct time', function() {
- expect(videoPlayer.currentTime).toEqual(15);
+ expect(videoPlayer.currentTime).toEqual(14.91);
});
});
@@ -455,7 +455,20 @@
});
it('trigger seek event with the correct time', function() {
- expect(videoPlayer.currentTime).toEqual(15);
+ expect(videoPlayer.currentTime).toEqual(14.91);
+ });
+ });
+
+ describe('when the player type is Flash at speed 0.75x', function () {
+ beforeEach(function () {
+ initialize();
+ videoSpeedControl.currentSpeed = '0.75';
+ state.currentPlayerMode = 'flash';
+ $('.subtitles li[data-start="14910"]').trigger('click');
+ });
+
+ it('trigger seek event with the correct time', function () {
+ expect(videoPlayer.currentTime).toEqual(15);
});
});
});
From 1128f369f7261d8a6dfaae7bb5dc0e708f5b6e79 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Fri, 16 Aug 2013 15:24:20 -0400
Subject: [PATCH 124/125] add changelog entry
---
CHANGELOG.rst | 2 ++
1 file changed, 2 insertions(+)
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index e108a31594..1338cacd3b 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -96,6 +96,8 @@ LMS: Removed press releases
Common: Updated Sass and Bourbon libraries, added Neat library
+LMS: Add a MixedModuleStore to aggregate the XMLModuleStore and MongoMonduleStore
+
LMS: Users are no longer auto-activated if they click "reset password"
This is now done when they click on the link in the reset password
email they receive (along with usual path through activation email).
From bd71a2ceb3a03d283db6e6a255ac409b80419b20 Mon Sep 17 00:00:00 2001
From: Chris Dodge
Date: Fri, 16 Aug 2013 15:24:43 -0400
Subject: [PATCH 125/125] add unit test for video_caption asset path
---
.../contentstore/tests/test_contentstore.py | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py
index e70df4164a..96b0b84e36 100644
--- a/cms/djangoapps/contentstore/tests/test_contentstore.py
+++ b/cms/djangoapps/contentstore/tests/test_contentstore.py
@@ -400,6 +400,20 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
+ def test_video_module_caption_asset_path(self):
+ '''
+ This verifies that a video caption url is as we expect it to be
+ '''
+ direct_store = modulestore('direct')
+ import_from_xml(direct_store, 'common/test/data/', ['toy'])
+
+ # also try a custom response which will trigger the 'is this course in whitelist' logic
+ video_module_location = Location(['i4x', 'edX', 'toy', 'video', 'sample_video', None])
+ url = reverse('preview_component', kwargs={'location': video_module_location.url()})
+ resp = self.client.get(url)
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, 'data-caption-asset-path="/c4x/edX/toy/asset/subs_"')
+
def test_delete(self):
direct_store = modulestore('direct')
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')