From 59c5baeb1ba72cfb0cfcd9139510feb264140f6b Mon Sep 17 00:00:00 2001 From: Usman Khalid Date: Thu, 19 Sep 2013 20:50:05 +0500 Subject: [PATCH 01/54] Disables the previous/next arrows on pdf books on the first/last pages respectively --- common/static/js/pdfviewer.js | 6 ++++++ lms/static/sass/course/_textbook.scss | 8 ++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/common/static/js/pdfviewer.js b/common/static/js/pdfviewer.js index 258541e0ad..889c1e7d71 100644 --- a/common/static/js/pdfviewer.js +++ b/common/static/js/pdfviewer.js @@ -200,6 +200,12 @@ PDFJS.disableWorker = true; document.getElementById('numPages').textContent = 'of ' + pdfDocument.numPages; $("#pageNumber").max = pdfDocument.numPages; $("#pageNumber").val(pageNum); + + // Enable/disable the previous/next buttons + if (pageNum > 1) $("#previous").addClass("enabled"); + else $("#previous").removeClass("enabled"); + if (pageNum < pdfDocument.numPages) $("#next").addClass("enabled"); + else $("#next").removeClass("enabled"); } // Go to previous page diff --git a/lms/static/sass/course/_textbook.scss b/lms/static/sass/course/_textbook.scss index 72d73bdb78..f839c0dd10 100644 --- a/lms/static/sass/course/_textbook.scss +++ b/lms/static/sass/course/_textbook.scss @@ -160,11 +160,11 @@ div.book-wrapper { @include transition(none); vertical-align: middle; width: 100%; + } - &:hover { - opacity: 1.0; - filter: alpha(opacity=100); - } + a.enabled:hover { + opacity: 1.0; + filter: alpha(opacity=100); } &.last { From 93a9e28fc7e5e30bfaa928f2b7a5cb74953b1bfd Mon Sep 17 00:00:00 2001 From: e0d Date: Mon, 16 Sep 2013 09:11:30 -0400 Subject: [PATCH 02/54] Upgrading to 1.4.8 --- requirements/edx/base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 62515f7445..bcfc01ab66 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -29,7 +29,7 @@ django-storages==1.1.5 django-threaded-multihost==1.4-1 django-method-override==0.1.0 djangorestframework==2.3.5 -django==1.4.5 +django==1.4.8 feedparser==5.1.3 fs==0.4.0 GitPython==0.3.2.RC1 From d2cd8a6a58a40e8c55da06f68dc06972e529dd7e Mon Sep 17 00:00:00 2001 From: Usman Khalid Date: Fri, 20 Sep 2013 15:59:34 +0500 Subject: [PATCH 03/54] Added setting to disable find courses links --- lms/envs/common.py | 3 +++ lms/templates/dashboard.html | 10 +++++++--- lms/templates/navigation.html | 8 +++++--- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/lms/envs/common.py b/lms/envs/common.py index 3fa1918769..b09af34b38 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -103,6 +103,9 @@ MITX_FEATURES = { # with Shib. Feature was requested by Stanford's office of general counsel 'SHIB_DISABLE_TOS': False, + # Can be turned off if all courses are invite-only. Effects views and templates. + 'ENABLE_STUDENT_SELF_ENROLLMENT': True, + # Enables ability to restrict enrollment in specific courses by the user account login method 'RESTRICT_ENROLL_BY_REG_METHOD': False, diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 0a5892e3ea..3e851ecbc6 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -332,10 +332,14 @@ % else:
-

${_("Looks like you haven't registered for any courses yet.")}

- + % if settings.MITX_FEATURES.get('ENABLE_STUDENT_SELF_ENROLLMENT'): +

${_("Looks like you haven't registered for any courses yet.")}

+
${_("Find courses now!")} - + + % else: +

${_("Looks like you haven't joined any courses yet.")}

+ %endif
% endif diff --git a/lms/templates/navigation.html b/lms/templates/navigation.html index 1dd5aa5229..d44cd0e1b2 100644 --- a/lms/templates/navigation.html +++ b/lms/templates/navigation.html @@ -57,9 +57,11 @@ site_status_msg = get_site_status_msg(course_id)
    From 3ff203d11f3847621b175e9174b565fac083f4d1 Mon Sep 17 00:00:00 2001 From: Usman Khalid Date: Mon, 23 Sep 2013 15:04:11 +0000 Subject: [PATCH 04/54] Renamed setting ENABLE_STUDENT_SELF_ENROLLMENT to COURSES_ARE_BROWSABLE --- lms/envs/common.py | 4 ++-- lms/templates/dashboard.html | 2 +- lms/templates/index.html | 20 +++++++++++--------- lms/templates/navigation.html | 2 +- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/lms/envs/common.py b/lms/envs/common.py index b09af34b38..ba060f5ab1 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -103,8 +103,8 @@ MITX_FEATURES = { # with Shib. Feature was requested by Stanford's office of general counsel 'SHIB_DISABLE_TOS': False, - # Can be turned off if all courses are invite-only. Effects views and templates. - 'ENABLE_STUDENT_SELF_ENROLLMENT': True, + # Can be turned off if course lists need to be hidden. Effects views and templates. + 'COURSES_ARE_BROWSABLE': True, # Enables ability to restrict enrollment in specific courses by the user account login method 'RESTRICT_ENROLL_BY_REG_METHOD': False, diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 3e851ecbc6..bca2bf8c38 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -332,7 +332,7 @@ % else:
    - % if settings.MITX_FEATURES.get('ENABLE_STUDENT_SELF_ENROLLMENT'): + % if settings.MITX_FEATURES.get('COURSES_ARE_BROWSABLE'):

    ${_("Looks like you haven't registered for any courses yet.")}

    ${_("Find courses now!")} diff --git a/lms/templates/index.html b/lms/templates/index.html index 0fecd24e84..83024e01aa 100644 --- a/lms/templates/index.html +++ b/lms/templates/index.html @@ -165,15 +165,17 @@
    % endif -
    -
      - %for course in courses: -
    • - <%include file="course.html" args="course=course" /> -
    • - %endfor -
    -
    + % if settings.MITX_FEATURES.get('COURSES_ARE_BROWSABLE'): +
    +
      + %for course in courses: +
    • + <%include file="course.html" args="course=course" /> +
    • + %endfor +
    +
    + % endif diff --git a/lms/templates/navigation.html b/lms/templates/navigation.html index d44cd0e1b2..680838a719 100644 --- a/lms/templates/navigation.html +++ b/lms/templates/navigation.html @@ -57,7 +57,7 @@ site_status_msg = get_site_status_msg(course_id)
    % if settings.MITX_FEATURES.get('ENABLE_PAID_COURSE_REGISTRATION') and \ - settings.MITX_FEATURES['ENABLE_SHOPPING_CART'] and \ + settings.MITX_FEATURES.get('ENABLE_SHOPPING_CART') and \ shoppingcart.models.Order.user_cart_has_items(user):
    1. From 7d578e4f1f8e68c689e763240e5d0f277071e915 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Mon, 30 Sep 2013 11:23:40 -0700 Subject: [PATCH 33/54] Update CHANGELOG.rst --- CHANGELOG.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 443e787482..83323e0de3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +LMS: Add PaidCourseRegistration mode, where payment is required before course registration. + LMS: Add split testing functionality for internal use. Studio and LMS: add ability to lock assets (cannot be viewed unless registered for class). From aeac8dddbb388da2cd72f026b430637d1c0a567f Mon Sep 17 00:00:00 2001 From: jmclaus Date: Thu, 26 Sep 2013 14:48:59 +0200 Subject: [PATCH 34/54] All video controls have a full outline when receiving focus --- .../xmodule/xmodule/css/video/display.scss | 72 +++++++++++++------ 1 file changed, 50 insertions(+), 22 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/video/display.scss b/common/lib/xmodule/xmodule/css/video/display.scss index 1a1ca14fbd..c087d18098 100644 --- a/common/lib/xmodule/xmodule/css/video/display.scss +++ b/common/lib/xmodule/xmodule/css/video/display.scss @@ -136,7 +136,7 @@ div.video { &:focus, &:hover { background-color: lighten($pink, 10%); - outline: none; + outline: 0; } } } @@ -162,9 +162,16 @@ div.video { text-indent: -9999px; width: 14px; background: url('../images/vcr.png') 15px 15px no-repeat; - outline: 0; &:focus { + position: relative; + z-index: 10000; + outline: #fff dotted thin; + outline-offset: -2px; + background: #333; + } + + &:hover { outline: 0; } @@ -176,7 +183,7 @@ div.video { &.play { background-position: 17px -114px; - &:hover, &:focus { + &:hover { background-color: #444; } } @@ -184,7 +191,7 @@ div.video { &.pause { background-position: 16px -50px; - &:hover, &:focus { + &:hover { background-color: #444; } } @@ -203,6 +210,19 @@ div.video { div.secondary-controls { float: right; + div.speeds>a, div.volume>a, a.add-fullscreen, a.quality_control, + a.hide-subtitles { + // overflow is used to bypass Firefox CSS :focus outline bug + // http://johndoesdesign.com/blog/2012/css/firefox-and-its-css-focus-outline-bug/ + &:focus { + position: relative; + z-index: 10000; + outline: #fff dotted thin; + outline-offset: -2px; + overflow: auto; + } + } + div.speeds { float: left; position: relative; @@ -250,10 +270,15 @@ div.video { } } - outline: 0; - - &:focus { + &:hover { outline: 0; + opacity: 1.0; + background-color: #444; + } + + &:active { + opacity: 1.0; + background-color: #444; } h3 { @@ -280,11 +305,6 @@ div.video { line-height: 46px; color: #fff; } - - &:hover, &:active, &:focus { - opacity: 1.0; - background-color: #444; - } } // fix for now @@ -320,6 +340,7 @@ div.video { &:hover { background-color: #666; color: #aaa; + outline-offset: -4px; } } @@ -371,9 +392,12 @@ div.video { @include transition(none); -webkit-font-smoothing: antialiased; width: 30px; - - &:hover, &:active, &:focus { + + &:hover, &:active { background-color: #444; + color: #fff; + text-decoration: none; + outline: 0; } } @@ -433,14 +457,16 @@ div.video { text-indent: -9999px; @include transition(none); width: 30px; - - &:hover, &:active, &:focus { + + &:hover, &:active { background-color: #444; color: #fff; text-decoration: none; + outline: 0; } } + a.quality_control { background: url(../images/hd.png) center no-repeat; border-right: 1px solid #000; @@ -455,16 +481,18 @@ div.video { @include transition(none); width: 30px; - &:hover, &:focus { + &:hover { background-color: #444; color: #fff; text-decoration: none; + outline: 0; } &.active { background-color: #F44; color: #0ff; text-decoration: none; + outline: 0; } } @@ -483,10 +511,11 @@ div.video { -webkit-font-smoothing: antialiased; width: 30px; - &:hover, &:focus { + &:hover { background-color: #444; color: #fff; text-decoration: none; + outline: 0; } &.off { @@ -530,8 +559,7 @@ div.video { margin-bottom: 8px; padding: 0; line-height: lh(); - outline-width: 0px; - outline-style: none; + outline: 0; &.current { color: #333; @@ -539,8 +567,8 @@ div.video { } &.focused { - outline-width: 1px; - outline-style: dotted; + outline: #000 dotted thin; + outline-offset: -1px; } &:hover { From d7e441088b628a24cb40437832a2497f1b0d4c69 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 17 Sep 2013 15:10:56 -0400 Subject: [PATCH 35/54] Adjust parameters to construct_xblock_from_class to match XBlock repo. --- common/lib/xmodule/xmodule/error_module.py | 4 ++-- common/lib/xmodule/xmodule/modulestore/mongo/base.py | 7 +++---- .../modulestore/split_mongo/caching_descriptor_system.py | 2 +- common/lib/xmodule/xmodule/modulestore/xml.py | 2 +- common/lib/xmodule/xmodule/tests/test_editing_module.py | 2 +- common/lib/xmodule/xmodule/tests/test_video.py | 2 +- common/lib/xmodule/xmodule/tests/test_xblock_wrappers.py | 4 ++-- common/lib/xmodule/xmodule/tests/test_xml_module.py | 2 +- common/lib/xmodule/xmodule/video_module.py | 5 ++--- common/lib/xmodule/xmodule/x_module.py | 2 +- common/lib/xmodule/xmodule/xml_module.py | 5 ++--- 11 files changed, 17 insertions(+), 20 deletions(-) diff --git a/common/lib/xmodule/xmodule/error_module.py b/common/lib/xmodule/xmodule/error_module.py index 5c13b90ccf..7fc3747f44 100644 --- a/common/lib/xmodule/xmodule/error_module.py +++ b/common/lib/xmodule/xmodule/error_module.py @@ -105,10 +105,10 @@ class ErrorDescriptor(ErrorFields, XModuleDescriptor): }) return system.construct_xblock_from_class( cls, - field_data, # The error module doesn't use scoped data, and thus doesn't need # real scope keys - ScopeIds('error', None, location, location) + ScopeIds('error', None, location, location), + field_data, ) def get_context(self): diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/base.py b/common/lib/xmodule/xmodule/modulestore/mongo/base.py index 44865ab58e..2843287055 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo/base.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo/base.py @@ -193,7 +193,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem): field_data = DbModel(kvs) scope_ids = ScopeIds(None, category, location, location) - module = self.construct_xblock_from_class(class_, field_data, scope_ids) + module = self.construct_xblock_from_class(class_, scope_ids, field_data) if self.cached_metadata is not None: # parent container pointers don't differentiate between draft and non-draft # so when we do the lookup, we should do so with a non-draft location @@ -621,12 +621,11 @@ class MongoModuleStore(ModuleStoreBase): dbmodel = self._create_new_field_data(location.category, location, definition_data, metadata) xmodule = system.construct_xblock_from_class( xblock_class, - dbmodel, - # We're loading a descriptor, so student_id is meaningless # We also don't have separate notions of definition and usage ids yet, # so we use the location for both. - ScopeIds(None, location.category, location, location) + ScopeIds(None, location.category, location, location), + dbmodel, ) # decache any pending field settings from init xmodule.save() diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py index ba81a5231d..a0549074c3 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py @@ -111,8 +111,8 @@ class CachingDescriptorSystem(MakoDescriptorSystem): try: module = self.construct_xblock_from_class( class_, + ScopeIds(None, json_data.get('category'), definition_id, block_locator), field_data, - ScopeIds(None, json_data.get('category'), definition_id, block_locator) ) except Exception: log.warning("Failed to load descriptor", exc_info=True) diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index 93ab0497f9..49ad6f4e95 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -477,11 +477,11 @@ class XMLModuleStore(ModuleStoreBase): loc = Location('i4x', course_descriptor.location.org, course_descriptor.location.course, category, slug) module = system.construct_xblock_from_class( HtmlDescriptor, - DictFieldData({'data': html, 'location': loc, 'category': category}), # We're loading a descriptor, so student_id is meaningless # We also don't have separate notions of definition and usage ids yet, # so we use the location for both ScopeIds(None, category, loc, loc), + DictFieldData({'data': html, 'location': loc, 'category': category}), ) # VS[compat]: # Hack because we need to pull in the 'display_name' for static tabs (because we need to edit them) diff --git a/common/lib/xmodule/xmodule/tests/test_editing_module.py b/common/lib/xmodule/xmodule/tests/test_editing_module.py index 2e59c545a4..36f028e8a4 100644 --- a/common/lib/xmodule/xmodule/tests/test_editing_module.py +++ b/common/lib/xmodule/xmodule/tests/test_editing_module.py @@ -46,8 +46,8 @@ class TabsEditingDescriptorTestCase(unittest.TestCase): TabsEditingDescriptor.tabs = self.tabs self.descriptor = system.construct_xblock_from_class( TabsEditingDescriptor, - field_data=DictFieldData({}), scope_ids=ScopeIds(None, None, None, None), + field_data=DictFieldData({}), ) def test_get_css(self): diff --git a/common/lib/xmodule/xmodule/tests/test_video.py b/common/lib/xmodule/xmodule/tests/test_video.py index b935acfb57..4c9972ea3a 100644 --- a/common/lib/xmodule/xmodule/tests/test_video.py +++ b/common/lib/xmodule/xmodule/tests/test_video.py @@ -133,8 +133,8 @@ class VideoDescriptorTest(unittest.TestCase): system = get_test_descriptor_system() self.descriptor = system.construct_xblock_from_class( VideoDescriptor, - field_data=DictFieldData({}), scope_ids=ScopeIds(None, None, None, None), + field_data=DictFieldData({}), ) def test_get_context(self): diff --git a/common/lib/xmodule/xmodule/tests/test_xblock_wrappers.py b/common/lib/xmodule/xmodule/tests/test_xblock_wrappers.py index 900907f9d1..ef81a9a26b 100644 --- a/common/lib/xmodule/xmodule/tests/test_xblock_wrappers.py +++ b/common/lib/xmodule/xmodule/tests/test_xblock_wrappers.py @@ -87,8 +87,8 @@ class TestXBlockWrapper(object): runtime.render_template = lambda *args, **kwargs: u'{!r}, {!r}'.format(args, kwargs) return runtime.construct_xblock_from_class( descriptor_cls, + ScopeIds(None, descriptor_cls.__name__, location, location), DictFieldData({}), - ScopeIds(None, descriptor_cls.__name__, location, location) ) def leaf_module(self, descriptor_cls): @@ -109,10 +109,10 @@ class TestXBlockWrapper(object): runtime.render_template = lambda *args, **kwargs: u'{!r}, {!r}'.format(args, kwargs) return runtime.construct_xblock_from_class( descriptor_cls, + ScopeIds(None, descriptor_cls.__name__, location, location), DictFieldData({ 'children': range(3) }), - ScopeIds(None, descriptor_cls.__name__, location, location) ) def container_module(self, descriptor_cls, depth): diff --git a/common/lib/xmodule/xmodule/tests/test_xml_module.py b/common/lib/xmodule/xmodule/tests/test_xml_module.py index da6062f662..f3f3a961f7 100644 --- a/common/lib/xmodule/xmodule/tests/test_xml_module.py +++ b/common/lib/xmodule/xmodule/tests/test_xml_module.py @@ -166,8 +166,8 @@ class EditableMetadataFieldsTest(unittest.TestCase): runtime = get_test_descriptor_system() return runtime.construct_xblock_from_class( XmlDescriptor, + scope_ids=Mock(), field_data=field_data, - scope_ids=Mock() ).editable_metadata_fields def get_descriptor(self, field_data): diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py index febfe5961f..96d0a09498 100644 --- a/common/lib/xmodule/xmodule/video_module.py +++ b/common/lib/xmodule/xmodule/video_module.py @@ -247,12 +247,11 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor field_data = DbModel(kvs) video = system.construct_xblock_from_class( cls, - field_data, - # We're loading a descriptor, so student_id is meaningless # We also don't have separate notions of definition and usage ids yet, # so we use the location for both - ScopeIds(None, location.category, location, location) + ScopeIds(None, location.category, location, location), + field_data, ) return video diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 63ff80b9d9..271ad4e101 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -502,8 +502,8 @@ class XModuleDescriptor(XModuleMixin, HTMLSnippet, ResourceTemplates, XBlock): module = system.construct_xblock_from_class( self.module_class, descriptor=self, - field_data=system.xmodule_field_data(self), scope_ids=self.scope_ids, + field_data=system.xmodule_field_data(self), ) module.save() return module diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py index 83bb6faffd..742d79969b 100644 --- a/common/lib/xmodule/xmodule/xml_module.py +++ b/common/lib/xmodule/xmodule/xml_module.py @@ -339,12 +339,11 @@ class XmlDescriptor(XModuleDescriptor): return system.construct_xblock_from_class( cls, - field_data, - # We're loading a descriptor, so student_id is meaningless # We also don't have separate notions of definition and usage ids yet, # so we use the location for both - ScopeIds(None, location.category, location, location) + ScopeIds(None, location.category, location, location), + field_data, ) @classmethod From c36adf84a0b65337ff9c7cb07cbe6da1741b3037 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 20 Sep 2013 16:11:03 -0400 Subject: [PATCH 36/54] Minor cosmetic code fixes. --- common/lib/xmodule/xmodule/x_module.py | 12 ++++-------- common/lib/xmodule/xmodule/xml_module.py | 3 +-- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 271ad4e101..ef43e05a26 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -555,7 +555,7 @@ class XModuleDescriptor(XModuleMixin, HTMLSnippet, ResourceTemplates, XBlock): def from_xml(cls, xml_data, system, org=None, course=None): """ Creates an instance of this descriptor from the supplied xml_data. - This may be overridden by subclasses + This may be overridden by subclasses. xml_data: A string of xml that will be translated into data and children for this module @@ -565,13 +565,12 @@ class XModuleDescriptor(XModuleMixin, HTMLSnippet, ResourceTemplates, XBlock): org and course are optional strings that will be used in the generated module's url identifiers """ - raise NotImplementedError( - 'Modules must implement from_xml to be parsable from xml') + raise NotImplementedError('Modules must implement from_xml to be parsable from xml') def export_to_xml(self, resource_fs): """ Returns an xml string representing this module, and all modules - underneath it. May also write required resources out to resource_fs + underneath it. May also write required resources out to resource_fs. Assumes that modules have single parentage (that no module appears twice in the same course), and that it is thus safe to nest modules as xml @@ -581,8 +580,7 @@ class XModuleDescriptor(XModuleMixin, HTMLSnippet, ResourceTemplates, XBlock): XModuleDescriptor using the from_xml method with the same system, org, and course """ - raise NotImplementedError( - 'Modules must implement export_to_xml to enable xml export') + raise NotImplementedError('Modules must implement export_to_xml to enable xml export') # =============================== BUILTIN METHODS ========================== def __eq__(self, other): @@ -756,8 +754,6 @@ class DescriptorSystem(Runtime): class XMLParsingSystem(DescriptorSystem): def __init__(self, process_xml, policy, **kwargs): """ - load_item, resources_fs, error_tracker: see DescriptorSystem - policy: a policy dictionary for overriding xml metadata process_xml: Takes an xml string, and returns a XModuleDescriptor diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py index 742d79969b..b555d8f775 100644 --- a/common/lib/xmodule/xmodule/xml_module.py +++ b/common/lib/xmodule/xmodule/xml_module.py @@ -217,8 +217,7 @@ class XmlDescriptor(XModuleDescriptor): # give the class a chance to fix it up. The file will be written out # again in the correct format. This should go away once the CMS is # online and has imported all current (fall 2012) courses from xml - if not system.resources_fs.exists(filepath) and hasattr( - cls, 'backcompat_paths'): + if not system.resources_fs.exists(filepath) and hasattr(cls, 'backcompat_paths'): candidates = cls.backcompat_paths(filepath) for candidate in candidates: if system.resources_fs.exists(candidate): From 3a6450c3fc4f9020e5e440f963078c6d0a931fb6 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 13 Sep 2013 14:18:58 -0400 Subject: [PATCH 37/54] Use XModuleDescriptor.parse_xml to interpret XML. --- common/lib/xmodule/setup.py | 41 +++++++++++++++++++ common/lib/xmodule/xmodule/modulestore/xml.py | 36 +++++++++++++++- common/lib/xmodule/xmodule/x_module.py | 40 +++++++----------- 3 files changed, 90 insertions(+), 27 deletions(-) diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index 6a24bf8f27..13e9e2a2cc 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -17,6 +17,47 @@ setup( # See http://guide.python-distribute.org/creation.html#entry-points # for a description of entry_points entry_points={ + 'xblock.v1': [ + "abtest = xmodule.abtest_module:ABTestDescriptor", + "book = xmodule.backcompat_module:TranslateCustomTagDescriptor", + "chapter = xmodule.seq_module:SequenceDescriptor", + "combinedopenended = xmodule.combined_open_ended_module:CombinedOpenEndedDescriptor", + "conditional = xmodule.conditional_module:ConditionalDescriptor", + "course = xmodule.course_module:CourseDescriptor", + "customtag = xmodule.template_module:CustomTagDescriptor", + "discuss = xmodule.backcompat_module:TranslateCustomTagDescriptor", + "html = xmodule.html_module:HtmlDescriptor", + "image = xmodule.backcompat_module:TranslateCustomTagDescriptor", + "error = xmodule.error_module:ErrorDescriptor", + "peergrading = xmodule.peer_grading_module:PeerGradingDescriptor", + "poll_question = xmodule.poll_module:PollDescriptor", + "problem = xmodule.capa_module:CapaDescriptor", + "problemset = xmodule.seq_module:SequenceDescriptor", + "randomize = xmodule.randomize_module:RandomizeDescriptor", + "section = xmodule.backcompat_module:SemanticSectionDescriptor", + "sequential = xmodule.seq_module:SequenceDescriptor", + "slides = xmodule.backcompat_module:TranslateCustomTagDescriptor", + "timelimit = xmodule.timelimit_module:TimeLimitDescriptor", + "vertical = xmodule.vertical_module:VerticalDescriptor", + "video = xmodule.video_module:VideoDescriptor", + "videoalpha = xmodule.video_module:VideoDescriptor", + "videodev = xmodule.backcompat_module:TranslateCustomTagDescriptor", + "videosequence = xmodule.seq_module:SequenceDescriptor", + "discussion = xmodule.discussion_module:DiscussionDescriptor", + "course_info = xmodule.html_module:CourseInfoDescriptor", + "static_tab = xmodule.html_module:StaticTabDescriptor", + "custom_tag_template = xmodule.raw_module:RawDescriptor", + "about = xmodule.html_module:AboutDescriptor", + "wrapper = xmodule.wrapper_module:WrapperDescriptor", + "graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor", + "annotatable = xmodule.annotatable_module:AnnotatableDescriptor", + "foldit = xmodule.foldit_module:FolditDescriptor", + "word_cloud = xmodule.word_cloud_module:WordCloudDescriptor", + "hidden = xmodule.hidden_module:HiddenDescriptor", + "raw = xmodule.raw_module:RawDescriptor", + "crowdsource_hinter = xmodule.crowdsource_hinter:CrowdsourceHinterDescriptor", + "lti = xmodule.lti_module:LTIModuleDescriptor" + ], 'xmodule.v1': [ "abtest = xmodule.abtest_module:ABTestDescriptor", "book = xmodule.backcompat_module:TranslateCustomTagDescriptor", diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index 49ad6f4e95..3354c682c2 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -20,8 +20,10 @@ from xmodule.mako_module import MakoDescriptorSystem from xmodule.x_module import XModuleDescriptor, XMLParsingSystem from xmodule.html_module import HtmlDescriptor +from xblock.core import XBlock from xblock.fields import ScopeIds from xblock.field_data import DictFieldData +from xblock.plugin import PluginMissingError from . import ModuleStoreBase, Location, XML_MODULESTORE_TYPE @@ -163,7 +165,7 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): make_name_unique(xml_data) - descriptor = XModuleDescriptor.load_from_xml( + descriptor = create_block_from_xml( etree.tostring(xml_data, encoding='unicode'), self, self.org, self.course, xmlstore.default_class) except Exception as err: @@ -219,6 +221,38 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): ) +def create_block_from_xml(xml_data, system, org=None, course=None, default_class=None): + """ + Create an XBlock instance from XML data. + + `xml_data' is a string containing valid xml. + + `system` is an XMLParsingSystem. + + `org` and `course` are optional strings that will be used in the generated + block's url identifiers. + + `default_class` is the class to instantiate of the XML indicates a class + that can't be loaded. + + Returns the fully instantiated XBlock. + + """ + node = etree.fromstring(xml_data) + raw_class = XBlock.load_class(node.tag, default_class) + xblock_class = system.mixologist.mix(raw_class) + + # leave next line commented out - useful for low-level debugging + # log.debug('[create_block_from_xml] tag=%s, class=%s' % (node.tag, xblock_class)) + + url_name = node.get('url_name', node.get('slug')) + location = Location('i4x', org, course, node.tag, url_name) + + scope_ids = ScopeIds(None, location.category, location, location) + xblock = xblock_class.parse_xml(node, system, scope_ids) + return xblock + + class ParentTracker(object): """A simple class to factor out the logic for tracking location parent pointers.""" def __init__(self): diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index ef43e05a26..ef37843371 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -524,32 +524,15 @@ class XModuleDescriptor(XModuleMixin, HTMLSnippet, ResourceTemplates, XBlock): return cls.metadata_translations.get(key, key) # ================================= XML PARSING ============================ - @staticmethod - def load_from_xml(xml_data, - system, - org=None, - course=None, - default_class=None): + @classmethod + def parse_xml(cls, node, runtime, keys): """ - This method instantiates the correct subclass of XModuleDescriptor based - on the contents of xml_data. - - xml_data must be a string containing valid xml - - system is an XMLParsingSystem - - org and course are optional strings that will be used in the generated - module's url identifiers + Interpret the parsed XML in `node`, creating an XModuleDescriptor. """ - class_ = system.mixologist.mix(XModuleDescriptor.load_class( - etree.fromstring(xml_data).tag, - default_class - )) - # leave next line, commented out - useful for low-level debugging - # log.debug('[XModuleDescriptor.load_from_xml] tag=%s, class_=%s' % ( - # etree.fromstring(xml_data).tag,class_)) - - return class_.from_xml(xml_data, system, org, course) + xml = etree.tostring(node) + # TODO: change from_xml to not take org and course, it can use self.system. + block = cls.from_xml(xml, runtime, runtime.org, runtime.course) + return block @classmethod def from_xml(cls, xml_data, system, org=None, course=None): @@ -713,7 +696,9 @@ class DescriptorSystem(Runtime): that you're about to re-raise---let the caller track them. """ - super(DescriptorSystem, self).__init__(**kwargs) + # Right now, usage_store is unused, and field_data is always supplanted + # with an explicit field_data during construct_xblock, so None's suffice. + super(DescriptorSystem, self).__init__(usage_store=None, field_data=None, **kwargs) self.load_item = load_item self.resources_fs = resources_fs @@ -835,7 +820,10 @@ class ModuleSystem(Runtime): not to allow the execution of unsafe, unsandboxed code. """ - super(ModuleSystem, self).__init__(**kwargs) + + # Right now, usage_store is unused, and field_data is always supplanted + # with an explicit field_data during construct_xblock, so None's suffice. + super(ModuleSystem, self).__init__(usage_store=None, field_data=None, **kwargs) self.ajax_url = ajax_url self.xqueue = xqueue From a1d464aed9fc383689981210f677af6f911a0d3f Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 27 Sep 2013 10:04:11 -0400 Subject: [PATCH 38/54] Update XBlock requirement to new XML code. --- common/lib/xmodule/setup.py | 132 +++++++++++++----------------------- requirements/edx/github.txt | 2 +- 2 files changed, 48 insertions(+), 86 deletions(-) diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index 13e9e2a2cc..c99234e385 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -1,5 +1,47 @@ from setuptools import setup, find_packages +XMODULES = [ + "abtest = xmodule.abtest_module:ABTestDescriptor", + "book = xmodule.backcompat_module:TranslateCustomTagDescriptor", + "chapter = xmodule.seq_module:SequenceDescriptor", + "combinedopenended = xmodule.combined_open_ended_module:CombinedOpenEndedDescriptor", + "conditional = xmodule.conditional_module:ConditionalDescriptor", + "course = xmodule.course_module:CourseDescriptor", + "customtag = xmodule.template_module:CustomTagDescriptor", + "discuss = xmodule.backcompat_module:TranslateCustomTagDescriptor", + "html = xmodule.html_module:HtmlDescriptor", + "image = xmodule.backcompat_module:TranslateCustomTagDescriptor", + "error = xmodule.error_module:ErrorDescriptor", + "peergrading = xmodule.peer_grading_module:PeerGradingDescriptor", + "poll_question = xmodule.poll_module:PollDescriptor", + "problem = xmodule.capa_module:CapaDescriptor", + "problemset = xmodule.seq_module:SequenceDescriptor", + "randomize = xmodule.randomize_module:RandomizeDescriptor", + "section = xmodule.backcompat_module:SemanticSectionDescriptor", + "sequential = xmodule.seq_module:SequenceDescriptor", + "slides = xmodule.backcompat_module:TranslateCustomTagDescriptor", + "timelimit = xmodule.timelimit_module:TimeLimitDescriptor", + "vertical = xmodule.vertical_module:VerticalDescriptor", + "video = xmodule.video_module:VideoDescriptor", + "videoalpha = xmodule.video_module:VideoDescriptor", + "videodev = xmodule.backcompat_module:TranslateCustomTagDescriptor", + "videosequence = xmodule.seq_module:SequenceDescriptor", + "discussion = xmodule.discussion_module:DiscussionDescriptor", + "course_info = xmodule.html_module:CourseInfoDescriptor", + "static_tab = xmodule.html_module:StaticTabDescriptor", + "custom_tag_template = xmodule.raw_module:RawDescriptor", + "about = xmodule.html_module:AboutDescriptor", + "wrapper = xmodule.wrapper_module:WrapperDescriptor", + "graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor", + "annotatable = xmodule.annotatable_module:AnnotatableDescriptor", + "foldit = xmodule.foldit_module:FolditDescriptor", + "word_cloud = xmodule.word_cloud_module:WordCloudDescriptor", + "hidden = xmodule.hidden_module:HiddenDescriptor", + "raw = xmodule.raw_module:RawDescriptor", + "crowdsource_hinter = xmodule.crowdsource_hinter:CrowdsourceHinterDescriptor", + "lti = xmodule.lti_module:LTIModuleDescriptor", +] + setup( name="XModule", version="0.1", @@ -11,96 +53,16 @@ setup( 'path.py', ], package_data={ - 'xmodule': ['js/module/*'] + 'xmodule': ['js/module/*'], }, # See http://guide.python-distribute.org/creation.html#entry-points # for a description of entry_points entry_points={ - 'xblock.v1': [ - "abtest = xmodule.abtest_module:ABTestDescriptor", - "book = xmodule.backcompat_module:TranslateCustomTagDescriptor", - "chapter = xmodule.seq_module:SequenceDescriptor", - "combinedopenended = xmodule.combined_open_ended_module:CombinedOpenEndedDescriptor", - "conditional = xmodule.conditional_module:ConditionalDescriptor", - "course = xmodule.course_module:CourseDescriptor", - "customtag = xmodule.template_module:CustomTagDescriptor", - "discuss = xmodule.backcompat_module:TranslateCustomTagDescriptor", - "html = xmodule.html_module:HtmlDescriptor", - "image = xmodule.backcompat_module:TranslateCustomTagDescriptor", - "error = xmodule.error_module:ErrorDescriptor", - "peergrading = xmodule.peer_grading_module:PeerGradingDescriptor", - "poll_question = xmodule.poll_module:PollDescriptor", - "problem = xmodule.capa_module:CapaDescriptor", - "problemset = xmodule.seq_module:SequenceDescriptor", - "randomize = xmodule.randomize_module:RandomizeDescriptor", - "section = xmodule.backcompat_module:SemanticSectionDescriptor", - "sequential = xmodule.seq_module:SequenceDescriptor", - "slides = xmodule.backcompat_module:TranslateCustomTagDescriptor", - "timelimit = xmodule.timelimit_module:TimeLimitDescriptor", - "vertical = xmodule.vertical_module:VerticalDescriptor", - "video = xmodule.video_module:VideoDescriptor", - "videoalpha = xmodule.video_module:VideoDescriptor", - "videodev = xmodule.backcompat_module:TranslateCustomTagDescriptor", - "videosequence = xmodule.seq_module:SequenceDescriptor", - "discussion = xmodule.discussion_module:DiscussionDescriptor", - "course_info = xmodule.html_module:CourseInfoDescriptor", - "static_tab = xmodule.html_module:StaticTabDescriptor", - "custom_tag_template = xmodule.raw_module:RawDescriptor", - "about = xmodule.html_module:AboutDescriptor", - "wrapper = xmodule.wrapper_module:WrapperDescriptor", - "graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor", - "annotatable = xmodule.annotatable_module:AnnotatableDescriptor", - "foldit = xmodule.foldit_module:FolditDescriptor", - "word_cloud = xmodule.word_cloud_module:WordCloudDescriptor", - "hidden = xmodule.hidden_module:HiddenDescriptor", - "raw = xmodule.raw_module:RawDescriptor", - "crowdsource_hinter = xmodule.crowdsource_hinter:CrowdsourceHinterDescriptor", - "lti = xmodule.lti_module:LTIModuleDescriptor" - ], - 'xmodule.v1': [ - "abtest = xmodule.abtest_module:ABTestDescriptor", - "book = xmodule.backcompat_module:TranslateCustomTagDescriptor", - "chapter = xmodule.seq_module:SequenceDescriptor", - "combinedopenended = xmodule.combined_open_ended_module:CombinedOpenEndedDescriptor", - "conditional = xmodule.conditional_module:ConditionalDescriptor", - "course = xmodule.course_module:CourseDescriptor", - "customtag = xmodule.template_module:CustomTagDescriptor", - "discuss = xmodule.backcompat_module:TranslateCustomTagDescriptor", - "html = xmodule.html_module:HtmlDescriptor", - "image = xmodule.backcompat_module:TranslateCustomTagDescriptor", - "error = xmodule.error_module:ErrorDescriptor", - "peergrading = xmodule.peer_grading_module:PeerGradingDescriptor", - "poll_question = xmodule.poll_module:PollDescriptor", - "problem = xmodule.capa_module:CapaDescriptor", - "problemset = xmodule.seq_module:SequenceDescriptor", - "randomize = xmodule.randomize_module:RandomizeDescriptor", - "section = xmodule.backcompat_module:SemanticSectionDescriptor", - "sequential = xmodule.seq_module:SequenceDescriptor", - "slides = xmodule.backcompat_module:TranslateCustomTagDescriptor", - "timelimit = xmodule.timelimit_module:TimeLimitDescriptor", - "vertical = xmodule.vertical_module:VerticalDescriptor", - "video = xmodule.video_module:VideoDescriptor", - "videoalpha = xmodule.video_module:VideoDescriptor", - "videodev = xmodule.backcompat_module:TranslateCustomTagDescriptor", - "videosequence = xmodule.seq_module:SequenceDescriptor", - "discussion = xmodule.discussion_module:DiscussionDescriptor", - "course_info = xmodule.html_module:CourseInfoDescriptor", - "static_tab = xmodule.html_module:StaticTabDescriptor", - "custom_tag_template = xmodule.raw_module:RawDescriptor", - "about = xmodule.html_module:AboutDescriptor", - "wrapper = xmodule.wrapper_module:WrapperDescriptor", - "graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor", - "annotatable = xmodule.annotatable_module:AnnotatableDescriptor", - "foldit = xmodule.foldit_module:FolditDescriptor", - "word_cloud = xmodule.word_cloud_module:WordCloudDescriptor", - "hidden = xmodule.hidden_module:HiddenDescriptor", - "raw = xmodule.raw_module:RawDescriptor", - "crowdsource_hinter = xmodule.crowdsource_hinter:CrowdsourceHinterDescriptor", - "lti = xmodule.lti_module:LTIModuleDescriptor" - ], + 'xblock.v1': XMODULES, + 'xmodule.v1': XMODULES, 'console_scripts': [ 'xmodule_assets = xmodule.static_content:main', - ] - } + ], + }, ) diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index e0c88b217e..3a95566b2b 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -14,7 +14,7 @@ -e git+https://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk # Our libraries: --e git+https://github.com/edx/XBlock.git@a8de02c0#egg=XBlock +-e git+https://github.com/edx/XBlock.git@8a66ca3#egg=XBlock -e git+https://github.com/edx/codejail.git@0a1b468#egg=codejail -e git+https://github.com/edx/diff-cover.git@v0.2.4#egg=diff_cover -e git+https://github.com/edx/js-test-tool.git@v0.0.7#egg=js_test_tool From 718328e4e63b90944f643c04c78168a268841822 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Mon, 30 Sep 2013 10:34:36 -0400 Subject: [PATCH 39/54] Get rid of all mention of XModuleDescriptor. --- common/lib/xmodule/xmodule/modulestore/xml.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index 3354c682c2..74c375a545 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -17,13 +17,12 @@ from xmodule.error_module import ErrorDescriptor from xmodule.errortracker import make_error_tracker, exc_info_to_str from xmodule.course_module import CourseDescriptor from xmodule.mako_module import MakoDescriptorSystem -from xmodule.x_module import XModuleDescriptor, XMLParsingSystem +from xmodule.x_module import XMLParsingSystem from xmodule.html_module import HtmlDescriptor from xblock.core import XBlock from xblock.fields import ScopeIds from xblock.field_data import DictFieldData -from xblock.plugin import PluginMissingError from . import ModuleStoreBase, Location, XML_MODULESTORE_TYPE @@ -65,7 +64,7 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): self.load_error_modules = load_error_modules def process_xml(xml): - """Takes an xml string, and returns a XModuleDescriptor created from + """Takes an xml string, and returns a XBlock created from that xml. """ @@ -312,8 +311,8 @@ class XMLModuleStore(ModuleStoreBase): super(XMLModuleStore, self).__init__(**kwargs) self.data_dir = path(data_dir) - self.modules = defaultdict(dict) # course_id -> dict(location -> XModuleDescriptor) - self.courses = {} # course_dir -> XModuleDescriptor for the course + self.modules = defaultdict(dict) # course_id -> dict(location -> XBlock) + self.courses = {} # course_dir -> XBlock for the course self.errored_courses = {} # course_dir -> errorlog, for dirs that failed to load self.load_error_modules = load_error_modules @@ -534,7 +533,7 @@ class XMLModuleStore(ModuleStoreBase): def get_instance(self, course_id, location, depth=0): """ - Returns an XModuleDescriptor instance for the item at + Returns an XBlock instance for the item at location, with the policy for course_id. (In case two xml dirs have different content at the same location, return the one for this course_id.) @@ -562,7 +561,7 @@ class XMLModuleStore(ModuleStoreBase): def get_item(self, location, depth=0): """ - Returns an XModuleDescriptor instance for the item at location. + Returns an XBlock instance for the item at location. If any segment of the location is None except revision, raises xmodule.modulestore.exceptions.InsufficientSpecificationError From cdb80bb8eda37776aa9423462fc368df6fde49e1 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Mon, 30 Sep 2013 12:03:38 -0400 Subject: [PATCH 40/54] Fix the new tests to use the new way to make XBlocks. --- common/lib/xmodule/xmodule/tests/xml/__init__.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/xml/__init__.py b/common/lib/xmodule/xmodule/tests/xml/__init__.py index 32cefadca7..93aa582cde 100644 --- a/common/lib/xmodule/xmodule/tests/xml/__init__.py +++ b/common/lib/xmodule/xmodule/tests/xml/__init__.py @@ -4,8 +4,9 @@ Xml parsing tests for XModules import pprint from mock import Mock -from xmodule.x_module import XMLParsingSystem, XModuleDescriptor +from xmodule.x_module import XMLParsingSystem from xmodule.mako_module import MakoDescriptorSystem +from xmodule.modulestore.xml import create_block_from_xml class InMemorySystem(XMLParsingSystem, MakoDescriptorSystem): # pylint: disable=abstract-method @@ -28,8 +29,8 @@ class InMemorySystem(XMLParsingSystem, MakoDescriptorSystem): # pylint: disable ) def process_xml(self, xml): # pylint: disable=method-hidden - """Parse `xml` as an XModuleDescriptor, and add it to `self._descriptors`""" - descriptor = XModuleDescriptor.load_from_xml(xml, self, self.org, self.course, self.default_class) + """Parse `xml` as an XBlock, and add it to `self._descriptors`""" + descriptor = create_block_from_xml(xml, self, self.org, self.course, self.default_class) self._descriptors[descriptor.location.url()] = descriptor return descriptor @@ -39,8 +40,8 @@ class InMemorySystem(XMLParsingSystem, MakoDescriptorSystem): # pylint: disable class XModuleXmlImportTest(object): - """Base class for tests that use basic `XModuleDescriptor.load_from_xml` xml parsing""" + """Base class for tests that use basic XML parsing""" def process_xml(self, xml_import_data): - """Use the `xml_import_data` to import an :class:`XModuleDescriptor` from xml""" + """Use the `xml_import_data` to import an :class:`XBlock` from XML.""" system = InMemorySystem(xml_import_data) return system.process_xml(xml_import_data.xml_string) From 0ed1ee917ed14fb7678df1cef1a9833d285077b8 Mon Sep 17 00:00:00 2001 From: Nick Parlante Date: Thu, 26 Sep 2013 16:34:43 -0700 Subject: [PATCH 41/54] Create edit_course_tabs management command Just works on the tabs list in mongo, nothing deeper than that. Added implementation functions in tabs.py, providing a first step for some future GUI editor. --- CHANGELOG.rst | 3 + .../management/commands/edit_course_tabs.py | 88 +++++++++++++++++++ .../contentstore/tests/test_tabs.py | 41 +++++++++ cms/djangoapps/contentstore/views/tabs.py | 44 +++++++++- 4 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 cms/djangoapps/contentstore/management/commands/edit_course_tabs.py create mode 100644 cms/djangoapps/contentstore/tests/test_tabs.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 443e787482..dbe74f3059 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,9 @@ the top. Include a label indicating the component affected. LMS: Add split testing functionality for internal use. +CMS: Add edit_course_tabs management command, providing a primitive +editing capability for a course's list of tabs. + Studio and LMS: add ability to lock assets (cannot be viewed unless registered for class). LMS: Improved accessibility of parts of forum navigation sidebar. diff --git a/cms/djangoapps/contentstore/management/commands/edit_course_tabs.py b/cms/djangoapps/contentstore/management/commands/edit_course_tabs.py new file mode 100644 index 0000000000..d9c73e42fa --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/edit_course_tabs.py @@ -0,0 +1,88 @@ +### +### Script for editing the course's tabs +### + +# +# Run it this way: +# ./manage.py cms --settings dev edit_course_tabs --course Stanford/CS99/2013_spring +# Or via rake: +# rake django-admin[edit_course_tabs,cms,dev,"--course Stanford/CS99/2013_spring --delete 4"] +# +from optparse import make_option +from django.core.management.base import BaseCommand, CommandError +from .prompt import query_yes_no + +from courseware.courses import get_course_by_id + +from contentstore.views import tabs + + +def print_course(course): + "Prints out the course id and a numbered list of tabs." + print course.id + for index, item in enumerate(course.tabs): + print index + 1, '"' + item.get('type') + '"', '"' + item.get('name', '') + '"' + + +# course.tabs looks like this +# [{u'type': u'courseware'}, {u'type': u'course_info', u'name': u'Course Info'}, {u'type': u'textbooks'}, +# {u'type': u'discussion', u'name': u'Discussion'}, {u'type': u'wiki', u'name': u'Wiki'}, +# {u'type': u'progress', u'name': u'Progress'}] + + +class Command(BaseCommand): + help = """See and edit a course's tabs list. +Only supports insertion and deletion. Move and +rename etc. can be done with a delete +followed by an insert. +The tabs are numbered starting with 1. +Tabs 1 and 2 cannot be changed, and tabs of type +static_tab cannot be edited (use Studio for those). +""" + # Making these option objects separately, so can refer to their .help below + course_option = make_option('--course', + action='store', + dest='course', + default=False, + help='--course required, e.g. Stanford/CS99/2013_spring') + delete_option = make_option('--delete', + action='store_true', + dest='delete', + default=False, + help='--delete ') + insert_option = make_option('--insert', + action='store_true', + dest='insert', + default=False, + help='--insert , e.g. 2 "course_info" "Course Info"') + + option_list = BaseCommand.option_list + (course_option, delete_option, insert_option) + + def handle(self, *args, **options): + if not options['course']: + raise CommandError(Command.course_option.help) + + course = get_course_by_id(options['course']) + + print 'Warning: this command directly edits the list of course tabs in mongo.' + print 'Tabs before any changes:' + print_course(course) + + try: + if options['delete']: + if len(args) != 1: + raise CommandError(Command.delete_option.help) + num = int(args[0]) + if query_yes_no('Deleting tab {0} Confirm?'.format(num), default='no'): + tabs.primitive_delete(course, num - 1) # -1 for 0-based indexing + elif options['insert']: + if len(args) != 3: + raise CommandError(Command.insert_option.help) + num = int(args[0]) + tab_type = args[1] + name = args[2] + if query_yes_no('Inserting tab {0} "{1}" "{2}" Confirm?'.format(num, tab_type, name), default='no'): + tabs.primitive_insert(course, num - 1, tab_type, name) # -1 as above + except ValueError as e: + # Cute: translate to CommandError so the CLI error prints nicely. + raise CommandError(e) diff --git a/cms/djangoapps/contentstore/tests/test_tabs.py b/cms/djangoapps/contentstore/tests/test_tabs.py new file mode 100644 index 0000000000..f1cf8ddfa5 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_tabs.py @@ -0,0 +1,41 @@ +""" Tests for tab functions (just primitive). """ + +from contentstore.views import tabs +from django.test import TestCase +from xmodule.modulestore.tests.factories import CourseFactory +from courseware.courses import get_course_by_id + + +class PrimitiveTabEdit(TestCase): + """Tests for the primitive tab edit data manipulations""" + + def test_delete(self): + """Test primitive tab deletion.""" + course = CourseFactory.create(org='edX', course='999') + with self.assertRaises(ValueError): + tabs.primitive_delete(course, 0) + with self.assertRaises(ValueError): + tabs.primitive_delete(course, 1) + with self.assertRaises(IndexError): + tabs.primitive_delete(course, 6) + tabs.primitive_delete(course, 2) + self.assertFalse({u'type': u'textbooks'} in course.tabs) + # Check that discussion has shifted down + self.assertEquals(course.tabs[2], {'type': 'discussion', 'name': 'Discussion'}) + + def test_insert(self): + """Test primitive tab insertion.""" + course = CourseFactory.create(org='edX', course='999') + tabs.primitive_insert(course, 2, 'atype', 'aname') + self.assertEquals(course.tabs[2], {'type': 'atype', 'name': 'aname'}) + with self.assertRaises(ValueError): + tabs.primitive_insert(course, 0, 'atype', 'aname') + with self.assertRaises(ValueError): + tabs.primitive_insert(course, 3, 'static_tab', 'aname') + + def test_save(self): + """Test course saving.""" + course = CourseFactory.create(org='edX', course='999') + tabs.primitive_insert(course, 3, 'atype', 'aname') + course2 = get_course_by_id(course.id) + self.assertEquals(course2.tabs[3], {'type': 'atype', 'name': 'aname'}) diff --git a/cms/djangoapps/contentstore/views/tabs.py b/cms/djangoapps/contentstore/views/tabs.py index f38685edfc..f897fa1378 100644 --- a/cms/djangoapps/contentstore/views/tabs.py +++ b/cms/djangoapps/contentstore/views/tabs.py @@ -9,13 +9,14 @@ from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied from django_future.csrf import ensure_csrf_cookie from mitxmako.shortcuts import render_to_response - from xmodule.modulestore import Location from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.django import modulestore + from ..utils import get_course_for_item, get_modulestore from .access import get_location_and_verify_access + __all__ = ['edit_tabs', 'reorder_static_tabs', 'static_pages'] @@ -84,6 +85,7 @@ def reorder_static_tabs(request): # MongoKeyValueStore before we update the mongo datastore. course.save() modulestore('direct').update_metadata(course.location, own_metadata(course)) + # TODO: above two lines are used for the primitive-save case. Maybe factor them out? return HttpResponse() @@ -136,3 +138,43 @@ def static_pages(request, org, course, coursename): return render_to_response('static-pages.html', { 'context_course': course, }) + + +# "primitive" tab edit functions driven by the command line. +# These should be replaced/deleted by a more capable GUI someday. +# Note that the command line UI identifies the tabs with 1-based +# indexing, but this implementation code is standard 0-based. + +def validate_args(num, tab_type): + "Throws for the disallowed cases." + if num <= 1: + raise ValueError('Tabs 1 and 2 cannot be edited') + if tab_type == 'static_tab': + raise ValueError('Tabs of type static_tab cannot be edited here (use Studio)') + + +def primitive_delete(course, num): + "Deletes the given tab number (0 based)." + tabs = course.tabs + validate_args(num, tabs[num].get('type', '')) + del tabs[num] + # Note for future implementations: if you delete a static_tab, then Chris Dodge + # points out that there's other stuff to delete beyond this element. + # This code happens to not delete static_tab so it doesn't come up. + primitive_save(course) + + +def primitive_insert(course, num, tab_type, name): + "Inserts a new tab at the given number (0 based)." + validate_args(num, tab_type) + new_tab = {u'type': unicode(tab_type), u'name': unicode(name)} + tabs = course.tabs + tabs.insert(num, new_tab) + primitive_save(course) + + +def primitive_save(course): + "Saves the course back to modulestore." + # This code copied from reorder_static_tabs above + course.save() + modulestore('direct').update_metadata(course.location, own_metadata(course)) From 0bb11335b2e8242592ef33804b8a0ffbb1a89e4e Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 1 Oct 2013 13:53:59 -0400 Subject: [PATCH 42/54] We aren't ready to use XBlock.load_class, there are duplicate entry point names, and simply choosing the first one leads to unpredictability. --- common/lib/xmodule/xmodule/modulestore/xml.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index 74c375a545..da86279b68 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -17,7 +17,7 @@ from xmodule.error_module import ErrorDescriptor from xmodule.errortracker import make_error_tracker, exc_info_to_str from xmodule.course_module import CourseDescriptor from xmodule.mako_module import MakoDescriptorSystem -from xmodule.x_module import XMLParsingSystem +from xmodule.x_module import XMLParsingSystem, XModuleDescriptor from xmodule.html_module import HtmlDescriptor from xblock.core import XBlock @@ -238,7 +238,7 @@ def create_block_from_xml(xml_data, system, org=None, course=None, default_class """ node = etree.fromstring(xml_data) - raw_class = XBlock.load_class(node.tag, default_class) + raw_class = XModuleDescriptor.load_class(node.tag, default_class) xblock_class = system.mixologist.mix(raw_class) # leave next line commented out - useful for low-level debugging From b5fc5d89d3c97a5f90e86dc8d87f83c3d770c36f Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 1 Oct 2013 15:53:16 -0400 Subject: [PATCH 43/54] Fix run single test example --- docs/internal/testing.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/internal/testing.md b/docs/internal/testing.md index 5d6c75bddb..14cc2879b3 100644 --- a/docs/internal/testing.md +++ b/docs/internal/testing.md @@ -128,11 +128,11 @@ other module level tests include To run a single django test class: - rake test_lms[courseware.tests.tests:testViewAuth] + rake test_lms[lms/djangoapps/courseware/tests/tests.py:ActivateLoginTest] To run a single django test: - rake test_lms[courseware.tests.tests:TestViewAuth.test_dark_launch] + rake test_lms[lms/djangoapps/courseware/tests/tests.py:ActivateLoginTest.test_activate_login] To re-run all failing django tests from lms or cms: From 6e3ece92e37bdf2a56d3c62c125c583b63ec7c1f Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Thu, 26 Sep 2013 11:51:09 -0400 Subject: [PATCH 44/54] Refactor upload acceptance tests and add locked asset tests for registered user --- .../contentstore/features/upload.feature | 85 ++++++------- .../contentstore/features/upload.py | 117 ++++++++++-------- common/djangoapps/terrain/course_helpers.py | 11 +- common/test/data/uploads/.gitignore | 3 + common/test/data/uploads/test | 1 - 5 files changed, 108 insertions(+), 109 deletions(-) create mode 100644 common/test/data/uploads/.gitignore delete mode 100644 common/test/data/uploads/test diff --git a/cms/djangoapps/contentstore/features/upload.feature b/cms/djangoapps/contentstore/features/upload.feature index e01bcf8fed..2e73c11c0c 100644 --- a/cms/djangoapps/contentstore/features/upload.feature +++ b/cms/djangoapps/contentstore/features/upload.feature @@ -5,17 +5,16 @@ Feature: CMS.Upload Files # Uploading isn't working on safari with sauce labs @skip_safari Scenario: Users can upload files - Given I have opened a new course in Studio - And I go to the files and uploads page + Given I am at the files and upload page of a Studio course When I upload the file "test" Then I should see the file "test" was uploaded And The url for the file "test" is valid + # Uploading isn't working on safari with sauce labs @skip_safari Scenario: Users can upload multiple files - Given I have opened a new course in studio - And I go to the files and uploads page - When I upload the files "test","test2" + Given I am at the files and upload page of a Studio course + When I upload the files "test,test2" Then I should see the file "test" was uploaded And I should see the file "test2" was uploaded And The url for the file "test2" is valid @@ -24,8 +23,7 @@ Feature: CMS.Upload Files # Uploading isn't working on safari with sauce labs @skip_safari Scenario: Users can update files - Given I have opened a new course in studio - And I go to the files and uploads page + Given I am at the files and upload page of a Studio course When I upload the file "test" And I upload the file "test" Then I should see only one "test" @@ -33,8 +31,7 @@ Feature: CMS.Upload Files # Uploading isn't working on safari with sauce labs @skip_safari Scenario: Users can delete uploaded files - Given I have opened a new course in studio - And I go to the files and uploads page + Given I am at the files and upload page of a Studio course When I upload the file "test" And I delete the file "test" Then I should not see the file "test" was uploaded @@ -43,16 +40,14 @@ Feature: CMS.Upload Files # Uploading isn't working on safari with sauce labs @skip_safari Scenario: Users can download files - Given I have opened a new course in studio - And I go to the files and uploads page + Given I am at the files and upload page of a Studio course When I upload the file "test" Then I can download the correct "test" file # Uploading isn't working on safari with sauce labs @skip_safari Scenario: Users can download updated files - Given I have opened a new course in studio - And I go to the files and uploads page + Given I am at the files and upload page of a Studio course When I upload the file "test" And I modify "test" And I reload the page @@ -62,57 +57,59 @@ Feature: CMS.Upload Files # Uploading isn't working on safari with sauce labs @skip_safari Scenario: Users can lock assets through asset index - Given I have opened a new course in studio - And I go to the files and uploads page - When I upload the file "test" - And I lock "test" - Then "test" is locked + Given I am at the files and upload page of a Studio course + When I upload an asset + And I lock the asset + Then the asset is locked And I see a "saving" notification And I reload the page - Then "test" is locked + Then the asset is locked # Uploading isn't working on safari with sauce labs @skip_safari Scenario: Users can unlock assets through asset index - Given I have opened a course with a locked asset "test" - And I unlock "test" - Then "test" is unlocked + Given I have created a course with a locked asset + When I unlock the asset + Then the asset is unlocked And I see a "saving" notification And I reload the page - Then "test" is unlocked + Then the asset is unlocked # Uploading isn't working on safari with sauce labs - # TODO: work with Jay -# @skip_safari -# Scenario: Locked assets can't be viewed if logged in as unregistered user -# Given I have opened a course with a locked asset "locked.html" -# Then the asset "locked.html" can be clicked from the asset index -# And the user "bob" exists -# And "bob" logs in -# Then the asset "locked.html" is protected + @skip_safari + Scenario: Locked assets can't be viewed if logged in as an unregistered user + Given I have created a course with a locked asset + And the user "bob" exists + When "bob" logs in + Then the asset is protected + + # Uploading isn't working on safari with sauce labs + @skip_safari + Scenario: Locked assets can be viewed if logged in as a registered user + Given I have created a course with a locked asset + And the user "bob" exists + And the user "bob" is enrolled in the course + When "bob" logs in + Then the asset is viewable # Uploading isn't working on safari with sauce labs @skip_safari Scenario: Locked assets can't be viewed if logged out - Given I have opened a course with a locked asset "locked.html" - # Note that logging out doesn't really matter at the moment- - # the asset will be protected because the user sent to middleware is the anonymous user. - # Need to work with Jay. - And I log out - Then the asset "locked.html" is protected + Given I have created a course with a locked asset + When I log out + Then the asset is protected # Uploading isn't working on safari with sauce labs @skip_safari Scenario: Locked assets can be viewed with is_staff account - Given I have opened a course with a locked asset "locked.html" + Given I have created a course with a locked asset And the user "staff" exists as a course is_staff - And "staff" logs in - Then the asset "locked.html" can be clicked from the asset index + When "staff" logs in + Then the asset is viewable # Uploading isn't working on safari with sauce labs @skip_safari Scenario: Unlocked assets can be viewed by anyone - Given I have opened a course with a unlocked asset "unlocked.html" - Then the asset "unlocked.html" can be clicked from the asset index - And I log out - Then the asset "unlocked.html" is viewable + Given I have created a course with a unlocked asset + When I log out + Then the asset is viewable diff --git a/cms/djangoapps/contentstore/features/upload.py b/cms/djangoapps/contentstore/features/upload.py index b94ccd114a..25e33f7e5e 100644 --- a/cms/djangoapps/contentstore/features/upload.py +++ b/cms/djangoapps/contentstore/features/upload.py @@ -2,14 +2,17 @@ #pylint: disable=W0621 from lettuce import world, step +from lettuce.django import django_url from django.conf import settings import requests import string import random import os +from django.contrib.auth.models import User +from student.models import CourseEnrollment +from splinter.request_handler.status_code import HttpResponseError from nose.tools import assert_equal, assert_not_equal # pylint: disable=E0611 - TEST_ROOT = settings.COMMON_TEST_DATA_ROOT ASSET_NAMES_CSS = 'td.name-col > span.title > a.filename' @@ -26,7 +29,10 @@ def go_to_uploads(_step): def upload_file(_step, file_name): upload_css = 'a.upload-button' world.css_click(upload_css) - #uploading the file itself + + _write_test_file(file_name, "test file") + + # uploading the file itself path = os.path.join(TEST_ROOT, 'uploads/', file_name) world.browser.execute_script("$('input.file-input').css('display', 'block')") world.browser.attach_file('file', os.path.abspath(path)) @@ -34,19 +40,20 @@ def upload_file(_step, file_name): world.css_click(close_css) -@step(u'I upload the files (".*")$') +@step(u'I upload the files "([^"]*)"$') def upload_files(_step, files_string): - # Turn files_string to a list of file names + # files_string should be comma separated with no spaces. files = files_string.split(",") - files = map(lambda x: string.strip(x, ' "\''), files) - upload_css = 'a.upload-button' world.css_click(upload_css) - #uploading the files - for f in files: - path = os.path.join(TEST_ROOT, 'uploads/', f) + + # uploading the files + for filename in files: + _write_test_file(filename, "test file") + path = os.path.join(TEST_ROOT, 'uploads/', filename) world.browser.execute_script("$('input.file-input').css('display', 'block')") world.browser.attach_file('file', os.path.abspath(path)) + close_css = 'a.close-button' world.css_click(close_css) @@ -104,13 +111,13 @@ def check_download(_step, file_name): r = get_file(file_name) downloaded_text = r.text assert cur_text == downloaded_text - #resetting the file back to its original state + # resetting the file back to its original state _write_test_file(file_name, "This is an arbitrary file for testing uploads") def _write_test_file(file_name, text): path = os.path.join(TEST_ROOT, 'uploads/', file_name) - #resetting the file back to its original state + # resetting the file back to its original state with open(os.path.abspath(path), 'w') as cur_file: cur_file.write(text) @@ -121,68 +128,68 @@ def modify_upload(_step, file_name): _write_test_file(file_name, new_text) -@step(u'I (lock|unlock) "([^"]*)"$') -def lock_unlock_file(_step, _lock_state, file_name): - index = get_index(file_name) - assert index != -1 +@step(u'I upload an asset$') +def upload_an_asset(step): + step.given('I upload the file "asset.html"') + + +@step(u'I (lock|unlock) the asset$') +def lock_unlock_file(_step, _lock_state): + index = get_index('asset.html') + assert index != -1, 'Expected to find an asset but could not.' + + # Warning: this is a misnomer, it really only toggles the + # lock state. TODO: fix it. lock_css = "input.lock-checkbox" world.css_find(lock_css)[index].click() -@step(u'Then "([^"]*)" is (locked|unlocked)$') -def verify_lock_unlock_file(_step, file_name, lock_state): - index = get_index(file_name) - assert index != -1 +@step(u'the user "([^"]*)" is enrolled in the course$') +def user_foo_is_enrolled_in_the_course(step, name): + world.create_user(name, 'test') + user = User.objects.get(username=name) + + course_id = world.scenario_dict['COURSE'].location.course_id + CourseEnrollment.enroll(user, course_id) + + +@step(u'Then the asset is (locked|unlocked)$') +def verify_lock_unlock_file(_step, lock_state): + index = get_index('asset.html') + assert index != -1, 'Expected to find an asset but could not.' lock_css = "input.lock-checkbox" checked = world.css_find(lock_css)[index]._element.get_attribute('checked') assert_equal(lock_state == "locked", bool(checked)) -@step(u'I have opened a course with a (locked|unlocked) asset "([^"]*)"$') -def open_course_with_locked(step, lock_state, file_name): +@step(u'I am at the files and upload page of a Studio course') +def at_upload_page(step): step.given('I have opened a new course in studio') step.given('I go to the files and uploads page') - _write_test_file(file_name, "test file") - step.given('I upload the file "' + file_name + '"') + + +@step(u'I have created a course with a (locked|unlocked) asset$') +def open_course_with_locked(step, lock_state): + step.given('I am at the files and upload page of a Studio course') + step.given('I upload the file "asset.html"') + if lock_state == "locked": - step.given('I lock "' + file_name + '"') + step.given('I lock the asset') step.given('I reload the page') -@step(u'Then the asset "([^"]*)" is (viewable|protected)$') -def view_asset(_step, file_name, status): - url = '/c4x/MITx/999/asset/' + file_name +@step(u'Then the asset is (viewable|protected)$') +def view_asset(_step, status): + url = django_url('/c4x/MITx/999/asset/asset.html') if status == 'viewable': - world.visit(url) - _verify_body_text() + expected_text = 'test file' else: - error_thrown = False - try: - world.visit(url) - except Exception as e: - assert e.status_code == 403 - error_thrown = True - assert error_thrown + expected_text = 'Unauthorized' - -@step(u'Then the asset "([^"]*)" can be clicked from the asset index$') -def click_asset_from_index(step, file_name): - # This is not ideal, but I'm having trouble with the middleware not having - # the same user in the request when I hit the URL directly. - course_link_css = 'a.course-link' - world.css_click(course_link_css) - step.given("I go to the files and uploads page") - index = get_index(file_name) - assert index != -1 - world.css_click('a.filename', index=index) - _verify_body_text() - - -def _verify_body_text(): - def verify_text(driver): - return world.css_text('body') == 'test file' - - world.wait_for(verify_text) + # Note that world.visit would trigger a 403 error instead of displaying "Unauthorized" + # Instead, we can drop back into the selenium driver get command. + world.browser.driver.get(url) + assert_equal(world.css_text('body'),expected_text) @step('I see a confirmation that the file was deleted$') diff --git a/common/djangoapps/terrain/course_helpers.py b/common/djangoapps/terrain/course_helpers.py index fc01d25d66..22222d30a4 100644 --- a/common/djangoapps/terrain/course_helpers.py +++ b/common/djangoapps/terrain/course_helpers.py @@ -2,17 +2,10 @@ # pylint: disable=W0621 from lettuce import world -from .factories import * -from django.conf import settings -from django.http import HttpRequest from django.contrib.auth.models import User -from django.contrib.auth import authenticate, login -from django.contrib.auth.middleware import AuthenticationMiddleware -from django.contrib.sessions.middleware import SessionMiddleware from student.models import CourseEnrollment from xmodule.modulestore.django import editable_modulestore from xmodule.contentstore.django import contentstore -from urllib import quote_plus @world.absorb @@ -22,7 +15,7 @@ def create_user(uname, password): if len(User.objects.filter(username=uname)) > 0: return - portal_user = UserFactory.build(username=uname, email=uname + '@edx.org') + portal_user = world.UserFactory.build(username=uname, email=uname + '@edx.org') portal_user.set_password(password) portal_user.save() @@ -30,7 +23,7 @@ def create_user(uname, password): registration.register(portal_user) registration.activate() - user_profile = world.UserProfileFactory(user=portal_user) + world.UserProfileFactory(user=portal_user) @world.absorb diff --git a/common/test/data/uploads/.gitignore b/common/test/data/uploads/.gitignore new file mode 100644 index 0000000000..a85ef7b7f3 --- /dev/null +++ b/common/test/data/uploads/.gitignore @@ -0,0 +1,3 @@ +test +test2 +asset.html diff --git a/common/test/data/uploads/test b/common/test/data/uploads/test deleted file mode 100644 index 588e9fb125..0000000000 --- a/common/test/data/uploads/test +++ /dev/null @@ -1 +0,0 @@ -This is an arbitrary file for testing uploads \ No newline at end of file From dfe45d5bd6bbe2ff0b8393d4d8dce7cf4a4f0375 Mon Sep 17 00:00:00 2001 From: jmclaus Date: Fri, 27 Sep 2013 19:21:43 +0200 Subject: [PATCH 45/54] First pass at ARIA. Video player buttons announce themselves as button when used with a screen reader --- common/lib/xmodule/xmodule/js/src/video/04_video_control.js | 4 ++++ .../xmodule/xmodule/js/src/video/05_video_quality_control.js | 3 +++ .../xmodule/xmodule/js/src/video/07_video_volume_control.js | 3 +++ .../xmodule/xmodule/js/src/video/08_video_speed_control.js | 3 +++ common/lib/xmodule/xmodule/js/src/video/09_video_caption.js | 3 +++ 5 files changed, 16 insertions(+) diff --git a/common/lib/xmodule/xmodule/js/src/video/04_video_control.js b/common/lib/xmodule/xmodule/js/src/video/04_video_control.js index 5cdb5c7536..1e1450dc54 100644 --- a/common/lib/xmodule/xmodule/js/src/video/04_video_control.js +++ b/common/lib/xmodule/xmodule/js/src/video/04_video_control.js @@ -63,6 +63,10 @@ function () { state.videoControl.el.addClass('html5'); state.controlHideTimeout = setTimeout(state.videoControl.hideControls, state.videoControl.fadeOutTimeout); } + // ARIA + // Let screen readers know these anchors behaves like a button. + state.videoControl.playPauseEl.attr('role', gettext('button')); + state.videoControl.fullScreenEl.attr('role', gettext('button')); } // function _bindHandlers(state) diff --git a/common/lib/xmodule/xmodule/js/src/video/05_video_quality_control.js b/common/lib/xmodule/xmodule/js/src/video/05_video_quality_control.js index cff103468d..e996ce5213 100644 --- a/common/lib/xmodule/xmodule/js/src/video/05_video_quality_control.js +++ b/common/lib/xmodule/xmodule/js/src/video/05_video_quality_control.js @@ -43,6 +43,9 @@ function () { state.videoQualityControl.el.show(); state.videoQualityControl.quality = null; + // ARIA + // Let screen readers know this anchor behaves like a button. + state.videoQualityControl.el.attr('role', gettext('button')); } // function _bindHandlers(state) diff --git a/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js b/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js index d8398ab530..3f31ddc75e 100644 --- a/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js +++ b/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js @@ -62,6 +62,9 @@ function () { }); state.videoVolumeControl.el.toggleClass('muted', state.videoVolumeControl.currentVolume === 0); + // ARIA + // Let screen readers know this anchor behaves like a button. + state.videoVolumeControl.buttonEl.attr('role', gettext('button')); } /** 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 67f62edf95..4b9f532e43 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 @@ -79,6 +79,9 @@ function () { }); state.videoSpeedControl.setSpeed(state.speed); + // ARIA + // Let screen readers know this anchor behaves like a button. + state.videoSpeedControl.el.children('a').attr('role', gettext('button')); } /** 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 825ed7b935..57bb2a38d1 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 @@ -105,6 +105,9 @@ function () { this.videoCaption.hideCaptions(true); this.videoCaption.hideSubtitlesEl.hide(); } + // ARIA + // Let screen readers know this anchor behaves like a button. + this.videoCaption.hideSubtitlesEl.attr('role', gettext('button')); } // function bindHandlers() From ce976a80c81099eae898b66c94f037d2f520e7b0 Mon Sep 17 00:00:00 2001 From: jmclaus Date: Sat, 28 Sep 2013 02:04:36 +0200 Subject: [PATCH 46/54] All buttons have an ARIA role, name , and state --- .../xmodule/js/src/video/04_video_control.js | 13 ++++++++++++- .../js/src/video/05_video_quality_control.js | 11 ++++++++++- .../xmodule/js/src/video/07_video_volume_control.js | 11 ++++++++++- .../xmodule/js/src/video/08_video_speed_control.js | 11 ++++++++++- .../xmodule/js/src/video/09_video_caption.js | 13 +++++++++++-- lms/templates/video.html | 2 +- 6 files changed, 54 insertions(+), 7 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/video/04_video_control.js b/common/lib/xmodule/xmodule/js/src/video/04_video_control.js index 1e1450dc54..f31ed1caee 100644 --- a/common/lib/xmodule/xmodule/js/src/video/04_video_control.js +++ b/common/lib/xmodule/xmodule/js/src/video/04_video_control.js @@ -63,10 +63,21 @@ function () { state.videoControl.el.addClass('html5'); state.controlHideTimeout = setTimeout(state.videoControl.hideControls, state.videoControl.fadeOutTimeout); } + // ARIA - // Let screen readers know these anchors behaves like a button. + // Let screen readers know that: + + // these anchors behaves like buttons state.videoControl.playPauseEl.attr('role', gettext('button')); state.videoControl.fullScreenEl.attr('role', gettext('button')); + + // what their names are: (title attribute are set in video.html template): + // Play, Pause + // Fill browser + + // what their states are: + state.videoControl.playPauseEl.attr('aria-disabled', 'false'); + state.videoControl.fullScreenEl.attr('aria-disabled', 'false'); } // function _bindHandlers(state) diff --git a/common/lib/xmodule/xmodule/js/src/video/05_video_quality_control.js b/common/lib/xmodule/xmodule/js/src/video/05_video_quality_control.js index e996ce5213..5788cd6291 100644 --- a/common/lib/xmodule/xmodule/js/src/video/05_video_quality_control.js +++ b/common/lib/xmodule/xmodule/js/src/video/05_video_quality_control.js @@ -43,9 +43,18 @@ function () { state.videoQualityControl.el.show(); state.videoQualityControl.quality = null; + // ARIA - // Let screen readers know this anchor behaves like a button. + // Let screen readers know that: + + // this anchor behaves like a button state.videoQualityControl.el.attr('role', gettext('button')); + + // what its name is: (title attribute is set in video.html template): + // HD + + // what its state is: + state.videoQualityControl.el.attr('aria-disabled', 'false'); } // function _bindHandlers(state) diff --git a/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js b/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js index 3f31ddc75e..9eda8eade7 100644 --- a/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js +++ b/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js @@ -62,9 +62,18 @@ function () { }); state.videoVolumeControl.el.toggleClass('muted', state.videoVolumeControl.currentVolume === 0); + // ARIA - // Let screen readers know this anchor behaves like a button. + // Let screen readers know that: + + // this anchor behaves like a button state.videoVolumeControl.buttonEl.attr('role', gettext('button')); + + // what its name is: (title attribute is set in video.html template): + state.videoVolumeControl.buttonEl.attr('aria-label', gettext('Volume')); + + // what its state is: + state.videoVolumeControl.buttonEl.attr('aria-disabled', 'false'); } /** 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 4b9f532e43..0ec9a5a96f 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 @@ -79,9 +79,18 @@ function () { }); state.videoSpeedControl.setSpeed(state.speed); + // ARIA - // Let screen readers know this anchor behaves like a button. + // Let screen readers know that: + + // this anchor behaves like a button state.videoSpeedControl.el.children('a').attr('role', gettext('button')); + + // what its name is: (title attribute is set in video.html template): + state.videoSpeedControl.el.children('a').attr('aria-label', 'Speeds'); + + // what its state is: + state.videoSpeedControl.el.children('a').attr('aria-disabled', '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 57bb2a38d1..d606245420 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 @@ -105,9 +105,18 @@ function () { this.videoCaption.hideCaptions(true); this.videoCaption.hideSubtitlesEl.hide(); } + // ARIA - // Let screen readers know this anchor behaves like a button. - this.videoCaption.hideSubtitlesEl.attr('role', gettext('button')); + // Let screen readers know that: + + // this anchor behaves like a button + this.videoCaption.hideSubtitlesEl.attr('role', gettext('button')); + // what its name is: + // what its name is: (title attribute is set in video.html template): + // Speeds + + // what its state is: + this.videoCaption.hideSubtitlesEl.attr('aria-disabled', 'false'); } // function bindHandlers() diff --git a/lms/templates/video.html b/lms/templates/video.html index 3d0b9bd936..1974a08e6e 100644 --- a/lms/templates/video.html +++ b/lms/templates/video.html @@ -66,7 +66,7 @@ ${_('Fill browser')} ${_('HD')} - ${_('Captions')} + ${_('Turn off captions')} From a9b2f2dea0e80d48592e362a22250b45730c9b64 Mon Sep 17 00:00:00 2001 From: jmclaus Date: Sun, 29 Sep 2013 01:50:34 +0200 Subject: [PATCH 47/54] Volume slider announces its name and value to screen readers --- .../xmodule/xmodule/css/video/display.scss | 1 + .../xmodule/js/src/video/04_video_control.js | 3 ++ .../js/src/video/07_video_volume_control.js | 50 +++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/common/lib/xmodule/xmodule/css/video/display.scss b/common/lib/xmodule/xmodule/css/video/display.scss index c087d18098..a0b514ab0f 100644 --- a/common/lib/xmodule/xmodule/css/video/display.scss +++ b/common/lib/xmodule/xmodule/css/video/display.scss @@ -109,6 +109,7 @@ div.video { -webkit-transition: -webkit-transform 0.7s ease-in-out; -moz-transition: -moz-transform 0.7s ease-in-out; -ms-transition: -ms-transform 0.7s ease-in-out; + tabindex: -1; transition: transform 0.7s ease-in-out; @include transform(scaleY(0.5) translate3d(0, 50%, 0)); diff --git a/common/lib/xmodule/xmodule/js/src/video/04_video_control.js b/common/lib/xmodule/xmodule/js/src/video/04_video_control.js index f31ed1caee..5272a3fb64 100644 --- a/common/lib/xmodule/xmodule/js/src/video/04_video_control.js +++ b/common/lib/xmodule/xmodule/js/src/video/04_video_control.js @@ -70,10 +70,13 @@ function () { // these anchors behaves like buttons state.videoControl.playPauseEl.attr('role', gettext('button')); state.videoControl.fullScreenEl.attr('role', gettext('button')); + // and this one as a slider + state.videoControl.sliderEl.find('.ui-slider-handle').attr('role', gettext('slider')); // what their names are: (title attribute are set in video.html template): // Play, Pause // Fill browser + state.videoControl.sliderEl.find('.ui-slider-handle').attr('title', gettext('video slider')); // what their states are: state.videoControl.playPauseEl.attr('aria-disabled', 'false'); diff --git a/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js b/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js index 9eda8eade7..d7116f24dc 100644 --- a/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js +++ b/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js @@ -74,6 +74,18 @@ function () { // what its state is: state.videoVolumeControl.buttonEl.attr('aria-disabled', 'false'); + + // Volume slider + state.videoVolumeControl.volumeSliderHandleEl = state.videoVolumeControl.volumeSliderEl.find('.ui-slider-handle'); + state.videoVolumeControl.volumeSliderHandleEl.attr({ + 'role': gettext('slider'), + 'title': 'volume', + 'aria-disabled': 'false', + 'aria-valuetext': getVolumeDescription(state.videoVolumeControl.slider.slider('option', 'value')), + 'aria-valuenow': state.videoVolumeControl.slider.slider('option', 'value'), + 'aria-valuemin': state.videoVolumeControl.slider.slider('option', 'min'), + 'aria-valuemax': state.videoVolumeControl.slider.slider('option', 'max') + }); } /** @@ -159,6 +171,9 @@ function () { }); this.trigger('videoPlayer.onVolumeChange', ui.value); + // ARIA + this.videoVolumeControl.volumeSliderHandleEl.attr('aria-valuenow', ui.value); + this.videoVolumeControl.volumeSliderHandleEl.attr('aria-valuetext', getVolumeDescription(ui.value)); } function toggleMute(event) { @@ -167,8 +182,43 @@ function () { if (this.videoVolumeControl.currentVolume > 0) { this.videoVolumeControl.previousVolume = this.videoVolumeControl.currentVolume; this.videoVolumeControl.slider.slider('option', 'value', 0); + // ARIA + state.videoVolumeControl.volumeSliderHandleEl.attr({ + 'aria-valuetext': getVolumeDescription(0), + 'aria-valuenow': 0 + }); } else { this.videoVolumeControl.slider.slider('option', 'value', this.videoVolumeControl.previousVolume); + // ARIA + state.videoVolumeControl.volumeSliderHandleEl.attr({ + 'aria-valuetext': getVolumeDescription(this.videoVolumeControl.previousVolume), + 'aria-valuenow': this.videoVolumeControl.previousVolume + }); + } + } + + // ARIA + function getVolumeDescription(vol) { + if (vol === 0) { + return 'silent'; + } + else if (vol <= 20) { + return 'very low'; + } + else if (vol <= 40) { + return 'low'; + } + else if (vol <= 60) { + return 'average'; + } + else if (vol <= 80) { + return 'loud'; + } + else if (vol <= 99) { + return 'very loud'; + } + else { + return 'maximum'; } } From e5895adb2b7e11de8b4ae98b4a9175eb4d7b1a3c Mon Sep 17 00:00:00 2001 From: jmclaus Date: Mon, 30 Sep 2013 14:16:26 +0200 Subject: [PATCH 48/54] Progress slider announces its nabe and value to screen readers --- .../js/src/video/06_video_progress_slider.js | 68 +++++++++++++++++++ lms/templates/video.html | 2 +- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/js/src/video/06_video_progress_slider.js b/common/lib/xmodule/xmodule/js/src/video/06_video_progress_slider.js index b45494ca34..7741f6eadb 100644 --- a/common/lib/xmodule/xmodule/js/src/video/06_video_progress_slider.js +++ b/common/lib/xmodule/xmodule/js/src/video/06_video_progress_slider.js @@ -54,6 +54,19 @@ function () { function _buildHandle(state) { state.videoProgressSlider.handle = state.videoProgressSlider.el.find('.ui-slider-handle'); + + // ARIA + // Let screen readers know that this anchor behaves like a slider, is + // named 'video position' and give its state + state.videoProgressSlider.handle.attr({ + 'role': gettext('slider'), + 'title': 'video position', + 'aria-disabled': 'false', + 'aria-valuetext': getTimeDescription(state.videoProgressSlider.slider.slider('option', 'value')) + //'aria-valuenow': state.videoProgressSlider.slider.slider('option', 'value'), + //'aria-valuemin': state.videoProgressSlider.slider.slider('option', 'min'), + //'aria-valuemax': state.videoProgressSlider.slider.slider('option', 'max') + }); } // *************************************************************** @@ -74,6 +87,10 @@ function () { this.videoProgressSlider.frozen = true; this.trigger('videoPlayer.onSlideSeek', {'type': 'onSlideSeek', 'time': ui.value}); + + // ARIA + this.videoProgressSlider.handle.attr('aria-valuetext', + getTimeDescription(this.videoPlayer.currentTime)); } function onStop(event, ui) { @@ -83,6 +100,10 @@ function () { this.trigger('videoPlayer.onSlideSeek', {'type': 'onSlideSeek', 'time': ui.value}); + // ARIA + this.videoProgressSlider.handle.attr('aria-valuetext', + getTimeDescription(this.videoPlayer.currentTime)); + setTimeout(function() { _this.videoProgressSlider.frozen = false; }, 200); @@ -99,6 +120,53 @@ function () { } } + function getTimeDescription(time) { + var seconds = Math.floor(time), + minutes = Math.floor(seconds / 60), + hours = Math.floor(minutes / 60), + hrStr, minStr, secStr; + seconds = seconds % 60; + minutes = minutes % 60; + + hrStr = hours.toString(10); + minStr = minutes.toString(10); + secStr = seconds.toString(10); + + if (hours) { + hrStr += (hours < 2 ? ' hour ' : ' hours '); + if (minutes) { + minStr += (minutes < 2 ? ' minute ' : ' minutes '); + } + else { + minStr += ' 0 minutes '; + } + if (seconds) { + secStr += (seconds < 2 ? ' second ' : ' seconds '); + } + else { + secStr += ' 0 seconds '; + } + return hrStr + minStr + secStr; + } + else if (minutes) { + minStr += (minutes < 2 ? ' minute ' : ' minutes '); + if (seconds) { + secStr += (seconds < 2 ? ' second ' : ' seconds '); + } + else { + secStr += ' 0 seconds '; + } + return minStr + secStr; + } + else if (seconds) { + secStr += (seconds < 2 ? ' second ' : ' seconds '); + return secStr; + } + else { + return '0 seconds'; + } + } + }); }(RequireJS.requirejs, RequireJS.require, RequireJS.define)); diff --git a/lms/templates/video.html b/lms/templates/video.html index 1974a08e6e..8ddaa68049 100644 --- a/lms/templates/video.html +++ b/lms/templates/video.html @@ -42,7 +42,7 @@
      -
      +
      From 9d418755a681c6e520d36a23b0bade63f9e921b1 Mon Sep 17 00:00:00 2001 From: jmclaus Date: Wed, 2 Oct 2013 16:54:48 +0200 Subject: [PATCH 54/54] Added changes made to video.html to fixtures. Addressed latest PR comments --- .../xmodule/xmodule/js/fixtures/video.html | 12 +++++------ .../xmodule/js/fixtures/video_all.html | 12 +++++------ .../js/fixtures/video_yt_multiple.html | 12 +++++------ .../js/src/video/06_video_progress_slider.js | 20 +++++++------------ .../js/src/video/07_video_volume_control.js | 20 +++++++------------ lms/templates/video.html | 4 ++-- 6 files changed, 34 insertions(+), 46 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/fixtures/video.html b/common/lib/xmodule/xmodule/js/fixtures/video.html index 410b5869f0..f607430ba0 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/video.html +++ b/common/lib/xmodule/xmodule/js/fixtures/video.html @@ -26,26 +26,26 @@
        -
      • +
      • 0:00 / 0:00
      diff --git a/common/lib/xmodule/xmodule/js/fixtures/video_all.html b/common/lib/xmodule/xmodule/js/fixtures/video_all.html index f155905282..57052bf65d 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/video_all.html +++ b/common/lib/xmodule/xmodule/js/fixtures/video_all.html @@ -29,26 +29,26 @@
        -
      • +
      • 0:00 / 0:00
      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 834de10406..c6b40cdf16 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/video_yt_multiple.html +++ b/common/lib/xmodule/xmodule/js/fixtures/video_yt_multiple.html @@ -26,26 +26,26 @@
        -
      • +
      • 0:00 / 0:00
      diff --git a/common/lib/xmodule/xmodule/js/src/video/06_video_progress_slider.js b/common/lib/xmodule/xmodule/js/src/video/06_video_progress_slider.js index 5750a7574e..18fa7ee3ad 100644 --- a/common/lib/xmodule/xmodule/js/src/video/06_video_progress_slider.js +++ b/common/lib/xmodule/xmodule/js/src/video/06_video_progress_slider.js @@ -138,35 +138,29 @@ function () { hrStr += (hours < 2 ? ' hour ' : ' hours '); if (minutes) { minStr += (minutes < 2 ? ' minute ' : ' minutes '); - } - else { + } else { minStr += ' 0 minutes '; } if (seconds) { secStr += (seconds < 2 ? ' second ' : ' seconds '); - } - else { + } else { secStr += ' 0 seconds '; } return hrStr + minStr + secStr; - } - else if (minutes) { + } else if (minutes) { minStr += (minutes < 2 ? ' minute ' : ' minutes '); if (seconds) { secStr += (seconds < 2 ? ' second ' : ' seconds '); - } - else { + } else { secStr += ' 0 seconds '; } return minStr + secStr; - } - else if (seconds) { + } else if (seconds) { secStr += (seconds < 2 ? ' second ' : ' seconds '); return secStr; } - else { - return '0 seconds'; - } + + return '0 seconds'; } }); diff --git a/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js b/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js index a9c7aef269..ea813f3912 100644 --- a/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js +++ b/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js @@ -216,25 +216,19 @@ function () { function getVolumeDescription(vol) { if (vol === 0) { return 'muted'; - } - else if (vol <= 20) { + } else if (vol <= 20) { return 'very low'; - } - else if (vol <= 40) { + } else if (vol <= 40) { return 'low'; - } - else if (vol <= 60) { + } else if (vol <= 60) { return 'average'; - } - else if (vol <= 80) { + } else if (vol <= 80) { return 'loud'; - } - else if (vol <= 99) { + } else if (vol <= 99) { return 'very loud'; } - else { - return 'maximum'; - } + + return 'maximum'; } }); diff --git a/lms/templates/video.html b/lms/templates/video.html index 89d376090c..caf0aaa06f 100644 --- a/lms/templates/video.html +++ b/lms/templates/video.html @@ -51,14 +51,14 @@