PY-2016.1.4 <alisantang@C02RP0YSG8WM.tld Merge branch 'master'

This commit is contained in:
alisan617
2016-06-24 20:02:21 -04:00
233 changed files with 11471 additions and 7647 deletions

View File

@@ -1000,7 +1000,7 @@ class MiscCourseTests(ContentStoreTestCase):
3) computing thumbnail location of asset
4) deleting the asset from the course
"""
asset_key = self.course.id.make_asset_key('asset', 'sample_static.txt')
asset_key = self.course.id.make_asset_key('asset', 'sample_static.html')
content = StaticContent(
asset_key, "Fake asset", "application/text", "test",
)
@@ -1072,7 +1072,7 @@ class MiscCourseTests(ContentStoreTestCase):
draft content is also deleted
"""
# add an asset
asset_key = self.course.id.make_asset_key('asset', 'sample_static.txt')
asset_key = self.course.id.make_asset_key('asset', 'sample_static.html')
content = StaticContent(
asset_key, "Fake asset", "application/text", "test",
)

View File

@@ -101,7 +101,7 @@ class TestOrphan(TestOrphanBase):
@ddt.data(
(ModuleStoreEnum.Type.split, 9, 6),
(ModuleStoreEnum.Type.mongo, 30, 13),
(ModuleStoreEnum.Type.mongo, 34, 13),
)
@ddt.unpack
def test_delete_orphans(self, default_store, max_mongo_calls, min_mongo_calls):

View File

@@ -121,7 +121,7 @@ class CourseTestCase(ProceduralCourseTestMixin, ModuleStoreTestCase):
SEQUENTIAL = 'vertical_sequential'
DRAFT_HTML = 'draft_html'
DRAFT_VIDEO = 'draft_video'
LOCKED_ASSET_KEY = AssetLocation.from_deprecated_string('/c4x/edX/toy/asset/sample_static.txt')
LOCKED_ASSET_KEY = AssetLocation.from_deprecated_string('/c4x/edX/toy/asset/sample_static.html')
def import_and_populate_course(self):
"""

View File

@@ -126,7 +126,7 @@ class BasicAssetsTestCase(AssetsTestCase):
)
course = module_store.get_course(course_id)
filename = 'sample_static.txt'
filename = 'sample_static.html'
html_src_attribute = '"/static/{}"'.format(filename)
asset_url = replace_static_urls(html_src_attribute, course_id=course.id)
url = asset_url.replace('"', '')
@@ -379,7 +379,7 @@ class LockAssetTestCase(AssetsTestCase):
"""
def verify_asset_locked_state(locked):
""" Helper method to verify lock state in the contentstore """
asset_location = StaticContent.get_location_from_path('/c4x/edX/toy/asset/sample_static.txt')
asset_location = StaticContent.get_location_from_path('/c4x/edX/toy/asset/sample_static.html')
content = contentstore().find(asset_location)
self.assertEqual(content.locked, locked)
@@ -387,14 +387,14 @@ class LockAssetTestCase(AssetsTestCase):
""" Helper method for posting asset update. """
content_type = 'application/txt'
upload_date = datetime(2013, 6, 1, 10, 30, tzinfo=UTC)
asset_location = course.id.make_asset_key('asset', 'sample_static.txt')
asset_location = course.id.make_asset_key('asset', 'sample_static.html')
url = reverse_course_url('assets_handler', course.id, kwargs={'asset_key_string': unicode(asset_location)})
resp = self.client.post(
url,
# pylint: disable=protected-access
json.dumps(assets._get_asset_json(
"sample_static.txt", content_type, upload_date, asset_location, None, lock)),
"sample_static.html", content_type, upload_date, asset_location, None, lock)),
"application/json"
)

View File

@@ -228,6 +228,7 @@
"Alternative source": "\u00c0lt\u00e9rn\u00e4t\u00efv\u00e9 s\u00f6\u00fcr\u00e7\u00e9 \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442#",
"Always cohort content-specific discussion topics": "\u00c0lw\u00e4\u00fds \u00e7\u00f6h\u00f6rt \u00e7\u00f6nt\u00e9nt-sp\u00e9\u00e7\u00eff\u00ef\u00e7 d\u00efs\u00e7\u00fcss\u00ef\u00f6n t\u00f6p\u00ef\u00e7s \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1#",
"Amount": "\u00c0m\u00f6\u00fcnt \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5#",
"An email has been sent to {userEmail} with a link for you to activate your account.": "\u00c0n \u00e9m\u00e4\u00efl h\u00e4s \u00df\u00e9\u00e9n s\u00e9nt t\u00f6 {userEmail} w\u00efth \u00e4 l\u00efnk f\u00f6r \u00fd\u00f6\u00fc t\u00f6 \u00e4\u00e7t\u00efv\u00e4t\u00e9 \u00fd\u00f6\u00fcr \u00e4\u00e7\u00e7\u00f6\u00fcnt. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5#",
"An error has occurred. Check your Internet connection and try again.": "\u00c0n \u00e9rr\u00f6r h\u00e4s \u00f6\u00e7\u00e7\u00fcrr\u00e9d. \u00c7h\u00e9\u00e7k \u00fd\u00f6\u00fcr \u00ccnt\u00e9rn\u00e9t \u00e7\u00f6nn\u00e9\u00e7t\u00ef\u00f6n \u00e4nd tr\u00fd \u00e4g\u00e4\u00efn. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f #",
"An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page.": "\u00c0n \u00e9rr\u00f6r h\u00e4s \u00f6\u00e7\u00e7\u00fcrr\u00e9d. M\u00e4k\u00e9 s\u00fcr\u00e9 th\u00e4t \u00fd\u00f6\u00fc \u00e4r\u00e9 \u00e7\u00f6nn\u00e9\u00e7t\u00e9d t\u00f6 th\u00e9 \u00ccnt\u00e9rn\u00e9t, \u00e4nd th\u00e9n tr\u00fd r\u00e9fr\u00e9sh\u00efng th\u00e9 p\u00e4g\u00e9. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 #",
"An error has occurred. Please try again later.": "\u00c0n \u00e9rr\u00f6r h\u00e4s \u00f6\u00e7\u00e7\u00fcrr\u00e9d. Pl\u00e9\u00e4s\u00e9 tr\u00fd \u00e4g\u00e4\u00efn l\u00e4t\u00e9r. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1#",
@@ -413,6 +414,7 @@
"Confirm": "\u00c7\u00f6nf\u00efrm \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c #",
"Confirm Timed Transcript": "\u00c7\u00f6nf\u00efrm T\u00efm\u00e9d Tr\u00e4ns\u00e7r\u00efpt \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7#",
"Congratulations! You are now verified on %(platformName)s!": "\u00c7\u00f6ngr\u00e4t\u00fcl\u00e4t\u00ef\u00f6ns! \u00dd\u00f6\u00fc \u00e4r\u00e9 n\u00f6w v\u00e9r\u00eff\u00ef\u00e9d \u00f6n %(platformName)s! \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f #",
"Congratulations! You have earned a certificate for this course.": "\u00c7\u00f6ngr\u00e4t\u00fcl\u00e4t\u00ef\u00f6ns! \u00dd\u00f6\u00fc h\u00e4v\u00e9 \u00e9\u00e4rn\u00e9d \u00e4 \u00e7\u00e9rt\u00eff\u00ef\u00e7\u00e4t\u00e9 f\u00f6r th\u00efs \u00e7\u00f6\u00fcrs\u00e9. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1#",
"Constrain proportions": "\u00c7\u00f6nstr\u00e4\u00efn pr\u00f6p\u00f6rt\u00ef\u00f6ns \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, #",
"Contains staff only content": "\u00c7\u00f6nt\u00e4\u00efns st\u00e4ff \u00f6nl\u00fd \u00e7\u00f6nt\u00e9nt \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454#",
"Contains {count} group": [
@@ -1647,6 +1649,7 @@
"View Teams in the %(topic_name)s Topic": "V\u00ef\u00e9w T\u00e9\u00e4ms \u00efn th\u00e9 %(topic_name)s T\u00f6p\u00ef\u00e7 \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454#",
"View all errors": "V\u00ef\u00e9w \u00e4ll \u00e9rr\u00f6rs \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1#",
"View discussion": "V\u00ef\u00e9w d\u00efs\u00e7\u00fcss\u00ef\u00f6n \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1#",
"View/Share Certificate": "V\u00ef\u00e9w/Sh\u00e4r\u00e9 \u00c7\u00e9rt\u00eff\u00ef\u00e7\u00e4t\u00e9 \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2#",
"Viewing %s course": [
"V\u00ef\u00e9w\u00efng %s \u00e7\u00f6\u00fcrs\u00e9 \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442#",
"V\u00ef\u00e9w\u00efng %s \u00e7\u00f6\u00fcrs\u00e9s \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442,#"
@@ -1660,6 +1663,7 @@
"Want to confirm your identity later?": "W\u00e4nt t\u00f6 \u00e7\u00f6nf\u00efrm \u00fd\u00f6\u00fcr \u00efd\u00e9nt\u00eft\u00fd l\u00e4t\u00e9r? \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5#",
"Warning": "W\u00e4rn\u00efng \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c #",
"Warnings": "W\u00e4rn\u00efngs \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202#",
"We ask you to activate your account to ensure it is really you creating the account and to prevent fraud.": "W\u00e9 \u00e4sk \u00fd\u00f6\u00fc t\u00f6 \u00e4\u00e7t\u00efv\u00e4t\u00e9 \u00fd\u00f6\u00fcr \u00e4\u00e7\u00e7\u00f6\u00fcnt t\u00f6 \u00e9ns\u00fcr\u00e9 \u00eft \u00efs r\u00e9\u00e4ll\u00fd \u00fd\u00f6\u00fc \u00e7r\u00e9\u00e4t\u00efng th\u00e9 \u00e4\u00e7\u00e7\u00f6\u00fcnt \u00e4nd t\u00f6 pr\u00e9v\u00e9nt fr\u00e4\u00fcd. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 #",
"We couldn't create your account.": "W\u00e9 \u00e7\u00f6\u00fcldn't \u00e7r\u00e9\u00e4t\u00e9 \u00fd\u00f6\u00fcr \u00e4\u00e7\u00e7\u00f6\u00fcnt. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454#",
"We couldn't find any results for \"%s\".": "W\u00e9 \u00e7\u00f6\u00fcldn't f\u00efnd \u00e4n\u00fd r\u00e9s\u00fclts f\u00f6r \"%s\". \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f#",
"We couldn't sign you in.": "W\u00e9 \u00e7\u00f6\u00fcldn't s\u00efgn \u00fd\u00f6\u00fc \u00efn. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7#",
@@ -1705,6 +1709,7 @@
"When your face is in position, use the camera button {icon} below to take your photo.": "Wh\u00e9n \u00fd\u00f6\u00fcr f\u00e4\u00e7\u00e9 \u00efs \u00efn p\u00f6s\u00eft\u00ef\u00f6n, \u00fcs\u00e9 th\u00e9 \u00e7\u00e4m\u00e9r\u00e4 \u00df\u00fctt\u00f6n {icon} \u00df\u00e9l\u00f6w t\u00f6 t\u00e4k\u00e9 \u00fd\u00f6\u00fcr ph\u00f6t\u00f6. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454#",
"Which timed transcript would you like to use?": "Wh\u00ef\u00e7h t\u00efm\u00e9d tr\u00e4ns\u00e7r\u00efpt w\u00f6\u00fcld \u00fd\u00f6\u00fc l\u00efk\u00e9 t\u00f6 \u00fcs\u00e9? \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f #",
"Whole words": "Wh\u00f6l\u00e9 w\u00f6rds \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f #",
"Why activate?": "Wh\u00fd \u00e4\u00e7t\u00efv\u00e4t\u00e9? \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9#",
"Why does %(platformName)s need my photo?": "Wh\u00fd d\u00f6\u00e9s %(platformName)s n\u00e9\u00e9d m\u00fd ph\u00f6t\u00f6? \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454#",
"Width": "W\u00efdth \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455#",
"Will Be Visible To:": "W\u00efll B\u00e9 V\u00efs\u00ef\u00dfl\u00e9 T\u00f6: \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442,#",

View File

@@ -228,6 +228,7 @@
"Alternative source": "\u023al\u0287\u01dd\u0279n\u0250\u0287\u1d09\u028c\u01dd s\u00f8n\u0279\u0254\u01dd",
"Always cohort content-specific discussion topics": "\u023al\u028d\u0250\u028es \u0254\u00f8\u0265\u00f8\u0279\u0287 \u0254\u00f8n\u0287\u01ddn\u0287-sd\u01dd\u0254\u1d09\u025f\u1d09\u0254 d\u1d09s\u0254nss\u1d09\u00f8n \u0287\u00f8d\u1d09\u0254s",
"Amount": "\u023a\u026f\u00f8nn\u0287",
"An email has been sent to {userEmail} with a link for you to activate your account.": "\u023an \u01dd\u026f\u0250\u1d09l \u0265\u0250s b\u01dd\u01ddn s\u01ddn\u0287 \u0287\u00f8 {userEmail} \u028d\u1d09\u0287\u0265 \u0250 l\u1d09n\u029e \u025f\u00f8\u0279 \u028e\u00f8n \u0287\u00f8 \u0250\u0254\u0287\u1d09\u028c\u0250\u0287\u01dd \u028e\u00f8n\u0279 \u0250\u0254\u0254\u00f8nn\u0287.",
"An error has occurred. Check your Internet connection and try again.": "\u023an \u01dd\u0279\u0279\u00f8\u0279 \u0265\u0250s \u00f8\u0254\u0254n\u0279\u0279\u01ddd. \u023b\u0265\u01dd\u0254\u029e \u028e\u00f8n\u0279 \u0197n\u0287\u01dd\u0279n\u01dd\u0287 \u0254\u00f8nn\u01dd\u0254\u0287\u1d09\u00f8n \u0250nd \u0287\u0279\u028e \u0250\u0183\u0250\u1d09n.",
"An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page.": "\u023an \u01dd\u0279\u0279\u00f8\u0279 \u0265\u0250s \u00f8\u0254\u0254n\u0279\u0279\u01ddd. M\u0250\u029e\u01dd sn\u0279\u01dd \u0287\u0265\u0250\u0287 \u028e\u00f8n \u0250\u0279\u01dd \u0254\u00f8nn\u01dd\u0254\u0287\u01ddd \u0287\u00f8 \u0287\u0265\u01dd \u0197n\u0287\u01dd\u0279n\u01dd\u0287, \u0250nd \u0287\u0265\u01ddn \u0287\u0279\u028e \u0279\u01dd\u025f\u0279\u01dds\u0265\u1d09n\u0183 \u0287\u0265\u01dd d\u0250\u0183\u01dd.",
"An error has occurred. Please try again later.": "\u023an \u01dd\u0279\u0279\u00f8\u0279 \u0265\u0250s \u00f8\u0254\u0254n\u0279\u0279\u01ddd. \u2c63l\u01dd\u0250s\u01dd \u0287\u0279\u028e \u0250\u0183\u0250\u1d09n l\u0250\u0287\u01dd\u0279.",
@@ -413,6 +414,7 @@
"Confirm": "\u023b\u00f8n\u025f\u1d09\u0279\u026f",
"Confirm Timed Transcript": "\u023b\u00f8n\u025f\u1d09\u0279\u026f \u0166\u1d09\u026f\u01ddd \u0166\u0279\u0250ns\u0254\u0279\u1d09d\u0287",
"Congratulations! You are now verified on %(platformName)s!": "\u023b\u00f8n\u0183\u0279\u0250\u0287nl\u0250\u0287\u1d09\u00f8ns! \u024e\u00f8n \u0250\u0279\u01dd n\u00f8\u028d \u028c\u01dd\u0279\u1d09\u025f\u1d09\u01ddd \u00f8n %(platformName)s!",
"Congratulations! You have earned a certificate for this course.": "\u023b\u00f8n\u0183\u0279\u0250\u0287nl\u0250\u0287\u1d09\u00f8ns! \u024e\u00f8n \u0265\u0250\u028c\u01dd \u01dd\u0250\u0279n\u01ddd \u0250 \u0254\u01dd\u0279\u0287\u1d09\u025f\u1d09\u0254\u0250\u0287\u01dd \u025f\u00f8\u0279 \u0287\u0265\u1d09s \u0254\u00f8n\u0279s\u01dd.",
"Constrain proportions": "\u023b\u00f8ns\u0287\u0279\u0250\u1d09n d\u0279\u00f8d\u00f8\u0279\u0287\u1d09\u00f8ns",
"Contains staff only content": "\u023b\u00f8n\u0287\u0250\u1d09ns s\u0287\u0250\u025f\u025f \u00f8nl\u028e \u0254\u00f8n\u0287\u01ddn\u0287",
"Contains {count} group": [
@@ -1647,6 +1649,7 @@
"View Teams in the %(topic_name)s Topic": "V\u1d09\u01dd\u028d \u0166\u01dd\u0250\u026fs \u1d09n \u0287\u0265\u01dd %(topic_name)s \u0166\u00f8d\u1d09\u0254",
"View all errors": "V\u1d09\u01dd\u028d \u0250ll \u01dd\u0279\u0279\u00f8\u0279s",
"View discussion": "V\u1d09\u01dd\u028d d\u1d09s\u0254nss\u1d09\u00f8n",
"View/Share Certificate": "V\u1d09\u01dd\u028d/S\u0265\u0250\u0279\u01dd \u023b\u01dd\u0279\u0287\u1d09\u025f\u1d09\u0254\u0250\u0287\u01dd",
"Viewing %s course": [
"V\u1d09\u01dd\u028d\u1d09n\u0183 %s \u0254\u00f8n\u0279s\u01dd",
"V\u1d09\u01dd\u028d\u1d09n\u0183 %s \u0254\u00f8n\u0279s\u01dds"
@@ -1660,6 +1663,7 @@
"Want to confirm your identity later?": "W\u0250n\u0287 \u0287\u00f8 \u0254\u00f8n\u025f\u1d09\u0279\u026f \u028e\u00f8n\u0279 \u1d09d\u01ddn\u0287\u1d09\u0287\u028e l\u0250\u0287\u01dd\u0279?",
"Warning": "W\u0250\u0279n\u1d09n\u0183",
"Warnings": "W\u0250\u0279n\u1d09n\u0183s",
"We ask you to activate your account to ensure it is really you creating the account and to prevent fraud.": "W\u01dd \u0250s\u029e \u028e\u00f8n \u0287\u00f8 \u0250\u0254\u0287\u1d09\u028c\u0250\u0287\u01dd \u028e\u00f8n\u0279 \u0250\u0254\u0254\u00f8nn\u0287 \u0287\u00f8 \u01ddnsn\u0279\u01dd \u1d09\u0287 \u1d09s \u0279\u01dd\u0250ll\u028e \u028e\u00f8n \u0254\u0279\u01dd\u0250\u0287\u1d09n\u0183 \u0287\u0265\u01dd \u0250\u0254\u0254\u00f8nn\u0287 \u0250nd \u0287\u00f8 d\u0279\u01dd\u028c\u01ddn\u0287 \u025f\u0279\u0250nd.",
"We couldn't create your account.": "W\u01dd \u0254\u00f8nldn'\u0287 \u0254\u0279\u01dd\u0250\u0287\u01dd \u028e\u00f8n\u0279 \u0250\u0254\u0254\u00f8nn\u0287.",
"We couldn't find any results for \"%s\".": "W\u01dd \u0254\u00f8nldn'\u0287 \u025f\u1d09nd \u0250n\u028e \u0279\u01ddsnl\u0287s \u025f\u00f8\u0279 \"%s\".",
"We couldn't sign you in.": "W\u01dd \u0254\u00f8nldn'\u0287 s\u1d09\u0183n \u028e\u00f8n \u1d09n.",
@@ -1705,6 +1709,7 @@
"When your face is in position, use the camera button {icon} below to take your photo.": "W\u0265\u01ddn \u028e\u00f8n\u0279 \u025f\u0250\u0254\u01dd \u1d09s \u1d09n d\u00f8s\u1d09\u0287\u1d09\u00f8n, ns\u01dd \u0287\u0265\u01dd \u0254\u0250\u026f\u01dd\u0279\u0250 bn\u0287\u0287\u00f8n {icon} b\u01ddl\u00f8\u028d \u0287\u00f8 \u0287\u0250\u029e\u01dd \u028e\u00f8n\u0279 d\u0265\u00f8\u0287\u00f8.",
"Which timed transcript would you like to use?": "W\u0265\u1d09\u0254\u0265 \u0287\u1d09\u026f\u01ddd \u0287\u0279\u0250ns\u0254\u0279\u1d09d\u0287 \u028d\u00f8nld \u028e\u00f8n l\u1d09\u029e\u01dd \u0287\u00f8 ns\u01dd?",
"Whole words": "W\u0265\u00f8l\u01dd \u028d\u00f8\u0279ds",
"Why activate?": "W\u0265\u028e \u0250\u0254\u0287\u1d09\u028c\u0250\u0287\u01dd?",
"Why does %(platformName)s need my photo?": "W\u0265\u028e d\u00f8\u01dds %(platformName)s n\u01dd\u01ddd \u026f\u028e d\u0265\u00f8\u0287\u00f8?",
"Width": "W\u1d09d\u0287\u0265",
"Will Be Visible To:": "W\u1d09ll \u0243\u01dd V\u1d09s\u1d09bl\u01dd \u0166\u00f8:",

View File

@@ -228,6 +228,7 @@
"Alternative source": "\u0634\u0645\u0641\u062b\u0642\u0631\u0634\u0641\u0647\u062f\u062b \u0633\u062e\u0639\u0642\u0630\u062b",
"Always cohort content-specific discussion topics": "\u0634\u0645\u0635\u0634\u063a\u0633 \u0630\u062e\u0627\u062e\u0642\u0641 \u0630\u062e\u0631\u0641\u062b\u0631\u0641-\u0633\u062d\u062b\u0630\u0647\u0628\u0647\u0630 \u064a\u0647\u0633\u0630\u0639\u0633\u0633\u0647\u062e\u0631 \u0641\u062e\u062d\u0647\u0630\u0633",
"Amount": "\u0634\u0648\u062e\u0639\u0631\u0641",
"An email has been sent to {userEmail} with a link for you to activate your account.": "\u0634\u0631 \u062b\u0648\u0634\u0647\u0645 \u0627\u0634\u0633 \u0632\u062b\u062b\u0631 \u0633\u062b\u0631\u0641 \u0641\u062e {userEmail} \u0635\u0647\u0641\u0627 \u0634 \u0645\u0647\u0631\u0646 \u0628\u062e\u0642 \u063a\u062e\u0639 \u0641\u062e \u0634\u0630\u0641\u0647\u062f\u0634\u0641\u062b \u063a\u062e\u0639\u0642 \u0634\u0630\u0630\u062e\u0639\u0631\u0641.",
"An error has occurred. Check your Internet connection and try again.": "\u0634\u0631 \u062b\u0642\u0642\u062e\u0642 \u0627\u0634\u0633 \u062e\u0630\u0630\u0639\u0642\u0642\u062b\u064a. \u0630\u0627\u062b\u0630\u0646 \u063a\u062e\u0639\u0642 \u0647\u0631\u0641\u062b\u0642\u0631\u062b\u0641 \u0630\u062e\u0631\u0631\u062b\u0630\u0641\u0647\u062e\u0631 \u0634\u0631\u064a \u0641\u0642\u063a \u0634\u0644\u0634\u0647\u0631.",
"An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page.": "\u0634\u0631 \u062b\u0642\u0642\u062e\u0642 \u0627\u0634\u0633 \u062e\u0630\u0630\u0639\u0642\u0642\u062b\u064a. \u0648\u0634\u0646\u062b \u0633\u0639\u0642\u062b \u0641\u0627\u0634\u0641 \u063a\u062e\u0639 \u0634\u0642\u062b \u0630\u062e\u0631\u0631\u062b\u0630\u0641\u062b\u064a \u0641\u062e \u0641\u0627\u062b \u0647\u0631\u0641\u062b\u0642\u0631\u062b\u0641, \u0634\u0631\u064a \u0641\u0627\u062b\u0631 \u0641\u0642\u063a \u0642\u062b\u0628\u0642\u062b\u0633\u0627\u0647\u0631\u0644 \u0641\u0627\u062b \u062d\u0634\u0644\u062b.",
"An error has occurred. Please try again later.": "\u0634\u0631 \u062b\u0642\u0642\u062e\u0642 \u0627\u0634\u0633 \u062e\u0630\u0630\u0639\u0642\u0642\u062b\u064a. \u062d\u0645\u062b\u0634\u0633\u062b \u0641\u0642\u063a \u0634\u0644\u0634\u0647\u0631 \u0645\u0634\u0641\u062b\u0642.",
@@ -413,6 +414,7 @@
"Confirm": "\u0630\u062e\u0631\u0628\u0647\u0642\u0648",
"Confirm Timed Transcript": "\u0630\u062e\u0631\u0628\u0647\u0642\u0648 \u0641\u0647\u0648\u062b\u064a \u0641\u0642\u0634\u0631\u0633\u0630\u0642\u0647\u062d\u0641",
"Congratulations! You are now verified on %(platformName)s!": "\u0630\u062e\u0631\u0644\u0642\u0634\u0641\u0639\u0645\u0634\u0641\u0647\u062e\u0631\u0633! \u063a\u062e\u0639 \u0634\u0642\u062b \u0631\u062e\u0635 \u062f\u062b\u0642\u0647\u0628\u0647\u062b\u064a \u062e\u0631 %(platformName)s!",
"Congratulations! You have earned a certificate for this course.": "\u0630\u062e\u0631\u0644\u0642\u0634\u0641\u0639\u0645\u0634\u0641\u0647\u062e\u0631\u0633! \u063a\u062e\u0639 \u0627\u0634\u062f\u062b \u062b\u0634\u0642\u0631\u062b\u064a \u0634 \u0630\u062b\u0642\u0641\u0647\u0628\u0647\u0630\u0634\u0641\u062b \u0628\u062e\u0642 \u0641\u0627\u0647\u0633 \u0630\u062e\u0639\u0642\u0633\u062b.",
"Constrain proportions": "\u0630\u062e\u0631\u0633\u0641\u0642\u0634\u0647\u0631 \u062d\u0642\u062e\u062d\u062e\u0642\u0641\u0647\u062e\u0631\u0633",
"Contains staff only content": "\u0630\u062e\u0631\u0641\u0634\u0647\u0631\u0633 \u0633\u0641\u0634\u0628\u0628 \u062e\u0631\u0645\u063a \u0630\u062e\u0631\u0641\u062b\u0631\u0641",
"Contains {count} group": [
@@ -1647,6 +1649,7 @@
"View Teams in the %(topic_name)s Topic": "\u062f\u0647\u062b\u0635 \u0641\u062b\u0634\u0648\u0633 \u0647\u0631 \u0641\u0627\u062b %(topic_name)s \u0641\u062e\u062d\u0647\u0630",
"View all errors": "\u062f\u0647\u062b\u0635 \u0634\u0645\u0645 \u062b\u0642\u0642\u062e\u0642\u0633",
"View discussion": "\u062f\u0647\u062b\u0635 \u064a\u0647\u0633\u0630\u0639\u0633\u0633\u0647\u062e\u0631",
"View/Share Certificate": "\u062f\u0647\u062b\u0635/\u0633\u0627\u0634\u0642\u062b \u0630\u062b\u0642\u0641\u0647\u0628\u0647\u0630\u0634\u0641\u062b",
"Viewing %s course": [
"\u062f\u0647\u062b\u0635\u0647\u0631\u0644 %s \u0630\u062e\u0639\u0642\u0633\u062b",
"\u062f\u0647\u062b\u0635\u0647\u0631\u0644 %s \u0630\u062e\u0639\u0642\u0633\u062b\u0633"
@@ -1660,6 +1663,7 @@
"Want to confirm your identity later?": "\u0635\u0634\u0631\u0641 \u0641\u062e \u0630\u062e\u0631\u0628\u0647\u0642\u0648 \u063a\u062e\u0639\u0642 \u0647\u064a\u062b\u0631\u0641\u0647\u0641\u063a \u0645\u0634\u0641\u062b\u0642?",
"Warning": "\u0635\u0634\u0642\u0631\u0647\u0631\u0644",
"Warnings": "\u0635\u0634\u0642\u0631\u0647\u0631\u0644\u0633",
"We ask you to activate your account to ensure it is really you creating the account and to prevent fraud.": "\u0635\u062b \u0634\u0633\u0646 \u063a\u062e\u0639 \u0641\u062e \u0634\u0630\u0641\u0647\u062f\u0634\u0641\u062b \u063a\u062e\u0639\u0642 \u0634\u0630\u0630\u062e\u0639\u0631\u0641 \u0641\u062e \u062b\u0631\u0633\u0639\u0642\u062b \u0647\u0641 \u0647\u0633 \u0642\u062b\u0634\u0645\u0645\u063a \u063a\u062e\u0639 \u0630\u0642\u062b\u0634\u0641\u0647\u0631\u0644 \u0641\u0627\u062b \u0634\u0630\u0630\u062e\u0639\u0631\u0641 \u0634\u0631\u064a \u0641\u062e \u062d\u0642\u062b\u062f\u062b\u0631\u0641 \u0628\u0642\u0634\u0639\u064a.",
"We couldn't create your account.": "\u0635\u062b \u0630\u062e\u0639\u0645\u064a\u0631'\u0641 \u0630\u0642\u062b\u0634\u0641\u062b \u063a\u062e\u0639\u0642 \u0634\u0630\u0630\u062e\u0639\u0631\u0641.",
"We couldn't find any results for \"%s\".": "\u0635\u062b \u0630\u062e\u0639\u0645\u064a\u0631'\u0641 \u0628\u0647\u0631\u064a \u0634\u0631\u063a \u0642\u062b\u0633\u0639\u0645\u0641\u0633 \u0628\u062e\u0642 \"%s\".",
"We couldn't sign you in.": "\u0635\u062b \u0630\u062e\u0639\u0645\u064a\u0631'\u0641 \u0633\u0647\u0644\u0631 \u063a\u062e\u0639 \u0647\u0631.",
@@ -1705,6 +1709,7 @@
"When your face is in position, use the camera button {icon} below to take your photo.": "\u0635\u0627\u062b\u0631 \u063a\u062e\u0639\u0642 \u0628\u0634\u0630\u062b \u0647\u0633 \u0647\u0631 \u062d\u062e\u0633\u0647\u0641\u0647\u062e\u0631, \u0639\u0633\u062b \u0641\u0627\u062b \u0630\u0634\u0648\u062b\u0642\u0634 \u0632\u0639\u0641\u0641\u062e\u0631 {icon} \u0632\u062b\u0645\u062e\u0635 \u0641\u062e \u0641\u0634\u0646\u062b \u063a\u062e\u0639\u0642 \u062d\u0627\u062e\u0641\u062e.",
"Which timed transcript would you like to use?": "\u0635\u0627\u0647\u0630\u0627 \u0641\u0647\u0648\u062b\u064a \u0641\u0642\u0634\u0631\u0633\u0630\u0642\u0647\u062d\u0641 \u0635\u062e\u0639\u0645\u064a \u063a\u062e\u0639 \u0645\u0647\u0646\u062b \u0641\u062e \u0639\u0633\u062b?",
"Whole words": "\u0635\u0627\u062e\u0645\u062b \u0635\u062e\u0642\u064a\u0633",
"Why activate?": "\u0635\u0627\u063a \u0634\u0630\u0641\u0647\u062f\u0634\u0641\u062b?",
"Why does %(platformName)s need my photo?": "\u0635\u0627\u063a \u064a\u062e\u062b\u0633 %(platformName)s \u0631\u062b\u062b\u064a \u0648\u063a \u062d\u0627\u062e\u0641\u062e?",
"Width": "\u0635\u0647\u064a\u0641\u0627",
"Will Be Visible To:": "\u0635\u0647\u0645\u0645 \u0632\u062b \u062f\u0647\u0633\u0647\u0632\u0645\u062b \u0641\u062e:",

View File

@@ -61,7 +61,7 @@
"%d \u5206\u949f"
],
"%d month": [
"%d \u6708"
"%d \u4e2a\u6708"
],
"%d year": [
"%d \u5e74"
@@ -77,7 +77,7 @@
"(%(num_points)s point possible)": [
"\uff08\u53ef\u80fd\u4e3a %(num_points)s \u5206\uff09"
],
"(Caption will be displayed when you start playing the video.)": "(\u5f53\u89c6\u9891\u5f00\u59cb\u62e8\u653e\u65f6\u6807\u9898\u5c06\u4f1a\u663e\u793a)",
"(Caption will be displayed when you start playing the video.)": "(\u5f53\u89c6\u9891\u5f00\u59cb\u64ad\u653e\u65f6\u5c06\u663e\u793a\u5b57\u5e55)",
"(Required Field)": "(\u5fc5\u586b\u5b57\u6bb5)",
"(contains %(student_count)s student)": [
"\uff08\u5305\u62ec %(student_count)s \u4e2a\u5b66\u751f\uff09"
@@ -100,7 +100,7 @@
"Align right": "\u53f3\u5bf9\u9f50",
"Alignment": "\u5bf9\u9f50\u65b9\u5f0f",
"All accounts were created successfully.": "\u6240\u6709\u8d26\u6237\u521b\u5efa\u6210\u529f\u3002",
"All flags have been removed. To undo, uncheck the box.": "\u6240\u6709\u6807\u8bb0\u5df2\u79fb\u9664\u3002\u53d6\u6d88\u9009\u4e2d\u4fe1\u606f\u6846\u4ee5\u64a4\u9500\u3002",
"All flags have been removed. To undo, uncheck the box.": "\u6240\u6709\u6807\u8bb0\u5df2\u79fb\u9664\u3002\u53d6\u6d88\u9009\u4e2d\u6b64\u9009\u6846\u4ee5\u64a4\u9500\u3002",
"All groups must have a name.": "\u6240\u6709\u7ec4\u90fd\u5fc5\u987b\u6709\u540d\u79f0\u3002",
"All groups must have a unique name.": "\u6240\u6709\u7684\u7ec4\u7684\u540d\u5b57\u5fc5\u987b\u662f\u552f\u4e00\u7684\u3002",
"All professional education courses are fee-based, and require payment to complete the enrollment process.": "\u6240\u6709\u7684\u4e13\u4e1a\u6559\u80b2\u8bfe\u7a0b\u90fd\u662f\u6536\u8d39\u7684\uff0c\u5fc5\u987b\u6210\u529f\u4ea4\u8d39\u624d\u80fd\u5b8c\u6210\u9009\u8bfe\u8fc7\u7a0b\u3002",
@@ -114,24 +114,24 @@
"Alternative source": "\u5907\u7528\u6e90",
"Always cohort content-specific discussion topics": "\u603b\u662f\u6839\u636e\u7279\u5b9a\u5185\u5bb9\u7684\u8ba8\u8bba\u8bdd\u9898\u5206\u7ec4",
"Amount": "\u91d1\u989d",
"An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page.": "\u51fa\u73b0\u4e86\u4e00\u4e2a\u9519\u8bef\u3002\u8bf7\u786e\u4fdd\u4f60\u5df2\u8054\u7f51\uff0c\u7136\u540e\u5237\u65b0\u9875\u9762\u3002",
"An error has occurred. Please try again later.": "\u53d1\u751f\u4e86\u4e00\u4e2a\u672a\u77e5\u9519\u8bef\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002",
"An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page.": "\u51fa\u73b0\u4e86\u4e00\u4e2a\u9519\u8bef\u3002\u8bf7\u786e\u4fdd\u60a8\u5df2\u8054\u7f51\uff0c\u7136\u540e\u5237\u65b0\u9875\u9762\u3002",
"An error has occurred. Please try again later.": "\u53d1\u751f\u4e86\u4e00\u4e2a\u9519\u8bef\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002",
"An error has occurred. Please try again.": "\u53d1\u751f\u4e86\u4e00\u4e2a\u672a\u77e5\u9519\u8bef\uff0c\u8bf7\u91cd\u8bd5\u3002",
"An error has occurred. Please try reloading the page.": "\u53d1\u751f\u4e86\u4e00\u4e2a\u9519\u8bef\u3002\u8bf7\u91cd\u65b0\u52a0\u8f7d\u8fd9\u4e2a\u9875\u9762\u3002",
"An error occurred retrieving your email. Please try again later, and contact technical support if the problem persists.": "\u83b7\u53d6\u90ae\u4ef6\u53d1\u751f\u9519\u8bef\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002\u5982\u95ee\u9898\u6301\u7eed\u53d1\u751f\uff0c\u8bf7\u54a8\u8be2\u6280\u672f\u652f\u6301\u3002",
"An error occurred.": "\u53d1\u751f\u4e86\u4e00\u4e2a\u9519\u8bef\u3002",
"An error occurred. Make sure that the student's username or email address is correct and try again.": "\u53d1\u751f\u4e86\u4e00\u4e2a\u9519\u8bef\uff0c\u8bf7\u786e\u8ba4\u5b66\u751f\u7528\u6237\u540d\u6216\u7535\u5b50\u90ae\u4ef6\u5730\u5740\u6b63\u786e\u540e\u518d\u8bd5\u4e00\u6b21\u3002",
"An error occurred. Please try again later.": "\u51fa\u73b0\u4e86\u672a\u77e5\u9519\u8bef\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002",
"An error occurred. Make sure that the student's username or email address is correct and try again.": "\u53d1\u751f\u4e86\u4e00\u4e2a\u9519\u8bef\uff0c\u8bf7\u786e\u8ba4\u5b66\u751f\u7528\u6237\u540d\u6216\u7535\u5b50\u90ae\u4ef6\u5730\u5740\u6b63\u786e\u5e76\u518d\u6b21\u5c1d\u8bd5\u3002",
"An error occurred. Please try again later.": "\u51fa\u73b0\u4e86\u4e00\u4e2a\u9519\u8bef\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002",
"Anchor": "\u951a\u70b9",
"Anchors": "\u951a\u70b9",
"Annotation": "\u6279\u6ce8",
"Annotation Text": "\u6279\u6ce8\u6587\u672c",
"Answer hidden": "\u7b54\u6848\u9690\u85cf",
"Answer:": "\u56de\u7b54\uff1a",
"Are you sure you want to delete this comment?": "\u4f60\u786e\u5b9a\u8981\u5220\u9664\u8fd9\u6761\u8bc4\u8bba\u5417\uff1f",
"Are you sure you want to delete this comment?": "\u60a8\u786e\u5b9a\u8981\u5220\u9664\u8fd9\u6761\u8bc4\u8bba\u5417\uff1f",
"Are you sure you want to delete this page? This action cannot be undone.": "\u60a8\u786e\u8ba4\u8981\u5220\u9664\u8be5\u9875\u9762\u5417\uff1f\u8be5\u64cd\u4f5c\u65e0\u6cd5\u64a4\u9500\u3002",
"Are you sure you want to delete this post?": "\u60a8\u786e\u5b9a\u8981\u5220\u9664\u8fd9\u4e2a\u5e16\u5b50\uff1f",
"Are you sure you want to delete this response?": "\u4f60\u786e\u5b9a\u8981\u5220\u9664\u8fd9\u4e2a\u56de\u590d\u5417",
"Are you sure you want to delete this response?": "\u60a8\u786e\u5b9a\u8981\u5220\u9664\u8fd9\u4e2a\u56de\u590d\u5417",
"Are you sure you want to delete this update?": "\u60a8\u786e\u5b9a\u8981\u5220\u9664\u6b64\u66f4\u65b0\u5417\uff1f",
"Are you sure you want to delete {email} from the course team for \u201c{container}\u201d?": "\u60a8\u786e\u5b9a\u8981\u4ece\u201c{container}\u201d\u7684\u8bfe\u7a0b\u56e2\u961f\u4e2d\u5220\u9664{email}\uff1f",
"Are you sure you want to delete {email} from the library \u201c{container}\u201d?": "\u60a8\u786e\u5b9a\u8981\u4ece\u77e5\u8bc6\u5e93\u201c{container}\u201d\u4e2d\u5220\u9664{email}\uff1f",
@@ -175,12 +175,12 @@
"Change the settings for %(display_name)s": "\u4fee\u6539%(display_name)s\u7684\u8bbe\u7f6e",
"Check Your Email": "\u68c0\u67e5\u4f60\u7684\u7535\u5b50\u90ae\u4ef6",
"Check the box to remove %(count)s flag.": [
"\u9009\u4e2d\u590d\u9009\u6846\u4ee5\u53bb\u9664 %(count)s \u4e2a\u6807\u8bb0\u3002"
"\u9009\u4e2d\u6b64\u9009\u6846\u4ee5\u79fb\u9664 %(count)s \u4e2a\u6807\u8bb0\u3002"
],
"Check the box to remove %(totalFlags)s flag.": [
"\u9009\u4e2d\u590d\u9009\u6846\u4ee5\u53bb\u9664 %(totalFlags)s \u4e2a\u6807\u8bb0\u3002"
"\u9009\u4e2d\u6b64\u9009\u6846\u4ee5\u79fb\u9664\u6240\u6709 %(totalFlags)s \u4e2a\u6807\u8bb0\u3002"
],
"Check the box to remove all flags.": "\u67e5\u770b\u4fe1\u606f\u680f\u4ee5\u79fb\u9664\u6240\u6709\u6807\u8bb0",
"Check the box to remove all flags.": "\u9009\u4e2d\u6b64\u9009\u6846\u4ee5\u79fb\u9664\u6240\u6709\u6807\u8bb0",
"Check your email": "\u67e5\u6536\u4f60\u7684\u90ae\u4ef6",
"Choose File": "\u9009\u62e9\u6587\u4ef6",
"Choose a .csv file": "\u9009\u62e9\u4e00\u4e2a.csv\u7684\u6587\u4ef6",
@@ -191,8 +191,8 @@
"Clear formatting": "\u6e05\u9664\u683c\u5f0f",
"Clear search results": "\u6e05\u7a7a\u641c\u7d22\u7ed3\u679c",
"Click OK to have your e-mail address sent to a 3rd party application.\n\nClick Cancel to return to this page without sending your information.": "\u5355\u51fb\u786e\u5b9a\uff0c\u5c06\u4f60\u7684\u7535\u5b50\u90ae\u4ef6\u5730\u5740\u53d1\u9001\u7ed9\u7b2c\u4e09\u65b9\u5e94\u7528\u7a0b\u5e8f\u3002\n\n\u5355\u51fb\u53d6\u6d88\uff0c\u53d6\u6d88\u53d1\u9001\u4fe1\u606f\u5e76\u8fd4\u56de\u672c\u9875\u3002",
"Click OK to have your username and e-mail address sent to a 3rd party application.\n\nClick Cancel to return to this page without sending your information.": "\u5355\u51fb\u786e\u5b9a\uff0c\u5c06\u4f60\u7684\u7528\u6237\u540d\u548c\u7535\u5b50\u90ae\u4ef6\u5730\u5740\u53d1\u9001\u7ed9\u7b2c\u4e09\u65b9\u5e94\u7528\u7a0b\u5e8f\u3002\n\n\u5355\u51fb\u53d6\u6d88\uff0c\u53d6\u6d88\u53d1\u9001\u4fe1\u606f\u5e76\u8fd4\u56de\u672c\u9875\u3002",
"Click OK to have your username sent to a 3rd party application.\n\nClick Cancel to return to this page without sending your information.": "\u5355\u51fb\u786e\u5b9a\uff0c\u5c06\u4f60\u7684\u7528\u6237\u540d\u5411\u7b2c3\u65b9\u5e94\u7528\u7a0b\u5e8f\u53d1\u9001\u90ae\u4ef6\u3002\n\n\u5355\u51fb\u53d6\u6d88\uff0c\u53d6\u6d88\u53d1\u9001\u4fe1\u606f\u5e76\u8fd4\u56de\u672c\u9875\u3002",
"Click OK to have your username and e-mail address sent to a 3rd party application.\n\nClick Cancel to return to this page without sending your information.": "\u5355\u51fb\u786e\u5b9a\uff0c\u5c06\u60a8\u7684\u7528\u6237\u540d\u548c\u7535\u5b50\u90ae\u4ef6\u5730\u5740\u53d1\u9001\u7ed9\u7b2c\u4e09\u65b9\u5e94\u7528\u7a0b\u5e8f\u3002\n\n\u5355\u51fb\u53d6\u6d88\uff0c\u53d6\u6d88\u53d1\u9001\u4fe1\u606f\u5e76\u8fd4\u56de\u672c\u9875\u3002",
"Click OK to have your username sent to a 3rd party application.\n\nClick Cancel to return to this page without sending your information.": "\u5355\u51fb\u786e\u5b9a\uff0c\u5c06\u60a8\u7684\u7528\u6237\u540d\u53d1\u9001\u7ed9\u7b2c3\u65b9\u5e94\u7528\u7a0b\u5e8f\u3002\n\n\u5355\u51fb\u53d6\u6d88\uff0c\u53d6\u6d88\u53d1\u9001\u4fe1\u606f\u5e76\u8fd4\u56de\u672c\u9875\u3002",
"Close": "\u5173\u95ed",
"Close Calculator": "\u5173\u95ed\u8ba1\u7b97\u5668",
"Code": "\u4ee3\u7801",
@@ -222,7 +222,7 @@
"Correct failed component": "\u7ea0\u6b63\u5931\u8d25\u7684\u7ec4\u4ef6",
"Could not find a user with username or email address '<%= identifier %>'.": "\u627e\u4e0d\u5230\u7528\u6237\u540d\u6216\u7535\u5b50\u90ae\u4ef6\u5730\u5740\u4e3a\u201c<%= identifier %>\u201d\u7684\u7528\u6237\u3002",
"Could not find the specified string.": "\u65e0\u6cd5\u627e\u5230\u6307\u5b9a\u7684\u5b57\u7b26\u4e32\u3002",
"Could not find users associated with the following identifiers:": "\u672a\u80fd\u627e\u5230\u4e0e\u4ee5\u4e0bID\u5173\u8054\u7684\u7528\u6237\uff1a",
"Could not find users associated with the following identifiers:": "\u672a\u80fd\u627e\u5230\u4e0e\u4ee5\u4e0b\u8bc6\u522b\u7801\u5173\u8054\u7684\u7528\u6237\uff1a",
"Could not retrieve payment information": "\u65e0\u6cd5\u8bfb\u53d6\u652f\u4ed8\u4fe1\u606f",
"Could not submit order": "\u8ba2\u5355\u63d0\u4ea4\u5931\u8d25",
"Could not submit photos": "\u7167\u7247\u63d0\u4ea4\u5931\u8d25",
@@ -239,7 +239,7 @@
"Creating missing groups": "\u6b63\u5728\u521b\u5efa\u7f3a\u5931\u7684\u7ec4\u3002",
"Crossed out items have been refunded.": "\u5212\u6389\u7684\u9879\u76ee\u5df2\u9000\u6b3e\u3002",
"Current conversation": "\u5f53\u524d\u5bf9\u8bdd",
"Current tab": "\u5f53\u524d\u9009\u9879",
"Current tab": "\u5f53\u524d\u6807\u7b7e",
"Custom color": "\u81ea\u5b9a\u4e49\u989c\u8272",
"Custom...": "\u81ea\u5b9a\u4e49\u2026",
"Cut": "\u526a\u5207",
@@ -256,7 +256,7 @@
"Delete Page Confirmation": "\u786e\u8ba4\u5220\u9664\u9875\u9762",
"Delete column": "\u5220\u9664\u5217",
"Delete row": "\u5220\u9664\u884c",
"Delete student '<%= student_id %>'s state on problem '<%= problem_id %>'?": "\u786e\u8ba4\u5220\u9664\u5b66\u751f\u201c<%= student_id %>\u201d\u5728\u95ee\u9898\u201c<%= problem_id %>\u201d\u4e0a\u7684\u72b6\u6001?",
"Delete student '<%= student_id %>'s state on problem '<%= problem_id %>'?": "\u786e\u8ba4\u5220\u9664\u5b66\u751f'<%= student_id %>'\u5728\u95ee\u9898'<%= problem_id %>'\u4e0a\u7684\u72b6\u6001?",
"Delete table": "\u5220\u9664\u8868\u683c",
"Delete this %(item_display_name)s?": "\u8981\u5220\u9664\u8be5%(item_display_name)s\u5417\uff1f",
"Delete this %(xblock_type)s?": "\u8981\u5220\u9664\u8be5%(xblock_type)s\u5417\uff1f",
@@ -274,15 +274,15 @@
"Discussion": "\u8ba8\u8bba",
"Discussion admins, moderators, and TAs can make their posts visible to all students or specify a single cohort.": "\u8ba8\u8bba\u533a\u7ba1\u7406\u5458\u3001\u7248\u4e3b\u4ee5\u53ca\u52a9\u6559\u53ef\u4ee5\u5c06\u5b83\u4eec\u7684\u5e16\u5b50\u8bbe\u7f6e\u4e3a\u5bf9\u6240\u6709\u5b66\u751f\u53ef\u89c1\u6216\u8005\u4ec5\u5bf9\u67d0\u4e2a\u7fa4\u7ec4\u53ef\u89c1\u3002",
"Div": "Div \u6807\u7b7e",
"Do not show again": "\u4e0d\u5728\u663e\u793a",
"Do you want to allow this student ('{student_id}') to skip the entrance exam?": "\u60a8\u662f\u5426\u5141\u8bb8\u8be5\u5b66\u751f(\u201c{student_id}\u201d)\u8df3\u8fc7\u5165\u5b66\u8003\u8bd5\uff1f",
"Do not show again": "\u4e0d\u518d\u663e\u793a",
"Do you want to allow this student ('{student_id}') to skip the entrance exam?": "\u60a8\u662f\u5426\u5141\u8bb8\u8be5\u5b66\u751f('{student_id}')\u8df3\u8fc7\u5165\u5b66\u8003\u8bd5\uff1f",
"Document properties": "\u6587\u6863\u5c5e\u6027",
"Does the name on your ID match your account name: %(fullName)s?": "\u4f60\u8eab\u4efd\u8bc1\u4ef6\u4e0a\u7684\u59d3\u540d\u548c\u4f60\u5728\u8d26\u6237\u4e2d\u586b\u5199\u7684\u59d3\u540d\u201c%(fullName)s\u201d\u76f8\u7b26\u5417\uff1f",
"Does the photo of you match your ID photo?": "\u8fd9\u5f20\u7167\u7247\u548c\u4f60\u8eab\u4efd\u8bc1\u4ef6\u4e0a\u7684\u7167\u7247\u76f8\u5339\u914d\u5417\uff1f",
"Does the photo of you show your whole face?": "\u8fd9\u5f20\u7167\u7247\u4e2d\u6709\u4f60\u7684\u6574\u5f20\u8138\u5417\uff1f",
"Don't see your picture? Make sure to allow your browser to use your camera when it asks for permission.": "\u6ca1\u6709\u770b\u5230\u4f60\u81ea\u5df1\uff1f\u8bf7\u786e\u8ba4\u5f53\u6d4f\u89c8\u5668\u8bf7\u6c42\u4f7f\u7528\u6444\u50cf\u5934\u6743\u9650\u7684\u65f6\u5019\u4f60\u9009\u62e9\u4e86\u5141\u8bb8\u3002",
"Donate": "\u6350\u732e",
"Double-check that your webcam is connected and working to continue.": "\u518d\u6b21\u786e\u8ba4\u4f60\u7684\u6444\u50cf\u5934\u5df2\u7ecf\u8fde\u63a5\u5e76\u4e14\u6b63\u5e38\u5de5\u4f5c\u4ee5\u7ee7\u7eed\u3002",
"Double-check that your webcam is connected and working to continue.": "\u7ee7\u7eed\u524d\u8bf7\u518d\u6b21\u786e\u8ba4\u60a8\u7684\u6444\u50cf\u5934\u5df2\u7ecf\u8fde\u63a5\u5e76\u4e14\u53ef\u4ee5\u6b63\u5e38\u4f7f\u7528\u3002",
"Drop target image": "\u62d6\u653e\u7684\u76ee\u6807\u56fe\u50cf",
"Due Date": "\u622a\u6b62\u65e5\u671f",
"Duplicating": "\u6b63\u5728\u590d\u5236",
@@ -307,32 +307,32 @@
"Enter the name of the cohort": "\u8bf7\u8f93\u5165\u7fa4\u7ec4\u7684\u540d\u5b57",
"Enter username or email": "\u8f93\u5165\u7528\u6237\u540d\u6216\u8005\u7535\u5b50\u90ae\u4ef6\u5730\u5740",
"Entrance exam attempts is being reset for student '{student_id}'.": "\u6b63\u5728\u91cd\u7f6e\u5b66\u751f\u201c{student_id}\u201d\u7684\u5165\u5b66\u8003\u8bd5\u5c1d\u8bd5\u6b21\u6570\u3002",
"Entrance exam state is being deleted for student '{student_id}'.": "\u5b66\u751f\u201c{student_id}\u201d\u7684\u5165\u5b66\u8003\u8bd5\u7684\u72b6\u6001\u5df2\u88ab\u5220\u9664\u3002",
"Entrance exam state is being deleted for student '{student_id}'.": "\u5b66\u751f'{student_id}'\u7684\u5165\u5b66\u8003\u8bd5\u7684\u72b6\u6001\u5df2\u88ab\u5220\u9664\u3002",
"Error": "\u9519\u8bef",
"Error adding students.": "\u6dfb\u52a0\u5b66\u751f\u51fa\u73b0\u9519\u8bef",
"Error adding user": "\u6dfb\u52a0\u7528\u6237\u8fc7\u7a0b\u4e2d\u51fa\u73b0\u9519\u8bef",
"Error adding/removing users as beta testers.": "\u6dfb\u52a0\uff0f\u5220\u9664beta\u6d4b\u8bd5\u7528\u6237\u51fa\u9519\u3002",
"Error changing user's permissions.": "\u66f4\u6539\u7528\u6237\u6743\u9650\u51fa\u9519\u3002",
"Error deleting entrance exam state for student '{student_id}'. Make sure student identifier is correct.": "\u5220\u9664\u5b66\u751f\u201c{student_id}\u201d\u7684\u5165\u5b66\u8003\u8bd5\u72b6\u6001\u65f6\u51fa\u9519\u4e86\uff0c\u8bf7\u786e\u8ba4\u5b66\u751f\u7f16\u53f7\u65e0\u8bef\u3002",
"Error deleting student '<%= student_id %>'s state on problem '<%= problem_id %>'. Make sure that the problem and student identifiers are complete and correct.": "\u5220\u9664\u5b66\u751f\u201c<%= student_id %>\u201d\u5728\u95ee\u9898\u201c<%= problem_id %>\u201d\u4e0a\u7684\u72b6\u6001\u65f6\u51fa\u9519\u3002\u8bf7\u786e\u8ba4\u8be5\u95ee\u9898\u7684 ID \u53ca\u5b66\u751f\u7684 ID \u662f\u5b8c\u6574\u4e14\u6b63\u786e\u7684\u3002",
"Error enrolling/unenrolling users.": "\u7528\u6237\u9009\u4fee\uff0f\u653e\u5f03\u9009\u4fee\u65f6\u51fa\u9519\u3002",
"Error generating grades. Please try again.": "\u751f\u6210\u8bc4\u5206\u7ed3\u679c\u9519\u8bef\uff0c\u8bf7\u91cd\u8bd5\u3002",
"Error generating student profile information. Please try again.": "\u751f\u6210\u5b66\u751f\u6863\u6848\u4fe1\u606f\u65f6\u51fa\u73b0\u9519\u8bef\uff0c\u8bf7\u91cd\u8bd5\u3002",
"Error getting entrance exam task history for student '{student_id}'. Make sure student identifier is correct.": "\u83b7\u53d6\u5b66\u751f\u201c{student_id}\u201d\u7684\u5165\u5b66\u8003\u8bd5\u4efb\u52a1\u5386\u53f2\u65f6\u51fa\u9519\u4e86\uff0c\u8bf7\u786e\u8ba4\u5b66\u751f\u7f16\u53f7\u65e0\u8bef\u3002",
"Error getting student list.": "\u65e0\u6cd5\u83b7\u53d6\u5b66\u751f\u5217\u8868",
"Error getting student progress url for '<%= student_id %>'. Make sure that the student identifier is spelled correctly.": "\u83b7\u53d6\u5b66\u751f\u201c<%= student_id %>\u201d\u7684\u8fdb\u5ea6 URL \u65f6\u51fa\u9519\u3002\u8bf7\u786e\u8ba4\u8be5\u5b66\u751f\u7684 ID \u5df2\u6b63\u786e\u62fc\u5199\u3002",
"Error getting task history for problem '<%= problem_id %>' and student '<%= student_id %>'. Make sure that the problem and student identifiers are complete and correct.": "\u5728\u83b7\u53d6\u5b66\u751f\u201c<%= student_id %>\u201d\u5bf9\u95ee\u9898\u201c<%= problem_id %>\u201d\u7684\u4efb\u52a1\u5386\u53f2\u65f6\u51fa\u9519\u3002\u8bf7\u786e\u8ba4\u8be5\u95ee\u9898\u7684 ID \u53ca\u5b66\u751f\u7684 ID \u662f\u5b8c\u6574\u4e14\u6b63\u786e\u7684\u3002",
"Error deleting entrance exam state for student '{student_id}'. Make sure student identifier is correct.": "\u5220\u9664\u5b66\u751f'{student_id}'\u7684\u5165\u5b66\u8003\u8bd5\u72b6\u6001\u65f6\u51fa\u9519\u4e86\uff0c\u8bf7\u786e\u8ba4\u5b66\u751f\u7f16\u53f7\u65e0\u8bef\u3002",
"Error deleting student '<%= student_id %>'s state on problem '<%= problem_id %>'. Make sure that the problem and student identifiers are complete and correct.": "\u5220\u9664\u5b66\u751f'<%= student_id %>'\u5728\u95ee\u9898'<%= problem_id %>'\u4e0a\u7684\u72b6\u6001\u65f6\u51fa\u9519\u3002\u8bf7\u786e\u8ba4\u8be5\u95ee\u9898\u7684 ID \u53ca\u5b66\u751f\u7684 ID \u662f\u5b8c\u6574\u4e14\u6b63\u786e\u7684\u3002",
"Error enrolling/unenrolling users.": "\u7528\u6237\u9009\u8bfe\uff0f\u653e\u5f03\u9009\u8bfe\u65f6\u51fa\u9519\u3002",
"Error generating grades. Please try again.": "\u751f\u6210\u8bc4\u5206\u7ed3\u679c\u65f6\u53d1\u751f\u9519\u8bef\uff0c\u8bf7\u91cd\u8bd5\u3002",
"Error generating student profile information. Please try again.": "\u751f\u6210\u5b66\u751f\u6863\u6848\u4fe1\u606f\u65f6\u53d1\u751f\u9519\u8bef\uff0c\u8bf7\u91cd\u8bd5\u3002",
"Error getting entrance exam task history for student '{student_id}'. Make sure student identifier is correct.": "\u83b7\u53d6\u5b66\u751f'{student_id}'\u7684\u5165\u5b66\u8003\u8bd5\u4efb\u52a1\u5386\u53f2\u65f6\u51fa\u9519\u4e86\uff0c\u8bf7\u786e\u8ba4\u5b66\u751f\u7f16\u53f7\u65e0\u8bef\u3002",
"Error getting student list.": "\u83b7\u53d6\u5b66\u751f\u5217\u8868\u65f6\u53d1\u751f\u9519\u8bef",
"Error getting student progress url for '<%= student_id %>'. Make sure that the student identifier is spelled correctly.": "\u83b7\u53d6\u5b66\u751f\u201c<%= student_id %>\u201d\u7684\u8fdb\u5ea6 URL \u65f6\u51fa\u9519\u3002\u8bf7\u786e\u8ba4\u8be5\u5b66\u751f\u7684 ID \u62fc\u5199\u6b63\u786e\u3002",
"Error getting task history for problem '<%= problem_id %>' and student '<%= student_id %>'. Make sure that the problem and student identifiers are complete and correct.": "\u5728\u83b7\u53d6\u5b66\u751f'<%= student_id %>'\u548c\u95ee\u9898'<%= problem_id %>'\u7684\u4efb\u52a1\u5386\u53f2\u65f6\u51fa\u9519\u3002\u8bf7\u786e\u8ba4\u8be5\u95ee\u9898\u7684 ID \u53ca\u5b66\u751f\u7684 ID \u662f\u5b8c\u6574\u4e14\u6b63\u786e\u7684\u3002",
"Error importing course": "\u5bfc\u5165\u8bfe\u7a0b\u8fc7\u7a0b\u4e2d\u51fa\u73b0\u9519\u8bef",
"Error listing task history for this student and problem.": "\u5217\u8868\u663e\u793a\u8be5\u540d\u5b66\u751f\u4e0e\u95ee\u9898\u7684\u4efb\u52a1\u5386\u53f2\u65f6\u53d1\u751f\u9519\u8bef\u3002",
"Error listing task history for this student and problem.": "\u663e\u793a\u6b64\u5b66\u751f\u4e0e\u95ee\u9898\u7684\u4efb\u52a1\u5386\u53f2\u65f6\u53d1\u751f\u9519\u8bef\u3002",
"Error removing user": "\u5220\u9664\u7528\u6237\u8fc7\u7a0b\u4e2d\u51fa\u73b0\u9519\u8bef",
"Error resetting entrance exam attempts for student '{student_id}'. Make sure student identifier is correct.": "\u91cd\u7f6e\u5b66\u751f\u201c{student_id}\u201d\u7684\u5165\u5b66\u8003\u8bd5\u5c1d\u8bd5\u6b21\u6570\u65f6\u51fa\u9519\u4e86\uff0c\u8bf7\u786e\u8ba4\u5b66\u751f\u7f16\u53f7\u65e0\u8bef\u3002",
"Error resetting problem attempts for problem '<%= problem_id %>' and student '<%= student_id %>'. Make sure that the problem and student identifiers are complete and correct.": "\u91cd\u7f6e\u5b66\u751f\u201c<%= student_id %>\u201d\u5bf9\u95ee\u9898\u201c<%= problem_id %>\u201d\u7684\u5c1d\u8bd5\u6b21\u6570\u65f6\u51fa\u9519\u3002\u8bf7\u786e\u8ba4\u8be5\u95ee\u9898\u7684 ID \u53ca\u5b66\u751f\u7684 ID \u5df2\u6b63\u786e\u62fc\u5199\u3002",
"Error resetting entrance exam attempts for student '{student_id}'. Make sure student identifier is correct.": "\u91cd\u7f6e\u5b66\u751f'{student_id}'\u7684\u5165\u5b66\u8003\u8bd5\u5c1d\u8bd5\u6b21\u6570\u65f6\u51fa\u9519\u4e86\uff0c\u8bf7\u786e\u8ba4\u5b66\u751f\u7f16\u53f7\u65e0\u8bef\u3002",
"Error resetting problem attempts for problem '<%= problem_id %>' and student '<%= student_id %>'. Make sure that the problem and student identifiers are complete and correct.": "\u91cd\u7f6e\u5b66\u751f'<%= problem_id %>'\u5bf9\u95ee\u9898 '<%= student_id %>'\u7684\u5c1d\u8bd5\u6b21\u6570\u65f6\u51fa\u9519\u3002\u8bf7\u786e\u8ba4\u8be5\u95ee\u9898ID\u53ca\u5b66\u751fID \u62fc\u5199\u6b63\u786e\u3002",
"Error retrieving grading configuration.": "\u53d6\u5f97\u8bc4\u5206\u6807\u51c6\u65f6\u9519\u8bef\u3002",
"Error sending email.": "\u53d1\u9001\u7535\u5b50\u90ae\u4ef6\u9519\u8bef\u3002",
"Error starting a task to rescore entrance exam for student '{student_id}'. Make sure that entrance exam has problems in it and student identifier is correct.": "\u4e3a\u5b66\u751f\u201c{student_id}\u201d\u5f00\u59cb\u8fd0\u884c\u91cd\u65b0\u8ba1\u7b97\u5165\u5b66\u8003\u8bd5\u5206\u6570\u7684\u4efb\u52a1\u65f6\u51fa\u9519\u4e86\uff0c\u8bf7\u786e\u8ba4\u8be5\u5165\u5b66\u8003\u8bd5\u4e2d\u6709\u9898\u76ee\u5e76\u4e14\u5b66\u751f\u7f16\u53f7\u65e0\u8bef\u3002",
"Error starting a task to rescore problem '<%= problem_id %>' for student '<%= student_id %>'. Make sure that the the problem and student identifiers are complete and correct.": "\u4e3a\u5b66\u751f\u201c<%= student_id %>\u201d\u542f\u52a8\u5bf9\u95ee\u9898\u201c<%= problem_id %>\u201d\u7684\u91cd\u65b0\u8bc4\u5206\u4efb\u52a1\u65f6\u51fa\u9519\u3002\u8bf7\u786e\u8ba4\u8be5\u95ee\u9898\u7684 ID \u53ca\u5b66\u751f\u7684 ID \u662f\u5b8c\u6574\u4e14\u6b63\u786e\u7684\u3002",
"Error starting a task to rescore problem '<%= problem_id %>'. Make sure that the problem identifier is complete and correct.": "\u542f\u52a8\u5bf9\u95ee\u9898\u201c<%= problem_id %>\u201d\u7684\u91cd\u65b0\u8bc4\u5206\u4efb\u52a1\u65f6\u51fa\u9519\u3002\u8bf7\u786e\u8ba4\u8be5\u95ee\u9898\u7684 ID \u662f\u5b8c\u6574\u4e14\u6b63\u786e\u7684\u3002",
"Error starting a task to reset attempts for all students on problem '<%= problem_id %>'. Make sure that the problem identifier is complete and correct.": "\u542f\u52a8\u91cd\u7f6e\u6240\u6709\u5b66\u751f\u5728\u95ee\u9898\u201c<%= problem_id %>\u201d\u4e0a\u5c1d\u8bd5\u6b21\u6570\u7684\u4efb\u52a1\u65f6\u51fa\u9519\u3002\u8bf7\u786e\u8ba4\u8be5\u95ee\u9898\u7684 ID \u662f\u5b8c\u6574\u4e14\u6b63\u786e\u7684\u3002",
"Error sending email.": "\u53d1\u9001\u7535\u5b50\u90ae\u4ef6\u65f6\u51fa\u9519\u3002",
"Error starting a task to rescore entrance exam for student '{student_id}'. Make sure that entrance exam has problems in it and student identifier is correct.": "\u4e3a\u5b66\u751f'{student_id}'\u5f00\u59cb\u8fd0\u884c\u91cd\u65b0\u8ba1\u7b97\u5165\u5b66\u8003\u8bd5\u5206\u6570\u7684\u4efb\u52a1\u65f6\u51fa\u9519\u4e86\uff0c\u8bf7\u786e\u8ba4\u8be5\u5165\u5b66\u8003\u8bd5\u4e2d\u6709\u9898\u76ee\u5e76\u4e14\u5b66\u751f\u7f16\u53f7\u65e0\u8bef\u3002",
"Error starting a task to rescore problem '<%= problem_id %>' for student '<%= student_id %>'. Make sure that the the problem and student identifiers are complete and correct.": "\u4e3a\u5b66\u751f '<%= student_id %>'\u542f\u52a8\u5bf9\u95ee\u9898'<%= problem_id %>'\u7684\u91cd\u65b0\u8bc4\u5206\u4efb\u52a1\u65f6\u51fa\u9519\u3002\u8bf7\u786e\u8ba4\u8be5\u95ee\u9898\u7684 ID \u53ca\u5b66\u751f\u7684 ID \u662f\u5b8c\u6574\u4e14\u6b63\u786e\u7684\u3002",
"Error starting a task to rescore problem '<%= problem_id %>'. Make sure that the problem identifier is complete and correct.": "\u542f\u52a8\u5bf9\u95ee\u9898'<%= problem_id %>'\u7684\u91cd\u65b0\u8bc4\u5206\u4efb\u52a1\u65f6\u51fa\u9519\u3002\u8bf7\u786e\u8ba4\u8be5\u95ee\u9898\u7684 ID \u662f\u5b8c\u6574\u4e14\u6b63\u786e\u7684\u3002",
"Error starting a task to reset attempts for all students on problem '<%= problem_id %>'. Make sure that the problem identifier is complete and correct.": "\u542f\u52a8\u91cd\u7f6e\u6240\u6709\u5b66\u751f\u5728\u95ee\u9898'<%= problem_id %>'\u4e0a\u5c1d\u8bd5\u6b21\u6570\u7684\u4efb\u52a1\u65f6\u51fa\u9519\u3002\u8bf7\u786e\u8ba4\u8be5\u95ee\u9898\u7684 ID \u662f\u5b8c\u6574\u4e14\u6b63\u786e\u7684\u3002",
"Error:": "\u9519\u8bef\uff1a",
"Error: Choosing failed.": "\u9519\u8bef\uff1a\u9009\u62e9\u5931\u8d25\u3002",
"Error: Connection with server failed.": "\u9519\u8bef\uff1a\u8fde\u63a5\u670d\u52a1\u5668\u5931\u8d25\u3002",
@@ -340,9 +340,9 @@
"Error: Replacing failed.": "\u9519\u8bef\uff1a\u66ff\u6362\u5931\u8d25\u3002",
"Error: Uploading failed.": "\u9519\u8bef\uff1a\u4e0a\u4f20\u5931\u8d25\u3002",
"Error: User '<%= username %>' has not yet activated their account. Users must create and activate their accounts before they can be assigned a role.": "\u9519\u8bef\uff1a\u7528\u6237\u201c<%= username %>\u201d\u5c1a\u672a\u6fc0\u6d3b\u4ed6\u7684\u8d26\u6237\uff0c\u7528\u6237\u5fc5\u987b\u5148\u521b\u5efa\u5e76\u6fc0\u6d3b\u540e\u65b9\u53ef\u4e3a\u5176\u5206\u914d\u89d2\u8272\u3002",
"Error: You cannot remove yourself from the Instructor group!": "\u9519\u8bef\uff1a\u60a8\u4e0d\u53ef\u4ee5\u5c06\u81ea\u5df1\u4ece\u4e3b\u8bb2\u6559\u5e08\u7ec4\u4e2d\u5220\u9664\u3002",
"Error: You cannot remove yourself from the Instructor group!": "\u9519\u8bef\uff1a\u60a8\u4e0d\u53ef\u4ee5\u5c06\u81ea\u5df1\u4ece\u6559\u5e08\u7ec4\u4e2d\u5220\u9664\u3002",
"Errors": "\u9519\u8bef",
"Exit full browser": "\u9000\u51fa\u5168\u5c4f\u6d4f\u89c8\u5668",
"Exit full browser": "\u9000\u51fa\u5168\u5c4f",
"Expand Instructions": "\u5c55\u5f00\u8bf4\u660e",
"Expand discussion": "\u5c55\u5f00\u8ba8\u8bba",
"Explicitly Hiding from Students": "\u660e\u786e\u5bf9\u5b66\u751f\u9690\u85cf",
@@ -354,7 +354,7 @@
"File Name": "\u6587\u4ef6\u540d",
"File {filename} exceeds maximum size of {maxFileSizeInMBs} MB": "\u6587\u4ef6 {filename} \u7684\u5927\u5c0f\u8d85\u8fc7\u4e86 {maxFileSizeInMBs} MB \u7684\u9650\u5236",
"Files must be in JPEG or PNG format.": "\u6587\u4ef6\u5fc5\u987b\u662fJPEG\u6216\u8005PNG\u683c\u5f0f\u3002",
"Fill browser": "\u5168\u5c4f\u663e\u793a",
"Fill browser": "\u5168\u5c4f",
"Find": "\u67e5\u627e",
"Find and replace": "\u67e5\u627e\u548c\u66ff\u6362",
"Find discussions": "\u641c\u7d22\u8ba8\u8bba\u5e16",
@@ -430,7 +430,7 @@
"Inline": "\u5bf9\u9f50",
"Insert": "\u63d2\u5165",
"Insert Hyperlink": "\u63d2\u5165\u8d85\u94fe\u63a5",
"Insert Image (upload file or type URL)": "\u63d2\u5165\u56fe\u7247 (\u4e0a\u4f20\u6587\u4ef6\u6216\u8f93\u5165\u56fe\u7247URL)",
"Insert Image (upload file or type URL)": "\u63d2\u5165\u56fe\u7247 (\u4e0a\u4f20\u6587\u4ef6\u6216\u8f93\u5165URL)",
"Insert column after": "\u5728\u53f3\u4fa7\u63d2\u5165\u5217",
"Insert column before": "\u5728\u5de6\u4fa7\u63d2\u5165\u5217",
"Insert date/time": "\u63d2\u5165\u65e5\u671f\uff0f\u65f6\u95f4",
@@ -444,7 +444,7 @@
"Insert/edit image": "\u63d2\u5165\uff0f\u7f16\u8f91\u56fe\u7247",
"Insert/edit link": "\u63d2\u5165\uff0f\u7f16\u8f91\u94fe\u63a5",
"Insert/edit video": "\u63d2\u5165\uff0f\u7f16\u8f91\u89c6\u9891",
"Instructor": "\u4e3b\u8bb2\u6559\u5e08",
"Instructor": "\u6559\u5e08",
"Is your name on your ID readable?": "\u4f60\u8eab\u4efd\u8bc1\u4ef6\u4e0a\u7684\u540d\u5b57\u662f\u5426\u6e05\u6670\u53ef\u89c1\uff1f",
"Italic": "\u659c\u4f53",
"Italic (Ctrl+I)": "\u659c\u4f53(Ctrl+I)",
@@ -457,14 +457,14 @@
"Library User": "\u77e5\u8bc6\u5e93\u7528\u6237",
"Link Description": "\u94fe\u63a5\u7684\u63cf\u8ff0",
"Link types should be unique.": "\u94fe\u63a5\u7c7b\u578b\u5e94\u5f53\u552f\u4e00\u3002",
"Links are generated on demand and expire within 5 minutes due to the sensitive nature of student information.": "\u7531\u4e8e\u6d89\u53ca\u5b66\u751f\u7684\u654f\u611f\u4fe1\u606f\uff0c\u751f\u6210\u7684\u94fe\u63a5\u5c06\u57285\u5206\u949f\u540e\u5931\u6548\u3002",
"Links are generated on demand and expire within 5 minutes due to the sensitive nature of student information.": "\u7531\u4e8e\u5305\u542b\u6d89\u53ca\u5b66\u751f\u7684\u654f\u611f\u4fe1\u606f\uff0c\u751f\u6210\u7684\u94fe\u63a5\u5c06\u57285\u5206\u949f\u540e\u5931\u6548\u3002",
"Links should be unique.": "\u94fe\u63a5\u5e94\u5f53\u552f\u4e00\u3002",
"List item": "\u5217\u8868\u9879",
"Live view of webcam": "\u6444\u50cf\u5934\u7684\u5b9e\u65f6\u753b\u9762",
"Load Another File": "\u52a0\u8f7d\u5176\u4ed6\u6587\u4ef6",
"Load all responses": "\u8f7d\u5165\u6240\u6709\u7684\u56de\u590d",
"Load more": "\u8f7d\u5165\u66f4\u591a",
"Load next %(numResponses)s responses": "\u52a0\u8f7d\u4e0b\u9762\u7684%(numResponses)s\u6761\u56de\u590d",
"Load next %(numResponses)s responses": "\u52a0\u8f7d\u63a5\u4e0b\u6765\u7684%(numResponses)s\u6761\u56de\u590d",
"Load next %(num_items)s result": [
"\u52a0\u8f7d\u540e %(num_items)s \u4e2a\u7ed3\u679c"
],
@@ -473,7 +473,7 @@
"Loading more threads": "\u8f7d\u5165\u66f4\u591a\u7684\u4e3b\u9898",
"Loading thread list": "\u8f7d\u5165\u4e3b\u9898\u5217\u8868",
"Location in Course": "\u8bfe\u7a0b\u4e2d\u7684\u4f4d\u7f6e",
"Loud": "\u5927\u58f0",
"Loud": "\u97f3\u91cf\u9ad8",
"Low": "\u4f4e",
"Lower Alpha": "\u5c0f\u5199\u5b57\u6bcd",
"Lower Greek": "\u5c0f\u5199\u5e0c\u814a\u5b57\u6bcd",
@@ -512,11 +512,11 @@
"No color": "\u65e0\u989c\u8272",
"No content-specific discussion topics exist.": "\u65e0\u7279\u5b9a\u5185\u5bb9\u7684\u8ba8\u8bba\u8bdd\u9898",
"No receipt available": "\u6ca1\u6709\u53ef\u63d0\u4f9b\u7684\u6536\u636e\u3002",
"No results found for \"%(query_string)s\". Please try searching again.": "\u672a\u627e\u5230\u201c%(query_string)s\u201d\u3002\u8bf7\u91cd\u65b0\u641c\u7d22\u3002",
"No results found for \"%(query_string)s\". Please try searching again.": "\u672a\u627e\u5230\u6709\u5173\"%(query_string)s\"\u7684\u4efb\u4f55\u7ed3\u679c\u3002\u8bf7\u91cd\u65b0\u641c\u7d22\u3002",
"No results found for %(original_query)s. Showing results for %(suggested_query)s.": "\u672a\u627e\u5230\u4e0e %(original_query)s \u5339\u914d\u7684\u7ed3\u679c\u3002\u663e\u793a\u7684\u662f\u67e5\u627e %(suggested_query)s \u7684\u7ed3\u679c\u3002",
"No sources": "\u6ca1\u6709\u6e90",
"No tasks currently running.": "\u6ca1\u6709\u6b63\u5728\u6267\u884c\u7684\u4efb\u52a1",
"No threads matched your query.": "\u672a\u627e\u5230\u5339\u914d\u7684\u7ed3\u679c",
"No threads matched your query.": "\u672a\u627e\u5230\u5339\u914d\u7684\u5e16\u5b50",
"Nonbreaking space": "\u4e0d\u95f4\u65ad\u7a7a\u683c",
"None": "\u65e0",
"Not Graded": "\u5c1a\u672a\u8bc4\u5206",
@@ -534,11 +534,11 @@
"Only <%= fileTypes %> files can be uploaded. Please select a file ending in <%= fileExtensions %> to upload.": "\u53ea\u6709 <%= fileTypes %> \u683c\u5f0f\u7684\u6587\u4ef6\u53ef\u4ee5\u4e0a\u4f20\u3002\u8bf7\u9009\u62e9\u4e00\u4e2a\u4ee5 <%= fileExtensions %> \u7ed3\u5c3e\u7684\u6587\u4ef6\u4e0a\u4f20\u3002",
"Only properly formatted .csv files will be accepted.": "\u53ea\u6709\u6807\u51c6\u7684CSV\u683c\u5f0f\u6587\u4ef6\u4f1a\u88ab\u63a5\u53d7\u3002",
"Open Calculator": "\u6253\u5f00\u8ba1\u7b97\u5668",
"Open language menu.": "\u6253\u5f00\u8bed\u8a00\u83dc\u5355\u3002",
"Open language menu.": "\u6253\u5f00\u8bed\u8a00\u529f\u80fd\u83dc\u5355\u3002",
"OpenAssessment Save Error": "\u5f00\u653e\u5f0f\u8bc4\u4f30\u4fdd\u5b58\u9519\u8bef",
"Order No.": "\u8ba2\u5355\u53f7\uff1a",
"Page break": "\u5206\u9875\u7b26",
"Pagination": "\u5206\u9875",
"Pagination": "\u9875\u7801",
"Paragraph": "\u6bb5\u843d",
"Password": "\u5bc6\u7801",
"Password Reset Email Sent": "\u5bc6\u7801\u91cd\u7f6e\u90ae\u4ef6\u5df2\u53d1\u9001",
@@ -547,7 +547,7 @@
"Paste": "\u7c98\u8d34",
"Paste as text": "\u7c98\u8d34\u4e3a\u6587\u672c",
"Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.": "\u5f53\u524d\u4e3a\u7eaf\u6587\u672c\u7c98\u8d34\u6a21\u5f0f\uff0c\u6240\u6709\u5185\u5bb9\u90fd\u5c06\u4ee5\u7eaf\u6587\u672c\u5f62\u5f0f\u7c98\u8d34\u3002\u5173\u95ed\u8be5\u9009\u9879\u4ee5\u56de\u5230\u666e\u901a\u7c98\u8d34\u6a21\u5f0f\u3002",
"Paste row after": "\u5728\u4e0a\u65b9\u7c98\u8d34\u884c",
"Paste row after": "\u5728\u4e0b\u65b9\u7c98\u8d34\u884c",
"Paste row before": "\u5728\u4e0a\u65b9\u7c98\u8d34\u884c",
"Paste your embed code below:": "\u5c06\u5185\u5d4c\u4ee3\u7801\u7c98\u8d34\u5230\u4e0b\u65b9\uff1a",
"Pause": "\u6682\u505c",
@@ -579,8 +579,8 @@
"Please provide a description of the link destination.": "\u8bf7\u63d0\u4f9b\u94fe\u63a5\u7684\u63cf\u8ff0\u3002",
"Please select a PDF file to upload.": "\u8bf7\u9009\u62e9\u4e0a\u4f20\u4e00\u4e2aPDF\u6587\u4ef6\u3002",
"Please select a file in .srt format.": "\u8bf7\u9009\u62e9\u4e00\u4e2a .srt \u683c\u5f0f\u7684\u6587\u4ef6\u3002",
"Please verify that you have uploaded a valid image (PNG and JPEG).": "\u8bf7\u9a8c\u8bc1\u4f60\u5df2\u4e0a\u4f20\u4e86\u4e00\u5f20\u6709\u6548\u7684\u56fe\u7247(PNG\u6216JPEG\u683c\u5f0f)\u3002",
"Please verify that your webcam is connected and that you have allowed your browser to access it.": "\u8bf7\u68c0\u67e5\u4f60\u7684\u6444\u50cf\u5934\u5df2\u8fde\u63a5\u5e76\u4e14\u4f60\u7684\u6d4f\u89c8\u5668\u53ef\u4ee5\u8bbf\u95ee\u3002",
"Please verify that you have uploaded a valid image (PNG and JPEG).": "\u8bf7\u9a8c\u8bc1\u60a8\u5df2\u4e0a\u4f20\u4e86\u4e00\u5f20\u6709\u6548\u7684\u56fe\u7247(PNG\u6216JPEG\u683c\u5f0f)\u3002",
"Please verify that your webcam is connected and that you have allowed your browser to access it.": "\u8bf7\u68c0\u67e5\u60a8\u7684\u6444\u50cf\u5934\u5df2\u8fde\u63a5\u5e76\u4e14\u5141\u8bb8\u6d4f\u89c8\u5668\u4f7f\u7528\u5b83\u3002",
"Post body": "\u5e16\u5b50\u5185\u5bb9",
"Poster": "\u5c01\u9762",
"Pre": "Pre \u6807\u7b7e",
@@ -609,12 +609,12 @@
"Replace with": "\u66ff\u6362\u4e3a",
"Reply": "\u56de\u590d",
"Reply to Annotation": "\u56de\u590d\u6279\u6ce8",
"Report annotation as inappropriate or offensive.": "\u62a5\u544a\u4e0d\u6070\u5f53\u7684\u6216\u5177\u6709\u653b\u51fb\u6027\u7684\u6279\u6ce8\u3002",
"Report annotation as inappropriate or offensive.": "\u62a5\u544a\u6b64\u6279\u6ce8\u4e0d\u6070\u5f53\u6216\u5177\u6709\u653b\u51fb\u6027\u3002",
"Requester": "\u8bf7\u6c42\u8005",
"Required field": "\u5fc5\u586b\u9879\u76ee",
"Rescore problem '<%= problem_id %>' for all students?": "\u786e\u8ba4\u5bf9\u6240\u6709\u5b66\u751f\u56de\u7b54\u95ee\u9898\u201c<%= problem_id %>\u201d\u91cd\u65b0\u8bc4\u5206\uff1f",
"Rescore problem '<%= problem_id %>' for all students?": "\u786e\u8ba4\u5bf9\u6240\u6709\u5b66\u751f\u56de\u7b54\u95ee\u9898'<%= problem_id %>'\u91cd\u65b0\u8bc4\u5206\uff1f",
"Reset Password": "\u91cd\u8bbe\u5bc6\u7801",
"Reset attempts for all students on problem '<%= problem_id %>'?": "\u786e\u8ba4\u91cd\u7f6e\u6240\u6709\u5b66\u751f\u5728\u95ee\u9898\u201c<%= problem_id %>\u201d\u7684\u5c1d\u8bd5\u6b21\u6570\uff1f",
"Reset attempts for all students on problem '<%= problem_id %>'?": "\u786e\u8ba4\u91cd\u7f6e\u6240\u6709\u5b66\u751f\u5728\u95ee\u9898'<%= problem_id %>'\u7684\u5c1d\u8bd5\u6b21\u6570\uff1f",
"Reset my password": "\u91cd\u8bbe\u6211\u7684\u5bc6\u7801",
"Restore last draft": "\u6062\u590d\u4e0a\u4e00\u7248\u8349\u7a3f",
"Retake Photo": "\u91cd\u65b0\u62cd\u7167",
@@ -669,7 +669,7 @@
],
"Sign in": "\u767b\u5f55",
"Skip": "\u8df3\u8fc7",
"Sorry": "\u5f88\u62b1\u6b49",
"Sorry": "\u62b1\u6b49",
"Sorry, no results were found.": "\u5bf9\u4e0d\u8d77\uff0c\u672a\u627e\u5230\u641c\u7d22\u7ed3\u679c\u3002",
"Sorry, there was an error parsing the subtitles that you uploaded. Please check the format and try again.": "\u5bf9\u4e0d\u8d77\uff0c\u60a8\u4e0a\u4f20\u7684\u5b57\u5e55\u6587\u4ef6\u5b58\u5728\u683c\u5f0f\u9519\u8bef\uff0c\u8bf7\u68c0\u67e5\u5e76\u91cd\u65b0\u4e0a\u4f20\u3002",
"Source": "\u6e90",
@@ -685,8 +685,8 @@
"Start": "\u5f00\u59cb",
"Start Date": "\u5f00\u59cb\u65e5\u671f",
"Start search": "\u5f00\u59cb\u641c\u7d22",
"Started entrance exam rescore task for student '{student_id}'. Click the 'Show Background Task History for Student' button to see the status of the task.": "\u5df2\u542f\u52a8\u4e3a\u5b66\u751f\u201c{student_id}\u201d\u91cd\u65b0\u8ba1\u7b97\u5165\u5b66\u8003\u8bd5\u5206\u6570\u7684\u4efb\u52a1\uff0c\u8bf7\u70b9\u51fb\u201c\u4e3a\u5b66\u751f\u663e\u793a\u540e\u53f0\u4efb\u52a1\u5386\u53f2\u201d\u6309\u94ae\u67e5\u770b\u4efb\u52a1\u72b6\u6001\u3002",
"Started rescore problem task for problem '<%= problem_id %>' and student '<%= student_id %>'. Click the 'Show Background Task History for Student' button to see the status of the task.": "\u5df2\u542f\u52a8\u5bf9\u95ee\u9898\u201c<%= problem_id %>\u201d\u548c\u5b66\u751f\u201c<%= student_id %>\u201d\u7684\u91cd\u65b0\u8bc4\u5206\u4efb\u52a1\u3002\u70b9\u51fb '\u663e\u793a\u5b66\u751f\u7684\u80cc\u666f\u4efb\u52a1\u5386\u53f2' \u6309\u94ae\u6765\u67e5\u770b\u4efb\u52a1\u72b6\u6001\u3002",
"Started entrance exam rescore task for student '{student_id}'. Click the 'Show Background Task History for Student' button to see the status of the task.": "\u5df2\u542f\u52a8\u4e3a\u5b66\u751f'{student_id}'\u91cd\u65b0\u8ba1\u7b97\u5165\u5b66\u8003\u8bd5\u5206\u6570\u7684\u4efb\u52a1\uff0c\u8bf7\u70b9\u51fb\u201c\u4e3a\u5b66\u751f\u663e\u793a\u540e\u53f0\u4efb\u52a1\u5386\u53f2\u201d\u6309\u94ae\u67e5\u770b\u4efb\u52a1\u72b6\u6001\u3002",
"Started rescore problem task for problem '<%= problem_id %>' and student '<%= student_id %>'. Click the 'Show Background Task History for Student' button to see the status of the task.": "\u5df2\u542f\u52a8\u5bf9\u95ee\u9898'<%= problem_id %>'\u548c\u5b66\u751f '<%= student_id %>'\u7684\u91cd\u65b0\u8bc4\u5206\u4efb\u52a1\u3002\u70b9\u51fb\u201c\u663e\u793a\u5b66\u751f\u7684\u80cc\u666f\u4efb\u52a1\u5386\u53f2\u201d\u6309\u94ae\u6765\u67e5\u770b\u4efb\u52a1\u72b6\u6001\u3002",
"Starts: %(start)s": "\u5f00\u59cb\u4e8e\uff1a %(start)s",
"State": "\u72b6\u6001",
"Status": "\u72b6\u6001",
@@ -694,21 +694,21 @@
"Strikethrough": "\u5220\u9664\u7ebf",
"Studio's having trouble saving your work": "Studio \u4fdd\u5b58\u60a8\u5de5\u4f5c\u65f6\u9047\u5230\u95ee\u9898",
"Style": "\u6837\u5f0f",
"Subject": "\u79d1\u76ee",
"Subject:": "\u79d1\u76ee",
"Subject": "\u6807\u9898",
"Subject:": "\u6807\u9898",
"Submit": "\u63d0\u4ea4",
"Submitted": "\u5df2\u63d0\u4ea4",
"Subscript": "\u4e0b\u6807",
"Success": "\u6210\u529f",
"Success! Problem attempts reset for problem '<%= problem_id %>' and student '<%= student_id %>'.": "\u6210\u529f\uff01\u95ee\u9898\u201c<%= problem_id %>\u201d\u4e0e\u5b66\u751f\u201c<%= student_id %>\u201d\u7684\u95ee\u9898\u5c1d\u8bd5\u6b21\u6570\u91cd\u7f6e\u4e86\u3002",
"Success! Problem attempts reset for problem '<%= problem_id %>' and student '<%= student_id %>'.": "\u6210\u529f\uff01\u5b66\u751f'<%= student_id %>'\u5bf9\u95ee\u9898'<%= problem_id %>'\u7684\u5c1d\u8bd5\u6b21\u6570\u5df2\u91cd\u7f6e\u3002",
"Successfully deleted student state for user {user}": "\u6210\u529f\u5220\u9664\u5b66\u751f{user}\u7684\u72b6\u6001",
"Successfully enrolled and sent email to the following users:": "\u4ee5\u4e0b\u7528\u6237\u5df2\u6210\u529f\u9009\u4fee\uff0c\u5e76\u5411\u4ed6\u4eec\u53d1\u9001\u7535\u5b50\u90ae\u4ef6\uff1a",
"Successfully enrolled the following users:": "\u4ee5\u4e0b\u7528\u6237\u5df2\u7ecf\u6210\u529f\u9009\u4fee\uff1a",
"Successfully enrolled and sent email to the following users:": "\u4ee5\u4e0b\u7528\u6237\u5df2\u6210\u529f\u9009\u8bfe\uff0c\u5e76\u5411\u4ed6\u4eec\u53d1\u9001\u7535\u5b50\u90ae\u4ef6\uff1a",
"Successfully enrolled the following users:": "\u4ee5\u4e0b\u7528\u6237\u5df2\u7ecf\u6210\u529f\u9009\u8bfe\uff1a",
"Successfully rescored problem for user {user}": "\u6210\u529f\u91cd\u8bc4\u7528\u6237 {user}\u5f97\u5206",
"Successfully reset the attempts for user {user}": "\u6210\u529f\u91cd\u7f6e\u7528\u6237{user}\u7684\u8bf7\u6c42",
"Successfully sent enrollment emails to the following users. They will be allowed to enroll once they register:": "\u9009\u8bfe\u90ae\u4ef6\u5df2\u53d1\u9001\u81f3\u4ee5\u4e0b\u7528\u6237\uff0c\u4ed6\u4eec\u6ce8\u518c\u540e\u5373\u53ef\u9009\u8bfe\uff1a",
"Successfully sent enrollment emails to the following users. They will be enrolled once they register:": "\u9009\u8bfe\u90ae\u4ef6\u5df2\u53d1\u9001\u81f3\u8fd9\u4e9b\u7528\u6237\uff0c\u4ed6\u4eec\u6ce8\u518c\u540e\u5373\u5df2\u9009\u8bfe\uff1a",
"Successfully started task to rescore problem '<%= problem_id %>' for all students. Click the 'Show Background Task History for Problem' button to see the status of the task.": "\u6210\u529f\u542f\u52a8\u6240\u6709\u5b66\u751f\u56de\u7b54\u95ee\u9898 \u201c<%= problem_id %>\u201d\u91cd\u65b0\u8bc4\u5206\u7684\u4efb\u52a1\u3002\u70b9\u51fb\u201c\u663e\u793a\u95ee\u9898\u7684\u80cc\u666f\u4efb\u52a1\u5386\u53f2\u201d\u6309\u94ae\u6765\u67e5\u770b\u4efb\u52a1\u72b6\u6001\u3002",
"Successfully sent enrollment emails to the following users. They will be allowed to enroll once they register:": "\u9009\u8bfe\u90ae\u4ef6\u5df2\u6210\u529f\u53d1\u9001\u81f3\u4ee5\u4e0b\u7528\u6237\uff0c\u4ed6\u4eec\u6ce8\u518c\u540e\u5373\u53ef\u9009\u8bfe\uff1a",
"Successfully sent enrollment emails to the following users. They will be enrolled once they register:": "\u9009\u8bfe\u90ae\u4ef6\u5df2\u6210\u529f\u53d1\u9001\u81f3\u8fd9\u4e9b\u7528\u6237\uff0c\u4ed6\u4eec\u6ce8\u518c\u540e\u5373\u5df2\u9009\u8bfe\uff1a",
"Successfully started task to rescore problem '<%= problem_id %>' for all students. Click the 'Show Background Task History for Problem' button to see the status of the task.": "\u6210\u529f\u542f\u52a8\u6240\u6709\u5b66\u751f\u56de\u7b54\u95ee\u9898 '<%= problem_id %>'\u91cd\u65b0\u8bc4\u5206\u7684\u4efb\u52a1\u3002\u70b9\u51fb\u201c\u663e\u793a\u95ee\u9898\u7684\u80cc\u666f\u4efb\u52a1\u5386\u53f2\u201d\u6309\u94ae\u6765\u67e5\u770b\u4efb\u52a1\u72b6\u6001\u3002",
"Successfully started task to reset attempts for problem '<%= problem_id %>'. Click the 'Show Background Task History for Problem' button to see the status of the task.": "\u6210\u529f\u542f\u52a8\u91cd\u7f6e\u95ee\u9898\u201c<%= problem_id %>\u201d\u5c1d\u8bd5\u6b21\u6570\u7684\u4efb\u52a1\u3002\u70b9\u51fb\u201c\u663e\u793a\u95ee\u9898\u7684\u80cc\u666f\u4efb\u52a1\u5386\u53f2\u201d\u6309\u94ae\u6765\u67e5\u770b\u4efb\u52a1\u72b6\u6001\u3002",
"Superscript": "\u4e0a\u6807",
"Table": "\u8868\u683c",
@@ -732,8 +732,8 @@
"Text color": "\u6587\u672c\u989c\u8272",
"Text to display": "\u8981\u663e\u793a\u7684\u6587\u5b57",
"Thank you for submitting your photos. We will review them shortly. You can now sign up for any %(platformName)s course that offers verified certificates. Verification is good for one year. After one year, you must submit photos for verification again.": "\u611f\u8c22\u63d0\u4ea4\u60a8\u7684\u7167\u7247\uff0c\u6211\u4eec\u5c06\u4e8e\u7a0d\u540e\u8fdb\u884c\u5ba1\u6838\u3002\u60a8\u73b0\u5728\u5c31\u53ef\u4ee5\u6ce8\u518c%(platformName)s\u4e0a\u4efb\u4e00\u63d0\u4f9b\u8ba4\u8bc1\u8bc1\u4e66\u7684\u8bfe\u7a0b\u3002\u8ba4\u8bc1\u6709\u6548\u671f\u4e3a\u4e00\u5e74\u3002\u4e00\u5e74\u540e\uff0c\u60a8\u5fc5\u987b\u8981\u63d0\u4ea4\u7167\u7247\u91cd\u65b0\u8ba4\u8bc1\u3002",
"The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?": "\u8f93\u5165\u7684URL\u4f3c\u4e4e\u662f\u4e00\u4e2a\u7535\u5b50\u90ae\u4ef6\u5730\u5740\uff0c\u9700\u8981\u52a0\u4e0a\u201cmailto:\u201d\u524d\u7f00\u5417\uff1f",
"The URL you entered seems to be an external link. Do you want to add the required http:// prefix?": "\u8f93\u5165\u7684 URL \u4f3c\u4e4e\u662f\u4e00\u4e2a\u5916\u90e8\u94fe\u63a5\uff0c\u9700\u8981\u52a0\u4e0a\u201chttp://\u201d\u524d\u7f00\u5417\uff1f",
"The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?": "\u8f93\u5165\u7684URL\u4f3c\u4e4e\u662f\u4e00\u4e2a\u7535\u5b50\u90ae\u4ef6\u5730\u5740\uff0c\u60a8\u60f3\u52a0\u4e0a\u5fc5\u8981\u7684\u201cmailto:\u201d\u524d\u7f00\u5417\uff1f",
"The URL you entered seems to be an external link. Do you want to add the required http:// prefix?": "\u8f93\u5165\u7684 URL \u4f3c\u4e4e\u662f\u4e00\u4e2a\u5916\u90e8\u94fe\u63a5\uff0c\u60a8\u60f3\u52a0\u4e0a\u5fc5\u8981\u7684 http:// \u524d\u7f00\u5417\uff1f",
"The cohort cannot be added": "\u8be5\u7fa4\u7ec4\u4e0d\u80fd\u6dfb\u52a0",
"The cohort cannot be saved": "\u8be5\u7fa4\u7ec4\u4e0d\u80fd\u4fdd\u5b58",
"The combined length of the organization and library code fields cannot be more than <%=limit%> characters.": "\u673a\u6784\u548c\u77e5\u8bc6\u5e93\u7f16\u53f7\u5b57\u6bb5\u5408\u5728\u4e00\u8d77\u4e0d\u80fd\u8d85\u8fc7 <%=limit%> \u4e2a\u5b57\u7b26",
@@ -749,11 +749,11 @@
"The grading process is still running. Refresh the page to see updates.": "\u8bc4\u5206\u8fc7\u7a0b\u4ecd\u5728\u8fdb\u884c\uff0c\u8bf7\u5237\u65b0\u9875\u9762\u67e5\u770b\u66f4\u65b0\u3002",
"The raw error message is:": "\u539f\u59cb\u7684\u9519\u8bef\u4fe1\u606f\u662f\uff1a",
"The selected content group does not exist": "\u9009\u53d6\u7684\u5185\u5bb9\u7ec4\u4e0d\u5b58\u5728\u3002",
"The thread you selected has been deleted. Please select another thread.": "\u4f60\u9009\u4e2d\u7684\u4e3b\u9898\u5df2\u88ab\u5220\u9664\uff0c\u8bf7\u9009\u62e9\u5176\u4ed6\u4e3b\u9898\u3002",
"The thread you selected has been deleted. Please select another thread.": "\u60a8\u9009\u4e2d\u7684\u4e3b\u9898\u5df2\u88ab\u5220\u9664\uff0c\u8bf7\u9009\u62e9\u5176\u4ed6\u4e3b\u9898\u3002",
"The {cohortGroupName} cohort has been created. You can manually add students to this cohort below.": "{cohortGroupName}\u7fa4\u7ec4\u5df2\u7ecf\u521b\u5efa\uff0c\u60a8\u53ef\u4ee5\u624b\u52a8\u6dfb\u52a0\u5b66\u751f\u5230\u8fd9\u4e2a\u7fa4\u7ec4\u3002",
"There are invalid keywords in your email. Please check the following keywords and try again:": "\u4f60\u7684\u90ae\u4ef6\u4e2d\u542b\u6709\u975e\u6cd5\u5173\u952e\u8bcd\u3002\u8bf7\u68c0\u67e5\u4e0b\u5217\u5173\u952e\u8bcd\u5e76\u91cd\u8bd5\uff1a",
"There are invalid keywords in your email. Please check the following keywords and try again:": "\u60a8\u7684\u90ae\u4ef6\u4e2d\u542b\u6709\u65e0\u6548\u5173\u952e\u8bcd\u3002\u8bf7\u68c0\u67e5\u4e0b\u5217\u5173\u952e\u8bcd\u5e76\u91cd\u8bd5\uff1a",
"There has been a failure to export to XML at least one component. It is recommended that you go to the edit page and repair the error before attempting another export. Please check that all components on the page are valid and do not display any error messages.": "\u81f3\u5c11\u6709\u4e00\u4e2a\u7ec4\u4ef6\u5728\u5bfc\u51fa\u5230XML\u65f6\u5931\u8d25\u4e86\u3002\u5728\u5c1d\u8bd5\u91cd\u65b0\u5bfc\u51fa\u4e4b\u524d\uff0c\u5efa\u8bae\u60a8\u5148\u53bb\u7f16\u8f91\u9875\u9762\u5e76\u4fee\u590d\u9519\u8bef\u3002\u8bf7\u68c0\u67e5\u5e76\u786e\u8ba4\u8fd9\u4e2a\u9875\u9762\u4e0a\u7684\u6240\u6709\u7684\u7ec4\u4ef6\u90fd\u6709\u6548\uff0c\u800c\u4e14\u6ca1\u6709\u663e\u793a\u4efb\u4f55\u9519\u8bef\u4fe1\u606f\u3002",
"There has been an error processing your survey.": "\u5728\u5904\u7406\u4f60\u7684\u8c03\u67e5\u7684\u65f6\u5019\u51fa\u73b0\u4e86\u4e00\u4e2a\u9519\u8bef\u3002",
"There has been an error processing your survey.": "\u5728\u5904\u7406\u60a8\u7684\u8c03\u67e5\u65f6\u51fa\u73b0\u4e86\u4e00\u4e2a\u9519\u8bef\u3002",
"There has been an error while exporting.": "\u5bfc\u51fa\u65f6\u51fa\u9519\u4e86\u3002",
"There has been an error with your export.": "\u5bfc\u51fa\u65f6\u53d1\u751f\u4e86\u9519\u8bef\u3002",
"There is no email history for this course.": "\u672c\u8bfe\u7a0b\u5c1a\u65e0\u53d1\u9001\u7535\u5b50\u90ae\u4ef6\u8bb0\u5f55\u3002",
@@ -761,8 +761,8 @@
"There must be one cohort to which students can automatically be assigned.": "\u5fc5\u987b\u5b58\u5728\u4e00\u4e2a\u5b66\u751f\u53ef\u88ab\u81ea\u52a8\u5206\u914d\u8fdb\u53bb\u7684\u7fa4\u7ec4\u3002",
"There was an error changing the user's role": "\u66f4\u6539\u7528\u6237\u89d2\u8272\u8fc7\u7a0b\u4e2d\u51fa\u73b0\u9519\u8bef",
"There was an error during the upload process.": "\u5728\u6587\u4ef6\u4e0a\u4f20\u8fc7\u7a0b\u4e2d\u53d1\u751f\u9519\u8bef\u3002",
"There was an error obtaining email content history for this course.": "\u5b58\u5728\u80fd\u83b7\u53d6\u8be5\u8bfe\u7a0b\u90ae\u4ef6\u5386\u53f2\u5185\u5bb9\u7684\u9519\u8bef",
"There was an error obtaining email task history for this course.": "\u83b7\u53d6\u8be5\u8bfe\u7a0b\u7684\u90ae\u4ef6\u4efb\u52a1\u5386\u53f2\u65f6\u53d1\u751f\u9519\u8bef\u3002",
"There was an error obtaining email content history for this course.": "\u5b58\u5728\u80fd\u83b7\u53d6\u8be5\u8bfe\u7a0b\u90ae\u4ef6\u5185\u5bb9\u5386\u53f2\u8bb0\u5f55\u7684\u9519\u8bef",
"There was an error obtaining email task history for this course.": "\u83b7\u53d6\u8be5\u8bfe\u7a0b\u7684\u90ae\u4ef6\u4efb\u52a1\u5386\u53f2\u8bb0\u5f55\u65f6\u53d1\u751f\u9519\u8bef\u3002",
"There was an error when trying to add students:": [
"\u5c1d\u8bd5\u6dfb\u52a0\u5b66\u751f\u65f6\u51fa\u73b0 {numErrors} \u4e2a\u9519\u8bef\uff1a"
],
@@ -775,11 +775,11 @@
"There were errors reindexing course.": "\u91cd\u5efa\u8bfe\u7a0b\u7d22\u5f15\u65f6\u51fa\u9519\u4e86\u3002",
"There's already another assignment type with this name.": "\u5df2\u7ecf\u6709\u53e6\u4e00\u4e2a\u4f5c\u4e1a\u7c7b\u578b\u4f7f\u7528\u4e86\u8fd9\u4e2a\u540d\u5b57\u3002",
"These users were not added as beta testers:": "\u8fd9\u4e9b\u7528\u6237\u672a\u6dfb\u52a0\u4e3abeta\u6d4b\u8bd5\u8005\uff1a",
"These users were not affiliated with the course so could not be unenrolled:": "\u8fd9\u4e9b\u7528\u6237\u5e76\u4e0d\u5c5e\u4e8e\u672c\u8bfe\u7a0b\u5b66\u5458\uff0c\u56e0\u6b64\u65e0\u6cd5\u653e\u5f03\u9009\u4fee\uff1a",
"These users were not affiliated with the course so could not be unenrolled:": "\u8fd9\u4e9b\u7528\u6237\u5e76\u4e0d\u5c5e\u4e8e\u672c\u8bfe\u7a0b\u5b66\u5458\uff0c\u56e0\u6b64\u65e0\u6cd5\u4f7f\u5176\u653e\u5f03\u9009\u4fee\uff1a",
"These users were not removed as beta testers:": "\u8fd9\u4e9b\u7528\u6237\u672a\u4ecebeta\u6d4b\u8bd5\u8005\u4e2d\u5220\u9664\uff1a",
"These users were successfully added as beta testers:": "\u8fd9\u4e9b\u7528\u6237\u5df2\u7ecf\u6dfb\u52a0\u4e3abeta\u6d4b\u8bd5\u8005\uff1a",
"These users were successfully removed as beta testers:": "\u8fd9\u4e9b\u7528\u6237\u4e0d\u518d\u662fbeta\u6d4b\u8bd5\u8005\uff1a",
"These users will be allowed to enroll once they register:": "\u8fd9\u4e9b\u7528\u6237\u4e00\u65e6\u6ce8\u518c\u5373\u53ef\u9009\u4fee\uff1a",
"These users will be allowed to enroll once they register:": "\u8fd9\u4e9b\u7528\u6237\u4e00\u65e6\u6ce8\u518c\u5373\u53ef\u9009\u8bfe\uff1a",
"These users will be enrolled once they register:": "\u8fd9\u4e9b\u7528\u6237\u6ce8\u518c\u540e\u5373\u5df2\u9009\u8bfe\uff1a",
"This action cannot be undone.": "\u8fd9\u4e2a\u52a8\u4f5c\u65e0\u6cd5\u53d6\u6d88\u3002",
"This annotation has %(count)s flag.": [
@@ -794,9 +794,9 @@
"Tips on taking a successful photo": "\u6210\u529f\u62cd\u6444\u7684\u5c0f\u6280\u5de7",
"Title": "\u6807\u9898",
"Tools": "\u5de5\u5177",
"Top": "\u9876\u7aef\u5bf9\u9f50",
"Top": "\u9876\u7aef",
"Total": "\u603b\u8ba1",
"Transcript will be displayed when you start playing the video.": "\u5f53\u4f60\u5f00\u59cb\u64ad\u653e\u89c6\u9891\u65f6\u5c06\u663e\u793a\u6210\u7ee9\u5355\u3002",
"Transcript will be displayed when you start playing the video.": "\u5f53\u4f60\u5f00\u59cb\u64ad\u653e\u89c6\u9891\u65f6\u5c06\u663e\u793a\u5b57\u5e55\u3002",
"Try using a different browser, such as Google Chrome.": "\u8bf7\u8bd5\u7740\u66f4\u6362\u4e00\u4e2a\u6d4f\u89c8\u5668\uff0c\u5982\u8c37\u6b4c\u7684 Chrome \u6d4f\u89c8\u5668\u3002",
"Turn off transcripts": "\u5173\u95ed\u5b57\u5e55",
"Turn on transcripts": "\u6253\u5f00\u5b57\u5e55",
@@ -838,14 +838,14 @@
"Validation Error While Saving": "\u5728\u4fdd\u5b58\u8fc7\u7a0b\u4e2d\u51fa\u73b0\u9a8c\u8bc1\u9519\u8bef",
"Verified Status": "\u9a8c\u8bc1\u72b6\u6001",
"Vertical space": "\u5782\u76f4\u95f4\u8ddd",
"Very loud": "\u5f88\u5927",
"Very loud": "\u97f3\u91cf\u5f88\u9ad8",
"Very low": "\u5f88\u4f4e",
"Video": "\u89c6\u9891",
"Video Capture Error": "\u89c6\u9891\u6355\u83b7\u5931\u8d25",
"Video ended": "\u89c6\u9891\u7ed3\u675f",
"Video position": "\u89c6\u9891\u4f4d\u7f6e",
"Video transcript": "\u89c6\u9891\u6210\u7ee9\u5355",
"VideoPlayer: Element corresponding to the given selector was not found.": "\u89c6\u9891\u64ad\u653e\u5668\uff1a\u672a\u627e\u5230\u4e0e\u7ed9\u5b9a\u9009\u62e9\u5b50\u5bf9\u5e94\u7684\u5143\u7d20\u3002",
"Video transcript": "\u89c6\u9891\u5b57\u5e55",
"VideoPlayer: Element corresponding to the given selector was not found.": "\u89c6\u9891\u64ad\u653e\u5668\uff1a\u672a\u627e\u5230\u4e0e\u7ed9\u5b9a\u9009\u62e9\u5bf9\u5e94\u7684\u5143\u7d20\u3002",
"View": "\u89c6\u56fe",
"View Cohort": "\u67e5\u770b\u7fa4\u7ec4",
"View all errors": "\u67e5\u770b\u6240\u6709\u9519\u8bef",
@@ -859,7 +859,7 @@
"We couldn't sign you in.": "\u767b\u5f55\u5931\u8d25\u3002",
"We had some trouble closing this thread. Please try again.": "\u5173\u95ed\u8fd9\u4e2a\u5e16\u5b50\u65f6\u51fa\u73b0\u4e86\u4e00\u4e9b\u95ee\u9898\uff0c\u8bf7\u91cd\u8bd5\u3002",
"We had some trouble deleting this comment. Please try again.": "\u5728\u5220\u9664\u8fd9\u6761\u8bc4\u8bba\u65f6\u51fa\u9519\uff0c\u8bf7\u518d\u8bd5\u4e00\u904d\u3002",
"We had some trouble loading more responses. Please try again.": "\u8f7d\u5165\u56de\u590d\u65f6\u9047\u5230\u9ebb\u70e6\uff0c\u8bf7\u91cd\u8bd5\u3002",
"We had some trouble loading more responses. Please try again.": "\u8f7d\u5165\u66f4\u591a\u56de\u590d\u65f6\u9047\u5230\u9ebb\u70e6\uff0c\u8bf7\u91cd\u8bd5\u3002",
"We had some trouble loading more threads. Please try again.": "\u8f7d\u5165\u4e3b\u9898\u9047\u5230\u95ee\u9898\uff0c\u8bf7\u91cd\u8bd5\u3002",
"We had some trouble loading responses. Please reload the page.": "\u8f7d\u5165\u56de\u590d\u65f6\u9047\u5230\u9ebb\u70e6\uff0c\u8bf7\u91cd\u8bd5\u3002",
"We had some trouble loading the discussion. Please try again.": "\u8f7d\u5165\u8ba8\u8bba\u65f6\u9047\u5230\u95ee\u9898\uff0c\u8bf7\u91cd\u8bd5\u3002",
@@ -872,7 +872,7 @@
"We had some trouble processing your request. Please try again.": "\u5904\u7406\u8bf7\u6c42\u9047\u5230\u95ee\u9898\uff0c\u8bf7\u91cd\u8bd5\u3002",
"We had some trouble removing this endorsement. Please try again.": "\u53d6\u6d88\u6807\u8bb0\u8fd9\u4e2a\u56de\u590d\u4e3a\u652f\u6301\u65f6\u51fa\u73b0\u4e86\u4e00\u4e9b\u95ee\u9898\uff0c\u8bf7\u91cd\u8bd5\u3002",
"We had some trouble removing this response as an answer. Please try again.": "\u53d6\u6d88\u6807\u8bb0\u8fd9\u4e2a\u56de\u590d\u4e3a\u7b54\u6848\u65f6\u51fa\u73b0\u4e86\u4e00\u4e9b\u95ee\u9898\uff0c\u8bf7\u91cd\u8bd5\u3002",
"We had some trouble removing your flag on this post. Please try again.": "\u79fb\u9664\u8fd9\u4e2a\u5e16\u5b50\u7684\u62a5\u544a\u65d7\u5e1c\u65f6\u51fa\u73b0\u4e86\u4e00\u4e9b\u95ee\u9898\uff0c\u8bf7\u91cd\u8bd5\u3002",
"We had some trouble removing your flag on this post. Please try again.": "\u79fb\u9664\u8fd9\u4e2a\u5e16\u5b50\u7684\u6807\u8bb0\u65f6\u51fa\u73b0\u4e86\u4e00\u4e9b\u95ee\u9898\uff0c\u8bf7\u91cd\u8bd5\u3002",
"We had some trouble reopening this thread. Please try again.": "\u91cd\u65b0\u5f00\u653e\u8fd9\u4e2a\u5e16\u5b50\u65f6\u51fa\u73b0\u4e86\u4e00\u4e9b\u95ee\u9898\uff0c\u8bf7\u91cd\u8bd5\u3002",
"We had some trouble reporting this post. Please try again.": "\u62a5\u544a\u8fd9\u4e2a\u5e16\u5b50\u4f7f\u7528\u4e0d\u5f53\u65f6\u51fa\u73b0\u4e86\u4e00\u4e9b\u95ee\u9898\uff0c\u8bf7\u91cd\u8bd5\u3002",
"We had some trouble saving your vote. Please try again.": "\u4fdd\u5b58\u4f60\u7684\u6295\u7968\u65f6\u51fa\u73b0\u4e86\u4e00\u4e9b\u95ee\u9898\uff0c\u8bf7\u91cd\u8bd5\u3002",
@@ -883,7 +883,7 @@
"We use the highest levels of security available to encrypt your photo and send it to our authorization service for review. Your photo and information are not saved or visible anywhere on %(platformName)s after the verification process is complete.": "\u6211\u4eec\u4f1a\u91c7\u7528\u6700\u9ad8\u7ea7\u522b\u7684\u5b89\u5168\u6280\u672f\u6765\u52a0\u5bc6\u4f60\u7684\u7167\u7247\u5e76\u53d1\u9001\u5230\u6211\u4eec\u7684\u6388\u6743\u670d\u52a1\u7528\u4e8e\u5ba1\u6838\u76ee\u7684\uff1b\u4e00\u65e6\u5b8c\u6210\u4e86\u8ba4\u8bc1\u8fc7\u7a0b\uff0c%(platformName)s\u4e0d\u4f1a\u7ee7\u7eed\u4fdd\u5b58\u8fd9\u4e9b\u7167\u7247\u548c\u4fe1\u606f\u3002",
"We weren't able to send you a password reset email.": "\u5bc6\u7801\u91cd\u7f6e\u90ae\u4ef6\u53d1\u9001\u5931\u8d25\u3002",
"We're sorry, there was an error": "\u5f88\u62b1\u6b49\uff0c\u51fa\u73b0\u9519\u8bef",
"We've encountered an error. Refresh your browser and then try again.": "\u6211\u4eec\u9047\u5230\u4e86\u4e00\u4e2a\u9519\u8bef\u3002\u8bf7\u5237\u65b0\u4f60\u7684\u6d4f\u89c8\u5668\u5e76\u91cd\u8bd5\u3002",
"We've encountered an error. Refresh your browser and then try again.": "\u6211\u4eec\u9047\u5230\u4e86\u4e00\u4e2a\u9519\u8bef\u3002\u8bf7\u5237\u65b0\u60a8\u7684\u6d4f\u89c8\u5668\u5e76\u91cd\u8bd5\u3002",
"We've sent instructions for resetting your password to the email address you provided.": "\u6211\u4eec\u5df2\u7ecf\u5411\u60a8\u63d0\u4f9b\u7684\u7535\u5b50\u90ae\u4ef6\u53d1\u9001\u4e86\u91cd\u7f6e\u5bc6\u7801\u7684\u8bf4\u660e\u3002",
"Webcam": "\u6444\u50cf\u5934",
"What does %(platformName)s do with this photo?": "%(platformName)s\u7528\u8fd9\u5f20\u7167\u7247\u505a\u4ec0\u4e48\uff1f",
@@ -893,8 +893,8 @@
"Width": "\u5bbd",
"Words: {0}": "\u5b57\u6570\uff1a {0}",
"Yes, delete this %(xblock_type)s": "\u662f\u7684\uff0c\u5220\u9664\u8be5%(xblock_type)s",
"You are about to send an email titled '<%= subject %>' to ALL (everyone who is enrolled in this course as student, staff, or instructor). Is this OK?": "\u60a8\u5373\u5c06\u5411\u9009\u4fee\u8be5\u8bfe\u7a0b\u7684\u6240\u6709\u4eba(\u9009\u8bfe\u7684\u5b66\u751f\u3001\u6559\u5458\u548c\u4e3b\u8bb2\u6559\u5e08)\u53d1\u9001\u4e00\u5c01\u6807\u9898\u4e3a\u201c<%= subject %>\u201d\u7684\u90ae\u4ef6\uff0c\u786e\u8ba4\u5417\uff1f",
"You are about to send an email titled '<%= subject %>' to everyone who is staff or instructor on this course. Is this OK?": "\u60a8\u51c6\u5907\u5411\u8be5\u8bfe\u7a0b\u7684\u6240\u6709\u6559\u5458\u548c\u4e3b\u8bb2\u6559\u5e08\u53d1\u9001\u4e00\u5c01\u6807\u9898\u4e3a\u201c<%= subject %>\u201d\u7684\u90ae\u4ef6\uff0c\u786e\u8ba4\u5417\uff1f",
"You are about to send an email titled '<%= subject %>' to ALL (everyone who is enrolled in this course as student, staff, or instructor). Is this OK?": "\u60a8\u5373\u5c06\u5411\u6b64\u8bfe\u7a0b\u4e2d\u7684\u6240\u6709\u4eba(\u9009\u8bfe\u7684\u5b66\u751f\u3001\u5de5\u4f5c\u4eba\u5458\u548c\u6559\u5e08)\u53d1\u9001\u4e00\u5c01\u6807\u9898\u4e3a\u201c<%= subject %>\u201d\u7684\u90ae\u4ef6\uff0c\u786e\u8ba4\u5417\uff1f",
"You are about to send an email titled '<%= subject %>' to everyone who is staff or instructor on this course. Is this OK?": "\u60a8\u51c6\u5907\u5411\u6b64\u8bfe\u7a0b\u7684\u6240\u6709\u5de5\u4f5c\u4eba\u5458\u548c\u6559\u5e08\u53d1\u9001\u4e00\u5c01\u6807\u9898\u4e3a\u201c<%= subject %>\u201d\u7684\u90ae\u4ef6\uff0c\u786e\u8ba4\u5417\uff1f",
"You are about to send an email titled '<%= subject %>' to yourself. Is this OK?": "\u4f60\u51c6\u5907\u5411\u81ea\u5df1\u53d1\u9001\u4e00\u5c01\u6807\u9898\u4e3a\u201c<%= subject %>\u201d\u7684\u90ae\u4ef6\uff0c\u786e\u8ba4\u5417\uff1f",
"You are now enrolled as a verified student for:": "\u60a8\u5df2\u7ecf\u5df2\u8ba4\u8bc1\u5b66\u751f\u7684\u8eab\u4efd\u9009\u62e9\u4e86\u8bfe\u7a0b\uff1a",
"You can now enter your payment information and complete your enrollment.": "\u4f60\u53ef\u4ee5\u73b0\u5728\u5c31\u8f93\u5165\u652f\u4ed8\u4fe1\u606f\u5e76\u5b8c\u6210\u9009\u8bfe\u3002",
@@ -903,8 +903,8 @@
"You commented...": "\u4f60\u8bc4\u8bba\u7684\u2026",
"You currently have no cohorts configured": "\u60a8\u76ee\u524d\u6ca1\u6709\u5df2\u914d\u7f6e\u7684\u7fa4\u7ec4",
"You did not select a content group": "\u60a8\u672a\u9009\u53d6\u5185\u5bb9\u7ec4\u3002",
"You don't seem to have a webcam connected.": "\u8c8c\u4f3c\u4f60\u8fd8\u6ca1\u6709\u4e00\u4e2a\u53ef\u4f7f\u7528\u7684\u6444\u50cf\u5934\u3002",
"You have already reported this annotation.": "\u60a8\u5df2\u7ecf\u62a5\u544a\u8fc7\u4e86\u8be5\u6279\u6ce8\u3002",
"You don't seem to have a webcam connected.": "\u60a8\u4f3c\u4e4e\u6ca1\u6709\u8fde\u63a5\u4e00\u4e2a\u6444\u50cf\u5934\u3002",
"You have already reported this annotation.": "\u60a8\u5df2\u7ecf\u62a5\u544a\u8fc7\u4e86\u6b64\u6279\u6ce8\u3002",
"You have already verified your ID!": "\u60a8\u5df2\u7ecf\u6210\u529f\u9a8c\u8bc1\u4e86\u60a8\u7684\u8eab\u4efd\u8bc1\u4ef6\uff01",
"You have been logged out of your edX account. ": "\u60a8\u5df2\u4ece\u60a8\u7684 edX \u8d26\u6237\u767b\u51fa\u3002",
"You have not created any content groups yet.": "\u60a8\u8fd8\u6ca1\u6709\u521b\u5efa\u4efb\u4f55\u5185\u5bb9\u7ec4\u3002",
@@ -927,9 +927,9 @@
"Your changes will not take effect until you save your progress. Take care with key and value formatting, as validation is not implemented.": "\u53d8\u66f4\u5728\u4fdd\u5b58\u4e4b\u540e\u751f\u6548\u3002\u7531\u4e8e\u7cfb\u7edf\u6682\u65f6\u4e0d\u652f\u6301\u6821\u9a8c\u529f\u80fd\uff0c\u8bf7\u4ed4\u7ec6\u68c0\u67e5\u7b56\u7565\u952e\u503c\u5bf9\u8bbe\u7f6e\u3002",
"Your course could not be exported to XML. There is not enough information to identify the failed component. Inspect your course to identify any problematic components and try again.": "\u60a8\u7684\u8bfe\u7a0b\u65e0\u6cd5\u5bfc\u51fa\u81f3XML\u3002\u6682\u65f6\u6ca1\u6709\u8db3\u591f\u7684\u4fe1\u606f\u6765\u5b9a\u4f4d\u5931\u8d25\u7684\u7ec4\u4ef6\uff0c\u8bf7\u68c0\u67e5\u60a8\u7684\u8bfe\u7a0b\u4ee5\u5b9a\u4f4d\u4efb\u4f55\u53ef\u80fd\u6709\u95ee\u9898\u7684\u7ec4\u4ef6\uff0c\u7136\u540e\u91cd\u8bd5\u3002",
"Your donation could not be submitted.": "\u60a8\u7684\u6350\u6b3e\u65e0\u6cd5\u63d0\u4ea4\u3002",
"Your email was successfully queued for sending.": "\u4f60\u7684\u7535\u5b50\u90ae\u4ef6\u5df2\u6210\u529f\u52a0\u5165\u53d1\u9001\u961f\u5217\u3002",
"Your email was successfully queued for sending. Please note that for large classes, it may take up to an hour (or more, if other courses are simultaneously sending email) to send all emails.": "\u4f60\u7684\u7535\u5b50\u90ae\u4ef6\u5df2\u6210\u529f\u52a0\u5165\u53d1\u9001\u961f\u5217\u3002\u9700\u8981\u6ce8\u610f\u7684\u662f\uff0c\u5bf9\u4e8e\u5927\u578b\u8bfe\u7a0b\uff0c\u53ef\u80fd\u9700\u8981\u82b1\u8d39\u4e00\u5c0f\u65f6\u4ee5\u4e0a(\u751a\u81f3\u66f4\u591a\uff0c\u5982\u679c\u5176\u4ed6\u8bfe\u7a0b\u4e5f\u5728\u540c\u65f6\u53d1\u9001\u90ae\u4ef6)\u6765\u53d1\u9001\u6240\u6709\u90ae\u4ef6\u3002",
"Your file '{file}' has been uploaded. Allow a few minutes for processing.": "\u4f60\u7684\u6587\u4ef6\u201c{file}\u201d\u5df2\u7ecf\u4e0a\u4f20\u3002\u9700\u8981\u51e0\u5206\u949f\u65f6\u95f4\u8fdb\u884c\u5904\u7406\u3002",
"Your email was successfully queued for sending.": "\u60a8\u7684\u7535\u5b50\u90ae\u4ef6\u5df2\u6210\u529f\u52a0\u5165\u53d1\u9001\u961f\u5217\u3002",
"Your email was successfully queued for sending. Please note that for large classes, it may take up to an hour (or more, if other courses are simultaneously sending email) to send all emails.": "\u60a8\u7684\u7535\u5b50\u90ae\u4ef6\u5df2\u6210\u529f\u52a0\u5165\u53d1\u9001\u961f\u5217\u3002\u9700\u8981\u6ce8\u610f\u7684\u662f\uff0c\u5bf9\u4e8e\u9009\u8bfe\u4eba\u6570\u591a\u7684\u8bfe\u7a0b\uff0c\u53ef\u80fd\u9700\u8981\u82b1\u8d39\u81f3\u591a\u4e00\u5c0f\u65f6(\u6216\u8005\u5982\u679c\u5176\u4ed6\u8bfe\u7a0b\u4e5f\u5728\u540c\u65f6\u53d1\u9001\u90ae\u4ef6)\u6765\u53d1\u9001\u6240\u6709\u90ae\u4ef6\u3002",
"Your file '{file}' has been uploaded. Allow a few minutes for processing.": "\u4f60\u7684\u6587\u4ef6'{file}'\u5df2\u7ecf\u4e0a\u4f20\u3002\u9700\u8981\u51e0\u5206\u949f\u65f6\u95f4\u8fdb\u884c\u5904\u7406\u3002",
"Your file could not be uploaded": "\u60a8\u7684\u6587\u4ef6\u65e0\u6cd5\u4e0a\u4f20\u3002",
"Your file has been deleted.": "\u60a8\u7684\u6587\u4ef6\u5df2\u7ecf\u88ab\u5220\u9664",
"Your import has failed.": "\u5bfc\u5165\u5931\u8d25\u3002",
@@ -938,10 +938,10 @@
"Your message must have a subject.": "\u60a8\u7684\u6d88\u606f\u5fc5\u987b\u6709\u4e00\u4e2a\u6807\u9898\u3002",
"Your policy changes have been saved.": "\u60a8\u7684\u7b56\u7565\u53d8\u66f4\u5df2\u4fdd\u5b58\u3002",
"Your post will be discarded.": "\u60a8\u7684\u5e16\u5b50\u5c06\u88ab\u64a4\u9500\u3002",
"Your request could not be completed due to a server problem. Reload the page": "\u7531\u4e8e\u670d\u52a1\u5668\u7684\u95ee\u9898\uff0c\u65e0\u6cd5\u5b8c\u6210\u4f60\u7684\u8bf7\u6c42\uff0c\u8bf7\u91cd\u65b0\u52a0\u8f7d\u9875\u9762\u3002",
"Your request could not be completed. Reload the page and try again. If the issue persists, click the Help tab to report the problem.": "\u4f60\u7684\u8bf7\u6c42\u65e0\u6cd5\u5b8c\u6210\u3002\u91cd\u65b0\u52a0\u8f7d\u9875\u9762\u5e76\u91cd\u8bd5\u3002\u5982\u679c\u95ee\u9898\u4ecd\u7136\u5b58\u5728\uff0c\u5219\u5355\u51fb\u201c\u5e2e\u52a9\u201d\u9009\u9879\u5361\u62a5\u544a\u95ee\u9898\u3002",
"Your upload of '{file}' failed.": "\u4f60\u7684\u6587\u4ef6\u201c{file}\u201d\u4e0a\u4f20\u5931\u8d25\u3002",
"Your upload of '{file}' succeeded.": "\u4f60\u7684\u6587\u4ef6\u201c{file}\u201d\u4e0a\u4f20\u6210\u529f\u3002",
"Your request could not be completed due to a server problem. Reload the page": "\u7531\u4e8e\u670d\u52a1\u5668\u7684\u95ee\u9898\uff0c\u65e0\u6cd5\u5b8c\u6210\u60a8\u7684\u8bf7\u6c42\uff0c\u8bf7\u91cd\u65b0\u52a0\u8f7d\u9875\u9762\u3002",
"Your request could not be completed. Reload the page and try again. If the issue persists, click the Help tab to report the problem.": "\u60a8\u7684\u8bf7\u6c42\u65e0\u6cd5\u5b8c\u6210\u3002\u91cd\u65b0\u52a0\u8f7d\u9875\u9762\u5e76\u91cd\u8bd5\u3002\u5982\u679c\u95ee\u9898\u4ecd\u7136\u5b58\u5728\uff0c\u70b9\u51fb\u201c\u5e2e\u52a9\u201d\u9009\u9879\u62a5\u544a\u95ee\u9898\u3002",
"Your upload of '{file}' failed.": "\u60a8\u7684\u6587\u4ef6'{file}'\u4e0a\u4f20\u5931\u8d25\u3002",
"Your upload of '{file}' succeeded.": "\u60a8\u7684\u6587\u4ef6'{file}'\u4e0a\u4f20\u6210\u529f\u3002",
"a day": "\u4e00\u5929",
"about %d hour": [
"\u5927\u7ea6 %d \u5c0f\u65f6"

View File

@@ -36,8 +36,8 @@ body, input, button {
font-family: 'Open Sans', sans-serif;
}
// we want to hide the outline on the focusable <main> element
main {
// removing the outline on any element that we make programmatically focusable
[tabindex="-1"] {
outline: none;
}

View File

@@ -0,0 +1,72 @@
"""
Populates a ConfigurationModel by deserializing JSON data contained in a file.
"""
import os
from optparse import make_option
from django.core.management.base import BaseCommand, CommandError
from django.utils.translation import ugettext_lazy as _
from config_models.utils import deserialize_json
class Command(BaseCommand):
"""
This command will deserialize the JSON data in the supplied file to populate
a ConfigurationModel. Note that this will add new entries to the model, but it
will not delete any entries (ConfigurationModel entries are read-only).
"""
help = """
Populates a ConfigurationModel by deserializing the supplied JSON.
JSON should be in a file, with the following format:
{ "model": "config_models.ExampleConfigurationModel",
"data":
[
{ "enabled": True,
"color": "black"
...
},
{ "enabled": False,
"color": "yellow"
...
},
...
]
}
A username corresponding to an existing user must be specified to indicate who
is executing the command.
$ ... populate_model -f path/to/file.json -u username
"""
option_list = BaseCommand.option_list + (
make_option('-f', '--file',
metavar='JSON_FILE',
dest='file',
default=False,
help='JSON file to import ConfigurationModel data'),
make_option('-u', '--username',
metavar='USERNAME',
dest='username',
default=False,
help='username to specify who is executing the command'),
)
def handle(self, *args, **options):
if 'file' not in options or not options['file']:
raise CommandError(_("A file containing JSON must be specified."))
if 'username' not in options or not options['username']:
raise CommandError(_("A valid username must be specified."))
json_file = options['file']
if not os.path.exists(json_file):
raise CommandError(_("File {0} does not exist").format(json_file))
self.stdout.write(_("Importing JSON data from file {0}").format(json_file))
with open(json_file) as data:
created_entries = deserialize_json(data, options['username'])
self.stdout.write(_("Import complete, {0} new entries created").format(created_entries))

View File

@@ -6,6 +6,9 @@ from django.contrib.auth.models import User
from django.core.cache import caches, InvalidCacheBackendError
from django.utils.translation import ugettext_lazy as _
from rest_framework.utils import model_meta
try:
cache = caches['configuration'] # pylint: disable=invalid-name
except InvalidCacheBackendError:
@@ -176,3 +179,58 @@ class ConfigurationModel(models.Model):
values = list(cls.objects.values_list(*key_fields, flat=flat).order_by().distinct())
cache.set(cache_key, values, cls.cache_timeout)
return values
def fields_equal(self, instance, fields_to_ignore=("id", "change_date", "changed_by")):
"""
Compares this instance's fields to the supplied instance to test for equality.
This will ignore any fields in `fields_to_ignore`.
Note that this method ignores many-to-many fields.
Args:
instance: the model instance to compare
fields_to_ignore: List of fields that should not be compared for equality. By default
includes `id`, `change_date`, and `changed_by`.
Returns: True if the checked fields are all equivalent, else False
"""
for field in self._meta.get_fields():
if not field.many_to_many and field.name not in fields_to_ignore:
if getattr(instance, field.name) != getattr(self, field.name):
return False
return True
@classmethod
def equal_to_current(cls, json, fields_to_ignore=("id", "change_date", "changed_by")):
"""
Compares for equality this instance to a model instance constructed from the supplied JSON.
This will ignore any fields in `fields_to_ignore`.
Note that this method cannot handle fields with many-to-many associations, as those can only
be set on a saved model instance (and saving the model instance will create a new entry).
All many-to-many field entries will be removed before the equality comparison is done.
Args:
json: json representing an entry to compare
fields_to_ignore: List of fields that should not be compared for equality. By default
includes `id`, `change_date`, and `changed_by`.
Returns: True if the checked fields are all equivalent, else False
"""
# Remove many-to-many relationships from json.
# They require an instance to be already saved.
info = model_meta.get_field_info(cls)
for field_name, relation_info in info.relations.items():
if relation_info.to_many and (field_name in json):
json.pop(field_name)
new_instance = cls(**json)
key_field_args = tuple(getattr(new_instance, key) for key in cls.KEY_FIELDS)
current = cls.current(*key_field_args)
# If current.id is None, no entry actually existed and the "current" method created it.
if current.id is not None:
return current.fields_equal(new_instance, fields_to_ignore)
return False

View File

@@ -0,0 +1,14 @@
{
"model": "config_models.ExampleDeserializeConfig",
"data": [
{
"name": "betty",
"enabled": true,
"int_field": 5
},
{
"name": "fred",
"enabled": false
}
]
}

View File

@@ -0,0 +1,219 @@
"""
Tests of the populate_model management command and its helper utils.deserialize_json method.
"""
import textwrap
import os.path
from django.utils import timezone
from django.utils.six import BytesIO
from django.contrib.auth.models import User
from django.core.management.base import CommandError
from django.db import models
from config_models.management.commands import populate_model
from config_models.models import ConfigurationModel
from config_models.utils import deserialize_json
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
class ExampleDeserializeConfig(ConfigurationModel):
"""
Test model for testing deserialization of ``ConfigurationModels`` with keyed configuration.
"""
KEY_FIELDS = ('name',)
name = models.TextField()
int_field = models.IntegerField(default=10)
def __unicode__(self):
return "ExampleDeserializeConfig(enabled={}, name={}, int_field={})".format(
self.enabled, self.name, self.int_field
)
class DeserializeJSONTests(CacheIsolationTestCase):
"""
Tests of deserializing the JSON representation of ConfigurationModels.
"""
def setUp(self):
super(DeserializeJSONTests, self).setUp()
self.test_username = 'test_worker'
User.objects.create_user(username=self.test_username)
self.fixture_path = os.path.join(os.path.dirname(__file__), 'data', 'data.json')
def test_deserialize_models(self):
"""
Tests the "happy path", where 2 instances of the test model should be created.
A valid username is supplied for the operation.
"""
start_date = timezone.now()
with open(self.fixture_path) as data:
entries_created = deserialize_json(data, self.test_username)
self.assertEquals(2, entries_created)
self.assertEquals(2, ExampleDeserializeConfig.objects.count())
betty = ExampleDeserializeConfig.current('betty')
self.assertTrue(betty.enabled)
self.assertEquals(5, betty.int_field)
self.assertGreater(betty.change_date, start_date)
self.assertEquals(self.test_username, betty.changed_by.username)
fred = ExampleDeserializeConfig.current('fred')
self.assertFalse(fred.enabled)
self.assertEquals(10, fred.int_field)
self.assertGreater(fred.change_date, start_date)
self.assertEquals(self.test_username, fred.changed_by.username)
def test_existing_entries_not_removed(self):
"""
Any existing configuration model entries are retained
(though they may be come history)-- deserialize_json is purely additive.
"""
ExampleDeserializeConfig(name="fred", enabled=True).save()
ExampleDeserializeConfig(name="barney", int_field=200).save()
with open(self.fixture_path) as data:
entries_created = deserialize_json(data, self.test_username)
self.assertEquals(2, entries_created)
self.assertEquals(4, ExampleDeserializeConfig.objects.count())
self.assertEquals(3, len(ExampleDeserializeConfig.objects.current_set()))
self.assertEquals(5, ExampleDeserializeConfig.current('betty').int_field)
self.assertEquals(200, ExampleDeserializeConfig.current('barney').int_field)
# The JSON file changes "enabled" to False for Fred.
fred = ExampleDeserializeConfig.current('fred')
self.assertFalse(fred.enabled)
def test_duplicate_entries_not_made(self):
"""
If there is no change in an entry (besides changed_by and change_date),
a new entry is not made.
"""
with open(self.fixture_path) as data:
entries_created = deserialize_json(data, self.test_username)
self.assertEquals(2, entries_created)
with open(self.fixture_path) as data:
entries_created = deserialize_json(data, self.test_username)
self.assertEquals(0, entries_created)
# Importing twice will still only result in 2 records (second import a no-op).
self.assertEquals(2, ExampleDeserializeConfig.objects.count())
# Change Betty.
betty = ExampleDeserializeConfig.current('betty')
betty.int_field = -8
betty.save()
self.assertEquals(3, ExampleDeserializeConfig.objects.count())
self.assertEquals(-8, ExampleDeserializeConfig.current('betty').int_field)
# Now importing will add a new entry for Betty.
with open(self.fixture_path) as data:
entries_created = deserialize_json(data, self.test_username)
self.assertEquals(1, entries_created)
self.assertEquals(4, ExampleDeserializeConfig.objects.count())
self.assertEquals(5, ExampleDeserializeConfig.current('betty').int_field)
def test_bad_username(self):
"""
Tests the error handling when the specified user does not exist.
"""
test_json = textwrap.dedent("""
{
"model": "config_models.ExampleDeserializeConfig",
"data": [{"name": "dino"}]
}
""")
with self.assertRaisesRegexp(Exception, "User matching query does not exist"):
deserialize_json(BytesIO(test_json), "unknown_username")
def test_invalid_json(self):
"""
Tests the error handling when there is invalid JSON.
"""
test_json = textwrap.dedent("""
{
"model": "config_models.ExampleDeserializeConfig",
"data": [{"name": "dino"
""")
with self.assertRaisesRegexp(Exception, "JSON parse error"):
deserialize_json(BytesIO(test_json), self.test_username)
def test_invalid_model(self):
"""
Tests the error handling when the configuration model specified does not exist.
"""
test_json = textwrap.dedent("""
{
"model": "xxx.yyy",
"data":[{"name": "dino"}]
}
""")
with self.assertRaisesRegexp(Exception, "No installed app"):
deserialize_json(BytesIO(test_json), self.test_username)
class PopulateModelTestCase(CacheIsolationTestCase):
"""
Tests of populate model management command.
"""
def setUp(self):
super(PopulateModelTestCase, self).setUp()
self.file_path = os.path.join(os.path.dirname(__file__), 'data', 'data.json')
self.test_username = 'test_management_worker'
User.objects.create_user(username=self.test_username)
def test_run_command(self):
"""
Tests the "happy path", where 2 instances of the test model should be created.
A valid username is supplied for the operation.
"""
_run_command(file=self.file_path, username=self.test_username)
self.assertEquals(2, ExampleDeserializeConfig.objects.count())
betty = ExampleDeserializeConfig.current('betty')
self.assertEquals(self.test_username, betty.changed_by.username)
fred = ExampleDeserializeConfig.current('fred')
self.assertEquals(self.test_username, fred.changed_by.username)
def test_no_user_specified(self):
"""
Tests that a username must be specified.
"""
with self.assertRaisesRegexp(CommandError, "A valid username must be specified"):
_run_command(file=self.file_path)
def test_bad_user_specified(self):
"""
Tests that a username must be specified.
"""
with self.assertRaisesRegexp(Exception, "User matching query does not exist"):
_run_command(file=self.file_path, username="does_not_exist")
def test_no_file_specified(self):
"""
Tests the error handling when no JSON file is supplied.
"""
with self.assertRaisesRegexp(CommandError, "A file containing JSON must be specified"):
_run_command(username=self.test_username)
def test_bad_file_specified(self):
"""
Tests the error handling when the path to the JSON file is incorrect.
"""
with self.assertRaisesRegexp(CommandError, "File does/not/exist.json does not exist"):
_run_command(file="does/not/exist.json", username=self.test_username)
def _run_command(*args, **kwargs):
"""Run the management command to deserializer JSON ConfigurationModel data. """
command = populate_model.Command()
return command.handle(*args, **kwargs)

View File

@@ -25,6 +25,24 @@ class ExampleConfig(ConfigurationModel):
string_field = models.TextField()
int_field = models.IntegerField(default=10)
def __unicode__(self):
return "ExampleConfig(enabled={}, string_field={}, int_field={})".format(
self.enabled, self.string_field, self.int_field
)
class ManyToManyExampleConfig(ConfigurationModel):
"""
Test model configuration with a many-to-many field.
"""
cache_timeout = 300
string_field = models.TextField()
many_user_field = models.ManyToManyField(User, related_name='topic_many_user_field')
def __unicode__(self):
return "ManyToManyExampleConfig(enabled={}, string_field={})".format(self.enabled, self.string_field)
@patch('config_models.models.cache')
class ConfigurationModelTests(TestCase):
@@ -40,7 +58,7 @@ class ConfigurationModelTests(TestCase):
ExampleConfig(changed_by=self.user).save()
mock_cache.delete.assert_called_with(ExampleConfig.cache_key_name())
def test_cache_key_name(self, _mock_cache):
def test_cache_key_name(self, __):
self.assertEquals(ExampleConfig.cache_key_name(), 'configuration/ExampleConfig/current')
def test_no_config_empty_cache(self, mock_cache):
@@ -103,6 +121,64 @@ class ConfigurationModelTests(TestCase):
self.assertEquals(2, ExampleConfig.objects.all().count())
def test_equality(self, mock_cache):
mock_cache.get.return_value = None
config = ExampleConfig(changed_by=self.user, string_field='first')
config.save()
self.assertTrue(ExampleConfig.equal_to_current({"string_field": "first"}))
self.assertTrue(ExampleConfig.equal_to_current({"string_field": "first", "enabled": False}))
self.assertTrue(ExampleConfig.equal_to_current({"string_field": "first", "int_field": 10}))
self.assertFalse(ExampleConfig.equal_to_current({"string_field": "first", "enabled": True}))
self.assertFalse(ExampleConfig.equal_to_current({"string_field": "first", "int_field": 20}))
self.assertFalse(ExampleConfig.equal_to_current({"string_field": "second"}))
self.assertFalse(ExampleConfig.equal_to_current({}))
def test_equality_custom_fields_to_ignore(self, mock_cache):
mock_cache.get.return_value = None
config = ExampleConfig(changed_by=self.user, string_field='first')
config.save()
# id, change_date, and changed_by will all be different for a newly created entry
self.assertTrue(ExampleConfig.equal_to_current({"string_field": "first"}))
self.assertFalse(
ExampleConfig.equal_to_current({"string_field": "first"}, fields_to_ignore=("change_date", "changed_by"))
)
self.assertFalse(
ExampleConfig.equal_to_current({"string_field": "first"}, fields_to_ignore=("id", "changed_by"))
)
self.assertFalse(
ExampleConfig.equal_to_current({"string_field": "first"}, fields_to_ignore=("change_date", "id"))
)
# Test the ability to ignore a different field ("int_field").
self.assertFalse(ExampleConfig.equal_to_current({"string_field": "first", "int_field": 20}))
self.assertTrue(
ExampleConfig.equal_to_current(
{"string_field": "first", "int_field": 20},
fields_to_ignore=("id", "change_date", "changed_by", "int_field")
)
)
def test_equality_ignores_many_to_many(self, mock_cache):
mock_cache.get.return_value = None
config = ManyToManyExampleConfig(changed_by=self.user, string_field='first')
config.save()
second_user = User(username="second_user")
second_user.save()
config.many_user_field.add(second_user) # pylint: disable=no-member
config.save()
# The many-to-many field is ignored in comparison.
self.assertTrue(
ManyToManyExampleConfig.equal_to_current({"string_field": "first", "many_user_field": "removed"})
)
class ExampleKeyedConfig(ConfigurationModel):
"""
@@ -120,6 +196,11 @@ class ExampleKeyedConfig(ConfigurationModel):
string_field = models.TextField()
int_field = models.IntegerField(default=10)
def __unicode__(self):
return "ExampleKeyedConfig(enabled={}, left={}, right={}, string_field={}, int_field={})".format(
self.enabled, self.left, self.right, self.string_field, self.int_field
)
@ddt.ddt
@patch('config_models.models.cache')
@@ -294,6 +375,45 @@ class KeyedConfigurationModelTests(TestCase):
mock_cache.get.return_value = fake_result
self.assertEquals(ExampleKeyedConfig.key_values(), fake_result)
def test_equality(self, mock_cache):
mock_cache.get.return_value = None
config1 = ExampleKeyedConfig(left='left_a', right='right_a', int_field=1, changed_by=self.user)
config1.save()
config2 = ExampleKeyedConfig(left='left_b', right='right_b', int_field=2, changed_by=self.user, enabled=True)
config2.save()
config3 = ExampleKeyedConfig(left='left_c', changed_by=self.user)
config3.save()
self.assertTrue(
ExampleKeyedConfig.equal_to_current({"left": "left_a", "right": "right_a", "int_field": 1})
)
self.assertTrue(
ExampleKeyedConfig.equal_to_current({"left": "left_b", "right": "right_b", "int_field": 2, "enabled": True})
)
self.assertTrue(
ExampleKeyedConfig.equal_to_current({"left": "left_c"})
)
self.assertFalse(
ExampleKeyedConfig.equal_to_current(
{"left": "left_a", "right": "right_a", "int_field": 1, "string_field": "foo"}
)
)
self.assertFalse(
ExampleKeyedConfig.equal_to_current({"left": "left_a", "int_field": 1})
)
self.assertFalse(
ExampleKeyedConfig.equal_to_current({"left": "left_b", "right": "right_b", "int_field": 2})
)
self.assertFalse(
ExampleKeyedConfig.equal_to_current({"left": "left_c", "int_field": 11})
)
self.assertFalse(ExampleKeyedConfig.equal_to_current({}))
@ddt.ddt
class ConfigurationModelAPITests(TestCase):

View File

@@ -0,0 +1,69 @@
"""
Utilities for working with ConfigurationModels.
"""
from django.apps import apps
from rest_framework.parsers import JSONParser
from rest_framework.serializers import ModelSerializer
from django.contrib.auth.models import User
def get_serializer_class(configuration_model):
""" Returns a ConfigurationModel serializer class for the supplied configuration_model. """
class AutoConfigModelSerializer(ModelSerializer):
"""Serializer class for configuration models."""
class Meta(object):
"""Meta information for AutoConfigModelSerializer."""
model = configuration_model
def create(self, validated_data):
if "changed_by_username" in self.context:
validated_data['changed_by'] = User.objects.get(username=self.context["changed_by_username"])
return super(AutoConfigModelSerializer, self).create(validated_data)
return AutoConfigModelSerializer
def deserialize_json(stream, username):
"""
Given a stream containing JSON, deserializers the JSON into ConfigurationModel instances.
The stream is expected to be in the following format:
{ "model": "config_models.ExampleConfigurationModel",
"data":
[
{ "enabled": True,
"color": "black"
...
},
{ "enabled": False,
"color": "yellow"
...
},
...
]
}
If the provided stream does not contain valid JSON for the ConfigurationModel specified,
an Exception will be raised.
Arguments:
stream: The stream of JSON, as described above.
username: The username of the user making the change. This must match an existing user.
Returns: the number of created entries
"""
parsed_json = JSONParser().parse(stream)
serializer_class = get_serializer_class(apps.get_model(parsed_json["model"]))
list_serializer = serializer_class(data=parsed_json["data"], context={"changed_by_username": username}, many=True)
if list_serializer.is_valid():
model_class = serializer_class.Meta.model
for data in reversed(list_serializer.validated_data):
if model_class.equal_to_current(data):
list_serializer.validated_data.remove(data)
entries_created = len(list_serializer.validated_data)
list_serializer.save()
return entries_created
else:
raise Exception(list_serializer.error_messages)

View File

@@ -4,9 +4,10 @@ API view to allow manipulation of configuration models.
from rest_framework.generics import CreateAPIView, RetrieveAPIView
from rest_framework.permissions import DjangoModelPermissions
from rest_framework.authentication import SessionAuthentication
from rest_framework.serializers import ModelSerializer
from django.db import transaction
from config_models.utils import get_serializer_class
class ReadableOnlyByAuthors(DjangoModelPermissions):
"""Only allow access by users with `add` permissions on the model."""
@@ -58,13 +59,7 @@ class ConfigurationModelCurrentAPIView(AtomicMixin, CreateAPIView, RetrieveAPIVi
def get_serializer_class(self):
if self.serializer_class is None:
class AutoConfigModelSerializer(ModelSerializer):
"""Serializer class for configuration models."""
class Meta(object):
"""Meta information for AutoConfigModelSerializer."""
model = self.model
self.serializer_class = AutoConfigModelSerializer
self.serializer_class = get_serializer_class(self.model)
return self.serializer_class

View File

@@ -3,12 +3,11 @@ Middleware to serve assets.
"""
import logging
import datetime
import newrelic.agent
from django.http import (
HttpResponse, HttpResponseNotModified, HttpResponseForbidden,
HttpResponseBadRequest, HttpResponseNotFound)
HttpResponseBadRequest, HttpResponseNotFound, HttpResponsePermanentRedirect)
from student.models import CourseEnrollment
from contentserver.models import CourseAssetCacheTtlConfig, CdnUserAgentsConfig
@@ -30,32 +29,54 @@ HTTP_DATE_FORMAT = "%a, %d %b %Y %H:%M:%S GMT"
class StaticContentServer(object):
"""
Serves course assets to end users. Colloquially referred to as "contentserver."
"""
def is_asset_request(self, request):
"""Determines whether the given request is an asset request"""
return (
request.path.startswith('/' + XASSET_LOCATION_TAG + '/')
or
request.path.startswith('/' + AssetLocator.CANONICAL_NAMESPACE)
or
StaticContent.is_versioned_asset_path(request.path)
)
def process_request(self, request):
"""Process the given request"""
asset_path = request.path
if self.is_asset_request(request):
# Make sure we can convert this request into a location.
if AssetLocator.CANONICAL_NAMESPACE in request.path:
request.path = request.path.replace('block/', 'block@', 1)
if AssetLocator.CANONICAL_NAMESPACE in asset_path:
asset_path = asset_path.replace('block/', 'block@', 1)
# If this is a versioned request, pull out the digest and chop off the prefix.
requested_digest = None
if StaticContent.is_versioned_asset_path(asset_path):
requested_digest, asset_path = StaticContent.parse_versioned_asset_path(asset_path)
# Make sure we have a valid location value for this asset.
try:
loc = StaticContent.get_location_from_path(request.path)
loc = StaticContent.get_location_from_path(asset_path)
except (InvalidLocationError, InvalidKeyError):
return HttpResponseBadRequest()
# Try and load the asset.
content = None
# Attempt to load the asset to make sure it exists, and grab the asset digest
# if we're able to load it.
actual_digest = None
try:
content = self.load_asset_from_location(loc)
actual_digest = getattr(content, "content_digest", None)
except (ItemNotFoundError, NotFoundError):
return HttpResponseNotFound()
# If this was a versioned asset, and the digest doesn't match, redirect
# them to the actual version.
if requested_digest is not None and actual_digest is not None and (actual_digest != requested_digest):
actual_asset_path = StaticContent.add_version_to_asset_path(asset_path, actual_digest)
return HttpResponsePermanentRedirect(actual_asset_path)
# Set the basics for this request. Make sure that the course key for this
# asset has a run, which old-style courses do not. Otherwise, this will
# explode when the key is serialized to be sent to NR.

View File

@@ -16,9 +16,13 @@ from django.test.utils import override_settings
from mock import patch
from xmodule.contentstore.django import contentstore
from xmodule.contentstore.content import StaticContent
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.xml_importer import import_course_from_xml
from xmodule.assetstore.assetmgr import AssetManager
from opaque_keys import InvalidKeyError
from xmodule.modulestore.exceptions import ItemNotFoundError
from contentserver.middleware import parse_range_header, HTTP_DATE_FORMAT, StaticContentServer
from student.models import CourseEnrollment
@@ -28,9 +32,24 @@ log = logging.getLogger(__name__)
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
FAKE_MD5_HASH = 'ffffffffffffffffffffffffffffffff'
def get_versioned_asset_url(asset_path):
"""
Creates a versioned asset URL.
"""
try:
locator = StaticContent.get_location_from_path(asset_path)
content = AssetManager.find(locator, as_stream=True)
return StaticContent.add_version_to_asset_path(asset_path, content.content_digest)
except (InvalidKeyError, ItemNotFoundError):
pass
return asset_path
@ddt.ddt
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE)
@@ -54,13 +73,15 @@ class ContentStoreToyCourseTest(SharedModuleStoreTestCase):
)
# A locked asset
cls.locked_asset = cls.course_key.make_asset_key('asset', 'sample_static.txt')
cls.locked_asset = cls.course_key.make_asset_key('asset', 'sample_static.html')
cls.url_locked = unicode(cls.locked_asset)
cls.url_locked_versioned = get_versioned_asset_url(cls.url_locked)
cls.contentstore.set_attr(cls.locked_asset, 'locked', True)
# An unlocked asset
cls.unlocked_asset = cls.course_key.make_asset_key('asset', 'another_static.txt')
cls.url_unlocked = unicode(cls.unlocked_asset)
cls.url_unlocked_versioned = get_versioned_asset_url(cls.url_unlocked)
cls.length_unlocked = cls.contentstore.get_attr(cls.unlocked_asset, 'length')
def setUp(self):
@@ -81,6 +102,37 @@ class ContentStoreToyCourseTest(SharedModuleStoreTestCase):
resp = self.client.get(self.url_unlocked)
self.assertEqual(resp.status_code, 200)
def test_unlocked_versioned_asset(self):
"""
Test that unlocked assets that are versioned are being served.
"""
self.client.logout()
resp = self.client.get(self.url_unlocked_versioned)
self.assertEqual(resp.status_code, 200)
def test_unlocked_versioned_asset_with_nonexistent_version(self):
"""
Test that unlocked assets that are versioned, but have a nonexistent version,
are sent back as a 301 redirect which tells the caller the correct URL.
"""
url_unlocked_versioned_old = StaticContent.add_version_to_asset_path(self.url_unlocked, FAKE_MD5_HASH)
self.client.logout()
resp = self.client.get(url_unlocked_versioned_old)
self.assertEqual(resp.status_code, 301)
self.assertTrue(resp.url.endswith(self.url_unlocked_versioned)) # pylint: disable=no-member
def test_locked_versioned_asset(self):
"""
Test that locked assets that are versioned are being served.
"""
CourseEnrollment.enroll(self.non_staff_usr, self.course_key)
self.assertTrue(CourseEnrollment.is_enrolled(self.non_staff_usr, self.course_key))
self.client.login(username=self.non_staff_usr, password='test')
resp = self.client.get(self.url_locked_versioned)
self.assertEqual(resp.status_code, 200)
def test_locked_asset_not_logged_in(self):
"""
Test that locked assets behave appropriately in case the user is not

View File

@@ -13,6 +13,7 @@ from xmodule.contentstore.content import StaticContent
from opaque_keys.edx.locator import AssetLocator
log = logging.getLogger(__name__)
XBLOCK_STATIC_RESOURCE_PREFIX = '/static/xblock'
def _url_replace_regex(prefix):
@@ -109,6 +110,13 @@ def process_static_urls(text, replacement_function, data_dir=None):
prefix = match.group('prefix')
quote = match.group('quote')
rest = match.group('rest')
# Don't rewrite XBlock resource links. Probably wasn't a good idea that /static
# works for actual static assets and for magical course asset URLs....
full_url = prefix + rest
if full_url.startswith(XBLOCK_STATIC_RESOURCE_PREFIX):
return original
return replacement_function(original, prefix, quote, rest)
return re.sub(

View File

@@ -1,12 +1,13 @@
# -*- coding: utf-8 -*-
"""Tests for static_replace"""
from urllib import quote_plus
import ddt
import re
from django.utils.http import urlquote, urlencode
from urlparse import urlparse, urlunparse, parse_qsl
from PIL import Image
from cStringIO import StringIO
from nose.tools import assert_equals, assert_true, assert_false # pylint: disable=no-name-in-module
from static_replace import (
replace_static_urls,
@@ -16,7 +17,6 @@ from static_replace import (
make_static_urls_absolute
)
from mock import patch, Mock
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import contentstore
@@ -25,12 +25,29 @@ from xmodule.modulestore.mongo import MongoModuleStore
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls
from xmodule.modulestore.xml import XMLModuleStore
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.exceptions import NotFoundError
from xmodule.assetstore.assetmgr import AssetManager
DATA_DIRECTORY = 'data_dir'
COURSE_KEY = SlashSeparatedCourseKey('org', 'course', 'run')
STATIC_SOURCE = '"/static/file.png"'
def encode_unicode_characters_in_url(url):
"""
Encodes all Unicode characters to their percent-encoding representation
in both the path portion and query parameter portion of the given URL.
"""
scheme, netloc, path, params, query, fragment = urlparse(url)
query_params = parse_qsl(query)
updated_query_params = []
for query_name, query_val in query_params:
updated_query_params.append((query_name, urlquote(query_val)))
return urlunparse((scheme, netloc, urlquote(path, '/:+@'), params, urlencode(query_params), fragment))
def test_multi_replace():
course_source = '"/course/file.png"'
@@ -179,6 +196,21 @@ def test_regex():
assert_false(re.match(regex, s))
@patch('static_replace.staticfiles_storage', autospec=True)
@patch('static_replace.modulestore', autospec=True)
def test_static_url_with_xblock_resource(mock_modulestore, mock_storage):
"""
Make sure that for URLs with XBlock resource URL, which start with /static/,
we don't rewrite them.
"""
mock_storage.exists.return_value = False
mock_modulestore.return_value = Mock(MongoModuleStore)
pre_text = 'EMBED src ="/static/xblock/resources/babys_first.lil_xblock/public/images/pacifier.png"'
post_text = pre_text
assert_equals(post_text, replace_static_urls(pre_text, DATA_DIRECTORY, COURSE_KEY))
@ddt.ddt
class CanonicalContentTest(SharedModuleStoreTestCase):
"""
@@ -202,7 +234,7 @@ class CanonicalContentTest(SharedModuleStoreTestCase):
cls.courses[prefix] = CourseFactory.create(org='a', course='b', run=prefix)
# Create an unlocked image.
unlock_content = cls.create_image(prefix, (32, 32), 'blue', '{}_unlock.png')
unlock_content = cls.create_image(prefix, (32, 32), 'blue', u'{}_ünlöck.png')
# Create a locked image.
lock_content = cls.create_image(prefix, (32, 32), 'green', '{}_lock.png', locked=True)
@@ -212,14 +244,14 @@ class CanonicalContentTest(SharedModuleStoreTestCase):
contentstore().generate_thumbnail(lock_content, dimensions=(16, 16))
# Create an unlocked image in a subdirectory.
cls.create_image(prefix, (1, 1), 'red', 'special/{}_unlock.png')
cls.create_image(prefix, (1, 1), 'red', u'special/{}_ünlöck.png')
# Create a locked image in a subdirectory.
cls.create_image(prefix, (1, 1), 'yellow', 'special/{}_lock.png', locked=True)
# Create an unlocked image with funky characters in the name.
cls.create_image(prefix, (1, 1), 'black', 'weird {}_unlock.png')
cls.create_image(prefix, (1, 1), 'black', 'special/weird {}_unlock.png')
cls.create_image(prefix, (1, 1), 'black', u'weird {}_ünlöck.png')
cls.create_image(prefix, (1, 1), 'black', u'special/weird {}_ünlöck.png')
# Create an HTML file to test extension exclusion, and create a control file.
cls.create_arbitrary_content(prefix, '{}_not_excluded.htm')
@@ -227,6 +259,24 @@ class CanonicalContentTest(SharedModuleStoreTestCase):
cls.create_arbitrary_content(prefix, 'special/{}_not_excluded.htm')
cls.create_arbitrary_content(prefix, 'special/{}_excluded.html')
@classmethod
def get_content_digest_for_asset_path(cls, prefix, path):
"""
Takes an unprocessed asset path, parses it just enough to try and find the
asset it refers to, and returns the content digest of that asset if it exists.
"""
# Parse the path as if it was potentially a relative URL with query parameters,
# or an absolute URL, etc. Only keep the path because that's all we need.
_, _, relative_path, _, _, _ = urlparse(path)
asset_key = StaticContent.get_asset_key_from_path(cls.courses[prefix].id, relative_path)
try:
content = AssetManager.find(asset_key, as_stream=True)
return content.content_digest
except (ItemNotFoundError, NotFoundError):
return None
@classmethod
def create_image(cls, prefix, dimensions, color, name, locked=False):
"""
@@ -277,100 +327,100 @@ class CanonicalContentTest(SharedModuleStoreTestCase):
@ddt.data(
# No leading slash.
(u'', u'{prfx}_unlock.png', u'/{asset}@{prfx}_unlock.png', 1),
(u'', u'{prfx}_ünlöck.png', u'/{asset}@{prfx}_ünlöck.png', 1),
(u'', u'{prfx}_lock.png', u'/{asset}@{prfx}_lock.png', 1),
(u'', u'weird {prfx}_unlock.png', u'/{asset}@weird_{prfx}_unlock.png', 1),
(u'', u'{prfx}_excluded.html', u'/{asset}@{prfx}_excluded.html', 1),
(u'', u'weird {prfx}_ünlöck.png', u'/{asset}@weird_{prfx}_ünlöck.png', 1),
(u'', u'{prfx}_excluded.html', u'/{base_asset}@{prfx}_excluded.html', 1),
(u'', u'{prfx}_not_excluded.htm', u'/{asset}@{prfx}_not_excluded.htm', 1),
(u'dev', u'{prfx}_unlock.png', u'//dev/{asset}@{prfx}_unlock.png', 1),
(u'dev', u'{prfx}_ünlöck.png', u'//dev/{asset}@{prfx}_ünlöck.png', 1),
(u'dev', u'{prfx}_lock.png', u'/{asset}@{prfx}_lock.png', 1),
(u'dev', u'weird {prfx}_unlock.png', u'//dev/{asset}@weird_{prfx}_unlock.png', 1),
(u'dev', u'{prfx}_excluded.html', u'/{asset}@{prfx}_excluded.html', 1),
(u'dev', u'weird {prfx}_ünlöck.png', u'//dev/{asset}@weird_{prfx}_ünlöck.png', 1),
(u'dev', u'{prfx}_excluded.html', u'/{base_asset}@{prfx}_excluded.html', 1),
(u'dev', u'{prfx}_not_excluded.htm', u'//dev/{asset}@{prfx}_not_excluded.htm', 1),
# No leading slash with subdirectory. This ensures we properly substitute slashes.
(u'', u'special/{prfx}_unlock.png', u'/{asset}@special_{prfx}_unlock.png', 1),
(u'', u'special/{prfx}_ünlöck.png', u'/{asset}@special_{prfx}_ünlöck.png', 1),
(u'', u'special/{prfx}_lock.png', u'/{asset}@special_{prfx}_lock.png', 1),
(u'', u'special/weird {prfx}_unlock.png', u'/{asset}@special_weird_{prfx}_unlock.png', 1),
(u'', u'special/{prfx}_excluded.html', u'/{asset}@special_{prfx}_excluded.html', 1),
(u'', u'special/weird {prfx}_ünlöck.png', u'/{asset}@special_weird_{prfx}_ünlöck.png', 1),
(u'', u'special/{prfx}_excluded.html', u'/{base_asset}@special_{prfx}_excluded.html', 1),
(u'', u'special/{prfx}_not_excluded.htm', u'/{asset}@special_{prfx}_not_excluded.htm', 1),
(u'dev', u'special/{prfx}_unlock.png', u'//dev/{asset}@special_{prfx}_unlock.png', 1),
(u'dev', u'special/{prfx}_ünlöck.png', u'//dev/{asset}@special_{prfx}_ünlöck.png', 1),
(u'dev', u'special/{prfx}_lock.png', u'/{asset}@special_{prfx}_lock.png', 1),
(u'dev', u'special/weird {prfx}_unlock.png', u'//dev/{asset}@special_weird_{prfx}_unlock.png', 1),
(u'dev', u'special/{prfx}_excluded.html', u'/{asset}@special_{prfx}_excluded.html', 1),
(u'dev', u'special/weird {prfx}_ünlöck.png', u'//dev/{asset}@special_weird_{prfx}_ünlöck.png', 1),
(u'dev', u'special/{prfx}_excluded.html', u'/{base_asset}@special_{prfx}_excluded.html', 1),
(u'dev', u'special/{prfx}_not_excluded.htm', u'//dev/{asset}@special_{prfx}_not_excluded.htm', 1),
# Leading slash.
(u'', u'/{prfx}_unlock.png', u'/{asset}@{prfx}_unlock.png', 1),
(u'', u'/{prfx}_ünlöck.png', u'/{asset}@{prfx}_ünlöck.png', 1),
(u'', u'/{prfx}_lock.png', u'/{asset}@{prfx}_lock.png', 1),
(u'', u'/weird {prfx}_unlock.png', u'/{asset}@weird_{prfx}_unlock.png', 1),
(u'', u'/{prfx}_excluded.html', u'/{asset}@{prfx}_excluded.html', 1),
(u'', u'/weird {prfx}_ünlöck.png', u'/{asset}@weird_{prfx}_ünlöck.png', 1),
(u'', u'/{prfx}_excluded.html', u'/{base_asset}@{prfx}_excluded.html', 1),
(u'', u'/{prfx}_not_excluded.htm', u'/{asset}@{prfx}_not_excluded.htm', 1),
(u'dev', u'/{prfx}_unlock.png', u'//dev/{asset}@{prfx}_unlock.png', 1),
(u'dev', u'/{prfx}_ünlöck.png', u'//dev/{asset}@{prfx}_ünlöck.png', 1),
(u'dev', u'/{prfx}_lock.png', u'/{asset}@{prfx}_lock.png', 1),
(u'dev', u'/weird {prfx}_unlock.png', u'//dev/{asset}@weird_{prfx}_unlock.png', 1),
(u'dev', u'/{prfx}_excluded.html', u'/{asset}@{prfx}_excluded.html', 1),
(u'dev', u'/weird {prfx}_ünlöck.png', u'//dev/{asset}@weird_{prfx}_ünlöck.png', 1),
(u'dev', u'/{prfx}_excluded.html', u'/{base_asset}@{prfx}_excluded.html', 1),
(u'dev', u'/{prfx}_not_excluded.htm', u'//dev/{asset}@{prfx}_not_excluded.htm', 1),
# Leading slash with subdirectory. This ensures we properly substitute slashes.
(u'', u'/special/{prfx}_unlock.png', u'/{asset}@special_{prfx}_unlock.png', 1),
(u'', u'/special/{prfx}_ünlöck.png', u'/{asset}@special_{prfx}_ünlöck.png', 1),
(u'', u'/special/{prfx}_lock.png', u'/{asset}@special_{prfx}_lock.png', 1),
(u'', u'/special/weird {prfx}_unlock.png', u'/{asset}@special_weird_{prfx}_unlock.png', 1),
(u'', u'/special/{prfx}_excluded.html', u'/{asset}@special_{prfx}_excluded.html', 1),
(u'', u'/special/weird {prfx}_ünlöck.png', u'/{asset}@special_weird_{prfx}_ünlöck.png', 1),
(u'', u'/special/{prfx}_excluded.html', u'/{base_asset}@special_{prfx}_excluded.html', 1),
(u'', u'/special/{prfx}_not_excluded.htm', u'/{asset}@special_{prfx}_not_excluded.htm', 1),
(u'dev', u'/special/{prfx}_unlock.png', u'//dev/{asset}@special_{prfx}_unlock.png', 1),
(u'dev', u'/special/{prfx}_ünlöck.png', u'//dev/{asset}@special_{prfx}_ünlöck.png', 1),
(u'dev', u'/special/{prfx}_lock.png', u'/{asset}@special_{prfx}_lock.png', 1),
(u'dev', u'/special/weird {prfx}_unlock.png', u'//dev/{asset}@special_weird_{prfx}_unlock.png', 1),
(u'dev', u'/special/{prfx}_excluded.html', u'/{asset}@special_{prfx}_excluded.html', 1),
(u'dev', u'/special/weird {prfx}_ünlöck.png', u'//dev/{asset}@special_weird_{prfx}_ünlöck.png', 1),
(u'dev', u'/special/{prfx}_excluded.html', u'/{base_asset}@special_{prfx}_excluded.html', 1),
(u'dev', u'/special/{prfx}_not_excluded.htm', u'//dev/{asset}@special_{prfx}_not_excluded.htm', 1),
# Static path.
(u'', u'/static/{prfx}_unlock.png', u'/{asset}@{prfx}_unlock.png', 1),
(u'', u'/static/{prfx}_ünlöck.png', u'/{asset}@{prfx}_ünlöck.png', 1),
(u'', u'/static/{prfx}_lock.png', u'/{asset}@{prfx}_lock.png', 1),
(u'', u'/static/weird {prfx}_unlock.png', u'/{asset}@weird_{prfx}_unlock.png', 1),
(u'', u'/static/{prfx}_excluded.html', u'/{asset}@{prfx}_excluded.html', 1),
(u'', u'/static/weird {prfx}_ünlöck.png', u'/{asset}@weird_{prfx}_ünlöck.png', 1),
(u'', u'/static/{prfx}_excluded.html', u'/{base_asset}@{prfx}_excluded.html', 1),
(u'', u'/static/{prfx}_not_excluded.htm', u'/{asset}@{prfx}_not_excluded.htm', 1),
(u'dev', u'/static/{prfx}_unlock.png', u'//dev/{asset}@{prfx}_unlock.png', 1),
(u'dev', u'/static/{prfx}_ünlöck.png', u'//dev/{asset}@{prfx}_ünlöck.png', 1),
(u'dev', u'/static/{prfx}_lock.png', u'/{asset}@{prfx}_lock.png', 1),
(u'dev', u'/static/weird {prfx}_unlock.png', u'//dev/{asset}@weird_{prfx}_unlock.png', 1),
(u'dev', u'/static/{prfx}_excluded.html', u'/{asset}@{prfx}_excluded.html', 1),
(u'dev', u'/static/weird {prfx}_ünlöck.png', u'//dev/{asset}@weird_{prfx}_ünlöck.png', 1),
(u'dev', u'/static/{prfx}_excluded.html', u'/{base_asset}@{prfx}_excluded.html', 1),
(u'dev', u'/static/{prfx}_not_excluded.htm', u'//dev/{asset}@{prfx}_not_excluded.htm', 1),
# Static path with subdirectory. This ensures we properly substitute slashes.
(u'', u'/static/special/{prfx}_unlock.png', u'/{asset}@special_{prfx}_unlock.png', 1),
(u'', u'/static/special/{prfx}_ünlöck.png', u'/{asset}@special_{prfx}_ünlöck.png', 1),
(u'', u'/static/special/{prfx}_lock.png', u'/{asset}@special_{prfx}_lock.png', 1),
(u'', u'/static/special/weird {prfx}_unlock.png', u'/{asset}@special_weird_{prfx}_unlock.png', 1),
(u'', u'/static/special/{prfx}_excluded.html', u'/{asset}@special_{prfx}_excluded.html', 1),
(u'', u'/static/special/weird {prfx}_ünlöck.png', u'/{asset}@special_weird_{prfx}_ünlöck.png', 1),
(u'', u'/static/special/{prfx}_excluded.html', u'/{base_asset}@special_{prfx}_excluded.html', 1),
(u'', u'/static/special/{prfx}_not_excluded.htm', u'/{asset}@special_{prfx}_not_excluded.htm', 1),
(u'dev', u'/static/special/{prfx}_unlock.png', u'//dev/{asset}@special_{prfx}_unlock.png', 1),
(u'dev', u'/static/special/{prfx}_ünlöck.png', u'//dev/{asset}@special_{prfx}_ünlöck.png', 1),
(u'dev', u'/static/special/{prfx}_lock.png', u'/{asset}@special_{prfx}_lock.png', 1),
(u'dev', u'/static/special/weird {prfx}_unlock.png', u'//dev/{asset}@special_weird_{prfx}_unlock.png', 1),
(u'dev', u'/static/special/{prfx}_excluded.html', u'/{asset}@special_{prfx}_excluded.html', 1),
(u'dev', u'/static/special/weird {prfx}_ünlöck.png', u'//dev/{asset}@special_weird_{prfx}_ünlöck.png', 1),
(u'dev', u'/static/special/{prfx}_excluded.html', u'/{base_asset}@special_{prfx}_excluded.html', 1),
(u'dev', u'/static/special/{prfx}_not_excluded.htm', u'//dev/{asset}@special_{prfx}_not_excluded.htm', 1),
# Static path with query parameter.
(
u'',
u'/static/{prfx}_unlock.png?foo=/static/{prfx}_lock.png',
u'/{asset}@{prfx}_unlock.png?foo={encoded_asset}{prfx}_lock.png',
u'/static/{prfx}_ünlöck.png?foo=/static/{prfx}_lock.png',
u'/{asset}@{prfx}_ünlöck.png?foo={encoded_asset}{prfx}_lock.png',
2
),
(
u'',
u'/static/{prfx}_lock.png?foo=/static/{prfx}_unlock.png',
u'/{asset}@{prfx}_lock.png?foo={encoded_asset}{prfx}_unlock.png',
u'/static/{prfx}_lock.png?foo=/static/{prfx}_ünlöck.png',
u'/{asset}@{prfx}_lock.png?foo={encoded_asset}{prfx}_ünlöck.png',
2
),
(
u'',
u'/static/{prfx}_excluded.html?foo=/static/{prfx}_excluded.html',
u'/{asset}@{prfx}_excluded.html?foo={encoded_asset}{prfx}_excluded.html',
u'/{base_asset}@{prfx}_excluded.html?foo={encoded_base_asset}{prfx}_excluded.html',
2
),
(
u'',
u'/static/{prfx}_excluded.html?foo=/static/{prfx}_not_excluded.htm',
u'/{asset}@{prfx}_excluded.html?foo={encoded_asset}{prfx}_not_excluded.htm',
u'/{base_asset}@{prfx}_excluded.html?foo={encoded_asset}{prfx}_not_excluded.htm',
2
),
(
u'',
u'/static/{prfx}_not_excluded.htm?foo=/static/{prfx}_excluded.html',
u'/{asset}@{prfx}_not_excluded.htm?foo={encoded_asset}{prfx}_excluded.html',
u'/{asset}@{prfx}_not_excluded.htm?foo={encoded_base_asset}{prfx}_excluded.html',
2
),
(
@@ -381,32 +431,32 @@ class CanonicalContentTest(SharedModuleStoreTestCase):
),
(
u'dev',
u'/static/{prfx}_unlock.png?foo=/static/{prfx}_lock.png',
u'//dev/{asset}@{prfx}_unlock.png?foo={encoded_asset}{prfx}_lock.png',
u'/static/{prfx}_ünlöck.png?foo=/static/{prfx}_lock.png',
u'//dev/{asset}@{prfx}_ünlöck.png?foo={encoded_asset}{prfx}_lock.png',
2
),
(
u'dev',
u'/static/{prfx}_lock.png?foo=/static/{prfx}_unlock.png',
u'/{asset}@{prfx}_lock.png?foo={encoded_base_url}{encoded_asset}{prfx}_unlock.png',
u'/static/{prfx}_lock.png?foo=/static/{prfx}_ünlöck.png',
u'/{asset}@{prfx}_lock.png?foo={encoded_base_url}{encoded_asset}{prfx}_ünlöck.png',
2
),
(
u'dev',
u'/static/{prfx}_excluded.html?foo=/static/{prfx}_excluded.html',
u'/{asset}@{prfx}_excluded.html?foo={encoded_asset}{prfx}_excluded.html',
u'/{base_asset}@{prfx}_excluded.html?foo={encoded_base_asset}{prfx}_excluded.html',
2
),
(
u'dev',
u'/static/{prfx}_excluded.html?foo=/static/{prfx}_not_excluded.htm',
u'/{asset}@{prfx}_excluded.html?foo={encoded_base_url}{encoded_asset}{prfx}_not_excluded.htm',
u'/{base_asset}@{prfx}_excluded.html?foo={encoded_base_url}{encoded_asset}{prfx}_not_excluded.htm',
2
),
(
u'dev',
u'/static/{prfx}_not_excluded.htm?foo=/static/{prfx}_excluded.html',
u'//dev/{asset}@{prfx}_not_excluded.htm?foo={encoded_asset}{prfx}_excluded.html',
u'//dev/{asset}@{prfx}_not_excluded.htm?foo={encoded_base_asset}{prfx}_excluded.html',
2
),
(
@@ -416,163 +466,189 @@ class CanonicalContentTest(SharedModuleStoreTestCase):
2
),
# Already asset key.
(u'', u'/{asset}@{prfx}_unlock.png', u'/{asset}@{prfx}_unlock.png', 1),
(u'', u'/{asset}@{prfx}_lock.png', u'/{asset}@{prfx}_lock.png', 1),
(u'', u'/{asset}@weird_{prfx}_unlock.png', u'/{asset}@weird_{prfx}_unlock.png', 1),
(u'', u'/{asset}@{prfx}_excluded.html', u'/{asset}@{prfx}_excluded.html', 1),
(u'', u'/{asset}@{prfx}_not_excluded.htm', u'/{asset}@{prfx}_not_excluded.htm', 1),
(u'dev', u'/{asset}@{prfx}_unlock.png', u'//dev/{asset}@{prfx}_unlock.png', 1),
(u'dev', u'/{asset}@{prfx}_lock.png', u'/{asset}@{prfx}_lock.png', 1),
(u'dev', u'/{asset}@weird_{prfx}_unlock.png', u'//dev/{asset}@weird_{prfx}_unlock.png', 1),
(u'dev', u'/{asset}@{prfx}_excluded.html', u'/{asset}@{prfx}_excluded.html', 1),
(u'dev', u'/{asset}@{prfx}_not_excluded.htm', u'//dev/{asset}@{prfx}_not_excluded.htm', 1),
(u'', u'/{base_asset}@{prfx}_ünlöck.png', u'/{asset}@{prfx}_ünlöck.png', 1),
(u'', u'/{base_asset}@{prfx}_lock.png', u'/{asset}@{prfx}_lock.png', 1),
(u'', u'/{base_asset}@weird_{prfx}_ünlöck.png', u'/{asset}@weird_{prfx}_ünlöck.png', 1),
(u'', u'/{base_asset}@{prfx}_excluded.html', u'/{base_asset}@{prfx}_excluded.html', 1),
(u'', u'/{base_asset}@{prfx}_not_excluded.htm', u'/{asset}@{prfx}_not_excluded.htm', 1),
(u'dev', u'/{base_asset}@{prfx}_ünlöck.png', u'//dev/{asset}@{prfx}_ünlöck.png', 1),
(u'dev', u'/{base_asset}@{prfx}_lock.png', u'/{asset}@{prfx}_lock.png', 1),
(u'dev', u'/{base_asset}@weird_{prfx}_ünlöck.png', u'//dev/{asset}@weird_{prfx}_ünlöck.png', 1),
(u'dev', u'/{base_asset}@{prfx}_excluded.html', u'/{base_asset}@{prfx}_excluded.html', 1),
(u'dev', u'/{base_asset}@{prfx}_not_excluded.htm', u'//dev/{asset}@{prfx}_not_excluded.htm', 1),
# Old, c4x-style path.
(u'', u'/{c4x}/{prfx}_unlock.png', u'/{c4x}/{prfx}_unlock.png', 1),
(u'', u'/{c4x}/{prfx}_ünlöck.png', u'/{c4x}/{prfx}_ünlöck.png', 1),
(u'', u'/{c4x}/{prfx}_lock.png', u'/{c4x}/{prfx}_lock.png', 1),
(u'', u'/{c4x}/weird_{prfx}_lock.png', u'/{c4x}/weird_{prfx}_lock.png', 1),
(u'', u'/{c4x}/{prfx}_excluded.html', u'/{c4x}/{prfx}_excluded.html', 1),
(u'', u'/{c4x}/{prfx}_not_excluded.htm', u'/{c4x}/{prfx}_not_excluded.htm', 1),
(u'dev', u'/{c4x}/{prfx}_unlock.png', u'/{c4x}/{prfx}_unlock.png', 1),
(u'dev', u'/{c4x}/{prfx}_ünlöck.png', u'/{c4x}/{prfx}_ünlöck.png', 1),
(u'dev', u'/{c4x}/{prfx}_lock.png', u'/{c4x}/{prfx}_lock.png', 1),
(u'dev', u'/{c4x}/weird_{prfx}_unlock.png', u'/{c4x}/weird_{prfx}_unlock.png', 1),
(u'dev', u'/{c4x}/weird_{prfx}_ünlöck.png', u'/{c4x}/weird_{prfx}_ünlöck.png', 1),
(u'dev', u'/{c4x}/{prfx}_excluded.html', u'/{c4x}/{prfx}_excluded.html', 1),
(u'dev', u'/{c4x}/{prfx}_not_excluded.htm', u'/{c4x}/{prfx}_not_excluded.htm', 1),
# Thumbnails.
(u'', u'/{th_key}@{prfx}_unlock-{th_ext}', u'/{th_key}@{prfx}_unlock-{th_ext}', 1),
(u'', u'/{th_key}@{prfx}_lock-{th_ext}', u'/{th_key}@{prfx}_lock-{th_ext}', 1),
(u'dev', u'/{th_key}@{prfx}_unlock-{th_ext}', u'//dev/{th_key}@{prfx}_unlock-{th_ext}', 1),
(u'dev', u'/{th_key}@{prfx}_lock-{th_ext}', u'//dev/{th_key}@{prfx}_lock-{th_ext}', 1),
(u'', u'/{base_th_key}@{prfx}_ünlöck-{th_ext}', u'/{th_key}@{prfx}_ünlöck-{th_ext}', 1),
(u'', u'/{base_th_key}@{prfx}_lock-{th_ext}', u'/{th_key}@{prfx}_lock-{th_ext}', 1),
(u'dev', u'/{base_th_key}@{prfx}_ünlöck-{th_ext}', u'//dev/{th_key}@{prfx}_ünlöck-{th_ext}', 1),
(u'dev', u'/{base_th_key}@{prfx}_lock-{th_ext}', u'//dev/{th_key}@{prfx}_lock-{th_ext}', 1),
)
@ddt.unpack
def test_canonical_asset_path_with_new_style_assets(self, base_url, start, expected, mongo_calls):
exts = ['.html', '.tm']
prefix = 'split'
encoded_base_url = quote_plus('//' + base_url)
c4x = 'c4x/a/b/asset'
asset_key = 'asset-v1:a+b+{}+type@asset+block'.format(prefix)
encoded_asset_key = quote_plus('/asset-v1:a+b+{}+type@asset+block@'.format(prefix))
th_key = 'asset-v1:a+b+{}+type@thumbnail+block'.format(prefix)
th_ext = 'png-16x16.jpg'
prefix = u'split'
encoded_base_url = urlquote(u'//' + base_url)
c4x = u'c4x/a/b/asset'
base_asset_key = u'asset-v1:a+b+{}+type@asset+block'.format(prefix)
adjusted_asset_key = base_asset_key
encoded_asset_key = urlquote(u'/asset-v1:a+b+{}+type@asset+block@'.format(prefix))
encoded_base_asset_key = encoded_asset_key
base_th_key = u'asset-v1:a+b+{}+type@thumbnail+block'.format(prefix)
adjusted_th_key = base_th_key
th_ext = u'png-16x16.jpg'
start = start.format(
prfx=prefix,
c4x=c4x,
asset=asset_key,
base_asset=base_asset_key,
asset=adjusted_asset_key,
encoded_base_url=encoded_base_url,
encoded_asset=encoded_asset_key,
th_key=th_key,
base_th_key=base_th_key,
th_key=adjusted_th_key,
th_ext=th_ext
)
# Adjust for content digest. This gets dicey quickly and we have to order our steps:
# - replace format markets because they have curly braces
# - encode Unicode characters to percent-encoded
# - finally shove back in our regex patterns
digest = CanonicalContentTest.get_content_digest_for_asset_path(prefix, start)
if digest:
adjusted_asset_key = u'assets/courseware/MARK/asset-v1:a+b+{}+type@asset+block'.format(prefix)
adjusted_th_key = u'assets/courseware/MARK/asset-v1:a+b+{}+type@thumbnail+block'.format(prefix)
encoded_asset_key = u'/assets/courseware/MARK/asset-v1:a+b+{}+type@asset+block@'.format(prefix)
encoded_asset_key = urlquote(encoded_asset_key)
expected = expected.format(
prfx=prefix,
c4x=c4x,
asset=asset_key,
base_asset=base_asset_key,
asset=adjusted_asset_key,
encoded_base_url=encoded_base_url,
encoded_asset=encoded_asset_key,
th_key=th_key,
th_ext=th_ext
base_th_key=base_th_key,
th_key=adjusted_th_key,
th_ext=th_ext,
encoded_base_asset=encoded_base_asset_key,
)
expected = encode_unicode_characters_in_url(expected)
expected = expected.replace('MARK', '[a-f0-9]{32}')
expected = expected.replace('+', r'\+').replace('?', r'\?')
with check_mongo_calls(mongo_calls):
asset_path = StaticContent.get_canonicalized_asset_path(self.courses[prefix].id, start, base_url, exts)
self.assertEqual(asset_path, expected)
print expected
print asset_path
self.assertIsNotNone(re.match(expected, asset_path))
@ddt.data(
# No leading slash.
(u'', u'{prfx}_unlock.png', u'/{c4x}/{prfx}_unlock.png', 1),
(u'', u'{prfx}_ünlöck.png', u'/{c4x}/{prfx}_ünlöck.png', 1),
(u'', u'{prfx}_lock.png', u'/{c4x}/{prfx}_lock.png', 1),
(u'', u'weird {prfx}_unlock.png', u'/{c4x}/weird_{prfx}_unlock.png', 1),
(u'', u'{prfx}_excluded.html', u'/{c4x}/{prfx}_excluded.html', 1),
(u'', u'weird {prfx}_ünlöck.png', u'/{c4x}/weird_{prfx}_ünlöck.png', 1),
(u'', u'{prfx}_excluded.html', u'/{base_c4x}/{prfx}_excluded.html', 1),
(u'', u'{prfx}_not_excluded.htm', u'/{c4x}/{prfx}_not_excluded.htm', 1),
(u'dev', u'{prfx}_unlock.png', u'//dev/{c4x}/{prfx}_unlock.png', 1),
(u'dev', u'{prfx}_ünlöck.png', u'//dev/{c4x}/{prfx}_ünlöck.png', 1),
(u'dev', u'{prfx}_lock.png', u'/{c4x}/{prfx}_lock.png', 1),
(u'dev', u'weird {prfx}_unlock.png', u'//dev/{c4x}/weird_{prfx}_unlock.png', 1),
(u'dev', u'{prfx}_excluded.html', u'/{c4x}/{prfx}_excluded.html', 1),
(u'dev', u'weird {prfx}_ünlöck.png', u'//dev/{c4x}/weird_{prfx}_ünlöck.png', 1),
(u'dev', u'{prfx}_excluded.html', u'/{base_c4x}/{prfx}_excluded.html', 1),
(u'dev', u'{prfx}_not_excluded.htm', u'//dev/{c4x}/{prfx}_not_excluded.htm', 1),
# No leading slash with subdirectory. This ensures we probably substitute slashes.
(u'', u'special/{prfx}_unlock.png', u'/{c4x}/special_{prfx}_unlock.png', 1),
(u'', u'special/{prfx}_ünlöck.png', u'/{c4x}/special_{prfx}_ünlöck.png', 1),
(u'', u'special/{prfx}_lock.png', u'/{c4x}/special_{prfx}_lock.png', 1),
(u'', u'special/weird {prfx}_unlock.png', u'/{c4x}/special_weird_{prfx}_unlock.png', 1),
(u'', u'special/{prfx}_excluded.html', u'/{c4x}/special_{prfx}_excluded.html', 1),
(u'', u'special/weird {prfx}_ünlöck.png', u'/{c4x}/special_weird_{prfx}_ünlöck.png', 1),
(u'', u'special/{prfx}_excluded.html', u'/{base_c4x}/special_{prfx}_excluded.html', 1),
(u'', u'special/{prfx}_not_excluded.htm', u'/{c4x}/special_{prfx}_not_excluded.htm', 1),
(u'dev', u'special/{prfx}_unlock.png', u'//dev/{c4x}/special_{prfx}_unlock.png', 1),
(u'dev', u'special/{prfx}_ünlöck.png', u'//dev/{c4x}/special_{prfx}_ünlöck.png', 1),
(u'dev', u'special/{prfx}_lock.png', u'/{c4x}/special_{prfx}_lock.png', 1),
(u'dev', u'special/weird {prfx}_unlock.png', u'//dev/{c4x}/special_weird_{prfx}_unlock.png', 1),
(u'dev', u'special/{prfx}_excluded.html', u'/{c4x}/special_{prfx}_excluded.html', 1),
(u'dev', u'special/weird {prfx}_ünlöck.png', u'//dev/{c4x}/special_weird_{prfx}_ünlöck.png', 1),
(u'dev', u'special/{prfx}_excluded.html', u'/{base_c4x}/special_{prfx}_excluded.html', 1),
(u'dev', u'special/{prfx}_not_excluded.htm', u'//dev/{c4x}/special_{prfx}_not_excluded.htm', 1),
# Leading slash.
(u'', u'/{prfx}_unlock.png', u'/{c4x}/{prfx}_unlock.png', 1),
(u'', u'/{prfx}_ünlöck.png', u'/{c4x}/{prfx}_ünlöck.png', 1),
(u'', u'/{prfx}_lock.png', u'/{c4x}/{prfx}_lock.png', 1),
(u'', u'/weird {prfx}_unlock.png', u'/{c4x}/weird_{prfx}_unlock.png', 1),
(u'', u'/{prfx}_excluded.html', u'/{c4x}/{prfx}_excluded.html', 1),
(u'', u'/weird {prfx}_ünlöck.png', u'/{c4x}/weird_{prfx}_ünlöck.png', 1),
(u'', u'/{prfx}_excluded.html', u'/{base_c4x}/{prfx}_excluded.html', 1),
(u'', u'/{prfx}_not_excluded.htm', u'/{c4x}/{prfx}_not_excluded.htm', 1),
(u'dev', u'/{prfx}_unlock.png', u'//dev/{c4x}/{prfx}_unlock.png', 1),
(u'dev', u'/{prfx}_ünlöck.png', u'//dev/{c4x}/{prfx}_ünlöck.png', 1),
(u'dev', u'/{prfx}_lock.png', u'/{c4x}/{prfx}_lock.png', 1),
(u'dev', u'/weird {prfx}_unlock.png', u'//dev/{c4x}/weird_{prfx}_unlock.png', 1),
(u'dev', u'/{prfx}_excluded.html', u'/{c4x}/{prfx}_excluded.html', 1),
(u'dev', u'/weird {prfx}_ünlöck.png', u'//dev/{c4x}/weird_{prfx}_ünlöck.png', 1),
(u'dev', u'/{prfx}_excluded.html', u'/{base_c4x}/{prfx}_excluded.html', 1),
(u'dev', u'/{prfx}_not_excluded.htm', u'//dev/{c4x}/{prfx}_not_excluded.htm', 1),
# Leading slash with subdirectory. This ensures we properly substitute slashes.
(u'', u'/special/{prfx}_unlock.png', u'/{c4x}/special_{prfx}_unlock.png', 1),
(u'', u'/special/{prfx}_ünlöck.png', u'/{c4x}/special_{prfx}_ünlöck.png', 1),
(u'', u'/special/{prfx}_lock.png', u'/{c4x}/special_{prfx}_lock.png', 1),
(u'', u'/special/weird {prfx}_unlock.png', u'/{c4x}/special_weird_{prfx}_unlock.png', 1),
(u'', u'/special/{prfx}_excluded.html', u'/{c4x}/special_{prfx}_excluded.html', 1),
(u'', u'/special/weird {prfx}_ünlöck.png', u'/{c4x}/special_weird_{prfx}_ünlöck.png', 1),
(u'', u'/special/{prfx}_excluded.html', u'/{base_c4x}/special_{prfx}_excluded.html', 1),
(u'', u'/special/{prfx}_not_excluded.htm', u'/{c4x}/special_{prfx}_not_excluded.htm', 1),
(u'dev', u'/special/{prfx}_unlock.png', u'//dev/{c4x}/special_{prfx}_unlock.png', 1),
(u'dev', u'/special/{prfx}_ünlöck.png', u'//dev/{c4x}/special_{prfx}_ünlöck.png', 1),
(u'dev', u'/special/{prfx}_lock.png', u'/{c4x}/special_{prfx}_lock.png', 1),
(u'dev', u'/special/weird {prfx}_unlock.png', u'//dev/{c4x}/special_weird_{prfx}_unlock.png', 1),
(u'dev', u'/special/{prfx}_excluded.html', u'/{c4x}/special_{prfx}_excluded.html', 1),
(u'dev', u'/special/weird {prfx}_ünlöck.png', u'//dev/{c4x}/special_weird_{prfx}_ünlöck.png', 1),
(u'dev', u'/special/{prfx}_excluded.html', u'/{base_c4x}/special_{prfx}_excluded.html', 1),
(u'dev', u'/special/{prfx}_not_excluded.htm', u'//dev/{c4x}/special_{prfx}_not_excluded.htm', 1),
# Static path.
(u'', u'/static/{prfx}_unlock.png', u'/{c4x}/{prfx}_unlock.png', 1),
(u'', u'/static/{prfx}_ünlöck.png', u'/{c4x}/{prfx}_ünlöck.png', 1),
(u'', u'/static/{prfx}_lock.png', u'/{c4x}/{prfx}_lock.png', 1),
(u'', u'/static/weird {prfx}_unlock.png', u'/{c4x}/weird_{prfx}_unlock.png', 1),
(u'', u'/static/{prfx}_excluded.html', u'/{c4x}/{prfx}_excluded.html', 1),
(u'', u'/static/weird {prfx}_ünlöck.png', u'/{c4x}/weird_{prfx}_ünlöck.png', 1),
(u'', u'/static/{prfx}_excluded.html', u'/{base_c4x}/{prfx}_excluded.html', 1),
(u'', u'/static/{prfx}_not_excluded.htm', u'/{c4x}/{prfx}_not_excluded.htm', 1),
(u'dev', u'/static/{prfx}_unlock.png', u'//dev/{c4x}/{prfx}_unlock.png', 1),
(u'dev', u'/static/{prfx}_ünlöck.png', u'//dev/{c4x}/{prfx}_ünlöck.png', 1),
(u'dev', u'/static/{prfx}_lock.png', u'/{c4x}/{prfx}_lock.png', 1),
(u'dev', u'/static/weird {prfx}_unlock.png', u'//dev/{c4x}/weird_{prfx}_unlock.png', 1),
(u'dev', u'/static/{prfx}_excluded.html', u'/{c4x}/{prfx}_excluded.html', 1),
(u'dev', u'/static/weird {prfx}_ünlöck.png', u'//dev/{c4x}/weird_{prfx}_ünlöck.png', 1),
(u'dev', u'/static/{prfx}_excluded.html', u'/{base_c4x}/{prfx}_excluded.html', 1),
(u'dev', u'/static/{prfx}_not_excluded.htm', u'//dev/{c4x}/{prfx}_not_excluded.htm', 1),
# Static path with subdirectory. This ensures we properly substitute slashes.
(u'', u'/static/special/{prfx}_unlock.png', u'/{c4x}/special_{prfx}_unlock.png', 1),
(u'', u'/static/special/{prfx}_ünlöck.png', u'/{c4x}/special_{prfx}_ünlöck.png', 1),
(u'', u'/static/special/{prfx}_lock.png', u'/{c4x}/special_{prfx}_lock.png', 1),
(u'', u'/static/special/weird {prfx}_unlock.png', u'/{c4x}/special_weird_{prfx}_unlock.png', 1),
(u'', u'/static/special/{prfx}_excluded.html', u'/{c4x}/special_{prfx}_excluded.html', 1),
(u'', u'/static/special/weird {prfx}_ünlöck.png', u'/{c4x}/special_weird_{prfx}_ünlöck.png', 1),
(u'', u'/static/special/{prfx}_excluded.html', u'/{base_c4x}/special_{prfx}_excluded.html', 1),
(u'', u'/static/special/{prfx}_not_excluded.htm', u'/{c4x}/special_{prfx}_not_excluded.htm', 1),
(u'dev', u'/static/special/{prfx}_unlock.png', u'//dev/{c4x}/special_{prfx}_unlock.png', 1),
(u'dev', u'/static/special/{prfx}_ünlöck.png', u'//dev/{c4x}/special_{prfx}_ünlöck.png', 1),
(u'dev', u'/static/special/{prfx}_lock.png', u'/{c4x}/special_{prfx}_lock.png', 1),
(u'dev', u'/static/special/weird {prfx}_unlock.png', u'//dev/{c4x}/special_weird_{prfx}_unlock.png', 1),
(u'dev', u'/static/special/{prfx}_excluded.html', u'/{c4x}/special_{prfx}_excluded.html', 1),
(u'dev', u'/static/special/weird {prfx}_ünlöck.png', u'//dev/{c4x}/special_weird_{prfx}_ünlöck.png', 1),
(u'dev', u'/static/special/{prfx}_excluded.html', u'/{base_c4x}/special_{prfx}_excluded.html', 1),
(u'dev', u'/static/special/{prfx}_not_excluded.htm', u'//dev/{c4x}/special_{prfx}_not_excluded.htm', 1),
# Static path with query parameter.
(
u'',
u'/static/{prfx}_unlock.png?foo=/static/{prfx}_lock.png',
u'/{c4x}/{prfx}_unlock.png?foo={encoded_c4x}{prfx}_lock.png',
u'/static/{prfx}_ünlöck.png?foo=/static/{prfx}_lock.png',
u'/{c4x}/{prfx}_ünlöck.png?foo={encoded_c4x}{prfx}_lock.png',
2
),
(
u'',
u'/static/{prfx}_lock.png?foo=/static/{prfx}_unlock.png',
u'/{c4x}/{prfx}_lock.png?foo={encoded_c4x}{prfx}_unlock.png',
u'/static/{prfx}_lock.png?foo=/static/{prfx}_ünlöck.png',
u'/{c4x}/{prfx}_lock.png?foo={encoded_c4x}{prfx}_ünlöck.png',
2
),
(
u'',
u'/static/{prfx}_excluded.html?foo=/static/{prfx}_excluded.html',
u'/{c4x}/{prfx}_excluded.html?foo={encoded_c4x}{prfx}_excluded.html',
u'/{base_c4x}/{prfx}_excluded.html?foo={encoded_base_c4x}{prfx}_excluded.html',
2
),
(
u'',
u'/static/{prfx}_excluded.html?foo=/static/{prfx}_not_excluded.htm',
u'/{c4x}/{prfx}_excluded.html?foo={encoded_c4x}{prfx}_not_excluded.htm',
u'/{base_c4x}/{prfx}_excluded.html?foo={encoded_c4x}{prfx}_not_excluded.htm',
2
),
(
u'',
u'/static/{prfx}_not_excluded.htm?foo=/static/{prfx}_excluded.html',
u'/{c4x}/{prfx}_not_excluded.htm?foo={encoded_c4x}{prfx}_excluded.html',
u'/{c4x}/{prfx}_not_excluded.htm?foo={encoded_base_c4x}{prfx}_excluded.html',
2
),
(
@@ -583,32 +659,32 @@ class CanonicalContentTest(SharedModuleStoreTestCase):
),
(
u'dev',
u'/static/{prfx}_unlock.png?foo=/static/{prfx}_lock.png',
u'//dev/{c4x}/{prfx}_unlock.png?foo={encoded_c4x}{prfx}_lock.png',
u'/static/{prfx}_ünlöck.png?foo=/static/{prfx}_lock.png',
u'//dev/{c4x}/{prfx}_ünlöck.png?foo={encoded_c4x}{prfx}_lock.png',
2
),
(
u'dev',
u'/static/{prfx}_lock.png?foo=/static/{prfx}_unlock.png',
u'/{c4x}/{prfx}_lock.png?foo={encoded_base_url}{encoded_c4x}{prfx}_unlock.png',
u'/static/{prfx}_lock.png?foo=/static/{prfx}_ünlöck.png',
u'/{c4x}/{prfx}_lock.png?foo={encoded_base_url}{encoded_c4x}{prfx}_ünlöck.png',
2
),
(
u'dev',
u'/static/{prfx}_excluded.html?foo=/static/{prfx}_excluded.html',
u'/{c4x}/{prfx}_excluded.html?foo={encoded_c4x}{prfx}_excluded.html',
u'/{base_c4x}/{prfx}_excluded.html?foo={encoded_base_c4x}{prfx}_excluded.html',
2
),
(
u'dev',
u'/static/{prfx}_excluded.html?foo=/static/{prfx}_not_excluded.htm',
u'/{c4x}/{prfx}_excluded.html?foo={encoded_base_url}{encoded_c4x}{prfx}_not_excluded.htm',
u'/{base_c4x}/{prfx}_excluded.html?foo={encoded_base_url}{encoded_c4x}{prfx}_not_excluded.htm',
2
),
(
u'dev',
u'/static/{prfx}_not_excluded.htm?foo=/static/{prfx}_excluded.html',
u'//dev/{c4x}/{prfx}_not_excluded.htm?foo={encoded_c4x}{prfx}_excluded.html',
u'//dev/{c4x}/{prfx}_not_excluded.htm?foo={encoded_base_c4x}{prfx}_excluded.html',
2
),
(
@@ -618,38 +694,58 @@ class CanonicalContentTest(SharedModuleStoreTestCase):
2
),
# Old, c4x-style path.
(u'', u'/{c4x}/{prfx}_unlock.png', u'/{c4x}/{prfx}_unlock.png', 1),
(u'', u'/{c4x}/{prfx}_ünlöck.png', u'/{c4x}/{prfx}_ünlöck.png', 1),
(u'', u'/{c4x}/{prfx}_lock.png', u'/{c4x}/{prfx}_lock.png', 1),
(u'', u'/{c4x}/weird_{prfx}_lock.png', u'/{c4x}/weird_{prfx}_lock.png', 1),
(u'', u'/{c4x}/{prfx}_excluded.html', u'/{c4x}/{prfx}_excluded.html', 1),
(u'', u'/{c4x}/{prfx}_excluded.html', u'/{base_c4x}/{prfx}_excluded.html', 1),
(u'', u'/{c4x}/{prfx}_not_excluded.htm', u'/{c4x}/{prfx}_not_excluded.htm', 1),
(u'dev', u'/{c4x}/{prfx}_unlock.png', u'//dev/{c4x}/{prfx}_unlock.png', 1),
(u'dev', u'/{c4x}/{prfx}_ünlöck.png', u'//dev/{c4x}/{prfx}_ünlöck.png', 1),
(u'dev', u'/{c4x}/{prfx}_lock.png', u'/{c4x}/{prfx}_lock.png', 1),
(u'dev', u'/{c4x}/weird_{prfx}_unlock.png', u'//dev/{c4x}/weird_{prfx}_unlock.png', 1),
(u'dev', u'/{c4x}/{prfx}_excluded.html', u'/{c4x}/{prfx}_excluded.html', 1),
(u'dev', u'/{c4x}/weird_{prfx}_ünlöck.png', u'//dev/{c4x}/weird_{prfx}_ünlöck.png', 1),
(u'dev', u'/{c4x}/{prfx}_excluded.html', u'/{base_c4x}/{prfx}_excluded.html', 1),
(u'dev', u'/{c4x}/{prfx}_not_excluded.htm', u'//dev/{c4x}/{prfx}_not_excluded.htm', 1),
)
@ddt.unpack
def test_canonical_asset_path_with_c4x_style_assets(self, base_url, start, expected, mongo_calls):
exts = ['.html', '.tm']
prefix = 'old'
c4x_block = 'c4x/a/b/asset'
encoded_c4x_block = quote_plus('/' + c4x_block + '/')
encoded_base_url = quote_plus('//' + base_url)
base_c4x_block = 'c4x/a/b/asset'
adjusted_c4x_block = base_c4x_block
encoded_c4x_block = urlquote('/' + base_c4x_block + '/')
encoded_base_url = urlquote('//' + base_url)
encoded_base_c4x_block = encoded_c4x_block
start = start.format(
prfx=prefix,
encoded_base_url=encoded_base_url,
c4x=c4x_block,
encoded_c4x=encoded_c4x_block
)
expected = expected.format(
prfx=prefix,
encoded_base_url=encoded_base_url,
c4x=c4x_block,
c4x=base_c4x_block,
encoded_c4x=encoded_c4x_block
)
# Adjust for content digest. This gets dicey quickly and we have to order our steps:
# - replace format markets because they have curly braces
# - encode Unicode characters to percent-encoded
# - finally shove back in our regex patterns
digest = CanonicalContentTest.get_content_digest_for_asset_path(prefix, start)
if digest:
adjusted_c4x_block = 'assets/courseware/MARK/c4x/a/b/asset'
encoded_c4x_block = urlquote('/' + adjusted_c4x_block + '/')
expected = expected.format(
prfx=prefix,
encoded_base_url=encoded_base_url,
base_c4x=base_c4x_block,
c4x=adjusted_c4x_block,
encoded_c4x=encoded_c4x_block,
encoded_base_c4x=encoded_base_c4x_block,
)
expected = encode_unicode_characters_in_url(expected)
expected = expected.replace('MARK', '[a-f0-9]{32}')
expected = expected.replace('+', r'\+').replace('?', r'\?')
with check_mongo_calls(mongo_calls):
asset_path = StaticContent.get_canonicalized_asset_path(self.courses[prefix].id, start, base_url, exts)
self.assertEqual(asset_path, expected)
print expected
print asset_path
self.assertIsNotNone(re.match(expected, asset_path))

View File

@@ -58,7 +58,7 @@ class PasswordResetFormNoActive(PasswordResetForm):
email_template_name='registration/password_reset_email.html',
use_https=False,
token_generator=default_token_generator,
from_email=theming_helpers.get_value('default_from_email', settings.DEFAULT_FROM_EMAIL),
from_email=theming_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL),
request=None
):
"""

View File

@@ -57,7 +57,7 @@ class EmailTestMixin(object):
email_user.assert_called_with(
mock_render_to_string(subject_template, subject_context),
mock_render_to_string(body_template, body_context),
theming_helpers.get_value('default_from_email', settings.DEFAULT_FROM_EMAIL)
theming_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
)
def append_allowed_hosts(self, hostname):
@@ -298,7 +298,7 @@ class EmailChangeRequestTests(EventTestMixin, TestCase):
send_mail.assert_called_with(
mock_render_to_string('emails/email_change_subject.txt', context),
mock_render_to_string('emails/email_change.txt', context),
theming_helpers.get_value('default_from_email', settings.DEFAULT_FROM_EMAIL),
theming_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL),
[new_email]
)
self.assert_event_emitted(

View File

@@ -125,7 +125,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
(subject, msg, from_addr, to_addrs) = send_email.call_args[0]
self.assertIn("Password reset", subject)
self.assertIn("You're receiving this e-mail because you requested a password reset", msg)
self.assertEquals(from_addr, theming_helpers.get_value('default_from_email', settings.DEFAULT_FROM_EMAIL))
self.assertEquals(from_addr, theming_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL))
self.assertEquals(len(to_addrs), 1)
self.assertIn(self.user.email, to_addrs)

View File

@@ -2229,11 +2229,11 @@ def reactivation_email_for_user(user):
message = render_to_string('emails/activation_email.txt', context)
try:
user.email_user(subject, message, theming_helpers.get_value('default_from_email', settings.DEFAULT_FROM_EMAIL))
user.email_user(subject, message, theming_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL))
except Exception: # pylint: disable=broad-except
log.error(
u'Unable to send reactivation email from "%s"',
theming_helpers.get_value('default_from_email', settings.DEFAULT_FROM_EMAIL),
theming_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL),
exc_info=True
)
return JsonResponse({
@@ -2357,7 +2357,7 @@ def confirm_email_change(request, key): # pylint: disable=unused-argument
user.email_user(
subject,
message,
theming_helpers.get_value('default_from_email', settings.DEFAULT_FROM_EMAIL)
theming_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
)
except Exception: # pylint: disable=broad-except
log.warning('Unable to send confirmation email to old address', exc_info=True)
@@ -2373,7 +2373,7 @@ def confirm_email_change(request, key): # pylint: disable=unused-argument
user.email_user(
subject,
message,
theming_helpers.get_value('default_from_email', settings.DEFAULT_FROM_EMAIL)
theming_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
)
except Exception: # pylint: disable=broad-except
log.warning('Unable to send confirmation email to new address', exc_info=True)

View File

@@ -0,0 +1,80 @@
"""
Simple utility functions that operate on block metadata.
This is a place to put simple functions that operate on block metadata. It
allows us to share code between the XModuleMixin and CourseOverview and
BlockStructure.
"""
def url_name_for_block(block):
"""
Given a block, returns the block's URL name.
Arguments:
block (XModuleMixin|CourseOverview|BlockStructureBlockData):
Block that is being accessed
"""
return block.location.name
def display_name_with_default(block):
"""
Calculates the display name for a block.
Default to the display_name if it isn't None, else fall back to creating
a name based on the URL.
Unlike the rest of this module's functions, this function takes an entire
course descriptor/overview as a parameter. This is because a few test cases
(specifically, {Text|Image|Video}AnnotationModuleTestCase.test_student_view)
create scenarios where course.display_name is not None but course.location
is None, which causes calling course.url_name to fail. So, although we'd
like to just pass course.display_name and course.url_name as arguments to
this function, we can't do so without breaking those tests.
Note: This method no longer escapes as it once did, so the caller must
ensure it is properly escaped where necessary.
Arguments:
block (XModuleMixin|CourseOverview|BlockStructureBlockData):
Block that is being accessed
"""
return (
block.display_name if block.display_name is not None
else url_name_for_block(block).replace('_', ' ')
)
def display_name_with_default_escaped(block):
"""
DEPRECATED: use display_name_with_default
Calculates the display name for a block with some HTML escaping.
This follows the same logic as display_name_with_default, with
the addition of the escaping.
Here is an example of how to move away from this method in Mako html:
Before:
<span class="course-name">${course.display_name_with_default_escaped}</span>
After:
<span class="course-name">${course.display_name_with_default | h}</span>
If the context is Javascript in Mako, you'll need to follow other best practices.
Note: Switch to display_name_with_default, and ensure the caller
properly escapes where necessary.
Note: This newly introduced method should not be used. It was only
introduced to enable a quick search/replace and the ability to slowly
migrate and test switching to display_name_with_default, which is no
longer escaped.
Arguments:
block (XModuleMixin|CourseOverview|BlockStructureBlockData):
Block that is being accessed
"""
# This escaping is incomplete. However, rather than switching this to use
# markupsafe.escape() and fixing issues, better to put that energy toward
# migrating away from this method altogether.
return display_name_with_default(block).replace('<', '&lt;').replace('>', '&gt;')

View File

@@ -5,16 +5,16 @@ from xmodule.assetstore.assetmgr import AssetManager
XASSET_LOCATION_TAG = 'c4x'
XASSET_SRCREF_PREFIX = 'xasset:'
XASSET_THUMBNAIL_TAIL_NAME = '.jpg'
STREAM_DATA_CHUNK_SIZE = 1024
VERSIONED_ASSETS_PREFIX = '/assets/courseware'
VERSIONED_ASSETS_PATTERN = r'/assets/courseware/([a-f0-9]{32})'
import os
import logging
import StringIO
from urlparse import urlparse, urlunparse, parse_qsl
from urllib import urlencode
from urllib import urlencode, quote_plus
from opaque_keys.edx.locator import AssetLocator
from opaque_keys.edx.keys import CourseKey, AssetKey
@@ -26,7 +26,7 @@ from PIL import Image
class StaticContent(object):
def __init__(self, loc, name, content_type, data, last_modified_at=None, thumbnail_location=None, import_path=None,
length=None, locked=False):
length=None, locked=False, content_digest=None):
self.location = loc
self.name = name # a display string which can be edited, and thus not part of the location which needs to be fixed
self.content_type = content_type
@@ -38,6 +38,7 @@ class StaticContent(object):
# cycles
self.import_path = import_path
self.locked = locked
self.content_digest = content_digest
@property
def is_thumbnail(self):
@@ -145,6 +146,40 @@ class StaticContent(object):
# try stripping off the leading slash and try again
return AssetKey.from_string(path[1:])
@staticmethod
def is_versioned_asset_path(path):
"""Determines whether the given asset path is versioned."""
return path.startswith(VERSIONED_ASSETS_PREFIX)
@staticmethod
def parse_versioned_asset_path(path):
"""
Examines an asset path and breaks it apart if it is versioned,
returning both the asset digest and the unversioned asset path,
which will normally be an AssetKey.
"""
asset_digest = None
asset_path = path
if StaticContent.is_versioned_asset_path(asset_path):
result = re.match(VERSIONED_ASSETS_PATTERN, asset_path)
if result is not None:
asset_digest = result.groups()[0]
asset_path = re.sub(VERSIONED_ASSETS_PATTERN, '', asset_path)
return (asset_digest, asset_path)
@staticmethod
def add_version_to_asset_path(path, version):
"""
Adds a prefix to an asset path indicating the asset's version.
"""
# Don't version an already-versioned path.
if StaticContent.is_versioned_asset_path(path):
return path
return VERSIONED_ASSETS_PREFIX + '/' + version + path
@staticmethod
def get_asset_key_from_path(course_key, path):
"""
@@ -172,7 +207,16 @@ class StaticContent(object):
return StaticContent.compute_location(course_key, path)
@staticmethod
def get_canonicalized_asset_path(course_key, path, base_url, excluded_exts):
def is_excluded_asset_type(path, excluded_exts):
"""
Check if this is an allowed file extension to serve.
Some files aren't served through the CDN in order to avoid same-origin policy/CORS-related issues.
"""
return any(path.lower().endswith(excluded_ext.lower()) for excluded_ext in excluded_exts)
@staticmethod
def get_canonicalized_asset_path(course_key, path, base_url, excluded_exts, encode=True):
"""
Returns a fully-qualified path to a piece of static content.
@@ -188,25 +232,27 @@ class StaticContent(object):
"""
# Break down the input path.
_, _, relative_path, params, query_string, fragment = urlparse(path)
_, _, relative_path, params, query_string, _ = urlparse(path)
# Convert our path to an asset key if it isn't one already.
asset_key = StaticContent.get_asset_key_from_path(course_key, relative_path)
# Check the status of the asset to see if this can be served via CDN aka publicly.
serve_from_cdn = False
content_digest = None
try:
content = AssetManager.find(asset_key, as_stream=True)
is_locked = getattr(content, "locked", True)
serve_from_cdn = not is_locked
serve_from_cdn = not getattr(content, "locked", True)
content_digest = getattr(content, "content_digest", None)
except (ItemNotFoundError, NotFoundError):
# If we can't find the item, just treat it as if it's locked.
serve_from_cdn = False
# See if this is an allowed file extension to serve. Some files aren't served through the
# CDN in order to avoid same-origin policy/CORS-related issues.
if any(relative_path.lower().endswith(excluded_ext.lower()) for excluded_ext in excluded_exts):
# Do a generic check to see if anything about this asset disqualifies it from being CDN'd.
is_excluded = False
if StaticContent.is_excluded_asset_type(relative_path, excluded_exts):
serve_from_cdn = False
is_excluded = True
# Update any query parameter values that have asset paths in them. This is for assets that
# require their own after-the-fact values, like a Flash file that needs the path of a config
@@ -215,15 +261,29 @@ class StaticContent(object):
updated_query_params = []
for query_name, query_val in query_params:
if query_val.startswith("/static/"):
new_val = StaticContent.get_canonicalized_asset_path(course_key, query_val, base_url, excluded_exts)
new_val = StaticContent.get_canonicalized_asset_path(
course_key, query_val, base_url, excluded_exts, encode=False)
updated_query_params.append((query_name, new_val))
else:
updated_query_params.append((query_name, query_val))
# Make sure we're encoding Unicode strings down to their byte string
# representation so that `urlencode` can handle it.
updated_query_params.append((query_name, query_val.encode('utf-8')))
serialized_asset_key = StaticContent.serialize_asset_key_with_slash(asset_key)
base_url = base_url if serve_from_cdn else ''
asset_path = serialized_asset_key
return urlunparse((None, base_url, serialized_asset_key, params, urlencode(updated_query_params), fragment))
# If the content has a digest (i.e. md5sum) value specified, create a versioned path to the asset using it.
if not is_excluded and content_digest:
asset_path = StaticContent.add_version_to_asset_path(serialized_asset_key, content_digest)
# Only encode this if told to. Important so that we don't double encode
# when working with paths that are in query parameters.
asset_path = asset_path.encode('utf-8')
if encode:
asset_path = quote_plus(asset_path, '/:+@')
return urlunparse((None, base_url.encode('utf-8'), asset_path, params, urlencode(updated_query_params), None))
def stream_data(self):
yield self._data
@@ -242,10 +302,10 @@ class StaticContent(object):
class StaticContentStream(StaticContent):
def __init__(self, loc, name, content_type, stream, last_modified_at=None, thumbnail_location=None, import_path=None,
length=None, locked=False):
length=None, locked=False, content_digest=None):
super(StaticContentStream, self).__init__(loc, name, content_type, None, last_modified_at=last_modified_at,
thumbnail_location=thumbnail_location, import_path=import_path,
length=length, locked=locked)
length=length, locked=locked, content_digest=content_digest)
self._stream = stream
def stream_data(self):
@@ -277,7 +337,8 @@ class StaticContentStream(StaticContent):
self._stream.seek(0)
content = StaticContent(self.location, self.name, self.content_type, self._stream.read(),
last_modified_at=self.last_modified_at, thumbnail_location=self.thumbnail_location,
import_path=self.import_path, length=self.length, locked=self.locked)
import_path=self.import_path, length=self.length, locked=self.locked,
content_digest=self.content_digest)
return content

View File

@@ -128,7 +128,8 @@ class MongoContentStore(ContentStore):
location, fp.displayname, fp.content_type, fp, last_modified_at=fp.uploadDate,
thumbnail_location=thumbnail_location,
import_path=getattr(fp, 'import_path', None),
length=fp.length, locked=getattr(fp, 'locked', False)
length=fp.length, locked=getattr(fp, 'locked', False),
content_digest=getattr(fp, 'md5', None),
)
else:
with self.fs.get(content_id) as fp:
@@ -142,7 +143,8 @@ class MongoContentStore(ContentStore):
location, fp.displayname, fp.content_type, fp.read(), last_modified_at=fp.uploadDate,
thumbnail_location=thumbnail_location,
import_path=getattr(fp, 'import_path', None),
length=fp.length, locked=getattr(fp, 'locked', False)
length=fp.length, locked=getattr(fp, 'locked', False),
content_digest=getattr(fp, 'md5', None),
)
except NoFile:
if throw_on_not_found:

View File

@@ -32,78 +32,6 @@ def clean_course_key(course_key, padding_char):
)
def url_name_for_course_location(location):
"""
Given a course's usage locator, returns the course's URL name.
Arguments:
location (BlockUsageLocator): The course's usage locator.
"""
return location.name
def display_name_with_default(course):
"""
Calculates the display name for a course.
Default to the display_name if it isn't None, else fall back to creating
a name based on the URL.
Unlike the rest of this module's functions, this function takes an entire
course descriptor/overview as a parameter. This is because a few test cases
(specifically, {Text|Image|Video}AnnotationModuleTestCase.test_student_view)
create scenarios where course.display_name is not None but course.location
is None, which causes calling course.url_name to fail. So, although we'd
like to just pass course.display_name and course.url_name as arguments to
this function, we can't do so without breaking those tests.
Note: This method no longer escapes as it once did, so the caller must
ensure it is properly escaped where necessary.
Arguments:
course (CourseDescriptor|CourseOverview): descriptor or overview of
said course.
"""
return (
course.display_name if course.display_name is not None
else course.url_name.replace('_', ' ')
)
def display_name_with_default_escaped(course):
"""
DEPRECATED: use display_name_with_default
Calculates the display name for a course with some HTML escaping.
This follows the same logic as display_name_with_default, with
the addition of the escaping.
Here is an example of how to move away from this method in Mako html:
Before:
<span class="course-name">${course.display_name_with_default_escaped}</span>
After:
<span class="course-name">${course.display_name_with_default | h}</span>
If the context is Javascript in Mako, you'll need to follow other best practices.
Note: Switch to display_name_with_default, and ensure the caller
properly escapes where necessary.
Note: This newly introduced method should not be used. It was only
introduced to enable a quick search/replace and the ability to slowly
migrate and test switching to display_name_with_default, which is no
longer escaped.
Arguments:
course (CourseDescriptor|CourseOverview): descriptor or overview of
said course.
"""
# This escaping is incomplete. However, rather than switching this to use
# markupsafe.escape() and fixing issues, better to put that energy toward
# migrating away from this method altogether.
return course.display_name_with_default.replace('<', '&lt;').replace('>', '&gt;')
def number_for_course_location(location):
"""
Given a course's block usage locator, returns the course's number.

View File

@@ -11,12 +11,10 @@ from django.utils.timezone import UTC
from lazy import lazy
from lxml import etree
from path import Path as path
from xblock.core import XBlock
from xblock.fields import Scope, List, String, Dict, Boolean, Integer, Float
from xmodule import course_metadata_utils
from xmodule.course_metadata_utils import DEFAULT_START_DATE
from xmodule.exceptions import UndefinedContext
from xmodule.graders import grader_from_conf
from xmodule.mixin import LicenseMixin
from xmodule.seq_module import SequenceDescriptor, SequenceModule
@@ -1183,83 +1181,6 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin):
"""
return course_metadata_utils.sorting_score(self.start, self.advertised_start, self.announcement)
@lazy
def grading_context(self):
"""
This returns a dictionary with keys necessary for quickly grading
a student. They are used by grades.grade()
The grading context has two keys:
graded_sections - This contains the sections that are graded, as
well as all possible children modules that can affect the
grading. This allows some sections to be skipped if the student
hasn't seen any part of it.
The format is a dictionary keyed by section-type. The values are
arrays of dictionaries containing
"section_descriptor" : The section descriptor
"xmoduledescriptors" : An array of xmoduledescriptors that
could possibly be in the section, for any student
all_descriptors - This contains a list of all xmodules that can
effect grading a student. This is used to efficiently fetch
all the xmodule state for a FieldDataCache without walking
the descriptor tree again.
"""
# If this descriptor has been bound to a student, return the corresponding
# XModule. If not, just use the descriptor itself
try:
module = getattr(self, '_xmodule', None)
if not module:
module = self
except UndefinedContext:
module = self
def possibly_scored(usage_key):
"""Can this XBlock type can have a score or children?"""
return usage_key.block_type in self.block_types_affecting_grading
all_descriptors = []
graded_sections = {}
def yield_descriptor_descendents(module_descriptor):
for child in module_descriptor.get_children(usage_key_filter=possibly_scored):
yield child
for module_descriptor in yield_descriptor_descendents(child):
yield module_descriptor
for chapter in self.get_children():
for section in chapter.get_children():
if section.graded:
xmoduledescriptors = list(yield_descriptor_descendents(section))
xmoduledescriptors.append(section)
# The xmoduledescriptors included here are only the ones that have scores.
section_description = {
'section_descriptor': section,
'xmoduledescriptors': [child for child in xmoduledescriptors if child.has_score]
}
section_format = section.format if section.format is not None else ''
graded_sections[section_format] = graded_sections.get(section_format, []) + [section_description]
all_descriptors.extend(xmoduledescriptors)
all_descriptors.append(section)
return {'graded_sections': graded_sections,
'all_descriptors': all_descriptors, }
@lazy
def block_types_affecting_grading(self):
"""Return all block types that could impact grading (i.e. scored, or having children)."""
return frozenset(
cat for (cat, xblock_class) in XBlock.load_classes() if (
getattr(xblock_class, 'has_score', False) or getattr(xblock_class, 'has_children', False)
)
)
@staticmethod
def make_id(org, course, url_name):
return '/'.join([org, course, url_name])

View File

@@ -535,8 +535,6 @@
.speed-option,
.control-lang {
@include border-left($baseline/10 solid rgb(14, 166, 236));
font-weight: $font-bold;
color: rgb(14, 166, 236); // UXPL primary accent
}
}

View File

@@ -173,7 +173,7 @@ class WeightedSubsectionsGrader(CourseGrader):
All items in section_breakdown for each subgrader will be combined. A grade_breakdown will be
composed using the score from each grader.
Note that the sum of the weights is not take into consideration. If the weights add up to
Note that the sum of the weights is not taken into consideration. If the weights add up to
a value > 1, the student may end up with a percent > 100%. This allows for sections that
are extra credit.
"""

View File

@@ -55,9 +55,8 @@
</div>
</section>
</article>
<div class="subtitles">
<ol class="subtitles-menu"><li></li></ol>
</div>
<ol class="subtitles"><li></li></ol>
</div>
</div>
</div>
@@ -109,9 +108,7 @@
</section>
</article>
<div class="subtitles">
<ol class="subtitles-menu"><li></li></ol>
</div>
<ol class="subtitles"><li></li></ol>
</div>
</div>
</div>

View File

@@ -20,10 +20,12 @@ var options = {
libraryFilesToInclude: [
{pattern: 'common_static/js/vendor/requirejs/require.js', included: true},
{pattern: 'RequireJS-namespace-undefine.js', included: true},
{pattern: 'spec/main_requirejs.js', included: true},
{pattern: 'common_static/coffee/src/ajax_prefix.js', included: true},
{pattern: 'common_static/common/js/vendor/underscore.js', included: true},
{pattern: 'common_static/common/js/vendor/backbone.js', included: true},
{pattern: 'common_static/edx-ui-toolkit/js/utils/global-loader.js', included: true},
{pattern: 'common_static/js/vendor/CodeMirror/codemirror.js', included: true},
{pattern: 'common_static/js/vendor/draggabilly.js'},
{pattern: 'common_static/common/js/vendor/jquery.js', included: true},
@@ -48,14 +50,11 @@ var options = {
{pattern: 'common_static/js/vendor/jasmine-imagediff.js', included: true},
{pattern: 'common_static/common/js/spec_helpers/jasmine-waituntil.js', included: true},
{pattern: 'common_static/common/js/spec_helpers/jasmine-extensions.js', included: true},
{pattern: 'common_static/js/vendor/sinon-1.17.0.js', included: true},
{pattern: 'spec/main_requirejs.js', included: true},
{pattern: 'common_static/js/vendor/sinon-1.17.0.js', included: true}
],
libraryFiles: [
{pattern: 'common_static/edx-pattern-library/js/**/*.js'},
{pattern: 'common_static/edx-ui-toolkit/js/**/*.js'}
{pattern: 'common_static/edx-pattern-library/js/**/*.js'}
],
// Make sure the patterns in sourceFiles and specFiles do not match the same file.

View File

@@ -1,36 +1,4 @@
(function(requirejs, define) {
'use strict';
// We do not wish to bundle common libraries (that may also be used by non-RequireJS code on the page
// into the optimized files. Therefore load these libraries through script tags and explicitly define them.
// Note that when the optimizer executes this code, window will not be defined.
if (window) {
var defineDependency = function (globalName, name, noShim) {
var getGlobalValue = function(name) {
var globalNamePath = name.split('.'),
result = window,
i;
for (i = 0; i < globalNamePath.length; i++) {
result = result[globalNamePath[i]];
}
return result;
},
globalValue = getGlobalValue(globalName);
if (globalValue) {
if (noShim) {
define(name, {});
}
else {
define(name, [], function() { return globalValue; });
}
}
else {
console.error("Expected library to be included on page, but not found on window object: " + name);
}
};
defineDependency("jQuery", "jquery");
defineDependency("jQuery", "jquery-migrate");
defineDependency("_", "underscore");
}
(function(requirejs) {
requirejs.config({
baseUrl: '/base/',
paths: {
@@ -38,8 +6,7 @@
"modernizr": "common_static/edx-pattern-library/js/modernizr-custom",
"afontgarde": "common_static/edx-pattern-library/js/afontgarde",
"edxicons": "common_static/edx-pattern-library/js/edx-icons",
"draggabilly": "common_static/js/vendor/draggabilly",
'edx-ui-toolkit': 'common_static/edx-ui-toolkit'
"draggabilly": "common_static/js/vendor/draggabilly"
},
"moment": {
exports: "moment"
@@ -51,4 +18,5 @@
exports: "AFontGarde"
}
});
}).call(this, RequireJS.requirejs, RequireJS.define);
}).call(this, RequireJS.requirejs);

View File

@@ -266,7 +266,6 @@
expect($('.closed-captions')).toHaveAttrs({
'lang': 'de'
});
expect(link).toHaveAttr('aria-pressed', 'true');
});
it('when clicking on link with current language', function () {
@@ -285,7 +284,6 @@
expect(state.storage.setItem)
.not.toHaveBeenCalledWith('language', 'en');
expect($('.langs-list li.is-active').length).toBe(1);
expect(link).toHaveAttr('aria-pressed', 'true');
});
it('open the language toggle on hover', function () {
@@ -415,7 +413,7 @@
});
it('show explanation message', function () {
expect($('.subtitles .subtitles-menu li')).toHaveText(
expect($('.subtitles-menu li')).toHaveText(
'Transcript will be displayed when you start playing the video.'
);
});

View File

@@ -203,18 +203,16 @@
describe('onSpeedChange', function () {
beforeEach(function () {
state = jasmine.initializePlayer();
$('li[data-speed="1.0"]').addClass('is-active').attr('aria-pressed', 'true');
$('li[data-speed="1.0"]').addClass('is-active');
state.videoSpeedControl.setSpeed(0.75);
});
it('set the new speed as active', function () {
expect($('li[data-speed="1.0"]')).not.toHaveClass('is-active');
expect($('li[data-speed="1.0"] .speed-option').attr('aria-pressed')).not.toEqual('true');
expect($('li[data-speed="0.75"]')).toHaveClass('is-active');
expect($('li[data-speed="0.75"] .speed-option').attr('aria-pressed')).toEqual('true');
expect($('.speeds .speed-button .value')).toHaveHtml('0.75x');
expect($('.video-speeds li[data-speed="1.0"]'))
.not.toHaveClass('is-active');
expect($('.video-speeds li[data-speed="0.75"]'))
.toHaveClass('is-active');
expect($('.speeds .value')).toHaveHtml('0.75x');
});
});

View File

@@ -1,10 +1,9 @@
(function (requirejs, require, define) {
"use strict";
define(
'video/08_video_speed_control.js', [
'video/00_iterator.js',
'edx-ui-toolkit/js/utils/html-utils'
], function (Iterator, HtmlUtils) {
'video/08_video_speed_control.js',
['video/00_iterator.js'],
function (Iterator) {
/**
* Video speed control module.
* @exports video/08_video_speed_control.js
@@ -96,37 +95,23 @@ define(
* Creates any necessary DOM elements, attach them, and set their,
* initial configuration.
* @param {array} speeds List of speeds available for the player.
* @param {string} currentSpeed The current speed set to the player.
*/
render: function (speeds, currentSpeed) {
render: function (speeds) {
var speedsContainer = this.speedsContainer,
reversedSpeeds = speeds.concat().reverse(),
speedsList = $.map(reversedSpeeds, function (speed) {
return HtmlUtils.interpolateHtml(
HtmlUtils.joinHtml(
HtmlUtils.HTML('<li data-speed="{speed}">'),
HtmlUtils.HTML('<button class="control speed-option" tabindex="-1" aria-pressed="false">'),
HtmlUtils.HTML(speed),
HtmlUtils.HTML('x'),
HtmlUtils.HTML('</button>'),
HtmlUtils.HTML('</li>')
),
{
speed: speed
}
).toString();
return [
'<li data-speed="', speed, '">',
'<button class="control speed-option" tabindex="-1">',
speed, 'x',
'</button>',
'</li>'
].join('');
});
HtmlUtils.setHtml(
speedsContainer,
HtmlUtils.HTML(speedsList)
);
speedsContainer.html(speedsList.join(''));
this.speedLinks = new Iterator(speedsContainer.find('.speed-option'));
HtmlUtils.prepend(
this.state.el.find('.secondary-controls'),
HtmlUtils.HTML(this.el)
);
this.setActiveSpeed(currentSpeed);
this.state.el.find('.secondary-controls').prepend(this.el);
},
/**
@@ -231,38 +216,17 @@ define(
if (speed !== this.currentSpeed || forceUpdate) {
this.speedsContainer
.find('li')
.siblings("li[data-speed='" + speed + "']");
.removeClass('is-active')
.siblings("li[data-speed='" + speed + "']")
.addClass('is-active');
this.speedButton.find('.value').text(speed + 'x');
this.speedButton.find('.value').html(speed + 'x');
this.currentSpeed = speed;
if (!silent) {
this.el.trigger('speedchange', [speed, this.state.speed]);
}
}
this.resetActiveSpeed();
this.setActiveSpeed(speed);
},
resetActiveSpeed: function() {
var speedOptions = this.speedsContainer.find('li');
$(speedOptions).each(function(index, el) {
$(el).removeClass('is-active')
.find('.speed-option')
.attr('aria-pressed', 'false');
});
},
setActiveSpeed: function(speed) {
var speedOption = this.speedsContainer.find('li[data-speed="' + speed + '"]');
speedOption.addClass('is-active')
.find('.speed-option')
.attr('aria-pressed', 'true');
this.speedButton.attr('title', gettext('Video speed: ') + speed + 'x');
},
/**
@@ -280,13 +244,10 @@ define(
* @param {jquery Event} event
*/
clickLinkHandler: function (event) {
var el = $(event.currentTarget).parent(),
speed = $(el).data('speed');
this.resetActiveSpeed();
this.setActiveSpeed(speed);
var speed = $(event.currentTarget).parent().data('speed');
this.closeMenu();
this.state.videoCommands.execute('speed', speed);
this.closeMenu(true);
return false;
},

View File

@@ -5,12 +5,11 @@
define('video/09_video_caption.js',[
'video/00_sjson.js',
'video/00_async_process.js',
'edx-ui-toolkit/js/utils/html-utils',
'draggabilly',
'modernizr',
'afontgarde',
'edxicons'
], function (Sjson, AsyncProcess, HtmlUtils, Draggabilly) {
], function (Sjson, AsyncProcess, Draggabilly) {
/**
* @desc VideoCaption module exports a function.
@@ -81,53 +80,47 @@
renderElements: function () {
var languages = this.state.config.transcriptLanguages;
var langHtml = HtmlUtils.joinHtml(
HtmlUtils.HTML('<div class="grouped-controls">'),
HtmlUtils.HTML('<button class="control toggle-captions" aria-disabled="false">'),
HtmlUtils.HTML('<span class="icon-fallback-img">'),
HtmlUtils.HTML('<span class="icon fa fa-cc" aria-hidden="true"></span>'),
HtmlUtils.HTML('<span class="sr control-text"></span>'),
HtmlUtils.HTML('</span>'),
HtmlUtils.HTML('</button>'),
HtmlUtils.HTML('<button class="control toggle-transcript" aria-disabled="false">'),
HtmlUtils.HTML('<span class="icon-fallback-img">'),
HtmlUtils.HTML('<span class="icon fa fa-quote-left" aria-hidden="true"></span>'),
HtmlUtils.HTML('<span class="sr control-text"></span>'),
HtmlUtils.HTML('</span>'),
HtmlUtils.HTML('</button>'),
HtmlUtils.HTML('<div class="lang menu-container" role="application">'),
HtmlUtils.HTML('<p class="sr instructions" id="lang-instructions"></p>'),
HtmlUtils.HTML('<button class="control language-menu" aria-disabled="false"'),
HtmlUtils.HTML('aria-describedby="lang-instructions" '),
HtmlUtils.HTML('title="'),
var langTemplate = [
'<div class="grouped-controls">',
'<button class="control toggle-captions" aria-disabled="false">',
'<span class="icon-fallback-img">',
'<span class="icon fa fa-cc" aria-hidden="true"></span>',
'<span class="sr control-text"></span>',
'</span>',
'</button>',
'<button class="control toggle-transcript" aria-disabled="false">',
'<span class="icon-fallback-img">',
'<span class="icon fa fa-quote-left" aria-hidden="true"></span>',
'<span class="sr control-text"></span>',
'</span>',
'</button>',
'<div class="lang menu-container" role="application">',
'<p class="sr instructions" id="lang-instructions"></p>',
'<button class="control language-menu" aria-disabled="false"',
'aria-describedby="lang-instructions" ',
'title="',
gettext('Open language menu'),
HtmlUtils.HTML('">'),
HtmlUtils.HTML('<span class="icon-fallback-img">'),
HtmlUtils.HTML('<span class="icon fa fa-caret-left" aria-hidden="true"></span>'),
HtmlUtils.HTML('<span class="sr control-text"></span>'),
HtmlUtils.HTML('</span>'),
HtmlUtils.HTML('</button>'),
HtmlUtils.HTML('</div>'),
HtmlUtils.HTML('</div>)')
);
'">',
'<span class="icon-fallback-img">',
'<span class="icon fa fa-caret-left" aria-hidden="true"></span>',
'<span class="sr control-text"></span>',
'</span>',
'</button>',
'</div>',
'</div>'
].join('');
var subtitlesHtml = HtmlUtils.interpolateHtml(
HtmlUtils.joinHtml(
HtmlUtils.HTML('<div class="subtitles" role="region" id="transcript-{courseId}">'),
HtmlUtils.HTML('<h3 id="transcript-label-{courseId}" class="transcript-title sr"></h3>'),
HtmlUtils.HTML('<ol id="transcript-captions" class="subtitles-menu" lang="{courseLang}"></ol>'),
HtmlUtils.HTML('</div>')
),
{
courseId: this.state.id,
courseLang: this.state.lang
}
);
var template = [
'<div class="subtitles" role="region" id="transcript-' + this.state.id + '">',
'<h3 id="transcript-label-' + this.state.id + '" class="transcript-title sr"></h3>',
'<ol id="transcript-captions" class="subtitles-menu" lang="' + this.state.lang + '"></ol>',
'</div>'
].join('');
this.loaded = false;
this.subtitlesEl = $(HtmlUtils.ensureHtml(subtitlesHtml).toString());
this.subtitlesEl = $(template);
this.subtitlesMenuEl = this.subtitlesEl.find('.subtitles-menu');
this.container = $(HtmlUtils.ensureHtml(langHtml).toString());
this.container = $(langTemplate);
this.captionControlEl = this.container.find('.toggle-captions');
this.captionDisplayEl = this.state.el.find('.closed-captions');
this.transcriptControlEl = this.container.find('.toggle-transcript');
@@ -549,26 +542,15 @@
}
} else {
if (state.isTouch) {
HtmlUtils.setHtml(
self.subtitlesEl.find('.subtitles-menu'),
HtmlUtils.joinHtml(
HtmlUtils.HTML('<li>'),
gettext('Transcript will be displayed when you start playing the video.'),
HtmlUtils.HTML('</li>')
)
);
self.subtitlesEl.find('.subtitles-menu')
.text(gettext('Transcript will be displayed when you start playing the video.')) // jshint ignore: line
.wrapInner('<li></li>');
} else {
self.renderCaption(start, captions);
}
self.hideCaptions(state.hide_captions, false);
HtmlUtils.append(
self.state.el.find('.video-wrapper').parent(),
HtmlUtils.HTML(self.subtitlesEl)
);
HtmlUtils.append(
self.state.el.find('.secondary-controls'),
HtmlUtils.HTML(self.container)
);
self.state.el.find('.video-wrapper').after(self.subtitlesEl);
self.state.el.find('.secondary-controls').append(self.container);
self.bindHandlers();
}
@@ -648,11 +630,9 @@
onResize: function () {
this.subtitlesEl
.find('.spacing').first()
.height(this.topSpacingHeight());
this.subtitlesEl
.height(this.topSpacingHeight()).end()
.find('.spacing').last()
.height(this.bottomSpacingHeight());
.height(this.bottomSpacingHeight());
this.scrollCaption();
this.setSubtitlesHeight();
@@ -669,9 +649,8 @@
renderLanguageMenu: function (languages) {
var self = this,
state = this.state,
$menu = $('<ol class="langs-list menu">'),
currentLang = state.getCurrentLanguage(),
$li, $link, linkHtml;
menu = $('<ol class="langs-list menu">'),
currentLang = state.getCurrentLanguage();
if (_.keys(languages).length < 2) {
// Remove the menu toggle button
@@ -682,29 +661,20 @@
this.showLanguageMenu = true;
$.each(languages, function(code, label) {
$li = $('<li />', { 'data-lang-code': code });
linkHtml = HtmlUtils.joinHtml(
HtmlUtils.HTML('<button class="control control-lang">'),
label,
HtmlUtils.HTML('</button>')
);
$link = $(linkHtml.toString());
var li = $('<li data-lang-code="' + code + '" />'),
link = $('<button class="control control-lang">' + label + '</button>');
if (currentLang === code) {
$li.addClass('is-active');
$link.attr('aria-pressed', 'true');
li.addClass('is-active');
}
$li.append($link);
$menu.append($li);
li.append(link);
menu.append(li);
});
HtmlUtils.append(
this.languageChooserEl,
HtmlUtils.HTML($menu)
);
$menu.on('click', '.control-lang', function (e) {
this.languageChooserEl.append(menu);
menu.on('click', '.control-lang', function (e) {
var el = $(e.currentTarget).parent(),
state = self.state,
langCode = el.data('lang-code');
@@ -713,11 +683,7 @@
state.lang = langCode;
el .addClass('is-active')
.siblings('li')
.removeClass('is-active')
.find('.control-lang')
.attr('aria-pressed', 'false');
$(e.currentTarget).attr('aria-pressed', 'true');
.removeClass('is-active');
state.el.trigger('language_menu:change', [langCode]);
self.fetchCaption();
@@ -727,7 +693,6 @@
// update the transcript lang attribute
self.subtitlesMenuEl.attr('lang', langCode);
self.closeLanguageMenu(e);
}
});
},
@@ -750,18 +715,13 @@
'data-index': index,
'data-start': start[index],
'tabindex': 0
});
$(liEl).text(text);
}).html(text);
return liEl[0];
};
return AsyncProcess.array(captions, process).done(function (list) {
HtmlUtils.append(
container,
HtmlUtils.HTML(list)
);
container.append(list);
});
},
@@ -830,40 +790,17 @@
* out of the tabbing order.
*
*/
addPaddings: function() {
var topSpacer = HtmlUtils.interpolateHtml(
HtmlUtils.HTML([
'<li class="spacing" style="height: {height}px">',
'<a href="#transcript-end-{id}" id="transcript-start-{id}" class="transcript-start"></a>', // jshint ignore:line
'</li>'
].join('')),
{
id: this.state.id,
height: this.topSpacingHeight()
}
);
addPaddings: function () {
var bottomSpacer = HtmlUtils.interpolateHtml(
HtmlUtils.HTML([
'<li class="spacing" style="height: {height}px">',
'<a href="#transcript-start-{id}" id="transcript-end-{id}" class="transcript-end"></a>', // jshint ignore:line
'</li>'
].join('')),
{
id: this.state.id,
height: this.bottomSpacingHeight()
}
this.subtitlesMenuEl
.prepend(
$('<li class="spacing"><a href="#transcript-end-' + this.state.id + '" id="transcript-start-' + this.state.id + '" class="transcript-start"></a>') // jshint ignore: line
.height(this.topSpacingHeight())
)
.append(
$('<li class="spacing"><a href="#transcript-start-' + this.state.id + '" id="transcript-end-' + this.state.id + '" class="transcript-end"></a>') // jshint ignore: line
.height(this.bottomSpacingHeight())
);
HtmlUtils.prepend(
this.subtitlesMenuEl,
topSpacer
);
HtmlUtils.append(
this.subtitlesMenuEl,
bottomSpacer
);
},
/**

View File

@@ -2950,10 +2950,11 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
output_fields = dict(jsonfields)
for field_name, value in output_fields.iteritems():
if value:
field = xblock_class.fields.get(field_name)
if field is None:
try:
field = xblock_class.fields.get(field_name)
except AttributeError:
continue
elif isinstance(field, Reference):
if isinstance(field, Reference):
output_fields[field_name] = robust_usage_key(value)
elif isinstance(field, ReferenceList):
output_fields[field_name] = [robust_usage_key(ele) for ele in value]

View File

@@ -218,7 +218,7 @@ class ModuleStoreIsolationMixin(CacheIsolationMixin):
MODULESTORE = functools.partial(mixed_store_config, mkdtemp_clean(), {})
CONTENTSTORE = functools.partial(contentstore_config)
ENABLED_CACHES = ['mongo_metadata_inheritance', 'loc_cache']
ENABLED_CACHES = ['default', 'mongo_metadata_inheritance', 'loc_cache']
__settings_overrides = []
__old_modulestores = []
__old_contentstores = []

View File

@@ -210,13 +210,15 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
self._capture_basic_metrics()
# Is this sequential part of a timed or proctored exam?
masquerading = context.get('specific_masquerade', False)
special_exam_html = None
if self.is_time_limited:
view_html = self._time_limited_student_view(context)
special_exam_html = self._time_limited_student_view(context)
# Do we have an alternate rendering
# Do we have an applicable alternate rendering
# from the edx_proctoring subsystem?
if view_html:
fragment.add_content(view_html)
if special_exam_html and not masquerading:
fragment.add_content(special_exam_html)
return fragment
for child in display_items:
@@ -249,6 +251,7 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
'ajax_url': self.system.ajax_url,
'next_url': context.get('next_url'),
'prev_url': context.get('prev_url'),
'override_hidden_exam': masquerading and special_exam_html is not None,
}
fragment.add_content(self.system.render_template("seq_module.html", params))

View File

@@ -7,11 +7,13 @@ from unittest import TestCase
from django.utils.timezone import UTC
from xmodule.course_metadata_utils import (
clean_course_key,
url_name_for_course_location,
from xmodule.block_metadata_utils import (
url_name_for_block,
display_name_with_default,
display_name_with_default_escaped,
)
from xmodule.course_metadata_utils import (
clean_course_key,
number_for_course_location,
has_course_started,
has_course_ended,
@@ -130,9 +132,9 @@ class CourseMetadataUtilsTestCase(TestCase):
"course_MNXXK4TTMUWXMMJ2KVXGS5TFOJZWS5DZLAVUGUZNGIYDGK2ZGIYDSNQ~"
),
]),
FunctionTest(url_name_for_course_location, [
TestScenario((self.demo_course.location,), self.demo_course.location.name),
TestScenario((self.html_course.location,), self.html_course.location.name),
FunctionTest(url_name_for_block, [
TestScenario((self.demo_course,), self.demo_course.location.name),
TestScenario((self.html_course,), self.html_course.location.name),
]),
FunctionTest(display_name_with_default_escaped, [
# Test course with no display name.

View File

@@ -27,7 +27,7 @@ from xblock.fields import (
from xblock.fragment import Fragment
from xblock.runtime import Runtime, IdReader, IdGenerator
from xmodule import course_metadata_utils
from xmodule import block_metadata_utils
from xmodule.fields import RelativeTime
from xmodule.errortracker import exc_info_to_str
from xmodule.modulestore.exceptions import ItemNotFoundError
@@ -340,7 +340,7 @@ class XModuleMixin(XModuleFields, XBlock):
@property
def url_name(self):
return course_metadata_utils.url_name_for_course_location(self.location)
return block_metadata_utils.url_name_for_block(self)
@property
def display_name_with_default(self):
@@ -348,7 +348,7 @@ class XModuleMixin(XModuleFields, XBlock):
Return a display name for the module: use display_name if defined in
metadata, otherwise convert the url name.
"""
return course_metadata_utils.display_name_with_default(self)
return block_metadata_utils.display_name_with_default(self)
@property
def display_name_with_default_escaped(self):
@@ -363,7 +363,7 @@ class XModuleMixin(XModuleFields, XBlock):
migrate and test switching to display_name_with_default, which is no
longer escaped.
"""
return course_metadata_utils.display_name_with_default_escaped(self)
return block_metadata_utils.display_name_with_default_escaped(self)
@property
def tooltip_title(self):

View File

@@ -1,2 +0,0 @@
!view/discussion_thread_edit_view_spec.js
!view/discussion_topic_menu_view_spec.js

View File

@@ -1,115 +0,0 @@
describe 'All Content', ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
describe 'Staff and TA Content', ->
beforeEach ->
DiscussionUtil.loadRoles({"Moderator": [567], "Administrator": [567], "Community TA": [567]})
it 'anonymous thread should not include login role label', ->
anon_content = new Content
anon_content.initialize
expect(anon_content.get 'staff_authored').toBe false
expect(anon_content.get 'community_ta_authored').toBe false
it 'general thread should include login role label', ->
anon_content = new Content { user_id: '567' }
anon_content.initialize
expect(anon_content.get 'staff_authored').toBe true
expect(anon_content.get 'community_ta_authored').toBe true
describe 'Content', ->
beforeEach ->
@content = new Content {
id: '01234567',
user_id: '567',
course_id: 'edX/999/test',
body: 'this is some content',
abuse_flaggers: ['123']
}
it 'should exist', ->
expect(Content).toBeDefined()
it 'is initialized correctly', ->
@content.initialize
expect(Content.contents['01234567']).toEqual @content
expect(@content.get 'id').toEqual '01234567'
expect(@content.get 'user_url').toEqual '/courses/edX/999/test/discussion/forum/users/567'
expect(@content.get 'children').toEqual []
expect(@content.get 'comments').toEqual(jasmine.any(Comments))
it 'can update info', ->
@content.updateInfo {
ability: {'can_edit': true},
voted: true,
subscribed: true
}
expect(@content.get 'ability').toEqual {'can_edit': true}
expect(@content.get 'voted').toEqual true
expect(@content.get 'subscribed').toEqual true
it 'can be flagged for abuse', ->
@content.flagAbuse()
expect(@content.get 'abuse_flaggers').toEqual ['123', '567']
it 'can be unflagged for abuse', ->
temp_array = []
temp_array.push(window.user.get('id'))
@content.set("abuse_flaggers",temp_array)
@content.unflagAbuse()
expect(@content.get 'abuse_flaggers').toEqual []
describe 'Comments', ->
beforeEach ->
@comment1 = new Comment {id: '123'}
@comment2 = new Comment {id: '345'}
it 'can contain multiple comments', ->
myComments = new Comments
expect(myComments.length).toEqual 0
myComments.add @comment1
expect(myComments.length).toEqual 1
myComments.add @comment2
expect(myComments.length).toEqual 2
it 'returns results to the find method', ->
myComments = new Comments
myComments.add @comment1
expect(myComments.find('123')).toBe @comment1
it 'can be endorsed', ->
DiscussionUtil.loadRoles(
{"Moderator": [111], "Administrator": [222], "Community TA": [333]}
)
@discussionThread = new Thread({id: 1, thread_type: "discussion", user_id: 99})
@discussionResponse = new Comment({id: 1, thread: @discussionThread})
@questionThread = new Thread({id: 1, thread_type: "question", user_id: 99})
@questionResponse = new Comment({id: 1, thread: @questionThread})
# mod
window.user = new DiscussionUser({id: 111})
expect(@discussionResponse.canBeEndorsed()).toBe(true)
expect(@questionResponse.canBeEndorsed()).toBe(true)
# admin
window.user = new DiscussionUser({id: 222})
expect(@discussionResponse.canBeEndorsed()).toBe(true)
expect(@questionResponse.canBeEndorsed()).toBe(true)
# TA
window.user = new DiscussionUser({id: 333})
expect(@discussionResponse.canBeEndorsed()).toBe(true)
expect(@questionResponse.canBeEndorsed()).toBe(true)
# thread author
window.user = new DiscussionUser({id: 99})
expect(@discussionResponse.canBeEndorsed()).toBe(false)
expect(@questionResponse.canBeEndorsed()).toBe(true)
# anyone else
window.user = new DiscussionUser({id: 999})
expect(@discussionResponse.canBeEndorsed()).toBe(false)
expect(@questionResponse.canBeEndorsed()).toBe(false)

View File

@@ -1,72 +0,0 @@
class @DiscussionSpecHelper
# This is sad. We should avoid dependence on global vars.
@setUpGlobals = ->
DiscussionUtil.loadRoles({"Moderator": [], "Administrator": [], "Community TA": []})
window.$$course_id = "edX/999/test"
window.user = new DiscussionUser({username: "test_user", id: "567", upvoted_ids: []})
DiscussionUtil.setUser(window.user)
@makeTA = () ->
DiscussionUtil.roleIds["Community TA"].push(parseInt(DiscussionUtil.getUser().id))
@makeModerator = () ->
DiscussionUtil.roleIds["Moderator"].push(parseInt(DiscussionUtil.getUser().id))
@makeAjaxSpy = (fakeAjax) ->
spyOn($, "ajax").and.callFake(
(params) ->
fakeAjax(params)
{always: ->}
)
@makeEventSpy = () ->
jasmine.createSpyObj('event', ['preventDefault', 'target'])
@makeCourseSettings = (is_cohorted=true) ->
new DiscussionCourseSettings(
category_map:
children: ['Test Topic', 'Other Topic']
entries:
'Test Topic':
is_cohorted: is_cohorted
id: 'test_topic'
'Other Topic':
is_cohorted: is_cohorted
id: 'other_topic'
is_cohorted: is_cohorted
)
@setUnderscoreFixtures = ->
templateNames = [
'thread', 'thread-show', 'thread-edit',
'thread-response', 'thread-response-show', 'thread-response-edit',
'response-comment-show', 'response-comment-edit',
'thread-list-item', 'discussion-home', 'search-alert',
'new-post', 'thread-type', 'new-post-menu-entry',
'new-post-menu-category', 'topic', 'post-user-display',
'inline-discussion', 'pagination', 'user-profile', 'profile-thread'
]
templateNamesNoTrailingTemplate = [
'forum-action-endorse', 'forum-action-answer', 'forum-action-follow',
'forum-action-vote', 'forum-action-report', 'forum-action-pin',
'forum-action-close', 'forum-action-edit', 'forum-action-delete',
'forum-actions',
]
for templateName in templateNames
templateFixture = readFixtures('common/templates/discussion/' + templateName + '.underscore')
appendSetFixtures($('<script>', { id: templateName + '-template', type: 'text/template' })
.text(templateFixture))
for templateName in templateNamesNoTrailingTemplate
templateFixture = readFixtures('common/templates/discussion/' + templateName + '.underscore')
appendSetFixtures($('<script>', { id: templateName, type: 'text/template' })
.text(templateFixture))
appendSetFixtures("""
<div id="fixture-element"></div>
<div id="discussion-container"
data-course-name="Fake Course"
data-user-create-comment="true"
data-user-create-subcomment="true"
data-read-only="false"
></div>
""")

View File

@@ -1,48 +0,0 @@
describe 'DiscussionUtil', ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
describe "updateWithUndo", ->
it "calls through to safeAjax with correct params, and reverts the model in case of failure", ->
deferred = $.Deferred()
spyOn($, "ajax").and.returnValue(deferred)
spyOn(DiscussionUtil, "safeAjax").and.callThrough()
model = new Backbone.Model({hello: false, number: 42})
updates = {hello: "world"}
# the ajax request should fire and the model should be updated
res = DiscussionUtil.updateWithUndo(model, updates, {foo: "bar"}, "error message")
expect(DiscussionUtil.safeAjax).toHaveBeenCalled()
expect(model.attributes).toEqual({hello: "world", number: 42})
# the error message callback should be set up correctly
spyOn(DiscussionUtil, "discussionAlert")
DiscussionUtil.safeAjax.calls.mostRecent().args[0].error()
expect(DiscussionUtil.discussionAlert).toHaveBeenCalledWith("Sorry", "error message")
# if the ajax call ends in failure, the model state should be reverted
deferred.reject()
expect(model.attributes).toEqual({hello: false, number: 42})
it "rolls back the changes if the associated element is disabled", ->
spyOn(DiscussionUtil, "safeAjax").and.callThrough()
model = new Backbone.Model({hello: false, number: 42})
updates = {hello: "world"}
# This is the element that is disabled/enabled while the ajax request is
# in progress
$elem = jasmine.createSpyObj('$elem', ['attr'])
$elem.attr.and.returnValue(true)
res = DiscussionUtil.updateWithUndo(model, updates, {foo: "bar", $elem:$elem}, "error message")
expect($elem.attr).toHaveBeenCalledWith("disabled")
expect(DiscussionUtil.safeAjax).toHaveBeenCalled()
expect(model.attributes).toEqual({hello: false, number: 42})
failed = false
res.fail(() => failed = true)
expect(failed).toBe(true);

View File

@@ -1,43 +0,0 @@
describe "DiscussionContentView", ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
DiscussionSpecHelper.setUnderscoreFixtures()
@threadData = {
id: '01234567',
user_id: '567',
course_id: 'edX/999/test',
body: 'this is a thread',
created_at: '2013-04-03T20:08:39Z',
abuse_flaggers: ['123'],
votes: {up_count: '42'},
type: "thread",
roles: []
}
@thread = new Thread(@threadData)
@view = new DiscussionContentView({ model: @thread })
@view.setElement($('#fixture-element'))
@view.render()
it 'defines the tag', ->
expect($('#jasmine-fixtures')).toExist
expect(@view.tagName).toBeDefined
expect(@view.el.tagName.toLowerCase()).toBe 'div'
it "defines the class", ->
# spyOn @content, 'initialize'
expect(@view.model).toBeDefined();
it 'is tied to the model', ->
expect(@view.model).toBeDefined();
it 'can be flagged for abuse', ->
@thread.flagAbuse()
expect(@thread.get 'abuse_flaggers').toEqual ['123', '567']
it 'can be unflagged for abuse', ->
temp_array = []
temp_array.push(window.user.get('id'))
@thread.set("abuse_flaggers",temp_array)
@thread.unflagAbuse()
expect(@thread.get 'abuse_flaggers').toEqual []

View File

@@ -1,602 +0,0 @@
describe "DiscussionThreadListView", ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
DiscussionSpecHelper.setUnderscoreFixtures()
appendSetFixtures("""
<script type="text/template" id="thread-list-template">
<div class="forum-nav-header">
<button type="button" class="forum-nav-browse" id="forum-nav-browse" aria-haspopup="true">
<span class="icon fa fa-bars" aria-hidden="true"></span>
<span class="sr">Discussion topics; currently listing: </span>
<span class="forum-nav-browse-current">All Discussions</span>
</button>
<form class="forum-nav-search">
<label>
<span class="sr">Search all posts</span>
<input class="forum-nav-search-input" id="forum-nav-search" type="text" placeholder="Search all posts">
<span class="icon fa fa-search" aria-hidden="true"></span>
</label>
</form>
</div>
<div class="forum-nav-browse-menu-wrapper" style="display: none">
<form class="forum-nav-browse-filter">
<label>
<span class="sr">Filter Topics</span>
<input type="text" class="forum-nav-browse-filter-input" placeholder="filter topics">
</label>
</form>
<ul class="forum-nav-browse-menu">
<li class="forum-nav-browse-menu-item forum-nav-browse-menu-all">
<a href="#" class="forum-nav-browse-title">All Discussions</a>
</li>
<li class="forum-nav-browse-menu-item forum-nav-browse-menu-following">
<a href="#" class="forum-nav-browse-title"><span class="icon fa fa-star" aria-hidden="true"></span>Posts I'm Following</a>
</li>
<li class="forum-nav-browse-menu-item">
<a href="#" class="forum-nav-browse-title">Parent</a>
<ul class="forum-nav-browse-submenu">
<li class="forum-nav-browse-menu-item">
<a href="#" class="forum-nav-browse-title">Target</a>
<ul class="forum-nav-browse-submenu">
<li
class="forum-nav-browse-menu-item"
data-discussion-id="child"
data-cohorted="false"
>
<a href="#" class="forum-nav-browse-title">Child</a>
</li>
</ul>
<li
class="forum-nav-browse-menu-item"
data-discussion-id="sibling"
data-cohorted="false"
>
<a href="#" class="forum-nav-browse-title">Sibling</a>
</li>
</ul>
</li>
<li
class="forum-nav-browse-menu-item"
data-discussion-id="other"
data-cohorted="true"
>
<a href="#" class="forum-nav-browse-title">Other Category</a>
</li>
</ul>
</div>
<div class="forum-nav-thread-list-wrapper" id="sort-filter-wrapper" tabindex="-1">
<div class="forum-nav-refine-bar">
<label class="forum-nav-filter-main">
<select class="forum-nav-filter-main-control">
<option value="all">Show all</option>
<option value="unread">Unread</option>
<option value="unanswered">Unanswered</option>
<option value="flagged">Flagged</option>
</select>
</label>
<% if (isCohorted && isPrivilegedUser) { %>
<label class="forum-nav-filter-cohort">
<span class="sr">Cohort:</span>
<select class="forum-nav-filter-cohort-control">
<option value="">in all cohorts</option>
<option value="1">Cohort1</option>
<option value="2">Cohort2</option>
</select>
</label>
<% } %>
<label class="forum-nav-sort">
<select class="forum-nav-sort-control">
<option value="activity">by recent activity</option>
<option value="comments">by most activity</option>
<option value="votes">by most votes</option>
</select>
</label>
</div>
</div>
<div class="search-alerts"></div>
<ul class="forum-nav-thread-list"></ul>
</script>
""")
@threads = [
DiscussionViewSpecHelper.makeThreadWithProps({
id: "1",
title: "Thread1",
votes: {up_count: '20'},
pinned: true,
comments_count: 1,
created_at: '2013-04-03T20:08:39Z',
}),
DiscussionViewSpecHelper.makeThreadWithProps({
id: "2",
title: "Thread2",
votes: {up_count: '42'},
comments_count: 2,
created_at: '2013-04-03T20:07:39Z',
}),
DiscussionViewSpecHelper.makeThreadWithProps({
id: "3",
title: "Thread3",
votes: {up_count: '12'},
comments_count: 3,
created_at: '2013-04-03T20:06:39Z',
}),
DiscussionViewSpecHelper.makeThreadWithProps({
id: "4",
title: "Thread4",
votes: {up_count: '25'},
comments_count: 0,
pinned: true,
created_at: '2013-04-03T20:05:39Z',
}),
]
deferred = $.Deferred()
spyOn($, "ajax").and.returnValue(deferred);
@discussion = new Discussion([])
@view = new DiscussionThreadListView(
collection: @discussion,
el: $("#fixture-element"),
courseSettings: new DiscussionCourseSettings({is_cohorted: true})
)
@view.render()
setupAjax = (callback) ->
$.ajax.and.callFake(
(params) =>
if callback
callback(params)
params.success({discussion_data: [], page: 1, num_pages: 1})
{always: ->}
)
renderSingleThreadWithProps = (props) ->
makeView(new Discussion([new Thread(DiscussionViewSpecHelper.makeThreadWithProps(props))])).render()
makeView = (discussion) ->
return new DiscussionThreadListView(
el: $("#fixture-element"),
collection: discussion,
courseSettings: new DiscussionCourseSettings({is_cohorted: true})
)
expectFilter = (filterVal) ->
$.ajax.and.callFake((params) ->
_.each(["unread", "unanswered", "flagged"], (paramName)->
if paramName == filterVal
expect(params.data[paramName]).toEqual(true)
else
expect(params.data[paramName]).toBeUndefined()
)
{always: ->}
)
describe "should filter correctly", ->
_.each(["all", "unread", "unanswered", "flagged"], (filterVal) ->
it "for #{filterVal}", ->
expectFilter(filterVal)
@view.$(".forum-nav-filter-main-control").val(filterVal).change()
expect($.ajax).toHaveBeenCalled()
)
describe "cohort selector", ->
it "should not be visible to students", ->
expect(@view.$(".forum-nav-filter-cohort-control:visible")).not.toExist()
it "should allow moderators to select visibility", ->
DiscussionSpecHelper.makeModerator()
@view.render()
expectedGroupId = null
setupAjax((params) => expect(params.data.group_id).toEqual(expectedGroupId))
_.each(
[
{val: "", expectedGroupId: undefined},
{val: "1", expectedGroupId: "1"},
{val: "2", expectedGroupId: "2"}
],
(optionInfo) =>
expectedGroupId = optionInfo.expectedGroupId
@view.$(".forum-nav-filter-cohort-control").val(optionInfo.val).change()
expect($.ajax).toHaveBeenCalled()
$.ajax.calls.reset()
)
it "search should clear filter", ->
expectFilter(null)
@view.$(".forum-nav-filter-main-control").val("flagged")
@view.searchFor("foobar")
expect(@view.$(".forum-nav-filter-main-control").val()).toEqual("all")
checkThreadsOrdering = (view, sort_order, type) ->
expect(view.$el.find(".forum-nav-thread").children().length).toEqual(4)
expect(view.$el.find(".forum-nav-thread:nth-child(1) .forum-nav-thread-title").text()).toEqual(sort_order[0])
expect(view.$el.find(".forum-nav-thread:nth-child(2) .forum-nav-thread-title").text()).toEqual(sort_order[1])
expect(view.$el.find(".forum-nav-thread:nth-child(3) .forum-nav-thread-title").text()).toEqual(sort_order[2])
expect(view.$el.find(".forum-nav-thread:nth-child(4) .forum-nav-thread-title").text()).toEqual(sort_order[3])
expect(view.$el.find(".forum-nav-sort-control").val()).toEqual(type)
describe "thread rendering should be correct", ->
checkRender = (threads, type, sort_order) ->
discussion = new Discussion(_.map(threads, (thread) -> new Thread(thread)), {pages: 1, sort: type})
view = makeView(discussion)
view.render()
checkThreadsOrdering(view, sort_order, type)
expect(view.$el.find(".forum-nav-thread-comments-count:visible").length).toEqual(if type == "votes" then 0 else 4)
expect(view.$el.find(".forum-nav-thread-votes-count:visible").length).toEqual(if type == "votes" then 4 else 0)
if type == "votes"
expect(
_.map(
view.$el.find(".forum-nav-thread-votes-count"),
(element) -> $(element).text().trim()
)
).toEqual(["+25 votes", "+20 votes", "+42 votes", "+12 votes"])
it "with sort preference activity", ->
checkRender(@threads, "activity", ["Thread1", "Thread2", "Thread3", "Thread4"])
it "with sort preference votes", ->
checkRender(@threads, "votes", ["Thread4", "Thread1", "Thread2", "Thread3"])
it "with sort preference comments", ->
checkRender(@threads, "comments", ["Thread1", "Thread4", "Thread3", "Thread2"])
describe "Sort change should be correct", ->
changeSorting = (threads, selected_type, new_type, sort_order) ->
discussion = new Discussion(_.map(threads, (thread) -> new Thread(thread)), {pages: 1, sort: selected_type})
view = makeView(discussion)
view.render()
sortControl = view.$el.find(".forum-nav-sort-control")
expect(sortControl.val()).toEqual(selected_type)
sorted_threads = []
if new_type == 'activity'
sorted_threads = [threads[0], threads[3], threads[1], threads[2]]
else if new_type == 'comments'
sorted_threads = [threads[0], threads[3], threads[2], threads[1]]
else if new_type == 'votes'
sorted_threads = [threads[3], threads[0], threads[1], threads[2]]
$.ajax.and.callFake((params) =>
params.success(
{"discussion_data":sorted_threads, page:1, num_pages:1}
)
{always: ->}
)
sortControl.val(new_type).change()
expect($.ajax).toHaveBeenCalled()
checkThreadsOrdering(view, sort_order, new_type)
it "with sort preference activity", ->
changeSorting(@threads, "comments", "activity", ["Thread1", "Thread4", "Thread3", "Thread2"])
it "with sort preference votes", ->
changeSorting(@threads, "activity", "votes", ["Thread4", "Thread1", "Thread2", "Thread3"])
it "with sort preference comments", ->
changeSorting(@threads, "votes", "comments", ["Thread1", "Thread4", "Thread3", "Thread2"])
describe "search alerts", ->
testAlertMessages = (expectedMessages) ->
expect($(".search-alert .message").map( ->
$(@).html()
).get()).toEqual(expectedMessages)
it "renders and removes search alerts", ->
testAlertMessages []
foo = @view.addSearchAlert("foo")
testAlertMessages ["foo"]
bar = @view.addSearchAlert("bar")
testAlertMessages ["foo", "bar"]
@view.removeSearchAlert(foo.cid)
testAlertMessages ["bar"]
@view.removeSearchAlert(bar.cid)
testAlertMessages []
it "clears all search alerts", ->
@view.addSearchAlert("foo")
@view.addSearchAlert("bar")
@view.addSearchAlert("baz")
testAlertMessages ["foo", "bar", "baz"]
@view.clearSearchAlerts()
testAlertMessages []
describe "search spell correction", ->
beforeEach ->
spyOn(@view, "searchForUser")
testCorrection = (view, correctedText) ->
spyOn(view, "addSearchAlert")
$.ajax.and.callFake(
(params) =>
params.success(
{discussion_data: [], page: 42, num_pages: 99, corrected_text: correctedText}, 'success'
)
{always: ->}
)
view.searchFor("dummy")
expect($.ajax).toHaveBeenCalled()
it "adds a search alert when an alternate term was searched", ->
testCorrection(@view, "foo")
expect(@view.addSearchAlert.calls.count()).toEqual(1)
expect(@view.addSearchAlert.calls.mostRecent().args[0]).toMatch(/foo/)
it "does not add a search alert when no alternate term was searched", ->
testCorrection(@view, null)
expect(@view.addSearchAlert.calls.count()).toEqual(1)
expect(@view.addSearchAlert.calls.mostRecent().args[0]).toMatch(/no threads matched/i)
it "clears search alerts when a new search is performed", ->
spyOn(@view, "clearSearchAlerts")
spyOn(DiscussionUtil, "safeAjax")
@view.searchFor("dummy")
expect(@view.clearSearchAlerts).toHaveBeenCalled()
it "clears search alerts when the underlying collection changes", ->
spyOn(@view, "clearSearchAlerts")
spyOn(@view, "renderThread")
@view.collection.trigger("change", new Thread({id: 1}))
expect(@view.clearSearchAlerts).toHaveBeenCalled()
describe "Search events", ->
it "perform search when enter pressed inside search textfield", ->
setupAjax()
spyOn(@view, "searchFor")
@view.$el.find(".forum-nav-search-input").trigger($.Event("keydown", {which: 13}))
expect(@view.searchFor).toHaveBeenCalled()
it "perform search when search icon is clicked", ->
setupAjax()
spyOn(@view, "searchFor")
@view.$el.find(".fa-search").click()
expect(@view.searchFor).toHaveBeenCalled()
describe "username search", ->
it "makes correct ajax calls", ->
$.ajax.and.callFake(
(params) =>
expect(params.data.username).toEqual("testing-username")
expect(params.url.path()).toEqual(DiscussionUtil.urlFor("users"))
params.success(
{users: []}, 'success'
)
{always: ->}
)
@view.searchForUser("testing-username")
expect($.ajax).toHaveBeenCalled()
setAjaxResults = (threadSuccess, userResult) ->
# threadSuccess is a boolean indicating whether the thread search ajax call should succeed
# userResult is the value that should be returned as data from the username search ajax call
$.ajax.and.callFake(
(params) =>
if params.data.text and threadSuccess
params.success(
{discussion_data: [], page: 42, num_pages: 99, corrected_text: "dummy"},
"success"
)
else if params.data.username
params.success(
{users: userResult},
"success"
)
{always: ->}
)
it "gets called after a thread search succeeds", ->
spyOn(@view, "searchForUser").and.callThrough()
setAjaxResults(true, [])
@view.searchFor("gizmo")
expect(@view.searchForUser).toHaveBeenCalled()
expect($.ajax.calls.mostRecent().args[0].data.username).toEqual("gizmo")
it "does not get called after a thread search fails", ->
spyOn(@view, "searchForUser").and.callThrough()
setAjaxResults(false, [])
@view.searchFor("gizmo")
expect(@view.searchForUser).not.toHaveBeenCalled()
it "adds a search alert when an username was matched", ->
spyOn(@view, "addSearchAlert")
setAjaxResults(true, [{username: "gizmo", id: "1"}])
@view.searchForUser("dummy")
expect($.ajax).toHaveBeenCalled()
expect(@view.addSearchAlert).toHaveBeenCalled()
expect(@view.addSearchAlert.calls.mostRecent().args[0]).toMatch(/gizmo/)
it "does not add a search alert when no username was matched", ->
spyOn(@view, "addSearchAlert")
setAjaxResults(true, [])
@view.searchForUser("dummy")
expect($.ajax).toHaveBeenCalled()
expect(@view.addSearchAlert).not.toHaveBeenCalled()
describe "post type renders correctly", ->
it "for discussion", ->
renderSingleThreadWithProps({thread_type: "discussion"})
expect($(".forum-nav-thread-wrapper-0 .icon")).toHaveClass("fa-comments")
expect($(".forum-nav-thread-wrapper-0 .sr")).toHaveText("discussion")
it "for answered question", ->
renderSingleThreadWithProps({thread_type: "question", endorsed: true})
expect($(".forum-nav-thread-wrapper-0 .icon")).toHaveClass("fa-check-square-o")
expect($(".forum-nav-thread-wrapper-0 .sr")).toHaveText("answered question")
it "for unanswered question", ->
renderSingleThreadWithProps({thread_type: "question", endorsed: false})
expect($(".forum-nav-thread-wrapper-0 .icon")).toHaveClass("fa-question")
expect($(".forum-nav-thread-wrapper-0 .sr")).toHaveText("unanswered question")
describe "post labels render correctly", ->
beforeEach ->
@moderatorId = "42"
@administratorId = "43"
@communityTaId = "44"
DiscussionUtil.loadRoles({
"Moderator": [parseInt(@moderatorId)],
"Administrator": [parseInt(@administratorId)],
"Community TA": [parseInt(@communityTaId)],
})
it "for pinned", ->
renderSingleThreadWithProps({pinned: true})
expect($(".post-label-pinned").length).toEqual(1)
it "for following", ->
renderSingleThreadWithProps({subscribed: true})
expect($(".post-label-following").length).toEqual(1)
it "for moderator", ->
renderSingleThreadWithProps({user_id: @moderatorId})
expect($(".post-label-by-staff").length).toEqual(1)
it "for administrator", ->
renderSingleThreadWithProps({user_id: @administratorId})
expect($(".post-label-by-staff").length).toEqual(1)
it "for community TA", ->
renderSingleThreadWithProps({user_id: @communityTaId})
expect($(".post-label-by-community-ta").length).toEqual(1)
it "when none should be present", ->
renderSingleThreadWithProps({})
expect($(".forum-nav-thread-labels").length).toEqual(0)
describe "browse menu", ->
afterEach ->
# Remove handler added to make browse menu disappear
$("body").unbind("click")
expectBrowseMenuVisible = (isVisible) ->
expect($(".forum-nav-browse-menu:visible").length).toEqual(if isVisible then 1 else 0)
expect($(".forum-nav-thread-list-wrapper:visible").length).toEqual(if isVisible then 0 else 1)
it "should not be visible by default", ->
expectBrowseMenuVisible(false)
it "should show when header button is clicked", ->
$(".forum-nav-browse").click()
expectBrowseMenuVisible(true)
describe "when shown", ->
beforeEach ->
$(".forum-nav-browse").click()
it "should hide when header button is clicked", ->
$(".forum-nav-browse").click()
expectBrowseMenuVisible(false)
it "should hide when a click outside the menu occurs", ->
$(".forum-nav-search-input").click()
expectBrowseMenuVisible(false)
it "should hide when a search is executed", ->
setupAjax()
$(".forum-nav-search-input").trigger($.Event("keydown", {which: 13}))
expectBrowseMenuVisible(false)
it "should hide when a category is clicked", ->
$(".forum-nav-browse-title")[0].click()
expectBrowseMenuVisible(false)
it "should still be shown when filter input is clicked", ->
$(".forum-nav-browse-filter-input").click()
expectBrowseMenuVisible(true)
describe "filtering", ->
checkFilter = (filterText, expectedItems) ->
$(".forum-nav-browse-filter-input").val(filterText).keyup()
visibleItems = $(".forum-nav-browse-title:visible").map(
(i, elem) -> $(elem).text()
).get()
expect(visibleItems).toEqual(expectedItems)
it "should be case-insensitive", ->
checkFilter("other", ["Other Category"])
it "should match partial words", ->
checkFilter("ateg", ["Other Category"])
it "should show ancestors and descendants of matches", ->
checkFilter("Target", ["Parent", "Target", "Child"])
it "should handle multiple words regardless of order", ->
checkFilter("Following Posts", ["Posts I'm Following"])
it "should handle multiple words in different depths", ->
checkFilter("Parent Child", ["Parent", "Target", "Child"])
describe "selecting an item", ->
it "should clear the search box", ->
setupAjax()
$(".forum-nav-search-input").val("foobar")
$(".forum-nav-browse-menu-following .forum-nav-browse-title").click()
expect($(".forum-nav-search-input").val()).toEqual("")
it "should change the button text", ->
setupAjax()
$(".forum-nav-browse-menu-following .forum-nav-browse-title").click()
expect($(".forum-nav-browse-current").text()).toEqual("Posts I'm Following")
it "should show/hide the cohort selector", ->
DiscussionSpecHelper.makeModerator()
@view.render()
setupAjax()
_.each(
[
{selector: ".forum-nav-browse-menu-all", cohortVisibility: true},
{selector: ".forum-nav-browse-menu-following", cohortVisibility: false},
{
selector: ".forum-nav-browse-menu-item:has(.forum-nav-browse-menu-item .forum-nav-browse-menu-item)",
cohortVisibility: false
},
{selector: "[data-discussion-id=child]", cohortVisibility: false},
{selector: "[data-discussion-id=other]", cohortVisibility: true}
],
(itemInfo) =>
@view.$("#{itemInfo.selector} > .forum-nav-browse-title").click()
expect(@view.$(".forum-nav-filter-cohort").is(":visible")).toEqual(itemInfo.cohortVisibility)
)
testSelectionRequest = (callback, itemText) ->
setupAjax(callback)
$(".forum-nav-browse-title:contains(#{itemText})").click()
expect($.ajax).toHaveBeenCalled()
it "should get all discussions", ->
testSelectionRequest(
(params) -> expect(params.url.path()).toEqual(DiscussionUtil.urlFor("threads")),
"All"
)
it "should get followed threads", ->
testSelectionRequest(
(params) ->
expect(params.url.path()).toEqual(
DiscussionUtil.urlFor("followed_threads", window.user.id)
)
,
"Following"
)
expect($.ajax.calls.mostRecent().args[0].data.group_id).toBeUndefined();
it "should get threads for the selected leaf", ->
testSelectionRequest(
(params) ->
expect(params.url.path()).toEqual(DiscussionUtil.urlFor("search"))
expect(params.data.commentable_ids).toEqual("child")
,
"Child"
)
it "should get threads for children of the selected intermediate node", ->
testSelectionRequest(
(params) ->
expect(params.url.path()).toEqual(DiscussionUtil.urlFor("search"))
expect(params.data.commentable_ids).toEqual("child,sibling")
,
"Parent"
)

View File

@@ -1,110 +0,0 @@
# -*- coding: utf-8 -*-
describe "DiscussionThreadProfileView", ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
DiscussionSpecHelper.setUnderscoreFixtures()
@threadData = {
id: "1",
body: "dummy body",
discussion: new Discussion()
abuse_flaggers: [],
commentable_id: 'dummy_discussion',
votes: {up_count: "42"},
created_at: "2014-09-09T20:11:08Z"
}
@imageTag = '<img src="https://www.google.com.pk/images/srpr/logo11w.png">'
window.MathJax = { Hub: { Queue: -> } }
makeView = (thread) ->
view = new DiscussionThreadProfileView(model: thread)
spyConvertMath(view)
return view
makeThread = (threadData) ->
thread = new Thread(threadData)
thread.discussion = new Discussion()
return thread
spyConvertMath = (view) ->
spyOn(view, "convertMath").and.callFake( ->
@model.set('markdownBody', @model.get('body'))
)
checkPostWithImages = (numberOfImages, truncatedText, threadData, imageTag) ->
expectedHtml = '<p>'
threadData.body = '<p>'
testText = ''
expectedText = ''
if truncatedText
testText = new Array(100).join('test ')
expectedText = testText.substring(0, 139)+ ''
else
testText = 'Test body'
expectedText = 'Test body'
for i in [0..numberOfImages-1]
threadData.body = threadData.body + imageTag
if i == 0
expectedHtml = expectedHtml + imageTag
else
expectedHtml = expectedHtml + '<em>image omitted</em>'
threadData.body = threadData.body + '<em>' + testText + '</em></p>'
if numberOfImages > 1
expectedHtml = expectedHtml + '<em>' + expectedText + '</em></p><p><em>Some images in this post have been omitted</em></p>'
else
expectedHtml = expectedHtml + '<em>' + expectedText + '</em></p>'
view = makeView(makeThread(threadData))
view.render()
expect(view.$el.find(".post-body").html()).toEqual(expectedHtml)
checkBody = (truncated, view, threadData) ->
view.render()
if not truncated
expect(view.model.get("body")).toEqual(view.model.get("abbreviatedBody"))
expect(view.$el.find(".post-body").html()).toEqual(threadData.body)
else
expect(view.model.get("body")).not.toEqual(view.model.get("abbreviatedBody"))
expect(view.$el.find(".post-body").html()).not.toEqual(threadData.body)
outputHtmlStripped = view.$el.find(".post-body").html().replace(/(<([^>]+)>)/ig,"");
outputHtmlStripped = outputHtmlStripped.replace("Some images in this post have been omitted","")
outputHtmlStripped = outputHtmlStripped.replace("image omitted","")
inputHtmlStripped = threadData.body.replace(/(<([^>]+)>)/ig,"");
expectedOutput = inputHtmlStripped.substring(0, 139)+ ''
expect(outputHtmlStripped).toEqual(expectedOutput)
expect(view.$el.find(".post-body").html().indexOf("")).toBeGreaterThan(0)
describe "Body markdown should be correct", ->
it "untruncated text without markdown body", ->
@threadData.body = "Test body"
view = makeView(makeThread(@threadData))
checkBody(false, view, @threadData)
it "truncated text without markdown body", ->
@threadData.body = new Array(100).join("test ")
view = makeView(makeThread(@threadData))
checkBody(true, view, @threadData)
it "untruncated text with markdown body", ->
@threadData.body = '<p>' + @imageTag + '<em>Google top search engine</em></p>'
view = makeView(makeThread(@threadData))
checkBody(false, view, @threadData)
it "truncated text with markdown body", ->
testText = new Array(100).join("test ")
@threadData.body = '<p>' + @imageTag + @imageTag + '<em>' + testText + '</em></p>'
view = makeView(makeThread(@threadData))
checkBody(true, view, @threadData)
for numImages in [1, 2, 10]
for truncatedText in [true, false]
it "body with #{numImages} images and #{if truncatedText then "truncated" else "untruncated"} text", ->
checkPostWithImages(numImages, truncatedText, @threadData, @imageTag)
it "check the thread retrieve url", ->
thread = makeThread(@threadData)
expect(thread.urlFor('retrieve')).toBe('/courses/edX/999/test/discussion/forum/dummy_discussion/threads/1')

View File

@@ -1,159 +0,0 @@
describe "DiscussionThreadShowView", ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
DiscussionSpecHelper.setUnderscoreFixtures()
@user = DiscussionUtil.getUser()
@threadData = {
id: "dummy",
user_id: @user.id,
username: @user.get('username'),
course_id: $$course_id,
title: "dummy title",
body: "this is a thread",
created_at: "2013-04-03T20:08:39Z",
abuse_flaggers: [],
votes: {up_count: 42},
thread_type: "discussion",
closed: false,
pinned: false,
type: "thread" # TODO - silly that this needs to be explicitly set
}
@thread = new Thread(@threadData)
@view = new DiscussionThreadShowView({ model: @thread })
@view.setElement($("#fixture-element"))
spyOn(@view, "convertMath")
describe "voting", ->
it "renders the vote state correctly", ->
DiscussionViewSpecHelper.checkRenderVote(@view, @thread)
it "votes correctly via click", ->
DiscussionViewSpecHelper.checkUpvote(@view, @thread, @user, $.Event("click"))
it "votes correctly via spacebar", ->
DiscussionViewSpecHelper.checkUpvote(@view, @thread, @user, $.Event("keydown", {which: 32}))
it "unvotes correctly via click", ->
DiscussionViewSpecHelper.checkUnvote(@view, @thread, @user, $.Event("click"))
it "unvotes correctly via spacebar", ->
DiscussionViewSpecHelper.checkUnvote(@view, @thread, @user, $.Event("keydown", {which: 32}))
describe "pinning", ->
expectPinnedRendered = (view, model) ->
pinned = model.get('pinned')
button = view.$el.find(".action-pin")
expect(button.hasClass("is-checked")).toBe(pinned)
expect(button.attr("aria-checked")).toEqual(pinned.toString())
it "renders the pinned state correctly", ->
@view.render()
expectPinnedRendered(@view, @thread)
@thread.set('pinned', false)
@view.render()
expectPinnedRendered(@view, @thread)
@thread.set('pinned', true)
@view.render()
expectPinnedRendered(@view, @thread)
it "exposes the pinning control only to authorized users", ->
@thread.updateInfo({ability: {can_openclose: false}})
@view.render()
expect(@view.$el.find(".action-pin").closest(".is-hidden")).toExist()
@thread.updateInfo({ability: {can_openclose: true}})
@view.render()
expect(@view.$el.find(".action-pin").closest(".is-hidden")).not.toExist()
it "handles events correctly", ->
@view.render()
DiscussionViewSpecHelper.checkButtonEvents(@view, "togglePin", ".action-pin")
describe "labels", ->
expectOneElement = (view, selector, visible=true) =>
view.render()
elements = view.$el.find(selector)
expect(elements.length).toEqual(1)
if visible
expect(elements).not.toHaveClass("is-hidden")
else
expect(elements).toHaveClass("is-hidden")
it 'displays the closed label when appropriate', ->
expectOneElement(@view, '.post-label-closed', false)
@thread.set('closed', true)
expectOneElement(@view, '.post-label-closed')
it 'displays the pinned label when appropriate', ->
expectOneElement(@view, '.post-label-pinned', false)
@thread.set('pinned', true)
expectOneElement(@view, '.post-label-pinned')
it 'displays the reported label when appropriate for a non-staff user', ->
expectOneElement(@view, '.post-label-reported', false)
# flagged by current user - should be labelled
@thread.set('abuse_flaggers', [DiscussionUtil.getUser().id])
expectOneElement(@view, '.post-label-reported')
# flagged by some other user but not the current one - should not be labelled
@thread.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
expectOneElement(@view, '.post-label-reported', false)
it 'displays the reported label when appropriate for a flag moderator', ->
DiscussionSpecHelper.makeModerator()
expectOneElement(@view, '.post-label-reported', false)
# flagged by current user - should be labelled
@thread.set('abuse_flaggers', [DiscussionUtil.getUser().id])
expectOneElement(@view, '.post-label-reported')
# flagged by some other user but not the current one - should still be labelled
@thread.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
expectOneElement(@view, '.post-label-reported')
describe "author display", ->
beforeEach ->
@thread.set('user_url', 'test_user_url')
checkUserLink = (element, is_ta, is_staff) ->
expect(element.find('a.username').length).toEqual(1)
expect(element.find('a.username').text()).toEqual('test_user')
expect(element.find('a.username').attr('href')).toEqual('test_user_url')
expect(element.find('.user-label-community-ta').length).toEqual(if is_ta then 1 else 0)
expect(element.find('.user-label-staff').length).toEqual(if is_staff then 1 else 0)
it "renders correctly for a student-authored thread", ->
$el = $('#fixture-element').html(@view.getAuthorDisplay())
checkUserLink($el, false, false)
it "renders correctly for a community TA-authored thread", ->
@thread.set('community_ta_authored', true)
$el = $('#fixture-element').html(@view.getAuthorDisplay())
checkUserLink($el, true, false)
it "renders correctly for a staff-authored thread", ->
@thread.set('staff_authored', true)
$el = $('#fixture-element').html(@view.getAuthorDisplay())
checkUserLink($el, false, true)
it "renders correctly for an anonymously-authored thread", ->
@thread.set('username', null)
$el = $('#fixture-element').html(@view.getAuthorDisplay())
expect($el.find('a.username').length).toEqual(0)
expect($el.text()).toMatch(/^(\s*)anonymous(\s*)$/)
describe "cohorting", ->
it "renders correctly for an uncohorted thread", ->
@view.render()
expect(@view.$('.group-visibility-label').text().trim()).toEqual(
'This post is visible to everyone.'
)
it "renders correctly for a cohorted thread", ->
@thread.set('group_id', '1')
@thread.set('group_name', 'Mock Cohort')
@view.render()
expect(@view.$('.group-visibility-label').text().trim()).toEqual(
'This post is visible only to Mock Cohort.'
)

View File

@@ -1,410 +0,0 @@
describe "DiscussionThreadView", ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
DiscussionSpecHelper.setUnderscoreFixtures()
jasmine.clock().install()
@threadData = DiscussionViewSpecHelper.makeThreadWithProps({})
@thread = new Thread(@threadData)
@discussion = new Discussion(@thread)
deferred = $.Deferred();
spyOn($, "ajax").and.returnValue(deferred);
# Avoid unnecessary boilerplate
spyOn(DiscussionThreadShowView.prototype, "convertMath")
spyOn(DiscussionContentView.prototype, "makeWmdEditor")
spyOn(DiscussionUtil, "makeWmdEditor")
spyOn(DiscussionUtil, "setWmdContent")
spyOn(ThreadResponseShowView.prototype, "convertMath")
afterEach ->
$.ajax.calls.reset()
jasmine.clock().uninstall()
renderWithContent = (view, content) ->
$.ajax.and.callFake((params) =>
params.success(
createAjaxResponseJson(content, false),
'success'
)
{always: ->}
)
view.render()
jasmine.clock().tick(100)
renderWithTestResponses = (view, count, options) ->
renderWithContent(
view,
_.extend(
{
resp_total: count,
children: if count > 0 then (createTestResponseJson(index) for index in [1..count]) else []
},
options
)
)
createTestResponseJson = (index) ->
{
user_id: window.user.id,
body: "Response " + index,
id: "id_" + index,
created_at: "2015-01-01T22:20:28Z"
}
assertContentVisible = (view, selector, visible) ->
content = view.$el.find(selector)
expect(content.length).toBeGreaterThan(0)
content.each (i, elem) ->
expect($(elem).is(":visible")).toEqual(visible)
assertExpandedContentVisible = (view, expanded) ->
expect(view.$el.hasClass("expanded")).toEqual(expanded)
assertContentVisible(view, ".post-extended-content", expanded)
assertContentVisible(view, ".forum-thread-expand", not expanded)
assertContentVisible(view, ".forum-thread-collapse", expanded)
assertResponseCountAndPaginationCorrect = (view, countText, displayCountText, buttonText) ->
expect(view.$el.find(".response-count").text()).toEqual(countText)
if displayCountText
expect(view.$el.find(".response-display-count").text()).toEqual(displayCountText)
else
expect(view.$el.find(".response-display-count").length).toEqual(0)
if buttonText
expect(view.$el.find(".load-response-button").text()).toEqual(buttonText)
else
expect(view.$el.find(".load-response-button").length).toEqual(0)
createAjaxResponseJson = (content, can_act) ->
{
content: content,
annotated_content_info: {
ability: {
editable: can_act,
can_delete: can_act,
can_reply: can_act,
can_vote: can_act
}
}
}
postResponse = (view, index) ->
testResponseJson = createTestResponseJson(index)
responseText = testResponseJson.body
spyOn(view, "getWmdContent").and.returnValue(responseText)
$.ajax.and.callFake((params) =>
expect(params.type).toEqual("POST")
expect(params.data.body).toEqual(responseText)
params.success(
createAjaxResponseJson(testResponseJson, true),
'success'
)
{always: ->}
)
view.$(".discussion-submit-post").click()
describe "closed and open Threads", ->
createDiscussionThreadView = (originallyClosed, mode) ->
threadData = DiscussionViewSpecHelper.makeThreadWithProps({closed: originallyClosed})
thread = new Thread(threadData)
discussion = new Discussion(thread)
view = new DiscussionThreadView(
model: thread
el: $("#fixture-element")
mode: mode
course_settings: DiscussionSpecHelper.makeCourseSettings()
)
renderWithTestResponses(view, 1)
if mode == "inline"
view.expand()
spyOn(DiscussionUtil, "updateWithUndo").and.callFake(
(model, updates, safeAjaxParams, errorMsg) ->
model.set(updates)
)
view
checkCommentForm = (originallyClosed, mode) ->
view = createDiscussionThreadView(originallyClosed, mode)
expect(view.$('.comment-form').closest('li').is(":visible")).toBe(not originallyClosed)
expect(view.$(".discussion-reply-new").is(":visible")).toBe(not originallyClosed)
view.$(".action-close").click()
expect(view.$('.comment-form').closest('li').is(":visible")).toBe(originallyClosed)
expect(view.$(".discussion-reply-new").is(":visible")).toBe(originallyClosed)
checkVoteDisplay = (originallyClosed, mode) ->
view = createDiscussionThreadView(originallyClosed, mode)
expect(view.$('.thread-main-wrapper .action-vote').is(":visible")).toBe(not originallyClosed)
expect(view.$('.thread-main-wrapper .display-vote').is(":visible")).toBe(originallyClosed)
view.$(".action-close").click()
expect(view.$('.action-vote').is(":visible")).toBe(originallyClosed)
expect(view.$('.display-vote').is(":visible")).toBe(not originallyClosed)
_.each(["tab", "inline"], (mode) =>
it "Test that in #{mode} mode when a closed thread is opened the comment form is displayed", ->
checkCommentForm(true, mode)
it "Test that in #{mode} mode when a open thread is closed the comment form is hidden", ->
checkCommentForm(false, mode)
it "Test that in #{mode} mode when a closed thread is opened the vote button is displayed and vote count is hidden", ->
checkVoteDisplay(true, mode)
it "Test that in #{mode} mode when a open thread is closed the vote button is hidden and vote count is displayed", ->
checkVoteDisplay(false, mode)
)
describe "tab mode", ->
beforeEach ->
@view = new DiscussionThreadView(
model: @thread
el: $("#fixture-element")
mode: "tab"
course_settings: DiscussionSpecHelper.makeCourseSettings()
)
describe "responses", ->
it "can post a first response", ->
# Initially render a test post (made by someone else) with zero responses
renderWithTestResponses(@view, 0)
postResponse(@view, 1)
expect(@view.$(".forum-response").length).toBe(1)
# At this point, there are 2 DiscussionContentViews, the main post and the response.
# Each an .action-edit button, but only 1 (the response) should be available.
expect(@view.$(".post-actions-list").find(".action-edit").parent(".is-hidden").length).toBe(1)
expect(@view.$(".response-actions-list").find(".action-edit").parent().not(".is-hidden").length).toBe(1)
it "can post a second response", ->
# Initially render a test post (made by someone else) with a single response (made by the current learner)
renderWithTestResponses(@view, 1)
expect(@view.$(".forum-response").length).toBe(1)
# Post should not be editable, response should be
expect(@view.$(".post-actions-list").find(".action-edit").parent(".is-hidden").length).toBe(1)
expect(@view.$(".response-actions-list").find(".action-edit").parent().not(".is-hidden").length).toBe(1)
# Now make a second response. Prior to TNL-3788, a bug would hide the edit button for the first response
postResponse(@view, 2)
expect(@view.$(".forum-response").length).toBe(2)
# Post should not be editable, responses should be
expect(@view.$(".post-actions-list").find(".action-edit").parent(".is-hidden").length).toBe(1)
expect(@view.$(".response-actions-list").find(".action-edit").parent().not(".is-hidden").length).toBe(2)
describe "response count and pagination", ->
it "correctly render for a thread with no responses", ->
renderWithTestResponses(@view, 0)
assertResponseCountAndPaginationCorrect(@view, "0 responses", null, null)
it "correctly render for a thread with one response", ->
renderWithTestResponses(@view, 1)
assertResponseCountAndPaginationCorrect(@view, "1 response", "Showing all responses", null)
it "correctly render for a thread with one additional page", ->
renderWithTestResponses(@view, 1, {resp_total: 2})
assertResponseCountAndPaginationCorrect(@view, "2 responses", "Showing first response", "Load all responses")
it "correctly render for a thread with multiple additional pages", ->
renderWithTestResponses(@view, 2, {resp_total: 111})
assertResponseCountAndPaginationCorrect(@view, "111 responses", "Showing first 2 responses", "Load next 100 responses")
describe "on clicking the load more button", ->
beforeEach ->
renderWithTestResponses(@view, 1, {resp_total: 5})
assertResponseCountAndPaginationCorrect(@view, "5 responses", "Showing first response", "Load all responses")
it "correctly re-render when all threads have loaded", ->
renderWithTestResponses(@view, 5, {resp_total: 5})
@view.$el.find(".load-response-button").click()
assertResponseCountAndPaginationCorrect(@view, "5 responses", "Showing all responses", null)
it "correctly re-render when one page remains", ->
renderWithTestResponses(@view, 3, {resp_total: 42})
@view.$el.find(".load-response-button").click()
assertResponseCountAndPaginationCorrect(@view, "42 responses", "Showing first 3 responses", "Load all responses")
it "correctly re-render when multiple pages remain", ->
renderWithTestResponses(@view, 3, {resp_total: 111})
@view.$el.find(".load-response-button").click()
assertResponseCountAndPaginationCorrect(@view, "111 responses", "Showing first 3 responses", "Load next 100 responses")
describe "inline mode", ->
beforeEach ->
@view = new DiscussionThreadView(
model: @thread
el: $("#fixture-element")
mode: "inline"
course_settings: DiscussionSpecHelper.makeCourseSettings()
)
describe "render", ->
it "shows content that should be visible when collapsed", ->
@view.render()
assertExpandedContentVisible(@view, false)
it "does not render any responses by default", ->
@view.render()
expect($.ajax).not.toHaveBeenCalled()
expect(@view.$el.find(".responses li").length).toEqual(0)
describe "focus", ->
it "sends focus to the conversation when opened", (done) ->
DiscussionViewSpecHelper.setNextResponseContent({resp_total: 0, children: []})
@view.render()
@view.expand()
self = @
jasmine.waitUntil(->
# This is the implementation of "toBeFocused". However, simply calling that method
# with no wait seems to be flaky.
article = self.view.$el.find('.discussion-article')
return article[0] == article[0].ownerDocument.activeElement
).then ->
done()
describe "expand/collapse", ->
it "shows/hides appropriate content", ->
DiscussionViewSpecHelper.setNextResponseContent({resp_total: 0, children: []})
@view.render()
@view.expand()
assertExpandedContentVisible(@view, true)
@view.collapse()
assertExpandedContentVisible(@view, false)
it "switches between the abbreviated and full body", ->
DiscussionViewSpecHelper.setNextResponseContent({resp_total: 0, children: []})
longBody = new Array(100).join("test ")
expectedAbbreviation = DiscussionUtil.abbreviateString(longBody, 140)
@thread.set("body", longBody)
@view.render()
expect($(".post-body").text()).toEqual(expectedAbbreviation)
expect(DiscussionThreadShowView.prototype.convertMath).toHaveBeenCalled()
DiscussionThreadShowView.prototype.convertMath.calls.reset()
@view.expand()
expect($(".post-body").text()).toEqual(longBody)
expect(DiscussionThreadShowView.prototype.convertMath).toHaveBeenCalled()
DiscussionThreadShowView.prototype.convertMath.calls.reset()
@view.collapse()
expect($(".post-body").text()).toEqual(expectedAbbreviation)
expect(DiscussionThreadShowView.prototype.convertMath).toHaveBeenCalled()
it "strips script tags appropriately", ->
DiscussionViewSpecHelper.setNextResponseContent({resp_total: 0, children: []})
longMaliciousBody = new Array(100).join("<script>alert('Until they think warm days will never cease');</script>\n")
@thread.set("body", longMaliciousBody)
maliciousAbbreviation = DiscussionUtil.abbreviateString(@thread.get('body'), 140)
# The nodes' html should be different than the strings, but
# their texts should be the same, indicating that they've been
# properly escaped. To be safe, make sure the string "<script"
# isn't present, either
@view.render()
expect($(".post-body").html()).not.toEqual(maliciousAbbreviation)
expect($(".post-body").text()).toEqual(maliciousAbbreviation)
expect($(".post-body").html()).not.toContain("<script")
@view.expand()
expect($(".post-body").html()).not.toEqual(longMaliciousBody)
expect($(".post-body").text()).toEqual(longMaliciousBody)
expect($(".post-body").html()).not.toContain("<script")
@view.collapse()
expect($(".post-body").html()).not.toEqual(maliciousAbbreviation)
expect($(".post-body").text()).toEqual(maliciousAbbreviation)
expect($(".post-body").html()).not.toContain("<script")
it "re-renders the show view correctly when leaving the edit view", ->
DiscussionViewSpecHelper.setNextResponseContent({resp_total: 0, children: []})
@view.render()
@view.expand()
assertExpandedContentVisible(@view, true)
@view.edit()
assertContentVisible(@view, ".edit-post-body", true)
expect(@view.$el.find(".post-actions-list").length).toBe(0)
@view.closeEditView(DiscussionSpecHelper.makeEventSpy())
expect(@view.$el.find(".edit-post-body").length).toBe(0)
assertContentVisible(@view, ".post-actions-list", true)
describe "for question threads", ->
beforeEach ->
@thread.set("thread_type", "question")
@view = new DiscussionThreadView(
model: @thread
el: $("#fixture-element")
mode: "tab"
course_settings: DiscussionSpecHelper.makeCourseSettings()
)
generateContent = (idStart, idEnd) ->
_.map(_.range(idStart, idEnd), (i) -> createTestResponseJson(i))
renderTestCase = (view, numEndorsed, numNonEndorsed) ->
renderWithContent(
view,
{
endorsed_responses: generateContent(0, numEndorsed),
non_endorsed_responses: generateContent(numEndorsed, numEndorsed + numNonEndorsed),
non_endorsed_resp_total: numNonEndorsed
}
)
expect(view.$(".js-marked-answer-list .discussion-response").length).toEqual(numEndorsed)
expect(view.$(".js-response-list .discussion-response").length).toEqual(numNonEndorsed)
assertResponseCountAndPaginationCorrect(
view,
"#{numNonEndorsed} #{if numEndorsed then "other " else ""}#{if numNonEndorsed == 1 then "response" else "responses"}",
if numNonEndorsed then "Showing all responses" else null,
null
)
_.each({"no": 0, "one": 1, "many": 5}, (numEndorsed, endorsedDesc) ->
_.each({"no": 0, "one": 1, "many": 5}, (numNonEndorsed, nonEndorsedDesc) ->
it "renders correctly with #{endorsedDesc} marked answer(s) and #{nonEndorsedDesc} response(s)", ->
renderTestCase(@view, numEndorsed, numNonEndorsed)
)
)
it "handles pagination correctly", ->
renderWithContent(
@view,
{
endorsed_responses: generateContent(0, 2),
non_endorsed_responses: generateContent(3, 6),
non_endorsed_resp_total: 42
}
)
DiscussionViewSpecHelper.setNextResponseContent({
# Add an endorsed response; it should be rendered
endorsed_responses: generateContent(0, 3),
non_endorsed_responses: generateContent(6, 9),
non_endorsed_resp_total: 41
})
@view.$el.find(".load-response-button").click()
expect($(".js-marked-answer-list .discussion-response").length).toEqual(3)
expect($(".js-response-list .discussion-response").length).toEqual(6)
assertResponseCountAndPaginationCorrect(
@view,
"41 other responses",
"Showing first 6 responses",
"Load all responses"
)
describe "post restrictions", ->
beforeEach ->
@thread.attributes.ability = _.extend(@thread.attributes.ability, {
can_report: false
can_vote: false
})
@view = new DiscussionThreadView(
model: @thread
el: $("#fixture-element")
mode: "tab"
course_settings: DiscussionSpecHelper.makeCourseSettings()
)
it "doesn't show report option if can_report ability is disabled", ->
@view.render()
expect(@view.$el.find(".action-report").closest(".actions-item")).toHaveClass('is-hidden')
it "doesn't show voting button if can_vote ability is disabled", ->
@view.render()
expect(@view.$el.find(".action-vote").closest(".actions-item")).toHaveClass('is-hidden')

View File

@@ -1,221 +0,0 @@
describe "DiscussionUserProfileView", ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
DiscussionSpecHelper.setUnderscoreFixtures()
spyOn(DiscussionThreadProfileView.prototype, "render")
makeThreads = (numThreads) ->
_.map(_.range(numThreads), (i) -> {id: i.toString(), body: "dummy body"})
makeView = (threads, page, numPages) ->
new DiscussionUserProfileView(
collection: threads
page: page
numPages: numPages
)
describe "thread rendering should be correct", ->
checkRender = (numThreads) ->
threads = makeThreads(numThreads)
view = makeView(threads, 1, 1)
expect(view.$(".discussion").children().length).toEqual(numThreads)
_.each(threads, (thread) -> expect(view.$("#thread_#{thread.id}").length).toEqual(1))
it "with no threads", ->
checkRender(0)
it "with one thread", ->
checkRender(1)
it "with several threads", ->
checkRender(5)
describe "pagination rendering should be correct", ->
baseUri = URI(window.location)
pageInfo = (page) -> {url: baseUri.clone().addSearch("page", page).toString(), number: page}
checkRender = (params) ->
view = makeView([], params.page, params.numPages)
paginator = view.$(".discussion-paginator")
expect(paginator.find(".current-page").text()).toEqual(params["page"].toString())
expect(paginator.find(".first-page").length).toBe(if params["first"] then 1 else 0);
expect(paginator.find(".previous-page").length).toBe(if params["previous"] then 1 else 0);
expect(paginator.find(".previous-ellipses").length).toBe(if params["leftdots"] then 1 else 0);
expect(paginator.find(".next-page").length).toBe(if params["next"] then 1 else 0);
expect(paginator.find(".next-ellipses").length).toBe(if params["rightdots"] then 1 else 0);
expect(paginator.find(".last-page").length).toBe(if params["last"] then 1 else 0);
get_page_number = (element) => parseInt($(element).text())
expect(_.map(paginator.find(".lower-page a"), get_page_number)).toEqual(params["lowPages"])
expect(_.map(paginator.find(".higher-page a"), get_page_number)).toEqual(params["highPages"])
it "for one page", ->
checkRender(
page: 1
numPages: 1
previous: null
first: null
leftdots: false
lowPages: []
highPages: []
rightdots: false
last: null
next: null
)
it "for first page of three (max with no last)", ->
checkRender(
page: 1
numPages: 3
previous: null
first: null
leftdots: false
lowPages: []
highPages: [2, 3]
rightdots: false
last: null
next: 2
)
it "for first page of four (has last but no dots)", ->
checkRender(
page: 1
numPages: 4
previous: null
first: null
leftdots: false
lowPages: []
highPages: [2, 3]
rightdots: false
last: 4
next: 2
)
it "for first page of five (has dots)", ->
checkRender(
page: 1
numPages: 5
previous: null
first: null
leftdots: false
lowPages: []
highPages: [2, 3]
rightdots: true
last: 5
next: 2
)
it "for last page of three (max with no first)", ->
checkRender(
page: 3
numPages: 3
previous: 2
first: null
leftdots: false
lowPages: [1, 2]
highPages: []
rightdots: false
last: null
next: null
)
it "for last page of four (has first but no dots)", ->
checkRender(
page: 4
numPages: 4
previous: 3
first: 1
leftdots: false
lowPages: [2, 3]
highPages: []
rightdots: false
last: null
next: null
)
it "for last page of five (has dots)", ->
checkRender(
page: 5
numPages: 5
previous: 4
first: 1
leftdots: true
lowPages: [3, 4]
highPages: []
rightdots: false
last: null
next: null
)
it "for middle page of five (max with no first/last)", ->
checkRender(
page: 3
numPages: 5
previous: 2
first: null
leftdots: false
lowPages: [1, 2]
highPages: [4, 5]
rightdots: false
last: null
next: 4
)
it "for middle page of seven (has first/last but no dots)", ->
checkRender(
page: 4
numPages: 7
previous: 3
first: 1
leftdots: false
lowPages: [2, 3]
highPages: [5, 6]
rightdots: false
last: 7
next: 5
)
it "for middle page of nine (has dots)", ->
checkRender(
page: 5
numPages: 9
previous: 4
first: 1
leftdots: true
lowPages: [3, 4]
highPages: [6, 7]
rightdots: true
last: 9
next: 6
)
describe "pagination interaction", ->
beforeEach ->
@view = makeView(makeThreads(3), 1, 2)
deferred = $.Deferred();
spyOn($, "ajax").and.returnValue(deferred);
it "causes updated rendering", ->
$.ajax.and.callFake(
(params) =>
params.success(
discussion_data: [{id: "on_page_42", body: "dummy body"}]
page: 42
num_pages: 99
)
{always: ->}
)
@view.$(".discussion-pagination a").first().click()
expect(@view.$(".current-page").text()).toEqual("42")
expect(@view.$(".last-page").text()).toEqual("99")
it "handles AJAX errors", ->
spyOn(DiscussionUtil, "discussionAlert")
$.ajax.and.callFake(
(params) =>
params.error()
{always: ->}
)
@view.$(".discussion-pagination a").first().click()
expect(DiscussionUtil.discussionAlert).toHaveBeenCalled()

View File

@@ -1,98 +0,0 @@
class @DiscussionViewSpecHelper
@makeThreadWithProps = (props) ->
# Minimal set of properties necessary for rendering
thread = {
id: "dummy_id",
thread_type: "discussion",
pinned: false,
endorsed: false,
votes: {up_count: '0'},
read: false,
unread_comments_count: 0,
comments_count: 0,
abuse_flaggers: [],
body: "",
title: "dummy title",
created_at: "2014-08-18T01:02:03Z"
ability: {
can_delete: false,
can_reply: true,
can_vote: false,
editable: false,
}
}
$.extend(thread, props)
@checkVoteClasses = (view) ->
view.render()
display_button = view.$el.find(".display-vote")
expect(display_button.hasClass("is-hidden")).toBe(true)
action_button = view.$el.find(".action-vote")
# Check that inline css is not applied to the ".action-vote"
expect(action_button).not.toHaveAttr('style','display: inline; ');
@expectVoteRendered = (view, model, user) ->
button = view.$el.find(".action-vote")
expect(button.hasClass("is-checked")).toBe(user.voted(model))
expect(button.attr("aria-checked")).toEqual(user.voted(model).toString())
expect(button.find(".vote-count").text()).toMatch("^#{model.get('votes').up_count} Votes?$")
expect(button.find(".sr.js-sr-vote-count").text()).toMatch("^there are currently #{model.get('votes').up_count} votes?$")
@checkRenderVote = (view, model) ->
view.render()
DiscussionViewSpecHelper.expectVoteRendered(view, model, window.user)
window.user.vote(model)
view.render()
DiscussionViewSpecHelper.expectVoteRendered(view, model, window.user)
window.user.unvote(model)
view.render()
DiscussionViewSpecHelper.expectVoteRendered(view, model, window.user)
triggerVoteEvent = (view, event, expectedUrl) ->
deferred = $.Deferred()
spyOn($, "ajax").and.callFake((params) =>
expect(params.url.toString()).toEqual(expectedUrl)
return deferred
)
view.render()
view.$el.find(".action-vote").trigger(event)
expect($.ajax).toHaveBeenCalled()
deferred.resolve()
@checkUpvote = (view, model, user, event) ->
expect(model.id in user.get('upvoted_ids')).toBe(false)
initialVoteCount = model.get('votes').up_count
triggerVoteEvent(view, event, DiscussionUtil.urlFor("upvote_#{model.get('type')}", model.id) + "?ajax=1")
expect(model.id in user.get('upvoted_ids')).toBe(true)
expect(model.get('votes').up_count).toEqual(initialVoteCount + 1)
@checkUnvote = (view, model, user, event) ->
user.vote(model)
expect(model.id in user.get('upvoted_ids')).toBe(true)
initialVoteCount = model.get('votes').up_count
triggerVoteEvent(view, event, DiscussionUtil.urlFor("undo_vote_for_#{model.get('type')}", model.id) + "?ajax=1")
expect(user.get('upvoted_ids')).toEqual([])
expect(model.get('votes').up_count).toEqual(initialVoteCount - 1)
@checkButtonEvents = (view, viewFunc, buttonSelector) ->
spy = spyOn(view, viewFunc)
button = view.$el.find(buttonSelector)
button.click()
expect(spy).toHaveBeenCalled()
spy.calls.reset()
button.trigger($.Event("keydown", {which: 13}))
expect(spy).not.toHaveBeenCalled()
spy.calls.reset()
button.trigger($.Event("keydown", {which: 32}))
expect(spy).toHaveBeenCalled()
@checkVoteButtonEvents = (view) ->
@checkButtonEvents(view, "toggleVote", ".action-vote")
@setNextResponseContent = (content) ->
$.ajax.and.callFake(
(params) =>
params.success({"content": content})
{always: ->}
)

View File

@@ -1,229 +0,0 @@
# -*- coding: utf-8 -*-
describe "NewPostView", ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
DiscussionSpecHelper.setUnderscoreFixtures()
window.$$course_id = "edX/999/test"
spyOn(DiscussionUtil, "makeWmdEditor").and.callFake(
($content, $local, cls_identifier) ->
$local("." + cls_identifier).html("<textarea></textarea>")
)
@discussion = new Discussion([], {pages: 1})
checkVisibility = (view, expectedVisible, expectedDisabled, render) =>
if render
view.render()
# Can also be undefined if the element does not exist.
expect(view.$('.group-selector-wrapper').is(":visible") or false).toEqual(expectedVisible)
disabled = view.$(".js-group-select").prop("disabled") or false
group_disabled = view.$('.group-selector-wrapper').hasClass('disabled')
if expectedVisible and !expectedDisabled
expect(disabled).toEqual(false)
expect(group_disabled).toEqual(false)
else if expectedDisabled
expect(disabled).toEqual(true)
expect(group_disabled).toEqual(true)
describe "cohort selector", ->
beforeEach ->
@course_settings = new DiscussionCourseSettings({
"category_map": {
"children": ["Topic", "General"],
"entries": {
"Topic": {"is_cohorted": true, "id": "topic"},
"General": {"is_cohorted": false, "id": "general"}
}
},
"allow_anonymous": false,
"allow_anonymous_to_peers": false,
"is_cohorted": true,
"cohorts": [
{"id": 1, "name": "Cohort1"},
{"id": 2, "name": "Cohort2"}
]
})
@view = new NewPostView(
el: $("#fixture-element"),
collection: @discussion,
course_settings: @course_settings,
is_commententable_cohorted: true,
mode: "tab"
)
it "is not visible to students", ->
checkVisibility(@view, false, false, true)
it "allows TAs to see the cohort selector", ->
DiscussionSpecHelper.makeTA()
checkVisibility(@view, true, false, true)
it "allows moderators to see the cohort selector", ->
DiscussionSpecHelper.makeModerator()
checkVisibility(@view, true, false, true)
it "only enables the cohort selector when applicable", ->
DiscussionSpecHelper.makeModerator()
# We start on the cohorted discussion
checkVisibility(@view, true, false, true)
# Select the uncohorted topic
$('.topic-title:contains(General)').click()
# The menu should now be visible but disabled.
checkVisibility(@view, true, true, false)
# Select the cohorted topic again
$('.topic-title:contains(Topic)').click()
# It should be visible and enabled once more.
checkVisibility(@view, true, false, false)
it "allows the user to make a cohort selection", ->
DiscussionSpecHelper.makeModerator()
@view.render()
expectedGroupId = null
DiscussionSpecHelper.makeAjaxSpy(
(params) -> expect(params.data.group_id).toEqual(expectedGroupId)
)
_.each(
["1", "2", ""],
(groupIdStr) =>
expectedGroupId = groupIdStr
@view.$(".js-group-select").val(groupIdStr)
@view.$(".js-post-title").val("dummy title")
@view.$(".js-post-body textarea").val("dummy body")
@view.$(".forum-new-post-form").submit()
expect($.ajax).toHaveBeenCalled()
$.ajax.calls.reset()
)
describe "always cohort inline discussions ", ->
beforeEach ->
@course_settings = new DiscussionCourseSettings({
"category_map": {
"children": [],
"entries": {}
},
"allow_anonymous": false,
"allow_anonymous_to_peers": false,
"is_cohorted": true,
"cohorts": [
{"id": 1, "name": "Cohort1"},
{"id": 2, "name": "Cohort2"}
]
})
@view = new NewPostView(
el: $("#fixture-element"),
collection: @discussion,
course_settings: @course_settings,
mode: "tab"
)
it "disables the cohort menu if it is set false", ->
DiscussionSpecHelper.makeModerator()
@view.is_commentable_cohorted = false
checkVisibility(@view, true, true, true)
it "enables the cohort menu if it is set true", ->
DiscussionSpecHelper.makeModerator()
@view.is_commentable_cohorted = true
checkVisibility(@view, true, false, true)
it "is not visible to students when set false", ->
@view.is_commentable_cohorted = false
checkVisibility(@view, false, false, true)
it "is not visible to students when set true", ->
@view.is_commentable_cohorted = true
checkVisibility(@view, false, false, true)
describe "cancel post resets form ", ->
beforeEach ->
@course_settings = new DiscussionCourseSettings({
"allow_anonymous_to_peers":true,
"allow_anonymous":true,
"category_map": {
"subcategories": {
"Week 1": {
"subcategories": {},
"children": [
"Topic-Level Student-Visible Label"
],
"entries": {
"Topic-Level Student-Visible Label": {
"sort_key": null,
"is_cohorted": false,
"id": "2b3a858d0c884eb4b272dbbe3f2ffddd"
}
}
}
},
"children": [
"General",
"Week 1"
],
"entries": {
"General": {
"sort_key": "General",
"is_cohorted": false,
"id": "i4x-waqastest-waqastest-course-waqastest"
}
}
}
})
checkPostCancelReset = (mode, discussion, course_settings) ->
view = new NewPostView(
el: $("#fixture-element"),
collection: discussion,
course_settings: course_settings,
mode: mode
)
view.render()
eventSpy = jasmine.createSpy('eventSpy')
view.listenTo(view, "newPost:cancel", eventSpy)
view.$(".post-errors").html("<li class='post-error'>Title can't be empty</li>")
view.$("label[for$='post-type-question']").click()
view.$(".js-post-title").val("Test Title")
view.$(".js-post-body textarea").val("Test body")
view.$(".wmd-preview p").html("Test body")
view.$(".js-follow").prop("checked", false)
view.$(".js-anon").prop("checked", true)
view.$(".js-anon-peers").prop("checked", true)
if mode == "tab"
view.$("a[data-discussion-id='2b3a858d0c884eb4b272dbbe3f2ffddd']").click()
view.$(".cancel").click()
expect(eventSpy).toHaveBeenCalled()
expect(view.$(".post-errors").html()).toEqual("");
expect($("input[id$='post-type-discussion']")).toBeChecked()
expect($("input[id$='post-type-question']")).not.toBeChecked()
expect(view.$(".js-post-title").val()).toEqual("");
expect(view.$(".js-post-body textarea").val()).toEqual("");
expect(view.$(".js-follow")).toBeChecked()
expect(view.$(".js-anon")).not.toBeChecked()
expect(view.$(".js-anon-peers")).not.toBeChecked()
if mode == "tab"
expect(view.$(".js-selected-topic").text()).toEqual("General")
_.each(["tab", "inline"], (mode) =>
it "resets the form in #{mode} mode", ->
checkPostCancelReset(mode, @discussion, @course_settings)
)
it "posts to the correct URL", ->
topicId = "test_topic"
spyOn($, "ajax").and.callFake(
(params) ->
expect(params.url.path()).toEqual(DiscussionUtil.urlFor("create_thread", topicId))
{always: ->}
)
view = new NewPostView(
el: $("#fixture-element"),
collection: @discussion,
course_settings: new DiscussionCourseSettings({
allow_anonymous: false,
allow_anonymous_to_peers: false
}),
mode: "inline",
topicId: topicId
)
view.render()
view.$(".forum-new-post-form").submit()
expect($.ajax).toHaveBeenCalled()

View File

@@ -1,102 +0,0 @@
describe 'ResponseCommentShowView', ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
# set up the container for the response to go in
DiscussionSpecHelper.setUnderscoreFixtures()
# set up a model for a new Comment
@comment = new Comment {
id: '01234567',
user_id: '567',
course_id: 'edX/999/test',
body: 'this is a response',
created_at: '2013-04-03T20:08:39Z',
abuse_flaggers: ['123']
roles: []
}
@view = new ResponseCommentShowView({ model: @comment })
spyOn(@view, "convertMath")
it 'defines the tag', ->
expect($('#jasmine-fixtures')).toExist
expect(@view.tagName).toBeDefined
expect(@view.el.tagName.toLowerCase()).toBe 'li'
it 'is tied to the model', ->
expect(@view.model).toBeDefined()
describe 'rendering', ->
beforeEach ->
spyOn(@view, 'renderAttrs')
it 'can be flagged for abuse', ->
@comment.flagAbuse()
expect(@comment.get 'abuse_flaggers').toEqual ['123', '567']
it 'can be unflagged for abuse', ->
temp_array = []
temp_array.push(window.user.get('id'))
@comment.set("abuse_flaggers",temp_array)
@comment.unflagAbuse()
expect(@comment.get 'abuse_flaggers').toEqual []
describe '_delete', ->
it 'triggers on the correct events', ->
DiscussionUtil.loadRoles []
@comment.updateInfo {ability: {'can_delete': true}}
@view.render()
DiscussionViewSpecHelper.checkButtonEvents(@view, "_delete", ".action-delete")
it 'triggers the delete event', ->
triggerTarget = jasmine.createSpy()
@view.bind "comment:_delete", triggerTarget
@view._delete()
expect(triggerTarget).toHaveBeenCalled()
describe 'edit', ->
it 'triggers on the correct events', ->
DiscussionUtil.loadRoles []
@comment.updateInfo {ability: {'can_edit': true}}
@view.render()
DiscussionViewSpecHelper.checkButtonEvents(@view, "edit", ".action-edit")
it 'triggers comment:edit when the edit button is clicked', ->
triggerTarget = jasmine.createSpy()
@view.bind "comment:edit", triggerTarget
@view.edit()
expect(triggerTarget).toHaveBeenCalled()
describe "labels", ->
expectOneElement = (view, selector, visible=true) =>
view.render()
elements = view.$el.find(selector)
expect(elements.length).toEqual(1)
if visible
expect(elements).not.toHaveClass("is-hidden")
else
expect(elements).toHaveClass("is-hidden")
it 'displays the reported label when appropriate for a non-staff user', ->
@comment.set('abuse_flaggers', [])
expectOneElement(@view, '.post-label-reported', false)
# flagged by current user - should be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id])
expectOneElement(@view, '.post-label-reported')
# flagged by some other user but not the current one - should not be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
expectOneElement(@view, '.post-label-reported', false)
it 'displays the reported label when appropriate for a flag moderator', ->
DiscussionSpecHelper.makeModerator()
@comment.set('abuse_flaggers', [])
expectOneElement(@view, '.post-label-reported', false)
# flagged by current user - should be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id])
expectOneElement(@view, '.post-label-reported')
# flagged by some other user but not the current one - should still be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
expectOneElement(@view, '.post-label-reported')

View File

@@ -1,155 +0,0 @@
describe 'ResponseCommentView', ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
@comment = new Comment {
id: '01234567',
user_id: user.id,
course_id: $$course_id,
body: 'this is a response',
created_at: '2013-04-03T20:08:39Z',
abuse_flaggers: ['123']
roles: ['Student']
}
DiscussionSpecHelper.setUnderscoreFixtures()
@view = new ResponseCommentView({ model: @comment, el: $("#fixture-element") })
spyOn(ResponseCommentShowView.prototype, "convertMath")
spyOn(DiscussionUtil, "makeWmdEditor")
@view.render()
describe '_delete', ->
beforeEach ->
@comment.updateInfo {ability: {can_delete: true}}
@event = DiscussionSpecHelper.makeEventSpy()
spyOn(@comment, "remove")
spyOn(@view.$el, "remove")
setAjaxResult = (isSuccess) ->
spyOn($, "ajax").and.callFake(
(params) =>
(if isSuccess then params.success else params.error) {}
{always: ->}
)
it 'requires confirmation before deleting', ->
spyOn(window, "confirm").and.returnValue(false)
setAjaxResult(true)
@view._delete(@event)
expect(window.confirm).toHaveBeenCalled()
expect($.ajax).not.toHaveBeenCalled()
expect(@comment.remove).not.toHaveBeenCalled()
it 'removes the deleted comment object', ->
setAjaxResult(true)
@view._delete(@event)
expect(@comment.remove).toHaveBeenCalled()
expect(@view.$el.remove).toHaveBeenCalled()
it 'calls the ajax comment deletion endpoint', ->
setAjaxResult(true)
@view._delete(@event)
expect(@event.preventDefault).toHaveBeenCalled()
expect($.ajax).toHaveBeenCalled()
expect($.ajax.calls.mostRecent().args[0].url._parts.path).toEqual('/courses/edX/999/test/discussion/comments/01234567/delete')
it 'handles ajax errors', ->
spyOn(DiscussionUtil, "discussionAlert")
setAjaxResult(false)
@view._delete(@event)
expect(@event.preventDefault).toHaveBeenCalled()
expect($.ajax).toHaveBeenCalled()
expect(@comment.remove).not.toHaveBeenCalled()
expect(@view.$el.remove).not.toHaveBeenCalled()
expect(DiscussionUtil.discussionAlert).toHaveBeenCalled()
it 'does not delete a comment if the permission is false', ->
@comment.updateInfo {ability: {'can_delete': false}}
spyOn(window, "confirm")
setAjaxResult(true)
@view._delete(@event)
expect(window.confirm).not.toHaveBeenCalled()
expect($.ajax).not.toHaveBeenCalled()
expect(@comment.remove).not.toHaveBeenCalled()
expect(@view.$el.remove).not.toHaveBeenCalled()
describe 'renderShowView', ->
it 'renders the show view, removes the edit view, and registers event handlers', ->
spyOn(@view, "_delete")
spyOn(@view, "edit")
# Without calling renderEditView first, renderShowView is a no-op
@view.renderEditView()
@view.renderShowView()
@view.showView.trigger "comment:_delete", DiscussionSpecHelper.makeEventSpy()
expect(@view._delete).toHaveBeenCalled()
@view.showView.trigger "comment:edit", DiscussionSpecHelper.makeEventSpy()
expect(@view.edit).toHaveBeenCalled()
expect(@view.$(".edit-post-form#comment_#{@comment.id}")).not.toHaveClass("edit-post-form")
describe 'renderEditView', ->
it 'renders the edit view, removes the show view, and registers event handlers', ->
spyOn(@view, "update")
spyOn(@view, "cancelEdit")
@view.renderEditView()
@view.editView.trigger "comment:update", DiscussionSpecHelper.makeEventSpy()
expect(@view.update).toHaveBeenCalled()
@view.editView.trigger "comment:cancel_edit", DiscussionSpecHelper.makeEventSpy()
expect(@view.cancelEdit).toHaveBeenCalled()
expect(@view.$(".edit-post-form#comment_#{@comment.id}")).toHaveClass("edit-post-form")
describe 'edit', ->
it 'triggers the appropriate event and switches to the edit view', ->
spyOn(@view, 'renderEditView')
editTarget = jasmine.createSpy()
@view.bind "comment:edit", editTarget
@view.edit()
expect(@view.renderEditView).toHaveBeenCalled()
expect(editTarget).toHaveBeenCalled()
describe 'with edit view displayed', ->
beforeEach ->
@view.renderEditView()
describe 'cancelEdit', ->
it 'triggers the appropriate event and switches to the show view', ->
spyOn(@view, 'renderShowView')
cancelEditTarget = jasmine.createSpy()
@view.bind "comment:cancel_edit", cancelEditTarget
@view.cancelEdit()
expect(@view.renderShowView).toHaveBeenCalled()
expect(cancelEditTarget).toHaveBeenCalled()
describe 'update', ->
beforeEach ->
@updatedBody = "updated body"
# Markdown code creates the editor, so we simulate that here
@view.$el.find(".edit-comment-body").html($("<textarea></textarea>"))
@view.$el.find(".edit-comment-body textarea").val(@updatedBody)
spyOn(@view, 'cancelEdit')
spyOn($, "ajax").and.callFake(
(params) =>
if @ajaxSucceed
params.success()
else
params.error({status: 500})
{always: ->}
)
it 'calls the update endpoint correctly and displays the show view on success', ->
@ajaxSucceed = true
@view.update(DiscussionSpecHelper.makeEventSpy())
expect($.ajax).toHaveBeenCalled()
expect($.ajax.calls.mostRecent().args[0].url._parts.path).toEqual('/courses/edX/999/test/discussion/comments/01234567/update')
expect($.ajax.calls.mostRecent().args[0].data.body).toEqual(@updatedBody)
expect(@view.model.get("body")).toEqual(@updatedBody)
expect(@view.cancelEdit).toHaveBeenCalled()
it 'handles AJAX errors', ->
originalBody = @comment.get("body")
@ajaxSucceed = false
@view.update(DiscussionSpecHelper.makeEventSpy())
expect($.ajax).toHaveBeenCalled()
expect($.ajax.calls.mostRecent().args[0].url._parts.path).toEqual('/courses/edX/999/test/discussion/comments/01234567/update')
expect($.ajax.calls.mostRecent().args[0].data.body).toEqual(@updatedBody)
expect(@view.model.get("body")).toEqual(originalBody)
expect(@view.cancelEdit).not.toHaveBeenCalled()
expect(@view.$(".edit-comment-form-errors *").length).toEqual(1)

View File

@@ -1,230 +0,0 @@
describe "ThreadResponseShowView", ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
DiscussionSpecHelper.setUnderscoreFixtures()
@user = DiscussionUtil.getUser()
@thread = new Thread({"thread_type": "discussion"})
@commentData = {
id: "dummy",
user_id: "567",
course_id: "TestOrg/TestCourse/TestRun",
body: "this is a comment",
created_at: "2013-04-03T20:08:39Z",
endorsed: false,
abuse_flaggers: [],
votes: {up_count: 42},
type: "comment"
}
@comment = new Comment(@commentData)
@comment.set("thread", @thread)
@view = new ThreadResponseShowView({ model: @comment, $el: $("#fixture-element") })
# Avoid unnecessary boilerplate
spyOn(ThreadResponseShowView.prototype, "convertMath")
@view.render()
describe "voting", ->
it "renders the vote state correctly", ->
DiscussionViewSpecHelper.checkRenderVote(@view, @comment)
it "check the vote classes after renders", ->
DiscussionViewSpecHelper.checkVoteClasses(@view)
it "votes correctly via click", ->
DiscussionViewSpecHelper.checkUpvote(@view, @comment, @user, $.Event("click"))
it "votes correctly via spacebar", ->
DiscussionViewSpecHelper.checkUpvote(@view, @comment, @user, $.Event("keydown", {which: 32}))
it "unvotes correctly via click", ->
DiscussionViewSpecHelper.checkUnvote(@view, @comment, @user, $.Event("click"))
it "unvotes correctly via spacebar", ->
DiscussionViewSpecHelper.checkUnvote(@view, @comment, @user, $.Event("keydown", {which: 32}))
it "renders endorsement correctly for a marked answer in a question thread", ->
endorsement = {
"username": "test_endorser",
"user_id": "test_id",
"time": new Date().toISOString()
}
@thread.set("thread_type", "question")
@comment.set({
"endorsed": true,
"endorsement": endorsement
})
@view.render()
expect(@view.$(".posted-details").text().replace(/\s+/g, " ")).toMatch(
"marked as answer less than a minute ago by " + endorsement.username
)
expect(@view.$(".posted-details > a").attr('href')).toEqual("/courses/edX/999/test/discussion/forum/users/test_id")
it "renders anonymous endorsement correctly for a marked answer in a question thread", ->
endorsement = {
"username": null,
"time": new Date().toISOString()
}
@thread.set("thread_type", "question")
@comment.set({
"endorsed": true,
"endorsement": endorsement
})
@view.render()
expect(@view.$(".posted-details").text()).toMatch("marked as answer less than a minute ago")
expect(@view.$(".posted-details").text()).not.toMatch("\sby\s")
it "renders endorsement correctly for an endorsed response in a discussion thread", ->
endorsement = {
"username": "test_endorser",
"user_id": "test_id",
"time": new Date().toISOString()
}
@thread.set("thread_type", "discussion")
@comment.set({
"endorsed": true,
"endorsement": endorsement
})
@view.render()
expect(@view.$(".posted-details").text().replace(/\s+/g, " ")).toMatch(
"endorsed less than a minute ago by " + endorsement.username
)
expect(@view.$(".posted-details > a").attr('href')).toEqual("/courses/edX/999/test/discussion/forum/users/test_id")
it "renders anonymous endorsement correctly for an endorsed response in a discussion thread", ->
endorsement = {
"username": null,
"time": new Date().toISOString()
}
@thread.set("thread_type", "discussion")
@comment.set({
"endorsed": true,
"endorsement": endorsement
})
@view.render()
expect(@view.$(".posted-details").text()).toMatch("endorsed less than a minute ago")
expect(@view.$(".posted-details").text()).not.toMatch("\sby\s")
it "re-renders correctly when endorsement changes", ->
spyOn($, "ajax").and.returnValue($.Deferred())
DiscussionUtil.loadRoles({"Moderator": [parseInt(window.user.id)]})
@thread.set("thread_type", "question")
@view.render()
expect(@view.$(".posted-details").text()).not.toMatch("marked as answer")
@view.$(".action-answer").click()
expect(@view.$(".posted-details").text()).toMatch("marked as answer")
@view.$(".action-answer").click()
expect(@view.$(".posted-details").text()).not.toMatch("marked as answer")
it "allows a moderator to mark an answer in a question thread", ->
spyOn($, "ajax").and.returnValue($.Deferred())
DiscussionUtil.loadRoles({"Moderator": [parseInt(window.user.id)]})
@thread.set({
"thread_type": "question",
"user_id": (parseInt(window.user.id) + 1).toString()
})
@view.render()
endorseButton = @view.$(".action-answer")
expect(endorseButton.length).toEqual(1)
expect(endorseButton.closest(".actions-item")).not.toHaveClass("is-hidden")
endorseButton.click()
expect(endorseButton).toHaveClass("is-checked")
it "allows the author of a question thread to mark an answer", ->
spyOn($, "ajax").and.returnValue($.Deferred())
@thread.set({
"thread_type": "question",
"user_id": window.user.id
})
@view.render()
endorseButton = @view.$(".action-answer")
expect(endorseButton.length).toEqual(1)
expect(endorseButton.closest(".actions-item")).not.toHaveClass("is-hidden")
endorseButton.click()
expect(endorseButton).toHaveClass("is-checked")
it "does not allow the author of a discussion thread to endorse", ->
@thread.set({
"thread_type": "discussion",
"user_id": window.user.id
})
@view.render()
endorseButton = @view.$(".action-endorse")
expect(endorseButton.length).toEqual(1)
expect(endorseButton.closest(".actions-item")).toHaveClass("is-hidden")
it "does not allow a student who is not the author of a question thread to mark an answer", ->
@thread.set({
"thread_type": "question",
"user_id": (parseInt(window.user.id) + 1).toString()
})
@view.render()
endorseButton = @view.$(".action-answer")
expect(endorseButton.length).toEqual(1)
expect(endorseButton.closest(".actions-item")).toHaveClass("is-hidden")
describe "labels", ->
expectOneElement = (view, selector, visible=true) =>
view.render()
elements = view.$el.find(selector)
expect(elements.length).toEqual(1)
if visible
expect(elements).not.toHaveClass("is-hidden")
else
expect(elements).toHaveClass("is-hidden")
it 'displays the reported label when appropriate for a non-staff user', ->
expectOneElement(@view, '.post-label-reported', false)
# flagged by current user - should be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id])
expectOneElement(@view, '.post-label-reported')
# flagged by some other user but not the current one - should not be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
expectOneElement(@view, '.post-label-reported', false)
it 'displays the reported label when appropriate for a flag moderator', ->
DiscussionSpecHelper.makeModerator()
expectOneElement(@view, '.post-label-reported', false)
# flagged by current user - should be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id])
expectOneElement(@view, '.post-label-reported')
# flagged by some other user but not the current one - should still be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
expectOneElement(@view, '.post-label-reported')
describe "endorser display", ->
beforeEach ->
@comment.set('endorsement', {
"username": "test_endorser",
"time": new Date().toISOString()
})
spyOn(DiscussionUtil, 'urlFor').and.returnValue('test_endorser_url')
checkUserLink = (element, is_ta, is_staff) ->
expect(element.find('a.username').length).toEqual(1)
expect(element.find('a.username').text()).toEqual('test_endorser')
expect(element.find('a.username').attr('href')).toEqual('test_endorser_url')
expect(element.find('.user-label-community-ta').length).toEqual(if is_ta then 1 else 0)
expect(element.find('.user-label-staff').length).toEqual(if is_staff then 1 else 0)
it "renders nothing when the response has not been endorsed", ->
@comment.set('endorsement', null)
expect(@view.getEndorserDisplay()).toBeNull()
it "renders correctly for a student-endorsed response", ->
$el = $('#fixture-element').html(@view.getEndorserDisplay())
checkUserLink($el, false, false)
it "renders correctly for a community TA-endorsed response", ->
spyOn(DiscussionUtil, 'isTA').and.returnValue(true)
$el = $('#fixture-element').html(@view.getEndorserDisplay())
checkUserLink($el, true, false)
it "renders correctly for a staff-endorsed response", ->
spyOn(DiscussionUtil, 'isStaff').and.returnValue(true)
$el = $('#fixture-element').html(@view.getEndorserDisplay())
checkUserLink($el, false, true)

View File

@@ -1,92 +0,0 @@
describe 'ThreadResponseView', ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
DiscussionSpecHelper.setUnderscoreFixtures()
@thread = new Thread({"thread_type": "discussion"})
@response = new Comment {
children: [{}, {}],
thread: @thread,
}
@view = new ThreadResponseView({model: @response, el: $("#fixture-element")})
spyOn(ThreadResponseShowView.prototype, "render")
spyOn(ResponseCommentView.prototype, "render")
describe 'closed and open Threads', ->
checkCommentForm = (closed) ->
thread = new Thread({"thread_type": "discussion", "closed": closed})
commentData = {
id: "dummy",
user_id: "567",
course_id: "TestOrg/TestCourse/TestRun",
body: "this is a comment",
created_at: "2013-04-03T20:08:39Z",
abuse_flaggers: [],
type: "comment",
children: [],
thread: thread,
}
comment = new Comment(commentData)
view = new ThreadResponseView({
model: comment, el: $("#fixture-element"),
})
view.render()
expect(view.$('.comment-form').closest('li').is(":visible")).toBe(not closed)
it 'hides comment form when thread is closed', ->
checkCommentForm(true)
it 'show comment form when thread is open', ->
checkCommentForm(false)
describe 'renderComments', ->
it 'hides "show comments" link if collapseComments is not set', ->
@view.render()
expect(@view.$(".comments")).toBeVisible()
expect(@view.$(".action-show-comments")).not.toBeVisible()
it 'hides "show comments" link if collapseComments is set but response has no comments', ->
@response = new Comment { children: [], thread: @thread }
@view = new ThreadResponseView({
model: @response, el: $("#fixture-element"),
collapseComments: true
})
@view.render()
expect(@view.$(".comments")).toBeVisible()
expect(@view.$(".action-show-comments")).not.toBeVisible()
it 'hides comments if collapseComments is set and shows them when "show comments" link is clicked', ->
@view = new ThreadResponseView({
model: @response, el: $("#fixture-element"),
collapseComments: true
})
@view.render()
expect(@view.$(".comments")).not.toBeVisible()
expect(@view.$(".action-show-comments")).toBeVisible()
@view.$(".action-show-comments").click()
expect(@view.$(".comments")).toBeVisible()
expect(@view.$(".action-show-comments")).not.toBeVisible()
it 'populates commentViews and binds events', ->
# Ensure that edit view is set to test invocation of cancelEdit
@view.createEditView()
spyOn(@view, 'cancelEdit')
spyOn(@view, 'cancelCommentEdits')
spyOn(@view, 'hideCommentForm')
spyOn(@view, 'showCommentForm')
@view.renderComments()
expect(@view.commentViews.length).toEqual(2)
@view.commentViews[0].trigger "comment:edit", jasmine.createSpyObj("event", ["preventDefault"])
expect(@view.cancelEdit).toHaveBeenCalled()
expect(@view.cancelCommentEdits).toHaveBeenCalled()
expect(@view.hideCommentForm).toHaveBeenCalled()
@view.commentViews[0].trigger "comment:cancel_edit"
expect(@view.showCommentForm).toHaveBeenCalled()
describe 'cancelCommentEdits', ->
it 'calls cancelEdit on each comment view', ->
@view.renderComments()
expect(@view.commentViews.length).toEqual(2)
_.each(@view.commentViews, (commentView) -> spyOn(commentView, 'cancelEdit'))
@view.cancelCommentEdits()
_.each(@view.commentViews, (commentView) -> expect(commentView.cancelEdit).toHaveBeenCalled())

View File

@@ -1,2 +0,0 @@
!views/discussion_thread_edit_view.js
!views/discussion_topic_menu_view.js

View File

@@ -1,218 +0,0 @@
if Backbone?
class @Content extends Backbone.Model
@contents: {}
@contentInfos: {}
template: -> DiscussionUtil.getTemplate('_content')
actions:
editable: '.admin-edit'
can_reply: '.discussion-reply'
can_delete: '.admin-delete'
can_openclose: '.admin-openclose'
can_report: '.admin-report'
can_vote: '.admin-vote'
urlMappers: {}
urlFor: (name) ->
@urlMappers[name].apply(@)
can: (action) ->
(@get('ability') || {})[action]
# Default implementation
canBeEndorsed: -> false
updateInfo: (info) ->
if info
@set('ability', info.ability)
@set('voted', info.voted)
@set('subscribed', info.subscribed)
addComment: (comment, options) ->
options ||= {}
if not options.silent
thread = @get('thread')
comments_count = parseInt(thread.get('comments_count'))
thread.set('comments_count', comments_count + 1)
@get('children').push comment
model = new Comment $.extend {}, comment, { thread: @get('thread') }
@get('comments').add model
@trigger "comment:add"
model
removeComment: (comment) ->
thread = @get('thread')
comments_count = parseInt(thread.get('comments_count'))
thread.set('comments_count', comments_count - 1 - comment.getCommentsCount())
@trigger "comment:remove"
resetComments: (children) ->
@set 'children', []
@set 'comments', new Comments()
for comment in (children || [])
@addComment comment, { silent: true }
initialize: ->
Content.addContent @id, @
userId = @get('user_id')
if userId?
@set('staff_authored', DiscussionUtil.isStaff(userId))
@set('community_ta_authored', DiscussionUtil.isTA(userId))
else
@set('staff_authored', false)
@set('community_ta_authored', false)
if Content.getInfo(@id)
@updateInfo(Content.getInfo(@id))
@set 'user_url', DiscussionUtil.urlFor('user_profile', userId)
@resetComments(@get('children'))
remove: ->
if @get('type') == 'comment'
@get('thread').removeComment(@)
@get('thread').trigger "comment:remove", @
else
@trigger "thread:remove", @
@addContent: (id, content) -> @contents[id] = content
@getContent: (id) -> @contents[id]
@getInfo: (id) ->
@contentInfos[id]
@loadContentInfos: (infos) ->
for id, info of infos
if @getContent(id)
@getContent(id).updateInfo(info)
$.extend @contentInfos, infos
pinThread: ->
pinned = @get("pinned")
@set("pinned",pinned)
@trigger "change", @
unPinThread: ->
pinned = @get("pinned")
@set("pinned",pinned)
@trigger "change", @
flagAbuse: ->
temp_array = @get("abuse_flaggers")
temp_array.push(window.user.get('id'))
@set("abuse_flaggers",temp_array)
@trigger "change", @
unflagAbuse: ->
@get("abuse_flaggers").pop(window.user.get('id'))
@trigger "change", @
isFlagged: ->
user = DiscussionUtil.getUser()
flaggers = @get("abuse_flaggers")
user and (user.id in flaggers or (DiscussionUtil.isPrivilegedUser(user.id) and flaggers.length > 0))
incrementVote: (increment) ->
newVotes = _.clone(@get("votes"))
newVotes.up_count = newVotes.up_count + increment
@set("votes", newVotes)
vote: ->
@incrementVote(1)
unvote: ->
@incrementVote(-1)
class @Thread extends @Content
urlMappers:
'retrieve' : -> DiscussionUtil.urlFor('retrieve_single_thread', @.get('commentable_id'), @id)
'reply' : -> DiscussionUtil.urlFor('create_comment', @id)
'unvote' : -> DiscussionUtil.urlFor("undo_vote_for_#{@get('type')}", @id)
'upvote' : -> DiscussionUtil.urlFor("upvote_#{@get('type')}", @id)
'downvote' : -> DiscussionUtil.urlFor("downvote_#{@get('type')}", @id)
'close' : -> DiscussionUtil.urlFor('openclose_thread', @id)
'update' : -> DiscussionUtil.urlFor('update_thread', @id)
'_delete' : -> DiscussionUtil.urlFor('delete_thread', @id)
'follow' : -> DiscussionUtil.urlFor('follow_thread', @id)
'unfollow' : -> DiscussionUtil.urlFor('unfollow_thread', @id)
'flagAbuse' : -> DiscussionUtil.urlFor("flagAbuse_#{@get('type')}", @id)
'unFlagAbuse' : -> DiscussionUtil.urlFor("unFlagAbuse_#{@get('type')}", @id)
'pinThread' : -> DiscussionUtil.urlFor("pin_thread", @id)
'unPinThread' : -> DiscussionUtil.urlFor("un_pin_thread", @id)
initialize: ->
@set('thread', @)
super()
comment: ->
@set("comments_count", parseInt(@get("comments_count")) + 1)
follow: ->
@set('subscribed', true)
unfollow: ->
@set('subscribed', false)
display_body: ->
if @has("highlighted_body")
String(@get("highlighted_body")).replace(/<highlight>/g, '<mark>').replace(/<\/highlight>/g, '</mark>')
else
@get("body")
display_title: ->
if @has("highlighted_title")
String(@get("highlighted_title")).replace(/<highlight>/g, '<mark>').replace(/<\/highlight>/g, '</mark>')
else
@get("title")
toJSON: ->
json_attributes = _.clone(@attributes)
_.extend(json_attributes, { title: @display_title(), body: @display_body() })
created_at_date: ->
new Date(@get("created_at"))
created_at_time: ->
new Date(@get("created_at")).getTime()
hasResponses: ->
@get('comments_count') > 0
class @Comment extends @Content
urlMappers:
'reply': -> DiscussionUtil.urlFor('create_sub_comment', @id)
'unvote': -> DiscussionUtil.urlFor("undo_vote_for_#{@get('type')}", @id)
'upvote': -> DiscussionUtil.urlFor("upvote_#{@get('type')}", @id)
'downvote': -> DiscussionUtil.urlFor("downvote_#{@get('type')}", @id)
'endorse': -> DiscussionUtil.urlFor('endorse_comment', @id)
'update': -> DiscussionUtil.urlFor('update_comment', @id)
'_delete': -> DiscussionUtil.urlFor('delete_comment', @id)
'flagAbuse' : -> DiscussionUtil.urlFor("flagAbuse_#{@get('type')}", @id)
'unFlagAbuse' : -> DiscussionUtil.urlFor("unFlagAbuse_#{@get('type')}", @id)
getCommentsCount: ->
count = 0
@get('comments').each (comment) ->
count += comment.getCommentsCount() + 1
count
canBeEndorsed: =>
user_id = window.user.get("id")
user_id && (
DiscussionUtil.isPrivilegedUser(user_id) ||
(@get('thread').get('thread_type') == 'question' && @get('thread').get('user_id') == user_id)
)
class @Comments extends Backbone.Collection
model: Comment
initialize: ->
@bind "add", (item) =>
item.collection = @
find: (id) ->
_.first @where(id: id)

View File

@@ -1,131 +0,0 @@
if Backbone?
class @Discussion extends Backbone.Collection
model: Thread
initialize: (models, options={})->
@pages = options['pages'] || 1
@current_page = 1
@sort_preference = options['sort']
@bind "add", (item) =>
item.discussion = @
@setSortComparator(@sort_preference)
@on "thread:remove", (thread) =>
@remove(thread)
find: (id) ->
_.first @where(id: id)
hasMorePages: ->
@current_page < @pages
setSortComparator: (sortBy) ->
switch sortBy
when 'activity' then @comparator = @sortByDateRecentFirst
when 'votes' then @comparator = @sortByVotes
when 'comments' then @comparator = @sortByComments
addThread: (thread, options) ->
# TODO: Check for existing thread with same ID in a faster way
if not @find(thread.id)
options ||= {}
model = new Thread thread
@add model
model
retrieveAnotherPage: (mode, options={}, sort_options={}, error=null)->
data = { page: @current_page + 1 }
if _.contains(["unread", "unanswered", "flagged"], options.filter)
data[options.filter] = true
switch mode
when 'search'
url = DiscussionUtil.urlFor 'search'
data['text'] = options.search_text
when 'commentables'
url = DiscussionUtil.urlFor 'search'
data['commentable_ids'] = options.commentable_ids
when 'all'
url = DiscussionUtil.urlFor 'threads'
when 'followed'
url = DiscussionUtil.urlFor 'followed_threads', options.user_id
if options['group_id']
data['group_id'] = options['group_id']
data['sort_key'] = sort_options.sort_key || 'activity'
data['sort_order'] = sort_options.sort_order || 'desc'
DiscussionUtil.safeAjax
$elem: @$el
url: url
data: data
dataType: 'json'
success: (response, textStatus) =>
models = @models
new_threads = [new Thread(data) for data in response.discussion_data][0]
new_collection = _.union(models, new_threads)
Content.loadContentInfos(response.annotated_content_info)
@pages = response.num_pages
@current_page = response.page
@reset new_collection
error: error
sortByDate: (thread) ->
#
# The comment client asks each thread for a value by which to sort the collection
# and calls this sort routine regardless of the order returned from the LMS/comments service
# so, this takes advantage of this per-thread value and returns tomorrow's date
# for pinned threads, ensuring that they appear first, (which is the intent of pinned threads)
#
@pinnedThreadsSortComparatorWithDate(thread, true)
sortByDateRecentFirst: (thread) ->
#
# Same as above
# but negative to flip the order (newest first)
#
@pinnedThreadsSortComparatorWithDate(thread, false)
#return String.fromCharCode.apply(String,
# _.map(thread.get("created_at").split(""),
# ((c) -> return 0xffff - c.charChodeAt()))
#)
sortByVotes: (thread1, thread2) ->
thread1_count = parseInt(thread1.get("votes")['up_count'])
thread2_count = parseInt(thread2.get("votes")['up_count'])
@pinnedThreadsSortComparatorWithCount(thread1, thread2, thread1_count, thread2_count)
sortByComments: (thread1, thread2) ->
thread1_count = parseInt(thread1.get("comments_count"))
thread2_count = parseInt(thread2.get("comments_count"))
@pinnedThreadsSortComparatorWithCount(thread1, thread2, thread1_count, thread2_count)
pinnedThreadsSortComparatorWithCount: (thread1, thread2, thread1_count, thread2_count) ->
# if threads are pinned they should be displayed on top.
# Unpinned will be sorted by their property count
if thread1.get('pinned') and not thread2.get('pinned')
-1
else if thread2.get('pinned') and not thread1.get('pinned')
1
else
if thread1_count > thread2_count
-1
else if thread2_count > thread1_count
1
else
if thread1.created_at_time() > thread2.created_at_time()
-1
else
1
pinnedThreadsSortComparatorWithDate: (thread, ascending)->
# if threads are pinned they should be displayed on top.
# Unpinned will be sorted by their last activity date
threadLastActivityAtTime = new Date(thread.get("last_activity_at")).getTime()
if thread.get('pinned')
#use tomorrow's date
today = new Date();
preferredDate = new Date(today.getTime() + (24 * 60 * 60 * 1000) + threadLastActivityAtTime);
else
preferredDate = threadLastActivityAtTime
if ascending
preferredDate
else
-(preferredDate)

View File

@@ -1,173 +0,0 @@
if Backbone?
class @DiscussionModuleView extends Backbone.View
events:
"click .discussion-show": "toggleDiscussion"
"keydown .discussion-show":
(event) -> DiscussionUtil.activateOnSpace(event, @toggleDiscussion)
"click .new-post-btn": "toggleNewPost"
"keydown .new-post-btn":
(event) -> DiscussionUtil.activateOnSpace(event, @toggleNewPost)
"click .discussion-paginator a": "navigateToPage"
page_re: /\?discussion_page=(\d+)/
initialize: (options) ->
@toggleDiscussionBtn = @$(".discussion-show")
# Set the page if it was set in the URL. This is used to allow deep linking to pages
match = @page_re.exec(window.location.href)
@context = options.context or "course" # allowed values are "course" or "standalone"
if match
@page = parseInt(match[1])
else
@page = 1
toggleNewPost: (event) =>
event.preventDefault()
if !@newPostForm
@toggleDiscussion()
@isWaitingOnNewPost = true;
return
if @showed
@newPostForm.slideDown(300)
else
@newPostForm.show().focus()
@toggleDiscussionBtn.addClass('shown')
@toggleDiscussionBtn.find('.button-text').html(gettext("Hide Discussion"))
@$("section.discussion").slideDown()
@showed = true
hideNewPost: =>
@newPostForm.slideUp(300)
hideDiscussion: =>
@$("section.discussion").slideUp()
@toggleDiscussionBtn.removeClass('shown')
@toggleDiscussionBtn.find('.button-text').html(gettext("Show Discussion"))
@showed = false
toggleDiscussion: (event) =>
if @showed
@hideDiscussion()
else
@toggleDiscussionBtn.addClass('shown')
@toggleDiscussionBtn.find('.button-text').html(gettext("Hide Discussion"))
if @retrieved
@$("section.discussion").slideDown()
@showed = true
else
$elem = @toggleDiscussionBtn
@loadPage(
$elem,
=>
@hideDiscussion()
DiscussionUtil.discussionAlert(
gettext("Sorry"),
gettext("We had some trouble loading the discussion. Please try again.")
)
)
loadPage: ($elem, error) =>
discussionId = @$el.data("discussion-id")
url = DiscussionUtil.urlFor('retrieve_discussion', discussionId) + "?page=#{@page}"
DiscussionUtil.safeAjax
$elem: $elem
$loading: $elem
takeFocus: true
url: url
type: "GET"
dataType: 'json'
success: (response, textStatus, jqXHR) => @renderDiscussion($elem, response, textStatus, discussionId)
error: error
renderDiscussion: ($elem, response, textStatus, discussionId) =>
$elem.focus()
user = new DiscussionUser(response.user_info)
window.user = user
DiscussionUtil.setUser(user)
Content.loadContentInfos(response.annotated_content_info)
DiscussionUtil.loadRoles(response.roles)
@course_settings = new DiscussionCourseSettings(response.course_settings)
@discussion = new Discussion()
@discussion.reset(response.discussion_data, {silent: false})
$discussion = _.template($("#inline-discussion-template").html())(
'threads': response.discussion_data,
'discussionId': discussionId
)
if @$('section.discussion').length
@$('section.discussion').replaceWith($discussion)
else
@$el.append($discussion)
@newPostForm = this.$el.find('.new-post-article')
@threadviews = @discussion.map (thread) =>
view = new DiscussionThreadView(
el: @$("article#thread_#{thread.id}"),
model: thread,
mode: "inline",
context: @context,
course_settings: @course_settings,
topicId: discussionId
)
thread.on "thread:thread_type_updated", ->
view.rerender()
view.expand()
return view
_.each @threadviews, (dtv) -> dtv.render()
DiscussionUtil.bulkUpdateContentInfo(window.$$annotated_content_info)
@newPostView = new NewPostView(
el: @newPostForm,
collection: @discussion,
course_settings: @course_settings,
topicId: discussionId,
is_commentable_cohorted: response.is_commentable_cohorted
)
@newPostView.render()
@listenTo( @newPostView, 'newPost:cancel', @hideNewPost )
@discussion.on "add", @addThread
@retrieved = true
@showed = true
@renderPagination(response.num_pages)
if @isWaitingOnNewPost
@newPostForm.show().focus()
addThread: (thread, collection, options) =>
# TODO: When doing pagination, this will need to repaginate. Perhaps just reload page 1?
article = $("<article class='discussion-thread' id='thread_#{thread.id}'></article>")
@$('section.discussion > .threads').prepend(article)
threadView = new DiscussionThreadView(
el: article,
model: thread,
mode: "inline",
context: @context,
course_settings: @course_settings,
topicId: @$el.data("discussion-id")
)
threadView.render()
@threadviews.unshift threadView
renderPagination: (numPages) =>
pageUrl = (number) ->
"?discussion_page=#{number}"
params = DiscussionUtil.getPaginationParams(@page, numPages, pageUrl)
pagination = _.template($("#pagination-template").html())(params)
@$('section.discussion-pagination').html(pagination)
navigateToPage: (event) =>
event.preventDefault()
window.history.pushState({}, window.document.title, event.target.href)
currPage = @page
@page = $(event.target).data('page-number')
@loadPage(
$(event.target),
=>
@page = currPage
DiscussionUtil.discussionAlert(
gettext("Sorry"),
gettext("We had some trouble loading the threads you requested. Please try again.")
)
)

View File

@@ -1,90 +0,0 @@
if Backbone?
class @DiscussionRouter extends Backbone.Router
routes:
"": "allThreads"
":forum_name/threads/:thread_id" : "showThread"
initialize: (options) ->
@discussion = options['discussion']
@course_settings = options['course_settings']
@nav = new DiscussionThreadListView(
collection: @discussion,
el: $(".forum-nav"),
courseSettings: @course_settings
)
@nav.on "thread:selected", @navigateToThread
@nav.on "thread:removed", @navigateToAllThreads
@nav.on "threads:rendered", @setActiveThread
@nav.on "thread:created", @navigateToThread
@nav.render()
@newPost = $('.new-post-article')
@newPostView = new NewPostView(
el: @newPost,
collection: @discussion,
course_settings: @course_settings,
mode: "tab"
)
@newPostView.render()
@listenTo( @newPostView, 'newPost:cancel', @hideNewPost )
$('.new-post-btn').bind "click", @showNewPost
$('.new-post-btn').bind "keydown", (event) => DiscussionUtil.activateOnSpace(event, @showNewPost)
allThreads: ->
@nav.updateSidebar()
@nav.goHome()
setActiveThread: =>
if @thread
@nav.setActiveThread(@thread.get("id"))
else
@nav.goHome
showThread: (forum_name, thread_id) ->
@thread = @discussion.get(thread_id)
@thread.set("unread_comments_count", 0)
@thread.set("read", true)
@setActiveThread()
@showMain()
showMain: =>
if(@main)
@main.cleanup()
@main.undelegateEvents()
unless($(".forum-content").is(":visible"))
$(".forum-content").fadeIn()
if(@newPost.is(":visible"))
@newPost.fadeOut()
@main = new DiscussionThreadView(
el: $(".forum-content"),
model: @thread,
mode: "tab",
course_settings: @course_settings,
)
@main.render()
@main.on "thread:responses:rendered", =>
@nav.updateSidebar()
@thread.on "thread:thread_type_updated", @showMain
navigateToThread: (thread_id) =>
thread = @discussion.get(thread_id)
@navigate("#{thread.get("commentable_id")}/threads/#{thread_id}", trigger: true)
navigateToAllThreads: =>
@navigate("", trigger: true)
showNewPost: (event) =>
$('.forum-content').fadeOut(
duration: 200
complete: =>
@newPost.fadeIn(200).focus()
)
hideNewPost: =>
@newPost.fadeOut(
duration: 200
complete: =>
$('.forum-content').fadeIn(200).find('.thread-wrapper').focus()
)

View File

@@ -1,38 +0,0 @@
if Backbone?
DiscussionApp =
start: (elem)->
# TODO: Perhaps eliminate usage of global variables when possible
DiscussionUtil.loadRolesFromContainer()
element = $(elem)
window.$$course_id = element.data("course-id")
window.courseName = element.data("course-name")
user_info = element.data("user-info")
sort_preference = element.data("sort-preference")
threads = element.data("threads")
thread_pages = element.data("thread-pages")
content_info = element.data("content-info")
user = new DiscussionUser(user_info)
DiscussionUtil.setUser(user)
window.user = user
Content.loadContentInfos(content_info)
discussion = new Discussion(threads, {pages: thread_pages, sort: sort_preference})
course_settings = new DiscussionCourseSettings(element.data("course-settings"))
new DiscussionRouter({discussion: discussion, course_settings: course_settings})
Backbone.history.start({pushState: true, root: "/courses/#{$$course_id}/discussion/forum/"})
DiscussionProfileApp =
start: (elem) ->
# Roles are not included in user profile page, but they are not used for anything
DiscussionUtil.loadRoles({"Moderator": [], "Administrator": [], "Community TA": []})
element = $(elem)
window.$$course_id = element.data("course-id")
threads = element.data("threads")
user_info = element.data("user-info")
window.user = new DiscussionUser(user_info)
page = element.data("page")
numPages = element.data("num-pages")
new DiscussionUserProfileView(el: element, collection: threads, page: page, numPages: numPages)
$ ->
$("section.discussion").each (index, elem) ->
DiscussionApp.start(elem)
$("section.discussion-user-threads").each (index, elem) ->
DiscussionProfileApp.start(elem)

View File

@@ -1,2 +0,0 @@
if Backbone?
class @DiscussionCourseSettings extends Backbone.Model

View File

@@ -1,15 +0,0 @@
if Backbone?
class @DiscussionUser extends Backbone.Model
following: (thread) ->
_.include(@get('subscribed_thread_ids'), thread.id)
voted: (thread) ->
_.include(@get('upvoted_ids'), thread.id)
vote: (thread) ->
@get('upvoted_ids').push(thread.id)
thread.vote()
unvote: (thread) ->
@set('upvoted_ids', _.without(@get('upvoted_ids'), thread.id))
thread.unvote()

View File

@@ -1,346 +0,0 @@
class @DiscussionUtil
@wmdEditors: {}
@getTemplate: (id) ->
$("script##{id}").html()
@setUser: (user) ->
@user = user
@getUser: () ->
@user
@loadRoles: (roles)->
@roleIds = roles
@loadRolesFromContainer: ->
@loadRoles($("#discussion-container").data("roles"))
@isStaff: (user_id) ->
user_id ?= @user?.id
staff = _.union(@roleIds['Moderator'], @roleIds['Administrator'])
_.include(staff, parseInt(user_id))
@isTA: (user_id) ->
user_id ?= @user?.id
ta = _.union(@roleIds['Community TA'])
_.include(ta, parseInt(user_id))
@isPrivilegedUser: (user_id) ->
@isStaff(user_id) || @isTA(user_id)
@bulkUpdateContentInfo: (infos) ->
for id, info of infos
Content.getContent(id).updateInfo(info)
@generateDiscussionLink: (cls, txt, handler) ->
$("<a>").addClass("discussion-link")
.attr("href", "javascript:void(0)")
.addClass(cls).html(txt)
.click -> handler(this)
@urlFor: (name, param, param1, param2) ->
{
follow_discussion : "/courses/#{$$course_id}/discussion/#{param}/follow"
unfollow_discussion : "/courses/#{$$course_id}/discussion/#{param}/unfollow"
create_thread : "/courses/#{$$course_id}/discussion/#{param}/threads/create"
update_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/update"
create_comment : "/courses/#{$$course_id}/discussion/threads/#{param}/reply"
delete_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/delete"
flagAbuse_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/flagAbuse"
unFlagAbuse_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/unFlagAbuse"
flagAbuse_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/flagAbuse"
unFlagAbuse_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/unFlagAbuse"
upvote_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/upvote"
downvote_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/downvote"
pin_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/pin"
un_pin_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/unpin"
undo_vote_for_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/unvote"
follow_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/follow"
unfollow_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/unfollow"
update_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/update"
endorse_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/endorse"
create_sub_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/reply"
delete_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/delete"
upvote_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/upvote"
downvote_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/downvote"
undo_vote_for_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/unvote"
upload : "/courses/#{$$course_id}/discussion/upload"
users : "/courses/#{$$course_id}/discussion/users"
search : "/courses/#{$$course_id}/discussion/forum/search"
retrieve_discussion : "/courses/#{$$course_id}/discussion/forum/#{param}/inline"
retrieve_single_thread : "/courses/#{$$course_id}/discussion/forum/#{param}/threads/#{param1}"
openclose_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/close"
permanent_link_thread : "/courses/#{$$course_id}/discussion/forum/#{param}/threads/#{param1}"
permanent_link_comment : "/courses/#{$$course_id}/discussion/forum/#{param}/threads/#{param1}##{param2}"
user_profile : "/courses/#{$$course_id}/discussion/forum/users/#{param}"
followed_threads : "/courses/#{$$course_id}/discussion/forum/users/#{param}/followed"
threads : "/courses/#{$$course_id}/discussion/forum"
"enable_notifications" : "/notification_prefs/enable/"
"disable_notifications" : "/notification_prefs/disable/"
"notifications_status" : "/notification_prefs/status/"
}[name]
@ignoreEnterKey: (event) =>
if event.which == 13
event.preventDefault()
@activateOnSpace: (event, func) ->
if event.which == 32
event.preventDefault()
func(event)
@makeFocusTrap: (elem) ->
elem.keydown(
(event) ->
if event.which == 9 # Tab
event.preventDefault()
)
@showLoadingIndicator: (element, takeFocus) ->
@$_loading = $("<div class='loading-animation' tabindex='0'><span class='sr'>" + gettext("Loading content") + "</span></div>")
element.after(@$_loading)
if takeFocus
@makeFocusTrap(@$_loading)
@$_loading.focus()
@hideLoadingIndicator: () ->
@$_loading.remove()
@discussionAlert: (header, body) ->
if $("#discussion-alert").length == 0
alertDiv = $("<div class='modal' role='alertdialog' id='discussion-alert' aria-describedby='discussion-alert-message'/>").css("display", "none")
alertDiv.html(
"<div class='inner-wrapper discussion-alert-wrapper'>" +
" <button class='close-modal dismiss' title='" + gettext("Close") + "'><span class='icon fa fa-times' aria-hidden='true'></span></button>" +
" <header><h2/><hr/></header>" +
" <p id='discussion-alert-message'/>" +
" <hr/>" +
" <button class='dismiss'>" + gettext("OK") + "</button>" +
"</div>"
)
@makeFocusTrap(alertDiv.find("button"))
alertTrigger = $("<a href='#discussion-alert' id='discussion-alert-trigger'/>").css("display", "none")
alertTrigger.leanModal({closeButton: "#discussion-alert .dismiss", overlay: 1, top: 200})
$("body").append(alertDiv).append(alertTrigger)
$("#discussion-alert header h2").html(header)
$("#discussion-alert p").html(body)
$("#discussion-alert-trigger").click()
$("#discussion-alert button").focus()
@safeAjax: (params) ->
$elem = params.$elem
if $elem and $elem.attr("disabled")
deferred = $.Deferred()
deferred.reject()
return deferred.promise()
params["url"] = URI(params["url"]).addSearch ajax: 1
params["beforeSend"] = =>
if $elem
$elem.attr("disabled", "disabled")
if params["$loading"]
if params["loadingCallback"]?
params["loadingCallback"].apply(params["$loading"])
else
@showLoadingIndicator($(params["$loading"]), params["takeFocus"])
if !params["error"]
params["error"] = =>
@discussionAlert(
gettext("Sorry"),
gettext("We had some trouble processing your request. Please ensure you have copied any unsaved work and then reload the page.")
)
request = $.ajax(params).always =>
if $elem
$elem.removeAttr("disabled")
if params["$loading"]
if params["loadedCallback"]?
params["loadedCallback"].apply(params["$loading"])
else
@hideLoadingIndicator()
return request
@updateWithUndo: (model, updates, safeAjaxParams, errorMsg) ->
if errorMsg
safeAjaxParams.error = => @discussionAlert(gettext("Sorry"), errorMsg)
undo = _.pick(model.attributes, _.keys(updates))
model.set(updates)
@safeAjax(safeAjaxParams).fail(() -> model.set(undo))
@bindLocalEvents: ($local, eventsHandler) ->
for eventSelector, handler of eventsHandler
[event, selector] = eventSelector.split(' ')
$local(selector).unbind(event)[event] handler
@formErrorHandler: (errorsField) ->
(xhr, textStatus, error) ->
makeErrorElem = (message) ->
$("<li>").addClass("post-error").html(message)
errorsField.empty().show()
if xhr.status == 400
response = JSON.parse(xhr.responseText)
if response.errors? and response.errors.length > 0
for error in response.errors
errorsField.append(makeErrorElem(error))
else
errorsField.append(
makeErrorElem(
gettext("We had some trouble processing your request. Please try again.")
)
)
@clearFormErrors: (errorsField) ->
errorsField.empty()
@postMathJaxProcessor: (text) ->
RE_INLINEMATH = /^\$([^\$]*)\$/g
RE_DISPLAYMATH = /^\$\$([^\$]*)\$\$/g
@processEachMathAndCode text, (s, type) ->
if type == 'display'
s.replace RE_DISPLAYMATH, ($0, $1) ->
"\\[" + $1 + "\\]"
else if type == 'inline'
s.replace RE_INLINEMATH, ($0, $1) ->
"\\(" + $1 + "\\)"
else
s
@makeWmdEditor: ($content, $local, cls_identifier) ->
elem = $local(".#{cls_identifier}")
placeholder = elem.data('placeholder')
id = elem.attr("data-id") # use attr instead of data because we want to avoid type coercion
appended_id = "-#{cls_identifier}-#{id}"
imageUploadUrl = @urlFor('upload')
_processor = (_this) ->
(text) -> _this.postMathJaxProcessor(text)
editor = Markdown.makeWmdEditor elem, appended_id, imageUploadUrl, _processor(@)
@wmdEditors["#{cls_identifier}-#{id}"] = editor
if placeholder?
elem.find("#wmd-input#{appended_id}").attr('placeholder', placeholder)
editor
@getWmdEditor: ($content, $local, cls_identifier) ->
elem = $local(".#{cls_identifier}")
id = elem.attr("data-id") # use attr instead of data because we want to avoid type coercion
@wmdEditors["#{cls_identifier}-#{id}"]
@getWmdInput: ($content, $local, cls_identifier) ->
elem = $local(".#{cls_identifier}")
id = elem.attr("data-id") # use attr instead of data because we want to avoid type coercion
$local("#wmd-input-#{cls_identifier}-#{id}")
@getWmdContent: ($content, $local, cls_identifier) ->
@getWmdInput($content, $local, cls_identifier).val()
@setWmdContent: ($content, $local, cls_identifier, text) ->
@getWmdInput($content, $local, cls_identifier).val(text)
@getWmdEditor($content, $local, cls_identifier).refreshPreview()
@processEachMathAndCode: (text, processor) ->
codeArchive = []
RE_DISPLAYMATH = /^([^\$]*?)\$\$([^\$]*?)\$\$(.*)$/m
RE_INLINEMATH = /^([^\$]*?)\$([^\$]+?)\$(.*)$/m
ESCAPED_DOLLAR = '@@ESCAPED_D@@'
ESCAPED_BACKSLASH = '@@ESCAPED_B@@'
processedText = ""
$div = $("<div>").html(text)
$div.find("code").each (index, code) ->
codeArchive.push $(code).html()
$(code).html(codeArchive.length - 1)
text = $div.html()
text = text.replace /\\\$/g, ESCAPED_DOLLAR
while true
if RE_INLINEMATH.test(text)
text = text.replace RE_INLINEMATH, ($0, $1, $2, $3) ->
processedText += $1 + processor("$" + $2 + "$", 'inline')
$3
else if RE_DISPLAYMATH.test(text)
text = text.replace RE_DISPLAYMATH, ($0, $1, $2, $3) ->
#processedText += $1 + processor("$$" + $2 + "$$", 'display')
#bug fix, ordering is off
processedText = processor("$$" + $2 + "$$", 'display') + processedText
processedText = $1 + processedText
$3
else
processedText += text
break
text = processedText
text = text.replace(new RegExp(ESCAPED_DOLLAR, 'g'), '\\$')
text = text.replace /\\\\\\\\/g, ESCAPED_BACKSLASH
text = text.replace /\\begin\{([a-z]*\*?)\}([\s\S]*?)\\end\{\1\}/img, ($0, $1, $2) ->
processor("\\begin{#{$1}}" + $2 + "\\end{#{$1}}")
text = text.replace(new RegExp(ESCAPED_BACKSLASH, 'g'), '\\\\\\\\')
$div = $("<div>").html(text)
cnt = 0
$div.find("code").each (index, code) ->
$(code).html(processor(codeArchive[cnt], 'code'))
cnt += 1
text = $div.html()
text
@unescapeHighlightTag: (text) ->
text.replace(/\&lt\;highlight\&gt\;/g, "<span class='search-highlight'>")
.replace(/\&lt\;\/highlight\&gt\;/g, "</span>")
@stripHighlight: (text) ->
text.replace(/\&(amp\;)?lt\;highlight\&(amp\;)?gt\;/g, "")
.replace(/\&(amp\;)?lt\;\/highlight\&(amp\;)?gt\;/g, "")
@stripLatexHighlight: (text) ->
@processEachMathAndCode text, @stripHighlight
@markdownWithHighlight: (text) ->
text = text.replace(/^\&gt\;/gm, ">")
converter = Markdown.getMathCompatibleConverter()
text = @unescapeHighlightTag @stripLatexHighlight converter.makeHtml text
return text.replace(/^>/gm,"&gt;")
@abbreviateString: (text, minLength) ->
# Abbreviates a string to at least minLength characters, stopping at word boundaries
if text.length<minLength
return text
else
while minLength < text.length && text[minLength] != ' '
minLength++
return text.substr(0, minLength) + gettext('')
@abbreviateHTML: (html, minLength) ->
# Abbreviates the html to at least minLength characters, stopping at word boundaries
truncated_text = jQuery.truncate(html, {length: minLength, noBreaks: true, ellipsis: gettext('')})
$result = $("<div>" + truncated_text + "</div>")
imagesToReplace = $result.find("img:not(:first)")
if imagesToReplace.length > 0
$result.append("<p><em>Some images in this post have been omitted</em></p>")
imagesToReplace.replaceWith("<em>image omitted</em>")
$result.html()
@getPaginationParams: (curPage, numPages, pageUrlFunc) =>
delta = 2
minPage = Math.max(curPage - delta, 1)
maxPage = Math.min(curPage + delta, numPages)
pageInfo = (pageNum) -> {number: pageNum, url: pageUrlFunc(pageNum)}
params =
page: curPage
lowPages: _.range(minPage, curPage).map(pageInfo)
highPages: _.range(curPage+1, maxPage+1).map(pageInfo)
previous: if curPage > 1 then pageInfo(curPage - 1) else null
next: if curPage < numPages then pageInfo(curPage + 1) else null
leftdots: minPage > 2
rightdots: maxPage < numPages-1
first: if minPage > 1 then pageInfo(1) else null
last: if maxPage < numPages then pageInfo(numPages) else null

View File

@@ -1,303 +0,0 @@
if Backbone?
class @DiscussionContentView extends Backbone.View
events:
"click .discussion-flag-abuse": "toggleFlagAbuse"
"keydown .discussion-flag-abuse":
(event) -> DiscussionUtil.activateOnSpace(event, @toggleFlagAbuse)
attrRenderer:
ability: (ability) ->
for action, selector of @abilityRenderer
if not ability[action]
selector.disable.apply(@)
else
selector.enable.apply(@)
abilityRenderer:
editable:
enable: -> @$(".action-edit").closest(".actions-item").removeClass("is-hidden")
disable: -> @$(".action-edit").closest(".actions-item").addClass("is-hidden")
can_delete:
enable: -> @$(".action-delete").closest(".actions-item").removeClass("is-hidden")
disable: -> @$(".action-delete").closest(".actions-item").addClass("is-hidden")
can_openclose:
enable: ->
_.each(
[".action-close", ".action-pin"],
(selector) => @$(selector).closest(".actions-item").removeClass("is-hidden")
)
disable: ->
_.each(
[".action-close", ".action-pin"],
(selector) => @$(selector).closest(".actions-item").addClass("is-hidden")
)
can_report:
enable: -> @$(".action-report").closest(".actions-item").removeClass("is-hidden")
disable: -> @$(".action-report").closest(".actions-item").addClass("is-hidden")
can_vote:
enable: -> @$(".action-vote").closest(".actions-item").removeClass("is-hidden")
disable: -> @$(".action-vote").closest(".actions-item").addClass("is-hidden")
renderPartialAttrs: ->
for attr, value of @model.changedAttributes()
if @attrRenderer[attr]
@attrRenderer[attr].apply(@, [value])
renderAttrs: ->
for attr, value of @model.attributes
if @attrRenderer[attr]
@attrRenderer[attr].apply(@, [value])
makeWmdEditor: (cls_identifier) =>
if not @$el.find(".wmd-panel").length
DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), cls_identifier
getWmdEditor: (cls_identifier) =>
DiscussionUtil.getWmdEditor @$el, $.proxy(@$, @), cls_identifier
getWmdContent: (cls_identifier) =>
DiscussionUtil.getWmdContent @$el, $.proxy(@$, @), cls_identifier
setWmdContent: (cls_identifier, text) =>
DiscussionUtil.setWmdContent @$el, $.proxy(@$, @), cls_identifier, text
initialize: ->
@model.bind('change', @renderPartialAttrs, @)
@listenTo(@model, "change:endorsed", =>
if @model instanceof Comment
@trigger("comment:endorse")
)
class @DiscussionContentShowView extends DiscussionContentView
events:
_.reduce(
[
[".action-follow", "toggleFollow"],
[".action-answer", "toggleEndorse"],
[".action-endorse", "toggleEndorse"],
[".action-vote", "toggleVote"],
[".action-more", "toggleSecondaryActions"],
[".action-pin", "togglePin"],
[".action-edit", "edit"],
[".action-delete", "_delete"],
[".action-report", "toggleReport"],
[".action-close", "toggleClose"],
],
(obj, event) =>
selector = event[0]
funcName = event[1]
obj["click #{selector}"] = (event) -> @[funcName](event)
obj["keydown #{selector}"] = (event) -> DiscussionUtil.activateOnSpace(event, @[funcName])
obj
,
{}
)
updateButtonState: (selector, checked) =>
$button = @$(selector)
$button.toggleClass("is-checked", checked)
$button.attr("aria-checked", checked)
attrRenderer: $.extend({}, DiscussionContentView.prototype.attrRenderer, {
subscribed: (subscribed) ->
@updateButtonState(".action-follow", subscribed)
endorsed: (endorsed) ->
selector = if @model.get("thread").get("thread_type") == "question" then ".action-answer" else ".action-endorse"
@updateButtonState(selector, endorsed)
$button = @$(selector)
$button.closest(".actions-item").toggleClass("is-hidden", not @model.canBeEndorsed())
$button.toggleClass("is-checked", endorsed)
votes: (votes) ->
selector = ".action-vote"
@updateButtonState(selector, window.user.voted(@model))
button = @$el.find(selector)
numVotes = votes.up_count
button.find(".js-sr-vote-count").html(
interpolate(
ngettext("there is currently %(numVotes)s vote", "there are currently %(numVotes)s votes", numVotes),
{numVotes: numVotes},
true
)
)
votesHtml = interpolate(
ngettext("%(numVotes)s Vote", "%(numVotes)s Votes", numVotes),
{numVotes: numVotes},
true
)
button.find(".vote-count").html(votesHtml)
@$el.find('.display-vote .vote-count').html(votesHtml)
pinned: (pinned) ->
@updateButtonState(".action-pin", pinned)
@$(".post-label-pinned").toggleClass("is-hidden", not pinned)
abuse_flaggers: (abuse_flaggers) ->
flagged = @model.isFlagged()
@updateButtonState(".action-report", flagged)
@$(".post-label-reported").toggleClass("is-hidden", not flagged)
closed: (closed) ->
@updateButtonState(".action-close", closed)
@$(".post-label-closed").toggleClass("is-hidden", not closed)
@$(".display-vote").toggle(closed)
})
toggleSecondaryActions: (event) =>
event.preventDefault()
event.stopPropagation()
@secondaryActionsExpanded = !@secondaryActionsExpanded
@$(".action-more").toggleClass("is-expanded", @secondaryActionsExpanded)
@$(".actions-dropdown").
toggleClass("is-expanded", @secondaryActionsExpanded).
attr("aria-expanded", @secondaryActionsExpanded)
if @secondaryActionsExpanded
if event.type == "keydown"
@$(".action-list-item:first").focus()
$("body").on("click", @toggleSecondaryActions)
$("body").on("keydown", @handleSecondaryActionEscape)
@$(".action-list-item").on("blur", @handleSecondaryActionBlur)
else
$("body").off("click", @toggleSecondaryActions)
$("body").off("keydown", @handleSecondaryActionEscape)
@$(".action-list-item").off("blur", @handleSecondaryActionBlur)
handleSecondaryActionEscape: (event) =>
if event.keyCode == 27 # Esc
@toggleSecondaryActions(event)
@$(".action-more").focus()
handleSecondaryActionBlur: (event) =>
setTimeout(
=>
if @secondaryActionsExpanded && @$(".actions-dropdown :focus").length == 0
@toggleSecondaryActions(event)
,
10
)
toggleFollow: (event) =>
event.preventDefault()
is_subscribing = not @model.get("subscribed")
url = @model.urlFor(if is_subscribing then "follow" else "unfollow")
if is_subscribing
msg = gettext("We had some trouble subscribing you to this thread. Please try again.")
else
msg = gettext("We had some trouble unsubscribing you from this thread. Please try again.")
DiscussionUtil.updateWithUndo(
@model,
{"subscribed": is_subscribing},
{url: url, type: "POST", $elem: $(event.currentTarget)},
msg
)
toggleEndorse: (event) =>
event.preventDefault()
is_endorsing = not @model.get("endorsed")
url = @model.urlFor("endorse")
updates =
endorsed: is_endorsing
endorsement: if is_endorsing then {username: DiscussionUtil.getUser().get("username"), user_id: DiscussionUtil.getUser().id, time: new Date().toISOString()} else null
if @model.get('thread').get('thread_type') == 'question'
if is_endorsing
msg = gettext("We had some trouble marking this response as an answer. Please try again.")
else
msg = gettext("We had some trouble removing this response as an answer. Please try again.")
else
if is_endorsing
msg = gettext("We had some trouble marking this response endorsed. Please try again.")
else
msg = gettext("We had some trouble removing this endorsement. Please try again.")
beforeFunc = () => @trigger("comment:endorse")
DiscussionUtil.updateWithUndo(
@model,
updates,
{url: url, type: "POST", data: {endorsed: is_endorsing}, beforeSend: beforeFunc, $elem: $(event.currentTarget)},
msg
).always(@trigger("comment:endorse")) # ensures UI components get updated to the correct state when ajax completes
toggleVote: (event) =>
event.preventDefault()
user = DiscussionUtil.getUser()
is_voting = not user.voted(@model)
url = @model.urlFor(if is_voting then "upvote" else "unvote")
updates =
upvoted_ids: (if is_voting then _.union else _.difference)(user.get('upvoted_ids'), [@model.id])
DiscussionUtil.updateWithUndo(
user,
updates,
{url: url, type: "POST", $elem: $(event.currentTarget)},
gettext("We had some trouble saving your vote. Please try again.")
).done(() => if is_voting then @model.vote() else @model.unvote())
togglePin: (event) =>
event.preventDefault()
is_pinning = not @model.get("pinned")
url = @model.urlFor(if is_pinning then "pinThread" else "unPinThread")
if is_pinning
msg = gettext("We had some trouble pinning this thread. Please try again.")
else
msg = gettext("We had some trouble unpinning this thread. Please try again.")
DiscussionUtil.updateWithUndo(
@model,
{pinned: is_pinning},
{url: url, type: "POST", $elem: $(event.currentTarget)},
msg
)
toggleReport: (event) =>
event.preventDefault()
if @model.isFlagged()
is_flagging = false
msg = gettext("We had some trouble removing your flag on this post. Please try again.")
else
is_flagging = true
msg = gettext("We had some trouble reporting this post. Please try again.")
url = @model.urlFor(if is_flagging then "flagAbuse" else "unFlagAbuse")
updates =
abuse_flaggers: (if is_flagging then _.union else _.difference)(@model.get("abuse_flaggers"), [DiscussionUtil.getUser().id])
DiscussionUtil.updateWithUndo(
@model,
updates,
{url: url, type: "POST", $elem: $(event.currentTarget)},
msg
)
toggleClose: (event) =>
event.preventDefault()
is_closing = not @model.get('closed')
if is_closing
msg = gettext("We had some trouble closing this thread. Please try again.")
else
msg = gettext("We had some trouble reopening this thread. Please try again.")
updates = {closed: is_closing}
DiscussionUtil.updateWithUndo(
@model,
updates,
{url: @model.urlFor("close"), type: "POST", data: updates, $elem: $(event.currentTarget)},
msg
)
getAuthorDisplay: ->
_.template($("#post-user-display-template").html())(
username: @model.get('username') || null
user_url: @model.get('user_url')
is_community_ta: @model.get('community_ta_authored')
is_staff: @model.get('staff_authored')
)
getEndorserDisplay: ->
endorsement = @model.get('endorsement')
if endorsement and endorsement.username
_.template($("#post-user-display-template").html())(
username: endorsement.username
user_url: DiscussionUtil.urlFor('user_profile', endorsement.user_id)
is_community_ta: DiscussionUtil.isTA(endorsement.user_id)
is_staff: DiscussionUtil.isStaff(endorsement.user_id)
)
else
null

View File

@@ -1,527 +0,0 @@
if Backbone?
class @DiscussionThreadListView extends Backbone.View
events:
"click .forum-nav-browse": "toggleBrowseMenu"
"keypress .forum-nav-browse-filter-input": (event) => DiscussionUtil.ignoreEnterKey(event)
"keyup .forum-nav-browse-filter-input": "filterTopics"
"click .forum-nav-browse-menu-wrapper": "ignoreClick"
"click .forum-nav-browse-title": "selectTopicHandler"
"keydown .forum-nav-search-input": "performSearch"
"click .fa-search": "performSearch"
"change .forum-nav-sort-control": "sortThreads"
"click .forum-nav-thread-link": "threadSelected"
"click .forum-nav-load-more-link": "loadMorePages"
"change .forum-nav-filter-main-control": "chooseFilter"
"change .forum-nav-filter-cohort-control": "chooseCohort"
initialize: (options) ->
@courseSettings = options.courseSettings
@displayedCollection = new Discussion(@collection.models, pages: @collection.pages)
@collection.on "change", @reloadDisplayedCollection
@discussionIds=""
@collection.on "reset", (discussion) =>
board = $(".current-board").html()
@displayedCollection.current_page = discussion.current_page
@displayedCollection.pages = discussion.pages
@displayedCollection.reset discussion.models
# TODO: filter correctly
# target = _.filter($("a.topic:contains('#{board}')"), (el) -> el.innerText == "General" || el.innerHTML == "General")
# if target.length > 0
# @filterTopic($.Event("filter", {'target': target[0]}))
@collection.on "add", @addAndSelectThread
@sidebar_padding = 10
@boardName
@template = _.template($("#thread-list-template").html())
@current_search = ""
@mode = 'all'
@searchAlertCollection = new Backbone.Collection([], {model: Backbone.Model})
@searchAlertCollection.on "add", (searchAlert) =>
content = _.template(
$("#search-alert-template").html())(
{'message': searchAlert.attributes.message, 'cid': searchAlert.cid}
)
@$(".search-alerts").append(content)
@$("#search-alert-" + searchAlert.cid + " a.dismiss").bind "click", searchAlert, (event) =>
@removeSearchAlert(event.data.cid)
@searchAlertCollection.on "remove", (searchAlert) =>
@$("#search-alert-" + searchAlert.cid).remove()
@searchAlertCollection.on "reset", =>
@$(".search-alerts").empty()
addSearchAlert: (message) =>
m = new Backbone.Model({"message": message})
@searchAlertCollection.add(m)
m
removeSearchAlert: (searchAlert) =>
@searchAlertCollection.remove(searchAlert)
clearSearchAlerts: =>
@searchAlertCollection.reset()
reloadDisplayedCollection: (thread) =>
@clearSearchAlerts()
thread_id = thread.get('id')
content = @renderThread(thread)
current_el = @$(".forum-nav-thread[data-id=#{thread_id}]")
active = current_el.has(".forum-nav-thread-link.is-active").length != 0
current_el.replaceWith(content)
@showMetadataAccordingToSort()
if active
@setActiveThread(thread_id)
#TODO fix this entire chain of events
addAndSelectThread: (thread) =>
commentable_id = thread.get("commentable_id")
menuItem = @$(".forum-nav-browse-menu-item[data-discussion-id]").filter(-> $(this).data("discussion-id") == commentable_id)
@setCurrentTopicDisplay(@getPathText(menuItem))
@retrieveDiscussion commentable_id, =>
@trigger "thread:created", thread.get('id')
updateSidebar: =>
scrollTop = $(window).scrollTop();
windowHeight = $(window).height();
discussionBody = $(".discussion-column")
discussionsBodyTop = if discussionBody[0] then discussionBody.offset().top
discussionsBodyBottom = discussionsBodyTop + discussionBody.outerHeight()
sidebar = $(".forum-nav")
if scrollTop > discussionsBodyTop - @sidebar_padding
sidebar.css('top', scrollTop - discussionsBodyTop + @sidebar_padding);
else
sidebar.css('top', '0');
sidebarHeight = windowHeight - Math.max(discussionsBodyTop - scrollTop, @sidebar_padding)
topOffset = scrollTop + windowHeight
discussionBottomOffset = discussionsBodyBottom + @sidebar_padding
amount = Math.max(topOffset - discussionBottomOffset, 0)
sidebarHeight = sidebarHeight - @sidebar_padding - amount
sidebarHeight = Math.min(sidebarHeight + 1, discussionBody.outerHeight())
sidebar.css 'height', sidebarHeight
headerHeight = @$(".forum-nav-header").outerHeight()
refineBarHeight = @$(".forum-nav-refine-bar").outerHeight()
browseFilterHeight = @$(".forum-nav-browse-filter").outerHeight()
@$('.forum-nav-thread-list').css('height', (sidebarHeight - headerHeight - refineBarHeight - 2) + 'px')
@$('.forum-nav-browse-menu').css('height', (sidebarHeight - headerHeight - browseFilterHeight - 2) + 'px')
# Because we want the behavior that when the body is clicked the menu is
# closed, we need to stop propagation of a click in any part of the menu
# that is not a link.
ignoreClick: (event) ->
event.stopPropagation()
render: ->
@timer = 0
@$el.html(
@template({
isCohorted: @courseSettings.get("is_cohorted"),
isPrivilegedUser: DiscussionUtil.isPrivilegedUser()
})
)
@$(".forum-nav-sort-control option").removeProp("selected")
@$(".forum-nav-sort-control option[value=#{@collection.sort_preference}]").prop("selected", true)
$(window).bind "load scroll resize", @updateSidebar
@displayedCollection.on "reset", @renderThreads
@displayedCollection.on "thread:remove", @renderThreads
@displayedCollection.on "change:commentable_id", (model, commentable_id) =>
@retrieveDiscussions @discussionIds.split(",") if @mode is "commentables"
@renderThreads()
@
renderThreads: =>
@$(".forum-nav-thread-list").html("")
rendered = $("<div></div>")
for thread in @displayedCollection.models
content = @renderThread(thread)
rendered.append content
@$(".forum-nav-thread-list").html(rendered.html())
@showMetadataAccordingToSort()
@renderMorePages()
@updateSidebar()
@trigger "threads:rendered"
showMetadataAccordingToSort: () =>
# Ensure that threads display metadata appropriate for the current sort
voteCounts = @$(".forum-nav-thread-votes-count")
commentCounts = @$(".forum-nav-thread-comments-count")
voteCounts.hide()
commentCounts.hide()
switch @$(".forum-nav-sort-control").val()
when "activity", "comments"
commentCounts.show()
when "votes"
voteCounts.show()
renderMorePages: ->
if @displayedCollection.hasMorePages()
@$(".forum-nav-thread-list").append("<li class='forum-nav-load-more'><a href='#' class='forum-nav-load-more-link'>" + gettext("Load more") + "</a></li>")
getLoadingContent: (srText) ->
return '<div class="forum-nav-loading" tabindex="0"><span class="icon fa fa-spinner fa-spin"/><span class="sr" role="alert">' + srText + '</span></div>'
loadMorePages: (event) =>
if event
event.preventDefault()
loadMoreElem = @$(".forum-nav-load-more")
loadMoreElem.html(@getLoadingContent(gettext("Loading more threads")))
loadingElem = loadMoreElem.find(".forum-nav-loading")
DiscussionUtil.makeFocusTrap(loadingElem)
loadingElem.focus()
options = {filter: @filter}
switch @mode
when 'search'
options.search_text = @current_search
if @group_id
options.group_id = @group_id
when 'followed'
options.user_id = window.user.id
when 'commentables'
options.commentable_ids = @discussionIds
if @group_id
options.group_id = @group_id
when 'all'
if @group_id
options.group_id = @group_id
lastThread = @collection.last()?.get('id')
if lastThread
# Pagination; focus the first thread after what was previously the last thread
@once("threads:rendered", ->
$(".forum-nav-thread[data-id='#{lastThread}'] + .forum-nav-thread .forum-nav-thread-link").focus()
)
else
# Totally refreshing the list (e.g. from clicking a sort button); focus the first thread
@once("threads:rendered", ->
$(".forum-nav-thread-link").first()?.focus()
)
error = =>
@renderThreads()
DiscussionUtil.discussionAlert(gettext("Sorry"), gettext("We had some trouble loading more threads. Please try again."))
@collection.retrieveAnotherPage(@mode, options, {sort_key: @$(".forum-nav-sort-control").val()}, error)
renderThread: (thread) =>
content = $(_.template($("#thread-list-item-template").html())(thread.toJSON()))
unreadCount = thread.get('unread_comments_count') + (if thread.get("read") then 0 else 1)
if unreadCount > 0
content.find('.forum-nav-thread-comments-count').attr(
"data-tooltip",
interpolate(
ngettext('%(unread_count)s new comment', '%(unread_count)s new comments', unreadCount),
{unread_count: unreadCount},
true
)
)
content
threadSelected: (e) =>
# Use .attr('data-id') rather than .data('id') because .data does type
# coercion. Usually, this is fine, but when Mongo gives an object id with
# no letters, it casts it to a Number.
thread_id = $(e.target).closest(".forum-nav-thread").attr("data-id")
@setActiveThread(thread_id)
@trigger("thread:selected", thread_id) # This triggers a callback in the DiscussionRouter which calls the line above...
false
threadRemoved: (thread_id) =>
@trigger("thread:removed", thread_id)
setActiveThread: (thread_id) ->
@$(".forum-nav-thread-link").find(".sr").remove()
@$(".forum-nav-thread[data-id!='#{thread_id}'] .forum-nav-thread-link").removeClass("is-active")
@$(".forum-nav-thread[data-id='#{thread_id}'] .forum-nav-thread-link").addClass("is-active").find(".forum-nav-thread-wrapper-1").prepend('<span class="sr">' + gettext("Current conversation") + '</span>')
goHome: ->
@template = _.template($("#discussion-home-template").html())
$(".forum-content").html(@template)
$(".forum-nav-thread-list a").removeClass("is-active").find(".sr").remove()
$("input.email-setting").bind "click", @updateEmailNotifications
url = DiscussionUtil.urlFor("notifications_status",window.user.get("id"))
DiscussionUtil.safeAjax
url: url
type: "GET"
success: (response, textStatus) =>
if response.status
$('input.email-setting').attr('checked','checked')
else
$('input.email-setting').removeAttr('checked')
thread_id = null
@trigger("thread:removed")
#select all threads
isBrowseMenuVisible: =>
@$(".forum-nav-browse-menu-wrapper").is(":visible")
showBrowseMenu: =>
if not @isBrowseMenuVisible()
@$(".forum-nav-browse").addClass("is-active")
@$(".forum-nav-browse-menu-wrapper").show()
@$(".forum-nav-thread-list-wrapper").hide()
$(".forum-nav-browse-filter-input").focus()
$("body").bind "click", @hideBrowseMenu
@updateSidebar()
hideBrowseMenu: =>
if @isBrowseMenuVisible()
@$(".forum-nav-browse").removeClass("is-active")
@$(".forum-nav-browse-menu-wrapper").hide()
@$(".forum-nav-thread-list-wrapper").show()
$("body").unbind "click", @hideBrowseMenu
@updateSidebar()
toggleBrowseMenu: (event) =>
event.preventDefault()
event.stopPropagation()
if @isBrowseMenuVisible()
@hideBrowseMenu()
else
@showBrowseMenu()
# Given a menu item, get the text for it and its ancestors
# (starting from the root, separated by " / ")
getPathText: (item) ->
path = item.parents(".forum-nav-browse-menu-item").andSelf()
pathTitles = path.children(".forum-nav-browse-title").map((i, elem) -> $(elem).text()).get()
pathText = pathTitles.join(" / ")
filterTopics: (event) =>
query = $(event.target).val()
items = @$(".forum-nav-browse-menu-item")
if query.length == 0
items.show()
else
# If all filter terms occur in the path to an item then that item and
# all its descendants are displayed
items.hide()
items.each (i, item) =>
item = $(item)
if not item.is(":visible")
pathText = @getPathText(item).toLowerCase()
if query.split(" ").every((term) -> pathText.search(term.toLowerCase()) != -1)
path = item.parents(".forum-nav-browse-menu-item").andSelf()
path.add(item.find(".forum-nav-browse-menu-item")).show()
setCurrentTopicDisplay: (text) ->
@$(".forum-nav-browse-current").text(@fitName(text))
getNameWidth: (name) ->
test = $("<div>")
test.css
"font-size": @$(".forum-nav-browse-current").css('font-size')
opacity: 0
position: 'absolute'
left: -1000
top: -1000
$("body").append(test)
test.html(name)
width = test.width()
test.remove()
return width
fitName: (name) ->
@maxNameWidth = @$(".forum-nav-browse").width() -
@$(".forum-nav-browse .icon").outerWidth(true) -
@$(".forum-nav-browse-drop-arrow").outerWidth(true)
width = @getNameWidth(name)
if width < @maxNameWidth
return name
path = (x.replace /^\s+|\s+$/g, "" for x in name.split("/"))
prefix = ""
while path.length > 1
prefix = gettext("") + "/"
path.shift()
partialName = prefix + path.join("/")
if @getNameWidth(partialName) < @maxNameWidth
return partialName
rawName = path[0]
name = prefix + rawName
while @getNameWidth(name) > @maxNameWidth
rawName = rawName[0...rawName.length-1]
name = prefix + rawName + gettext("")
return name
selectTopicHandler: (event) ->
event.preventDefault()
@selectTopic $(event.target)
selectTopic: ($target) ->
@hideBrowseMenu()
@clearSearch()
item = $target.closest('.forum-nav-browse-menu-item')
@setCurrentTopicDisplay(@getPathText(item))
if item.hasClass("forum-nav-browse-menu-all")
@discussionIds = ""
@$('.forum-nav-filter-cohort').show()
@retrieveAllThreads()
else if item.hasClass("forum-nav-browse-menu-following")
@retrieveFollowed()
@$('.forum-nav-filter-cohort').hide()
else
allItems = item.find(".forum-nav-browse-menu-item").andSelf()
discussionIds = allItems.filter("[data-discussion-id]").map(
(i, elem) -> $(elem).data("discussion-id")
).get()
@retrieveDiscussions(discussionIds)
@$(".forum-nav-filter-cohort").toggle(item.data('cohorted') == true)
chooseFilter: (event) =>
@filter = $(".forum-nav-filter-main-control :selected").val()
@retrieveFirstPage()
chooseCohort: (event) =>
@group_id = @$('.forum-nav-filter-cohort-control :selected').val()
@retrieveFirstPage()
retrieveDiscussion: (discussion_id, callback=null) ->
url = DiscussionUtil.urlFor("retrieve_discussion", discussion_id)
DiscussionUtil.safeAjax
url: url
type: "GET"
success: (response, textStatus) =>
@collection.current_page = response.page
@collection.pages = response.num_pages
@collection.reset(response.discussion_data)
Content.loadContentInfos(response.annotated_content_info)
@displayedCollection.reset(@collection.models)# Don't think this is necessary because it's called on collection.reset
if callback?
callback()
retrieveDiscussions: (discussion_ids) ->
@discussionIds = discussion_ids.join(',')
@mode = 'commentables'
@retrieveFirstPage()
retrieveAllThreads: () ->
@mode = 'all'
@retrieveFirstPage()
retrieveFirstPage: (event)->
@collection.current_page = 0
@collection.reset()
@loadMorePages(event)
sortThreads: (event) ->
@displayedCollection.setSortComparator(@$(".forum-nav-sort-control").val())
@retrieveFirstPage(event)
performSearch: (event) ->
#event.which 13 represent the Enter button
if event.which == 13 or event.type == 'click'
event.preventDefault()
@hideBrowseMenu()
@setCurrentTopicDisplay(gettext("Search Results"))
text = @$(".forum-nav-search-input").val()
@searchFor(text)
searchFor: (text) ->
@clearSearchAlerts()
@clearFilters()
@mode = 'search'
@current_search = text
url = DiscussionUtil.urlFor("search")
#TODO: This might be better done by setting discussion.current_page=0 and calling discussion.loadMorePages
# Mainly because this currently does not reset any pagination variables which could cause problems.
# This doesn't use pagination either.
DiscussionUtil.safeAjax
$elem: @$(".forum-nav-search-input")
data: { text: text }
url: url
type: "GET"
dataType: 'json'
$loading: $
loadingCallback: =>
@$(".forum-nav-thread-list").html("<li class='forum-nav-load-more'>" + @getLoadingContent(gettext("Loading thread list")) + "</li>")
loadedCallback: =>
@$(".forum-nav-thread-list .forum-nav-load-more").remove()
success: (response, textStatus) =>
if textStatus == 'success'
# TODO: Augment existing collection?
@collection.reset(response.discussion_data)
Content.loadContentInfos(response.annotated_content_info)
@collection.current_page = response.page
@collection.pages = response.num_pages
if !_.isNull response.corrected_text
message = interpolate(
_.escape(gettext('No results found for %(original_query)s. Showing results for %(suggested_query)s.')),
{"original_query": "<em>" + _.escape(text) + "</em>", "suggested_query": "<em>" + response.corrected_text + "</em>"},
true
)
@addSearchAlert(message)
else if response.discussion_data.length == 0
@addSearchAlert(gettext('No threads matched your query.'))
# TODO: Perhaps reload user info so that votes can be updated.
# In the future we might not load all of a user's votes at once
# so this would probably be necessary anyway
@displayedCollection.reset(@collection.models) # Don't think this is necessary
@searchForUser(text) if text
searchForUser: (text) ->
DiscussionUtil.safeAjax
data: { username: text }
url: DiscussionUtil.urlFor("users")
type: "GET"
dataType: 'json'
error: =>
return
success: (response) =>
if response.users.length > 0
message = interpolate(
_.escape(gettext('Show posts by %(username)s.')),
{"username":
_.template('<a class="link-jump" href="<%= url %>"><%- username %></a>')({
url: DiscussionUtil.urlFor("user_profile", response.users[0].id),
username: response.users[0].username
})
},
true
)
@addSearchAlert(message)
clearSearch: ->
@$(".forum-nav-search-input").val("")
@current_search = ""
@clearSearchAlerts()
clearFilters: ->
@$(".forum-nav-filter-main-control").val("all")
@$(".forum-nav-filter-cohort-control").val("all")
retrieveFollowed: () =>
@mode = 'followed'
@retrieveFirstPage()
updateEmailNotifications: () =>
if $('input.email-setting').attr('checked')
DiscussionUtil.safeAjax
url: DiscussionUtil.urlFor("enable_notifications")
type: "POST"
error: () =>
$('input.email-setting').removeAttr('checked')
else
DiscussionUtil.safeAjax
url: DiscussionUtil.urlFor("disable_notifications")
type: "POST"
error: () =>
$('input.email-setting').attr('checked','checked')

View File

@@ -1,21 +0,0 @@
if Backbone?
class @DiscussionThreadProfileView extends Backbone.View
render: ->
@convertMath()
@abbreviateBody()
params = $.extend(@model.toJSON(),{permalink: @model.urlFor('retrieve')})
if not @model.get('anonymous')
params = $.extend(params, user:{username: @model.username, user_url: @model.user_url})
@$el.html(_.template($("#profile-thread-template").html())(params))
@$("span.timeago").timeago()
element = @$(".post-body")
if MathJax?
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element[0]]
@
convertMath: ->
@model.set('markdownBody', DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight @model.get('body'))
abbreviateBody: ->
abbreviated = DiscussionUtil.abbreviateHTML @model.get('markdownBody'), 140
@model.set('abbreviatedBody', abbreviated)

View File

@@ -1,47 +0,0 @@
if Backbone?
class @DiscussionThreadShowView extends DiscussionContentShowView
initialize: (options) ->
super()
@mode = options.mode or "inline" # allowed values are "tab" or "inline"
if @mode not in ["tab", "inline"]
throw new Error("invalid mode: " + @mode)
renderTemplate: ->
@template = _.template($("#thread-show-template").html())
context = $.extend(
{
mode: @mode,
flagged: @model.isFlagged(),
author_display: @getAuthorDisplay(),
cid: @model.cid,
readOnly: $('.discussion-module').data('read-only')
},
@model.attributes,
)
@template(context)
render: ->
@$el.html(@renderTemplate())
@delegateEvents()
@renderAttrs()
@$("span.timeago").timeago()
@convertMath()
@highlight @$(".post-body")
@highlight @$("h1,h3")
@
convertMath: ->
element = @$(".post-body")
element.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight element.text()
if MathJax?
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element[0]]
edit: (event) ->
@trigger "thread:edit", event
_delete: (event) ->
@trigger "thread:_delete", event
highlight: (el) ->
if el.html()
el.html(el.html().replace(/&lt;mark&gt;/g, "<mark>").replace(/&lt;\/mark&gt;/g, "</mark>"))

View File

@@ -1,358 +0,0 @@
if Backbone?
class @DiscussionThreadView extends DiscussionContentView
INITIAL_RESPONSE_PAGE_SIZE = 25
SUBSEQUENT_RESPONSE_PAGE_SIZE = 100
events:
"click .discussion-submit-post": "submitComment"
"click .add-response-btn": "scrollToAddResponse"
"click .forum-thread-expand": "expand"
"click .forum-thread-collapse": "collapse"
$: (selector) ->
@$el.find(selector)
isQuestion: ->
@model.get("thread_type") == "question"
initialize: (options) ->
super()
@mode = options.mode or "inline" # allowed values are "tab" or "inline"
@context = options.context or "course" # allowed values are "course" or "standalone"
@options = _.extend({}, options)
if @mode not in ["tab", "inline"]
throw new Error("invalid mode: " + @mode)
@readOnly = $(".discussion-module").data('read-only')
# Quick fix to have an actual model when we're receiving new models from
# the server.
@model.collection.on "reset", (collection) =>
id = @model.get("id")
@model = collection.get(id) if collection.get(id)
@createShowView()
@responses = new Comments()
@loadedResponses = false
if @isQuestion()
@markedAnswers = new Comments()
rerender: () ->
if @showView?
@showView.undelegateEvents()
@undelegateEvents()
@$el.empty()
@initialize(
mode: @mode
model: @model
el: @el
course_settings: @options.course_settings
topicId: @topicId
)
@render()
renderTemplate: ->
@template = _.template($("#thread-template").html())
container = $("#discussion-container")
if !container.length
# inline discussion
container = $(".discussion-module")
templateData = _.extend(
@model.toJSON(),
readOnly: @readOnly,
can_create_comment: container.data("user-create-comment")
)
@template(templateData)
render: ->
@$el.html(@renderTemplate())
@delegateEvents()
@renderShowView()
@renderAttrs()
@$("span.timeago").timeago()
@makeWmdEditor "reply-body"
@renderAddResponseButton()
@responses.on("add", (response) => @renderResponseToList(response, ".js-response-list", {}))
if @isQuestion()
@markedAnswers.on("add", (response) => @renderResponseToList(response, ".js-marked-answer-list", {collapseComments: true}))
if @mode == "tab"
# Without a delay, jQuery doesn't add the loading extension defined in
# utils.coffee before safeAjax is invoked, which results in an error
setTimeout((=> @loadInitialResponses()), 100)
@$(".post-tools").hide()
else # mode == "inline"
@collapse()
attrRenderer: $.extend({}, DiscussionContentView.prototype.attrRenderer, {
closed: (closed) ->
@$(".discussion-reply-new").toggle(not closed)
@$('.comment-form').closest('li').toggle(not closed)
@$(".action-vote").toggle(not closed)
@$(".display-vote").toggle(closed)
@renderAddResponseButton()
})
expand: (event) ->
if event
event.preventDefault()
@$el.addClass("expanded")
@$el.find(".post-body").text(@model.get("body"))
@showView.convertMath()
@$el.find(".forum-thread-expand").hide()
@$el.find(".forum-thread-collapse").show()
@$el.find(".post-extended-content").show()
if not @loadedResponses
@loadInitialResponses()
collapse: (event) ->
if event
event.preventDefault()
@$el.removeClass("expanded")
@$el.find(".post-body").text(@getAbbreviatedBody())
@showView.convertMath()
@$el.find(".forum-thread-expand").show()
@$el.find(".forum-thread-collapse").hide()
@$el.find(".post-extended-content").hide()
getAbbreviatedBody: ->
cached = @model.get("abbreviatedBody")
if cached
cached
else
abbreviated = DiscussionUtil.abbreviateString @model.get("body"), 140
@model.set("abbreviatedBody", abbreviated)
abbreviated
cleanup: ->
if @responsesRequest?
@responsesRequest.abort()
loadResponses: (responseLimit, elem, firstLoad) ->
# takeFocus take the page focus to response loading element while responses are being fetched.
# - When viewing in the Discussions tab, responses are loaded automatically, Do not scroll to the
# element(TNL-1530)
# - When viewing inline in courseware, user clicks 'expand' to open responses, Its ok to scroll to the
# element (Default)
takeFocus = if @mode == "tab" then false else true
@responsesRequest = DiscussionUtil.safeAjax
url: DiscussionUtil.urlFor('retrieve_single_thread', @model.get('commentable_id'), @model.id)
data:
resp_skip: @responses.size()
resp_limit: responseLimit if responseLimit
$elem: elem
$loading: elem
takeFocus: takeFocus
complete: =>
@responsesRequest = null
success: (data, textStatus, xhr) =>
Content.loadContentInfos(data['annotated_content_info'])
if @isQuestion()
@markedAnswers.add(data["content"]["endorsed_responses"])
@responses.add(
if @isQuestion()
then data["content"]["non_endorsed_responses"]
else data["content"]["children"]
)
@renderResponseCountAndPagination(
if @isQuestion()
then data["content"]["non_endorsed_resp_total"]
else data["content"]["resp_total"]
)
@trigger "thread:responses:rendered"
@loadedResponses = true
@$el.find('.discussion-article[data-id="' + @model.id + '"]').focus() # Sends focus to the discussion once the thread loads
error: (xhr, textStatus) =>
return if textStatus == 'abort'
if xhr.status == 404
DiscussionUtil.discussionAlert(
gettext("Sorry"),
gettext("The thread you selected has been deleted. Please select another thread.")
)
else if firstLoad
DiscussionUtil.discussionAlert(
gettext("Sorry"),
gettext("We had some trouble loading responses. Please reload the page.")
)
else
DiscussionUtil.discussionAlert(
gettext("Sorry"),
gettext("We had some trouble loading more responses. Please try again.")
)
loadInitialResponses: () ->
@loadResponses(INITIAL_RESPONSE_PAGE_SIZE, @$el.find(".js-response-list"), true)
renderResponseCountAndPagination: (responseTotal) =>
if @isQuestion() && @markedAnswers.length != 0
responseCountFormat = ngettext(
"%(numResponses)s other response",
"%(numResponses)s other responses",
responseTotal
)
else
responseCountFormat = ngettext(
"%(numResponses)s response",
"%(numResponses)s responses",
responseTotal
)
@$el.find(".response-count").html(
interpolate(
responseCountFormat,
{numResponses: responseTotal},
true
)
)
responsePagination = @$el.find(".response-pagination")
responsePagination.empty()
if responseTotal > 0
responsesRemaining = responseTotal - @responses.size()
showingResponsesText =
if responsesRemaining == 0
gettext("Showing all responses")
else
interpolate(
ngettext(
"Showing first response",
"Showing first %(numResponses)s responses",
@responses.size()
),
{numResponses: @responses.size()},
true
)
responsePagination.append($("<span>").addClass("response-display-count").html(
_.escape(showingResponsesText)
))
if responsesRemaining > 0
if responsesRemaining < SUBSEQUENT_RESPONSE_PAGE_SIZE
responseLimit = null
buttonText = gettext("Load all responses")
else
responseLimit = SUBSEQUENT_RESPONSE_PAGE_SIZE
buttonText = interpolate(
gettext("Load next %(numResponses)s responses"),
{numResponses: responseLimit},
true
)
loadMoreButton = $("<button>").addClass("load-response-button").html(
_.escape(buttonText)
)
loadMoreButton.click((event) => @loadResponses(responseLimit, loadMoreButton))
responsePagination.append(loadMoreButton)
renderResponseToList: (response, listSelector, options) =>
response.set('thread', @model)
view = new ThreadResponseView($.extend({model: response}, options))
view.on "comment:add", @addComment
view.on "comment:endorse", @endorseThread
view.render()
@$el.find(listSelector).append(view.el)
view.afterInsert()
renderAddResponseButton: =>
if @model.hasResponses() and @model.can('can_reply') and !@model.get('closed')
@$el.find('div.add-response').show()
else
@$el.find('div.add-response').hide()
scrollToAddResponse: (event) ->
event.preventDefault()
form = $(event.target).parents('article.discussion-article').find('form.discussion-reply-new')
$('html, body').scrollTop(form.offset().top)
form.find('.wmd-panel textarea').focus()
addComment: =>
@model.comment()
endorseThread: =>
@model.set 'endorsed', @$el.find(".action-answer.is-checked").length > 0
submitComment: (event) ->
event.preventDefault()
url = @model.urlFor('reply')
body = @getWmdContent("reply-body")
return if not body.trim().length
@setWmdContent("reply-body", "")
comment = new Comment(body: body, created_at: (new Date()).toISOString(), username: window.user.get("username"), votes: { up_count: 0 }, abuse_flaggers:[], endorsed: false, user_id: window.user.get("id"))
comment.set('thread', @model.get('thread'))
@renderResponseToList(comment, ".js-response-list")
@model.addComment()
@renderAddResponseButton()
DiscussionUtil.safeAjax
$elem: $(event.target)
url: url
type: "POST"
dataType: 'json'
data:
body: body
success: (data, textStatus) =>
comment.updateInfo(data.annotated_content_info)
comment.set(data.content)
edit: (event) =>
@createEditView()
@renderEditView()
createEditView: () ->
if @showView?
@showView.undelegateEvents()
@showView.$el.empty()
@showView = null
@editView = new DiscussionThreadEditView(
container: @$('.thread-content-wrapper')
model: @model
mode: @mode
context: @context
course_settings: @options.course_settings
)
@editView.bind "thread:updated thread:cancel_edit", @closeEditView
@editView.bind "comment:endorse", @endorseThread
renderSubView: (view) ->
view.setElement(@$('.thread-content-wrapper'))
view.render()
view.delegateEvents()
renderEditView: () ->
@editView.render()
createShowView: () ->
@showView = new DiscussionThreadShowView({model: @model, mode: @mode})
@showView.bind "thread:_delete", @_delete
@showView.bind "thread:edit", @edit
renderShowView: () ->
@renderSubView(@showView)
closeEditView: (event) =>
@createShowView()
@renderShowView()
@renderAttrs()
# next call is necessary to re-render the post action controls after
# submitting or cancelling a thread edit in inline mode.
@$el.find(".post-extended-content").show()
# If you use "delete" here, it will compile down into JS that includes the
# use of DiscussionThreadView.prototype.delete, and that will break IE8
# because "delete" is a keyword. So, using an underscore to prevent that.
_delete: (event) =>
url = @model.urlFor('_delete')
if not @model.can('can_delete')
return
if not confirm gettext("Are you sure you want to delete this post?")
return
@model.remove()
@showView.undelegateEvents()
@undelegateEvents()
@$el.empty()
$elem = $(event.target)
DiscussionUtil.safeAjax
$elem: $elem
url: url
type: "POST"
success: (response, textStatus) =>

View File

@@ -1,43 +0,0 @@
if Backbone?
class @DiscussionUserProfileView extends Backbone.View
events:
"click .discussion-paginator a": "changePage"
initialize: (options) ->
super()
@page = options.page
@numPages = options.numPages
@discussion = new Discussion()
@discussion.on("reset", @render)
@discussion.reset(@collection, {silent: false})
render: () =>
@$el.html(_.template($("#user-profile-template").html())({threads: @discussion.models}))
@discussion.map (thread) ->
new DiscussionThreadProfileView(el: @$("article#thread_#{thread.id}"), model: thread).render()
baseUri = URI(window.location).removeSearch("page")
pageUrlFunc = (page) -> baseUri.clone().addSearch("page", page)
paginationParams = DiscussionUtil.getPaginationParams(@page, @numPages, pageUrlFunc)
@$el.find(".discussion-pagination").html(_.template($("#pagination-template").html())(paginationParams))
changePage: (event) ->
event.preventDefault()
url = $(event.target).attr("href")
DiscussionUtil.safeAjax
$elem: @$el
$loading: $(event.target)
takeFocus: true
url: url
type: "GET"
dataType: "json"
success: (response, textStatus, xhr) =>
@page = response.page
@numPages = response.num_pages
@discussion.reset(response.discussion_data, {silent: false})
history.pushState({}, "", url)
$("html, body").animate({ scrollTop: 0 });
error: =>
DiscussionUtil.discussionAlert(
gettext("Sorry"),
gettext("We had some trouble loading the page you requested. Please try again.")
)

View File

@@ -1,125 +0,0 @@
if Backbone?
class @NewPostView extends Backbone.View
initialize: (options) ->
@mode = options.mode or "inline" # allowed values are "tab" or "inline"
if @mode not in ["tab", "inline"]
throw new Error("invalid mode: " + @mode)
@course_settings = options.course_settings
@is_commentable_cohorted = options.is_commentable_cohorted
@topicId = options.topicId
render: () ->
context = _.clone(@course_settings.attributes)
_.extend(context, {
cohort_options: @getCohortOptions(),
is_commentable_cohorted: @is_commentable_cohorted,
mode: @mode,
form_id: @mode + (if @topicId then "-" + @topicId else "")
})
@$el.html(_.template($("#new-post-template").html())(context))
threadTypeTemplate = _.template($("#thread-type-template").html());
if $('.js-group-select').is(':disabled')
$('.group-selector-wrapper').addClass('disabled')
@addField(threadTypeTemplate({form_id: _.uniqueId("form-")}));
if @isTabMode()
@topicView = new DiscussionTopicMenuView {
topicId: @topicId
course_settings: @course_settings
}
@topicView.on('thread:topic_change', @toggleGroupDropdown)
@addField(@topicView.render())
DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), "js-post-body"
addField: (fieldView) ->
@$('.forum-new-post-form-wrapper').append fieldView
isTabMode: () ->
@mode is "tab"
getCohortOptions: () ->
if @course_settings.get("is_cohorted") and DiscussionUtil.isPrivilegedUser()
user_cohort_id = $("#discussion-container").data("user-cohort-id")
_.map @course_settings.get("cohorts"), (cohort) ->
{value: cohort.id, text: cohort.name, selected: cohort.id==user_cohort_id}
else
null
events:
"submit .forum-new-post-form": "createPost"
"change .post-option-input": "postOptionChange"
"click .cancel": "cancel"
"reset .forum-new-post-form": "updateStyles"
toggleGroupDropdown: ($target) ->
if $target.data('cohorted')
$('.js-group-select').prop('disabled', false);
$('.group-selector-wrapper').removeClass('disabled')
else
$('.js-group-select').val('').prop('disabled', true);
$('.group-selector-wrapper').addClass('disabled')
postOptionChange: (event) ->
$target = $(event.target)
$optionElem = $target.closest(".post-option")
if $target.is(":checked")
$optionElem.addClass("is-enabled")
else
$optionElem.removeClass("is-enabled")
createPost: (event) ->
event.preventDefault()
thread_type = @$(".post-type-input:checked").val()
title = @$(".js-post-title").val()
body = @$(".js-post-body").find(".wmd-input").val()
group = @$(".js-group-select option:selected").attr("value")
anonymous = false || @$(".js-anon").is(":checked")
anonymous_to_peers = false || @$(".js-anon-peers").is(":checked")
follow = false || @$(".js-follow").is(":checked")
topicId = if @isTabMode() then @topicView.getCurrentTopicId() else @topicId
url = DiscussionUtil.urlFor('create_thread', topicId)
DiscussionUtil.safeAjax
$elem: $(event.target)
$loading: $(event.target) if event
url: url
type: "POST"
dataType: 'json'
data:
thread_type: thread_type
title: title
body: body
anonymous: anonymous
anonymous_to_peers: anonymous_to_peers
auto_subscribe: follow
group_id: group
error: DiscussionUtil.formErrorHandler(@$(".post-errors"))
success: (response, textStatus) =>
# TODO: Move this out of the callback, this makes it feel sluggish
thread = new Thread response['content']
@$el.hide()
@resetForm()
@collection.add thread
cancel: (event) ->
event.preventDefault()
if not confirm gettext("Your post will be discarded.")
return
@trigger('newPost:cancel')
@resetForm()
resetForm: =>
@$(".forum-new-post-form")[0].reset()
DiscussionUtil.clearFormErrors(@$(".post-errors"))
@$(".wmd-preview p").html("")
if @isTabMode()
@topicView.setTopic(@$("a.topic-title").first())
updateStyles: =>
# form reset doesn't change the style of checkboxes so this event is to do that job
setTimeout(
(=> @$(".post-option-input").trigger("change")),
1
)

View File

@@ -1,25 +0,0 @@
if Backbone?
class @ResponseCommentEditView extends Backbone.View
events:
"click .post-update": "update"
"click .post-cancel": "cancel_edit"
$: (selector) ->
@$el.find(selector)
initialize: ->
super()
render: ->
@template = _.template($("#response-comment-edit-template").html())
@$el.html(@template(@model.toJSON()))
@delegateEvents()
DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), "edit-comment-body"
@
update: (event) ->
@trigger "comment:update", event
cancel_edit: (event) ->
@trigger "comment:cancel_edit", event

View File

@@ -1,44 +0,0 @@
if Backbone?
class @ResponseCommentShowView extends DiscussionContentShowView
tagName: "li"
render: ->
@template = _.template($("#response-comment-show-template").html())
@$el.html(
@template(
_.extend(
{
cid: @model.cid,
author_display: @getAuthorDisplay(),
readOnly: $('.discussion-module').data('read-only')
},
@model.attributes
)
)
)
@delegateEvents()
@renderAttrs()
@$el.find(".timeago").timeago()
@convertMath()
@addReplyLink()
@
addReplyLink: () ->
if @model.hasOwnProperty('parent')
name = @model.parent.get('username') ? gettext("anonymous")
html = "<a href='#comment_#{@model.parent.id}'>@#{name}</a>: "
p = @$('.response-body p:first')
p.prepend(html)
convertMath: ->
body = @$el.find(".response-body")
body.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight body.text()
if MathJax?
MathJax.Hub.Queue ["Typeset", MathJax.Hub, body[0]]
_delete: (event) =>
@trigger "comment:_delete", event
edit: (event) =>
@trigger "comment:edit", event

View File

@@ -1,85 +0,0 @@
if Backbone?
class @ResponseCommentView extends DiscussionContentView
tagName: "li"
$: (selector) ->
@$el.find(selector)
initialize: ->
super()
render: ->
@renderShowView()
@
renderSubView: (view) ->
view.setElement(@$el)
view.render()
view.delegateEvents()
renderShowView: () ->
if not @showView?
if @editView?
@editView.undelegateEvents()
@editView.$el.empty()
@editView = null
@showView = new ResponseCommentShowView(model: @model)
@showView.bind "comment:_delete", @_delete
@showView.bind "comment:edit", @edit
@renderSubView(@showView)
renderEditView: () ->
if not @editView?
if @showView?
@showView.undelegateEvents()
@showView.$el.empty()
@showView = null
@editView = new ResponseCommentEditView(model: @model)
@editView.bind "comment:update", @update
@editView.bind "comment:cancel_edit", @cancelEdit
@renderSubView(@editView)
_delete: (event) =>
event.preventDefault()
if not @model.can('can_delete')
return
if not confirm gettext("Are you sure you want to delete this comment?")
return
url = @model.urlFor('_delete')
$elem = $(event.target)
DiscussionUtil.safeAjax
$elem: $elem
url: url
type: "POST"
success: (response, textStatus) =>
@model.remove()
@$el.remove()
error: =>
DiscussionUtil.discussionAlert(
gettext("Sorry"),
gettext("We had some trouble deleting this comment. Please try again.")
)
cancelEdit: (event) =>
@trigger "comment:cancel_edit", event
@renderShowView()
edit: (event) =>
@trigger "comment:edit", event
@renderEditView()
update: (event) =>
newBody = @editView.$(".edit-comment-body textarea").val()
url = DiscussionUtil.urlFor("update_comment", @model.id)
DiscussionUtil.safeAjax
$elem: $(event.target)
$loading: $(event.target)
url: url
type: "POST"
dataType: "json"
data:
body: newBody
error: DiscussionUtil.formErrorHandler(@$(".edit-comment-form-errors"))
success: (response, textStatus) =>
@model.set("body", newBody)
@cancelEdit()

View File

@@ -1,25 +0,0 @@
if Backbone?
class @ThreadResponseEditView extends Backbone.View
events:
"click .post-update": "update"
"click .post-cancel": "cancel_edit"
$: (selector) ->
@$el.find(selector)
initialize: ->
super()
render: ->
@template = _.template($("#thread-response-edit-template").html())
@$el.html(@template(@model.toJSON()))
@delegateEvents()
DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), "edit-post-body"
@
update: (event) ->
@trigger "response:update", event
cancel_edit: (event) ->
@trigger "response:cancel_edit", event

View File

@@ -1,38 +0,0 @@
if Backbone?
class @ThreadResponseShowView extends DiscussionContentShowView
initialize: ->
super()
@listenTo(@model, "change", @render)
renderTemplate: ->
@template = _.template($("#thread-response-show-template").html())
context = _.extend(
{
cid: @model.cid,
author_display: @getAuthorDisplay(),
endorser_display: @getEndorserDisplay(),
readOnly: $('.discussion-module').data('read-only')
},
@model.attributes
)
@template(context)
render: ->
@$el.html(@renderTemplate())
@delegateEvents()
@renderAttrs()
@$el.find(".posted-details .timeago").timeago()
@convertMath()
@
convertMath: ->
element = @$(".response-body")
element.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight element.text()
if MathJax?
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element[0]]
edit: (event) ->
@trigger "response:edit", event
_delete: (event) ->
@trigger "response:_delete", event

View File

@@ -1,227 +0,0 @@
if Backbone?
class @ThreadResponseView extends DiscussionContentView
tagName: "li"
className: "forum-response"
events:
"click .discussion-submit-comment": "submitComment"
"focus .wmd-input": "showEditorChrome"
$: (selector) ->
@$el.find(selector)
initialize: (options) ->
@collapseComments = options.collapseComments
@createShowView()
@readOnly = $('.discussion-module').data('read-only')
renderTemplate: ->
@template = _.template($("#thread-response-template").html())
container = $("#discussion-container")
if !container.length
# inline discussion
container = $(".discussion-module")
templateData = _.extend(
@model.toJSON(),
wmdId: @model.id ? (new Date()).getTime(),
create_sub_comment: container.data("user-create-subcomment"),
readOnly: @readOnly
)
@template(templateData)
render: ->
@$el.addClass("response_" + @model.get("id"))
@$el.html(@renderTemplate())
@delegateEvents()
@renderShowView()
@renderAttrs()
if @model.get("thread").get("closed")
@hideCommentForm()
@renderComments()
@
afterInsert: ->
@makeWmdEditor "comment-body"
@hideEditorChrome()
hideEditorChrome: ->
@$('.wmd-button-row').hide()
@$('.wmd-preview-container').hide()
@$('.wmd-input').css({
height: '35px',
padding: '5px'
})
@$('.comment-post-control').hide()
showEditorChrome: ->
@$('.wmd-button-row').show()
@$('.wmd-preview-container').show()
@$('.comment-post-control').show()
@$('.wmd-input').css({
height: '125px',
padding: '10px'
})
renderComments: ->
comments = new Comments()
@commentViews = []
comments.comparator = (comment) ->
comment.get('created_at')
collectComments = (comment) ->
comments.add(comment)
children = new Comments(comment.get('children'))
children.each (child) ->
child.parent = comment
collectComments(child)
@model.get('comments').each collectComments
comments.each (comment) => @renderComment(comment, false, null)
if @collapseComments && comments.length
@$(".comments").hide()
@$(".action-show-comments").on("click", (event) =>
event.preventDefault()
@$(".action-show-comments").hide()
@$(".comments").show()
)
else
@$(".action-show-comments").hide()
renderComment: (comment) =>
comment.set('thread', @model.get('thread'))
view = new ResponseCommentView(model: comment)
view.render()
if @readOnly
@$el.find('.comments').append(view.el)
else
@$el.find(".comments .new-comment").before(view.el)
view.bind "comment:edit", (event) =>
@cancelEdit(event) if @editView?
@cancelCommentEdits()
@hideCommentForm()
view.bind "comment:cancel_edit", () => @showCommentForm()
@commentViews.push(view)
view
submitComment: (event) ->
event.preventDefault()
url = @model.urlFor('reply')
body = @getWmdContent("comment-body")
return if not body.trim().length
@setWmdContent("comment-body", "")
comment = new Comment(body: body, created_at: (new Date()).toISOString(), username: window.user.get("username"), abuse_flaggers:[], user_id: window.user.get("id"), id:"unsaved")
view = @renderComment(comment)
@hideEditorChrome()
@trigger "comment:add", comment
DiscussionUtil.safeAjax
$elem: $(event.target)
url: url
type: "POST"
dataType: 'json'
data:
body: body
success: (response, textStatus) ->
comment.set(response.content)
comment.updateInfo(response.annotated_content_info)
view.render() # This is just to update the id for the most part, but might be useful in general
_delete: (event) =>
event.preventDefault()
if not @model.can('can_delete')
return
if not confirm gettext("Are you sure you want to delete this response?")
return
url = @model.urlFor('_delete')
@model.remove()
@$el.remove()
$elem = $(event.target)
DiscussionUtil.safeAjax
$elem: $elem
url: url
type: "POST"
success: (response, textStatus) =>
createEditView: () ->
if @showView?
@showView.$el.empty()
if @editView?
@editView.model = @model
else
@editView = new ThreadResponseEditView(model: @model)
@editView.bind "response:update", @update
@editView.bind "response:cancel_edit", @cancelEdit
renderSubView: (view) ->
view.setElement(@$('.discussion-response'))
view.render()
view.delegateEvents()
renderEditView: () ->
@renderSubView(@editView)
cancelCommentEdits: () ->
_.each(@commentViews, (view) -> view.cancelEdit())
hideCommentForm: () ->
@$('.comment-form').closest('li').hide()
showCommentForm: () ->
@$('.comment-form').closest('li').show()
createShowView: () ->
if @editView?
@editView.$el.empty()
if @showView?
@showView.model = @model
else
@showView = new ThreadResponseShowView(model: @model)
@showView.bind "response:_delete", @_delete
@showView.bind "response:edit", @edit
@showView.on "comment:endorse", => @trigger("comment:endorse")
renderShowView: () ->
@renderSubView(@showView)
cancelEdit: (event) =>
event.preventDefault()
@createShowView()
@renderShowView()
@showCommentForm()
edit: (event) =>
@createEditView()
@renderEditView()
@cancelCommentEdits()
@hideCommentForm()
update: (event) =>
newBody = @editView.$(".edit-post-body textarea").val()
url = DiscussionUtil.urlFor('update_comment', @model.id)
DiscussionUtil.safeAjax
$elem: $(event.target)
$loading: $(event.target) if event
url: url
type: "POST"
dataType: 'json'
data:
body: newBody
error: DiscussionUtil.formErrorHandler(@$(".edit-post-form-errors"))
success: (response, textStatus) =>
@editView.$(".edit-post-body textarea").val("").attr("prev-text", "")
@editView.$(".wmd-preview p").html("")
@model.set
body: newBody
@createShowView()
@renderShowView()
@showCommentForm()

View File

@@ -0,0 +1,436 @@
/* globals DiscussionUtil */
(function() {
'use strict';
var __hasProp = {}.hasOwnProperty;
function __extends(child, parent) {
for (var key in parent) {
if (__hasProp.call(parent, key)) {
child[key] = parent[key];
}
}
function ctor() {
this.constructor = child;
}
ctor.prototype = parent.prototype;
child.prototype = new ctor();
child.__super__ = parent.prototype;
return child;
}
var __indexOf = [].indexOf || function(item) {
for (var i = 0, l = this.length; i < l; i++) {
if (i in this && this[i] === item) {
return i;
}
}
return -1;
};
if (typeof Backbone !== "undefined" && Backbone !== null) {
this.Content = (function(_super) {
__extends(Content, _super);
function Content() {
return Content.__super__.constructor.apply(this, arguments);
}
Content.contents = {};
Content.contentInfos = {};
Content.prototype.template = function() {
return DiscussionUtil.getTemplate('_content');
};
Content.prototype.actions = {
editable: '.admin-edit',
can_reply: '.discussion-reply',
can_delete: '.admin-delete',
can_openclose: '.admin-openclose',
can_report: '.admin-report',
can_vote: '.admin-vote'
};
Content.prototype.urlMappers = {};
Content.prototype.urlFor = function(name) {
return this.urlMappers[name].apply(this);
};
Content.prototype.can = function(action) {
return (this.get('ability') || {})[action];
};
Content.prototype.canBeEndorsed = function() {
return false;
};
Content.prototype.updateInfo = function(info) {
if (info) {
this.set('ability', info.ability);
this.set('voted', info.voted);
return this.set('subscribed', info.subscribed);
}
};
Content.prototype.addComment = function(comment, options) {
var comments_count, model, thread;
options = (options) ? options : {};
if (!options.silent) {
thread = this.get('thread');
comments_count = parseInt(thread.get('comments_count'));
thread.set('comments_count', comments_count + 1);
}
this.get('children').push(comment);
model = new Comment($.extend({}, comment, {
thread: this.get('thread')
}));
this.get('comments').add(model);
this.trigger("comment:add");
return model;
};
Content.prototype.removeComment = function(comment) {
var comments_count, thread;
thread = this.get('thread');
comments_count = parseInt(thread.get('comments_count'));
thread.set('comments_count', comments_count - 1 - comment.getCommentsCount());
return this.trigger("comment:remove");
};
Content.prototype.resetComments = function(children) {
var comment, _i, _len, _ref, _results;
this.set('children', []);
this.set('comments', new Comments()); // jshint ignore:line
_ref = children || [];
_results = [];
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
comment = _ref[_i];
_results.push(this.addComment(comment, {
silent: true
}));
}
return _results;
};
Content.prototype.initialize = function() {
var userId;
Content.addContent(this.id, this);
userId = this.get('user_id');
if (userId) {
this.set('staff_authored', DiscussionUtil.isStaff(userId));
this.set('community_ta_authored', DiscussionUtil.isTA(userId));
} else {
this.set('staff_authored', false);
this.set('community_ta_authored', false);
}
if (Content.getInfo(this.id)) {
this.updateInfo(Content.getInfo(this.id));
}
this.set('user_url', DiscussionUtil.urlFor('user_profile', userId));
return this.resetComments(this.get('children'));
};
Content.prototype.remove = function() {
if (this.get('type') === 'comment') {
this.get('thread').removeComment(this);
return this.get('thread').trigger("comment:remove", this);
} else {
return this.trigger("thread:remove", this);
}
};
Content.addContent = function(id, content) {
this.contents[id] = content;
};
Content.getContent = function(id) {
return this.contents[id];
};
Content.getInfo = function(id) {
return this.contentInfos[id];
};
Content.loadContentInfos = function(infos) {
var id, info;
for (id in infos) {
if (infos.hasOwnProperty(id)) {
info = infos[id];
if (this.getContent(id)) {
this.getContent(id).updateInfo(info);
}
}
}
return $.extend(this.contentInfos, infos);
};
Content.prototype.pinThread = function() {
var pinned;
pinned = this.get("pinned");
this.set("pinned", pinned);
return this.trigger("change", this);
};
Content.prototype.unPinThread = function() {
var pinned;
pinned = this.get("pinned");
this.set("pinned", pinned);
return this.trigger("change", this);
};
Content.prototype.flagAbuse = function() {
var temp_array;
temp_array = this.get("abuse_flaggers");
temp_array.push(window.user.get('id'));
this.set("abuse_flaggers", temp_array);
return this.trigger("change", this);
};
Content.prototype.unflagAbuse = function() {
this.get("abuse_flaggers").pop(window.user.get('id'));
return this.trigger("change", this);
};
Content.prototype.isFlagged = function() {
var flaggers, user;
user = DiscussionUtil.getUser();
flaggers = this.get("abuse_flaggers");
return user && (
(__indexOf.call(flaggers, user.id) >= 0) ||
(DiscussionUtil.isPrivilegedUser(user.id) && flaggers.length > 0)
);
};
Content.prototype.incrementVote = function(increment) {
var newVotes;
newVotes = _.clone(this.get("votes"));
newVotes.up_count = newVotes.up_count + increment;
return this.set("votes", newVotes);
};
Content.prototype.vote = function() {
return this.incrementVote(1);
};
Content.prototype.unvote = function() {
return this.incrementVote(-1);
};
return Content;
})(Backbone.Model);
this.Thread = (function(_super) {
__extends(Thread, _super);
function Thread() {
return Thread.__super__.constructor.apply(this, arguments);
}
Thread.prototype.urlMappers = {
'retrieve': function() {
return DiscussionUtil.urlFor('retrieve_single_thread', this.get('commentable_id'), this.id);
},
'reply': function() {
return DiscussionUtil.urlFor('create_comment', this.id);
},
'unvote': function() {
return DiscussionUtil.urlFor("undo_vote_for_" + (this.get('type')), this.id);
},
'upvote': function() {
return DiscussionUtil.urlFor("upvote_" + (this.get('type')), this.id);
},
'downvote': function() {
return DiscussionUtil.urlFor("downvote_" + (this.get('type')), this.id);
},
'close': function() {
return DiscussionUtil.urlFor('openclose_thread', this.id);
},
'update': function() {
return DiscussionUtil.urlFor('update_thread', this.id);
},
'_delete': function() {
return DiscussionUtil.urlFor('delete_thread', this.id);
},
'follow': function() {
return DiscussionUtil.urlFor('follow_thread', this.id);
},
'unfollow': function() {
return DiscussionUtil.urlFor('unfollow_thread', this.id);
},
'flagAbuse': function() {
return DiscussionUtil.urlFor("flagAbuse_" + (this.get('type')), this.id);
},
'unFlagAbuse': function() {
return DiscussionUtil.urlFor("unFlagAbuse_" + (this.get('type')), this.id);
},
'pinThread': function() {
return DiscussionUtil.urlFor("pin_thread", this.id);
},
'unPinThread': function() {
return DiscussionUtil.urlFor("un_pin_thread", this.id);
}
};
Thread.prototype.initialize = function() {
this.set('thread', this);
return Thread.__super__.initialize.call(this);
};
Thread.prototype.comment = function() {
return this.set("comments_count", parseInt(this.get("comments_count")) + 1);
};
Thread.prototype.follow = function() {
return this.set('subscribed', true);
};
Thread.prototype.unfollow = function() {
return this.set('subscribed', false);
};
Thread.prototype.display_body = function() {
if (this.has("highlighted_body")) {
return String(this.get("highlighted_body"))
.replace(/<highlight>/g, '<mark>')
.replace(/<\/highlight>/g, '</mark>');
} else {
return this.get("body");
}
};
Thread.prototype.display_title = function() {
if (this.has("highlighted_title")) {
return String(this.get("highlighted_title"))
.replace(/<highlight>/g, '<mark>')
.replace(/<\/highlight>/g, '</mark>');
} else {
return this.get("title");
}
};
Thread.prototype.toJSON = function() {
var json_attributes;
json_attributes = _.clone(this.attributes);
return _.extend(json_attributes, {
title: this.display_title(),
body: this.display_body()
});
};
Thread.prototype.created_at_date = function() {
return new Date(this.get("created_at"));
};
Thread.prototype.created_at_time = function() {
return new Date(this.get("created_at")).getTime();
};
Thread.prototype.hasResponses = function() {
return this.get('comments_count') > 0;
};
return Thread;
})(this.Content);
this.Comment = (function(_super) {
__extends(Comment, _super);
function Comment() {
var self = this;
this.canBeEndorsed = function() {
return Comment.prototype.canBeEndorsed.apply(self, arguments);
};
return Comment.__super__.constructor.apply(this, arguments);
}
Comment.prototype.urlMappers = {
'reply': function() {
return DiscussionUtil.urlFor('create_sub_comment', this.id);
},
'unvote': function() {
return DiscussionUtil.urlFor("undo_vote_for_" + (this.get('type')), this.id);
},
'upvote': function() {
return DiscussionUtil.urlFor("upvote_" + (this.get('type')), this.id);
},
'downvote': function() {
return DiscussionUtil.urlFor("downvote_" + (this.get('type')), this.id);
},
'endorse': function() {
return DiscussionUtil.urlFor('endorse_comment', this.id);
},
'update': function() {
return DiscussionUtil.urlFor('update_comment', this.id);
},
'_delete': function() {
return DiscussionUtil.urlFor('delete_comment', this.id);
},
'flagAbuse': function() {
return DiscussionUtil.urlFor("flagAbuse_" + (this.get('type')), this.id);
},
'unFlagAbuse': function() {
return DiscussionUtil.urlFor("unFlagAbuse_" + (this.get('type')), this.id);
}
};
Comment.prototype.getCommentsCount = function() {
var count;
count = 0;
this.get('comments').each(function(comment) {
return count += comment.getCommentsCount() + 1;
});
return count;
};
Comment.prototype.canBeEndorsed = function() {
var user_id;
user_id = window.user.get("id");
return user_id && (
DiscussionUtil.isPrivilegedUser(user_id) ||
(
this.get('thread').get('thread_type') === 'question' &&
this.get('thread').get('user_id') === user_id
)
);
};
return Comment;
})(this.Content);
this.Comments = (function(_super) {
__extends(Comments, _super);
function Comments() {
return Comments.__super__.constructor.apply(this, arguments);
}
Comments.prototype.model = Comment;
Comments.prototype.initialize = function() {
var self = this;
return this.bind("add", function(item) {
item.collection = self;
});
};
Comments.prototype.find = function(id) {
return _.first(this.where({
id: id
}));
};
return Comments;
})(Backbone.Collection);
}
}).call(window);

View File

@@ -0,0 +1,222 @@
/* globals Thread, DiscussionUtil, Content */
(function() {
'use strict';
var __hasProp = {}.hasOwnProperty,
__extends = function(child, parent) {
for (var key in parent) {
if (__hasProp.call(parent, key)) {
child[key] = parent[key];
}
}
function ctor() {
this.constructor = child;
}
ctor.prototype = parent.prototype;
child.prototype = new ctor();
child.__super__ = parent.prototype;
return child;
};
if (typeof Backbone !== "undefined" && Backbone !== null) {
this.Discussion = (function(_super) {
__extends(Discussion, _super);
function Discussion() {
return Discussion.__super__.constructor.apply(this, arguments);
}
Discussion.prototype.model = Thread;
Discussion.prototype.initialize = function(models, options) {
var self = this;
if (!options) {
options = {};
}
this.pages = options.pages || 1;
this.current_page = 1;
this.sort_preference = options.sort;
this.bind("add", function(item) {
item.discussion = self;
});
this.setSortComparator(this.sort_preference);
return this.on("thread:remove", function(thread) {
self.remove(thread);
});
};
Discussion.prototype.find = function(id) {
return _.first(this.where({
id: id
}));
};
Discussion.prototype.hasMorePages = function() {
return this.current_page < this.pages;
};
Discussion.prototype.setSortComparator = function(sortBy) {
switch (sortBy) {
case 'activity':
this.comparator = this.sortByDateRecentFirst;
break;
case 'votes':
this.comparator = this.sortByVotes;
break;
case 'comments':
this.comparator = this.sortByComments;
break;
}
};
Discussion.prototype.addThread = function(thread) {
var model;
if (!this.find(thread.id)) {
model = new Thread(thread);
this.add(model);
return model;
}
};
Discussion.prototype.retrieveAnotherPage = function(mode, options, sort_options, error) {
var data, url,
self = this;
if (!options) {
options = {};
}
if (!sort_options) {
sort_options = {};
}
data = {
page: this.current_page + 1
};
if (_.contains(["unread", "unanswered", "flagged"], options.filter)) {
data[options.filter] = true;
}
switch (mode) {
case 'search':
url = DiscussionUtil.urlFor('search');
data.text = options.search_text;
break;
case 'commentables':
url = DiscussionUtil.urlFor('search');
data.commentable_ids = options.commentable_ids;
break;
case 'all':
url = DiscussionUtil.urlFor('threads');
break;
case 'followed':
url = DiscussionUtil.urlFor('followed_threads', options.user_id);
}
if (options.group_id) {
data.group_id = options.group_id;
}
data.sort_key = sort_options.sort_key || 'activity';
data.sort_order = sort_options.sort_order || 'desc';
return DiscussionUtil.safeAjax({
$elem: this.$el,
url: url,
data: data,
dataType: 'json',
success: function(response) {
var models, new_collection, new_threads;
models = self.models;
new_threads = [
(function() {
var _i, _len, _ref, _results;
_ref = response.discussion_data;
_results = [];
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
data = _ref[_i];
_results.push(new Thread(data));
}
return _results;
})()
][0];
new_collection = _.union(models, new_threads);
Content.loadContentInfos(response.annotated_content_info);
self.pages = response.num_pages;
self.current_page = response.page;
return self.reset(new_collection);
},
error: error
});
};
Discussion.prototype.sortByDate = function(thread) {
/*
The comment client asks each thread for a value by which to sort the collection
and calls this sort routine regardless of the order returned from the LMS/comments service
so, this takes advantage of this per-thread value and returns tomorrow's date
for pinned threads, ensuring that they appear first, (which is the intent of pinned threads)
*/
return this.pinnedThreadsSortComparatorWithDate(thread, true);
};
Discussion.prototype.sortByDateRecentFirst = function(thread) {
/*
Same as above
but negative to flip the order (newest first)
*/
return this.pinnedThreadsSortComparatorWithDate(thread, false);
};
Discussion.prototype.sortByVotes = function(thread1, thread2) {
var thread1_count, thread2_count;
thread1_count = parseInt(thread1.get("votes").up_count);
thread2_count = parseInt(thread2.get("votes").up_count);
return this.pinnedThreadsSortComparatorWithCount(thread1, thread2, thread1_count, thread2_count);
};
Discussion.prototype.sortByComments = function(thread1, thread2) {
var thread1_count, thread2_count;
thread1_count = parseInt(thread1.get("comments_count"));
thread2_count = parseInt(thread2.get("comments_count"));
return this.pinnedThreadsSortComparatorWithCount(thread1, thread2, thread1_count, thread2_count);
};
Discussion.prototype.pinnedThreadsSortComparatorWithCount = function(
thread1, thread2, thread1_count, thread2_count
) {
if (thread1.get('pinned') && !thread2.get('pinned')) {
return -1;
} else if (thread2.get('pinned') && !thread1.get('pinned')) {
return 1;
} else {
if (thread1_count > thread2_count) {
return -1;
} else if (thread2_count > thread1_count) {
return 1;
} else {
if (thread1.created_at_time() > thread2.created_at_time()) {
return -1;
} else {
return 1;
}
}
}
};
Discussion.prototype.pinnedThreadsSortComparatorWithDate = function(thread, ascending) {
var preferredDate, threadLastActivityAtTime, today;
threadLastActivityAtTime = new Date(thread.get("last_activity_at")).getTime();
if (thread.get('pinned')) {
today = new Date();
preferredDate = new Date(today.getTime() + (24 * 60 * 60 * 1000) + threadLastActivityAtTime);
} else {
preferredDate = threadLastActivityAtTime;
}
if (ascending) {
return preferredDate;
} else {
return -preferredDate;
}
};
return Discussion;
})(Backbone.Collection);
}
}).call(window);

View File

@@ -0,0 +1,266 @@
/* globals Discussion, DiscussionUtil, DiscussionUser, DiscussionCourseSettings, DiscussionThreadView, Content,
NewPostView */
(function() {
'use strict';
var __hasProp = {}.hasOwnProperty,
__extends = function(child, parent) {
for (var key in parent) {
if (__hasProp.call(parent, key)) {
child[key] = parent[key];
}
}
function ctor() {
this.constructor = child;
}
ctor.prototype = parent.prototype;
child.prototype = new ctor();
child.__super__ = parent.prototype;
return child;
};
if (typeof Backbone !== "undefined" && Backbone !== null) {
this.DiscussionModuleView = (function(_super) {
__extends(DiscussionModuleView, _super);
function DiscussionModuleView() {
var self = this;
this.navigateToPage = function() {
return DiscussionModuleView.prototype.navigateToPage.apply(self, arguments);
};
this.renderPagination = function() {
return DiscussionModuleView.prototype.renderPagination.apply(self, arguments);
};
this.addThread = function() {
return DiscussionModuleView.prototype.addThread.apply(self, arguments);
};
this.renderDiscussion = function() {
return DiscussionModuleView.prototype.renderDiscussion.apply(self, arguments);
};
this.loadPage = function() {
return DiscussionModuleView.prototype.loadPage.apply(self, arguments);
};
this.toggleDiscussion = function() {
return DiscussionModuleView.prototype.toggleDiscussion.apply(self, arguments);
};
this.hideDiscussion = function() {
return DiscussionModuleView.prototype.hideDiscussion.apply(self, arguments);
};
this.hideNewPost = function() {
return DiscussionModuleView.prototype.hideNewPost.apply(self, arguments);
};
this.toggleNewPost = function() {
return DiscussionModuleView.prototype.toggleNewPost.apply(self, arguments);
};
return DiscussionModuleView.__super__.constructor.apply(this, arguments);
}
DiscussionModuleView.prototype.events = {
"click .discussion-show": "toggleDiscussion",
"keydown .discussion-show": function(event) {
return DiscussionUtil.activateOnSpace(event, this.toggleDiscussion);
},
"click .new-post-btn": "toggleNewPost",
"keydown .new-post-btn": function(event) {
return DiscussionUtil.activateOnSpace(event, this.toggleNewPost);
},
"click .discussion-paginator a": "navigateToPage"
};
DiscussionModuleView.prototype.page_re = /\?discussion_page=(\d+)/;
DiscussionModuleView.prototype.initialize = function(options) {
var match;
this.toggleDiscussionBtn = this.$(".discussion-show");
match = this.page_re.exec(window.location.href);
this.context = options.context || "course";
if (match) {
this.page = parseInt(match[1]);
} else {
this.page = 1;
}
};
DiscussionModuleView.prototype.toggleNewPost = function(event) {
event.preventDefault();
if (!this.newPostForm) {
this.toggleDiscussion();
this.isWaitingOnNewPost = true;
return;
}
if (this.showed) {
this.newPostForm.slideDown(300);
} else {
this.newPostForm.show().focus();
}
this.toggleDiscussionBtn.addClass('shown');
this.toggleDiscussionBtn.find('.button-text').html(gettext("Hide Discussion"));
this.$("section.discussion").slideDown();
this.showed = true;
};
DiscussionModuleView.prototype.hideNewPost = function() {
return this.newPostForm.slideUp(300);
};
DiscussionModuleView.prototype.hideDiscussion = function() {
this.$("section.discussion").slideUp();
this.toggleDiscussionBtn.removeClass('shown');
this.toggleDiscussionBtn.find('.button-text').html(gettext("Show Discussion"));
this.showed = false;
};
DiscussionModuleView.prototype.toggleDiscussion = function() {
var $elem,
self = this;
if (this.showed) {
return this.hideDiscussion();
} else {
this.toggleDiscussionBtn.addClass('shown');
this.toggleDiscussionBtn.find('.button-text').html(gettext("Hide Discussion"));
if (this.retrieved) {
this.$("section.discussion").slideDown();
this.showed = true;
} else {
$elem = this.toggleDiscussionBtn;
return this.loadPage($elem, function() {
self.hideDiscussion();
return DiscussionUtil.discussionAlert(
gettext("Sorry"),
gettext("We had some trouble loading the discussion. Please try again.")
);
});
}
}
};
DiscussionModuleView.prototype.loadPage = function($elem, error) {
var discussionId, url,
self = this;
discussionId = this.$el.data("discussion-id");
url = DiscussionUtil.urlFor('retrieve_discussion', discussionId) + ("?page=" + this.page);
return DiscussionUtil.safeAjax({
$elem: $elem,
$loading: $elem,
takeFocus: true,
url: url,
type: "GET",
dataType: 'json',
success: function(response, textStatus) {
return self.renderDiscussion($elem, response, textStatus, discussionId);
},
error: error
});
};
DiscussionModuleView.prototype.renderDiscussion = function($elem, response, textStatus, discussionId) {
var $discussion, user,
self = this;
$elem.focus();
user = new DiscussionUser(response.user_info);
window.user = user;
DiscussionUtil.setUser(user);
Content.loadContentInfos(response.annotated_content_info);
DiscussionUtil.loadRoles(response.roles);
this.course_settings = new DiscussionCourseSettings(response.course_settings);
this.discussion = new Discussion();
this.discussion.reset(response.discussion_data, {
silent: false
});
$discussion = _.template($("#inline-discussion-template").html())({
'threads': response.discussion_data,
'discussionId': discussionId
});
if (this.$('section.discussion').length) {
this.$('section.discussion').replaceWith($discussion);
} else {
this.$el.append($discussion);
}
this.newPostForm = this.$el.find('.new-post-article');
this.threadviews = this.discussion.map(function(thread) {
var view;
view = new DiscussionThreadView({
el: self.$("article#thread_" + thread.id),
model: thread,
mode: "inline",
context: self.context,
course_settings: self.course_settings,
topicId: discussionId
});
thread.on("thread:thread_type_updated", function() {
view.rerender();
return view.expand();
});
return view;
});
_.each(this.threadviews, function(dtv) {
return dtv.render();
});
DiscussionUtil.bulkUpdateContentInfo(window.$$annotated_content_info);
this.newPostView = new NewPostView({
el: this.newPostForm,
collection: this.discussion,
course_settings: this.course_settings,
topicId: discussionId,
is_commentable_cohorted: response.is_commentable_cohorted
});
this.newPostView.render();
this.listenTo(this.newPostView, 'newPost:cancel', this.hideNewPost);
this.discussion.on("add", this.addThread);
this.retrieved = true;
this.showed = true;
this.renderPagination(response.num_pages);
if (this.isWaitingOnNewPost) {
return this.newPostForm.show().focus();
}
};
DiscussionModuleView.prototype.addThread = function(thread) {
var article, threadView;
article = $("<article class='discussion-thread' id='thread_" + thread.id + "'></article>");
this.$('section.discussion > .threads').prepend(article);
threadView = new DiscussionThreadView({
el: article,
model: thread,
mode: "inline",
context: this.context,
course_settings: this.course_settings,
topicId: this.$el.data("discussion-id")
});
threadView.render();
return this.threadviews.unshift(threadView);
};
DiscussionModuleView.prototype.renderPagination = function(numPages) {
var pageUrl, pagination, params;
pageUrl = function(number) {
return "?discussion_page=" + number;
};
params = DiscussionUtil.getPaginationParams(this.page, numPages, pageUrl);
pagination = _.template($("#pagination-template").html())(params);
return this.$('section.discussion-pagination').html(pagination);
};
DiscussionModuleView.prototype.navigateToPage = function(event) {
var currPage,
self = this;
event.preventDefault();
window.history.pushState({}, window.document.title, event.target.href);
currPage = this.page;
this.page = $(event.target).data('page-number');
return this.loadPage($(event.target), function() {
self.page = currPage;
DiscussionUtil.discussionAlert(
gettext("Sorry"),
gettext("We had some trouble loading the threads you requested. Please try again.")
);
});
};
return DiscussionModuleView;
})(Backbone.View);
}
}).call(window);

View File

@@ -0,0 +1,167 @@
/* globals DiscussionThreadListView, DiscussionThreadView, DiscussionUtil, NewPostView */
(function() {
'use strict';
var __hasProp = {}.hasOwnProperty,
__extends = function(child, parent) {
for (var key in parent) {
if (__hasProp.call(parent, key)) {
child[key] = parent[key];
}
}
function ctor() {
this.constructor = child;
}
ctor.prototype = parent.prototype;
child.prototype = new ctor();
child.__super__ = parent.prototype;
return child;
};
if (typeof Backbone !== "undefined" && Backbone !== null) {
this.DiscussionRouter = (function(_super) {
__extends(DiscussionRouter, _super);
function DiscussionRouter() {
var self = this;
this.hideNewPost = function() {
return DiscussionRouter.prototype.hideNewPost.apply(self, arguments);
};
this.showNewPost = function() {
return DiscussionRouter.prototype.showNewPost.apply(self, arguments);
};
this.navigateToAllThreads = function() {
return DiscussionRouter.prototype.navigateToAllThreads.apply(self, arguments);
};
this.navigateToThread = function() {
return DiscussionRouter.prototype.navigateToThread.apply(self, arguments);
};
this.showMain = function() {
return DiscussionRouter.prototype.showMain.apply(self, arguments);
};
this.setActiveThread = function() {
return DiscussionRouter.prototype.setActiveThread.apply(self, arguments);
};
return DiscussionRouter.__super__.constructor.apply(this, arguments);
}
DiscussionRouter.prototype.routes = {
"": "allThreads",
":forum_name/threads/:thread_id": "showThread"
};
DiscussionRouter.prototype.initialize = function(options) {
var self = this;
this.discussion = options.discussion;
this.course_settings = options.course_settings;
this.nav = new DiscussionThreadListView({
collection: this.discussion,
el: $(".forum-nav"),
courseSettings: this.course_settings
});
this.nav.on("thread:selected", this.navigateToThread);
this.nav.on("thread:removed", this.navigateToAllThreads);
this.nav.on("threads:rendered", this.setActiveThread);
this.nav.on("thread:created", this.navigateToThread);
this.nav.render();
this.newPost = $('.new-post-article');
this.newPostView = new NewPostView({
el: this.newPost,
collection: this.discussion,
course_settings: this.course_settings,
mode: "tab"
});
this.newPostView.render();
this.listenTo(this.newPostView, 'newPost:cancel', this.hideNewPost);
$('.new-post-btn').bind("click", this.showNewPost);
return $('.new-post-btn').bind("keydown", function(event) {
return DiscussionUtil.activateOnSpace(event, self.showNewPost);
});
};
DiscussionRouter.prototype.allThreads = function() {
this.nav.updateSidebar();
return this.nav.goHome();
};
DiscussionRouter.prototype.setActiveThread = function() {
if (this.thread) {
return this.nav.setActiveThread(this.thread.get("id"));
} else {
return this.nav.goHome;
}
};
DiscussionRouter.prototype.showThread = function(forum_name, thread_id) {
this.thread = this.discussion.get(thread_id);
this.thread.set("unread_comments_count", 0);
this.thread.set("read", true);
this.setActiveThread();
return this.showMain();
};
DiscussionRouter.prototype.showMain = function() {
var self = this;
if (this.main) {
this.main.cleanup();
this.main.undelegateEvents();
}
if (!($(".forum-content").is(":visible"))) {
$(".forum-content").fadeIn();
}
if (this.newPost.is(":visible")) {
this.newPost.fadeOut();
}
this.main = new DiscussionThreadView({
el: $(".forum-content"),
model: this.thread,
mode: "tab",
course_settings: this.course_settings
});
this.main.render();
this.main.on("thread:responses:rendered", function() {
return self.nav.updateSidebar();
});
return this.thread.on("thread:thread_type_updated", this.showMain);
};
DiscussionRouter.prototype.navigateToThread = function(thread_id) {
var thread;
thread = this.discussion.get(thread_id);
return this.navigate("" + (thread.get("commentable_id")) + "/threads/" + thread_id, {
trigger: true
});
};
DiscussionRouter.prototype.navigateToAllThreads = function() {
return this.navigate("", {
trigger: true
});
};
DiscussionRouter.prototype.showNewPost = function() {
var self = this;
return $('.forum-content').fadeOut({
duration: 200,
complete: function() {
return self.newPost.fadeIn(200).focus();
}
});
};
DiscussionRouter.prototype.hideNewPost = function() {
return this.newPost.fadeOut({
duration: 200,
complete: function() {
return $('.forum-content').fadeIn(200).find('.thread-wrapper').focus();
}
});
};
return DiscussionRouter;
})(Backbone.Router);
}
}).call(window);

View File

@@ -0,0 +1,76 @@
/* global $$course_id, Content, Discussion, DiscussionRouter, DiscussionCourseSettings,
DiscussionUser, DiscussionUserProfileView, DiscussionUtil */
(function() {
'use strict';
var DiscussionApp, DiscussionProfileApp;
if (typeof Backbone !== "undefined" && Backbone !== null) {
DiscussionApp = {
start: function(elem) {
var content_info, course_settings, discussion, element, sort_preference, thread_pages, threads,
user, user_info;
DiscussionUtil.loadRolesFromContainer();
element = $(elem);
window.$$course_id = element.data("course-id");
window.courseName = element.data("course-name");
user_info = element.data("user-info");
sort_preference = element.data("sort-preference");
threads = element.data("threads");
thread_pages = element.data("thread-pages");
content_info = element.data("content-info");
user = new DiscussionUser(user_info);
DiscussionUtil.setUser(user);
window.user = user;
Content.loadContentInfos(content_info);
discussion = new Discussion(threads, {
pages: thread_pages,
sort: sort_preference
});
course_settings = new DiscussionCourseSettings(element.data("course-settings"));
// suppressing Do not use 'new' for side effects.
/* jshint -W031*/
new DiscussionRouter({
discussion: discussion,
course_settings: course_settings
});
/* jshint +W031*/
return Backbone.history.start({
pushState: true,
root: "/courses/" + $$course_id + "/discussion/forum/"
});
}
};
DiscussionProfileApp = {
start: function(elem) {
var element, numPages, page, threads, user_info;
DiscussionUtil.loadRoles({
"Moderator": [],
"Administrator": [],
"Community TA": []
});
element = $(elem);
window.$$course_id = element.data("course-id");
threads = element.data("threads");
user_info = element.data("user-info");
window.user = new DiscussionUser(user_info);
page = element.data("page");
numPages = element.data("num-pages");
return new DiscussionUserProfileView({
el: element,
collection: threads,
page: page,
numPages: numPages
});
}
};
$(function() {
$("section.discussion").each(function(index, elem) {
return DiscussionApp.start(elem);
});
return $("section.discussion-user-threads").each(function(index, elem) {
return DiscussionProfileApp.start(elem);
});
});
}
}).call(window);

View File

@@ -0,0 +1,34 @@
(function() {
'use strict';
var __hasProp = {}.hasOwnProperty,
__extends = function(child, parent) {
for (var key in parent) {
if (__hasProp.call(parent, key)) {
child[key] = parent[key];
}
}
function ctor() {
this.constructor = child;
}
ctor.prototype = parent.prototype;
child.prototype = new ctor();
child.__super__ = parent.prototype;
return child;
};
if (typeof Backbone !== "undefined" && Backbone !== null) {
this.DiscussionCourseSettings = (function(_super) {
__extends(DiscussionCourseSettings, _super);
function DiscussionCourseSettings() {
return DiscussionCourseSettings.__super__.constructor.apply(this, arguments);
}
return DiscussionCourseSettings;
})(Backbone.Model);
}
}).call(this);

View File

@@ -0,0 +1,52 @@
(function() {
'use strict';
var __hasProp = {}.hasOwnProperty,
__extends = function(child, parent) {
for (var key in parent) {
if (__hasProp.call(parent, key)) {
child[key] = parent[key];
}
}
function ctor() {
this.constructor = child;
}
ctor.prototype = parent.prototype;
child.prototype = new ctor();
child.__super__ = parent.prototype;
return child;
};
if (typeof Backbone !== "undefined" && Backbone !== null) {
this.DiscussionUser = (function(_super) {
__extends(DiscussionUser, _super);
function DiscussionUser() {
return DiscussionUser.__super__.constructor.apply(this, arguments);
}
DiscussionUser.prototype.following = function(thread) {
return _.include(this.get('subscribed_thread_ids'), thread.id);
};
DiscussionUser.prototype.voted = function(thread) {
return _.include(this.get('upvoted_ids'), thread.id);
};
DiscussionUser.prototype.vote = function(thread) {
this.get('upvoted_ids').push(thread.id);
return thread.vote();
};
DiscussionUser.prototype.unvote = function(thread) {
this.set('upvoted_ids', _.without(this.get('upvoted_ids'), thread.id));
return thread.unvote();
};
return DiscussionUser;
})(Backbone.Model);
}
}).call(this);

View File

@@ -0,0 +1,489 @@
/* globals $$course_id, Content, Markdown, URI */
(function() {
'use strict';
this.DiscussionUtil = (function() {
function DiscussionUtil() {
}
DiscussionUtil.wmdEditors = {};
DiscussionUtil.getTemplate = function(id) {
return $("script#" + id).html();
};
DiscussionUtil.setUser = function(user) {
this.user = user;
};
DiscussionUtil.getUser = function() {
return this.user;
};
DiscussionUtil.loadRoles = function(roles) {
this.roleIds = roles;
};
DiscussionUtil.loadRolesFromContainer = function() {
return this.loadRoles($("#discussion-container").data("roles"));
};
DiscussionUtil.isStaff = function(userId) {
var staff;
if (_.isUndefined(userId)) {
userId = this.user ? this.user.id : void 0;
}
staff = _.union(this.roleIds.Moderator, this.roleIds.Administrator);
return _.include(staff, parseInt(userId));
};
DiscussionUtil.isTA = function(userId) {
var ta;
if (_.isUndefined(userId)) {
userId = this.user ? this.user.id : void 0;
}
ta = _.union(this.roleIds['Community TA']);
return _.include(ta, parseInt(userId));
};
DiscussionUtil.isPrivilegedUser = function(userId) {
return this.isStaff(userId) || this.isTA(userId);
};
DiscussionUtil.bulkUpdateContentInfo = function(infos) {
var id, info, _results;
_results = [];
for (id in infos) {
if (infos.hasOwnProperty(id)) {
info = infos[id];
_results.push(Content.getContent(id).updateInfo(info));
}
}
return _results;
};
DiscussionUtil.generateDiscussionLink = function(cls, txt, handler) {
return $("<a>")
.addClass("discussion-link").attr("href", "#")
.addClass(cls).html(txt).click(function() {return handler(this);});
};
DiscussionUtil.urlFor = function(name, param, param1, param2) {
return {
follow_discussion: "/courses/" + $$course_id + "/discussion/" + param + "/follow",
unfollow_discussion: "/courses/" + $$course_id + "/discussion/" + param + "/unfollow",
create_thread: "/courses/" + $$course_id + "/discussion/" + param + "/threads/create",
update_thread: "/courses/" + $$course_id + "/discussion/threads/" + param + "/update",
create_comment: "/courses/" + $$course_id + "/discussion/threads/" + param + "/reply",
delete_thread: "/courses/" + $$course_id + "/discussion/threads/" + param + "/delete",
flagAbuse_thread: "/courses/" + $$course_id + "/discussion/threads/" + param + "/flagAbuse",
unFlagAbuse_thread: "/courses/" + $$course_id + "/discussion/threads/" + param + "/unFlagAbuse",
flagAbuse_comment: "/courses/" + $$course_id + "/discussion/comments/" + param + "/flagAbuse",
unFlagAbuse_comment: "/courses/" + $$course_id + "/discussion/comments/" + param + "/unFlagAbuse",
upvote_thread: "/courses/" + $$course_id + "/discussion/threads/" + param + "/upvote",
downvote_thread: "/courses/" + $$course_id + "/discussion/threads/" + param + "/downvote",
pin_thread: "/courses/" + $$course_id + "/discussion/threads/" + param + "/pin",
un_pin_thread: "/courses/" + $$course_id + "/discussion/threads/" + param + "/unpin",
undo_vote_for_thread: "/courses/" + $$course_id + "/discussion/threads/" + param + "/unvote",
follow_thread: "/courses/" + $$course_id + "/discussion/threads/" + param + "/follow",
unfollow_thread: "/courses/" + $$course_id + "/discussion/threads/" + param + "/unfollow",
update_comment: "/courses/" + $$course_id + "/discussion/comments/" + param + "/update",
endorse_comment: "/courses/" + $$course_id + "/discussion/comments/" + param + "/endorse",
create_sub_comment: "/courses/" + $$course_id + "/discussion/comments/" + param + "/reply",
delete_comment: "/courses/" + $$course_id + "/discussion/comments/" + param + "/delete",
upvote_comment: "/courses/" + $$course_id + "/discussion/comments/" + param + "/upvote",
downvote_comment: "/courses/" + $$course_id + "/discussion/comments/" + param + "/downvote",
undo_vote_for_comment: "/courses/" + $$course_id + "/discussion/comments/" + param + "/unvote",
upload: "/courses/" + $$course_id + "/discussion/upload",
users: "/courses/" + $$course_id + "/discussion/users",
search: "/courses/" + $$course_id + "/discussion/forum/search",
retrieve_discussion: "/courses/" + $$course_id + "/discussion/forum/" + param + "/inline",
retrieve_single_thread: "/courses/" + $$course_id + "/discussion/forum/" + param + "/threads/" + param1,
openclose_thread: "/courses/" + $$course_id + "/discussion/threads/" + param + "/close",
permanent_link_thread: "/courses/" + $$course_id + "/discussion/forum/" + param + "/threads/" + param1,
permanent_link_comment: "/courses/" + $$course_id +
"/discussion/forum/" + param + "/threads/" + param1 + "#" + param2,
user_profile: "/courses/" + $$course_id + "/discussion/forum/users/" + param,
followed_threads: "/courses/" + $$course_id + "/discussion/forum/users/" + param + "/followed",
threads: "/courses/" + $$course_id + "/discussion/forum",
"enable_notifications": "/notification_prefs/enable/",
"disable_notifications": "/notification_prefs/disable/",
"notifications_status": "/notification_prefs/status/"
}[name];
};
DiscussionUtil.ignoreEnterKey = function(event) {
if (event.which === 13) {
return event.preventDefault();
}
};
DiscussionUtil.activateOnSpace = function(event, func) {
if (event.which === 32) {
event.preventDefault();
return func(event);
}
};
DiscussionUtil.makeFocusTrap = function(elem) {
return elem.keydown(function(event) {
if (event.which === 9) {
return event.preventDefault();
}
});
};
DiscussionUtil.showLoadingIndicator = function(element, takeFocus) {
this.$_loading = $(
"<div class='loading-animation' tabindex='0'><span class='sr'>" +
gettext("Loading content") +
"</span></div>"
);
element.after(this.$_loading);
if (takeFocus) {
this.makeFocusTrap(this.$_loading);
return this.$_loading.focus();
}
};
DiscussionUtil.hideLoadingIndicator = function() {
return this.$_loading.remove();
};
DiscussionUtil.discussionAlert = function(header, body) {
var alertDiv, alertTrigger;
if ($("#discussion-alert").length === 0) {
alertDiv = $(
"<div class='modal' role='alertdialog' id='discussion-alert' " +
"aria-describedby='discussion-alert-message'/>"
).css("display", "none");
alertDiv.html(
"<div class='inner-wrapper discussion-alert-wrapper'>" +
" <button class='close-modal dismiss' title='" + gettext("Close") + "'>" +
" <span class='icon fa fa-times' aria-hidden='true'></span>" +
" </button>" +
" <header><h2/><hr/></header>" +
" <p id='discussion-alert-message'/><hr/>" +
" <button class='dismiss'>" + gettext("OK") + "</button>" +
"</div>"
);
this.makeFocusTrap(alertDiv.find("button"));
alertTrigger = $("<a href='#discussion-alert' id='discussion-alert-trigger'/>").css("display", "none");
alertTrigger.leanModal({
closeButton: "#discussion-alert .dismiss",
overlay: 1,
top: 200
});
$("body").append(alertDiv).append(alertTrigger);
}
$("#discussion-alert header h2").html(header);
$("#discussion-alert p").html(body);
$("#discussion-alert-trigger").click();
return $("#discussion-alert button").focus();
};
DiscussionUtil.safeAjax = function(params) {
var $elem, deferred, request,
self = this;
$elem = params.$elem;
if ($elem && $elem.attr("disabled")) {
deferred = $.Deferred();
deferred.reject();
return deferred.promise();
}
params.url = URI(params.url).addSearch({
ajax: 1
});
params.beforeSend = function() {
if ($elem) {
$elem.attr("disabled", "disabled");
}
if (params.$loading) {
if (params.loadingCallback) {
return params.loadingCallback.apply(params.$loading);
} else {
return self.showLoadingIndicator($(params.$loading), params.takeFocus);
}
}
};
if (!params.error) {
params.error = function() {
self.discussionAlert(
gettext("Sorry"),
gettext(
"We had some trouble processing your request. Please ensure you have copied any " +
"unsaved work and then reload the page.")
);
};
}
request = $.ajax(params).always(function() {
if ($elem) {
$elem.removeAttr("disabled");
}
if (params.$loading) {
if (params.loadedCallback) {
return params.loadedCallback.apply(params.$loading);
} else {
return self.hideLoadingIndicator();
}
}
});
return request;
};
DiscussionUtil.updateWithUndo = function(model, updates, safeAjaxParams, errorMsg) {
var undo,
self = this;
if (errorMsg) {
safeAjaxParams.error = function() {
return self.discussionAlert(gettext("Sorry"), errorMsg);
};
}
undo = _.pick(model.attributes, _.keys(updates));
model.set(updates);
return this.safeAjax(safeAjaxParams).fail(function() {
return model.set(undo);
});
};
DiscussionUtil.bindLocalEvents = function($local, eventsHandler) {
var event, eventSelector, handler, selector, _ref, _results;
_results = [];
for (eventSelector in eventsHandler) {
if (eventsHandler.hasOwnProperty(eventSelector)){
handler = eventsHandler[eventSelector];
_ref = eventSelector.split(' ');
event = _ref[0];
selector = _ref[1];
_results.push($local(selector).unbind(event)[event](handler));
}
}
return _results;
};
DiscussionUtil.formErrorHandler = function(errorsField) {
return function(xhr, textStatus, error) {
var makeErrorElem, response, _i, _len, _ref, _results;
makeErrorElem = function(message) {
return $("<li>").addClass("post-error").html(message);
};
errorsField.empty().show();
if (xhr.status === 400) {
response = JSON.parse(xhr.responseText);
if (response.errors) {
_ref = response.errors;
_results = [];
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
error = _ref[_i];
_results.push(errorsField.append(makeErrorElem(error)));
}
return _results;
}
} else {
return errorsField.append(makeErrorElem(
gettext("We had some trouble processing your request. Please try again."))
);
}
};
};
DiscussionUtil.clearFormErrors = function(errorsField) {
return errorsField.empty();
};
DiscussionUtil.postMathJaxProcessor = function(text) {
var RE_DISPLAYMATH, RE_INLINEMATH;
RE_INLINEMATH = /^\$([^\$]*)\$/g;
RE_DISPLAYMATH = /^\$\$([^\$]*)\$\$/g;
return this.processEachMathAndCode(text, function(s, type) {
if (type === 'display') {
return s.replace(RE_DISPLAYMATH, function($0, $1) {
return "\\[" + $1 + "\\]";
});
} else if (type === 'inline') {
return s.replace(RE_INLINEMATH, function($0, $1) {
return "\\(" + $1 + "\\)";
});
} else {
return s;
}
});
};
DiscussionUtil.makeWmdEditor = function($content, $local, cls_identifier) {
var appended_id, editor, elem, id, imageUploadUrl, placeholder, _processor;
elem = $local("." + cls_identifier);
placeholder = elem.data('placeholder');
id = elem.attr("data-id");
appended_id = "-" + cls_identifier + "-" + id;
imageUploadUrl = this.urlFor('upload');
_processor = function(self) {
return function(text) {
return self.postMathJaxProcessor(text);
};
};
editor = Markdown.makeWmdEditor(elem, appended_id, imageUploadUrl, _processor(this));
this.wmdEditors["" + cls_identifier + "-" + id] = editor;
if (placeholder) {
elem.find("#wmd-input" + appended_id).attr('placeholder', placeholder);
}
return editor;
};
DiscussionUtil.getWmdEditor = function($content, $local, cls_identifier) {
var elem, id;
elem = $local("." + cls_identifier);
id = elem.attr("data-id");
return this.wmdEditors["" + cls_identifier + "-" + id];
};
DiscussionUtil.getWmdInput = function($content, $local, cls_identifier) {
var elem, id;
elem = $local("." + cls_identifier);
id = elem.attr("data-id");
return $local("#wmd-input-" + cls_identifier + "-" + id);
};
DiscussionUtil.getWmdContent = function($content, $local, cls_identifier) {
return this.getWmdInput($content, $local, cls_identifier).val();
};
DiscussionUtil.setWmdContent = function($content, $local, cls_identifier, text) {
this.getWmdInput($content, $local, cls_identifier).val(text);
return this.getWmdEditor($content, $local, cls_identifier).refreshPreview();
};
DiscussionUtil.processEachMathAndCode = function(text, processor) {
var $div, ESCAPED_BACKSLASH, ESCAPED_DOLLAR, RE_DISPLAYMATH, RE_INLINEMATH, cnt, codeArchive, processedText;
codeArchive = [];
RE_DISPLAYMATH = /^([^\$]*?)\$\$([^\$]*?)\$\$(.*)$/m;
RE_INLINEMATH = /^([^\$]*?)\$([^\$]+?)\$(.*)$/m;
ESCAPED_DOLLAR = '@@ESCAPED_D@@';
ESCAPED_BACKSLASH = '@@ESCAPED_B@@';
processedText = "";
$div = $("<div>").html(text);
$div.find("code").each(function(index, code) {
codeArchive.push($(code).html());
return $(code).html(codeArchive.length - 1);
});
text = $div.html();
text = text.replace(/\\\$/g, ESCAPED_DOLLAR);
// suppressing Don't make functions within a loop.
/* jshint -W083 */
while (true) {
if (RE_INLINEMATH.test(text)) {
text = text.replace(RE_INLINEMATH, function($0, $1, $2, $3) {
processedText += $1 + processor("$" + $2 + "$", 'inline');
return $3;
});
} else if (RE_DISPLAYMATH.test(text)) {
text = text.replace(RE_DISPLAYMATH, function($0, $1, $2, $3) {
/*
bug fix, ordering is off
*/
processedText = processor("$$" + $2 + "$$", 'display') + processedText;
processedText = $1 + processedText;
return $3;
});
} else {
processedText += text;
break;
}
}
/* jshint +W083 */
text = processedText;
text = text.replace(new RegExp(ESCAPED_DOLLAR, 'g'), '\\$');
text = text.replace(/\\\\\\\\/g, ESCAPED_BACKSLASH);
text = text.replace(/\\begin\{([a-z]*\*?)\}([\s\S]*?)\\end\{\1\}/img, function($0, $1, $2) {
return processor(("\\begin{" + $1 + "}") + $2 + ("\\end{" + $1 + "}"));
});
text = text.replace(new RegExp(ESCAPED_BACKSLASH, 'g'), '\\\\\\\\');
$div = $("<div>").html(text);
cnt = 0;
$div.find("code").each(function(index, code) {
$(code).html(processor(codeArchive[cnt], 'code'));
return cnt += 1;
});
text = $div.html();
return text;
};
DiscussionUtil.unescapeHighlightTag = function(text) {
return text.replace(
/\&lt\;highlight\&gt\;/g,
"<span class='search-highlight'>").replace(/\&lt\;\/highlight\&gt\;/g, "</span>"
);
};
DiscussionUtil.stripHighlight = function(text) {
return text.replace(
/\&(amp\;)?lt\;highlight\&(amp\;)?gt\;/g, "").replace(/\&(amp\;)?lt\;\/highlight\&(amp\;)?gt\;/g, ""
);
};
DiscussionUtil.stripLatexHighlight = function(text) {
return this.processEachMathAndCode(text, this.stripHighlight);
};
DiscussionUtil.markdownWithHighlight = function(text) {
var converter;
text = text.replace(/^\&gt\;/gm, ">");
converter = Markdown.getMathCompatibleConverter();
text = this.unescapeHighlightTag(this.stripLatexHighlight(converter.makeHtml(text)));
return text.replace(/^>/gm, "&gt;");
};
DiscussionUtil.abbreviateString = function(text, minLength) {
if (text.length < minLength) {
return text;
} else {
while (minLength < text.length && text[minLength] !== ' ') {
minLength++;
}
return text.substr(0, minLength) + gettext('…');
}
};
DiscussionUtil.abbreviateHTML = function(html, minLength) {
var $result, imagesToReplace, truncated_text;
truncated_text = jQuery.truncate(html, {
length: minLength,
noBreaks: true,
ellipsis: gettext('…')
});
$result = $("<div>" + truncated_text + "</div>");
imagesToReplace = $result.find("img:not(:first)");
if (imagesToReplace.length > 0) {
$result.append("<p><em>Some images in this post have been omitted</em></p>");
}
imagesToReplace.replaceWith("<em>image omitted</em>");
return $result.html();
};
DiscussionUtil.getPaginationParams = function(curPage, numPages, pageUrlFunc) {
var delta, maxPage, minPage, pageInfo;
delta = 2;
minPage = Math.max(curPage - delta, 1);
maxPage = Math.min(curPage + delta, numPages);
pageInfo = function(pageNum) {
return {
number: pageNum,
url: pageUrlFunc(pageNum)
};
};
return {
page: curPage,
lowPages: _.range(minPage, curPage).map(pageInfo),
highPages: _.range(curPage + 1, maxPage + 1).map(pageInfo),
previous: curPage > 1 ? pageInfo(curPage - 1) : null,
next: curPage < numPages ? pageInfo(curPage + 1) : null,
leftdots: minPage > 2,
rightdots: maxPage < numPages - 1,
first: minPage > 1 ? pageInfo(1) : null,
last: maxPage < numPages ? pageInfo(numPages) : null
};
};
return DiscussionUtil;
}).call(this);
}).call(window);

View File

@@ -0,0 +1,520 @@
/* globals DiscussionContentView, DiscussionUtil */
(function() {
'use strict';
var __hasProp = {}.hasOwnProperty,
__extends = function(child, parent) {
for (var key in parent) {
if (__hasProp.call(parent, key)) {
child[key] = parent[key];
}
}
function ctor() {
this.constructor = child;
}
ctor.prototype = parent.prototype;
child.prototype = new ctor();
child.__super__ = parent.prototype;
return child;
};
if (typeof Backbone !== "undefined" && Backbone !== null) {
this.DiscussionContentView = (function(_super) {
__extends(DiscussionContentView, _super);
function DiscussionContentView() {
var self = this;
this.setWmdContent = function() {
return DiscussionContentView.prototype.setWmdContent.apply(self, arguments);
};
this.getWmdContent = function() {
return DiscussionContentView.prototype.getWmdContent.apply(self, arguments);
};
this.getWmdEditor = function() {
return DiscussionContentView.prototype.getWmdEditor.apply(self, arguments);
};
this.makeWmdEditor = function() {
return DiscussionContentView.prototype.makeWmdEditor.apply(self, arguments);
};
return DiscussionContentView.__super__.constructor.apply(this, arguments);
}
DiscussionContentView.prototype.events = {
"click .discussion-flag-abuse": "toggleFlagAbuse",
"keydown .discussion-flag-abuse": function(event) {
return DiscussionUtil.activateOnSpace(event, this.toggleFlagAbuse);
}
};
DiscussionContentView.prototype.attrRenderer = {
ability: function(ability) {
var action, selector, _ref, _results;
_ref = this.abilityRenderer;
_results = [];
for (action in _ref) {
if (_ref.hasOwnProperty(action)){
selector = _ref[action];
if (!ability[action]) {
_results.push(selector.disable.apply(this));
} else {
_results.push(selector.enable.apply(this));
}
}
}
return _results;
}
};
DiscussionContentView.prototype.abilityRenderer = {
editable: {
enable: function() {
return this.$(".action-edit").closest(".actions-item").removeClass("is-hidden");
},
disable: function() {
return this.$(".action-edit").closest(".actions-item").addClass("is-hidden");
}
},
can_delete: {
enable: function() {
return this.$(".action-delete").closest(".actions-item").removeClass("is-hidden");
},
disable: function() {
return this.$(".action-delete").closest(".actions-item").addClass("is-hidden");
}
},
can_openclose: {
enable: function() {
var self = this;
return _.each([".action-close", ".action-pin"], function(selector) {
return self.$(selector).closest(".actions-item").removeClass("is-hidden");
});
},
disable: function() {
var self = this;
return _.each([".action-close", ".action-pin"], function(selector) {
return self.$(selector).closest(".actions-item").addClass("is-hidden");
});
}
},
can_report: {
enable: function() {
return this.$(".action-report").closest(".actions-item").removeClass("is-hidden");
},
disable: function() {
return this.$(".action-report").closest(".actions-item").addClass("is-hidden");
}
},
can_vote: {
enable: function() {
return this.$(".action-vote").closest(".actions-item").removeClass("is-hidden");
},
disable: function() {
return this.$(".action-vote").closest(".actions-item").addClass("is-hidden");
}
}
};
DiscussionContentView.prototype.renderPartialAttrs = function() {
var attr, value, _ref, _results;
_ref = this.model.changedAttributes();
_results = [];
for (attr in _ref) {
if (_ref.hasOwnProperty(attr)) {
value = _ref[attr];
if (this.attrRenderer[attr]) {
_results.push(this.attrRenderer[attr].apply(this, [value]));
} else {
_results.push(void 0);
}
}
}
return _results;
};
DiscussionContentView.prototype.renderAttrs = function() {
var attr, value, _ref, _results;
_ref = this.model.attributes;
_results = [];
for (attr in _ref) {
if (_ref.hasOwnProperty(attr)) {
value = _ref[attr];
if (this.attrRenderer[attr]) {
_results.push(this.attrRenderer[attr].apply(this, [value]));
} else {
_results.push(void 0);
}
}
}
return _results;
};
DiscussionContentView.prototype.makeWmdEditor = function(cls_identifier) {
if (!this.$el.find(".wmd-panel").length) {
return DiscussionUtil.makeWmdEditor(this.$el, $.proxy(this.$, this), cls_identifier);
}
};
DiscussionContentView.prototype.getWmdEditor = function(cls_identifier) {
return DiscussionUtil.getWmdEditor(this.$el, $.proxy(this.$, this), cls_identifier);
};
DiscussionContentView.prototype.getWmdContent = function(cls_identifier) {
return DiscussionUtil.getWmdContent(this.$el, $.proxy(this.$, this), cls_identifier);
};
DiscussionContentView.prototype.setWmdContent = function(cls_identifier, text) {
return DiscussionUtil.setWmdContent(this.$el, $.proxy(this.$, this), cls_identifier, text);
};
DiscussionContentView.prototype.initialize = function() {
var self = this;
this.model.bind('change', this.renderPartialAttrs, this);
return this.listenTo(this.model, "change:endorsed", function() {
if (self.model instanceof Comment) {
return self.trigger("comment:endorse");
}
});
};
return DiscussionContentView;
})(Backbone.View);
this.DiscussionContentShowView = (function(_super) {
__extends(DiscussionContentShowView, _super);
function DiscussionContentShowView() {
var self = this;
this.toggleClose = function() {
return DiscussionContentShowView.prototype.toggleClose.apply(self, arguments);
};
this.toggleReport = function() {
return DiscussionContentShowView.prototype.toggleReport.apply(self, arguments);
};
this.togglePin = function() {
return DiscussionContentShowView.prototype.togglePin.apply(self, arguments);
};
this.toggleVote = function() {
return DiscussionContentShowView.prototype.toggleVote.apply(self, arguments);
};
this.toggleEndorse = function() {
return DiscussionContentShowView.prototype.toggleEndorse.apply(self, arguments);
};
this.toggleFollow = function() {
return DiscussionContentShowView.prototype.toggleFollow.apply(self, arguments);
};
this.handleSecondaryActionBlur = function() {
return DiscussionContentShowView.prototype.handleSecondaryActionBlur.apply(self, arguments);
};
this.handleSecondaryActionEscape = function() {
return DiscussionContentShowView.prototype.handleSecondaryActionEscape.apply(self, arguments);
};
this.toggleSecondaryActions = function() {
return DiscussionContentShowView.prototype.toggleSecondaryActions.apply(self, arguments);
};
this.updateButtonState = function() {
return DiscussionContentShowView.prototype.updateButtonState.apply(self, arguments);
};
return DiscussionContentShowView.__super__.constructor.apply(this, arguments);
}
DiscussionContentShowView.prototype.events = _.reduce(
[
[".action-follow", "toggleFollow"],
[".action-answer", "toggleEndorse"],
[".action-endorse", "toggleEndorse"],
[".action-vote", "toggleVote"],
[".action-more", "toggleSecondaryActions"],
[".action-pin", "togglePin"],
[".action-edit", "edit"],
[".action-delete", "_delete"],
[".action-report", "toggleReport"],
[".action-close", "toggleClose"]
],
function(obj, event) {
var funcName, selector;
selector = event[0];
funcName = event[1];
obj["click " + selector] = function(event) {
return this[funcName](event);
};
obj["keydown " + selector] = function(event) {
return DiscussionUtil.activateOnSpace(event, this[funcName]);
};
return obj;
},
{}
);
DiscussionContentShowView.prototype.updateButtonState = function(selector, checked) {
var $button;
$button = this.$(selector);
$button.toggleClass("is-checked", checked);
return $button.attr("aria-checked", checked);
};
DiscussionContentShowView.prototype.attrRenderer = $.extend(
{},
DiscussionContentView.prototype.attrRenderer,
{
subscribed: function(subscribed) {
return this.updateButtonState(".action-follow", subscribed);
},
endorsed: function(endorsed) {
var $button, selector;
selector = this.model.get("thread").get("thread_type") === "question" ?
".action-answer" :
".action-endorse";
this.updateButtonState(selector, endorsed);
$button = this.$(selector);
$button.closest(".actions-item").toggleClass("is-hidden", !this.model.canBeEndorsed());
return $button.toggleClass("is-checked", endorsed);
},
votes: function(votes) {
var button, numVotes, selector, votesHtml, votesCountMsg;
selector = ".action-vote";
this.updateButtonState(selector, window.user.voted(this.model));
button = this.$el.find(selector);
numVotes = votes.up_count;
votesCountMsg = ngettext(
"there is currently %(numVotes)s vote", "there are currently %(numVotes)s votes", numVotes
);
button.find(".js-sr-vote-count").html(interpolate(votesCountMsg, {numVotes: numVotes }, true));
votesHtml = interpolate(ngettext("%(numVotes)s Vote", "%(numVotes)s Votes", numVotes), {
numVotes: numVotes
}, true);
button.find(".vote-count").html(votesHtml);
return this.$el.find('.display-vote .vote-count').html(votesHtml);
},
pinned: function(pinned) {
this.updateButtonState(".action-pin", pinned);
return this.$(".post-label-pinned").toggleClass("is-hidden", !pinned);
},
abuse_flaggers: function() {
var flagged;
flagged = this.model.isFlagged();
this.updateButtonState(".action-report", flagged);
return this.$(".post-label-reported").toggleClass("is-hidden", !flagged);
},
closed: function(closed) {
this.updateButtonState(".action-close", closed);
this.$(".post-label-closed").toggleClass("is-hidden", !closed);
return this.$(".display-vote").toggle(closed);
}
}
);
DiscussionContentShowView.prototype.toggleSecondaryActions = function(event) {
event.preventDefault();
event.stopPropagation();
this.secondaryActionsExpanded = !this.secondaryActionsExpanded;
this.$(".action-more").toggleClass("is-expanded", this.secondaryActionsExpanded);
this.$(".actions-dropdown")
.toggleClass("is-expanded", this.secondaryActionsExpanded)
.attr("aria-expanded", this.secondaryActionsExpanded);
if (this.secondaryActionsExpanded) {
if (event.type === "keydown") {
this.$(".action-list-item:first").focus();
}
$("body").on("click", this.toggleSecondaryActions);
$("body").on("keydown", this.handleSecondaryActionEscape);
return this.$(".action-list-item").on("blur", this.handleSecondaryActionBlur);
} else {
$("body").off("click", this.toggleSecondaryActions);
$("body").off("keydown", this.handleSecondaryActionEscape);
return this.$(".action-list-item").off("blur", this.handleSecondaryActionBlur);
}
};
DiscussionContentShowView.prototype.handleSecondaryActionEscape = function(event) {
if (event.keyCode === 27) {
this.toggleSecondaryActions(event);
return this.$(".action-more").focus();
}
};
DiscussionContentShowView.prototype.handleSecondaryActionBlur = function(event) {
var self = this;
return setTimeout(function() {
if (self.secondaryActionsExpanded && self.$(".actions-dropdown :focus").length === 0) {
return self.toggleSecondaryActions(event);
}
}, 10);
};
DiscussionContentShowView.prototype.toggleFollow = function(event) {
var is_subscribing, msg, url;
event.preventDefault();
is_subscribing = !this.model.get("subscribed");
url = this.model.urlFor(is_subscribing ? "follow" : "unfollow");
if (is_subscribing) {
msg = gettext("We had some trouble subscribing you to this thread. Please try again.");
} else {
msg = gettext("We had some trouble unsubscribing you from this thread. Please try again.");
}
return DiscussionUtil.updateWithUndo(this.model, {
"subscribed": is_subscribing
}, {
url: url,
type: "POST",
$elem: $(event.currentTarget)
}, msg);
};
DiscussionContentShowView.prototype.toggleEndorse = function(event) {
var beforeFunc, is_endorsing, msg, updates, url,
self = this;
event.preventDefault();
is_endorsing = !this.model.get("endorsed");
url = this.model.urlFor("endorse");
updates = {
endorsed: is_endorsing,
endorsement: is_endorsing ? {
username: DiscussionUtil.getUser().get("username"),
user_id: DiscussionUtil.getUser().id,
time: new Date().toISOString()
} : null
};
if (this.model.get('thread').get('thread_type') === 'question') {
if (is_endorsing) {
msg = gettext("We had some trouble marking this response as an answer. Please try again.");
} else {
msg = gettext("We had some trouble removing this response as an answer. Please try again.");
}
} else {
if (is_endorsing) {
msg = gettext("We had some trouble marking this response endorsed. Please try again.");
} else {
msg = gettext("We had some trouble removing this endorsement. Please try again.");
}
}
beforeFunc = function() {
return self.trigger("comment:endorse");
};
return DiscussionUtil.updateWithUndo(this.model, updates, {
url: url,
type: "POST",
data: {
endorsed: is_endorsing
},
beforeSend: beforeFunc,
$elem: $(event.currentTarget)
}, msg).always(this.trigger("comment:endorse"));
};
DiscussionContentShowView.prototype.toggleVote = function(event) {
var is_voting, updates, url, user,
self = this;
event.preventDefault();
user = DiscussionUtil.getUser();
is_voting = !user.voted(this.model);
url = this.model.urlFor(is_voting ? "upvote" : "unvote");
updates = {
upvoted_ids: (is_voting ? _.union : _.difference)(user.get('upvoted_ids'), [this.model.id])
};
return DiscussionUtil.updateWithUndo(user, updates, {
url: url,
type: "POST",
$elem: $(event.currentTarget)
}, gettext("We had some trouble saving your vote. Please try again.")).done(function() {
if (is_voting) {
return self.model.vote();
} else {
return self.model.unvote();
}
});
};
DiscussionContentShowView.prototype.togglePin = function(event) {
var is_pinning, msg, url;
event.preventDefault();
is_pinning = !this.model.get("pinned");
url = this.model.urlFor(is_pinning ? "pinThread" : "unPinThread");
if (is_pinning) {
msg = gettext("We had some trouble pinning this thread. Please try again.");
} else {
msg = gettext("We had some trouble unpinning this thread. Please try again.");
}
return DiscussionUtil.updateWithUndo(this.model, {
pinned: is_pinning
}, {
url: url,
type: "POST",
$elem: $(event.currentTarget)
}, msg);
};
DiscussionContentShowView.prototype.toggleReport = function(event) {
var is_flagging, msg, updates, url;
event.preventDefault();
if (this.model.isFlagged()) {
is_flagging = false;
msg = gettext("We had some trouble removing your flag on this post. Please try again.");
} else {
is_flagging = true;
msg = gettext("We had some trouble reporting this post. Please try again.");
}
url = this.model.urlFor(is_flagging ? "flagAbuse" : "unFlagAbuse");
updates = {
abuse_flaggers: (is_flagging ? _.union : _.difference)(
this.model.get("abuse_flaggers"), [DiscussionUtil.getUser().id]
)
};
return DiscussionUtil.updateWithUndo(this.model, updates, {
url: url,
type: "POST",
$elem: $(event.currentTarget)
}, msg);
};
DiscussionContentShowView.prototype.toggleClose = function(event) {
var is_closing, msg, updates;
event.preventDefault();
is_closing = !this.model.get('closed');
if (is_closing) {
msg = gettext("We had some trouble closing this thread. Please try again.");
} else {
msg = gettext("We had some trouble reopening this thread. Please try again.");
}
updates = {
closed: is_closing
};
return DiscussionUtil.updateWithUndo(this.model, updates, {
url: this.model.urlFor("close"),
type: "POST",
data: updates,
$elem: $(event.currentTarget)
}, msg);
};
DiscussionContentShowView.prototype.getAuthorDisplay = function() {
return _.template($("#post-user-display-template").html())({
username: this.model.get('username') || null,
user_url: this.model.get('user_url'),
is_community_ta: this.model.get('community_ta_authored'),
is_staff: this.model.get('staff_authored')
});
};
DiscussionContentShowView.prototype.getEndorserDisplay = function() {
var endorsement;
endorsement = this.model.get('endorsement');
if (endorsement && endorsement.username) {
return _.template($("#post-user-display-template").html())({
username: endorsement.username,
user_url: DiscussionUtil.urlFor('user_profile', endorsement.user_id),
is_community_ta: DiscussionUtil.isTA(endorsement.user_id),
is_staff: DiscussionUtil.isStaff(endorsement.user_id)
});
} else {
return null;
}
};
return DiscussionContentShowView;
}).call(this, this.DiscussionContentView);
}
}).call(window);

View File

@@ -1,4 +1,5 @@
(function(Backbone) {
/* globals DiscussionTopicMenuView, DiscussionUtil */
(function() {
'use strict';
if (Backbone) {
this.DiscussionThreadEditView = Backbone.View.extend({
@@ -50,7 +51,7 @@
return this;
},
isTabMode: function () {
isTabMode: function() {
return this.mode === 'tab';
},
@@ -85,7 +86,7 @@
this.model.set(postData).unset('abbreviatedBody');
this.trigger('thread:updated');
if (this.threadType !== threadType) {
this.model.set("thread_type", threadType)
this.model.set("thread_type", threadType);
this.model.trigger('thread:thread_type_updated');
this.trigger('comment:endorse');
}
@@ -109,4 +110,4 @@
}
});
}
}).call(this, Backbone);
}).call(window); // jshint ignore:line

View File

@@ -0,0 +1,792 @@
/* globals Content, Discussion, DiscussionUtil */
(function() {
'use strict';
var __hasProp = {}.hasOwnProperty,
__extends = function(child, parent) {
for (var key in parent) {
if (__hasProp.call(parent, key)) {
child[key] = parent[key];
}
}
function ctor() {
this.constructor = child;
}
ctor.prototype = parent.prototype;
child.prototype = new ctor();
child.__super__ = parent.prototype;
return child;
};
if (typeof Backbone !== "undefined" && Backbone !== null) {
this.DiscussionThreadListView = (function(_super) {
__extends(DiscussionThreadListView, _super);
function DiscussionThreadListView() {
var self = this;
this.updateEmailNotifications = function() {
return DiscussionThreadListView.prototype.updateEmailNotifications.apply(self, arguments);
};
this.retrieveFollowed = function() {
return DiscussionThreadListView.prototype.retrieveFollowed.apply(self, arguments);
};
this.chooseCohort = function() {
return DiscussionThreadListView.prototype.chooseCohort.apply(self, arguments);
};
this.chooseFilter = function() {
return DiscussionThreadListView.prototype.chooseFilter.apply(self, arguments);
};
this.filterTopics = function() {
return DiscussionThreadListView.prototype.filterTopics.apply(self, arguments);
};
this.toggleBrowseMenu = function() {
return DiscussionThreadListView.prototype.toggleBrowseMenu.apply(self, arguments);
};
this.hideBrowseMenu = function() {
return DiscussionThreadListView.prototype.hideBrowseMenu.apply(self, arguments);
};
this.showBrowseMenu = function() {
return DiscussionThreadListView.prototype.showBrowseMenu.apply(self, arguments);
};
this.isBrowseMenuVisible = function() {
return DiscussionThreadListView.prototype.isBrowseMenuVisible.apply(self, arguments);
};
this.threadRemoved = function() {
return DiscussionThreadListView.prototype.threadRemoved.apply(self, arguments);
};
this.threadSelected = function() {
return DiscussionThreadListView.prototype.threadSelected.apply(self, arguments);
};
this.renderThread = function() {
return DiscussionThreadListView.prototype.renderThread.apply(self, arguments);
};
this.loadMorePages = function() {
return DiscussionThreadListView.prototype.loadMorePages.apply(self, arguments);
};
this.showMetadataAccordingToSort = function() {
return DiscussionThreadListView.prototype.showMetadataAccordingToSort.apply(self, arguments);
};
this.renderThreads = function() {
return DiscussionThreadListView.prototype.renderThreads.apply(self, arguments);
};
this.updateSidebar = function() {
return DiscussionThreadListView.prototype.updateSidebar.apply(self, arguments);
};
this.addAndSelectThread = function() {
return DiscussionThreadListView.prototype.addAndSelectThread.apply(self, arguments);
};
this.reloadDisplayedCollection = function() {
return DiscussionThreadListView.prototype.reloadDisplayedCollection.apply(self, arguments);
};
this.clearSearchAlerts = function() {
return DiscussionThreadListView.prototype.clearSearchAlerts.apply(self, arguments);
};
this.removeSearchAlert = function() {
return DiscussionThreadListView.prototype.removeSearchAlert.apply(self, arguments);
};
this.addSearchAlert = function() {
return DiscussionThreadListView.prototype.addSearchAlert.apply(self, arguments);
};
return DiscussionThreadListView.__super__.constructor.apply(this, arguments);
}
DiscussionThreadListView.prototype.events = {
"click .forum-nav-browse": "toggleBrowseMenu",
"keypress .forum-nav-browse-filter-input": function(event) {
return DiscussionUtil.ignoreEnterKey(event);
},
"keyup .forum-nav-browse-filter-input": "filterTopics",
"click .forum-nav-browse-menu-wrapper": "ignoreClick",
"click .forum-nav-browse-title": "selectTopicHandler",
"keydown .forum-nav-search-input": "performSearch",
"click .fa-search": "performSearch",
"change .forum-nav-sort-control": "sortThreads",
"click .forum-nav-thread-link": "threadSelected",
"click .forum-nav-load-more-link": "loadMorePages",
"change .forum-nav-filter-main-control": "chooseFilter",
"change .forum-nav-filter-cohort-control": "chooseCohort"
};
DiscussionThreadListView.prototype.initialize = function(options) {
var self = this;
this.courseSettings = options.courseSettings;
this.displayedCollection = new Discussion(this.collection.models, {
pages: this.collection.pages
});
this.collection.on("change", this.reloadDisplayedCollection);
this.discussionIds = "";
this.collection.on("reset", function(discussion) {
var board;
board = $(".current-board").html();
self.displayedCollection.current_page = discussion.current_page;
self.displayedCollection.pages = discussion.pages;
return self.displayedCollection.reset(discussion.models);
});
this.collection.on("add", this.addAndSelectThread);
this.sidebar_padding = 10;
this.boardName = null;
this.template = _.template($("#thread-list-template").html());
this.current_search = "";
this.mode = 'all';
this.searchAlertCollection = new Backbone.Collection([], {
model: Backbone.Model
});
this.searchAlertCollection.on("add", function(searchAlert) {
var content;
content = _.template($("#search-alert-template").html())({
'message': searchAlert.attributes.message,
'cid': searchAlert.cid
});
self.$(".search-alerts").append(content);
return self.$("#search-alert-" + searchAlert.cid + " a.dismiss")
.bind("click", searchAlert, function(event) {
return self.removeSearchAlert(event.data.cid);
});
});
this.searchAlertCollection.on("remove", function(searchAlert) {
return self.$("#search-alert-" + searchAlert.cid).remove();
});
return this.searchAlertCollection.on("reset", function() {
return self.$(".search-alerts").empty();
});
};
DiscussionThreadListView.prototype.addSearchAlert = function(message) {
var m;
m = new Backbone.Model({
"message": message
});
this.searchAlertCollection.add(m);
return m;
};
DiscussionThreadListView.prototype.removeSearchAlert = function(searchAlert) {
return this.searchAlertCollection.remove(searchAlert);
};
DiscussionThreadListView.prototype.clearSearchAlerts = function() {
return this.searchAlertCollection.reset();
};
DiscussionThreadListView.prototype.reloadDisplayedCollection = function(thread) {
var active, content, current_el, thread_id;
this.clearSearchAlerts();
thread_id = thread.get('id');
content = this.renderThread(thread);
current_el = this.$(".forum-nav-thread[data-id=" + thread_id + "]");
active = current_el.has(".forum-nav-thread-link.is-active").length !== 0;
current_el.replaceWith(content);
this.showMetadataAccordingToSort();
if (active) {
return this.setActiveThread(thread_id);
}
};
/*
TODO fix this entire chain of events
*/
DiscussionThreadListView.prototype.addAndSelectThread = function(thread) {
var commentable_id, menuItem,
self = this;
commentable_id = thread.get("commentable_id");
menuItem = this.$(".forum-nav-browse-menu-item[data-discussion-id]").filter(function() {
return $(this).data("discussion-id") === commentable_id;
});
this.setCurrentTopicDisplay(this.getPathText(menuItem));
return this.retrieveDiscussion(commentable_id, function() {
return self.trigger("thread:created", thread.get('id'));
});
};
DiscussionThreadListView.prototype.updateSidebar = function() {
var amount, browseFilterHeight, discussionBody, discussionBottomOffset, discussionsBodyBottom,
discussionsBodyTop, headerHeight, refineBarHeight, scrollTop, sidebar, sidebarHeight, topOffset,
windowHeight;
scrollTop = $(window).scrollTop();
windowHeight = $(window).height();
discussionBody = $(".discussion-column");
discussionsBodyTop = discussionBody[0] ? discussionBody.offset().top : void 0;
discussionsBodyBottom = discussionsBodyTop + discussionBody.outerHeight();
sidebar = $(".forum-nav");
if (scrollTop > discussionsBodyTop - this.sidebar_padding) {
sidebar.css('top', scrollTop - discussionsBodyTop + this.sidebar_padding);
} else {
sidebar.css('top', '0');
}
sidebarHeight = windowHeight - Math.max(discussionsBodyTop - scrollTop, this.sidebar_padding);
topOffset = scrollTop + windowHeight;
discussionBottomOffset = discussionsBodyBottom + this.sidebar_padding;
amount = Math.max(topOffset - discussionBottomOffset, 0);
sidebarHeight = sidebarHeight - this.sidebar_padding - amount;
sidebarHeight = Math.min(sidebarHeight + 1, discussionBody.outerHeight());
sidebar.css('height', sidebarHeight);
headerHeight = this.$(".forum-nav-header").outerHeight();
refineBarHeight = this.$(".forum-nav-refine-bar").outerHeight();
browseFilterHeight = this.$(".forum-nav-browse-filter").outerHeight();
this.$('.forum-nav-thread-list')
.css('height', (sidebarHeight - headerHeight - refineBarHeight - 2) + 'px');
this.$('.forum-nav-browse-menu')
.css('height', (sidebarHeight - headerHeight - browseFilterHeight - 2) + 'px');
};
DiscussionThreadListView.prototype.ignoreClick = function(event) {
return event.stopPropagation();
};
DiscussionThreadListView.prototype.render = function() {
var self = this;
this.timer = 0;
this.$el.html(this.template({
isCohorted: this.courseSettings.get("is_cohorted"),
isPrivilegedUser: DiscussionUtil.isPrivilegedUser()
}));
this.$(".forum-nav-sort-control option").removeProp("selected");
this.$(".forum-nav-sort-control option[value=" + this.collection.sort_preference + "]")
.prop("selected", true);
$(window).bind("load scroll resize", this.updateSidebar);
this.displayedCollection.on("reset", this.renderThreads);
this.displayedCollection.on("thread:remove", this.renderThreads);
this.displayedCollection.on("change:commentable_id", function() {
if (self.mode === "commentables") {
return self.retrieveDiscussions(self.discussionIds.split(","));
}
});
this.renderThreads();
return this;
};
DiscussionThreadListView.prototype.renderThreads = function() {
var content, rendered, thread, _i, _len, _ref;
this.$(".forum-nav-thread-list").html("");
rendered = $("<div></div>");
_ref = this.displayedCollection.models;
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
thread = _ref[_i];
content = this.renderThread(thread);
rendered.append(content);
}
this.$(".forum-nav-thread-list").html(rendered.html());
this.showMetadataAccordingToSort();
this.renderMorePages();
this.updateSidebar();
return this.trigger("threads:rendered");
};
DiscussionThreadListView.prototype.showMetadataAccordingToSort = function() {
var commentCounts, voteCounts;
voteCounts = this.$(".forum-nav-thread-votes-count");
commentCounts = this.$(".forum-nav-thread-comments-count");
voteCounts.hide();
commentCounts.hide();
switch (this.$(".forum-nav-sort-control").val()) {
case "activity":
case "comments":
return commentCounts.show();
case "votes":
return voteCounts.show();
}
};
DiscussionThreadListView.prototype.renderMorePages = function() {
if (this.displayedCollection.hasMorePages()) {
return this.$(".forum-nav-thread-list")
.append(
"<li class='forum-nav-load-more'>" +
" <a href='#' class='forum-nav-load-more-link'>" + gettext("Load more") + "</a>" +
"</li>"
);
}
};
DiscussionThreadListView.prototype.getLoadingContent = function(srText) {
return '<div class="forum-nav-loading" tabindex="0">' +
' <span class="icon fa fa-spinner fa-spin"/><span class="sr" role="alert">' + srText + '</span>' +
'</div>';
};
DiscussionThreadListView.prototype.loadMorePages = function(event) {
var error, lastThread, loadMoreElem, loadingElem, options, _ref,
self = this;
if (event) {
event.preventDefault();
}
loadMoreElem = this.$(".forum-nav-load-more");
loadMoreElem.html(this.getLoadingContent(gettext("Loading more threads")));
loadingElem = loadMoreElem.find(".forum-nav-loading");
DiscussionUtil.makeFocusTrap(loadingElem);
loadingElem.focus();
options = {
filter: this.filter
};
switch (this.mode) {
case 'search':
options.search_text = this.current_search;
if (this.group_id) {
options.group_id = this.group_id;
}
break;
case 'followed':
options.user_id = window.user.id;
break;
case 'commentables':
options.commentable_ids = this.discussionIds;
if (this.group_id) {
options.group_id = this.group_id;
}
break;
case 'all':
if (this.group_id) {
options.group_id = this.group_id;
}
}
_ref = this.collection.last();
lastThread = _ref ? _ref.get('id') : void 0;
if (lastThread) {
this.once("threads:rendered", function() {
var classSelector =
".forum-nav-thread[data-id='" + lastThread + "'] + .forum-nav-thread " +
".forum-nav-thread-link";
return $(classSelector).focus();
});
} else {
this.once("threads:rendered", function() {
var _ref1 = $(".forum-nav-thread-link").first();
return _ref1 ? _ref1.focus() : void 0;
});
}
error = function() {
self.renderThreads();
DiscussionUtil.discussionAlert(
gettext("Sorry"), gettext("We had some trouble loading more threads. Please try again.")
);
};
return this.collection.retrieveAnotherPage(this.mode, options, {
sort_key: this.$(".forum-nav-sort-control").val()
}, error);
};
DiscussionThreadListView.prototype.renderThread = function(thread) {
var content, unreadCount;
content = $(_.template($("#thread-list-item-template").html())(thread.toJSON()));
unreadCount = thread.get('unread_comments_count') + (thread.get("read") ? 0 : 1);
if (unreadCount > 0) {
content.find('.forum-nav-thread-comments-count').attr(
"data-tooltip",
interpolate(
ngettext('%(unread_count)s new comment', '%(unread_count)s new comments', unreadCount),
{unread_count: unreadCount},
true
)
);
}
return content;
};
DiscussionThreadListView.prototype.threadSelected = function(e) {
var thread_id;
thread_id = $(e.target).closest(".forum-nav-thread").attr("data-id");
this.setActiveThread(thread_id);
this.trigger("thread:selected", thread_id);
return false;
};
DiscussionThreadListView.prototype.threadRemoved = function(thread_id) {
return this.trigger("thread:removed", thread_id);
};
DiscussionThreadListView.prototype.setActiveThread = function(thread_id) {
this.$(".forum-nav-thread-link").find(".sr").remove();
this.$(".forum-nav-thread[data-id!='" + thread_id + "'] .forum-nav-thread-link")
.removeClass("is-active");
this.$(".forum-nav-thread[data-id='" + thread_id + "'] .forum-nav-thread-link")
.addClass("is-active").find(".forum-nav-thread-wrapper-1")
.prepend('<span class="sr">' + gettext("Current conversation") + '</span>');
};
DiscussionThreadListView.prototype.goHome = function() {
var thread_id, url;
this.template = _.template($("#discussion-home-template").html());
$(".forum-content").html(this.template);
$(".forum-nav-thread-list a").removeClass("is-active").find(".sr").remove();
$("input.email-setting").bind("click", this.updateEmailNotifications);
url = DiscussionUtil.urlFor("notifications_status", window.user.get("id"));
DiscussionUtil.safeAjax({
url: url,
type: "GET",
success: function(response) {
if (response.status) {
return $('input.email-setting').attr('checked', 'checked');
} else {
return $('input.email-setting').removeAttr('checked');
}
}
});
thread_id = null;
return this.trigger("thread:removed");
/*
select all threads
*/
};
DiscussionThreadListView.prototype.isBrowseMenuVisible = function() {
return this.$(".forum-nav-browse-menu-wrapper").is(":visible");
};
DiscussionThreadListView.prototype.showBrowseMenu = function() {
if (!this.isBrowseMenuVisible()) {
this.$(".forum-nav-browse").addClass("is-active");
this.$(".forum-nav-browse-menu-wrapper").show();
this.$(".forum-nav-thread-list-wrapper").hide();
$(".forum-nav-browse-filter-input").focus();
$("body").bind("click", this.hideBrowseMenu);
return this.updateSidebar();
}
};
DiscussionThreadListView.prototype.hideBrowseMenu = function() {
if (this.isBrowseMenuVisible()) {
this.$(".forum-nav-browse").removeClass("is-active");
this.$(".forum-nav-browse-menu-wrapper").hide();
this.$(".forum-nav-thread-list-wrapper").show();
$("body").unbind("click", this.hideBrowseMenu);
return this.updateSidebar();
}
};
DiscussionThreadListView.prototype.toggleBrowseMenu = function(event) {
event.preventDefault();
event.stopPropagation();
if (this.isBrowseMenuVisible()) {
return this.hideBrowseMenu();
} else {
return this.showBrowseMenu();
}
};
DiscussionThreadListView.prototype.getPathText = function(item) {
var path, pathTitles;
path = item.parents(".forum-nav-browse-menu-item").andSelf();
pathTitles = path.children(".forum-nav-browse-title").map(function(i, elem) {
return $(elem).text();
}).get();
return pathTitles.join(" / ");
};
DiscussionThreadListView.prototype.filterTopics = function(event) {
var items, query,
self = this;
query = $(event.target).val();
items = this.$(".forum-nav-browse-menu-item");
if (query.length === 0) {
return items.show();
} else {
items.hide();
return items.each(function(i, item) {
var path, pathText;
item = $(item);
if (!item.is(":visible")) {
pathText = self.getPathText(item).toLowerCase();
if (query.split(" ").every(function(term) {
return pathText.search(term.toLowerCase()) !== -1;
})) {
path = item.parents(".forum-nav-browse-menu-item").andSelf();
return path.add(item.find(".forum-nav-browse-menu-item")).show();
}
}
});
}
};
DiscussionThreadListView.prototype.setCurrentTopicDisplay = function(text) {
return this.$(".forum-nav-browse-current").text(this.fitName(text));
};
DiscussionThreadListView.prototype.getNameWidth = function(name) {
var test, width;
test = $("<div>");
test.css({
"font-size": this.$(".forum-nav-browse-current").css('font-size'),
opacity: 0,
position: 'absolute',
left: -1000,
top: -1000
});
$("body").append(test);
test.html(name);
width = test.width();
test.remove();
return width;
};
DiscussionThreadListView.prototype.fitName = function(name) {
var partialName, path, prefix, rawName, width, x;
this.maxNameWidth = this.$(".forum-nav-browse").width() -
this.$(".forum-nav-browse .icon").outerWidth(true) -
this.$(".forum-nav-browse-drop-arrow").outerWidth(true);
width = this.getNameWidth(name);
if (width < this.maxNameWidth) {
return name;
}
path = (function() {
var _i, _len, _ref, _results;
_ref = name.split("/");
_results = [];
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
x = _ref[_i];
_results.push(x.replace(/^\s+|\s+$/g, ""));
}
return _results;
})();
prefix = "";
while (path.length > 1) {
prefix = gettext("…") + "/";
path.shift();
partialName = prefix + path.join("/");
if (this.getNameWidth(partialName) < this.maxNameWidth) {
return partialName;
}
}
rawName = path[0];
name = prefix + rawName;
while (this.getNameWidth(name) > this.maxNameWidth) {
rawName = rawName.slice(0, rawName.length - 1);
name = prefix + rawName + gettext("…");
}
return name;
};
DiscussionThreadListView.prototype.selectTopicHandler = function(event) {
event.preventDefault();
return this.selectTopic($(event.target));
};
DiscussionThreadListView.prototype.selectTopic = function($target) {
var allItems, discussionIds, item;
this.hideBrowseMenu();
this.clearSearch();
item = $target.closest('.forum-nav-browse-menu-item');
this.setCurrentTopicDisplay(this.getPathText(item));
if (item.hasClass("forum-nav-browse-menu-all")) {
this.discussionIds = "";
this.$('.forum-nav-filter-cohort').show();
return this.retrieveAllThreads();
} else if (item.hasClass("forum-nav-browse-menu-following")) {
this.retrieveFollowed();
return this.$('.forum-nav-filter-cohort').hide();
} else {
allItems = item.find(".forum-nav-browse-menu-item").andSelf();
discussionIds = allItems.filter("[data-discussion-id]").map(function(i, elem) {
return $(elem).data("discussion-id");
}).get();
this.retrieveDiscussions(discussionIds);
return this.$(".forum-nav-filter-cohort").toggle(item.data('cohorted') === true);
}
};
DiscussionThreadListView.prototype.chooseFilter = function() {
this.filter = $(".forum-nav-filter-main-control :selected").val();
return this.retrieveFirstPage();
};
DiscussionThreadListView.prototype.chooseCohort = function() {
this.group_id = this.$('.forum-nav-filter-cohort-control :selected').val();
return this.retrieveFirstPage();
};
DiscussionThreadListView.prototype.retrieveDiscussion = function(discussion_id, callback) {
var url, self = this;
url = DiscussionUtil.urlFor("retrieve_discussion", discussion_id);
return DiscussionUtil.safeAjax({
url: url,
type: "GET",
success: function(response) {
self.collection.current_page = response.page;
self.collection.pages = response.num_pages;
self.collection.reset(response.discussion_data);
Content.loadContentInfos(response.annotated_content_info);
self.displayedCollection.reset(self.collection.models);
if (callback) {
return callback();
}
}
});
};
DiscussionThreadListView.prototype.retrieveDiscussions = function(discussion_ids) {
this.discussionIds = discussion_ids.join(',');
this.mode = 'commentables';
return this.retrieveFirstPage();
};
DiscussionThreadListView.prototype.retrieveAllThreads = function() {
this.mode = 'all';
return this.retrieveFirstPage();
};
DiscussionThreadListView.prototype.retrieveFirstPage = function(event) {
this.collection.current_page = 0;
this.collection.reset();
return this.loadMorePages(event);
};
DiscussionThreadListView.prototype.sortThreads = function(event) {
this.displayedCollection.setSortComparator(this.$(".forum-nav-sort-control").val());
return this.retrieveFirstPage(event);
};
DiscussionThreadListView.prototype.performSearch = function(event) {
/*
event.which 13 represent the Enter button
*/
var text;
if (event.which === 13 || event.type === 'click') {
event.preventDefault();
this.hideBrowseMenu();
this.setCurrentTopicDisplay(gettext("Search Results"));
text = this.$(".forum-nav-search-input").val();
return this.searchFor(text);
}
};
DiscussionThreadListView.prototype.searchFor = function(text) {
var url,
self = this;
this.clearSearchAlerts();
this.clearFilters();
this.mode = 'search';
this.current_search = text;
url = DiscussionUtil.urlFor("search");
/*
TODO: This might be better done by setting discussion.current_page=0 and
calling discussion.loadMorePages
Mainly because this currently does not reset any pagination variables which could cause problems.
This doesn't use pagination either.
*/
return DiscussionUtil.safeAjax({
$elem: this.$(".forum-nav-search-input"),
data: {
text: text
},
url: url,
type: "GET",
dataType: 'json',
$loading: $,
loadingCallback: function() {
return self.$(".forum-nav-thread-list")
.html(
"<li class='forum-nav-load-more'>" +
self.getLoadingContent(gettext("Loading thread list")) +
"</li>"
);
},
loadedCallback: function() {
return self.$(".forum-nav-thread-list .forum-nav-load-more").remove();
},
success: function(response, textStatus) {
var message, noResponseMsg;
if (textStatus === 'success') {
self.collection.reset(response.discussion_data);
Content.loadContentInfos(response.annotated_content_info);
self.collection.current_page = response.page;
self.collection.pages = response.num_pages;
if (!_.isNull(response.corrected_text)) {
noResponseMsg = _.escape(
gettext(
'No results found for %(original_query)s. ' +
'Showing results for %(suggested_query)s.'
)
);
message = interpolate(
noResponseMsg,
{
"original_query": "<em>" + _.escape(text) + "</em>",
"suggested_query": "<em>" + response.corrected_text + "</em>"
},
true
);
self.addSearchAlert(message);
} else if (response.discussion_data.length === 0) {
self.addSearchAlert(gettext('No threads matched your query.'));
}
self.displayedCollection.reset(self.collection.models);
if (text) {
return self.searchForUser(text);
}
}
}
});
};
DiscussionThreadListView.prototype.searchForUser = function(text) {
var self = this;
return DiscussionUtil.safeAjax({
data: {
username: text
},
url: DiscussionUtil.urlFor("users"),
type: "GET",
dataType: 'json',
error: function() {
},
success: function(response) {
var message;
if (response.users.length > 0) {
message = interpolate(_.escape(gettext('Show posts by %(username)s.')), {
"username": _.template('<a class="link-jump" href="<%= url %>"><%- username %></a>')({
url: DiscussionUtil.urlFor("user_profile", response.users[0].id),
username: response.users[0].username
})
}, true);
return self.addSearchAlert(message);
}
}
});
};
DiscussionThreadListView.prototype.clearSearch = function() {
this.$(".forum-nav-search-input").val("");
this.current_search = "";
return this.clearSearchAlerts();
};
DiscussionThreadListView.prototype.clearFilters = function() {
this.$(".forum-nav-filter-main-control").val("all");
return this.$(".forum-nav-filter-cohort-control").val("all");
};
DiscussionThreadListView.prototype.retrieveFollowed = function() {
this.mode = 'followed';
return this.retrieveFirstPage();
};
DiscussionThreadListView.prototype.updateEmailNotifications = function() {
if ($('input.email-setting').attr('checked')) {
return DiscussionUtil.safeAjax({
url: DiscussionUtil.urlFor("enable_notifications"),
type: "POST",
error: function() {
return $('input.email-setting').removeAttr('checked');
}
});
} else {
return DiscussionUtil.safeAjax({
url: DiscussionUtil.urlFor("disable_notifications"),
type: "POST",
error: function() {
return $('input.email-setting').attr('checked', 'checked');
}
});
}
};
return DiscussionThreadListView;
}).call(this, Backbone.View);
}
}).call(window);

View File

@@ -0,0 +1,72 @@
/* globals DiscussionUtil, MathJax */
(function() {
'use strict';
var __hasProp = {}.hasOwnProperty,
__extends = function(child, parent) {
for (var key in parent) {
if (__hasProp.call(parent, key)) {
child[key] = parent[key];
}
}
function ctor() {
this.constructor = child;
}
ctor.prototype = parent.prototype;
child.prototype = new ctor();
child.__super__ = parent.prototype;
return child;
};
if (typeof Backbone !== "undefined" && Backbone !== null) {
this.DiscussionThreadProfileView = (function(_super) {
__extends(DiscussionThreadProfileView, _super);
function DiscussionThreadProfileView() {
return DiscussionThreadProfileView.__super__.constructor.apply(this, arguments);
}
DiscussionThreadProfileView.prototype.render = function() {
var element, params;
this.convertMath();
this.abbreviateBody();
params = $.extend(this.model.toJSON(), {
permalink: this.model.urlFor('retrieve')
});
if (!this.model.get('anonymous')) {
params = $.extend(params, {
user: {
username: this.model.username,
user_url: this.model.user_url
}
});
}
this.$el.html(_.template($("#profile-thread-template").html())(params));
this.$("span.timeago").timeago();
element = this.$(".post-body");
if (typeof MathJax !== "undefined" && MathJax !== null) {
MathJax.Hub.Queue(["Typeset", MathJax.Hub, element[0]]);
}
return this;
};
DiscussionThreadProfileView.prototype.convertMath = function() {
return this.model.set(
'markdownBody',
DiscussionUtil.postMathJaxProcessor(DiscussionUtil.markdownWithHighlight(this.model.get('body')))
);
};
DiscussionThreadProfileView.prototype.abbreviateBody = function() {
var abbreviated;
abbreviated = DiscussionUtil.abbreviateHTML(this.model.get('markdownBody'), 140);
return this.model.set('abbreviatedBody', abbreviated);
};
return DiscussionThreadProfileView;
})(Backbone.View);
}
}).call(window);

View File

@@ -0,0 +1,91 @@
/* globals DiscussionUtil, DiscussionContentShowView, MathJax */
(function() {
'use strict';
var __hasProp = {}.hasOwnProperty,
__extends = function(child, parent) {
for (var key in parent) {
if (__hasProp.call(parent, key)) {
child[key] = parent[key];
}
}
function ctor() {
this.constructor = child;
}
ctor.prototype = parent.prototype;
child.prototype = new ctor();
child.__super__ = parent.prototype;
return child;
};
if (typeof Backbone !== "undefined" && Backbone !== null) {
this.DiscussionThreadShowView = (function(_super) {
__extends(DiscussionThreadShowView, _super);
function DiscussionThreadShowView() {
return DiscussionThreadShowView.__super__.constructor.apply(this, arguments);
}
DiscussionThreadShowView.prototype.initialize = function(options) {
var _ref;
DiscussionThreadShowView.__super__.initialize.call(this);
this.mode = options.mode || "inline";
if ((_ref = this.mode) !== "tab" && _ref !== "inline") {
throw new Error("invalid mode: " + this.mode);
}
};
DiscussionThreadShowView.prototype.renderTemplate = function() {
var context;
this.template = _.template($("#thread-show-template").html());
context = $.extend({
mode: this.mode,
flagged: this.model.isFlagged(),
author_display: this.getAuthorDisplay(),
cid: this.model.cid,
readOnly: $('.discussion-module').data('read-only')
}, this.model.attributes);
return this.template(context);
};
DiscussionThreadShowView.prototype.render = function() {
this.$el.html(this.renderTemplate());
this.delegateEvents();
this.renderAttrs();
this.$("span.timeago").timeago();
this.convertMath();
this.highlight(this.$(".post-body"));
this.highlight(this.$("h1,h3"));
return this;
};
DiscussionThreadShowView.prototype.convertMath = function() {
var element;
element = this.$(".post-body");
element.html(DiscussionUtil.postMathJaxProcessor(DiscussionUtil.markdownWithHighlight(element.text())));
if (typeof MathJax !== "undefined" && MathJax !== null) {
return MathJax.Hub.Queue(["Typeset", MathJax.Hub, element[0]]);
}
};
DiscussionThreadShowView.prototype.edit = function(event) {
return this.trigger("thread:edit", event);
};
DiscussionThreadShowView.prototype._delete = function(event) {
return this.trigger("thread:_delete", event);
};
DiscussionThreadShowView.prototype.highlight = function(el) {
if (el.html()) {
return el.html(el.html().replace(/&lt;mark&gt;/g, "<mark>").replace(/&lt;\/mark&gt;/g, "</mark>"));
}
};
return DiscussionThreadShowView;
})(DiscussionContentShowView);
}
}).call(window);

View File

@@ -0,0 +1,489 @@
/* globals
Comments, Content, DiscussionContentView, DiscussionThreadEditView,
DiscussionThreadShowView, DiscussionUtil, ThreadResponseView
*/
(function() {
'use strict';
var __hasProp = {}.hasOwnProperty,
__extends = function(child, parent) {
for (var key in parent) {
if (__hasProp.call(parent, key)) {
child[key] = parent[key];
}
}
function ctor() {
this.constructor = child;
}
ctor.prototype = parent.prototype;
child.prototype = new ctor();
child.__super__ = parent.prototype;
return child;
};
if (typeof Backbone !== "undefined" && Backbone !== null) {
this.DiscussionThreadView = (function(_super) {
var INITIAL_RESPONSE_PAGE_SIZE, SUBSEQUENT_RESPONSE_PAGE_SIZE;
__extends(DiscussionThreadView, _super);
function DiscussionThreadView() {
var self = this;
this._delete = function() {
return DiscussionThreadView.prototype._delete.apply(self, arguments);
};
this.closeEditView = function() {
return DiscussionThreadView.prototype.closeEditView.apply(self, arguments);
};
this.edit = function() {
return DiscussionThreadView.prototype.edit.apply(self, arguments);
};
this.endorseThread = function() {
return DiscussionThreadView.prototype.endorseThread.apply(self, arguments);
};
this.addComment = function() {
return DiscussionThreadView.prototype.addComment.apply(self, arguments);
};
this.renderAddResponseButton = function() {
return DiscussionThreadView.prototype.renderAddResponseButton.apply(self, arguments);
};
this.renderResponseToList = function() {
return DiscussionThreadView.prototype.renderResponseToList.apply(self, arguments);
};
this.renderResponseCountAndPagination = function() {
return DiscussionThreadView.prototype.renderResponseCountAndPagination.apply(self, arguments);
};
return DiscussionThreadView.__super__.constructor.apply(this, arguments);
}
INITIAL_RESPONSE_PAGE_SIZE = 25;
SUBSEQUENT_RESPONSE_PAGE_SIZE = 100;
DiscussionThreadView.prototype.events = {
"click .discussion-submit-post": "submitComment",
"click .add-response-btn": "scrollToAddResponse",
"click .forum-thread-expand": "expand",
"click .forum-thread-collapse": "collapse"
};
DiscussionThreadView.prototype.$ = function(selector) {
return this.$el.find(selector);
};
DiscussionThreadView.prototype.isQuestion = function() {
return this.model.get("thread_type") === "question";
};
DiscussionThreadView.prototype.initialize = function(options) {
var _ref,
self = this;
DiscussionThreadView.__super__.initialize.call(this);
this.mode = options.mode || "inline";
this.context = options.context || "course";
this.options = _.extend({}, options);
if ((_ref = this.mode) !== "tab" && _ref !== "inline") {
throw new Error("invalid mode: " + this.mode);
}
this.readOnly = $(".discussion-module").data('read-only');
this.model.collection.on("reset", function(collection) {
var id;
id = self.model.get("id");
if (collection.get(id)) {
self.model = collection.get(id);
}
});
this.createShowView();
this.responses = new Comments();
this.loadedResponses = false;
if (this.isQuestion()) {
this.markedAnswers = new Comments();
}
};
DiscussionThreadView.prototype.rerender = function() {
if (this.showView) {
this.showView.undelegateEvents();
}
this.undelegateEvents();
this.$el.empty();
this.initialize({
mode: this.mode,
model: this.model,
el: this.el,
course_settings: this.options.course_settings,
topicId: this.topicId
});
return this.render();
};
DiscussionThreadView.prototype.renderTemplate = function() {
var container, templateData;
this.template = _.template($("#thread-template").html());
container = $("#discussion-container");
if (!container.length) {
container = $(".discussion-module");
}
templateData = _.extend(this.model.toJSON(), {
readOnly: this.readOnly,
can_create_comment: container.data("user-create-comment")
});
return this.template(templateData);
};
DiscussionThreadView.prototype.render = function() {
var self = this;
this.$el.html(this.renderTemplate());
this.delegateEvents();
this.renderShowView();
this.renderAttrs();
this.$("span.timeago").timeago();
this.makeWmdEditor("reply-body");
this.renderAddResponseButton();
this.responses.on("add", function(response) {
return self.renderResponseToList(response, ".js-response-list", {});
});
if (this.isQuestion()) {
this.markedAnswers.on("add", function(response) {
return self.renderResponseToList(response, ".js-marked-answer-list", {
collapseComments: true
});
});
}
if (this.mode === "tab") {
setTimeout(function() {
return self.loadInitialResponses();
}, 100);
return this.$(".post-tools").hide();
} else {
return this.collapse();
}
};
DiscussionThreadView.prototype.attrRenderer = $.extend({}, DiscussionContentView.prototype.attrRenderer, {
closed: function(closed) {
this.$(".discussion-reply-new").toggle(!closed);
this.$('.comment-form').closest('li').toggle(!closed);
this.$(".action-vote").toggle(!closed);
this.$(".display-vote").toggle(closed);
return this.renderAddResponseButton();
}
});
DiscussionThreadView.prototype.expand = function(event) {
if (event) {
event.preventDefault();
}
this.$el.addClass("expanded");
this.$el.find(".post-body").text(this.model.get("body"));
this.showView.convertMath();
this.$el.find(".forum-thread-expand").hide();
this.$el.find(".forum-thread-collapse").show();
this.$el.find(".post-extended-content").show();
if (!this.loadedResponses) {
return this.loadInitialResponses();
}
};
DiscussionThreadView.prototype.collapse = function(event) {
if (event) {
event.preventDefault();
}
this.$el.removeClass("expanded");
this.$el.find(".post-body").text(this.getAbbreviatedBody());
this.showView.convertMath();
this.$el.find(".forum-thread-expand").show();
this.$el.find(".forum-thread-collapse").hide();
return this.$el.find(".post-extended-content").hide();
};
DiscussionThreadView.prototype.getAbbreviatedBody = function() {
var abbreviated, cached;
cached = this.model.get("abbreviatedBody");
if (cached) {
return cached;
} else {
abbreviated = DiscussionUtil.abbreviateString(this.model.get("body"), 140);
this.model.set("abbreviatedBody", abbreviated);
return abbreviated;
}
};
DiscussionThreadView.prototype.cleanup = function() {
if (this.responsesRequest) {
return this.responsesRequest.abort();
}
};
DiscussionThreadView.prototype.loadResponses = function(responseLimit, elem, firstLoad) {
var takeFocus,
self = this;
takeFocus = this.mode === "tab" ? false : true;
this.responsesRequest = DiscussionUtil.safeAjax({
url: DiscussionUtil.urlFor(
'retrieve_single_thread', this.model.get('commentable_id'), this.model.id
),
data: {
resp_skip: this.responses.size(),
resp_limit: responseLimit ? responseLimit : void 0
},
$elem: elem,
$loading: elem,
takeFocus: takeFocus,
complete: function() {
self.responsesRequest = null;
},
success: function(data) {
Content.loadContentInfos(data.annotated_content_info);
if (self.isQuestion()) {
self.markedAnswers.add(data.content.endorsed_responses);
}
self.responses.add(
self.isQuestion() ? data.content.non_endorsed_responses : data.content.children
);
self.renderResponseCountAndPagination(
self.isQuestion() ?
data.content.non_endorsed_resp_total :
data.content.resp_total
);
self.trigger("thread:responses:rendered");
self.loadedResponses = true;
return self.$el.find('.discussion-article[data-id="' + self.model.id + '"]').focus();
},
error: function(xhr, textStatus) {
if (textStatus === 'abort') {
return;
}
if (xhr.status === 404) {
DiscussionUtil.discussionAlert(
gettext("Sorry"),
gettext("The thread you selected has been deleted. Please select another thread.")
);
} else if (firstLoad) {
DiscussionUtil.discussionAlert(
gettext("Sorry"),
gettext("We had some trouble loading responses. Please reload the page.")
);
} else {
DiscussionUtil.discussionAlert(
gettext("Sorry"),
gettext("We had some trouble loading more responses. Please try again.")
);
}
}
});
};
DiscussionThreadView.prototype.loadInitialResponses = function() {
return this.loadResponses(INITIAL_RESPONSE_PAGE_SIZE, this.$el.find(".js-response-list"), true);
};
DiscussionThreadView.prototype.renderResponseCountAndPagination = function(responseTotal) {
var buttonText, loadMoreButton, responseCountFormat, responseLimit, responsePagination,
responsesRemaining, showingResponsesText, self = this;
if (this.isQuestion() && this.markedAnswers.length !== 0) {
responseCountFormat = ngettext(
"%(numResponses)s other response", "%(numResponses)s other responses", responseTotal
);
} else {
responseCountFormat = ngettext(
"%(numResponses)s response", "%(numResponses)s responses", responseTotal
);
}
this.$el.find(".response-count").html(interpolate(responseCountFormat, {
numResponses: responseTotal
}, true));
responsePagination = this.$el.find(".response-pagination");
responsePagination.empty();
if (responseTotal > 0) {
responsesRemaining = responseTotal - this.responses.size();
if (responsesRemaining === 0) {
showingResponsesText = gettext("Showing all responses");
}
else {
showingResponsesText = interpolate(
ngettext(
"Showing first response", "Showing first %(numResponses)s responses",
this.responses.size()
),
{ numResponses: this.responses.size() },
true
);
}
responsePagination.append($("<span>")
.addClass("response-display-count").html(_.escape(showingResponsesText)));
if (responsesRemaining > 0) {
if (responsesRemaining < SUBSEQUENT_RESPONSE_PAGE_SIZE) {
responseLimit = null;
buttonText = gettext("Load all responses");
} else {
responseLimit = SUBSEQUENT_RESPONSE_PAGE_SIZE;
buttonText = interpolate(gettext("Load next %(numResponses)s responses"), {
numResponses: responseLimit
}, true);
}
loadMoreButton = $("<button>").addClass("load-response-button").html(_.escape(buttonText));
loadMoreButton.click(function() {
return self.loadResponses(responseLimit, loadMoreButton);
});
return responsePagination.append(loadMoreButton);
}
}
};
DiscussionThreadView.prototype.renderResponseToList = function(response, listSelector, options) {
var view;
response.set('thread', this.model);
view = new ThreadResponseView($.extend({
model: response
}, options));
view.on("comment:add", this.addComment);
view.on("comment:endorse", this.endorseThread);
view.render();
this.$el.find(listSelector).append(view.el);
return view.afterInsert();
};
DiscussionThreadView.prototype.renderAddResponseButton = function() {
if (this.model.hasResponses() && this.model.can('can_reply') && !this.model.get('closed')) {
return this.$el.find('div.add-response').show();
} else {
return this.$el.find('div.add-response').hide();
}
};
DiscussionThreadView.prototype.scrollToAddResponse = function(event) {
var form;
event.preventDefault();
form = $(event.target).parents('article.discussion-article').find('form.discussion-reply-new');
$('html, body').scrollTop(form.offset().top);
return form.find('.wmd-panel textarea').focus();
};
DiscussionThreadView.prototype.addComment = function() {
return this.model.comment();
};
DiscussionThreadView.prototype.endorseThread = function() {
return this.model.set('endorsed', this.$el.find(".action-answer.is-checked").length > 0);
};
DiscussionThreadView.prototype.submitComment = function(event) {
var body, comment, url;
event.preventDefault();
url = this.model.urlFor('reply');
body = this.getWmdContent("reply-body");
if (!body.trim().length) {
return;
}
this.setWmdContent("reply-body", "");
comment = new Comment({
body: body,
created_at: (new Date()).toISOString(),
username: window.user.get("username"),
votes: {
up_count: 0
},
abuse_flaggers: [],
endorsed: false,
user_id: window.user.get("id")
});
comment.set('thread', this.model.get('thread'));
this.renderResponseToList(comment, ".js-response-list");
this.model.addComment();
this.renderAddResponseButton();
return DiscussionUtil.safeAjax({
$elem: $(event.target),
url: url,
type: "POST",
dataType: 'json',
data: {
body: body
},
success: function(data) {
comment.updateInfo(data.annotated_content_info);
return comment.set(data.content);
}
});
};
DiscussionThreadView.prototype.edit = function() {
this.createEditView();
return this.renderEditView();
};
DiscussionThreadView.prototype.createEditView = function() {
if (this.showView) {
this.showView.undelegateEvents();
this.showView.$el.empty();
this.showView = null;
}
this.editView = new DiscussionThreadEditView({
container: this.$('.thread-content-wrapper'),
model: this.model,
mode: this.mode,
context: this.context,
course_settings: this.options.course_settings
});
this.editView.bind("thread:updated thread:cancel_edit", this.closeEditView);
return this.editView.bind("comment:endorse", this.endorseThread);
};
DiscussionThreadView.prototype.renderSubView = function(view) {
view.setElement(this.$('.thread-content-wrapper'));
view.render();
return view.delegateEvents();
};
DiscussionThreadView.prototype.renderEditView = function() {
return this.editView.render();
};
DiscussionThreadView.prototype.createShowView = function() {
this.showView = new DiscussionThreadShowView({
model: this.model,
mode: this.mode
});
this.showView.bind("thread:_delete", this._delete);
return this.showView.bind("thread:edit", this.edit);
};
DiscussionThreadView.prototype.renderShowView = function() {
return this.renderSubView(this.showView);
};
DiscussionThreadView.prototype.closeEditView = function() {
this.createShowView();
this.renderShowView();
this.renderAttrs();
return this.$el.find(".post-extended-content").show();
};
DiscussionThreadView.prototype._delete = function(event) {
var $elem, url;
url = this.model.urlFor('_delete');
if (!this.model.can('can_delete')) {
return;
}
if (!confirm(gettext("Are you sure you want to delete this post?"))) {
return;
}
this.model.remove();
this.showView.undelegateEvents();
this.undelegateEvents();
this.$el.empty();
$elem = $(event.target);
return DiscussionUtil.safeAjax({
$elem: $elem,
url: url,
type: "POST"
});
};
return DiscussionThreadView;
})(DiscussionContentView);
}
}).call(window);

View File

@@ -1,4 +1,4 @@
(function(Backbone) {
(function() {
'use strict';
if (Backbone) {
this.DiscussionTopicMenuView = Backbone.View.extend({
@@ -42,7 +42,9 @@
this.selectedTopic = this.$('.js-selected-topic');
this.hideTopicDropdown();
if (this.getCurrentTopicId()) {
this.setTopic(this.$('a.topic-title').filter('[data-discussion-id="' + this.getCurrentTopicId() + '"]'));
this.setTopic(this.$('a.topic-title').filter(
'[data-discussion-id="' + this.getCurrentTopicId() + '"]')
);
} else {
this.setTopic(this.$('a.topic-title').first());
}
@@ -174,7 +176,7 @@
// TODO: this helper class duplicates functionality in DiscussionThreadListView.filterTopics
// for use with a very similar category dropdown in the New Post form. The two menus' implementations
// should be merged into a single reusable view.
filterDrop: function (e) {
filterDrop: function(e) {
var $drop, $items, query;
$drop = $(e.target).parents('.topic-menu-wrapper');
query = $(e.target).val();
@@ -186,14 +188,14 @@
}
$items.addClass('hidden');
$items.each(function (_index, item) {
$items.each(function(_index, item) {
var path, pathText, pathTitles;
path = $(item).parents(".topic-menu-item").andSelf();
pathTitles = path.children(".topic-title").map(function (_, elem) {
pathTitles = path.children(".topic-title").map(function(_, elem) {
return $(elem).text();
}).get();
pathText = pathTitles.join(" / ").toLowerCase();
if (query.split(" ").every(function (term) {
if (query.split(" ").every(function(term) {
return pathText.search(term.toLowerCase()) !== -1;
})) {
$(item).removeClass('hidden');
@@ -204,4 +206,4 @@
}
});
}
}).call(this, Backbone);
}).call(this);

View File

@@ -0,0 +1,107 @@
/* globals Discussion, DiscussionThreadProfileView, DiscussionUtil, URI */
(function() {
'use strict';
var __hasProp = {}.hasOwnProperty,
__extends = function(child, parent) {
for (var key in parent) {
if (__hasProp.call(parent, key)) {
child[key] = parent[key];
}
}
function ctor() {
this.constructor = child;
}
ctor.prototype = parent.prototype;
child.prototype = new ctor();
child.__super__ = parent.prototype;
return child;
};
if (typeof Backbone !== "undefined" && Backbone !== null) {
this.DiscussionUserProfileView = (function(_super) {
__extends(DiscussionUserProfileView, _super);
function DiscussionUserProfileView() {
var self = this;
this.render = function() {
return DiscussionUserProfileView.prototype.render.apply(self, arguments);
};
return DiscussionUserProfileView.__super__.constructor.apply(this, arguments);
}
DiscussionUserProfileView.prototype.events = {
"click .discussion-paginator a": "changePage"
};
DiscussionUserProfileView.prototype.initialize = function(options) {
DiscussionUserProfileView.__super__.initialize.call(this);
this.page = options.page;
this.numPages = options.numPages;
this.discussion = new Discussion();
this.discussion.on("reset", this.render);
return this.discussion.reset(this.collection, {
silent: false
});
};
DiscussionUserProfileView.prototype.render = function() {
var baseUri, pageUrlFunc, paginationParams,
self = this;
this.$el.html(_.template($("#user-profile-template").html())({
threads: this.discussion.models
}));
this.discussion.map(function(thread) {
return new DiscussionThreadProfileView({
el: self.$("article#thread_" + thread.id),
model: thread
}).render();
});
baseUri = URI(window.location).removeSearch("page");
pageUrlFunc = function(page) {
return baseUri.clone().addSearch("page", page);
};
paginationParams = DiscussionUtil.getPaginationParams(this.page, this.numPages, pageUrlFunc);
this.$el.find(".discussion-pagination")
.html(_.template($("#pagination-template").html())(paginationParams));
};
DiscussionUserProfileView.prototype.changePage = function(event) {
var url,
self = this;
event.preventDefault();
url = $(event.target).attr("href");
return DiscussionUtil.safeAjax({
$elem: this.$el,
$loading: $(event.target),
takeFocus: true,
url: url,
type: "GET",
dataType: "json",
success: function(response) {
self.page = response.page;
self.numPages = response.num_pages;
self.discussion.reset(response.discussion_data, {
silent: false
});
history.pushState({}, "", url);
return $("html, body").animate({
scrollTop: 0
});
},
error: function() {
return DiscussionUtil.discussionAlert(
gettext("Sorry"),
gettext("We had some trouble loading the page you requested. Please try again.")
);
}
});
};
return DiscussionUserProfileView;
})(Backbone.View);
}
}).call(window);

Some files were not shown because too many files have changed in this diff Show More