diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 2bb272bcfe..0f87ced3f1 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -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", ) diff --git a/cms/djangoapps/contentstore/tests/test_orphan.py b/cms/djangoapps/contentstore/tests/test_orphan.py index 519276cbc2..5d089bdd8f 100644 --- a/cms/djangoapps/contentstore/tests/test_orphan.py +++ b/cms/djangoapps/contentstore/tests/test_orphan.py @@ -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): diff --git a/cms/djangoapps/contentstore/tests/utils.py b/cms/djangoapps/contentstore/tests/utils.py index 2026295c9e..af5e7b8e52 100644 --- a/cms/djangoapps/contentstore/tests/utils.py +++ b/cms/djangoapps/contentstore/tests/utils.py @@ -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): """ diff --git a/cms/djangoapps/contentstore/views/tests/test_assets.py b/cms/djangoapps/contentstore/views/tests/test_assets.py index 78ce5b4050..797b76be59 100644 --- a/cms/djangoapps/contentstore/views/tests/test_assets.py +++ b/cms/djangoapps/contentstore/views/tests/test_assets.py @@ -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" ) diff --git a/cms/static/js/i18n/eo/djangojs.js b/cms/static/js/i18n/eo/djangojs.js index 5dd968d7bd..ccfe9c7fd9 100644 --- a/cms/static/js/i18n/eo/djangojs.js +++ b/cms/static/js/i18n/eo/djangojs.js @@ -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,#", diff --git a/cms/static/js/i18n/fake2/djangojs.js b/cms/static/js/i18n/fake2/djangojs.js index 4968a1597d..64aa6311bb 100644 --- a/cms/static/js/i18n/fake2/djangojs.js +++ b/cms/static/js/i18n/fake2/djangojs.js @@ -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:", diff --git a/cms/static/js/i18n/rtl/djangojs.js b/cms/static/js/i18n/rtl/djangojs.js index f8f88d1f9e..51d9c7b8bf 100644 --- a/cms/static/js/i18n/rtl/djangojs.js +++ b/cms/static/js/i18n/rtl/djangojs.js @@ -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:", diff --git a/cms/static/js/i18n/zh-cn/djangojs.js b/cms/static/js/i18n/zh-cn/djangojs.js index 9b10ea752d..dea2b447bc 100644 --- a/cms/static/js/i18n/zh-cn/djangojs.js +++ b/cms/static/js/i18n/zh-cn/djangojs.js @@ -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" diff --git a/cms/static/sass/_base.scss b/cms/static/sass/_base.scss index 8da5903138..3fd284bdea 100644 --- a/cms/static/sass/_base.scss +++ b/cms/static/sass/_base.scss @@ -36,8 +36,8 @@ body, input, button { font-family: 'Open Sans', sans-serif; } -// we want to hide the outline on the focusable
element -main { +// removing the outline on any element that we make programmatically focusable +[tabindex="-1"] { outline: none; } diff --git a/common/test/data/toy/static/sample_static.txt b/common/djangoapps/config_models/management/__init__.py similarity index 100% rename from common/test/data/toy/static/sample_static.txt rename to common/djangoapps/config_models/management/__init__.py diff --git a/common/djangoapps/config_models/management/commands/__init__.py b/common/djangoapps/config_models/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/config_models/management/commands/populate_model.py b/common/djangoapps/config_models/management/commands/populate_model.py new file mode 100644 index 0000000000..d41ceae58b --- /dev/null +++ b/common/djangoapps/config_models/management/commands/populate_model.py @@ -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)) diff --git a/common/djangoapps/config_models/models.py b/common/djangoapps/config_models/models.py index 5528f084cf..ab429c701d 100644 --- a/common/djangoapps/config_models/models.py +++ b/common/djangoapps/config_models/models.py @@ -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 diff --git a/common/djangoapps/config_models/tests/__init__.py b/common/djangoapps/config_models/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/config_models/tests/data/data.json b/common/djangoapps/config_models/tests/data/data.json new file mode 100644 index 0000000000..e6977c7d54 --- /dev/null +++ b/common/djangoapps/config_models/tests/data/data.json @@ -0,0 +1,14 @@ +{ + "model": "config_models.ExampleDeserializeConfig", + "data": [ + { + "name": "betty", + "enabled": true, + "int_field": 5 + }, + { + "name": "fred", + "enabled": false + } + ] +} diff --git a/common/djangoapps/config_models/tests/test_model_deserialization.py b/common/djangoapps/config_models/tests/test_model_deserialization.py new file mode 100644 index 0000000000..af1ff81e49 --- /dev/null +++ b/common/djangoapps/config_models/tests/test_model_deserialization.py @@ -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) diff --git a/common/djangoapps/config_models/tests.py b/common/djangoapps/config_models/tests/tests.py similarity index 73% rename from common/djangoapps/config_models/tests.py rename to common/djangoapps/config_models/tests/tests.py index 15f954e336..538058c109 100644 --- a/common/djangoapps/config_models/tests.py +++ b/common/djangoapps/config_models/tests/tests.py @@ -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): diff --git a/common/djangoapps/config_models/utils.py b/common/djangoapps/config_models/utils.py new file mode 100644 index 0000000000..10a293af4d --- /dev/null +++ b/common/djangoapps/config_models/utils.py @@ -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) diff --git a/common/djangoapps/config_models/views.py b/common/djangoapps/config_models/views.py index 3bd693ec59..c9d584f9cb 100644 --- a/common/djangoapps/config_models/views.py +++ b/common/djangoapps/config_models/views.py @@ -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 diff --git a/common/djangoapps/contentserver/middleware.py b/common/djangoapps/contentserver/middleware.py index 8b96c6b536..29eed2e048 100644 --- a/common/djangoapps/contentserver/middleware.py +++ b/common/djangoapps/contentserver/middleware.py @@ -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. diff --git a/common/djangoapps/contentserver/test/test_contentserver.py b/common/djangoapps/contentserver/test/test_contentserver.py index c68bc5a63c..52bdf83d2d 100644 --- a/common/djangoapps/contentserver/test/test_contentserver.py +++ b/common/djangoapps/contentserver/test/test_contentserver.py @@ -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 diff --git a/common/djangoapps/static_replace/__init__.py b/common/djangoapps/static_replace/__init__.py index d98bea1b90..e2a8fe5069 100644 --- a/common/djangoapps/static_replace/__init__.py +++ b/common/djangoapps/static_replace/__init__.py @@ -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( diff --git a/common/djangoapps/static_replace/test/test_static_replace.py b/common/djangoapps/static_replace/test/test_static_replace.py index bc203a85bc..04a562a771 100644 --- a/common/djangoapps/static_replace/test/test_static_replace.py +++ b/common/djangoapps/static_replace/test/test_static_replace.py @@ -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)) diff --git a/common/djangoapps/student/forms.py b/common/djangoapps/student/forms.py index 095b7cc21c..84bdb17e0c 100644 --- a/common/djangoapps/student/forms.py +++ b/common/djangoapps/student/forms.py @@ -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 ): """ diff --git a/common/djangoapps/student/tests/test_email.py b/common/djangoapps/student/tests/test_email.py index 2bd303c91d..e97311b9cd 100644 --- a/common/djangoapps/student/tests/test_email.py +++ b/common/djangoapps/student/tests/test_email.py @@ -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( diff --git a/common/djangoapps/student/tests/test_reset_password.py b/common/djangoapps/student/tests/test_reset_password.py index 96cca17e7a..6ca006765b 100644 --- a/common/djangoapps/student/tests/test_reset_password.py +++ b/common/djangoapps/student/tests/test_reset_password.py @@ -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) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 6db07a4bac..b502dc6bea 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -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) diff --git a/common/lib/xmodule/xmodule/block_metadata_utils.py b/common/lib/xmodule/xmodule/block_metadata_utils.py new file mode 100644 index 0000000000..3969419597 --- /dev/null +++ b/common/lib/xmodule/xmodule/block_metadata_utils.py @@ -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: + ${course.display_name_with_default_escaped} + + After: + ${course.display_name_with_default | h} + 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('<', '<').replace('>', '>') diff --git a/common/lib/xmodule/xmodule/contentstore/content.py b/common/lib/xmodule/xmodule/contentstore/content.py index 8b5ae95ea0..c98663e91c 100644 --- a/common/lib/xmodule/xmodule/contentstore/content.py +++ b/common/lib/xmodule/xmodule/contentstore/content.py @@ -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 diff --git a/common/lib/xmodule/xmodule/contentstore/mongo.py b/common/lib/xmodule/xmodule/contentstore/mongo.py index 8fc505e121..1d1f2a0314 100644 --- a/common/lib/xmodule/xmodule/contentstore/mongo.py +++ b/common/lib/xmodule/xmodule/contentstore/mongo.py @@ -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: diff --git a/common/lib/xmodule/xmodule/course_metadata_utils.py b/common/lib/xmodule/xmodule/course_metadata_utils.py index 0a6a3ba73c..0586d10274 100644 --- a/common/lib/xmodule/xmodule/course_metadata_utils.py +++ b/common/lib/xmodule/xmodule/course_metadata_utils.py @@ -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: - ${course.display_name_with_default_escaped} - - After: - ${course.display_name_with_default | h} - 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('<', '<').replace('>', '>') - - def number_for_course_location(location): """ Given a course's block usage locator, returns the course's number. diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 361523dcfd..bd9ff594ab 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -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]) diff --git a/common/lib/xmodule/xmodule/css/video/display.scss b/common/lib/xmodule/xmodule/css/video/display.scss index 060842d788..bd478ab0a2 100644 --- a/common/lib/xmodule/xmodule/css/video/display.scss +++ b/common/lib/xmodule/xmodule/css/video/display.scss @@ -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 } } diff --git a/common/lib/xmodule/xmodule/graders.py b/common/lib/xmodule/xmodule/graders.py index 64d2d76923..cb47fa2dbb 100644 --- a/common/lib/xmodule/xmodule/graders.py +++ b/common/lib/xmodule/xmodule/graders.py @@ -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. """ diff --git a/common/lib/xmodule/xmodule/js/fixtures/video_yt_multiple.html b/common/lib/xmodule/xmodule/js/fixtures/video_yt_multiple.html index c941c535a6..d4f5401785 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/video_yt_multiple.html +++ b/common/lib/xmodule/xmodule/js/fixtures/video_yt_multiple.html @@ -55,9 +55,8 @@ -
-
-
+ +
@@ -109,9 +108,7 @@ -
-
-
+
diff --git a/common/lib/xmodule/xmodule/js/karma_xmodule.conf.js b/common/lib/xmodule/xmodule/js/karma_xmodule.conf.js index 3f72dcb7b0..5ee38962a7 100644 --- a/common/lib/xmodule/xmodule/js/karma_xmodule.conf.js +++ b/common/lib/xmodule/xmodule/js/karma_xmodule.conf.js @@ -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. diff --git a/common/lib/xmodule/xmodule/js/spec/main_requirejs.js b/common/lib/xmodule/xmodule/js/spec/main_requirejs.js index d4692469e5..a57497f8c8 100644 --- a/common/lib/xmodule/xmodule/js/spec/main_requirejs.js +++ b/common/lib/xmodule/xmodule/js/spec/main_requirejs.js @@ -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); diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js index 5d85be2551..b8cf3065b1 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js @@ -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.' ); }); diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_speed_control_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_speed_control_spec.js index f73b1f3c59..2a8b5e8b67 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_speed_control_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_speed_control_spec.js @@ -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'); }); }); diff --git a/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js b/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js index b54de6a24a..986d9ea7ff 100644 --- a/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js +++ b/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js @@ -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('
  • '), - HtmlUtils.HTML(''), - HtmlUtils.HTML('
  • ') - ), - { - speed: speed - } - ).toString(); + return [ + '
  • ', + '', + '
  • ' + ].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; }, diff --git a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js index dd409e7a91..b99a1b1128 100644 --- a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js +++ b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js @@ -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('
    '), - HtmlUtils.HTML(''), - HtmlUtils.HTML(''), - HtmlUtils.HTML(')') - ); + '">', + '', + '', + '', + '', + '', + '
    ', + '' + ].join(''); - var subtitlesHtml = HtmlUtils.interpolateHtml( - HtmlUtils.joinHtml( - HtmlUtils.HTML('
    '), - HtmlUtils.HTML('

    '), - HtmlUtils.HTML('
      '), - HtmlUtils.HTML('
      ') - ), - { - courseId: this.state.id, - courseLang: this.state.lang - } - ); + var template = [ + '
      ', + '

      ', + '
        ', + '
        ' + ].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('
      1. '), - gettext('Transcript will be displayed when you start playing the video.'), - HtmlUtils.HTML('
      2. ') - ) - ); + self.subtitlesEl.find('.subtitles-menu') + .text(gettext('Transcript will be displayed when you start playing the video.')) // jshint ignore: line + .wrapInner('
      3. '); } 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 = $('